Apache Spark — 資料工程面試深入解析
範圍 — 資料工程面試會探的 Apache Spark 全部內容:執行模型、API 與最佳化器、shuffle、分區、join 與資料傾斜、快取、記憶體與序列化、PySpark 細節、寫入、Structured Streaming 與它的 watermark、調校、除錯與常見故障 —— 以 Spark 3.5 為準,並標出 Spark 4.0 的差異。 另見:
../faq_DE_interview.md— 橫跨所有 DE 主題的總覽與考前檢查清單;./stream_pipeline.md— 串流的深入內容;./batch_pipeline.md— Spark job 在管線中的位置;./hadoop.md— HDFS、YARN 與 MapReduce;../spark/faq_spark_hadoop.md— 題庫:可以直接說出口的簡短答案、排錯演練、管線設計題與讀書計畫。
每一題都先給你會說出口的那句話,再講機制,然後是面試官下一步會追問的取捨。 除非該節另有說明,設定名稱與預設值都以 Spark 3.5 為準。
0) 這份文件如何搭配題庫使用
這是深入解析:每個答案背後的機制。題庫 faq_spark_hadoop.md
放的是說出口的簡短答案、練習與讀書計畫,它的複習順序
把兩份文件一步步對齊。
- 照那份複習順序讀,而不是 §1 → §16。本文件的章節依 Spark 的構造排列,不是面試追問的順序。
- 題庫裡的答案經不起一句「為什麼?」時,就來這裡。題庫的每一節都連到這裡對應的章節。
- §10(PySpark)與 §11(寫入與 table format)在題庫裡沒有對應章節,準備資料工程面試時要讀。
- §16-1 的快問快答只是複習;完整的模擬題庫(含難度分級)是題庫的 §14-2。
1) Spark 是什麼、用在哪裡 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
1-1) 一段話的答案
Spark 是分散式、以記憶體優先的運算引擎:你描述對資料集的運算,Spark 把它轉成由平行 task 組成的計畫,在叢集上執行,並從 lineage 重新計算遺失的部分。
- 統一引擎 —— 批次(Spark SQL / DataFrame)、串流(Structured Streaming)、 機器學習(MLlib)與圖運算共用同一個引擎與同一套 API。
- 不綁定儲存 —— 讀寫 HDFS、S3 / GCS / ADLS、JDBC、Kafka、Delta、Iceberg、 Hudi、Parquet、ORC、Avro、JSON、CSV。
- 語言 —— Scala、Java、Python(PySpark)、SQL 與 R;Spark Connect(3.4+)新增一個 輕量的 client,透過 gRPC 與遠端的 driver 溝通。
1-2) Spark vs MapReduce
Spark 把中間結果留在記憶體,並規劃整個由 stage 組成的 DAG;MapReduce 每個 job 之間 都寫回 HDFS,所以多步驟與反覆迭代的工作在它上面慢很多。
| MapReduce | Spark | |
|---|---|---|
| 中間資料 | job 之間寫到 HDFS | stage 之間放在記憶體 / 本機磁碟 |
| 計畫 | 每個 job 一個 map + 一個 reduce | 由多個 stage 組成的 DAG,整體最佳化 |
| API | 低階的 map / reduce | DataFrame、SQL、RDD |
| 反覆迭代的 job(ML、圖) | 每次迭代都重讀輸入 | 快取一次,在記憶體中迭代 |
| 容錯 | 從 HDFS 輸入重跑失敗的 task | 從 lineage 重新計算 |
| 串流 | 沒有 | Structured Streaming |
Spark 的 shuffle 仍然經過本機磁碟,所以「in-memory」不代表「從不碰磁碟」。
1-3) Spark vs 其他選擇
| 引擎 | 何時選它 |
|---|---|
| Spark | 大型批次 ETL、SQL 與程式碼混用、ML 管線、批次與 micro-batch 串流共用一個引擎 |
| Flink | 低延遲、逐筆處理且狀態很大的串流 |
| Trino / Presto | 在 data lake 上做互動式 SQL,沒有長時間執行的 ETL |
| 雲端資料倉儲(BigQuery、Snowflake) | 只用 SQL 轉換,不需要自己維運叢集 |
| DuckDB / Polars / pandas | 單機放得下的資料 —— 通常更快也簡單得多 |
誠實的面試說法:「單機放得下,就不要用 Spark。」
2) 架構與執行模型 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
2-1) Driver、cluster manager、executor
一個 driver 規劃 job,許多 executor 平行執行,cluster manager 負責分配機器。
| 元件 | 角色 |
|---|---|
| Driver | 執行你的 main(),持有 SparkSession,建立 DAG、排程 task、收集結果 |
| Cluster manager | 分配資源 —— YARN、Kubernetes、Standalone(Mesos 已棄用) |
| Executor | worker 節點上的一個 JVM;以執行緒執行 task、快取資料、寫 shuffle 檔 |
| Task | 最小工作單位:一個 task 處理一個 stage 的一個 partition |
┌──────────── Driver ────────────┐
│ SparkSession → DAG → Stages │
│ DAGScheduler / TaskScheduler │
└──────┬──────────────────┬───────┘
│ request resources │
┌──────▼──────┐ │ send tasks
│Cluster Mgr │ │
│YARN / K8s │ │
└──────┬──────┘ │
┌──────────┴───────┬───────────▼──────┐
┌────▼─────┐ ┌─────▼────┐ ┌─────▼────┐
│Executor 1│ │Executor 2│ ... │Executor N│
│task task │ │task task │ │task task │
│ cache │ │ cache │ │ cache │
└──────────┘ └──────────┘ └──────────┘
- 同時可執行的 task 槽位 = executors ×
spark.executor.cores。一個 200 個 partition 的 stage 跑在 50 個槽位上(例如 10 個 executor × 5 個 core),要分 4 波執行;實際吞吐量 仍取決於工作負載與 I/O。 - Driver 是應用程式的單點故障:它掛了,job 就掛了(YARN 可以用
spark.yarn.maxAppAttempts重試整個應用程式)。
2-2) 部署模式與 spark-submit
client 模式下 driver 跑在你提交的那台機器;cluster 模式下它跑在叢集內 —— 正式環境
的 job 用 cluster,這樣筆電斷線也殺不掉它們。
spark-submit \
--master yarn \
--deploy-mode cluster \
--num-executors 10 \
--executor-cores 5 \
--executor-memory 16g \
--driver-memory 4g \
--conf spark.sql.shuffle.partitions=400 \
--conf spark.sql.adaptive.enabled=true \
etl_daily_orders.py --run-date 2026-11-01
| 模式 | Driver 跑在 | 用於 |
|---|---|---|
client |
提交的那台機器 | notebook、互動式 shell、除錯 |
cluster |
叢集內的某個節點 | 排程的正式環境 job |
local[*] |
一個 JVM,每個 core 一個執行緒 | 單元測試、小實驗 |
2-3) Job → stage → task,以及兩個排程器
一個 action 產生一個 job;DAGScheduler 在 shuffle 邊界把 job 切成 stage; TaskScheduler 為每個 stage 的每個 partition 送一個 task 給 executor。
Job = everything one action needs
Stage = a run of narrow transformations between two shuffles
Task = one partition of one stage, run on one core
df.read ─ filter ─ withColumn │ shuffle │ groupBy().agg ─ write
└──────── Stage 0 ───────────┘ └──── Stage 1 ─────┘
200 input partitions → 200 tasks shuffle partitions → N tasks
- DAGScheduler —— 建立 stage、追蹤哪些 shuffle 輸出存在,shuffle 檔遺失時重新提交 產生它的 stage。
- TaskScheduler —— 安排 task 的位置,偏好 data locality(
PROCESS_LOCAL→NODE_LOCAL→RACK_LOCAL→ANY,每一級最多等spark.locality.wait,預設 3 秒), 並把失敗的 task 重試最多spark.task.maxFailures次(預設 4)。 - 彼此沒有依賴的 stage 可以同時執行。
2-4) 容錯:lineage、重試與 speculation
Spark 不複製算好的資料;它記得每個 partition 是怎麼建出來的,遺失時就從上游重新計算。
- Lineage —— 每個 RDD / DataFrame 都知道它的上游與套用的函式。失去一個 executor, 它快取的 partition 會從 lineage 重算;失去它的 shuffle 檔,產生它們的 map stage 會重跑 (除非外部 shuffle service 仍在提供這些檔案)。
- Task 重試 —— 失敗的 task 會在另一個 executor 上重試;失敗超過
spark.task.maxFailures次,stage 與 job 就會失敗。 - Speculation(
spark.speculation=true)—— 對異常慢的 task 啟動一個複本,取先完成的 那個。它對壞掉的節點有幫助;對資料傾斜沒用,因為複本拿到的是同一個過大的 partition。 它也代表 task 裡的副作用可能執行兩次。 - Checkpointing(
df.checkpoint(),搭配spark.sparkContext.setCheckpointDir(...)) 把資料寫到可靠儲存並截斷 lineage —— 適合計畫很長的反覆迭代 job。
2-5) Dynamic allocation
Dynamic allocation 在 task 排隊時增加 executor、閒置時釋放,讓一個 job 不必在整個執行 期間佔著固定數量的機器。
spark.dynamicAllocation.enabled=true,以minExecutors/maxExecutors為上下限。- 釋放 executor 會丟掉它的 shuffle 檔,所以需要外部 shuffle service(YARN)或
spark.dynamicAllocation.shuffleTracking.enabled=true(保留仍持有有效 shuffle 資料的 executor,在 Kubernetes 上通常這樣做)。 - 取捨:叢集利用率更好,換來較慢的爬升速度,以及 stage 之間 executor 的反覆增減。
3) API:RDD、DataFrame、Dataset 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
3-1) 比較
用 DataFrame:它帶有 schema,所以 Catalyst 能最佳化它、Tungsten 能把它存得很緊湊; RDD 是一堆不透明的物件,經過 Spark 看不進去的函式處理。
| RDD | DataFrame | Dataset | |
|---|---|---|---|
| Schema | 沒有 | 有(具名、有型別的欄位所組成的列) | 有,型別化的物件(Dataset[T]) |
| 最佳化器 | 沒有 | Catalyst | Catalyst(lambda 對它不透明) |
| 記憶體格式 | JVM 物件 | Tungsten 二進位列 | Tungsten 二進位列 + encoder |
| 語言 | 全部 | 全部 | 只有 Scala / Java |
| 型別錯誤何時發現 | 執行期 | 執行期(欄位名稱) | 編譯期 |
| 何時用 | 需要底層控制、非結構化資料 | 幾乎所有情況 | 想要型別化物件的 Scala 程式碼 |
在 Python 與 SQL 裡只有 DataFrame;在 Scala 裡,DataFrame 字面上就是 Dataset[Row]。
3-2) Transformation vs action,以及 lazy evaluation
Transformation 是 lazy 的,只是加進計畫;action 才執行計畫 —— 這正是 Spark 能在做任何 工作之前最佳化整個計畫的原因。
| Transformation | Action | |
|---|---|---|
| 例子 | select、filter、withColumn、join、groupBy().agg、repartition |
count、collect、show、take、write.save、foreach |
| 回傳 | 新的 DataFrame | 回到 driver 的值,或寫出去的資料 |
| 會執行嗎? | 不會 | 會 —— 提交一個或多個 job |
# python — nothing runs until the last line
from pyspark.sql import functions as F
orders = spark.read.parquet("s3://lake/orders/") # reads only the footer / schema
big = orders.filter(F.col("amount") > 100) # lazy
by_day = big.groupBy("order_date").agg(F.sum("amount")) # lazy
by_day.write.mode("overwrite").parquet("s3://lake/agg/") # action: the job runs here
常見的坑:對同一個沒快取的 DataFrame 呼叫兩個 action,它會從來源被計算兩次。
3-3) 窄依賴 vs 寬依賴
窄 transformation 的每個輸出 partition 只需要一個輸入 partition,留在同一個 stage; 寬 transformation 需要來自很多 partition 的資料,所以會強制 shuffle 並開始新的 stage。
| 窄(不 shuffle) | 寬(shuffle) |
|---|---|
select、filter、withColumn、map |
groupBy、distinct、orderBy |
union |
join(除非 broadcast 或已經同分區) |
coalesce(合併 partition) |
repartition |
| broadcast hash join(大表那邊) | 帶 partitionBy 的 window function |
3-4) RDD 時代至今仍會被問的題目
reduceByKey 在 shuffle 前先在 map 端合併;groupByKey 把每個值都送過網路再分組 ——
所以做彙總時該用 reduceByKey(或 DataFrame 的 groupBy().agg)。
# python — word count, the RDD way
lines = spark.sparkContext.textFile("s3://lake/raw/books/*.txt")
counts = (lines.flatMap(lambda line: line.split())
.map(lambda word: (word, 1))
.reduceByKey(lambda a, b: a + b)) # map-side combine, then shuffle
top = counts.takeOrdered(10, key=lambda kv: -kv[1])
mapvsflatMap—— 每個輸入一個輸出 vs 每個輸入零到多個輸出。mapPartitions—— 每個 partition 執行一次;每個 partition 開一條 DB 連線,而不是每列一條。- Broadcast variable —— 唯讀的值,每個 executor 只送一次(例如一個查找用的 dict)。
- Accumulator —— 只寫的計數器,在 driver 上彙總。在 action 裡的更新保證只套用 一次;在 transformation 裡的更新,當 task 或 stage 重跑時可能被套用不只一次, 所以不要在那裡用它做精確計數。
4) Catalyst、Tungsten 與 AQE 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
4-1) Catalyst:查詢怎麼變成計畫
Catalyst 把你的 DataFrame 或 SQL 轉成邏輯計畫,用規則改寫它,挑一個實體計畫, 再為它生成程式碼。
SQL / DataFrame
│ parse
▼
Unresolved logical plan ── analysis (catalog: tables, columns, types) ──► Analysed plan
│ rule-based optimisation
▼
Optimised logical plan (predicate pushdown, column pruning, constant folding,
│ join reordering with CBO stats, subquery rewrites)
│ physical planning (strategies: which join, which aggregate)
▼
Physical plan ──► whole-stage code generation ──► RDD of InternalRow, run on executors
- Predicate pushdown ——
filter被往下推到讀取端,讓 Parquet / ORC 依 min / max 統計跳過 row group,JDBC 來源則把它推進WHERE。 - Column pruning —— 欄式檔案只讀被引用到的欄位。
- Partition pruning —— 在分區欄位上的過濾會跳過整個目錄; dynamic partition pruning(3.0+)在執行期從 join 的另一邊做到這件事。
- CBO(
spark.sql.cbo.enabled)用ANALYZE TABLE ... COMPUTE STATISTICS產生的表格 統計來決定 join 順序;沒有統計時,Spark 從檔案大小估計。
4-2) Tungsten:執行引擎
Tungsten 用緊湊的二進位格式存放列、自己管理記憶體,並把多個運算子融合成生成的 Java 程式碼,讓一個 stage 跑起來像一個手寫的迴圈。
- UnsafeRow —— 二進位列格式,脫離 JVM 物件模型:記憶體更少,幾乎沒有 GC 壓力。
- Whole-stage code generation ——
filter → project → aggregate變成一個生成的函式; 在explain()輸出中,這些運算子會標上*(n)。 - 為彙總與 join 設計的 cache-aware 排序與雜湊。
- 會讓它失效的:Python UDF、Dataset 上的 Scala lambda,以及非常寬的 schema
(超過
spark.sql.codegen.maxFields就不做 code generation)。
4-3) Adaptive Query Execution(AQE)
AQE 在 stage 之間用 shuffle 輸出的真實大小重新最佳化計畫,而不是相信 job 開始前所做的 估計。Spark 3.2 起預設開啟。
| AQE 功能 | 做什麼 | 關鍵設定 |
|---|---|---|
| 合併 shuffle partition | 把 shuffle 後許多小 partition 合併成較少、達目標大小的 partition | spark.sql.adaptive.coalescePartitions.enabled、advisoryPartitionSizeInBytes(64 MB) |
| 切換 join 策略 | 某一邊實際上很小時,把 sort-merge join 改成 broadcast join | spark.sql.adaptive.autoBroadcastJoinThreshold |
| Skew join | 把過大的 partition 切成多個 task,並複製另一邊對應的 partition | spark.sql.adaptive.skewJoin.enabled、skewedPartitionFactor、skewedPartitionThresholdInBytes |
- AQE 只在 shuffle 邊界起作用 —— 沒有 exchange 的計畫它修不了。
- 預設情況下,一個 partition 要同時大於
skewedPartitionFactor× partition 大小中位數 與skewedPartitionThresholdInBytes(256 MB),才算傾斜。
4-4) 讀懂實體計畫
df.explain("formatted") 顯示實體計畫:由下往上讀,找讀取端的過濾條件、每一個
Exchange(一次 shuffle),以及 join 運算子。
# python
(spark.table("sales").filter("sale_date = '2026-11-01'")
.join(spark.table("stores"), "store_id")
.groupBy("region").sum("amount")
.explain(mode="formatted"))
| 計畫節點 | 意義 |
|---|---|
FileScan parquet ... PushedFilters: [...]、PartitionFilters: [...] |
pushdown 與 partition pruning 有生效 |
Exchange hashpartitioning(key, 200) |
依 key shuffle 成 200 個 partition |
BroadcastExchange + BroadcastHashJoin |
小的那邊被 broadcast;大的那邊不 shuffle |
SortMergeJoin,兩邊下面各有 Sort 與 Exchange |
兩邊都 shuffle 並排序 |
HashAggregate(partial)→ Exchange → HashAggregate(final) |
帶 map 端合併的兩階段彙總 |
AdaptiveSparkPlan isFinalPlan=false |
AQE 會在執行期修改這個計畫 —— 到 SQL 分頁看最終版本 |
*(2) 前綴 |
這個運算子在第 2 個 whole-stage codegen stage 內 |
explain 模式:simple、extended(四種計畫全部)、codegen、cost、formatted。
5) Shuffle 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
5-1) Shuffle 實際上做了什麼
Shuffle 重新分配資料列,讓相同 key 的列都落在同一個 partition:每個 map task 把輸出依 目標 partition 排序寫到本機磁碟,每個 reduce task 再透過網路從每個 map task 抓取自己那一份。
Map side (stage N) Reduce side (stage N+1)
task 0: rows → partition by hash(key) task 0 fetches block 0 from every map output
→ sort / spill → data file task 1 fetches block 1 from every map output
+ index file (one per task) ...
task 1: ... then aggregate / join the fetched rows
M map tasks × R reduce partitions = up to M × R blocks fetched
- Spark 使用 sort-based shuffle:每個 map task 一個資料檔與一個索引檔。
- 成本:序列化、寫磁碟、網路傳輸、讀磁碟、反序列化 —— 通常是 job 中最昂貴的部分,也是 大多數 spill 與 OOM 發生的地方。§9-5 會跟著一筆資料走完整個過程。
- 外部 shuffle service 在寫出 shuffle 檔的 executor 消失後,仍繼續提供這些檔案 (在 YARN 上做 dynamic allocation 時需要)。
5-2) 怎麼避免或縮小 shuffle
- 儘早過濾與投影,讓進到 exchange 的資料更少(Catalyst 會做很多,但中間擋了一個 UDF 就做不了)。
- Broadcast join 中小的那一邊。
- 先彙總 ——
groupBy().agg本來就會在 map 端合併;避免先collect_list再彙總。 - 對經常以同一個 key join 的表做 bucketing(見 6-4 節)。
- 不要「以防萬一」就 repartition —— 每一次
repartition都是完整的 shuffle。 - 壓縮 shuffle 資料(
spark.shuffle.compress,預設開啟),RDD 工作負載用 Kryo 序列化。 Kryo 不會改變 DataFrame 的 shuffle(§9-4)。
5-3) spark.sql.shuffle.partitions
它決定每次 DataFrame shuffle 產生多少 partition —— 預設 200,對很小和很大的 job 都不對。
| 症狀 | 可能原因 | 解法 |
|---|---|---|
| 數千個 task 都在幾毫秒內結束 | 相對於資料量 partition 太多 | 調低,或讓 AQE 合併 |
| 少數幾個 task 往磁碟 spill 好幾 GB | partition 太少 | 調高,讓每個約 100–200 MB |
| 某一個 task 遠慢於其他 | 是傾斜,不是數量問題 | 見 7-3 節 |
經驗法則:shuffle 總大小 ÷ ~128 MB,且至少是 core 數的 2–3 倍。AQE 開啟時可以設得寬鬆 一點,讓合併機制把小 partition 併起來。
6) 分區與檔案佈局 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
6-1) 記憶體中的 partition
Partition 是一個 task 處理的那一塊資料;partition 的數量就是一個 stage 的平行度。
- 讀取時,檔案來源把輸入切成最大
spark.sql.files.maxPartitionBytes(預設 128 MB) 的 partition;不可切分的檔案(gzip 壓縮的 CSV)不管多大都只有一個 partition。 - Shuffle 之後,數量是
spark.sql.shuffle.partitions(或 AQE 合併後的數量)。 df.rdd.getNumPartitions()告訴你目前的數量。
6-2) repartition vs coalesce
repartition(n) 會做完整 shuffle,可增可減;coalesce(n) 只合併既有的 partition、
不做 shuffle,所以只能減少。
repartition(n) / repartition(n, col) |
coalesce(n) |
|
|---|---|---|
| Shuffle | 有 | 沒有 |
| 結果 | 大小平均的 partition(round-robin 或依 col 雜湊) |
可能不平均 |
| 用途 | 提高平行度;分區寫入前把同一個 key 聚在一起 | 在 job 最後減少輸出檔數 |
| 陷阱 | 要付一次完整 shuffle 的成本 | coalesce(1) 會把整個上游 stage 壓到一個 task 上 |
repartitionByRange(n, col) 透過抽樣做範圍分區 —— 適合在寫出排序好的輸出之前使用。
6-3) 寫入時的 partitionBy,以及小檔案問題
df.write.partitionBy("dt") 是讀取時給 pruning 用的目錄佈局,和記憶體中的 partition
不是同一回事 —— 而且每個記憶體 partition 都可能在它持有的每個目錄值下各寫一個檔案。
# python — one tidy set of files per date: shuffle by the partition column first
(df.repartition("event_date")
.write.mode("overwrite")
.partitionBy("event_date")
.parquet("s3://lake/events/"))
- 沒有那個
repartition,200 個 task × 30 個日期可能寫出 6,000 個小檔。 - 小檔案拖累每一個讀取者:每個檔案一個 task、讀檔尾、物件儲存列目錄很慢、在 HDFS 上 佔 NameNode 記憶體。
- 解法:寫入前
coalesce/repartition、AQE 合併、壓實 job(DeltaOPTIMIZE、 Icebergrewrite_data_files)。maxRecordsPerFile只限制單一檔案的大小;它從來不會合併檔案。 - 用查詢會過濾的低基數欄位分區(日期)。用
user_id分區會產生數百萬個目錄。
6-4) Bucketing
每天都以同一個 key 跑的 join,就把兩張表都依這個 key、用相同的 bucket 數做 bucketing。
Spark 會在同一個 task 裡讀兩張表的第 i 個 bucket,join 兩側的 Exchange 也就都不見了。
這要用 EXPLAIN FORMATTED 證明,寫了 DDL 不算證據。
它省掉的是執行期對兩側做的 hash partitioning(§5-1): 每跑一次就要序列化、寫出、傳送、讀回、反序列化一次。Bucketing 在寫入表時付一次差不多的成本, 之後每次 join 都沿用這個佈局。
寫出 bucketed 表
-- spark sql — CLUSTERED BY ... INTO n BUCKETS is bucketing, not clustering
CREATE TABLE customers_bucketed
USING PARQUET
CLUSTERED BY (customer_id) SORTED BY (customer_id) INTO 64 BUCKETS
AS SELECT /*+ REPARTITION(64, customer_id) */ customer_id, customer_name
FROM customers;
CREATE TABLE orders_bucketed
USING PARQUET
PARTITIONED BY (order_date) -- prune by day, bucket within each day
CLUSTERED BY (customer_id) SORTED BY (customer_id) INTO 64 BUCKETS
AS SELECT /*+ REPARTITION(64, customer_id) */ order_id, customer_id, amount, order_date
FROM orders;
# python — the DataFrame form; bucketBy needs saveAsTable, because the layout lives in the catalog
(orders.repartition(64, "customer_id") # task i holds exactly bucket i: one file per bucket
.write.bucketBy(64, "customer_id").sortBy("customer_id")
.mode("overwrite").saveAsTable("analytics.orders_bucketed"))
- 寫入前先 repartition 成 bucket 數。 不這麼做的話,每個寫入 task 都會往每個 bucket 寫一個檔案。 本機測試裡 4 個 task、64 個 bucket 寫出了 256 個檔案,加上 repartition 後是 64 個。 對同一個欄位 repartition 成同樣的分區數,會讓每個 bucket 只落在一個 task 裡,因為兩者用的是同一個 hash。
- 以表的方式讀它,用
spark.table(...)或 SQL 表名。spark.read.parquet(path)只看到普通檔案, 因為 bucket 的規格存在 metastore 裡,不在檔案裡。 - Partition 和 bucketing 可以並用。 每個
order_date目錄各有自己的 64 個 bucket, join 會在 task i 讀入所有被選中日期的第 i 個 bucket,所以只修剪到兩天也一樣不用 shuffle。 - Delta Lake 表不支援
bucketBy。在 lakehouse 上,對應的做法是 Iceberg 的bucket(n, col)partition transform(見下方的比較表)。
哪些條件要對上,否則 Exchange 會回來
以下每一條都在 Spark 4.0.1 上驗證過,測試時關掉了 broadcast join,以強制走 sort-merge join:
| 條件 | 不成立時會發生什麼 |
|---|---|
| Join key 就是 bucket 欄位 | 用別的欄位 join,或在兩側都把 key 包起來(CAST(customer_id AS STRING)),兩側都會被 shuffle。Scan 上會顯示 Bucketed: false (disabled by query planner) |
| 兩側的 key 型別相同 | INT key 對 BIGINT key 時,INT 那一側會被插入隱式轉型。當 Spark 能保留另一側的 bucketed 佈局時,會只 shuffle 被轉型的那一側來對上它(Spark 4.0.1 的 sort-merge join 就是如此)。不要假設每種 join 都這樣 |
| Bucket 數相同 | 64 對 128,或 64 對 16:Spark 只 shuffle bucket 數較少的那一側,讓它對上另一側,另一側保持 bucketed。這是一次 shuffle 而不是兩次,但也不是零 |
| ……或其中一個數是另一個的倍數 | 開啟 spark.sql.bucketing.coalesceBucketsInJoin.enabled=true(預設關閉),且比例在 spark.sql.bucketing.coalesceBucketsInJoin.maxBucketRatio(預設 4)以內時,Spark 改為合併較多那一側的 bucket。Scan 會顯示 SelectedBucketsCount: 128 out of 128 (Coalesced to 64),而且沒有 Exchange。這適用於 sort-merge join。在 shuffled-hash join 中,若較多 bucket 的一側是 build side,Spark 不會合併它,較少的一側仍會被 shuffle |
| Join 是 sort-merge 或 shuffled-hash join | 如果有一側小於 spark.sql.autoBroadcastJoinThreshold,Spark 會 broadcast 它,不使用 bucket。這是正確的選擇,因為 broadcast 更便宜 |
| 兩張表都由 Spark 做 bucketing | Spark 用 Murmur3 做 hash,Hive 用自己的 hash,所以 Hive bucketed 的表和 Spark 的對不上 |
spark.sql.sources.bucketing.enabled 必須維持 true(預設值)。
spark.sql.sources.bucketing.autoBucketedScan.enabled(同樣預設開啟)會在查詢用不到 bucket 時,
把 bucketed scan 關掉,disabled by query planner 這段文字就是這麼來的。
用 EXPLAIN FORMATTED 證明
-- before: plain tables — both sides are hash-partitioned at run time
SortMergeJoin Inner
:- Sort
: +- Exchange hashpartitioning(customer_id, 200)
: +- Scan parquet customers
+- Sort
+- Exchange hashpartitioning(customer_id, 200)
+- Scan parquet orders
-- after: both bucketed 64 / 64 on customer_id — nothing moves before the join
HashAggregate <- GROUP BY customer_id, customer_name: no Exchange either
+- HashAggregate
+- SortMergeJoin Inner
:- Sort
: +- Scan parquet customers_bucketed Bucketed: true, SelectedBucketsCount: 64 out of 64
+- Sort
+- Scan parquet orders_bucketed Bucketed: true, SelectedBucketsCount: 64 out of 64
- 要證明的是「join 到任一側 scan 之間都沒有
Exchange」。 沿著兩條輸入分支往下,經過Sort一路看到 scan。即使在 bucketing 之前的計畫裡,join 的直接子節點也是Sort,Exchange在它下面。 Join 上方的 Exchange 是另一個問題。 - 兩個 scan 都顯示
Bucketed: true本身不是證據。 顯示Bucketed: false (disabled by query planner)只表示那一側的 bucket 沒被使用:broadcast join 的 streamed side 也會這樣顯示,卻沒有被 shuffle。 而在autoBucketedScan.enabled=false時,即使正上方就有 Exchange,scan 仍會顯示Bucketed: true,所以要以分支為準。 Sort還在。 Spark 3+ 預設不依賴每個 bucket 內的排序,因為一個 bucket 可能有好幾個檔案。 所以移除 shuffle 的不是SORTED BY。- 包含 bucket key 的
GROUP BY會沿用 join 的分區方式,所以上面的計畫在 join 之後也沒有 Exchange。改成GROUP BY order_date就會在 join 上方多一個 Exchange,因為資料列必須依另一個 key 重新分組。
Bucketing 與 clustering:同一個字,不同的東西
| 語法 | 做什麼 | 能省掉 join 的 shuffle 嗎? |
|---|---|---|
Hive / Spark CLUSTERED BY (k) INTO n BUCKETS |
bucketing:寫表時把資料列 hash 到每個 partition 的 n 個檔案 | 可以,前提是上面的條件成立 |
Delta CLUSTER BY (k)(liquid clustering)、OPTIMIZE … ZORDER BY |
把相近的值放進同一批檔案,讓過濾條件能略過檔案 | 不能。讀取變便宜了,join 照樣 shuffle |
Iceberg PARTITIONED BY (bucket(n, k)) |
以表 partition 的形式儲存的 hash 分區 | 可以,透過 storage-partitioned join(Spark 3.3+):spark.sql.sources.v2.bucketing.enabled=true 加上 Iceberg 的 spark.sql.iceberg.planning.preserve-data-grouping=true,兩張表要用相同的 transform。如果兩張表擁有的 bucket 值集合不同,Spark 3.4 與 3.5 還需要 spark.sql.sources.v2.bucketing.pushPartValues.enabled=true(這兩版預設關閉,Spark 4.0 預設開啟)。Spark 3.3 沒有這個設定,兩側的 partition 值必須相同 |
Spark SQL SELECT … CLUSTER BY k |
對這個查詢的輸出做 DISTRIBUTE BY k SORT BY k |
不能,因為它本身就是一次 shuffle |
Clustering 讓過濾讀得更少,bucketing 讓 join 搬得更少。 兩者都不能取代對方。
各引擎的實體 clustering key 見
./schema_design.md。
選 bucket 數,以及維持佈局
- Bucketed join 剛好跑 n 個 task。 2 TB 的 orders 分成 64 個 bucket,每個 task 要處理 32 GB, 遠遠太多,而且第 64 個以外的 core 都閒著。n 要讓較大那張表的一個 bucket 成為合理的 task (幾百 MB 到一兩 GB)。例如 1,024 個 bucket,每個 task 大約是 2 GB 的 orders, 加上 100 GB customers 表的約 100 MB。有每日 partition 時,依一次 join 實際讀取的天數來決定 n。
- 用 2 的次方。 這樣日後表大小翻倍時,仍能使用 bucket 合併。
- 改 n 就要重寫整張表,所以要依預期的成長量來設定。
- Append 後表仍是 bucketed,但檔案會變多。 測試中一次小小的
INSERT INTO, 就讓每個寫入 task 在每個 bucket 多寫一個檔案(64 個檔案變成 320 個)。 要定期用同樣的REPARTITION重寫整張表來壓實,或讓每個每日 partition 只寫一次。 - 只有重複的 join 才划得來。 只跑一次的 join,寫入成本大約等於它省下的 shuffle。
Bucketing 解決不了的事:資料傾斜
熱門的 customer_id 仍然會 hash 到同一個 bucket,於是一個 task 做了大部分的工作。
Bucketed join 面對傾斜甚至比 shuffled join 更糟:AQE 的 skew-join 處理切的是 shuffle
partition(§4-3),而 bucketed join 沒有 shuffle partition,
AQE 也就沒有東西可切。可以把熱門 key 分開處理(過濾出來,改用 broadcast join),
或只在這一個查詢關掉 bucketing(spark.sql.sources.bucketing.enabled=false),讓 AQE 能切分它。
見 §7-3。
聽到這些說法時要更正
- 「網路成本降到零。」 消失的是 shuffle。放在 S3 或其他物件儲存上的 bucket 檔案仍然是透過網路讀取, 而且 Spark 不保證兩張表的第 i 個 bucket 在同一個節點上。 在同一個 task 裡讀入兩邊的第 i 個 bucket 檔案就夠了。
- 「成功的樣子是沒有 Exchange 的
BroadcastHashJoin。」 Broadcast 本身就有BroadcastExchange, 而且走 broadcast 時 Spark 不會使用 bucket。 - 「Bucket 數不同,這個優化就失效。」 只有 bucket 數較少的一側會被 shuffle。開啟合併後,在 sort-merge join 中兩側都不會,前提是一個數能整除另一個且比例在
maxBucketRatio以內:64 對 128 可以合併,64 對 100 不行。 - 「設
autoBroadcastJoinThreshold = -1,bucketing 才會生效。」 這只是在小的示範表上強制走 sort-merge join。正式環境中,小到可以 broadcast 的一側就應該 broadcast。
口頭怎麼說
For a join that runs every day on
customer_id, I would bucket both tables bycustomer_idwith the same power-of-two bucket count, written with a repartition so each bucket is one file. Then Spark joins bucket i to bucket i without shuffling either side. I would confirm it inEXPLAIN FORMATTED: noExchangeon either branch of the join, andBucketed: trueon both scans. It costs extra at write time and the bucket count is fixed, so it is only worth doing for a recurring join. It also makes skew harder, because AQE cannot split a bucket. On Delta, liquid clustering is not a substitute. It improves file skipping but still shuffles, and the lakehouse equivalent is an Iceberg bucket transform with a storage-partitioned join.
7) Join 與資料傾斜 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
7-1) Join 策略
一邊夠小時 Spark 選 broadcast hash join,否則選 sort-merge join;其他都是特殊情況。
| 策略 | 何時 | Shuffle | 備註 |
|---|---|---|---|
| Broadcast hash join | 一邊小於 spark.sql.autoBroadcastJoinThreshold(10 MB)或有 hint |
大的那邊不用 | 小的那邊收集到 driver,再送到每個 executor |
| Shuffle hash join | 一邊每個 partition 小很多,但大到不能 broadcast | 兩邊都要 | 每個 partition 建一個雜湊表;可能 OOM |
| Sort-merge join | 兩邊都大、等值 join —— 預設 | 兩邊都要 | 兩邊各自排序;能優雅地 spill |
| Broadcast nested loop join | 非等值 join 且有一邊很小 | 不用 | O(n × m) |
| Cartesian product | 非等值 join 且兩邊都大 | 兩邊都要 | 幾乎一定是 bug |
# python — hint the strategy
from pyspark.sql.functions import broadcast
joined = facts.join(broadcast(dim_store), "store_id")
-- Spark SQL join hints (3.0+): BROADCAST, MERGE, SHUFFLE_HASH, SHUFFLE_REPLICATE_NL
SELECT /*+ BROADCAST(s) */ f.*, s.region
FROM sales f
JOIN stores s ON f.store_id = s.store_id;
Broadcast 太大的東西,只是把 OOM 從 executor 搬到 driver —— 門檻值的存在是有原因的。
7-2) Join 類型與它們的陷阱
| Join | 回傳 | 陷阱 |
|---|---|---|
inner |
匹配的列 | 兩邊都有重複 key 時列數會相乘 |
left / right / full |
一邊 / 兩邊的所有列 | 之後在可為 null 的那邊過濾,會把它變回 inner join |
left_semi |
有匹配的左表列(只有左表欄位) | 有效率的 EXISTS |
left_anti |
沒有匹配的左表列 | 有效率且 NULL 安全的 NOT EXISTS |
cross |
每一對組合 | 列數爆炸 |
NULLkey 在等值 join 中永遠不會匹配;如果必須匹配,用eqNullSafe(SQL 中的<=>)。- 用欄位名稱清單做 join(
join(df2, "id")),可以避免df1.id == df2.id產生的重複、 有歧義的id欄位。
7-3) 資料傾斜:發現與修正
傾斜是少數幾個 key 握有大部分的列,於是一個 task 跑一小時,其他的一分鐘就結束 —— 整個 job 的時間由最慢的那個 task 決定。
怎麼看出來:在 Spark UI 的 stage 頁面,最大的 task 時間與 shuffle read 量是中位數的好幾倍, 而且其他 task 都結束很久了,還有一個在跑。
怎麼修,依你嘗試的順序:
- AQE skew join ——
spark.sql.adaptive.skewJoin.enabled=true(AQE 開啟時的預設) 會自動切開 sort-merge join 中過大的 partition。 - Broadcast 小的那一邊,讓大的那一邊根本不用依 key shuffle。
- 另外處理熱點 key —— 它常常是
NULL、-1、"unknown"或測試帳號:把它過濾掉、 其餘正常 join,熱點 key 用 broadcast 處理,再union。 - Salting(加鹽) —— 在大表的 key 上加一個隨機後綴,小表則按每個後綴複製一份。
- 傾斜的
groupBy用兩階段彙總 —— 先依(key, salt)彙總,再依key彙總。
# python — salting a skewed join
from pyspark.sql import functions as F
SALT = 16
big_salted = big.withColumn("salt", (F.rand() * SALT).cast("int"))
small_salted = small.crossJoin(spark.range(SALT).withColumnRenamed("id", "salt"))
joined = big_salted.join(small_salted, ["key", "salt"]).drop("salt")
# python — two-stage aggregation for a skewed groupBy
partial = (events.withColumn("salt", (F.rand() * 16).cast("int"))
.groupBy("key", "salt").agg(F.sum("amount").alias("part")))
result = partial.groupBy("key").agg(F.sum("part").alias("amount"))
Salting 只適用於可以拆開再合併的彙總(sum、count、max)—— 精確的 countDistinct
不額外處理就不行。
8) 快取與持久化 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
8-1) 何時快取
只有當一個 DataFrame 被多個 action 重複使用、而且重算很昂貴時才快取;否則快取只花記憶體, 什麼也換不到。
- 適合:被多個彙總使用的清理後資料集;反覆迭代的 ML 迴圈;在同一個 job 裡被 join 很多次的維度表。
- 不適合:只用一次的 DataFrame;會把其他東西都擠出去的巨大資料集;在單一次寫入前 「為了加速」而快取。
cache()是 lazy 的 —— 資料在下一個 action 時才實體化;常用count()來強制觸發。 用完一定要unpersist()。
8-2) 儲存等級
| 等級 | 存成 | 不夠時 |
|---|---|---|
MEMORY_ONLY |
記憶體中反序列化的物件 | 重新計算缺少的 partition |
MEMORY_AND_DISK |
記憶體中,溢出到本機磁碟 | 磁碟 |
MEMORY_ONLY_SER / MEMORY_AND_DISK_SER(Scala / Java) |
序列化的 bytes —— 較小、較耗 CPU | 重算 / 磁碟 |
DISK_ONLY |
本機磁碟 | — |
OFF_HEAP |
off-heap 記憶體 | 需要 spark.memory.offHeap.enabled |
..._2 變體 |
同上,但在兩個 executor 上各存一份 | 復原更快,記憶體用量加倍 |
RDD.cache()=MEMORY_ONLY;DataFrame.cache()=MEMORY_AND_DISK(以 Tungsten 的 記憶體內欄式格式存放)。- 快取的資料住在 executor 上 —— 失去一個 executor,就從 lineage 重算它的 partition。
8-3) 快取 vs checkpoint vs 寫到儲存
cache / persist |
checkpoint |
寫到一張表 | |
|---|---|---|---|
| 保留 lineage | 是 | 截斷 | 截斷(變成新的來源) |
| 應用程式結束後還在 | 否 | 檔案還在,但只供這個應用程式使用 | 是 |
| 存在哪 | executor 記憶體 / 本機磁碟 | 可靠儲存(HDFS / S3) | lake / 資料倉儲 |
| 用於 | 同一個 job 內重複使用 | 反覆迭代 job 中非常長的 lineage | 把資料交給另一個 job |
9) 記憶體、序列化與資源配置 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
9-1) 統一記憶體管理
Executor heap 分成保留區、user memory,以及一塊由 execution(shuffle、join、sort、彙總) 與 storage(快取、broadcast)共用的統一區域 —— execution 可以把快取區塊擠掉,storage 不能擠掉 execution。
Executor container (YARN / K8s pod)
├── spark.executor.memoryOverhead off-heap: JVM metaspace, thread stacks, NIO buffers,
│ Python workers (default max(384 MB, 10% of executor memory))
└── spark.executor.memory (JVM heap)
├── Reserved 300 MB
├── User memory (1 − spark.memory.fraction) of the rest — your objects, UDF data
└── Unified memory spark.memory.fraction = 0.6 of (heap − 300 MB)
├── Storage spark.memory.storageFraction = 0.5 is protected from eviction
└── Execution can borrow all free storage, and evict cache above the protected part
- Execution 記憶體不夠時,運算子會 spill 到磁碟(慢但撐得住);OOM 發生在某個東西無法 spill 時 —— 一筆巨大的資料列、shuffle hash join 的 build side、Python worker,或 overhead 用完。
- 當 heap + overhead 超過容器限制時,容器會被 YARN / Kubernetes 殺掉 —— 「Container killed for exceeding memory limits」指的是 overhead,不是 heap。
9-2) Executor 的大小配置
用中等大小的 executor —— 大約 4–5 個 core,記憶體足以容納每個 core 處理一個 partition 所需 —— 而不是每個節點一個巨大 executor,也不是每個 executor 一個 core。
| 選擇 | 問題 |
|---|---|
| 非常大的 executor(全部 core、100+ GB heap) | GC 停頓很長、每個 JVM 的 HDFS client 吞吐量有上限 |
| 很小的 executor(1 個 core) | 無法共用 broadcast / 快取,每個 core 都要付 JVM 的額外成本 |
| ~4–5 個 core,記憶體 ≈ cores × (partition 工作集)+ overhead | 常見的起點 |
- 在每個節點上替 OS 與 NodeManager / kubelet 留空間。
- PySpark:Python worker 在 heap 之外,所以要調高
spark.executor.memoryOverhead, 或明確設定spark.executor.pyspark.memory。 - Driver 記憶體只跟回到它身上的東西有關:
collect、toPandas、broadcast,以及非常大的 計畫 / 檔案清單。
9-3) 序列化與反序列化:是什麼、發生在哪裡
序列化把記憶體中的物件轉成可以跨越行程邊界、或存放在磁碟上的位元組;反序列化再從這些 位元組把物件重建回來。只要資料離開 executor 的 heap —— 派送 task、shuffle、序列化快取、 broadcast、回傳結果,以及 Python 邊界 —— Spark 就要做一次,所以每一條路徑都要付 CPU 成本。
JVM heap 上的物件是一張由物件頭與指標組成的圖,指向的是單一行程自己的記憶體。另一個 executor、另一台機器或一個檔案都無法跟著這些指標走,所以資料必須先攤平成一段自給自足的 位元組流。
executor A heap on the wire / on disk executor B heap
("U1", 100) ── serialise ──▶ 0a 02 55 31 64 … ── deserialise ──▶ ("U1", 100)
object graph: headers, flat, self-contained, a new object graph,
pointers, boxed fields compressible rebuilt field by field
| 發生在哪裡 | 方向 | 序列化的是什麼 | 用什麼序列化 |
|---|---|---|---|
| 派送 task | driver → executor | task 本身:你的函式,以及它捕獲的所有東西 | 一律用 Java 序列化,不管 spark.serializer 設成什麼 |
| Shuffle | map task → 本機磁碟 → reduce task | 每一筆跨越 exchange 的資料列 | DataFrame:UnsafeRowSerializer;RDD:spark.serializer |
| 序列化快取 | heap ↔ storage 記憶體 / 磁碟 | 被快取的 partition | RDD 的 *_SER 等級:spark.serializer;DataFrame.cache():Spark SQL 建立的壓縮欄式批次 |
| Broadcast | driver → executor,分塊傳送 | broadcast 的值 | spark.serializer,再壓縮 |
| 回傳結果 | executor → driver | collect() / take() 的輸出 |
spark.serializer,上限是 spark.driver.maxResultSize |
| Python 邊界 | JVM ↔ Python worker | 進出 Python UDF 與 RDD 函式的資料列 | pickle(函式用 cloudpickle),pandas UDF 用 Arrow —— 見 §10 |
Closure 也會被序列化,所以一個捕獲了不可序列化物件(DB 連線、logger、外層類別)的
lambda,會在任何 task 開始跑之前就以 Task not serializable 失敗。改在 executor 上建立該物件,並讓寫入具冪等性:重試或 speculative 的 task 會再執行一次 insert
(§15-3)。
// scala — fails: `conn` is opened on the driver and captured by the closure
val conn = DriverManager.getConnection(url)
rdd.foreach(row => insert(conn, row)) // Task not serializable
// works: the connection is opened on the executor, once per partition
rdd.foreachPartition { rows =>
val conn = DriverManager.getConnection(url)
try rows.foreach(row => insert(conn, row)) finally conn.close()
}
9-4) 序列化器:Java、Kryo 與 Tungsten 的二進位資料列
spark.serializer 決定 RDD 記錄、broadcast 與回傳結果用 Java 序列化(預設)還是 Kryo。
DataFrame 的資料列兩者都不經過:Tungsten 從一開始就把它們存成 UnsafeRow 位元組,所以
SQL 的 shuffle 沒有東西需要轉換。
Java(JavaSerializer) |
Kryo(KryoSerializer) |
Tungsten(UnsafeRow) |
|
|---|---|---|---|
| 用在 | RDD 資料、broadcast 與結果的預設;closure 一律用它 | spark.serializer 指定它之後的 RDD 資料、broadcast 與結果 |
DataFrame 與 SQL 資料列的 shuffle、排序與彙總 |
| 編碼方式 | 類別 metadata 加上每個欄位,經由 java.io.Serializable |
緊湊的欄位編碼;註冊過的類別只寫一個小整數 id | null bitmap 加上固定寬度的值欄位,變長資料放在最後 —— 本身就是位元組 |
| 速度與大小 | 最慢、最大 | Spark 調校指南:“often as much as 10x” 更快、更緊湊 | shuffle 路徑上沒有物件轉換 |
| 需要 | 類別實作 Serializable |
註冊類別才拿得到緊湊格式;buffer 要放得下最大的物件 | 一個 schema,所以只有 DataFrame 與 Dataset 用得到 |
spark.serializer 是 core 設定:要在 SparkContext 啟動之前設好(builder、
spark-submit --conf 或 spark-defaults.conf)。在 Spark 3 上,對執行中的 session 呼叫
spark.conf.set("spark.serializer", …) 會被拒絕。
# python
spark = (SparkSession.builder
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.kryo.classesToRegister", "com.acme.Click,com.acme.Session")
.config("spark.kryo.registrationRequired", "true") # fail fast on an unregistered class
.config("spark.kryoserializer.buffer.max", "256m") # default 64m
.getOrCreate())
陷阱
- Kryo 不會加快 DataFrame 的 shuffle。 SQL 的 exchange 用
UnsafeRowSerializer,不管spark.serializer設成什麼。Kryo 的效益在 RDD 程式碼與 JVM 物件的 broadcast。 - 沒註冊的類別照樣能用,只是慢。 除非開了
registrationRequired,Kryo 會在每個物件旁寫上 完整類別名稱,而這正是你改用 Kryo 想省掉的開銷。 Buffer overflow. Available: 0, required: …表示某個物件超過了spark.kryoserializer.buffer.max;調高它(必須小於 2048m)。- Typed Dataset 的 lambda 會轉換每一列。
ds.map(o => …)會把每個UnsafeRow反序列化成 JVM 物件,再把結果序列化回去 —— 計畫裡的DeserializeToObject與SerializeFromObject。 欄位運算式兩者都不需要。 - Spark 內部早就用 Kryo 來 shuffle 由基本型別、基本型別陣列與字串組成的 RDD(Spark 2.0 起),所以改用 Kryo 對「自訂類別的 RDD」幫助最大。
9-5) 一次 shuffle,逐位元組拆解
跨越 shuffle 的每一列,都會在 map 端被序列化、放進 buffer、壓縮後寫入本機 shuffle 檔, 再由 reduce task 透過網路抓取,最後解壓縮並反序列化 —— 每一列都付兩次 CPU、兩次磁碟、 一次網路。
# python
orders = spark.createDataFrame(
[("U1", 100), ("U2", 200), ("U1", 300), ("U3", 400), ("U2", 500), ("U1", 600)],
["user_id", "amount"])
orders.groupBy("user_id").sum("amount").show() # U1 1000, U2 700, U3 400
illustrative layout: 3 map tasks, 3 shuffle partitions
map side: partial sums first reduce side: hash(user_id) mod 3
task 0: (U1,100) (U2,200) → U1:100 U2:200 p0 ◀ U1:100 U1:300 U1:600 → U1 1000
task 1: (U1,300) (U3,400) → U1:300 U3:400 p1 ◀ U2:200 U2:500 → U2 700
task 2: (U2,500) (U1,600) → U2:500 U1:600 p2 ◀ U3:400 → U3 400
map task reduce task
1. compute the row, pick its partition id 5. fetch its block from every map output:
2. serialise it into the writer's buffers network, or a local read when co-located
3. group the bytes by partition id: with (up to spark.reducer.maxSizeInFlight = 48m)
≤ 200 partitions, as here, the bypass 6. decompress and deserialise
writer streams each to its own file, no 7. aggregate, join or sort
sort; the sort writers sort and spill
sorted runs when memory runs out
4. end with one data file + one index file,
compressed with lz4, through a 32k
spark.shuffle.file.buffer per stream
圖中 3 × 3 的配置只是示意。實際執行時,清單會依 session 的預設平行度切分,shuffle 成
spark.sql.shuffle.partitions(200)個 partition,AQE 還可能把它們合併。3 與 200 都在
bypass 門檻內,所以 writer 是同一個。
部分彙總(Exchange 之前的 HashAggregate,§4-4)才是縮小 shuffle 的關鍵。
它是獨立的運算子,所以它下面的 shuffle 本身沒有 map 端合併。
這個例子裡每個 map task 的 key 都不重複,所以沒有東西可以合併;當每個 key 有數百萬列時,
它對每個 task 的每個 key 只送出一筆部分總和,而不是每一列。
Map 端怎麼排序,取決於 Spark 選了哪個 writer:
| Writer | 何時被選上 | 排序的是什麼 |
|---|---|---|
BypassMergeSortShuffleWriter |
shuffle 本身沒有 map 端合併,且 reduce partition 不超過 spark.shuffle.sort.bypassMergeThreshold(200) |
不排序:每個 reduce partition 一個檔,最後串接起來 |
UnsafeShuffleWriter(serialised shuffle) |
沒有 map 端合併、序列化器能搬移已序列化的記錄(Kryo、UnsafeRowSerializer;Java 不行)、partition 少於 16,777,216 個 |
依 partition id 排序的 8 位元組指標;排序時記錄從不反序列化 |
SortShuffleWriter |
其他情況,例如有 map 端合併的 reduceByKey |
反序列化後的物件,依 partition id;合併時另依 key 的 hash 排,讓相同 key 合併 —— 不是 key 的順序 |
Serialised shuffle 只依 partition id 排序,不依 key。Sort-merge join 需要的依 key 排序,
是 reduce 端另一個 Sort。
| 成本 | 付在哪裡 | Spark UI 的 stage 指標 |
|---|---|---|
| CPU | 序列化與壓縮(map)、解壓縮與反序列化(reduce)、排序 | I/O 解釋不了的 task 時間、GC time |
| 磁碟 | shuffle 寫出、spill、把檔案讀回來 | Shuffle Write Size、Spill (Memory / Disk) |
| 網路 | 抓取遠端區塊 | Shuffle Remote Reads、Shuffle Read Blocked Time |
| 記憶體 | 排序 buffer、傳輸中的抓取、彙總用的 hash map | 先是 Spill,然後 OOM |
Shuffle 檔留在 map executor 的本機磁碟上。外部 shuffle service 在該 executor 被移除後仍繼續 提供這些檔案(§5-1);shuffle tracking 則是在輸出還被需要時讓 executor 保持存活 (§2-5)。兩者都沒有的話,遺失的輸出要重跑 map task 重新計算。
9-6) 降低序列化成本
先少搬一點位元組,再讓每個位元組便宜一點:縮小進到 exchange 的資料、讓資料列留在
DataFrame 運算子裡以維持 UnsafeRow,最後才去調序列化器與 buffer。
| 手段 | 為什麼有用 | 參考 |
|---|---|---|
| 在 exchange 之前過濾與投影 | 要序列化、寫出與抓取的列和欄都變少 | §5-2 |
| Broadcast join 中小的那一邊 | 大的那一邊完全不跨 shuffle | §7-1 |
| 用內建函式,不用 UDF 與 typed lambda | 沒有 UnsafeRow ↔ 物件轉換,也不用 pickle 到 Python |
§10-2 |
| Kryo 加上註冊類別 | RDD 記錄與 broadcast 更小、更快 | §9-4 |
序列化的 RDD 快取(MEMORY_ONLY_SER) |
heap 與 GC 大幅減少,代價是每次讀取的 CPU | §8-2 |
| 小 closure;大型查找表用 broadcast | JVM closure 捕獲的 200 MB map 會成為 stage task binary 的一部分,每個 task 都要反序列化它;大型的 PySpark command 會另外 broadcast,Python worker 仍要 unpickle 它 | §9-3 |
spark.shuffle.file.buffer 從 32k 調到 64k–1m |
shuffle 寫出時間是瓶頸時,寫入次數更少、每次更大 | 見下方 |
- Catalyst 本來就會把寫在
join之後的filter推到它下面,所以調換程式碼順序往往什麼都 沒變。宣稱有改善之前先看explain("formatted")。 spark.shuffle.file.buffer是每個輸出串流一份,不是每個 executor 一份,而 bypass writer 會為每個 reduce partition 開一個串流。串流數 × 大 buffer 是實打實的記憶體,所以只有在 shuffle 寫出時間是瓶頸時才調高,並且留意 GC。- Task Deserialization Time 偏高,或 driver 出現 “Broadcasting large task binary” 警告,
指向過大的 task:這個警告量的是 stage 序列化後的 RDD 與函式,而這個指標還包含載入相依套件
與 task 自己的 partition 資料。先找出被捕獲的大型值,若確實是它,再改用 broadcast。另一個警告 “Stage N contains a task of very
large size” 說的是單一序列化後的 task,通常是帶著
parallelize本地資料的 task。 - 面試時,先從 Spark UI 指出瓶頸 —— 序列化 CPU、fetch 等待、spill 還是網路 —— 再提解法。 單純加 executor 對這四者都沒有幫助。
10) PySpark 細節 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
10-1) PySpark 怎麼執行
PySpark 是架在 JVM 引擎上的 Python API:DataFrame 操作在 JVM 中規劃與執行,只有 Python UDF 與 RDD lambda 真的會執行 Python 程式碼 —— 在每個 executor 上獨立的 Python worker 行程裡。
Python driver ──Py4J──► JVM driver (Catalyst, scheduler)
│ tasks
▼
Executor JVM ──(rows, pickled or Arrow)──► Python worker(s)
run your UDF / lambda
所以一條只用內建函式寫成的管線,從 Python 呼叫也能以 JVM 的速度執行。
10-2) UDF vs pandas UDF vs 內建函式
優先用內建函式;需要自訂邏輯時用向量化的 pandas_udf;一般的 Python UDF 最慢,因為它
一次只搬一列。
內建(F.*) |
Python UDF(@udf) |
Pandas UDF(@pandas_udf) |
|
|---|---|---|---|
| 在哪執行 | JVM,code generation | Python worker,逐列 | Python worker,Arrow 批次 |
| 序列化 | 沒有 | 每列 pickle | Arrow 欄式批次 |
| 最佳化器看得見嗎 | 完全看得見 | 看不見(黑盒子) | 看不見(黑盒子) |
| 典型用途 | 它能表達的一切 | 快速原型 | 大規模的自訂數值 / 字串邏輯 |
# python — Spark 3.x pandas UDF with type hints
import pandas as pd
from pyspark.sql.functions import pandas_udf
@pandas_udf("double")
def fahrenheit_to_celsius(f: pd.Series) -> pd.Series:
return (f - 32) * 5.0 / 9.0
df = df.withColumn("temp_c", fahrenheit_to_celsius("temp_f"))
- UDF 也會擋住穿過它的 predicate pushdown。
- 分組邏輯:
groupBy(...).applyInPandas(func, schema)把每一組當成一個 pandas DataFrame 交給func—— 很大的一組必須放得進單一 worker 的記憶體。 - Spark 3.5 也有 Arrow 最佳化的 Python UDF(
useArrow=True),以批次方式傳輸。
10-3) Arrow、toPandas 與 collect
spark.sql.execution.arrow.pyspark.enabled=true讓toPandas()與createDataFrame(pandas_df)以欄式批次傳輸,而不是 pickle 過的列。toPandas()與collect()會把全部資料拉到 driver —— 摘要沒問題,整張表就是 OOM。 用limit()、先彙總,或寫到儲存。pyspark.pandas(前身是 Koalas)提供類似 pandas 的 API,在 Spark 上執行。
11) Spark SQL、寫入與表格格式 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
11-1) Spark SQL 與 catalog
Spark SQL 與 DataFrame API 編譯成同樣的計畫;依可讀性選擇,不是依速度。
-- Spark SQL: register and query a view
CREATE OR REPLACE TEMP VIEW orders AS
SELECT * FROM parquet.`s3://lake/orders/`;
SELECT customer_id,
SUM(amount) AS total,
ROW_NUMBER() OVER (PARTITION BY country ORDER BY SUM(amount) DESC) AS rank_in_country
FROM orders
WHERE order_date >= DATE '2026-10-01'
GROUP BY customer_id, country;
- Temp view 只存在一個 session;global temp view 在同一個應用程式的
global_temp中跨 session 存在;表格則存在 catalog(Hive Metastore、AWS Glue、Unity Catalog、Iceberg REST catalog)。 - Spark 4.0 預設開啟 ANSI SQL 模式(
spark.sql.ansi.enabled=true):無效的轉型與整數溢位 會拋出錯誤,而不是回傳NULL—— 常見的升級驚喜。
11-2) Save mode 與 dynamic partition overwrite
對分區表做 overwrite 預設會取代整張表;設定 partitionOverwriteMode=dynamic,就只會
取代你寫入的資料中出現的那些分區 —— 這個設定讓每日 job 可以安全地重跑。
| Save mode | 目標已存在時 |
|---|---|
errorifexists / error(預設) |
失敗 |
append |
新增資料列 —— 重跑會重複 |
overwrite |
取代(整張表,或 dynamic 模式下只取代寫入的分區) |
ignore |
什麼都不做 |
# python — idempotent daily load: replaces only event_date = run_date
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
(daily_df.write.mode("overwrite")
.partitionBy("event_date")
.parquet("s3://lake/events/"))
- 對 Hive 格式的表,SQL 的等價寫法是
INSERT OVERWRITE TABLE t PARTITION (dt = '2026-11-01') SELECT ...。 - 單純的 Parquet 目錄沒有交易:失敗的 overwrite 可能留下寫到一半的分區,讀取者也可能在 寫入途中看到它。表格格式解決了這個問題。
11-3) 在 Spark 上用 Delta Lake / Iceberg / Hudi
表格格式在 Parquet 檔案之上加一份交易 log,讓 Spark 在物件儲存上得到原子提交、MERGE、
time travel 與 schema 演進。
-- Spark SQL on Delta Lake: upsert a batch of changes
MERGE INTO dim_customer AS t
USING updates AS s
ON t.customer_id = s.customer_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
| 能力 | 單純 Parquet | Delta / Iceberg / Hudi |
|---|---|---|
| 原子的 overwrite / append | 沒有 | 有 |
UPDATE / DELETE / MERGE |
自己手動改寫 | 有 |
| 多個寫入者同時寫 | 不安全 | 樂觀併發控制 |
| Time travel | 沒有 | VERSION AS OF / snapshot |
| 小檔案壓實 | 自己寫 job | OPTIMIZE / rewrite_data_files / Hudi compaction |
它們如何融入管線見 ./batch_pipeline.md,佈局與演進見
./schema_design.md。
11-4) 把資料讀好
- 讀 JSON / CSV 時提供 schema,不要用
inferSchema—— 推斷要多掃一遍,還可能猜錯型別。 - 格式錯誤資料的
mode:PERMISSIVE(預設,把壞資料放進_corrupt_record)、DROPMALFORMED、FAILFAST。 - JDBC 讀取是單執行緒的,除非你設定
partitionColumn、lowerBound、upperBound與numPartitions—— 而那些上下界只決定切分的步距,不會過濾資料列。 - 讀 Parquet 時的
mergeSchema會調和不同檔案的 schema,但有代價;不要當成預設。
12) Structured Streaming 與 Watermark 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
12-1) 模型
Structured Streaming 把串流當成一張無界的表,增量地在上面執行你的 DataFrame 查詢,預設是 一連串的 micro-batch,進度記錄在 checkpoint 目錄中。
# python — Kafka → windowed counts → Delta, Spark 3.5
from pyspark.sql import functions as F
events = (spark.readStream.format("kafka")
.option("kafka.bootstrap.servers", "broker:9092")
.option("subscribe", "clicks")
.load()
.select(F.from_json(F.col("value").cast("string"), click_schema).alias("e"))
.select("e.*"))
counts = (events.withWatermark("event_time", "10 minutes")
.groupBy(F.window("event_time", "5 minutes"), "page_id")
.count())
# append: each window is written once, after the watermark passes it
(counts.writeStream.outputMode("append")
.option("checkpointLocation", "s3://lake/_chk/click_counts")
.format("delta")
.start("s3://lake/tables/click_counts"))
| 概念 | 選項 |
|---|---|
| 輸出模式 | append(只有最終的列)、update(變動的列)、complete(每批輸出整個結果) |
| Trigger | 預設(上一批結束就跑下一批)、processingTime="1 minute"、availableNow=True(處理現有的資料後停止 —— 3.3+) |
| 容錯 | offset 與狀態存在 checkpointLocation;搭配可重播的來源與冪等 / 交易式的 sink,結果就是 exactly-once |
- 不是每個 sink 都接受每種輸出模式 —— Delta sink 接受
append與complete; 需要 update 語意時,用foreachBatch搭配MERGE。 - 修改查詢中有狀態的邏輯,可能讓既有的 checkpoint 無法再使用。
Spark 的 watermark 怎麼運作,見 §12-2 到 §12-6。與引擎無關的概念(window 類型、遲到資料策略、
state backend),以及與 Flink、Kafka Streams 的比較,見
./stream_pipeline.md。
12-2) Watermark:它是什麼,Spark 怎麼算
Watermark 是 Spark 對「事件時間已經推進到哪裡」的持續估計:目前看到的最新事件時間,減去 你說可以容忍的遲到時間。它決定兩件事 —— 哪些遲到的資料列還能更新結果,以及哪些狀態已經舊到 可以刪除。
每筆記錄都帶著兩個時間。**事件時間(event time)**是事情發生的時間(記錄裡的一個欄位); **處理時間(processing time)**是 Spark 讀到它的時間。只要 Kafka consumer 落後、網路重試、 或手機離線,兩者就會拉開:
| 訂單 | 事件時間 | Spark 讀到的時間 |
|---|---|---|
| A | 10:00 | 10:01 |
| B | 10:02 | 10:03 |
| C | 10:01 | 10:08 —— 比 B、D 晚到,卻比兩者都早發生 |
| D | 10:04 | 10:05 |
依處理時間計數,C 會被算進錯的 window。依事件時間計數才對,但這樣 Spark 就無法知道一個 window
何時算完整 —— C 正好證明舊的 window 還可能改變。withWatermark 就是「要等多久」的答案:
# python — tolerate events up to 10 minutes behind the latest one seen
counts = (orders.withWatermark("event_time", "10 minutes")
.groupBy(F.window("event_time", "10 minutes"))
.count())
Spark 3.5 micro-batch 引擎的規則:
- watermark = 目前看到的最大事件時間 − delay,最大值取自輸入的所有 partition。
- 它在一個 micro-batch 結束時計算,由下一個 batch 使用。從 Spark 3.4 起,有狀態運算子在每個 batch 持有兩個值:用上一個 batch 的 watermark 過濾遲到的輸入,用這一個 batch 的 watermark 清除狀態。所以在 watermark 剛往前推進的那個 batch,狀態落在上一個值與新值之間的資料列仍會被放進來; 落在上一個值之後的則不會。
- 它只會往前走。舊的事件永遠不會把它往回拉。
- 只有當某個 batch 看到更新的事件時間,它才會前進。整條串流都沒資料時,watermark 就停住, 最後幾個 window 要等到資料再來才會定案。
| 看到的最大事件時間 | Delay | Watermark |
|---|---|---|
| 10:10 | 10 分鐘 | 10:00 |
| 10:15 | 10 分鐘 | 10:05 |
| 10:25 | 10 分鐘 | 10:15 |
withWatermark 必須指定有狀態運算子所用的事件時間欄位(window() 所依據的欄位,或是
grouping 或 join 的欄位;對 dropDuplicates 而言,是去重 key 裡的一個欄位 —— 見 §12-4),而且必須放在該運算子之前。用在批次 DataFrame 上則不起作用。
12-3) 遲到資料:哪些保留,哪些丟掉
Spark 保證:遲到程度小於 delay 的資料列 —— 事件時間 > 目前最大值減去 delay —— 絕不會因為 watermark 被丟掉。剛好等於或超過這個程度的資料列可能被丟掉,也可能仍被計入;watermark 是狀態的門檻, 不是輸入資料的嚴格截止時間。
以 append 或 update 模式下 10 分鐘的 tumbling window 為例(complete 模式什麼都不清,所以也什麼都
不丟),delay 10 分鐘,watermark 從上一個 batch 起就停在 10:15,所以遲到過濾與狀態清除都用 10:15。
接著到了三筆遲到的資料列:
| 資料列 | 事件時間 | 所屬 window | Window 結束 vs watermark(10:15) | 結果 |
|---|---|---|---|---|
| A | 10:18 | 10:10–10:20 | 10:20,尚未越過 | 計入 —— window 還開著 |
| B | 10:12 | 10:10–10:20 | 10:20,尚未越過 | 計入,雖然 10:12 已落在 watermark 之後:它所屬 window 的狀態還在 |
| C | 10:05 | 10:00–10:10 | 10:10,已越過 | 丟掉 —— 該 window 已定案,狀態已刪除 |
B 是最容易誤解的那筆。「比 watermark 舊就丟掉」並不是 window 聚合裡實際發生的事:決定因素是
這筆資料要更新的狀態還在不在。Spark 3.5 在有狀態運算子上,會過濾掉其狀態已落在 watermark 之後
的資料列(對 window 而言,是 window 結束時間落在其後),並把它計入進度指標
numRowsDroppedByWatermark。過濾的延遲只在一個條件下讓 C 進來:在 watermark 剛到 10:15 的那一個
batch,過濾仍用上一個值,所以只有當那個值還早於 C 所屬 window 的結束時間 10:10(例如 10:05)時,C 才會
被放進來,並在該 batch 清除它的 window 時一起輸出。如果上一個值已經是 10:12,C 在那個 batch 也會被擋掉。文件承諾的只有
上面那個保證,所以不要依賴確切的邊界。
被丟掉的資料列不會留在任何地方,而聚合之後的 foreachBatch sink 只看得到聚合後的資料列,看不到
運算子丟掉的那些。如果它們重要,就保留有狀態運算子之前的原始輸入 —— 讓串流也落地到一張原始資料表 ——
之後再用批次 job 從它對帳各個 window。拿每筆資料列的 event_time 與 watermark 比較並不能替代:
它會把 B 判為遲到。要在串流內分類資料列,就得重現運算子自己的規則,也就是拿 window 的結束時間
與上一個 batch 的 watermark 比較。
12-4) Watermark 清掉什麼,以及輸出模式
有狀態的查詢會把每個開著的 window、尚未配對的 join 資料列、看過的去重 key 都留在 state store。 沒有 watermark,就沒有任何東西會舊到可以刪除,狀態會在查詢的整個生命週期中成長。有了它, Spark 會在 watermark 越過某筆狀態後把它清掉。
no watermark — every window ever opened stays in the state store
[10:00–10:10] [10:10–10:20] [10:20–10:30] [10:30–10:40] [10:40–10:50] …
watermark at 10:15 — a window whose end is at or behind 10:15 is final; emit it, then evict it
[10:00–10:10] → emitted (append) and removed
[10:10–10:20] [10:20–10:30] [10:30–10:40] … kept: a late row can still update them
狀態無上限成長的徵兆是:checkpoint 不斷變大、每個 batch 的狀態讀寫變慢、batch 執行時間在幾天內
逐漸上升,最後 executor OOM —— 這正是 RocksDB state store 存在的原因
(faq_spark_hadoop.md §11-4)。
Watermark 限制的是單一有狀態運算子的狀態;它不會限制 executor 記憶體,對沒有事件時間欄位可判斷的
運算子也幫不上忙。dropDuplicates 是常見的例子:只有當事件時間欄位是 key 的一部分時,它的狀態才有
上限,而這樣只能去掉帶著相同時間戳的重送資料。要只依 ID 去重又讓狀態有上限,用 Spark 3.5 的
dropDuplicatesWithinWatermark,它只在 delay 的範圍內記住一個 ID:事件時間比第一份晚超過 delay 的
重複資料會再被輸出一次,所以 delay 必須涵蓋重複資料之間最大的時間差。
輸出模式決定一個 window 的結果何時送到 sink,以及 watermark 能不能刪掉任何東西:
| 模式 | 何時輸出一個 window | 狀態會被 watermark 清掉嗎? | 適用時機 |
|---|---|---|---|
append |
一次,在 watermark 越過其結束時間時 —— 所以結果會延遲 window 長度加上 delay | 會 | sink 不能接受更新:檔案、Delta append、每日報表 |
update |
每個計數有變的 batch 都輸出,包括遲到資料造成的修正 | 會 | dashboard 或支援 upsert 的 sink(foreachBatch + MERGE) |
complete |
每個 batch 輸出全部 window | 不會 —— 必須保留整個結果 | 只適合小的結果表;狀態會無限成長 |
在 append 模式下做聚合必須有 watermark —— 沒有的話 Spark 會拒絕這個查詢,因為它永遠無法
證明一個 window 已經定案。在 update 模式下,delay 內的一筆遲到資料會把 10:00–10:10 = 100
改成 101,sink 會收到新的那列;在 append 模式下,sink 只會收到一次 101,只是比較晚。
12-5) Stream–stream join 與多個 watermark
Stream–stream join 會把兩邊都留在狀態裡等待配對。只有當 Spark 確定另一邊未來不會再有資料列 能與某筆緩衝資料 join,才能刪掉它 —— 這需要兩邊各有一個 watermark,加上 join 條件裡的 事件時間範圍。
# python — a payment must arrive within an hour of its order
orders = orders_stream.withWatermark("order_time", "10 minutes")
payments = payments_stream.withWatermark("payment_time", "10 minutes")
paid = orders.join(
payments,
F.expr("""
order_id = payment_order_id
AND payment_time >= order_time
AND payment_time <= order_time + INTERVAL 1 HOUR
"""),
"inner",
)
- Inner join —— watermark 與時間範圍都是可選的,但沒有它們,一筆永遠等不到付款的訂單會 永遠留在狀態裡。
- Outer join —— left outer join 需要右邊的 watermark 加上時間範圍(right outer join 則相反)。
右邊的 watermark 讓 Spark 能清掉緩衝的左邊資料列;左邊的 watermark 是可選的,只在要清掉緩衝的
右邊資料列時才需要 —— 沒有它,右邊的狀態會一直成長。沒有配對的左邊資料列,要等 watermark 證明不可能再有
配對時才會帶著
NULL輸出,所以它會延遲「時間範圍加上 delay」那麼久。 - 兩邊各有 watermark 但條件裡沒有時間範圍,並不能限制狀態:Spark 仍然無法判斷一筆訂單可能要 等它的付款多久。
當一個查詢讀取不只一條串流(join 或 union),每個輸入有自己的 watermark,而查詢使用一個
全域 watermark,由 spark.sql.streaming.multipleWatermarkPolicy 決定:
| 輸入 | 最大事件時間 | Delay | 自己的 watermark |
|---|---|---|---|
| orders | 10:30 | 10 分鐘 | 10:20 |
| payments | 10:20 | 10 分鐘 | 10:10 |
全域,min(預設) |
10:10 | ||
全域,max |
10:20 |
| Policy | 好處 | 代價 |
|---|---|---|
min(預設) |
較慢那條串流的遲到資料仍會保留 | 狀態保留較久,由最慢的輸入決定 |
max |
狀態更早被清掉,state store 較小 | 較慢那條串流的資料列可能被丟掉 |
除非可以接受遺失慢速串流的遲到資料,否則維持 min。這個 policy 會記錄在 checkpoint 裡,所以
重新啟動的查詢會沿用它最初的設定。
12-6) 正式環境中的 watermark:選 delay 與監控
依照量測到的遲到分佈與業務能承受的損失來選 delay,而不是挑一個整數;然後每個 batch 都盯著 watermark 與狀態大小,因為 watermark 卡住與狀態成長,是同一起事故從兩端看到的樣子。
一道設計題,以及面試官想聽到的推理:每 5 分鐘計算各付款方式的成功率;Kafka 增加約 30 秒; 99% 的事件在 3 分鐘內抵達;部分行動裝置最晚會晚 15 分鐘才同步;狀態必須有上限。
# python
success_rate = (payments.withWatermark("event_time", "15 minutes")
.groupBy(F.window("event_time", "5 minutes"), "payment_method")
.agg(F.count("*").alias("total"),
F.sum(F.when(F.col("status") == "ok", 1).otherwise(0)).alias("ok"))
.withColumn("success_rate", F.col("ok") / F.col("total")))
| Delay | 效果 |
|---|---|
| 太短(3 分鐘) | 遲到超過 3 分鐘的那約 1% 事件 —— 其中很多來自晚同步的行動裝置 —— 有被丟掉的風險(所屬 window 還開著的資料列仍會計入),成功率會對行動裝置產生偏差 |
| 15 分鐘 | 保留已知的遲到尾端;在 append 下,每個 window 在開啟後約 20 分鐘定案 |
| 太長(6 小時) | append 的結果要等好幾個小時,而且最多 6 小時的 window 都留在狀態裡 |
接著是決定答案的問題:15 分鐘是觀察到的 p99.9 還是猜的?有沒有裝置會晚好幾個小時才同步?
業務能不能接受現在先給近似值、之後再修正 —— 用 update 模式,或用批次 job 從 lake 重算昨天?
落在 watermark 之後的資料列要去哪?
監控它 —— 每個 StreamingQueryProgress 都帶有 watermark 與狀態運算子的資訊:
# python
p = query.lastProgress
p["eventTime"]["watermark"] # e.g. "2026-11-08T10:15:00.000Z"
p["stateOperators"][0]["numRowsTotal"] # rows held in state
p["stateOperators"][0]["numRowsRemoved"] # evicted this batch
p["stateOperators"][0]["numRowsDroppedByWatermark"] # late input discarded
p["stateOperators"][0]["memoryUsedBytes"]
p["durationMs"]["triggerExecution"] # batch duration
| 徵兆 | 可能原因 | 要檢查什麼 |
|---|---|---|
| 狀態列數與 batch 執行時間連續幾天上升 | 沒有 watermark、complete 模式,或 join 沒有時間範圍 |
執行計畫與輸出模式 |
Watermark 卡住,append 什麼都沒輸出 |
完全沒有新資料,所以沒有更新的最大事件時間 | 每個來源的輸入速率;是不是離峰時段 |
Watermark 突然往前跳,numRowsDroppedByWatermark 暴增 |
少數帶有未來時間戳的資料列(裝置時鐘錯誤)設定了最大值 | eventTime.max 欄位;在 withWatermark 之前過濾或截斷 event_time > current_timestamp()。這能擋住下一次跳動,卻無法撤銷這一次:往前跳的 watermark 已寫進 checkpoint,透過那個 checkpoint 重播也會保留它。要用新的 checkpoint 恢復 —— 它從空的狀態開始 —— 並明確設定夠早的 startingOffsets 來重建開著的 window(新的 Kafka 查詢否則會從 latest 開始而跳過它們),然後對帳這段期間寫出的結果 |
| 結果缺少遲到的事件 | delay 比實際的遲到時間短 | 事件時間與讀取時間差距的分佈 |
一個 Spark 特有的陷阱。 Spark 的 watermark 取自所有 partition 的最大值,所以閒置的 Kafka
partition 不會拖住它。Flink 取的是各 source instance 的最小值,閒置 partition 才會凍結 watermark
(stream_pipeline.md §3-3)。
Spark 的失敗方向正好相反:某個 partition 跑得遠遠超前 —— 快的 producer、回補、偏斜的時鐘 ——
會替所有人推進 watermark,較慢 partition 的資料列就變成遲到而被丟掉。
13) 調校檢查清單 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
13-1) 依檢查的順序
先找出慢的 stage,再問它是讀太多、shuffle 太多,還是一個 task 做了所有工作 —— 最後才調設定。
- 少讀一點 —— 在過濾欄位上做 partition pruning、column pruning(不要
SELECT *)、 pushdown 沒被 UDF 擋住、欄式格式。 - 少 shuffle 一點 —— broadcast 小的維度表、拿掉不必要的
repartition/distinct、 join 前先彙總。 - 修正傾斜 —— AQE skew join、隔離熱點 key、加鹽。
- 調整 partition 大小 —— 每個 ~100–200 MB,task 數足以讓每個 core 都有事做;開 AQE。
- 熱路徑上避開 Python —— 內建函式優於 UDF,
pandas_udf優於udf。 - 快取會被重複使用的東西,用完 unpersist。
- 修正檔案佈局 —— 壓實小檔案、合理的
partitionBy、對重複的 join 做 bucketing / clustering。 - 最後才調資源 —— executor 的 core / 記憶體 / overhead、dynamic allocation。
13-2) 值得記住名字的設定
| 設定 | 預設值(3.5) | 控制什麼 |
|---|---|---|
spark.sql.shuffle.partitions |
200 | DataFrame shuffle 之後的 partition 數 |
spark.sql.adaptive.enabled |
true | AQE |
spark.sql.autoBroadcastJoinThreshold |
10 MB | join 中可被 broadcast 那一邊的最大大小(-1 表示停用) |
spark.sql.files.maxPartitionBytes |
128 MB | 讀檔時輸入切分的大小 |
spark.sql.sources.partitionOverwriteMode |
static | dynamic 只覆寫寫入的分區 |
spark.executor.memory / .cores |
1g / 依 cluster manager 而定 | executor heap 與 task 槽位 |
spark.executor.memoryOverhead |
max(384 MB, 10%) | off-heap 的預留空間(Python、NIO、metaspace) |
spark.memory.fraction |
0.6 | heap 中 execution + storage 統一區域的比例 |
spark.dynamicAllocation.enabled |
false | 依需求增減 executor |
spark.speculation |
false | 重新啟動慢的 task |
spark.serializer |
Java 序列化 | RDD 工作負載設成 Kryo |
14) 用 Spark UI 除錯 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
14-1) 該看哪裡
Jobs 分頁 → 慢的 job;Stages 分頁 → 慢的 stage 與它的 task 時間分佈;SQL 分頁 → 帶真實 列數的實體計畫;Executors 分頁 → GC 時間、spill 與失敗。
| 分頁 | 告訴你 |
|---|---|
| Jobs | 哪個 action 慢、它有幾個 stage |
| Stages | task 時間的 min / 中位數 / max、shuffle read / write、spill(記憶體與磁碟)、輸入大小 |
| SQL / DataFrame | 實際執行的計畫與每個運算子輸出的列數、broadcast 還是 sort-merge、AQE 的最終計畫 |
| Storage | 快取了什麼、多少在記憶體 vs 在磁碟 |
| Executors | GC 時間、失敗的 task、shuffle 總量、遺失的 executor |
| Environment | 實際生效的設定 |
Spark History Server 為已結束的應用程式顯示同樣的 UI。
14-2) 判讀症狀
| 你看到 | 通常代表 |
|---|---|
| 最大 task 時間 ≫ 中位數,某個 task 的 shuffle read ≫ 其他 | 傾斜 |
| 很多 task 都有大量「Spill (Disk)」 | partition 對 execution 記憶體來說太大 |
| GC 時間很高(> 約 task 時間的 10%) | 每個 executor JVM 的資料太多,或快取了反序列化的物件 |
| 某個 stage 只有一個 task | coalesce(1)、沒有 partitionBy 的 window、不可切分的檔案、全域排序結果 |
| 某個 stage 因「FetchFailed」重跑 | 失去 executor / shuffle 檔 —— 記憶體被殺、被搶占、壞節點 |
| 很多非常短的 task | partition 太多,或很多小檔案 |
| 第一個 task 之前有很長的空檔 | driver 端的工作:列檔案、推斷 schema、規劃巨大的計畫 |
15) 常見故障與解法 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
15-1) 記憶體不足(OOM)
| 錯誤 | 在哪 | 常見原因 | 解法 |
|---|---|---|---|
driver 上的 java.lang.OutOfMemoryError |
driver | collect() / toPandas()、過大的 broadcast、涵蓋數百萬個檔案的計畫 |
改成寫出去;limit;調低 broadcast 門檻;壓實檔案 |
executor 上的 OutOfMemoryError / heap space |
executor | 傾斜的 partition、shuffle hash join 的 build side、巨大的一列(collect_list) |
修正傾斜;更多 shuffle partition;改用 sort-merge |
| 「Container killed by YARN for exceeding memory limits」 | 容器 | off-heap 用量:Python worker、NIO buffer | 調高 spark.executor.memoryOverhead |
ExecutorLostFailure / FetchFailedException |
shuffle | 持有 shuffle 檔的 executor 掛了 | 找出它為什麼掛(通常是上面其中之一) |
15-2) 其他值得認得的錯誤
Task not serializable—— closure 抓到了不可序列化的物件;改在mapPartitions/foreachPartition裡建立它,或把它做成 broadcast。AnalysisException: cannot resolve column—— 拼錯字、欄位在上游被丟掉,或 self-join 後欄位有歧義(兩邊都加別名)。- Job 卡在 199/200 個 task —— 最後那個 task 就是傾斜的那個。
- Broadcast 逾時(
spark.sql.broadcastTimeout,300 秒)—— 「小」的那邊其實不小,或算得很慢; 先把它實體化,或不要 broadcast 它。 - 重跑得到不同結果 —— 非確定性的程式碼(沒有 seed 的
rand()、monotonically_increasing_id、沒有排序的first())在故障後被重新計算。 - 磁碟空間不足 —— shuffle 與 spill 檔塞滿本機磁碟;給 executor 更多本機儲存,或少 shuffle 一點。
15-3) 批次也要你自己保證 exactly-once
Spark 批次 job 會在 task、stage 與應用程式層級被重試,所以它的寫入必須是冪等的 —— 重跑必須是取代,而不是新增。
- 寫入:用 dynamic partition overwrite 或以自然鍵做
MERGE,不要append。 - Task 裡的副作用(HTTP 呼叫、寄信、在
foreach裡寫外部 DB)在重試或 speculation 時可能 執行不只一次 —— 讓它們冪等,或移出 job。 - Output committer:在物件儲存上,用 S3A committer 或表格格式,而不是依賴 rename 的提交, 因為 rename 在 S3 上不是原子的。
16) 常見問題 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
16-1) 快問快答
- 什麼會造成 stage 邊界? 寬依賴 —— 一次 shuffle。
- 一個 stage 有幾個 task? 該 stage 的每個 partition 一個。
cache()vspersist()?cache()就是用預設等級的persist()(DataFrame 是MEMORY_AND_DISK,RDD 是MEMORY_ONLY)。repartitionvscoalesce? 完整 shuffle、任意數量 vs 不 shuffle 的合併、只能變少。groupByKeyvsreduceByKey?reduceByKey先在 map 端合併;優先用它。- 為什麼一個 stage 很快跑完之後 job 就變慢? 通常是傾斜,或某次 shuffle 相對於它的 partition 數太大。
- AQE 做什麼? 在 shuffle 邊界重新規劃:合併 partition、切換成 broadcast join、切開傾斜的 partition。
- 什麼是 predicate pushdown? 把過濾條件移進讀取端,讓來源跳過資料。
- Broadcast join 的上限?
spark.sql.autoBroadcastJoinThreshold,預設 10 MB;小的那邊 必須放得進 driver 與 executor 的記憶體。 - Spark 怎麼復原遺失的 partition? 從 lineage 重新計算。
- 窄 vs 寬? 窄:每個子 partition 只有一個父 partition。寬:很多個,所以要 shuffle。
- 序列化 vs 反序列化? 物件 → 位元組,用來傳送或儲存;位元組 → 物件,用來運算。每次 shuffle、broadcast、序列化快取與派送 task,Spark 兩者都要做。
- Kryo 會加快 DataFrame 的 shuffle 嗎? 不會。SQL 的 exchange 用自己的序列化器處理
UnsafeRow;Kryo 幫的是 RDD 記錄與 broadcast。 Task not serializable是怎麼來的? Closure 捕獲了無法序列化的東西。改在 executor 上、 在foreachPartition裡建立它。- 為什麼避免 Python UDF? 逐列序列化到 Python 行程,而且最佳化器看不進去。
clientvscluster部署模式? Driver 跑在哪:你的機器 vs 叢集內。- Spark 裡的 DAG 是什麼? RDD / DataFrame 依賴關係的圖,由排程器切成 stage。
- Accumulator 有什麼要注意的? 在 transformation 裡的更新,重試時可能被算不只一次。
- Static vs dynamic partition overwrite? Static 取代整張表;dynamic 只取代寫入的資料中 出現的分區。
- 什麼是 shuffle spill? Execution 記憶體滿了,運算子把排序好的片段寫到本機磁碟,之後再合併。
- Window function 沒有
partitionBy? 所有資料列都搬到一個 partition —— 一個 task 做全部的事。 - Partition 數怎麼選? 總資料量 ÷ ~128 MB,至少是 core 數的 2–3 倍,然後讓 AQE 合併。
- 什麼是 watermark? 目前看到的最大事件時間減去允許的 delay。落在它之後的 window 與 join 資料列都已定案,狀態會被刪除。
- 設了 10 分鐘的 watermark,就會丟掉所有超過 10 分鐘的資料嗎? 不會。delay 內的資料一定不會被丟; 比它更晚的可能被丟 —— 所屬 window 還開著的資料列仍會計入。
complete模式下的 watermark? 什麼都不清:整個結果都要保留。- 兩條輸入串流,用哪個 watermark? 預設取兩者的最小值
(
spark.sql.streaming.multipleWatermarkPolicy)。 - Spark on Kubernetes vs YARN? 同一個引擎;K8s 把每個 executor 跑成一個 pod,預設沒有 外部 shuffle service,所以 dynamic allocation 用 shuffle tracking。
16-2) 設計題:「處理每天 10 TB 的點擊流」
先說出答案的骨架:只讀當天的分區、清理與去重、用 broadcast join 小維度表、彙總,再用可 重跑的 overwrite 寫出分區且壓實過的輸出。
- 輸入 —— 點擊資料以 Parquet 落在
s3://lake/raw/clicks/dt=YYYY-MM-DD/(或在第一步 用明確的 schema 把 JSON 轉成 Parquet)。 - 讀取 —— 以
dt = run_date過濾,只選需要的欄位。 - 清理 —— 解析、把格式錯誤的資料丟到隔離路徑、依
event_id去重 (當天內dropDuplicates(["event_id"]))。 - 補充資訊 —— broadcast join 小維度表(頁面、活動);使用者表很大時用 sort-merge join。
- 彙總 —— 用
groupBy與以user_id分區的 window function 算 session 與每日指標。 - 寫出 —— Delta / Iceberg(或 Parquet 搭配 dynamic partition overwrite),以
dt分區, 依dt(加上一個 clustering key)repartition來控制檔案大小。 - 維運 —— 開 AQE、依 ~10 TB 設定 shuffle partition 數(約數千個)、發佈前做資料品質 檢查、以邏輯日期編排以便回補。