🧪 Lab 1 จาก 6 · Spark Core

Spark Core & RDD

ลงมือเขียน word count จริงบน RDD ต่อยอดจากทฤษฎี MapReduce ในบทที่ 4 — เห็นด้วยตาตัวเองว่า transformation แบบ lazy และ action ทำงานต่างกันอย่างไร

🎯
เป้าหมายของ Lab นี้: เขียน word count ด้วย RDD API, เข้าใจ transformation vs action, ทดลอง cache() เพื่อดูผลต่างของความเร็ว

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

ขั้นตอนที่ 1
สร้างไฟล์ข้อความตัวอย่างในโฟลเดอร์ data/ ที่ mount ไว้จาก Lab 0
%%writefile /home/jovyan/data/sample.txt
big data analytics is about scale
spark makes big data processing fast
big data needs distributed systems
scale out beats scale up for big data

2สร้าง SparkContext และโหลดข้อมูล

from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .appName("Lab1-RDD") \
    .master("spark://spark-master:7077") \
    .getOrCreate()
sc = spark.sparkContext

lines = sc.textFile("/home/jovyan/data/sample.txt")
print("จำนวนบรรทัด:", lines.count())   # action ตัวแรก — บังคับให้อ่านไฟล์จริง

3Word Count แบบเต็มรูปแบบ

เชื่อมโยงกับบทที่ 4 และ 5
โค้ดนี้คือ pseudo-code MapReduce ในบทที่ 4 เขียนจริงด้วย Scala/Python functional API จากบทที่ 5
word_counts = (
    lines
    .flatMap(lambda line: line.split(" "))   # map: แตกแต่ละบรรทัดเป็นคำ
    .map(lambda word: (word, 1))              # map: แปลงเป็น (คำ, 1)
    .reduceByKey(lambda a, b: a + b)          # reduce: รวมค่าของคำเดียวกัน
)

for word, count in word_counts.collect():     # action: ดึงผลลัพธ์ทั้งหมดกลับมา
    print(f"{word}: {count}")
ผลลัพธ์ที่ควรเห็น (ลำดับอาจต่างกันได้)
big: 4
data: 4
analytics: 1
scale: 3
...

4ทดลอง: Transformation เป็น Lazy จริงไหม

การทดลอง
เพิ่ม print เข้าไปใน transformation แล้วสังเกตว่ามันรันตอนไหน
def debug_map(word):
    print(f"กำลังประมวลผลคำ: {word}")   # ไม่ควรเห็น print นี้จนกว่าจะมี action
    return (word, 1)

mapped = lines.flatMap(lambda l: l.split(" ")).map(debug_map)
print("--- ตอนนี้ยังไม่มี print ใด ๆ ปรากฏ เพราะยังไม่มี action ---")

result = mapped.reduceByKey(lambda a, b: a + b).collect()   # action — ตอนนี้ print ถึงจะทำงาน
print("--- ตอนนี้ print ปรากฏแล้ว เพราะ collect() บังคับให้รันทั้ง chain ---")
สิ่งที่ควรสังเกต
บรรทัด "กำลังประมวลผลคำ" จะปรากฏหลังบรรทัด "ยังไม่มี action" เสมอ — พิสูจน์ว่า transformation ไม่รันจริงจนกว่าจะมี action มาเรียก ตรงตามทฤษฎีในบทที่ 9

5ทดลอง: ผลของ cache() ต่อความเร็ว

import time

# โหลดไฟล์ที่ใหญ่กว่าเดิม (ทำซ้ำเนื้อหาเดิม 50,000 ครั้ง เพื่อให้เห็นผลชัด)
big_lines = sc.parallelize(["big data analytics is about scale"] * 50000)

# ไม่ cache — ต้องคำนวณ transformation ใหม่ทุกครั้งที่มี action
start = time.time()
big_lines.flatMap(lambda l: l.split(" ")).count()
big_lines.flatMap(lambda l: l.split(" ")).count()
print("ไม่ cache:", time.time() - start, "วินาที")

