🧪 Lab 2 จาก 6 · Spark SQL

Spark SQL & DataFrame

ทำงานกับข้อมูลแบบมี schema จริง — โหลด CSV เข้ามาเป็น DataFrame, สำรวจ query ทั้งแบบ DataFrame API และ SQL string, และเห็นด้วยตาตัวเองว่าทั้งสองแบบให้แผนการทำงานเดียวกัน

🎯
เป้าหมายของ Lab นี้: โหลด CSV เข้า DataFrame, กำหนด schema เอง, เขียน query ด้วย DataFrame API และ SQL string, ใช้ explain() ดูแผนการทำงาน

1เตรียมข้อมูลตัวอย่าง (Orders Dataset)

%%writefile /home/jovyan/data/orders.csv
order_id,customer,product,category,quantity,price,order_date
1,Alice,Laptop,Electronics,1,32000,2026-01-05
2,Bob,Mouse,Electronics,2,450,2026-01-05
3,Alice,Desk,Furniture,1,4500,2026-01-06
4,Charlie,Laptop,Electronics,1,31500,2026-01-07
5,Bob,Chair,Furniture,2,1200,2026-01-08
6,Diana,Monitor,Electronics,2,6000,2026-01-08
7,Alice,Mouse,Electronics,3,450,2026-01-09
8,Charlie,Desk,Furniture,1,4500,2026-01-10

2โหลดข้อมูลพร้อมกำหนด Schema เอง

ทำไมต้องกำหนด schema เอง ไม่ปล่อยให้ Spark เดา
inferSchema=True สะดวกแต่ Spark ต้องอ่านข้อมูลผ่านหนึ่งรอบก่อนเพื่อเดา type ซึ่งช้าลงสำหรับไฟล์ใหญ่ และบางครั้งเดาผิด (เช่น เดาว่าคอลัมน์ตัวเลขเป็น string) — การกำหนด schema เองให้ผลลัพธ์ที่คาดเดาได้และเร็วกว่าเสมอสำหรับ production
from pyspark.sql.types import StructType, StructField, IntegerType, StringType, DoubleType, DateType

spark = SparkSession.builder \
    .appName("Lab2-SparkSQL") \
    .master("spark://spark-master:7077") \
    .getOrCreate()

schema = StructType([
    StructField("order_id", IntegerType(), False),
    StructField("customer", StringType(), False),
    StructField("product", StringType(), False),
    StructField("category", StringType(), False),
    StructField("quantity", IntegerType(), False),
    StructField("price", DoubleType(), False),
    StructField("order_date", DateType(), False),
])

df = spark.read.format("csv") \
    .option("header", "true") \
    .schema(schema) \
    .load("/home/jovyan/data/orders.csv")

df.printSchema()
df.show()

3Query ด้วย DataFrame API

# ยอดขายรวมต่อลูกค้า (quantity * price)
from pyspark.sql.functions import col, sum as spark_sum, round as spark_round

df.withColumn("total", col("quantity") * col("price")) \
  .groupBy("customer") \
  .agg(spark_round(spark_sum("total"), 2).alias("total_spent")) \
  .orderBy(col("total_spent").desc()) \
  .show()

4Query แบบเดียวกัน แต่เขียนเป็น SQL

ลงทะเบียน DataFrame เป็น temporary view ก่อน
df.createOrReplaceTempView("orders")

spark.sql("""
    SELECT customer, ROUND(SUM(quantity * price), 2) AS total_spent
    FROM orders
    GROUP BY customer
    ORDER BY total_spent DESC
""").show()
ผลลัพธ์ที่ควรเห็น (เหมือนกันทั้งสองวิธี)
+--------+-----------+
|customer|total_spent|
+--------+-----------+
|   Alice|    38050.0|
|   Diana|    12000.0|
| Charlie|    36000.0|
|     Bob|     3300.0|
+--------+-----------+

5พิสูจน์ว่าทั้งสองวิธีให้แผนการทำงานเดียวกัน

plan_dataframe = df.withColumn("total", col("quantity") * col("price")) \
    .groupBy("customer").agg(spark_sum("total"))._jdf.queryExecution().optimizedPlan()

plan_sql = spark.sql("""
    SELECT customer, SUM(quantity * price) FROM orders GROUP BY customer
""")._jdf.queryExecution().optimizedPlan()

