การประมวลผลข้อมูลขนาดใหญ่แบบกระจายด้วย Apache Spark
5.1 การประมวลผลระดับ Big Data กระจายเครื่องด้วย Apache Spark
เมื่อปริมาณข้อมูลมีขนาดใหญ่เกินกว่าจะประมวลผลในเซิร์ฟเวอร์เครื่องเดียวได้ Apache Spark จะทำหน้าที่กระจายภาระงานไปยังโหนดประมวลผลย่อยในระบบคลัสเตอร์
3D Isometric Distributed Apache Spark Cluster Computing Node Topology
1. สถาปัตยกรรม Driver, Cluster Manager, และ Executor (Shared-Nothing JVM)
ทฤษฎีและกลไกการทำงาน (How it works): Apache Spark ทำงานแบบกระจายศูนย์โดยใช้สถาปัตยกรรมแบบ **Shared-Nothing**: - **Driver Program**: ทำหน้าที่แปลรหัสโค้ดของผู้พัฒนาสร้างเป็นแผนผังขั้นตอนคิวรี (Logical/Physical Plans) และแบ่งงานออกเป็น Task ย่อย - **Cluster Manager (เช่น YARN, Kubernetes, Standalone)**: บริหารจัดการและจัดสรรเครื่องคอมพิวเตอร์ในเครือข่าย - **Executors**: โพรเซส Java Virtual Machine (JVM) ย่อยที่รันอยู่บน Workers คอยดึงคำสั่ง Task มาประมวลผลในแรมของตนเองโดยไม่แชร์หน่วยความจำร่วมกันข้ามเครื่อง
# Spark Session configurations in PySpark pointing to a cluster manager
from pyspark.sql import SparkSession
spark = SparkSession.builder .appName("PipeCraftBigDataJob") .master("spark://spark-master:7077") .config("spark.executor.instances", "4") .config("spark.executor.cores", "2") .getOrCreate()
Use Case ในชีวิตจริง (Real-world Scenario): บริษัทยักษ์ใหญ่ด้านโทรคมนาคมคำนวณสถิติยอดใช้เน็ตเวิร์กของซิมมือถือระดับพันล้านรายการต่อคืน ไดรเวอร์ของ Spark จะหั่นแบ่งภาระงานแยกออกเป็น 2,000 ส่วนและแจกจ่ายให้ Executor 50 เครื่องรันคู่ขนานกันจนสำเร็จใน 10 นาที
ข้อควรระวังและวิธีแก้ (Pitfalls & Mitigations): ปัญหา **Driver Out of Memory (OOM)** เมื่อวิศวกรเรียกคำสั่งรวบรวมข้อมูลดิบขนาดใหญ่เข้าสู่เครื่องแม่ด้วยคำสั่ง `.collect()` แก้ไขโดยห้ามใช้คำสั่งดึงข้อมูลยักษ์เข้า Driver และให้ใช้วิธีเซฟผลลัพธ์ผ่านคำสั่งเขียนไฟล์แบบกระจายโดยตรง `.write.parquet()`
2. รายละเอียดการจัดการแรมหน่วยความจำภายใน Spark Executor JVM
ทฤษฎีและกลไกการทำงาน (How it works): แรมหน่วยความจำ Heap Memory ของ JVM บนโหนด Executor ถูกจัดสรรเพื่อความเสถียร: - **Execution Memory**: ใช้คำนวณการจัดกลุ่มคีย์ การเรียงข้อมูล และการทำ Shuffling หากหน่วยความจำในส่วนนี้มีเนื้อที่ไม่พอ Spark จะยอมเขียนหน่วยความจำส่วนเกินนี้บันทึกลงสู่ฮาร์ดดิสก์แทน (Spill to Disk) - **Storage Memory**: เก็บรักษากลุ่มตารางข้อมูลแคช (`.cache()`) และตัวแปรกลุ่ม Broadcast - **User Memory**: เก็บโครงสร้างข้อมูลปกติหรือตัวแปรโปรแกรมของ Python
# Spark configuration settings defining memory allocations
spark.conf.set("spark.memory.fraction", "0.6") # Storage + Execution memory fraction of JVM heap
spark.conf.set("spark.memory.storageFraction", "0.5") # Dedicated storage within memory fraction
Use Case ในชีวิตจริง (Real-world Scenario): ในจ๊อบแปลงประวัติธนาคารที่ต้องการเข้าถึงตารางซ้ำๆ การทำแคชตารางเงินฝากบน Storage Memory ช่วยข้ามความหน่วงเวลาอ่านไฟล์ Parquet ซ้ำๆ ทำให้ขั้นตอนคำนวณขั้นถัดไปรันได้เร็วขึ้นเป็น 5 เท่า
ข้อควรระวังและวิธีแก้ (Pitfalls & Mitigations): ข้อผิดพลาด **Executor OOM (Out of Memory)** เนื่องจากแรมล้นพังถล่ม แก้ไขโดยวิเคราะห์โครงสร้างพาร์ทิชันผ่าน UI และปรับแต่งแรมจัดสรรเพิ่มโควต้า `spark.executor.memory` หรือแก้ไขขอบเขตพาร์ทิชันให้เล็กลง
3. การเปรียบเทียบ Shuffle Joins กับ Broadcast Hash Joins และวิธีลดภาระเน็ตเวิร์ก
ทฤษฎีและกลไกการทำงาน (How it works): การเชื่อมตารางข้อมูล (JOIN) ในคลัสเตอร์ Spark มี 2 ยุทธวิธีหลัก: - **Shuffle Hash Join**: ใช้เมื่อทั้งสองตารางมีขนาดใหญ่ Spark จะหั่นเรียงข้อมูลและยิงส่งสลับข้อมูลที่มีคีย์เดียวกันข้ามเครือข่ายเน็ตเวิร์กไปไว้ที่ Executor เครื่องเดียวกัน ส่งผลให้เกิดความหน่วงเครือข่ายสูง - **Broadcast Hash Join**: ใช้เมื่อมีตารางหนึ่งขนาดเล็กมาก (เช่น <10MB) ไดรเวอร์จะสำเนาก๊อปปี้ตารางเล็กส่งไปจดจำใน Storage memory ของทุก Executors เพื่อให้ Executors เชื่อมตารางใหญ่ขนานกันในแรมเครื่องตัวเองทันที ข้ามขั้นตอนยิงสลับข้อมูลข้ามเน็ตเวิร์ก
# PySpark code demonstrating Broadcast Hash Join
from pyspark.sql.functions import broadcast
large_transactions_df = spark.read.parquet("s3a://datalake/silver/transactions/")
small_dim_stores_df = spark.read.parquet("s3a://datalake/silver/stores/")
# Forces Broadcast Join on the smaller stores lookup table
enriched_sales_df = large_transactions_df.join(
broadcast(small_dim_stores_df),
on="store_id",
how="inner"
)
Use Case ในชีวิตจริง (Real-world Scenario): ตารางบันทึกการทำธุรกรรม 500GB เชื่อมเข้าตารางรหัสแคมเปญโฆษณา 5MB การเลือกบรูทฟอร์ซ Broadcast Join ช่วยหลีกเลี่ยงขั้นตอน Shuffle ย่นระยะเวลาการรันจ๊อบจากเดิม 1 ชั่วโมงเหลือเพียง 4 นาที
ข้อควรระวังและวิธีแก้ (Pitfalls & Mitigations): หากสั่ง Broadcast ตารางที่มีขนาดใหญ่เกินขีดจำกัดแรมสูงสุด (เช่น 2GB) จะทำให้ Driver และ Executors ล้มเหลว OOM ทันที แก้ไขโดยปิดใช้ Broadcast หากพบว่าตาราง Lookup ขยายขนาดเกินความปลอดภัย
4. ปัญหาความไม่สมมาตรของข้อมูล และสัดส่วนพาร์ทิชันที่เหมาะสม (Data Skew & Partition Sizing)
ทฤษฎีและกลไกการทำงาน (How it works): ปัญหา **Data Skew** เกิดขึ้นเมื่อข้อมูลส่วนใหญ่ไปกระจุกตัวอยู่ภายใต้คีย์พาร์ทิชันไม่กี่ตัว (เช่น ลูกค้าองค์กรยักษ์ใหญ่สร้างรายการซื้อมากกว่าลูกค้าทั่วไปล้านเท่า) ส่งผลให้ Executor ตัวที่ได้พาร์ทิชันตัวนั้นไปรันต้องแบกภาระงานหนักและล่าช้ากว่าเครื่องอื่น วิธีแก้ไขทำได้โดยการซอยสัดส่วนพาร์ทิชันเฉลี่ยผ่านสูตร `spark.sql.shuffle.partitions` หรือเทคนิคการเติมเกลือสุ่มตัวเลขต่อท้ายคีย์ (Salting) เพื่อช่วยกระจายข้อมูลให้ออกไปรันบนโหนดอื่นเฉลี่ยกัน
# Salting technique to distribute skewed partition keys evenly
from pyspark.sql.functions import col, concat, lit, rand
# Add a random integer salt suffix (0-3) to the skew join key column
skewed_df = skewed_df.withColumn("salt_key", concat(col("customer_id"), lit("_"), (rand() * 4).cast("int")))
# Duplicate lookup tables entries with mapping keys to align joins
Use Case ในชีวิตจริง (Real-world Scenario): ในท่อส่งข้อมูลการสั่งของยักษ์ใหญ่ที่ยอดคลิกซื้อ 80% กระจุกตัวอยู่ที่กรุงเทพฯ วิศวกรข้อมูลทำ Salting บนคีย์ `province` ช่วยลดเวลาคอยงานจ๊อบค้างคิวใน Executor เครื่องสุดท้ายลงได้เกินครึ่ง
ข้อควรระวังและวิธีแก้ (Pitfalls & Mitigations): จำนวนพาร์ทิชันที่มากเกินไปส่งผลลบ (Too many small partitions) ทำให้หน่วยความจำ Metadata บน Driver ทำงานหนักและรันล่วงเวลาช้า แก้ไขโดยให้ปรับจำนวนเฉลี่ยพาร์ทิชันให้ขนาดแต่ละพาร์ทิชันข้อมูลดิบในแรมอยู่ที่ประมาณ 100MB - 200MB
5. กลไกบันทึกส่วนต่างลงดิสก์ชั่วคราวและการเขียนไฟล์พาร์ทิชัน Parquet
ทฤษฎีและกลไกการทำงาน (How it works): เมื่อ Executor รันคำนวณข้อมูลขนาดใหญ่เกินกำลังแรมที่เก็บได้ Spark จะเปิดใช้กลไก **Spill-to-Disk** บันทึกบล็อกข้อมูลส่วนเกินชั่วคราวลงในไดเรกทอรี SSD ในรูปแบบเรียงลำดับดัชนี แม้จะรันต่อไปได้แต่ความเร็วจะดิ่งลงรุนแรงเนื่องจากการอ่านเขียนดิสก์ทำงานได้ช้ากว่าแรมมาก ในขั้นตอนสุดท้ายของการบันทึกข้อมูลออก คีย์การเขียนจะถูกกรองและนำลงบันทึกในรูปแบบไฟล์ Parquet แยกย่อยตามสเปคพาร์ทิชันเป้าหมายอย่างเป็นระเบียบ
# PySpark writing partitioned Parquet data lake format with optimized block sizes
df_enriched.write .partitionBy("year", "month") .option("maxRecordsPerFile", "1000000") .mode("overwrite") .parquet("s3a://pipecraft-datalake/gold/customer_reporting/")
Use Case ในชีวิตจริง (Real-world Scenario): งานรวบรวมประวัติการใช้งานแพลตฟอร์มรายเดือนจำนวน 1TB การเขียนพาร์ทิชันแยกตามปีและเดือน ช่วยให้ปลายทางระบบแดชบอร์ดสามารถคิวรี่เจาะจงข้อมูลเฉพาะเดือนปัจจุบันได้เร็วระดับเสี้ยววินาทีโดยไม่ต้องสแกนข้อมูล 1TB ทั้งหมด
ข้อควรระวังและวิธีแก้ (Pitfalls & Mitigations): การเกิดไฟล์พาร์ทิชันที่มีขนาดเล็กเกินความจำเป็น (Small File Problem) จากการแบ่งพาร์ทิชันย่อยเกินไปสะสมจนทำให้ระบบการสแกนปลายทางอืด แก้ไขโดยรันคำสั่งย่นขนาดก่อนเขียนปลายทางด้วยคำสั่ง `.coalesce(n)` หรือ `.repartition(n)`
🛠️ Weekend Sandbox Challenge: Build Broadcast Hash Join in PySpark
โจทย์ปฏิบัติการ: จงเขียนสคริปต์ PySpark เพื่อทำการเชื่อมตารางธุรกรรมขนาดใหญ่ (Transactions) เข้ากับตาราง Lookup ขนาดเล็ก โดยกำหนดคำสั่ง `broadcast` เพื่อลดจำนวนพาร์ทิชันสลับข้ามเครือข่ายเน็ตเวิร์ก
# local_broadcast_join.py
from pyspark.sql import SparkSession
from pyspark.sql.functions import broadcast
spark = SparkSession.builder.master("local[*]").appName("BroadcastDemo").getOrCreate()
# Create mock dataframes
large_df = spark.createDataFrame([(1, "prod_1", 100.0), (2, "prod_2", 250.0)], ["id", "product_id", "price"])
small_df = spark.createDataFrame([("prod_1", "Electronics"), ("prod_2", "Home")], ["product_id", "category"])
# Join utilizing broadcast for the smaller table
result = large_df.join(broadcast(small_df), "product_id")
result.show()
💼 Senior Technical Interview Q&A
Q1: เพราะเหตุใด Broadcast Hash Join จึงเร็วกว่า Shuffle Hash Join อย่างมหาศาล และมีข้อจำกัดที่ควรระมัดระวังอะไรบ้าง?
A1: Broadcast Hash Join จะส่งตารางข้อมูลขนาดเล็กไปเก็บไว้ในแรมหน่วยความจำของทุกโหนด Executor โดยตรง ทำให้ Worker แต่ละตัวสามารถเชื่อมตารางใหญ่ขนานกันในแรมของตัวเองได้ทันทีโดยข้ามขั้นตอนการส่งข้อมูลสลับข้ามเครือข่าย (Shuffle) ข้อจำกัดคือหากตารางเล็กที่ส่งมีขนาดใหญ่เกินไปจะทำให้แรม Executors ล้นพัง OOM ทันที
Q2: เมื่อเกิดสภาวะข้อมูลหนาแน่นไม่สมมาตร (Data Skew) ในงาน Spark Join เราจะตรวจเช็คเชิงลึกผ่านหน้า Spark UI และประยุกต์ใช้วิธี Salting เพื่อกระจายงานได้อย่างไร?
A2: ตรวจดูหน้า Spark UI หากพบว่ามี Executor ตัวหนึ่งประมวลผลนานกว่าเพื่อนและขนาดข้อมูล Shuffle Read สูงผิดปกติ แสดงว่ามีคีย์กระจุกตัว วิธีแก้ไขทำ Salting โดยเติมตัวเลขสุ่ม (เช่น ต่อท้ายคีย์ด้วย 0-3) ในตารางใหญ่ และทำสำเนาคีย์เดียวกันในตารางเล็กเพื่อช่วยแบ่งคีย์กระจายให้โหนดอื่นช่วยกันคำนวณเฉลี่ย
ข้อดี / จุดเด่น & ข้อเสีย / ข้อควรระวัง
ข้อดี / จุดเด่น
- • ประมวลผลระดับ Big Data สเกลขยายเครื่องคอมพิวเตอร์แบบแนวนอนได้ไร้ขีดจำกัด
- • ประมวลผลในแรม (In-memory computing) ทำงานได้รวดเร็วกว่า Hadoop แบบเดิมเกือบ 100 เท่า
ข้อเสีย / ข้อควรระวัง
- • เกิดปัญหาแรมหมดบ่อยครั้ง (Out-of-memory) เมื่อข้อมูลในแต่ละ Partition ไม่สมดุลกัน
- • ต้องการงบประมาณสูงและควบคุมดูแลเครื่องในคลัสเตอร์ค่อนข้างลำบาก
ปฏิบัติการจริง (Lab Practice)
แล็บ: สร้างและรัน Spark Job ในการประมวลผลข้อมูลระดับล้านแถว
💡 คำแนะนำ & ทริกเด็ด
ก่อนจะขยับไปใช้งาน Spark ให้เช็คขนาดข้อมูลดิบเสมอ หากมีขนาดต่ำกว่า 100GB แนะนำให้ใช้ DuckDB รันในโหมด Single-node เครื่องเดี่ยว ซึ่งจะทำงานสำเร็จเร็วกว่าเนื่องจากไม่มีเวลาหน่วงของการเปิดคลัสเตอร์และการ Shuffling