# cache — คำนวณครั้งแรกแล้วเก็บผลไว้ใน memory
cached = big_lines.flatMap(lambda l: l.split(" ")).cache()
start = time.time()
cached.count()   # ครั้งแรก: คำนวณจริงและเก็บลง cache
cached.count()   # ครั้งที่สอง: อ่านจาก cache โดยตรง เร็วกว่ามาก
print("cache แล้ว:", time.time() - start, "วินาที")
ทำไมเรื่องนี้ถึงสำคัญ
Spark ไม่ได้ "จำ" ผลลัพธ์ของ transformation โดยอัตโนมัติ — ถ้า RDD ตัวเดียวกันถูกใช้ในหลาย action โดยไม่ cache() Spark จะคำนวณ transformation ทั้ง chain ใหม่ทุกครั้ง เชื่อมโยงตรงกับแนวคิด "Superlinear Speedup" ในบทที่ 6 — การจัดการว่าอะไรควรอยู่ใน memory คือกุญแจของประสิทธิภาพ

6อ่าน Lineage และแบ่ง Narrow/Wide Transformation

RDD ไม่ได้เก็บเพียงข้อมูล แต่จำสูตรว่าผลลัพธ์มาจากไหน toDebugString() ช่วยเห็น Dependency Chain และจุดที่เกิด Shuffle

lines = sc.textFile("/home/jovyan/data/text.txt")
words = lines.flatMap(lambda line: line.lower().split())
pairs = words.map(lambda word: (word, 1))
counts = pairs.reduceByKey(lambda a, b: a + b)
print(counts.toDebugString().decode("utf-8"))

map และ filter มักเป็น Narrow Dependency เพราะ Partition ปลายทางใช้ Parent จำนวนน้อย ส่วน reduceByKey, groupByKey, sortByKey และ join มักต้อง Shuffle ข้อมูลตาม Key จึงเกิด Stage Boundary

Operationคาดว่า Shuffle?เหตุผล
map/filterไม่ทำใน Partition เดิมได้
reduceByKeyใช่ค่า Key เดียวกันต้องมารวมกัน
repartitionใช่กระจาย Record ใหม่ทั้งชุด
coalesce ลด Partitionโดยทั่วไปน้อยกว่ารวม Partition ได้โดยไม่ Full Shuffle ในหลายกรณี

7พิสูจน์ว่า Action สร้าง Job

เปิด Spark UI แล้วรัน Action ทีละคำสั่ง จดจำนวน Job, Stage และ Task อย่ารัน Cell ทั้งก้อน เพราะจะจับคู่ Code กับ UI ยาก

base = sc.parallelize(range(1_000_000), 8)
even = base.filter(lambda x: x % 2 == 0)
squared = even.map(lambda x: x * x)

# จุดนี้ยังไม่ควรมี Job ใหม่จาก Transformation
print("Partitions:", squared.getNumPartitions())

print("Count:", squared.count())       # Action 1
print("First five:", squared.take(5)) # Action 2

Action สองครั้งอาจคำนวณ Lineage ซ้ำ ถ้าไม่ได้ Cache Spark ไม่สรุปเองว่าเรา “น่าจะใช้ซ้ำ” เพราะ Cache มีราคาและอาจไม่คุ้ม

8reduceByKey กับ groupByKey: คำตอบเหมือน ต้นทุนไม่เหมือน

data = sc.parallelize([
    ("A", 10), ("A", 20), ("B", 5), ("A", 7), ("B", 8)
], 4)

good = data.reduceByKey(lambda a, b: a + b)
expensive = data.groupByKey().mapValues(lambda xs: sum(xs))

print(good.collect())
print(expensive.collect())

reduceByKey รวมบางส่วนใน Partition ก่อนส่งข้าม Network ส่วน groupByKey ส่งค่าทุกตัวไปกองที่ปลายทางแล้วค่อย Sum หลักนี้เหมือนให้แต่ละสาขารวมยอดก่อนส่งสำนักงานใหญ่ แทนการขนใบเสร็จทุกใบ