print("แผนเหมือนกันหรือไม่:", str(plan_dataframe) == str(plan_sql))
# หรือใช้วิธีง่ายกว่า: เรียก .explain() แล้วเทียบด้วยตาก็ได้
df.groupBy("customer").agg(spark_sum(col("quantity") * col("price"))).explain()
เชื่อมโยงกับบทที่ 9
DataFrame API และ SQL ใช้ Catalyst ร่วมกัน จึงมักสร้างแผนเดียวกันเมื่อบอกความหมายเดียวกัน แต่ไม่ควรรับประกันว่าเหมือนทุกกรณี เพราะ Expression, UDF, Hint และ Configuration ทำให้ต่างได้ แล็บนี้จึงให้ตรวจ Plan จริงแทนการจำคำตอบล่วงหน้า

6เขียนผลลัพธ์เป็น Parquet

เชื่อมโยงกับบทที่ 8
Parquet คือ columnar format ที่กล่าวถึงในบทที่ 8 (Data Engineering Pipeline) — เหมาะกับการเก็บผลลัพธ์ analytical query เพราะอ่านเฉพาะคอลัมน์ที่ต้องการได้ ไม่ต้องอ่านทั้งแถว
summary = df.withColumn("total", col("quantity") * col("price")) \
    .groupBy("category").agg(spark_sum("total").alias("category_total"))

summary.write.mode("overwrite").parquet("/home/jovyan/data/category_summary.parquet")

# อ่านกลับมาตรวจสอบ
spark.read.parquet("/home/jovyan/data/category_summary.parquet").show()

7อ่าน Explain ให้เป็น ไม่ใช่เพียงเปิดดู

query = df.filter(col("category") == "Electronics") \
    .select("customer", "quantity", "price") \
    .withColumn("total", col("quantity") * col("price")) \
    .groupBy("customer").agg(spark_sum("total").alias("revenue"))

query.explain("extended")
query.explain("formatted")
ส่วนของ Planสิ่งที่ตรวจ
Parsedโค้ดถูกแปลงเป็น Operator ใด
Analyzedชื่อคอลัมน์และชนิด Resolve แล้วหรือไม่
OptimizedFilter/Projection ถูกดันหรือตัดอย่างไร
PhysicalExchange, HashAggregate, Join Strategy และ Scan

วงคำว่า Exchange ทุกจุด เพราะมักหมายถึง Shuffle แล้วอธิบายว่าทำไมข้อมูลต้องกระจายใหม่ Plan ที่มี Exchange ไม่ได้ผิด แต่ควรรู้ว่าจ่ายเพื่อ operation ใด

8Column Pruning และ Predicate Pushdown

สร้าง Parquet ที่มีหลายคอลัมน์แล้วอ่านเฉพาะสองคอลัมน์พร้อม Filter ตรวจ ReadSchema และ PushedFilters

orders = spark.read.parquet("/home/jovyan/data/orders.parquet")
small = orders.select("customer", "price") \
    .filter(col("price") > 1000)
small.explain("formatted")

ถ้า Source รองรับ Spark อ่านเฉพาะ Column และข้าม Data Group บางส่วนได้ หลักคือ “เอา Code ไปหา Data และอ่านเท่าที่ต้องใช้” ไม่ใช่โหลดทุกอย่างแล้วค่อยทิ้งใน Python

9Built-in Function กับ Python UDF

from pyspark.sql.functions import lower, trim, udf
from pyspark.sql.types import StringType

builtin = df.withColumn("customer_clean", lower(trim(col("customer"))))

@udf(StringType())
def normalize_name(x):
    return x.strip().lower() if x else None

python_udf = df.withColumn("customer_clean", normalize_name(col("customer")))

builtin.explain("formatted")
python_udf.explain("formatted")

Built-in Expression อยู่ในภาษาที่ Catalyst เข้าใจ ส่วน Python UDF อาจสร้าง Boundary และเป็นกล่องดำ ใช้ UDF เมื่อจำเป็น ไม่ใช่เพราะเขียน Python ถนัดกว่า แล้ววัดผลด้วยข้อมูลที่ใหญ่พอ

10Join Strategy และ Broadcast

customers = spark.createDataFrame([
    ("Alice", "Gold"), ("Bob", "Silver"), ("Carol", "Gold")
], ["customer", "segment"])

joined = df.join(customers, "customer", "left")
joined.explain("formatted")

from pyspark.sql.functions import broadcast
broadcast_join = df.join(broadcast(customers), "customer", "left")
broadcast_join.explain("formatted")

ดูว่า Physical Plan ใช้ BroadcastHashJoin หรือไม่ ตารางเล็กถูกส่งไป Executor เพื่อลด Shuffle ฝั่งใหญ่ แต่ถ้าตาราง “เล็ก” โตขึ้นใน Production การบังคับ Broadcast อาจกิน Memory จึงต้องตรวจขนาดและสถิติ

11NULL และ Data Quality

from pyspark.sql.functions import count, when, isnan

df.select([
    count(when(col(c).isNull(), c)).alias(c)
    for c in df.columns
]).show()