groupByKey ไม่ได้ผิดเสมอ
ถ้าโจทย์ต้องการค่าทุกรายการต่อ Key จริง ๆ มันอาจจำเป็น แต่ถ้าต้องการ Sum, Min, Max หรือ Count ให้ใช้ Aggregation ที่รวมบางส่วนได้ อย่าตัดสินจากชื่อสั้นกว่า ให้ดูข้อมูลที่ต้องข้าม Network

9Partition และ Load Balance

rdd = sc.parallelize(range(100), 6)
sizes = rdd.mapPartitionsWithIndex(
    lambda idx, it: [(idx, sum(1 for _ in it))]
).collect()
print(sizes)

จากนั้นสร้างข้อมูล Key เอียงและดูจำนวน Record หลัง partitionBy

skewed = sc.parallelize(
    [("HOT", i) for i in range(100_000)] +
    [(f"K{i}", i) for i in range(10_000)], 8
)
partitioned = skewed.partitionBy(8)
print(partitioned.mapPartitionsWithIndex(
    lambda idx, it: [(idx, sum(1 for _ in it))]
).collect())

ถ้า HOT Key ไป Partition เดียว Task นั้นจะช้ากว่ากลุ่ม Job รอ Task ช้าที่สุด การเพิ่ม Partition ไม่แยก Key เดียวออกเอง ต้องเปลี่ยน Algorithm เช่น Salting หรือ Pre-aggregation

10Cache, Persist และ Materialization

cache() ยัง Lazy การเรียกไม่ทำให้ข้อมูลเข้า Memory จนมี Action แรก ควรแยกเวลารอบ Warm-up กับเวลารอบ Reuse

from pyspark import StorageLevel
import time

base = sc.parallelize(range(5_000_000), 16).map(lambda x: (x % 1000, x * x))
base.persist(StorageLevel.MEMORY_AND_DISK)

t0 = time.perf_counter(); base.count(); first = time.perf_counter() - t0
t0 = time.perf_counter(); base.count(); second = time.perf_counter() - t0
print("materialize:", first, "reuse:", second)
base.unpersist()
ควร Cacheไม่ควร Cache โดยอัตโนมัติ
สร้างแพงและใช้ซ้ำหลาย Actionใช้ครั้งเดียว
ผลพอดี Storage Levelข้อมูลใหญ่มากจน Cache เบียด Shuffle/GC
Iterative AlgorithmSource อ่านเร็วและ Transformation ถูก

11Fault Tolerance จาก Lineage

สร้าง RDD จาก Deterministic Transformation แล้วสังเกตว่า Spark รู้ Parent Dependency แต่ Lineage ยาวมากทำให้ Recovery แพง Checkpoint ตัด Lineage ด้วยการบันทึกข้อมูลที่เชื่อถือได้

sc.setCheckpointDir("/home/jovyan/data/rdd-checkpoints")
rdd = sc.parallelize(range(1_000_000), 16)
for _ in range(20):
    rdd = rdd.map(lambda x: (x * 17 + 3) % 1000003)

print(rdd.toDebugString().decode())
rdd.checkpoint()
rdd.count()  # materialize checkpoint
print(rdd.toDebugString().decode())

Checkpoint ต่างจาก Cache: Cache เร่ง Reuse และอาจสูญหายได้; Checkpoint ตัด Dependency เพื่อ Recovery แต่มี I/O เพิ่ม บางงานใช้ทั้งสองร่วมกัน

12กับดัก collect() และ Driver

collect() ดึงทุก Record กลับ Driver งานกระจายอาจล้มตรงปลายเมื่อข้อมูลทั้งหมดอัดเข้าเครื่องเดียว ระหว่างสำรวจใช้ take, takeOrdered, Aggregate หรือเขียน Storage

# ปลอดภัยกว่าสำหรับดูตัวอย่าง
print(counts.takeOrdered(10, key=lambda kv: -kv[1]))

# หลีกเลี่ยงกับผลลัพธ์ใหญ่
# everything = huge_rdd.collect()