df.filter(col("price").isNull()).show()
df.selectExpr("customer", "coalesce(price, 0) AS price_or_zero").show()

NULL ไม่เท่ากับศูนย์ และ price = NULL ไม่ใช่วิธีกรองที่ถูก ต้องใช้ IS NULL การเติมศูนย์ควรมีเหตุผลทางธุรกิจ ไม่ใช่เพียงทำให้โมเดลรับข้อมูลได้

กฎวิธีตรวจAction
quantity > 0Filter ค่าผิดQuarantine/แก้ต้นทาง
price ไม่เป็น NULLNull CountReject หรือ Impute ตามบริบท
category อยู่ในชุดที่รู้จักAnti-join กับ ReferenceUnknown Bucket/แจ้ง Owner

12Partitioned Parquet และ Small Files

df.write.mode("overwrite") \
  .partitionBy("category") \
  .parquet("/home/jovyan/data/orders_by_category")

filtered = spark.read.parquet("/home/jovyan/data/orders_by_category") \
  .filter(col("category") == "Electronics")
filtered.explain("formatted")

ตรวจ PartitionFilters แต่ระวัง Partition ด้วยคอลัมน์ Cardinality สูง เช่น order_id จะสร้าง Directory/Small File จำนวนมาก ทดลอง repartition(2) ก่อนเขียนและนับไฟล์เพื่อเห็นความสัมพันธ์ระหว่าง Execution Partition กับ Output File

13Window Function

from pyspark.sql.window import Window
from pyspark.sql.functions import row_number, sum as spark_sum

w_rank = Window.partitionBy("category").orderBy(col("price").desc())
w_total = Window.partitionBy("customer").orderBy("order_id") \
    .rowsBetween(Window.unboundedPreceding, Window.currentRow)

df.withColumn("rank_in_category", row_number().over(w_rank)) \
  .withColumn("running_spend", spark_sum(col("quantity") * col("price")).over(w_total)) \
  .show()

Window ไม่ยุบแถวเหมือน GroupBy แต่ต้อง Partition/Sort ตามกรอบ จึงอาจ Shuffle แพง ตรวจ Plan และอธิบายว่าทำไม Window สองชุดที่ใช้ Key/Order ต่างกันอาจสร้าง Sort หลายครั้ง

14Mini Benchmark และสิ่งที่ต้องส่ง

สร้างข้อมูลอย่างน้อยหนึ่งล้านแถว เปรียบเทียบ CSV กับ Parquet, Built-in กับ UDF และ Join ปกติกับ Broadcast รันซ้ำอย่างน้อยสามครั้ง แยกเวลารอบแรก/รอบถัดไป และเก็บ Plan

สรุปและขั้นตอนถัดไป: DataFrame ทำให้ระบบเห็น Schema และ Algebra แต่ข้อมูลที่สะอาดยังไม่กลายเป็นโมเดล Lab 3 จะนำ Transformation กับ Estimator มาประกอบเป็น ML Pipeline โดยเน้น Data Leakage, Metric และ Reproducibility มากกว่าการได้ Accuracy ตัวเลขหนึ่งค่า

แบบฝึกหัดก่อนไป Lab 3
  1. เขียน query หาสินค้าที่ขายได้จำนวนชิ้น (quantity) รวมมากที่สุด ทั้งแบบ DataFrame API และ SQL
  2. ใช้ filter/WHERE หา order ทั้งหมดที่อยู่ในหมวด "Electronics" และมีราคารวมเกิน 5000 บาท
  3. ลองตั้งใจกำหนด schema ผิด (เช่นให้ price เป็น StringType) แล้วสังเกตว่าการคำนวณ quantity * price เกิด error หรือพฤติกรรมแปลกอย่างไร — อธิบายว่าทำไม schema ที่ถูกต้องถึงสำคัญ

20วิธีทำแล็บให้ได้มากกว่าคำว่า “รันผ่าน”

แล็บนี้ไม่ได้วัดว่าเราพิมพ์คำสั่งตามตัวอย่างได้ครบหรือไม่ เพราะการรันผ่านอาจเกิดจากความบังเอิญ ค่า Default หรือข้อมูลที่เล็กเกินไปจนมองไม่เห็นปัญหา เป้าหมายคือฝึกตั้งสมมติฐาน เปลี่ยนตัวแปรครั้งละอย่าง เก็บหลักฐาน และอธิบายผลด้วยแนวคิดของระบบกระจาย ถ้าผลไม่ตรงกับที่คาด นั่นไม่ใช่ความล้มเหลว แต่เป็นจุดเริ่มต้นของคำถามที่ดี

เปรียบเหมือนการทดลองในครัว
ถ้าเราเปลี่ยนทั้งเตา ภาชนะ ปริมาณวัตถุดิบ และเวลาปรุงพร้อมกัน แม้อาหารอร่อยขึ้น เราก็ไม่รู้ว่าอะไรเป็นสาเหตุ การทดลองระบบก็เหมือนกัน ควรเปลี่ยนทีละปัจจัย และจดสิ่งที่คงเดิมไว้ให้ชัด
  1. คาดก่อนรัน: เขียนหนึ่งหรือสองประโยคว่าคิดว่าจะเกิดอะไรและเพราะอะไร
  2. กำหนดสิ่งที่คุม: ใช้ข้อมูล โค้ด และทรัพยากรเดิม แล้วเปลี่ยนเฉพาะตัวแปรที่กำลังศึกษา
  3. เก็บหลักฐาน: บันทึกเวลา แผนการทำงาน Metric, Log หรือภาพ Spark UI ไม่อาศัยความรู้สึกว่า “เร็วขึ้น”
  4. อธิบายกลไก: เชื่อมผลกับ Partition, Shuffle, State, Memory, Network หรือ Scheduling
  5. บอกข้อจำกัด: ผลจาก Laptop และข้อมูลจำลองอาจไม่เหมือน Production ให้ระบุสิ่งที่ยังสรุปไม่ได้

21สมุดบันทึกการทดลอง

รายการสิ่งที่ควรบันทึกเหตุผล
คำถามครั้งนี้ต้องการรู้อะไรเพียงเรื่องเดียวกันการทดลองหลุดประเด็น
สมมติฐานผลที่คาดและกลไกที่คิดว่าเป็นสาเหตุทำให้ตรวจความเข้าใจได้
ตัวแปรสิ่งที่เปลี่ยน สิ่งที่คุม และค่าตั้งต้นทำให้เปรียบเทียบอย่างเป็นธรรม
หลักฐานเวลา Metric, Plan, Log, UI และ Sample outputทำให้คนอื่นตรวจซ้ำได้
ข้อสรุปหลักฐานสนับสนุนหรือหักล้างสมมติฐานอย่างไรแยกข้อเท็จจริงออกจากความเห็น
คำถามใหม่ถ้ามีเวลาเพิ่ม จะเปลี่ยนอะไรต่อเชื่อมไปสู่การทดลองรอบถัดไป

อย่าจดเฉพาะผลลัพธ์สุดท้าย ควรจด Environment ด้วย เช่น Spark version, Python version, จำนวน Core, Memory, จำนวน Partition, Seed และขนาดข้อมูล เปรียบเหมือนใบเสร็จและสูตรอาหารที่ทำให้คนอื่นย้อนทำจานเดิมได้ หากไม่มีข้อมูลเหล่านี้ ผลที่ดูน่าเชื่อถืออาจทำซ้ำไม่ได้

22ชุดการทดลองหลัก: Spark SQL และ DataFrame

การทดลอง 1: อ่านแผนก่อนรัน

ลงมือทำ: ใช้ explain formatted กับ Query ที่เลือกคอลัมน์ กรอง และ Join ก่อนรันให้เขียนผลที่คาดไว้ แล้วรันอย่างน้อยสามครั้งหากกำลังเปรียบเทียบเวลา เพื่อไม่ให้การเริ่ม JVM, Cache หรือ Background process เพียงครั้งเดียวหลอกเรา

หลักฐานที่ต้องเก็บ: แยก Logical Plan, Physical Plan, Scan และ Exchange แนบค่าตั้งต้นและขนาดข้อมูล เพื่อให้เพื่อนสามารถตรวจซ้ำได้

คำถามที่ต้องตอบ: ผลต่างมาจากการคำนวณ การสื่อสาร การอ่านเขียนข้อมูล หรือการรอคิว? ถ้าขยายข้อมูลสิบเท่า แนวโน้มเดิมน่าจะยังอยู่หรือไม่ เพราะอะไร?

การทดลอง 2: พิสูจน์ Pushdown

ลงมือทำ: อ่าน Parquet ด้วย Filter ที่รองรับและเทียบกับ Python UDF ก่อนรันให้เขียนผลที่คาดไว้ แล้วรันอย่างน้อยสามครั้งหากกำลังเปรียบเทียบเวลา เพื่อไม่ให้การเริ่ม JVM, Cache หรือ Background process เพียงครั้งเดียวหลอกเรา

หลักฐานที่ต้องเก็บ: PushedFilters และจำนวนข้อมูลที่อ่านจริง แนบค่าตั้งต้นและขนาดข้อมูล เพื่อให้เพื่อนสามารถตรวจซ้ำได้