13Mini Experiment: วัด Partition, Cache และ Shuffle

ออกแบบตารางทดลอง 3 ปัจจัย: Partition 4/8/16, Cache yes/no และ Aggregation reduceByKey/groupByKey รันซ้ำอย่างน้อย 3 ครั้ง บันทึก Median, Stage Count, Shuffle Read/Write และข้อสังเกต

ตัวแปรค่าทดลองสมมุติฐานก่อนรัน
Partitions4, 8, 16น้อยไปใช้ Core ไม่เต็ม มากไปเพิ่ม Scheduling
Cacheไม่/ใช้ซ้ำ 2 Actionรอบแรกแพงขึ้นเล็กน้อย รอบสองเร็วขึ้น
Aggregationreduce/groupReduce ส่งข้อมูลน้อยกว่า

รายงานผลที่ขัดสมมุติฐานได้ เช่นข้อมูลเล็กจน Overhead กลบความต่าง นั่นเป็นผลการทดลอง ไม่ใช่ข้อผิดพลาดที่ต้องซ่อน

14สิ่งที่ต้องส่งและสรุปขั้นตอนถัดไป

สรุปและขั้นตอนถัดไป: Lab นี้มองข้อมูลเป็น Record ที่ Spark ยังไม่เข้าใจความหมายคอลัมน์ Lab 2 จะยกระดับเป็น DataFrame/SQL ให้ Catalyst เห็น Schema และ Algebra แล้วเราจะตรวจว่า Optimization ใดเกิดจริงจาก explain() ไม่ใช่สรุปว่า SQL เร็วเพราะเขียนสั้นกว่า

แบบฝึกหัดก่อนไป Lab 2
  1. แก้ word count ให้กรองคำที่สั้นกว่า 4 ตัวอักษรออกก่อนนับ (ใช้ filter)
  2. เรียงผลลัพธ์ word count จากมากไปน้อยด้วย sortBy แล้วแสดง 5 คำที่พบบ่อยที่สุด
  3. ลองเปลี่ยนขนาด ["..."] * 50000 เป็น 500000 แล้วสังเกตว่าผลต่างระหว่าง cache/ไม่ cache ชัดเจนขึ้นไหม

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 Core และ RDD

การทดลอง 1: อ่าน Lineage

ลงมือทำ: ต่อ map, filter และ reduceByKey แล้วอ่าน toDebugString ก่อนสั่ง Action ก่อนรันให้เขียนผลที่คาดไว้ แล้วรันอย่างน้อยสามครั้งหากกำลังเปรียบเทียบเวลา เพื่อไม่ให้การเริ่ม JVM, Cache หรือ Background process เพียงครั้งเดียวหลอกเรา

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

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

การทดลอง 2: ลดก่อนส่ง

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

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

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

การทดลอง 3: สำรวจ Skew

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

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

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

การทดลอง 4: ใช้ Cache อย่างมีเหตุผล

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

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

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

การทดลอง 5: หลีกเลี่ยง Driver ล้น

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

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

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

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 Core และ RDD ตัดสินใจอย่างไรเมื่อข้อมูล งาน และทรัพยากรถูกกระจายออกจากกัน เมื่อทำเสร็จแล้วควรตอบได้สามเรื่อง: ระบบทำอะไร หลักฐานใดแสดงว่ามันทำเช่นนั้น และเงื่อนไขใดทำให้พฤติกรรมเปลี่ยน

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

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 Core และ RDD โดยห้ามเริ่มจากชื่อ Class หรือ API ให้เริ่มจากปัญหาที่ระบบกำลังแก้ ใช้ภาพหนึ่งภาพและตัวอย่างข้อมูลไม่เกินสิบแถว ผู้ฟังมีหน้าที่ถามว่า “รู้ได้อย่างไร” และ “ถ้าข้อมูลใหญ่ขึ้นจะเกิดอะไร” วิธีนี้ช่วยเปิดจุดที่เราจำคำสั่งได้แต่ยังเชื่อมเหตุผลไม่ครบ

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