คำถามที่ต้องตอบ: ผลต่างมาจากการคำนวณ การสื่อสาร การอ่านเขียนข้อมูล หรือการรอคิว? ถ้าขยายข้อมูลสิบเท่า แนวโน้มเดิมน่าจะยังอยู่หรือไม่ เพราะอะไร?

การทดลอง 3: เลือกวิธี Join

ลงมือทำ: เทียบ Sort-Merge Join กับ Broadcast Join เมื่อด้านหนึ่งมีขนาดเล็ก ก่อนรันให้เขียนผลที่คาดไว้ แล้วรันอย่างน้อยสามครั้งหากกำลังเปรียบเทียบเวลา เพื่อไม่ให้การเริ่ม JVM, Cache หรือ Background process เพียงครั้งเดียวหลอกเรา

หลักฐานที่ต้องเก็บ: Physical Plan, เวลา และเงื่อนไขที่ Broadcast เสี่ยง แนบค่าตั้งต้นและขนาดข้อมูล เพื่อให้เพื่อนสามารถตรวจซ้ำได้

คำถามที่ต้องตอบ: ผลต่างมาจากการคำนวณ การสื่อสาร การอ่านเขียนข้อมูล หรือการรอคิว? ถ้าขยายข้อมูลสิบเท่า แนวโน้มเดิมน่าจะยังอยู่หรือไม่ เพราะอะไร?

การทดลอง 4: จัดการ Null

ลงมือทำ: กำหนดความหมายของ Null ก่อน fill, drop หรือ impute ก่อนรันให้เขียนผลที่คาดไว้ แล้วรันอย่างน้อยสามครั้งหากกำลังเปรียบเทียบเวลา เพื่อไม่ให้การเริ่ม JVM, Cache หรือ Background process เพียงครั้งเดียวหลอกเรา

หลักฐานที่ต้องเก็บ: ตาราง Data Quality ก่อนและหลัง พร้อมจำนวนแถวที่เปลี่ยน แนบค่าตั้งต้นและขนาดข้อมูล เพื่อให้เพื่อนสามารถตรวจซ้ำได้

คำถามที่ต้องตอบ: ผลต่างมาจากการคำนวณ การสื่อสาร การอ่านเขียนข้อมูล หรือการรอคิว? ถ้าขยายข้อมูลสิบเท่า แนวโน้มเดิมน่าจะยังอยู่หรือไม่ เพราะอะไร?

การทดลอง 5: ระวัง Small Files

ลงมือทำ: เขียน Parquet หลาย Partition แล้วเทียบกับการปรับจำนวน Partition ก่อนรันให้เขียนผลที่คาดไว้ แล้วรันอย่างน้อยสามครั้งหากกำลังเปรียบเทียบเวลา เพื่อไม่ให้การเริ่ม JVM, Cache หรือ Background process เพียงครั้งเดียวหลอกเรา

หลักฐานที่ต้องเก็บ: จำนวนไฟล์ ขนาดเฉลี่ย และเวลาอ่านกลับ แนบค่าตั้งต้นและขนาดข้อมูล เพื่อให้เพื่อนสามารถตรวจซ้ำได้

คำถามที่ต้องตอบ: ผลต่างมาจากการคำนวณ การสื่อสาร การอ่านเขียนข้อมูล หรือการรอคิว? ถ้าขยายข้อมูลสิบเท่า แนวโน้มเดิมน่าจะยังอยู่หรือไม่ เพราะอะไร?

23บันไดแก้ปัญหาเมื่อผลไม่เป็นอย่างที่คิด

เวลางานล้ม อย่าเริ่มด้วยการลบทุกอย่างแล้วติดตั้งใหม่ เพราะเราอาจทำลายหลักฐานที่บอกสาเหตุ ให้ไล่จากชั้นนอกเข้าสู่ชั้นในเหมือนช่างไฟที่ตรวจตั้งแต่ปลั๊ก สายไฟ ฟิวส์ ไปจนถึงเครื่องใช้ไฟฟ้า

  1. ยืนยันอาการ: คัดลอก Error แรก ไม่ใช่เฉพาะบรรทัดสุดท้าย และบันทึกคำสั่งที่ทำให้เกิดซ้ำ
  2. ลดปัญหา: ใช้ข้อมูลเล็กที่สุดและโค้ดสั้นที่สุดที่ยังทำให้ Error เกิด
  3. ตรวจสัญญาข้อมูล: ดู Schema, Type, Null, Key และ Path ก่อนสงสัย Cluster
  4. ตรวจ Plan และ UI: ดูว่างานเริ่มจริงหรือค้างอยู่ก่อนสร้าง Job; แยกปัญหา Driver, Executor และ Storage
  5. ตรวจทรัพยากร: ดู Memory, Disk, Port, Permission และ Container status
  6. เปลี่ยนทีละจุด: เมื่อแก้แล้ว ให้ย้อนอธิบายว่าการเปลี่ยนนั้นจัดการสาเหตุใด
กติกาสำคัญ
การ Restart อาจทำให้อาการหาย แต่ยังไม่ใช่คำอธิบาย ถ้าต้อง Restart ให้เก็บ Log ก่อนและเขียนไว้ตรง ๆ ว่ายังหาสาเหตุรากไม่พบ ในงานจริง ความซื่อสัตย์ต่อหลักฐานสำคัญกว่ารายงานที่ดูเรียบร้อย

24คำถามชวนคิดหลังทำแล็บ

  1. ผลที่เห็นเกิดจาก Algorithm หรือเกิดจากการจัดวางข้อมูลและข้อจำกัดของเครื่อง?
  2. ถ้าข้อมูลใหญ่ขึ้นหนึ่งร้อยเท่า ขั้นตอนไหนจะพังก่อน และเรามีหลักฐานอะไร?
  3. ถ้าเครื่องหนึ่งหายไประหว่างงาน คำตอบจะยังถูกต้องหรือเพียงแค่งานยังรันต่อได้?
  4. ค่า Default ใดช่วยเราอยู่ และค่าใดอาจกลายเป็นกับดักเมื่อขึ้น Production?
  5. ถ้าต้องอธิบายผลให้เพื่อนที่ไม่ได้เห็นหน้าจอ เรามีหลักฐานครบพอหรือยัง?

25สิ่งที่ต้องส่ง

26เกณฑ์ตรวจงานแบบสั้น

ด้านงานที่ผ่านงานที่เข้าใจจริง
ความถูกต้องรันได้และได้คำตอบตรวจ Schema, Edge case และเทียบกับคำตอบเล็กที่คำนวณเองได้
การทดลองมีผลหนึ่งชุดคุมตัวแปร ทำซ้ำ และอธิบายความแปรปรวน
หลักฐานมี Screenshotเลือก Metric หรือ Plan ที่ตอบคำถามและตีความถูก
ความเข้าใจบอกว่าอะไรเร็วกว่าอธิบายกลไกและเงื่อนไขที่ข้อสรุปอาจกลับด้าน
การสื่อสารมี Codeผู้อื่นทำซ้ำได้และเห็นเส้นทางจากคำถามไปถึงข้อสรุป

27สรุปและขั้นตอนถัดไป

หัวใจของแล็บนี้ไม่ใช่จำคำสั่ง แต่คือการมองเห็นว่า Spark SQL และ DataFrame ตัดสินใจอย่างไรเมื่อข้อมูล งาน และทรัพยากรถูกกระจายออกจากกัน เมื่อทำเสร็จแล้วควรตอบได้สามเรื่อง: ระบบทำอะไร หลักฐานใดแสดงว่ามันทำเช่นนั้น และเงื่อนไขใดทำให้พฤติกรรมเปลี่ยน

ก่อนเปิดแล็บถัดไป ลองย่อสิ่งที่เรียนรู้ให้เหลือหนึ่งภาพ หนึ่งตาราง และสามประโยค หากย่อไม่ได้ อาจยังมีส่วนที่เราเห็นผลแต่ยังไม่เข้าใจกลไก ให้กลับไปเลือกการทดลองที่เล็กลง แล้วค่อยต่อภาพกลับขึ้นมาใหม่

28สถานการณ์จำลอง: จากข้อมูลก้อนเล็กไปสู่งานที่เริ่มมีปัญหา

ให้เริ่มด้วยข้อมูลก้อนเล็กที่ตรวจคำตอบด้วยมือได้ จากนั้นขยายเป็นสามระดับ โดยไม่เปลี่ยนความหมายของโจทย์ ระดับแรกใช้ตรวจความถูกต้อง ระดับที่สองใช้เปิดให้เห็น Parallelism และระดับที่สามใช้เปิดให้เห็นคอขวด วิธีนี้ช่วยแยกคำถามสองข้อที่มักปนกัน คือ “คำตอบถูกหรือไม่” กับ “ระบบรองรับขนาดงานได้หรือไม่” ระบบที่เร็วแต่ตอบผิดไม่มีประโยชน์ และระบบที่ตอบถูกเฉพาะข้อมูลสิบแถวก็ยังไม่ใช่คำตอบของงานขนาดใหญ่

ระดับจุดประสงค์สิ่งที่ควรเห็นสิ่งที่ยังสรุปไม่ได้
เล็กมากตรวจคำตอบด้วยมือและทดสอบ Edge caseOutput ทุกแถวและลำดับการแปลงข้อมูลประสิทธิภาพและการกระจายงาน
พอดีเครื่องดูจำนวน Task, Partition และการใช้ Coreงานหลาย Task ทำพร้อมกันและมี Metric ให้อ่านพฤติกรรมเมื่อ Memory ไม่พอ
เริ่มเกินหน่วยความจำดู Spill, Shuffle, GC และการอ่านเขียน Diskคอขวดปรากฏชัดใน UIพฤติกรรมของคลัสเตอร์ Production ขนาดใหญ่
ข้อมูลเอียงดูผลของ Key หรือกลุ่มที่ใหญ่ผิดปกติTask บางตัวช้ากว่าเพื่อนอย่างชัดเจนว่าวิธีแก้หนึ่งแบบเหมาะกับข้อมูลทุกชุด
Metaphor: ซ้อมขนย้ายบ้าน
กล่องเล็กหนึ่งใบช่วยตรวจว่าเราติดป้ายที่อยู่ถูก แต่ไม่ช่วยบอกว่ารถบรรทุกพอหรือไม่ ส่วนการขนของทั้งบ้านตั้งแต่ครั้งแรกทำให้เราแยกไม่ออกว่าช้าเพราะป้ายผิด ทางแคบ หรือรถไม่พอ การเพิ่มขนาดทีละระดับทำให้เห็นสาเหตุชัดขึ้น

29อ่าน Spark UI ให้เป็นเรื่องราว ไม่ใช่เพียงหน้าจอสีสวย

เริ่มจาก Job ว่าถูกสร้างเพราะ Action ใด แล้วลงไปที่ Stage เพื่อดูว่า Shuffle แบ่งเส้นทางตรงไหน จากนั้นจึงดู Task ว่ากระจายตัวสม่ำเสมอหรือมีตัวใดลากยาว สุดท้ายดู Executor ว่างานไปอยู่เครื่องใด ใช้ Memory เท่าไร และมี Failure หรือ Retry หรือไม่ ลำดับนี้เหมือนอ่านแผนที่จากประเทศ จังหวัด ถนน แล้วจึงถึงบ้าน หากกระโดดไปดู Task หนึ่งตัวทันที เราอาจเห็นอาการแต่ไม่เห็นบริบท

หน้าหรือ Metricคำถามที่ตอบได้สัญญาณที่ควรสงสัย
JobsAction ใดเริ่มการคำนวณ และจบหรือยังมี Job มากกว่าที่คาดจากการเรียก Action ซ้ำ
Stagesงานถูกตัดตรงไหนและ Stage ใดใช้เวลามากStage หลัง Shuffle ใช้เวลาครองงานทั้งหมด
Tasksงานย่อยกระจายสม่ำเสมอหรือไม่Max สูงกว่า Median มาก บ่งชี้ Skew หรือเครื่องช้า
Executorsใช้ Core/Memory/Storage อย่างไรExecutor หาย, Task Failed, GC สูง หรือ Disk Spill มาก
SQL PlanOptimizer เลือก Scan, Join และ Exchange แบบใดอ่านคอลัมน์เกินจำเป็น มี Exchange ซ้ำ หรือ Join ผิดคาด
StorageCache อะไร อยู่ระดับใด และกินพื้นที่เท่าไรCache ไว้แต่ไม่ถูกใช้ หรือผลักข้อมูลสำคัญออกจาก Memory

การจับภาพ UI ควรใส่ลูกศรหรือคำอธิบายว่ากำลังใช้ภาพนั้นสนับสนุนข้อสรุปใด ภาพทั้งหน้าที่ไม่มีคำอธิบายเหมือนแนบผลตรวจสุขภาพโดยไม่บอกว่าค่าไหนผิดปกติ หลักฐานที่ดีไม่จำเป็นต้องเยอะ แต่ต้องเชื่อมกับคำถามได้ตรงจุด

30แยกความถูกต้อง ความเร็ว และความทนทานออกจากกัน

สามเรื่องนี้สัมพันธ์กันแต่ทดแทนกันไม่ได้ งานอาจเร็วขึ้นเพราะเผลอทิ้งข้อมูลบางส่วน งานอาจทนต่อ Worker หายแต่สร้างข้อมูลซ้ำ หรือคำตอบอาจถูกแต่ใช้ทรัพยากรมากเกินจำเป็น ทุกครั้งที่ปรับระบบจึงควรตรวจทั้งสามแกน ไม่ประกาศชัยชนะจากเวลาเพียงตัวเดียว

  1. ความถูกต้อง: สร้างข้อมูลจิ๋วที่รู้คำตอบล่วงหน้า ใส่ Null, Duplicate, Key ที่ไม่มีคู่ และค่าขอบเขต แล้วตรวจผลทีละกรณี
  2. ความเร็ว: วัดหลายรอบ แยก Warm-up ออกจากรอบจริง ระบุขนาดข้อมูลและทรัพยากร และรายงานทั้งค่ากลางกับช่วง ไม่เลือกเฉพาะรอบที่ดีที่สุด
  3. ความทนทาน: ทำให้ส่วนหนึ่งล้มอย่างตั้งใจ ตรวจว่างาน Retry, Resume หรือคำนวณใหม่จาก Lineage และตรวจว่าคำตอบหลังการกู้ไม่ซ้ำหรือขาด
  4. ต้นทุน: เวลาเร็วขึ้นอาจแลกกับ Memory, Storage, Network หรือความซับซ้อนในการดูแล ให้บันทึกสิ่งที่จ่ายเพิ่มเสมอ
แบบตรวจสั้นก่อนเชื่อผล Benchmark
ข้อมูลเท่ากันหรือไม่? Output เท่ากันหรือไม่? Cache ของแต่ละรอบเท่ากันหรือไม่? มีงานอื่นแย่งเครื่องหรือไม่? จำนวน Partition และ Core เท่ากันหรือไม่? ถ้าตอบข้อใดไม่ได้ ให้เรียกผลนั้นว่า “ข้อสังเกตเบื้องต้น” ก่อน ยังไม่ควรเรียกว่า “ข้อสรุป”

31จาก Error Message ไปสู่คำอธิบาย

ให้เลือก Error จริงหนึ่งครั้งจากแล็บแล้วเขียน Postmortem สั้น ๆ โดยไม่กล่าวโทษผู้ใช้หรือเครื่องมือ เริ่มจากผลกระทบ ตามด้วยลำดับเหตุการณ์ สาเหตุใกล้ตัว สาเหตุราก วิธีแก้เฉพาะหน้า และวิธีป้องกันซ้ำ ตัวอย่างเช่น “Job ล้มเพราะ Out of Memory” ยังเป็นเพียงอาการ สาเหตุรากอาจเป็นการ collect ข้อมูลทั้งหมดมายัง Driver, Broadcast ตารางใหญ่เกินไป หรือ Partition หนึ่งก้อนใหญ่ผิดปกติ ซึ่งมีวิธีแก้คนละแบบ

ส่วนของ Postmortemตัวอย่างคำถาม
ผลกระทบงานใดหยุด คำตอบใดขาด และเสียเวลาเท่าไร
ลำดับเหตุการณ์คำสั่งใดเกิดก่อน Error; Metric เริ่มผิดปกติเมื่อใด
สาเหตุรากการตัดสินใจหรือข้อสมมติใดทำให้ปัญหาเกิดได้
การกู้ทำอย่างไรให้งานกลับมา และข้อมูลถูกตรวจซ้ำอย่างไร
การป้องกันเพิ่ม Test, Validation, Alert หรือเปลี่ยน Design ตรงไหน

จุดสำคัญคือแยก “แก้ให้งานผ่านครั้งนี้” ออกจาก “แก้ไม่ให้เกิดซ้ำ” การเพิ่ม Memory อาจช่วยงานรอบนี้ แต่ถ้าข้อมูลโตต่อเนื่อง ปัญหาจะกลับมา การเปลี่ยนรูปแบบ Aggregation, ลดข้อมูลก่อน Shuffle หรือเลิกดึงทุกอย่างเข้า Driver อาจแตะสาเหตุได้ตรงกว่า

32แบบฝึกสอนกลับ: อธิบายให้เพื่อนฟังในห้านาที

จับคู่กับเพื่อนแล้วผลัดกันอธิบาย Spark SQL และ DataFrame โดยห้ามเริ่มจากชื่อ Class หรือ API ให้เริ่มจากปัญหาที่ระบบกำลังแก้ ใช้ภาพหนึ่งภาพและตัวอย่างข้อมูลไม่เกินสิบแถว ผู้ฟังมีหน้าที่ถามว่า “รู้ได้อย่างไร” และ “ถ้าข้อมูลใหญ่ขึ้นจะเกิดอะไร” วิธีนี้ช่วยเปิดจุดที่เราจำคำสั่งได้แต่ยังเชื่อมเหตุผลไม่ครบ

คำถามปิดแล็บ
ถ้าต้องลบโค้ดตัวอย่างทั้งหมดออก แต่เก็บบทเรียนไว้ได้เพียงสามประโยค จะเก็บประโยคใด? คำตอบควรเป็นหลักคิดที่ยังใช้ได้แม้ Spark API เปลี่ยนชื่อในอนาคต ไม่ใช่เพียงลำดับคำสั่ง