問題拆解:面試官到底在問什麼 高頻
「把兩份資料 merge 在一起」看似一題簡單的 coding 題,其實是一個可以無限往下挖的系統題。面試官通常會先讓你寫出能動的版本(兩個 List[dict] 依 id 合併),然後一層層加條件:資料變大、記憶體不夠、要每天跑、要即時、資料有衝突、key 分布不均……每一層都在測試不同能力:
| 追問層級 | 限制條件 | 核心技巧 | 考的能力 |
|---|---|---|---|
| L0 | 兩個 list,幾千筆 | dict(hash map)依 id 合併 | 基本 coding、時間複雜度、衝突規則 |
| L1 | 上千萬筆,但記憶體放得下 | 只把小的一邊建成 hash map、串流讀大的一邊 | 記憶體估算、generator、I/O 意識 |
| L2 | 上億筆、單機記憶體只有 8GB | external merge sort、sort-merge join、hash partition 分桶 | out-of-core 演算法、磁碟 I/O 模型 |
| L3 | 單機太慢(數小時) | MapReduce / Spark:分散式 shuffle join | 分散式運算模型、partition、skew |
| L4 | 要每天自動跑、能重跑 | Airflow 排程、分區覆寫、idempotent backfill | 資料工程成熟度、維運 |
| L5 | 要即時(秒級)更新 | Kafka + Flink / Structured Streaming、狀態儲存 | 串流語意、event time、exactly-once |
O(n+m)"] B -- "否" --> D{"資料已依 key 排序?"} D -- "是" --> E["串流 sort-merge join
記憶體 O(1)"] D -- "否" --> F{"單機磁碟/時間夠?"} F -- "夠" --> G["External sort 或
Hash partition 分桶後逐桶 join"] F -- "不夠" --> H["分散式: Spark / MapReduce
shuffle join / broadcast join"] H --> I{"需要即時?"} I -- "是" --> J["串流: Kafka + Flink
keyed state 持續合併"] I -- "否" --> K["每日批次: Airflow 排程
分區覆寫, 可重跑"]
不是你背了多少框架名稱,而是你能主動釐清需求(資料量多大?每筆多大?key 是否唯一?衝突怎麼處理?多久跑一次?延遲要求?)、先給最簡單可行解、再根據限制條件說明為什麼要換做法,並且每一步都講得出時間/空間複雜度與 trade-off。一開口就說「用 Spark」反而是扣分:代表你沒想過單機能不能解決。
開始寫之前要先問的問題
- Join key 是什麼?是
user_id還是 email / device id?兩邊的 key 格式是否一致(大小寫、前後空白、hash 過的 email)? - Key 在單一來源中是否唯一?同一個 id 在一份資料內出現多次時,要取最新還是合併?
- Join 類型?inner(只要兩邊都有的)、left(以主資料為準)、full outer(全部保留)?使用者屬性整合通常是 full outer。
- 欄位衝突規則?同一欄位兩邊值不同時:最新時間戳優先、來源優先序、非空值優先、還是 list 取聯集?
- 規模與資源?筆數、每筆大小、機器記憶體、能接受的執行時間、是一次性還是每天跑。
- 輸出要給誰用?寫回 DB 給線上服務查(需要低延遲 key-value 查詢)還是寫成檔案給分析(需要列式格式)?
Level 0–1:記憶體放得下 —— Hash Map 合併 高頻 基礎
Appier 真實考題之一:「合併兩個 List[dict] 中相同 id 的資料」以及「合併多個資料來源的 user attributes」。最直接的解法是用 dict 當 hash map:以 id 為 key 掃過所有來源,時間複雜度 O(n + m)(n、m 為兩邊筆數,假設每筆欄位數為常數),空間 O(n + m)。
為什麼不能用雙層迴圈
雙層迴圈對每一筆 A 去掃整個 B 找相同 id,是 O(n × m)。兩邊各 10 萬筆就是 1010 次比較,在 Python 裡要跑好幾個小時;hash map 版本只需約 20 萬次 dict 操作,不到一秒。這就是資料庫中 nested loop join vs hash join 的差別。
基本版:依 id 合併,後者覆蓋前者
from typing import Any
def merge_by_id(a: list[dict[str, Any]], b: list[dict[str, Any]],
key: str = "id") -> list[dict[str, Any]]:
"""Full outer merge: 兩邊都保留,同 id 的欄位合併,b 的值覆蓋 a。"""
merged: dict[Any, dict[str, Any]] = {}
for rec in a:
merged.setdefault(rec[key], {}).update(rec)
for rec in b:
merged.setdefault(rec[key], {}).update(rec)
return list(merged.values()) # Python 3.7+ dict 保留插入順序
a = [{"id": 1, "name": "Amy"}, {"id": 2, "name": "Bob"}]
b = [{"id": 2, "email": "bob@x.com"}, {"id": 3, "name": "Cat"}]
print(merge_by_id(a, b))
# [{'id': 1, 'name': 'Amy'}, {'id': 2, 'name': 'Bob', 'email': 'bob@x.com'}, {'id': 3, 'name': 'Cat'}]
(1) dict.update 會讓 None 覆蓋掉有值的欄位,例如 b 中 {"id": 2, "name": None} 會把 Bob 洗掉。(2) 直接修改輸入的 dict(例如 merged[id] = rec 再 update)會產生 aliasing,呼叫端的原始資料被改到。(3) 若 id 有的是 int、有的是 str(1 vs "1"),dict 會當成兩個不同 key,要先正規化。(4) 若 a 本身同一 id 有多筆,要先定義規則,而不是讓順序決定結果。
進階版:可設定的欄位衝突規則
真實的 user attributes 整合(例如 CRM、App SDK、網站事件、線下 POS)一定會有衝突。好的答案是把規則參數化,常見策略:
- Latest wins(最新時間戳優先)
- 每個欄位帶
updated_at,取最新的值。適合會變動的屬性(地址、會員等級)。注意要比較的是欄位層級還是整筆記錄層級的時間戳。 - Source priority(來源優先序)
- 例如 CRM(使用者自己填的)> App SDK > 第三方推論。適合可信度不同的來源。
- Non-null wins(非空值優先)
- 只有在新值非空時才覆蓋,避免空值洗掉資料。通常與上面兩種規則組合。
- Union / Aggregate(聚合)
- 興趣標籤、裝置列表取聯集;購買金額相加;首次造訪時間取最小值。
from collections.abc import Iterable, Callable
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
SOURCE_PRIORITY = {"crm": 3, "app_sdk": 2, "web": 1, "third_party": 0}
@dataclass
class FieldValue:
value: Any
ts: datetime
source: str
def is_empty(v: Any) -> bool:
return v is None or v == "" or v == [] or v == {}
# 每種規則都是 (舊值, 新值) -> 勝出值
def latest_wins(old: FieldValue, new: FieldValue) -> FieldValue:
if is_empty(new.value):
return old
if is_empty(old.value):
return new
# 同時間戳時用來源優先序打破平手,確保結果與輸入順序無關(deterministic)
return max(old, new, key=lambda f: (f.ts, SOURCE_PRIORITY.get(f.source, -1)))
def priority_wins(old: FieldValue, new: FieldValue) -> FieldValue:
if is_empty(new.value):
return old
if is_empty(old.value):
return new
return max(old, new, key=lambda f: (SOURCE_PRIORITY.get(f.source, -1), f.ts))
def union_list(old: FieldValue, new: FieldValue) -> FieldValue:
merged = sorted(set(old.value or []) | set(new.value or []))
return FieldValue(merged, max(old.ts, new.ts), "merged")
def min_value(old: FieldValue, new: FieldValue) -> FieldValue:
return min(old, new, key=lambda f: f.value)
RULES: dict[str, Callable[[FieldValue, FieldValue], FieldValue]] = {
"email": priority_wins, # 使用者自填的最可信
"city": latest_wins, # 會搬家,取最新
"interests": union_list, # 標籤取聯集
"first_seen": min_value, # 首次出現時間取最小
}
DEFAULT_RULE = latest_wins
def merge_user_attributes(sources: dict[str, Iterable[dict]],
key: str = "user_id") -> dict[Any, dict[str, Any]]:
"""sources: {"crm": [...], "app_sdk": [...]};每筆記錄含 key 與 updated_at。"""
state: dict[Any, dict[str, FieldValue]] = {}
for source, records in sources.items():
for rec in records:
uid = str(rec[key]).strip().lower() # key 正規化
ts = rec.get("updated_at") or datetime.min
user = state.setdefault(uid, {})
for f, v in rec.items():
if f in (key, "updated_at"):
continue
new = FieldValue(v, ts, source)
old = user.get(f)
user[f] = new if old is None else RULES.get(f, DEFAULT_RULE)(old, new)
# 攤平成一般 dict 輸出;可另外保留 lineage(值來自哪個來源)方便除錯
return {uid: {f: fv.value for f, fv in fields.items()} for uid, fields in state.items()}
讓合併函式滿足交換律與結合律(merge(a, b) == merge(b, a),且分批合併結果相同)非常重要:這樣才能在分散式環境中任意順序、任意分批合併,結果仍一致(這正是 MapReduce 的 combiner 與 CRDT 的思想)。上面的 latest_wins 用 (ts, priority) 做 tie-break 就是為了這個;若用「後到者覆蓋」,結果會依讀取順序而變。
記憶體估算:1 億筆到底要多少記憶體
面試官問「上億筆怎麼辦」時,第一件事是算一下,而不是直接跳到 Spark。估算分兩部分:原始資料大小與程式中的物件大小。
| 項目 | 估算 | 說明 |
|---|---|---|
| 原始資料(CSV/JSON) | 1 億 × 200 B ≈ 20 GB | 假設每筆約 5–10 個欄位、200 bytes |
| Parquet + snappy | 通常為 CSV 的數分之一 | 列式壓縮,比例依資料重複度而異 |
| Python dict 物件 | 每筆數百 B 到 1 KB 以上 | dict 本身數百 bytes,每個 str/int 都是獨立物件(int 約 28 B、短 ASCII str 約 49 B + 長度) |
| 1 億筆 Python dict | ≈ 50–100+ GB | 遠超一般機器記憶體,物件開銷可能是原始資料的數倍 |
| 只存 id → offset 的索引 | 1 億 × (8 B + 8 B) ≈ 1.6 GB | 用 numpy 陣列或 array 模組存定長整數才會這麼小;用 dict 存仍會膨脹數倍 |
import sys
rec = {"user_id": "u_000123456", "city": "Taipei", "age": 31, "gender": "F", "tier": "gold"}
print(sys.getsizeof(rec)) # dict 本身(不含 key/value 物件)
deep = sys.getsizeof(rec) + sum(sys.getsizeof(k) + sys.getsizeof(v) for k, v in rec.items())
print(deep) # 實測會是數百 bytes;key 字串在多筆間通常是共用的 interned 物件,但 value 不是
# 更省記憶體的表示法
from dataclasses import dataclass
@dataclass(slots=True) # Python 3.10+;slots 讓每個 instance 沒有 __dict__
class User:
user_id: str
city: str
age: int
# 或者用 tuple / numpy structured array / pandas(底層為連續陣列)/ Apache Arrow
「1 億筆 × 每筆約 200 bytes,原始資料約 20GB;但載入成 Python dict 會因物件開銷膨脹好幾倍,50GB 以上,一台 8GB 機器絕對放不下。所以我有三條路:(1) 只把較小的一邊載入記憶體建 hash table,大的一邊串流讀;(2) 如果兩邊都大,用外部排序或hash 分桶把問題切小;(3) 如果單機處理時間不可接受,或資料會持續成長,才上分散式(Spark)。」能講出數量級,就已經贏過大部分人。
L1 技巧:小表建 hash、大表串流
如果 A 有 100 萬筆(放得下)、B 有 1 億筆(放不下),只需把 A 建成 dict,然後用 generator 一行一行讀 B 並輸出結果。記憶體只與 A 成正比。這就是資料庫 hash join 的 build side / probe side,也是 Spark broadcast join 的單機版本。
import csv
from collections.abc import Iterator
def stream_csv(path: str) -> Iterator[dict]:
with open(path, newline="", encoding="utf-8") as f:
yield from csv.DictReader(f) # 一次只在記憶體中保留一列
def header(path: str) -> list[str]:
with open(path, newline="", encoding="utf-8") as f:
return next(csv.reader(f))
def hash_join_stream(small_path: str, big_path: str, out_path: str, key: str = "user_id") -> None:
"""Left join:以大表為主,小表的屬性補進來;大表的非空值優先。"""
build = {row[key]: row for row in stream_csv(small_path)} # build side:小表
fields = list(dict.fromkeys(header(big_path) + header(small_path))) # 保序去重
with open(out_path, "w", newline="", encoding="utf-8") as out:
writer = csv.DictWriter(out, fieldnames=fields)
writer.writeheader()
for row in stream_csv(big_path): # probe side:大表
match = build.get(row[key], {})
writer.writerow({**match, **{k: v for k, v in row.items() if v != ""}})
Level 2:單機記憶體放不下 —— Out-of-core 演算法 高頻 進階
當兩邊都放不進記憶體(例如 8GB RAM、兩份各 30GB 資料),核心想法只有一個:把資料切成記憶體放得下的小塊,並讓需要配對的資料落在同一塊。有兩種切法:依 key 排序(sort-based)或依 key 的 hash 分桶(hash-based)。這兩種方法分別對應資料庫的 sort-merge join 與 grace hash join,也正是 MapReduce / Spark shuffle 的底層原理。
情況一:兩個檔案都已依 key 排序 —— 串流 merge
如果兩份檔案都已依 user_id 排序(例如上游匯出時已排序,或是 key 遞增的 log),可以像 merge sort 的 merge 步驟一樣兩個指標同時往前走,記憶體只需 O(同一 key 的筆數),時間 O(n + m),而且只需循序讀(sequential I/O,對磁碟最友善)。
import json
from collections.abc import Iterator
from itertools import groupby
def read_jsonl(path: str) -> Iterator[dict]:
with open(path, encoding="utf-8") as f:
for line in f:
if line.strip():
yield json.loads(line)
def grouped(path: str, key: str) -> Iterator[tuple[str, list[dict]]]:
"""把連續相同 key 的記錄聚成一組(前提:檔案已依 key 排序)。"""
for k, recs in groupby(read_jsonl(path), key=lambda r: r[key]):
yield k, list(recs)
def sorted_merge_join(path_a: str, path_b: str, out_path: str, key: str = "user_id") -> None:
"""Full outer sort-merge join。"""
ga, gb = grouped(path_a, key), grouped(path_b, key)
a, b = next(ga, None), next(gb, None)
with open(out_path, "w", encoding="utf-8") as out:
def emit(recs: list[dict]) -> None:
merged: dict = {}
for r in recs: # 這裡可換成上一節的衝突規則
merged.update({k: v for k, v in r.items() if v is not None})
out.write(json.dumps(merged, ensure_ascii=False) + "\n")
while a is not None or b is not None:
if b is None or (a is not None and a[0] < b[0]):
emit(a[1]); a = next(ga, None) # 只有 A 有
elif a is None or b[0] < a[0]:
emit(b[1]); b = next(gb, None) # 只有 B 有
else:
emit(a[1] + b[1]) # 兩邊都有:合併
a, b = next(ga, None), next(gb, None)
兩邊的排序規則必須一致:一邊用字串排序、一邊用數字排序("10" < "9" 但 10 > 9)、或是不同 locale/collation,會讓 merge 默默漏配對而不報錯。實務上可在讀取時檢查 key 是否單調遞增,發現逆序就直接 raise。
情況二:檔案未排序 —— External Merge Sort
外部排序分兩階段:
- Run generation(切塊排序):每次讀入記憶體放得下的量(例如 1GB),在記憶體內排序後寫成一個暫存檔(稱為 run)。30GB 資料 → 約 30 個已排序的 run。
- K-way merge(多路合併):同時開啟所有 run,用一個大小為 k 的 min-heap 每次取出最小的元素寫出。記憶體只需 O(k) 個元素加上每個檔案的讀取緩衝區。Python 的
heapq.merge正是 lazy 的 k-way merge。
複雜度:比較次數 O(N log N);I/O 方面,若 run 數量 k 不超過可同時開啟的檔案數與緩衝區限制,只需讀寫資料兩趟(一趟產生 run、一趟合併)。若 run 太多,則需要多輪合併(multi-pass),趟數約為 logk(run 數)。
import heapq
import json
import os
import tempfile
from collections.abc import Iterator
from itertools import islice
def _write_run(records: list[dict], key: str, tmpdir: str) -> str:
records.sort(key=lambda r: r[key])
fd, path = tempfile.mkstemp(dir=tmpdir, suffix=".run")
with os.fdopen(fd, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
return path
def _iter_run(path: str) -> Iterator[dict]:
with open(path, encoding="utf-8", buffering=1 << 20) as f: # 1MB 讀取緩衝
for line in f:
yield json.loads(line)
def external_sort(in_path: str, out_path: str, key: str,
chunk_records: int = 1_000_000) -> None:
"""chunk_records 依記憶體調整:每塊在記憶體中的 Python 物件大小要遠小於可用 RAM。"""
with tempfile.TemporaryDirectory() as tmpdir:
runs: list[str] = []
with open(in_path, encoding="utf-8") as f:
while True:
lines = list(islice(f, chunk_records))
if not lines:
break
runs.append(_write_run([json.loads(l) for l in lines], key, tmpdir))
# heapq.merge 是 lazy 的:任何時刻每個 run 只有一筆在 heap 中
merged = heapq.merge(*(_iter_run(p) for p in runs), key=lambda r: r[key])
with open(out_path, "w", encoding="utf-8") as out:
for r in merged:
out.write(json.dumps(r, ensure_ascii=False) + "\n")
# 兩份都外部排序後,再呼叫上面的 sorted_merge_join 即可完成上億筆的 join:
# external_sort("a.jsonl", "a.sorted", "user_id")
# external_sort("b.jsonl", "b.sorted", "user_id")
# sorted_merge_join("a.sorted", "b.sorted", "merged.jsonl")
在 Linux 上,GNU sort 本身就是高度最佳化的外部排序,還能平行化:sort -t, -k1,1 -S 4G --parallel=8 -T /mnt/fast_tmp a.csv(-S 記憶體緩衝、-T 暫存目錄),接著用 join -t, a.sorted b.sorted 就是 sort-merge join。注意 sort 與 join 要用相同的 locale(常用 LC_ALL=C,按位元組排序且較快)。面試時提到「我會先試試 Unix 工具」會顯得很務實。
情況三:Hash Partition 分桶 —— Grace Hash Join
如果不需要排序結果,只要 join,hash 分桶通常比排序更快(不需要 O(N log N) 比較):
- Partition 階段:讀 A,對每筆算
bucket = hash(key) % N,寫到a_bucket_{i};B 用同一個 hash 函式與同一個 N 寫到b_bucket_{i}。相同 key 必定落在相同編號的桶。 - Join 階段:對每個 i,把
a_bucket_i載入記憶體建 dict,串流讀b_bucket_i做 hash join。每桶獨立,天然可平行(多 process 或多台機器)。
N 怎麼選?讓每一桶的較小一邊能放進記憶體。例如 A 載入記憶體後估計 60GB,可用 4GB 給 hash table,N 至少 15,保守取 64 或 128(留餘裕給 skew)。但 N 也受限於同時開啟的檔案數(ulimit -n,常見預設 1024)與每個檔案的寫入緩衝。
import json
import zlib
from contextlib import ExitStack
from collections.abc import Iterator
def stable_bucket(key: str, n: int) -> int:
# 不要用內建 hash():str 的 hash 每個 process 隨機化(PYTHONHASHSEED),
# 跨 process / 跨次執行結果不同,A 與 B 會分到不同桶!
return zlib.crc32(key.encode("utf-8")) % n
def partition(in_path: str, prefix: str, key: str, n: int) -> None:
with ExitStack() as stack, open(in_path, encoding="utf-8") as f:
outs = [stack.enter_context(open(f"{prefix}_{i:04d}.jsonl", "w", encoding="utf-8"))
for i in range(n)]
for line in f:
rec = json.loads(line)
outs[stable_bucket(str(rec[key]), n)].write(line)
def join_bucket(i: int, key: str) -> Iterator[dict]:
"""Full outer merge 單一桶:每桶已經夠小,兩邊都載入同一個 dict。
同一 key 在 A 或 B 內出現多次也會被合併成一筆;B 的非空值覆蓋 A。
(若 B 的 key 保證唯一,也可只載入 A 當 build side、串流讀 B 當 probe side。)"""
merged: dict[str, dict] = {}
for side in ("a", "b"):
with open(f"{side}_{i:04d}.jsonl", encoding="utf-8") as f:
for line in f:
r = json.loads(line)
cur = merged.setdefault(str(r[key]), {})
cur.update({f: v for f, v in r.items() if v is not None})
yield from merged.values()
def grace_hash_join(a_path: str, b_path: str, out_path: str,
key: str = "user_id", n: int = 64) -> None:
partition(a_path, "a", key, n)
partition(b_path, "b", key, n)
with open(out_path, "w", encoding="utf-8") as out:
for i in range(n): # 可改用 multiprocessing.Pool 平行處理各桶
for rec in join_bucket(i, key):
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
資料傾斜(skew):如果某個 key 特別多(例如 user_id = "" 或 "unknown"、或是測試帳號),它所在的那一桶會遠大於其他桶,仍然放不進記憶體。解法:(1) 先過濾/單獨處理異常 key;(2) 對過大的桶遞迴再分桶(換一個 hash seed);(3) 若是單一熱點 key 本身就很大,改用 sort-merge 處理該桶。這與 Spark 的 skew join 問題是同一件事。
| 方法 | 前提 | 時間 | 記憶體 | 優點 | 缺點 |
|---|---|---|---|---|---|
| Hash map(全載入) | 資料放得下 | O(n+m) | O(n+m) | 最簡單、最快 | 受限於 RAM |
| 小表 hash + 大表串流 | 一邊放得下 | O(n+m) | O(小表) | 只讀大表一次 | 小表也要真的小 |
| 串流 sort-merge | 兩邊已排序 | O(n+m) | O(1)~O(同 key 筆數) | 循序 I/O、記憶體極小 | 必須先排序 |
| External sort + merge | 無 | O(N log N),約 2–3 趟 I/O | O(chunk) | 輸出有序、可處理 skew | 排序成本高 |
| Hash partition(Grace) | 無 | O(n+m),約 2 趟 I/O | O(最大桶) | 可平行、不用排序 | skew 會爆桶 |
以廣告/行銷平台常見做法為例:客戶上傳一份 CRM 會員名單(數十萬到數百萬筆),要與平台上數億筆裝置/事件資料 join 以建立受眾(audience)。這是典型的「小表 broadcast、大表串流」:會員名單建成 hash set(甚至是 Bloom filter),在掃描事件資料時直接比對;完全不需要對數億筆資料做 shuffle。
Join 策略總整理:Hash / Sort-merge / Broadcast / Shuffle 高頻 進階
單機的演算法搬到分散式環境後,多了一個成本最高的動作:網路傳輸(shuffle)。分散式 join 的核心問題變成「怎麼讓相同 key 的資料到同一台機器上,並且搬最少的資料」。
- Nested loop join
- 對外表每一筆掃內表,O(n×m)。只在其中一邊很小、或是非等值條件(例如
a.ts BETWEEN b.start AND b.end)時使用。Spark 中對應 BroadcastNestedLoopJoin / CartesianProduct,看到它通常代表 join 條件寫錯了。 - Hash join
- 小的一邊建 hash table(build),大的一邊探查(probe)。O(n+m),只支援等值 join。
- Sort-merge join
- 兩邊依 key 排序後合併。可處理很大的資料(排序可以 spill 到磁碟),是 Spark 對兩張大表做等值 join 時的預設策略。
- Broadcast join(map-side join)
- 把小表完整複製到每個 executor,大表完全不用 shuffle,每個 partition 在本地做 hash join。Spark 中小於
spark.sql.autoBroadcastJoinThreshold(預設 10MB)的表會自動 broadcast,也可以用broadcast()hint 強制。 - Shuffle join
- 兩邊都依
hash(key) % P重新分區,透過網路送到對應的節點,之後在每個分區做 sort-merge 或 hash join。成本:兩張表都要經過網路和磁碟。 - Bucketed join(storage-partitioned join)
- 如果兩張表寫入時就已用相同 key、相同桶數做 bucketing,join 時可以跳過 shuffle。適合天天重複 join 的大表,以寫入時的成本換讀取時的效能。
大表 part 1 + 小表"] SM --> E2["Executor 2
大表 part 2 + 小表"] SM --> E3["Executor 3
大表 part 3 + 小表"] end subgraph SJ["Shuffle join"] A1["A part 1"] -- "hash(key)%3" --> X1["Reducer 1"] A1 --> X2["Reducer 2"] B1["B part 1"] -- "hash(key)%3" --> X1 B1 --> X3["Reducer 3"] A2["A part 2"] --> X3 B2["B part 2"] --> X2 end
資料傾斜(skew)與 salting
Shuffle join 的效能取決於最慢的那個 task。若某個 key(例如熱門 app id、某個大客戶、null key)佔了 30% 的資料,那個 key 所在的 task 會跑非常久(Spark UI 上看到 200 個 task 中 199 個幾秒完成、1 個跑 40 分鐘),甚至 OOM。
Salting 的做法:把熱點 key 在大表那一側加上隨機後綴 key_0 … key_{S-1},打散到 S 個 task;小表(或另一側)那一邊則把該 key複製 S 份,每份配上一個後綴,確保仍能配對到。代價是另一側資料量變成 S 倍,所以通常只對熱點 key 做 salting。
from pyspark.sql import functions as F
SALT = 16
hot_keys = ["app_123", "app_456"] # 可先 groupBy(key).count() 找出熱點
events = events.withColumn(
"salt",
F.when(F.col("app_id").isin(hot_keys), (F.rand(seed=42) * SALT).cast("int"))
.otherwise(F.lit(0)))
events = events.withColumn("join_key", F.concat_ws("#", "app_id", "salt"))
# 維度表:熱點 key 複製 SALT 份,其他 key 只要 salt=0
salts = spark.range(SALT).withColumnRenamed("id", "salt")
dim_hot = dim.filter(F.col("app_id").isin(hot_keys)).crossJoin(salts)
dim_cold = dim.filter(~F.col("app_id").isin(hot_keys)).withColumn("salt", F.lit(0).cast("long"))
dim_salted = dim_hot.unionByName(dim_cold) \
.withColumn("join_key", F.concat_ws("#", "app_id", "salt"))
joined = events.join(dim_salted.drop("app_id", "salt"), "join_key")
Spark 3 的 AQE skew join(spark.sql.adaptive.skewJoin.enabled)能在執行期偵測過大的 shuffle partition 並自動拆分,很多情況下不必手寫 salting。但它只處理 sort-merge join 的傾斜分區;聚合(groupBy)的傾斜仍可能需要兩階段聚合(先加 salt 做 partial aggregate,再去掉 salt 做 final aggregate)。另一個常被忽略的熱點是 null key:inner join 時 null 永遠配對不到,應該先過濾掉。
「如果其中一邊夠小(例如幾十 MB 的維度表),我會用 broadcast join 避免 shuffle;兩邊都很大就是 shuffle + sort-merge join,此時要注意 partition 數與 skew。如果這兩張表每天都要 join,我會考慮寫入時就依 user_id bucketing,把 shuffle 成本移到寫入端。」再補一句「我會看 Spark UI 的 stage 時間分布、task 的 shuffle read size 來確認是否 skew」,就有實戰感。
MapReduce 模型與 Hadoop 生態系 進階
MapReduce(Google 2004 論文,Hadoop 為其開源實作)把「上一節的 hash 分桶」推廣到上千台機器:使用者只寫 map 與 reduce 兩個純函式,框架負責切分資料、排程、shuffle、容錯。雖然現在新專案多半用 Spark,但 Spark 的 shuffle、stage、partition 概念都源自 MapReduce,面試很常從這裡問起。
執行流程
- Input split:輸入檔案依 HDFS block(預設 128MB)切成 split,每個 split 啟動一個 map task,盡量排在存有該 block 的機器上(data locality:搬運算而不是搬資料)。
- Map:每筆記錄 → 0 到多個
(key, value)。輸出先寫進記憶體環形緩衝區,滿了就 spill 到本地磁碟。 - Partition + Sort(map 端):依
hash(key) % R決定要送給哪個 reducer,並在每個分區內依 key 排序。可選的 combiner 在這裡先做局部聚合。 - Shuffle:每個 reducer 透過網路從所有 mapper 拉取屬於自己的分區(M × R 次傳輸)。
- Merge + Reduce:reducer 對拉來的已排序片段做 merge(就是外部排序的 k-way merge),相同 key 的值聚在一起交給
reduce(key, values)。 - Output:結果寫回 HDFS(會被複製 3 份)。
(spill 到本地磁碟)"] M2 --> C2["combine + partition + sort"] M3 --> C3["combine + partition + sort"] C1 -- "shuffle" --> R1["reduce 1
key: a-m"] C1 -- "shuffle" --> R2["reduce 2
key: n-z"] C2 --> R1 C2 --> R2 C3 --> R1 C3 --> R2 R1 --> O1["HDFS part-00000"] R2 --> O2["HDFS part-00001"]
經典範例:Word Count(用 Python 模擬)
from collections import defaultdict
from collections.abc import Iterator
from itertools import groupby
import re
def mapper(line: str) -> Iterator[tuple[str, int]]:
for word in re.findall(r"\w+", line.lower()):
yield word, 1
def combiner(word: str, counts: list[int]) -> tuple[str, int]:
return word, sum(counts) # 與 reducer 相同:sum 滿足交換律、結合律
def reducer(word: str, counts: Iterator[int]) -> tuple[str, int]:
return word, sum(counts)
def run_mapreduce(splits: list[list[str]], num_reducers: int = 2) -> dict[str, int]:
# 1) map + combine,並依 hash 分到 reducer 分區
partitions: list[list[tuple[str, int]]] = [[] for _ in range(num_reducers)]
for split in splits: # 每個 split 是一個 map task
local: dict[str, list[int]] = defaultdict(list)
for line in split:
for k, v in mapper(line):
local[k].append(v)
for k, vs in local.items():
ck, cv = combiner(k, vs)
partitions[hash(ck) % num_reducers].append((ck, cv)) # 單一 process 內 hash 一致即可
# 2) shuffle 完成後,每個 reducer 對自己的分區排序 + 分組 + reduce
result: dict[str, int] = {}
for part in partitions:
part.sort(key=lambda kv: kv[0])
for k, group in groupby(part, key=lambda kv: kv[0]):
rk, rv = reducer(k, (v for _, v in group))
result[rk] = rv
return result
print(run_mapreduce([["the cat sat", "the dog"], ["the end"]]))
# {'cat': 1, 'dog': 1, ... 'the': 3}(輸出順序依分區而定)
Combiner 只能用在滿足交換律與結合律的運算(sum、count、max、min)。平均值不能直接 combine:avg(avg(1,2), avg(3)) ≠ avg(1,2,3);正確做法是 combine 成 (sum, count),最後再相除。
用 MapReduce 做兩份資料的 join(reduce-side join)
map 階段對兩份資料都輸出 (user_id, (source_tag, record));shuffle 把同一 user_id 的記錄送到同一個 reducer;reduce 階段依 tag 分成 A、B 兩組再做合併。這就是 Spark shuffle join 的原型。若一邊很小,則可做 map-side join:把小表放進 Distributed Cache 發給每個 mapper,跳過 shuffle(即 broadcast join)。
為什麼 MapReduce 慢
- 每個 job 之間都落地到 HDFS:多步驟的計算(例如迭代式 ML、多次 join)要串多個 job,中間結果每次都寫 HDFS(含 3 份複製)再讀回來。
- Map 輸出 spill 到磁碟、shuffle 強制排序:即使不需要排序也要排。
- JVM 啟動與排程開銷:每個 task 啟動成本高,小 job 也要數十秒。
- 程式模型僵硬:只有 map/reduce,複雜邏輯要拆成很多 job,難以全域最佳化。
Spark 的改進:用 DAG 把多個步驟合併最佳化、中間結果盡量留在記憶體、只在真正需要 shuffle 的地方切 stage、executor 長駐(不必每個 task 重啟 JVM)。
HDFS 架構
NameNode(主節點)
管理metadata:目錄樹、檔案 → block 清單、block → DataNode 位置。metadata 全放在記憶體(所以小檔案過多會吃爆 NameNode 記憶體),並以 fsimage + edit log 持久化。早期是單點故障;HA 架構用 Active/Standby NameNode + JournalNode 共享 edit log + ZooKeeper Failover Controller 自動切換。注意 Secondary NameNode 不是備援,只負責合併 checkpoint。
DataNode(資料節點)
實際儲存 block(預設 128MB),定期向 NameNode 送 heartbeat 與 block report。每個 block 預設複製 3 份,預設放置策略:第一份在寫入端所在節點、第二份在另一個 rack、第三份在第二份同 rack 的不同節點,兼顧 rack 故障容忍與跨 rack 頻寬。
讀取流程:client 問 NameNode 拿 block 位置 → 直接向最近的 DataNode 讀取(資料不經過 NameNode)。寫入流程:NameNode 分配 DataNode → client 對 DataNode 組成 pipeline 逐一轉寫 → ack 回傳。HDFS 設計為 write-once-read-many:支援 append,不支援隨機修改,適合大檔案循序讀,不適合低延遲隨機存取。
在雲端上,HDFS 的角色多半被物件儲存(S3、GCS)取代:儲存與運算分離,叢集可以隨用隨開。代價是物件儲存沒有 data locality、list 操作較慢、rename 不是原子操作(這也是 Iceberg/Delta 等 table format 出現的原因之一)。
YARN:叢集資源管理
- ResourceManager
- 全叢集的資源仲裁者,負責排程(FIFO / Capacity / Fair scheduler)。
- NodeManager
- 每台機器一個,管理本機的 container(CPU/記憶體配額),回報資源使用。
- ApplicationMaster
- 每個應用一個(例如一個 Spark job 的 driver 可以跑在 AM 中),向 RM 申請 container、監控自己的 task。
- Container
- 資源配額單位(例如 4 vCore + 8GB)。Spark executor 就跑在 container 裡。
現在 Spark 也常直接跑在 Kubernetes 上(spark-on-k8s),概念對應:driver/executor 是 pod,資源由 K8s scheduler 分配。
Hive 與 HiveQL
Hive 讓分析師用 SQL(HiveQL)查詢 HDFS 上的檔案:SQL 被編譯成 MapReduce / Tez / Spark 的執行計畫。它最重要的遺產是 Hive Metastore:記錄「表名 → schema、檔案位置、分區」的中央目錄,Spark、Presto/Trino 等引擎都能共用。Hive 是 schema-on-read:資料先放進去,讀的時候才套用 schema。
-- 外部表:刪表不會刪檔案;依日期分區、依 user_id 分桶
CREATE EXTERNAL TABLE user_events (
user_id STRING,
event_name STRING,
properties MAP<STRING, STRING>,
event_ts TIMESTAMP
)
PARTITIONED BY (dt STRING)
CLUSTERED BY (user_id) INTO 256 BUCKETS
STORED AS PARQUET
LOCATION 's3://my-bucket/warehouse/user_events/';
-- 分區裁剪(partition pruning):只會掃 dt='2026-11-01' 那個目錄
SELECT event_name, COUNT(DISTINCT user_id) AS uu
FROM user_events
WHERE dt = '2026-11-01'
GROUP BY event_name;
-- 覆寫單一分區:重跑同一天結果相同(idempotent)
INSERT OVERWRITE TABLE daily_user_profile PARTITION (dt = '2026-11-01')
SELECT user_id, MAX(event_ts) AS last_seen, COUNT(*) AS events
FROM user_events
WHERE dt = '2026-11-01'
GROUP BY user_id;
| Partitioning(分區) | Bucketing(分桶) | |
|---|---|---|
| 實體形式 | 目錄:/dt=2026-11-01/ | 固定數量的檔案:hash(user_id) % 256 |
| 適合的欄位 | 低基數、常用於過濾(日期、國家) | 高基數、常用於 join / group by(user_id) |
| 主要好處 | 查詢時跳過不相關的目錄(partition pruning) | 同桶 join 免 shuffle、抽樣、點查詢 |
| 常見錯誤 | 用高基數欄位分區 → 數百萬個小目錄/小檔案 | 兩表桶數不一致 / hash 函式不同 → 無法利用 |
依 user_id 做 partition 是經典錯誤:會產生上億個目錄,NameNode / metastore 直接崩潰,查詢規劃也會極慢。原則是 partition 數量控制在數千到數萬以內,每個分區的檔案大小在數百 MB 到 1GB 級別。
Spark 核心:RDD、DataFrame、DAG 與執行模型 高頻 深入
Spark 是目前最主流的分散式批次運算引擎。面試的深度通常會到:為什麼比 MapReduce 快、lazy evaluation、narrow/wide dependency、stage 怎麼切、shuffle 為什麼貴、怎麼調 partition、cache 何時用。
RDD vs DataFrame vs Dataset
| RDD | DataFrame | Dataset | |
|---|---|---|---|
| 抽象 | 分散式的物件集合,搭配函式式 API(map、filter、reduceByKey) | 有 schema 的分散式表格(Row 的集合),SQL 式 API | 強型別的 DataFrame(Dataset[User]) |
| 最佳化 | Spark 看不懂你的 lambda,無法最佳化 | Catalyst 最佳化 + Tungsten 執行 | 同 DataFrame,但 lambda 部分無法最佳化 |
| 語言 | Scala/Java/Python | 全部語言 | 只有 Scala/Java(Python 沒有) |
| Python 效能 | 差:每筆資料要在 JVM 與 Python worker 之間序列化 | 好:運算在 JVM 內執行,Python 只是送出計畫 | — |
| 何時用 | 非結構化資料、需要底層控制 | 預設選擇 | Scala 中需要編譯期型別檢查 |
PySpark 中用 Python UDF(@udf)會讓每一筆資料在 JVM 與 Python process 之間序列化往返,常常是效能殺手。優先順序:內建函式(pyspark.sql.functions)> SQL 表達式 > Pandas UDF(以 Arrow 批次傳輸,向量化)> 一般 Python UDF。
Lazy evaluation、DAG、Job / Stage / Task
Spark 的操作分兩類:transformation(select、filter、join、groupBy……只建立計畫,不執行)與 action(count、collect、write、show……觸發真正執行)。Lazy 的好處:看到整個計畫後才最佳化(例如把 filter 下推到讀檔階段、合併多個 map),而且只計算 action 真正需要的部分。
- Job
- 每個 action 觸發一個(或多個)job。
- Stage
- Job 依 shuffle 邊界(wide dependency)切成多個 stage;stage 內的操作可以 pipeline 串在一起,不落地。
- Task
- Stage 中每個 partition 一個 task,是排程到 executor core 上的最小單位。Task 數 = 該 stage 的 partition 數。
- Narrow dependency
- 子 partition 只依賴父的少數固定 partition(
map、filter、union、broadcast join)。不需 shuffle,可 pipeline,失敗時只需重算少數 partition。 - Wide dependency
- 子 partition 依賴父的所有 partition(
groupByKey、reduceByKey、join(非 co-partitioned)、distinct、repartition)。需要 shuffle:上游寫 shuffle file 到本地磁碟,下游跨網路拉取。
Shuffle 為什麼貴
- 磁碟 I/O:map 端把輸出依目標 partition 寫成 shuffle file(含排序與 spill)。
- 網路 I/O:每個下游 task 要從所有上游 executor 拉資料,M × R 個連線/區塊。
- 序列化/反序列化與壓縮:CPU 成本。
- 記憶體壓力:下游聚合/排序時可能 spill 或 OOM。
- 容錯:若存放 shuffle file 的 executor 掛了,需要重跑上游 stage(fetch failed)。
減少 shuffle 的手段:broadcast join、用 reduceByKey 而非 groupByKey(前者有 map 端局部聚合,類似 combiner)、提早 filter / select 減少欄位與列數、bucketing、避免不必要的 repartition / distinct / orderBy。
# RDD 時代的經典比較
pairs = sc.parallelize([("a", 1), ("b", 1), ("a", 1)] * 1_000_000)
pairs.groupByKey().mapValues(sum) # 所有 value 都被 shuffle,reducer 端可能 OOM
pairs.reduceByKey(lambda x, y: x + y) # 先在 map 端局部加總,只 shuffle 部分和
# DataFrame 的 groupBy().agg() 會自動做 partial aggregation,不需手動選
Partition 數量調整
| 位置 | 決定因素 | 調整方式 |
|---|---|---|
| 讀檔 | 檔案大小與 spark.sql.files.maxPartitionBytes(預設 128MB) | 調整該參數;檔案本身太小則先合併小檔案 |
| Shuffle 後 | spark.sql.shuffle.partitions(預設 200) | 依資料量設定,或讓 AQE 自動合併(coalesce) |
| 手動 | 程式碼 | repartition(n, col):全量 shuffle,可增可減、可依欄位分佈;coalesce(n):只合併不 shuffle,只能減少 |
經驗法則:每個 partition 約 100–200MB(處理後的資料量),partition 數至少是總 core 數的 2–3 倍以避免閒置。太少 → 每個 task 太大、spill/OOM、平行度不足;太多 → 排程開銷、輸出大量小檔案。
寫檔前用 coalesce(1) 想輸出單一檔案,會讓上游整個 stage 的平行度也降成 1(因為 coalesce 是 narrow,會跟上游 pipeline 在一起),非常慢。需要少量輸出檔時,用 repartition(n)(多一次 shuffle,但上游仍平行),或在寫入時控制 maxRecordsPerFile。
Cache / Persist
因為 lazy evaluation,同一個 DataFrame 被多個 action 使用時,每次都會從頭重算。cache() / persist(level) 讓第一次計算後的結果保留在 executor 記憶體或磁碟。
- DataFrame 的
cache()預設層級為MEMORY_AND_DISK;RDD 的cache()預設為MEMORY_ONLY。 cache()本身也是 lazy 的,要等下一個 action 才真的存。- 只在「被重複使用 + 重算成本高」時才 cache;用完記得
unpersist(),否則佔住 executor 記憶體反而造成其他 stage spill。 - 需要切斷過長 lineage(例如迭代演算法)時用
checkpoint(),會寫到可靠儲存並截斷 DAG。
Broadcast variable 與 Accumulator
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("demo").getOrCreate()
sc = spark.sparkContext
# Broadcast variable:唯讀資料,每個 executor 只傳一次(而非每個 task 都序列化一份)
country_map = sc.broadcast({"TW": "Taiwan", "JP": "Japan", "SG": "Singapore"})
rdd = sc.parallelize(["TW", "JP", "XX"])
print(rdd.map(lambda c: country_map.value.get(c, "Unknown")).collect())
# DataFrame 中強制 broadcast join
small = spark.read.parquet("s3://bucket/dim_campaign/")
big = spark.read.parquet("s3://bucket/impressions/dt=2026-11-01/")
joined = big.join(F.broadcast(small), "campaign_id")
# Accumulator:task 只能累加、driver 才能讀;適合計數/除錯統計
bad_rows = sc.accumulator(0)
def parse(line):
try:
return [int(line)]
except ValueError:
bad_rows.add(1)
return []
nums = sc.parallelize(["1", "x", "3"]).flatMap(parse)
nums.count()
print(bad_rows.value) # 1
在 transformation 中更新 accumulator,若 task 因失敗重試或 stage 被重算,可能被重複累加;Spark 只保證在 action 中(例如 foreach)的更新每個 task 只套用一次。所以 accumulator 適合拿來做監控統計,不適合當業務邏輯的精確計數。
Catalyst、Tungsten 與 AQE
- Catalyst optimizer
- 把 DataFrame/SQL 轉成邏輯計畫 → 解析(analysis,對照 catalog 解析欄位與型別)→ 邏輯最佳化(predicate pushdown、column pruning、constant folding、join reorder)→ 產生多個物理計畫並以成本模型選擇(例如選 broadcast hash join 還是 sort-merge join)→ code generation。用
df.explain(True)可以看到各階段計畫。 - Tungsten
- 執行層最佳化:以緊湊的二進位格式(UnsafeRow)管理記憶體、可用 off-heap 減少 GC 壓力、whole-stage code generation(把一個 stage 中的多個運算子融合成一個 Java 函式,避免虛擬函式呼叫與中間物件)、cache-friendly 的演算法。
- AQE(Adaptive Query Execution)
- Spark 3.0 引入、3.2 起預設開啟。在每個 shuffle stage 完成後,依實際的統計數據重新最佳化剩下的計畫:(1) 自動合併過小的 shuffle partition;(2) 發現某邊實際很小時把 sort-merge join 改成 broadcast join;(3) 拆分傾斜的 partition(skew join)。
spark.conf.set("spark.sql.adaptive.enabled", "true") # 3.2+ 預設已開
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", str(50 * 1024 * 1024)) # 調到 50MB
spark.conf.set("spark.sql.shuffle.partitions", "800") # AQE 開啟時可設大一點,讓它往下合併
df.explain(mode="formatted") # 看物理計畫:BroadcastHashJoin / SortMergeJoin / Exchange(= shuffle)
Spark 效能問題與 PySpark 實戰:上億筆 User Attributes 合併 高頻 深入
PySpark 範例:兩份 user attributes 合併,取每個欄位的最新非空值
這就是「上億筆怎麼 merge」的分散式標準答案。思路:把兩份資料 unionByName 成一張長表(每筆帶 source 與 updated_at),依 user_id 分組,用 window function 取最新的那一筆,或是逐欄位取最新的非空值。
from pyspark.sql import SparkSession, Window, functions as F
spark = (SparkSession.builder
.appName("merge-user-attributes")
.config("spark.sql.adaptive.enabled", "true")
.getOrCreate())
crm = (spark.read.parquet("s3://lake/raw/crm_users/dt=2026-11-01/")
.select(F.lower(F.trim("user_id")).alias("user_id"), # key 正規化
"email", "city", "tier",
F.col("modified_time").cast("timestamp").alias("updated_at"))
.withColumn("source", F.lit("crm"))
.withColumn("priority", F.lit(2)))
app = (spark.read.parquet("s3://lake/raw/app_profiles/dt=2026-11-01/")
.select(F.lower(F.trim("uid")).alias("user_id"),
F.col("mail").alias("email"), "city",
F.col("last_update").cast("timestamp").alias("updated_at"))
.withColumn("tier", F.lit(None).cast("string")) # 補齊欄位
.withColumn("source", F.lit("app_sdk"))
.withColumn("priority", F.lit(1)))
unioned = (crm.unionByName(app)
.filter(F.col("user_id").isNotNull() & (F.col("user_id") != "")))
# --- 做法 A:整筆記錄取最新(row-level latest wins)---
w_row = Window.partitionBy("user_id").orderBy(F.col("updated_at").desc_nulls_last(),
F.col("priority").desc())
latest_row = (unioned
.withColumn("rn", F.row_number().over(w_row))
.filter("rn = 1")
.drop("rn"))
# --- 做法 B:逐欄位取「最新的非空值」(field-level latest non-null)---
w_all = w_row.rowsBetween(Window.unboundedPreceding, Window.unboundedFollowing)
attrs = ["email", "city", "tier"]
field_level = (unioned
.select("user_id",
*[F.first(c, ignorenulls=True).over(w_all).alias(c) for c in attrs],
F.max("updated_at").over(Window.partitionBy("user_id")).alias("updated_at"))
.dropDuplicates(["user_id"]))
# --- 做法 C:用 groupBy + max_by(Spark 3.3+ 的 Python API)一次 shuffle 完成 ---
# 注意 max_by 不會略過 null,若要非空值優先,先把 null 欄位的排序 key 設成極小值
by_group = unioned.groupBy("user_id").agg(
*[F.max_by(c, F.when(F.col(c).isNotNull(), F.col("updated_at"))).alias(c) for c in attrs],
F.max("updated_at").alias("updated_at"))
(field_level
.repartition(256, "user_id") # 控制輸出檔數與大小
.write.mode("overwrite")
.parquet("s3://lake/curated/user_profile/dt=2026-11-01/"))
做法 A 與 B 都需要一次 shuffle(依 user_id 重新分區)加上分區內排序;做法 C 用聚合函式,可以做 map 端的 partial aggregation,通常 shuffle 的資料量更小。max_by(c, ord) 會略過 ord 為 null 的列,所以上面用 when(c 非空, updated_at) 當排序值,就等同「最新的非空值」。如果欄位層級的時間戳不同(例如 city 有自己的 city_updated_at),就把排序 key 換成各自的時間戳。面試時能說出三種寫法的 shuffle 次數與差異,深度就夠了。
Window 預設 frame:有 orderBy 時預設是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。如果你用 F.last() 想取「最後一筆」,在預設 frame 下拿到的是當前列而不是整個分區的最後一筆。所以範例中明確指定 rowsBetween(unboundedPreceding, unboundedFollowing)。
每日增量合併(upsert)
每天全量重算數億筆不一定必要。若今天只有 500 萬人有變動,可以:昨天的 profile 快照 ⨝ 今天的增量 → 新快照。用 Delta Lake / Iceberg 時可以直接寫 MERGE INTO:
MERGE INTO curated.user_profile AS t
USING staging.user_profile_delta AS s
ON t.user_id = s.user_id
WHEN MATCHED AND s.updated_at > t.updated_at THEN UPDATE SET
email = COALESCE(s.email, t.email), -- non-null wins
city = COALESCE(s.city, t.city),
updated_at = s.updated_at
WHEN NOT MATCHED THEN INSERT *;
常見效能問題與解法
| 症狀 | 可能原因 | 怎麼確認 | 解法 |
|---|---|---|---|
| 大部分 task 很快,少數 task 跑超久 | Data skew | Spark UI stage 頁面:task duration / shuffle read size 的 max 遠大於 median | AQE skew join、salting、熱點 key 單獨處理、過濾 null key、兩階段聚合 |
| Executor OOM / container killed by YARN | 單一 partition 太大、collect 到 driver、Python worker 記憶體、broadcast 太大 | log 中 OutOfMemoryError、Container killed ... exceeding memory limits | 增加 partition 數、調高 spark.executor.memoryOverhead(PySpark 常需要)、避免 collect()、避免 Python UDF |
| Driver OOM | collect()、toPandas() 大資料、broadcast 前在 driver 組表、過多小檔案的 metadata | driver log | 改用 write 到儲存;用 limit 取樣;合併小檔案 |
| 讀檔 stage 的 task 數極多、每個只有幾 KB | Small files problem | 輸入檔案數 vs 總大小 | 上游寫入時 repartition / compaction、Delta/Iceberg 的 OPTIMIZE / rewrite data files |
| 大量 spill(Spill (Memory) / Spill (Disk)) | Partition 太大、排序/聚合記憶體不足 | Spark UI 的 spill 指標 | 增加 shuffle partitions、增加 executor 記憶體、減少欄位 |
| Job 很慢但 CPU 閒置 | 平行度不足(partition 數 < core 數)、等待 I/O | 同時執行的 task 數 | 增加 partition、檢查是否 coalesce(1) |
| 同一段邏輯被重算多次 | 重複使用未 cache 的 DataFrame | SQL 頁籤看到相同的 scan 出現多次 | cache() 後再用多次 |
| GC 時間佔比高 | 大量物件、RDD of Python objects | executor 頁的 GC time | 改用 DataFrame、Kryo 序列化、調整記憶體 |
被問「Spark job 很慢你怎麼查」時,用系統化的順序回答:(1) 看 Spark UI 找出最慢的 stage;(2) 看該 stage 的 task 時間分布判斷是 skew 還是整體慢;(3) 看 shuffle read/write 與 spill 大小;(4) 看 SQL 頁籤的物理計畫,確認 join 策略是否符合預期、filter 是否下推;(5) 再決定調 partition、改 join 策略、或改資料佈局。這與第 8 章的 profiling 思維一致:先量測,再優化。
檔案格式:CSV/JSON vs Parquet/ORC vs Avro 高頻 進階
在大數據場景中,選對檔案格式常常比調參數有用十倍。核心區別是資料在磁碟上的排列方式:行式(row-oriented,一筆記錄的所有欄位放在一起)或列式(column-oriented,同一欄位的所有值放在一起)。
行式:CSV / JSON / Avro
一次寫入整筆記錄很自然,適合寫入密集、需要整筆讀取的場景(例如 Kafka 訊息、交易 log、OLTP)。缺點:分析查詢只要 2 個欄位也得讀完整筆記錄;同欄位的值不相鄰,壓縮率差。
列式:Parquet / ORC
同欄位的值連續存放:查詢只讀需要的欄位(column pruning);同型別、相似值連在一起,可用 dictionary / run-length / delta 編碼,壓縮率高;每個區塊有 min/max 統計,可跳過不相關的區塊(predicate pushdown)。缺點:寫入要先緩衝成批、不適合逐筆更新。
| 格式 | 佈局 | Schema | 可切分(splittable) | 典型用途 |
|---|---|---|---|---|
| CSV | 行式、純文字 | 無(型別全靠猜) | 未壓縮可切;gzip 壓縮後不可切 | 人工交換、小量匯入匯出 |
| JSON Lines | 行式、純文字 | 自描述但無強制 | 同上 | SDK 事件 log、API 原始資料 |
| Avro | 行式、二進位 | 有(schema 隨檔案存放),支援 schema evolution | 可(以 block 為單位) | Kafka 訊息(搭配 Schema Registry)、資料落地的 raw 層 |
| Parquet | 列式(row group → column chunk → page) | 有,存在 footer | 可(以 row group 為單位) | Spark / data lake 分析的事實標準 |
| ORC | 列式(stripe) | 有 | 可 | Hive 生態系、內建輕量索引 |
Parquet 內部結構與兩大最佳化
- 檔案 → row group(一批列,常見設定約 128MB)→ column chunk(該 row group 中某一欄的資料)→ page(編碼與壓縮單位)。
- Footer 存放 schema 以及每個 row group、每個 column chunk 的統計(min、max、null count)。讀取端先讀 footer 決定要讀哪些部分。
- Column pruning:
SELECT user_id, city只讀這兩個 column chunk,其他欄位完全不碰磁碟。 - Predicate pushdown:
WHERE event_ts >= '2026-11-01'時,若某 row group 的 event_ts max 小於該值,整個 row group 跳過。資料若事先依過濾欄位排序或 clustering,min/max 範圍才會窄,跳過的效果才好。
import pyarrow as pa
import pyarrow.parquet as pq
import pyarrow.dataset as ds
table = pa.table({"user_id": ["u1", "u2", "u3"], "city": ["Taipei", "Tokyo", None],
"event_ts": pa.array([1730419200, 1730505600, 1730592000], pa.int64())})
pq.write_table(table, "users.parquet", compression="zstd", row_group_size=1_000_000)
# 只讀兩欄 + 過濾:pyarrow 會用 footer 統計做 row group 跳過
dataset = ds.dataset("users.parquet", format="parquet")
result = dataset.to_table(columns=["user_id", "city"],
filter=ds.field("event_ts") >= 1730500000)
print(result.to_pydict())
print(pq.ParquetFile("users.parquet").metadata.row_group(0).column(2).statistics)
壓縮演算法
| 演算法 | 壓縮率 | 速度 | 適用 |
|---|---|---|---|
| Snappy | 中 | 非常快 | Spark 寫 Parquet 的預設;熱資料、CPU 敏感的場景 |
| LZ4 | 中 | 非常快(解壓特別快) | 即時系統、ClickHouse 預設、Kafka 也常用 |
| Zstd | 高(可調等級) | 快,解壓也快 | 現代的平衡首選,冷資料可用高等級 |
| Gzip | 高 | 慢 | 相容性需求;注意 gzip 的 CSV/JSON 不可切分,一個大 .gz 檔只能由一個 task 讀 |
以廣告平台常見做法為例:SDK / bidder 產生的原始事件先以 JSON 或 Avro 進 Kafka,落地到物件儲存時轉成依日期(甚至小時)分區的 Parquet;報表查詢通常只用到少數欄位(campaign_id、country、事件類型、計數),列式格式能讓掃描量降一個數量級以上。在 BigQuery 這類按掃描量計費的倉儲上,這直接等於成本。
Data Lake、Lakehouse、資料倉儲與 OLTP vs OLAP 進階
OLTP vs OLAP
| OLTP(線上交易處理) | OLAP(線上分析處理) | |
|---|---|---|
| 典型查詢 | 依主鍵讀寫少數幾筆:UPDATE balance WHERE id=? | 掃描大量資料做聚合:SUM(spend) GROUP BY campaign, day |
| 儲存 | 行式、B+tree 索引 | 列式、壓縮、分區、向量化執行 |
| 重點 | 低延遲、高並行、交易一致性(ACID) | 高吞吐掃描、複雜聚合 |
| Schema | 正規化(3NF) | 星狀 / 雪花 schema、寬表(反正規化) |
| 代表 | MySQL、PostgreSQL、MongoDB | BigQuery、Redshift、Snowflake、ClickHouse、Druid |
在線上 MySQL 主庫上跑報表等級的聚合查詢,會搶走 buffer pool 與 I/O、拖慢線上交易。標準做法是透過 CDC(例如 Debezium 讀 binlog)把資料同步到分析系統,或至少使用唯讀 replica。
Data Lake 與 Lakehouse
Data lake:把各種原始資料以開放格式(Parquet、JSON、Avro)放在便宜的物件儲存(S3、GCS),用 Spark / Trino / Hive 查詢。優點是便宜、彈性;缺點是原本沒有交易保證:寫到一半失敗留下殘檔、同時寫入互相覆蓋、無法有效率地 update/delete(GDPR 刪除請求就很痛苦)、schema 變更混亂(俗稱 data swamp)。
Lakehouse / Open table format 在物件儲存上加一層 metadata,提供 ACID 交易:
- Apache Iceberg
- 以 metadata 檔 + manifest 追蹤每個 snapshot 包含哪些資料檔。支援 hidden partitioning(查詢者不需知道分區欄位怎麼算)、partition evolution、schema evolution、time travel、
MERGE INTO。引擎中立(Spark、Flink、Trino、Snowflake 等都支援),近年採用度很高。 - Delta Lake
- 由 Databricks 開源,以
_delta_log交易日誌(JSON + 定期 checkpoint)記錄每次 commit。與 Spark 整合最緊密,支援 time travel、OPTIMIZE(合併小檔案)、Z-ordering。 - Apache Hudi
- 源自 Uber,重點在高頻 upsert 與增量讀取。兩種表型:Copy-on-Write(寫入時重寫檔案,讀快寫慢)與 Merge-on-Read(寫入 log 檔,讀取時合併,寫快讀慢)。
雲端資料倉儲與 OLAP 引擎
| 系統 | 架構特色 | 適用 | 注意 |
|---|---|---|---|
| BigQuery | Serverless,儲存與運算分離,列式儲存,依掃描量或預留 slot 計費 | 不想管叢集的大規模 ad-hoc 分析 | 務必用 partition + clustering,避免 SELECT * 掃全表燒錢 |
| Redshift | AWS 的 MPP 列式倉儲,distribution key / sort key 決定資料分布與排序(另有 serverless 版本) | AWS 生態系、穩定的 BI 工作負載 | dist key 選不好會導致 join 時大量資料重分佈 |
| Snowflake | 儲存與運算完全分離,多個獨立的 virtual warehouse 可同時讀同一份資料;micro-partition 自動管理 | 多團隊共用、工作負載隔離 | 依運算時間計費,warehouse 要設 auto-suspend |
| ClickHouse | 開源、列式、向量化執行,MergeTree 引擎家族,極快的聚合查詢 | 即時報表、事件分析、廣告報表 | 不擅長高頻更新/刪除與大表間的複雜 join;資料最好批次寫入 |
| Apache Druid / Pinot | 為即時 OLAP 設計,可直接從 Kafka 攝取,ingest 時預先 rollup | 面向使用者的即時 dashboard | 預聚合會失去明細;運維元件較多 |
JSON / Avro on S3"] RAW -- "Spark 清洗" --> CLEAN["Cleaned (silver)
Parquet + Iceberg/Delta"] CLEAN -- "聚合 / 特徵" --> GOLD["Curated (gold)
user_profile, daily_report"] GOLD --> WH["BigQuery / ClickHouse"] GOLD --> FS["Feature store / Redis
(線上查詢)"] WH --> BI["Dashboard / BI"] K -- "Flink 即時" --> FS
「merge 後的 user profile 要給誰用」決定存放位置:給分析師與報表 → 列式倉儲或 lakehouse 表;給線上服務(例如 bidder 每次請求要查使用者分群)→ 低延遲 key-value 儲存(Redis、Aerospike、Bigtable/HBase、DynamoDB 一類)。一份資料常常同時要有離線與線上兩種形態,並且要保證兩邊一致(這就是 feature store 要解決的問題,見第 14 章)。
批次 vs 串流、Lambda/Kappa、Flink 與 Structured Streaming 進階 深入
批次 vs 串流
| 批次(Batch) | 串流(Streaming) | |
|---|---|---|
| 資料 | 有界(bounded),例如「昨天整天的資料」 | 無界(unbounded),持續到達 |
| 延遲 | 分鐘到小時 | 毫秒到秒 |
| 正確性 | 容易:資料到齊才算,可重跑 | 困難:亂序、遲到資料、狀態管理、精確一次 |
| 成本 | 可用便宜的 spot 機器、跑完就關 | 長駐運算資源、狀態儲存 |
| 代表 | Spark、Hive、BigQuery 排程查詢 | Flink、Kafka Streams、Spark Structured Streaming |
Lambda vs Kappa 架構
Lambda 架構
同時維護批次層(定期全量重算,正確但慢)與速度層(串流,快但可能近似),查詢時由 serving 層合併兩者。優點:批次層可修正串流的錯誤。缺點:同一份邏輯要寫兩套(Spark + Flink),容易不一致、維護成本高。
Kappa 架構
只有串流一條路徑;需要重算時,把 Kafka 中保留的歷史事件從頭重播給新版本的 job,算完後切換。優點:一套程式碼。前提:事件 log 保留夠久(或分層儲存)、串流引擎能正確處理重播與狀態。
實務上很多公司是「混合」:即時指標走串流(例如即時花費、pacing),隔天用批次重算出精確的帳務數字並覆寫。這對廣告業特別常見:即時數字用於決策,批次數字用於計費與對帳。
Flink 核心概念
- Event time vs Processing time
- Event time 是事件實際發生的時間(寫在事件裡,例如手機 SDK 的時間戳);processing time 是運算元處理它時的系統時鐘。以 processing time 計算的「每分鐘點擊數」會受網路延遲、重播、積壓影響而失真;正確的業務統計應用 event time。
- Watermark
- 串流中的一個特殊標記 W(t),宣告「event time ≤ t 的事件應該都到了」。當 watermark 超過某個 window 的結束時間,該 window 才觸發計算。常見策略:
watermark = 目前看到的最大 event time − 允許亂序時間(bounded out-of-orderness)。Watermark 太保守 → 延遲高;太激進 → 更多遲到資料被丟棄或需另外處理(allowed lateness、side output)。 - Window 類型
- Tumbling(固定大小、不重疊:每 1 分鐘一個窗)、Sliding(固定大小、有重疊:長度 10 分鐘、每 1 分鐘滑動,一個事件屬於多個窗)、Session(依活動間隔切分:超過 30 分鐘沒事件就結束一個 session,窗長度不固定)、以及 Global window 搭配自訂 trigger。
- State
- 運算元需要記住的資料(例如每個 user 目前的合併屬性、每個 campaign 的累計花費)。Keyed state 依 key 分區,與資料一起 partition。State backend:HashMap(在 JVM heap,快但受記憶體限制)或 RocksDB(嵌入式 LSM 存在本地磁碟,可支撐遠大於記憶體的狀態,支援增量 checkpoint)。
- Checkpoint
- Flink 定期在資料流中插入 checkpoint barrier,各運算元收到 barrier 時把狀態快照到持久儲存(S3/HDFS),基於 Chandy-Lamport 分散式快照的變形。失敗時從最近的 checkpoint 還原狀態,並把 source(例如 Kafka offset)倒回對應位置重播。Savepoint 是手動觸發、用於升級/遷移的快照。
- Exactly-once
- Flink 內部狀態的 exactly-once 由 checkpoint 保證。端到端 exactly-once 還需要:可重播的 source(Kafka)+ 交易式或冪等的 sink(例如 Kafka transactional producer 搭配兩階段提交:checkpoint 完成時才 commit 交易;或寫入時以唯一 key upsert)。下游讀 Kafka 時要設
isolation.level=read_committed才看不到未提交的資料。
還原 state, Kafka offset 倒回, 未 commit 的 txn 中止
即時版本的 user attributes 合併
若面試官要求「使用者屬性一更新,幾秒內就要反映到線上」,概念上是一個以 user_id 為 key 的有狀態串流:每來一個屬性事件,就讀出該 user 目前的狀態、套用衝突規則、寫回狀態並輸出到線上 KV store。以下用純 Python 示意其邏輯(實務上由 Flink keyed state 或 Kafka Streams KTable 提供分區、容錯與重播):
from dataclasses import dataclass, field
@dataclass
class ProfileState:
fields: dict[str, tuple[object, int]] = field(default_factory=dict) # name -> (value, event_ts)
def process(state: dict[str, ProfileState], event: dict, sink) -> None:
"""event: {"user_id": ..., "event_ts": ..., "attrs": {...}};可亂序到達。"""
st = state.setdefault(event["user_id"], ProfileState())
changed = False
for name, value in event["attrs"].items():
if value is None:
continue # non-null wins
old = st.fields.get(name)
if old is None or event["event_ts"] >= old[1]: # 依 event time,而非到達順序
st.fields[name] = (value, event["event_ts"])
changed = True
if changed:
# 對 sink 做 upsert(以 user_id 為主鍵)→ 重播同一事件也不會產生重複:冪等
sink.upsert(event["user_id"], {k: v for k, (v, _) in st.fields.items()})
「以 event time 比較、冪等 upsert」這兩點,讓這個串流邏輯天然容忍亂序與重複投遞(at-least-once)。很多時候與其追求昂貴的端到端 exactly-once,不如設計成at-least-once + 冪等寫入 = 效果上的 exactly-once。這也是第 11 章訊息佇列的核心觀念。
Spark Structured Streaming vs Flink
| Spark Structured Streaming | Apache Flink | |
|---|---|---|
| 執行模型 | 預設 micro-batch(每個 trigger 處理一小批);另有實驗性的 continuous 模式 | 真正的逐筆串流(record-at-a-time,pipeline 式) |
| 延遲 | 通常秒級(取決於 trigger 間隔與每批處理時間) | 可到毫秒級 |
| API | 與批次相同的 DataFrame/SQL API,批次與串流程式碼高度共用 | DataStream API + Table/SQL API |
| 狀態與時間 | 支援 watermark(withWatermark)、event-time window、狀態儲存(可用 RocksDB) | 最完整:細緻的 timer、allowed lateness、side output、大狀態 |
| 適合 | 已有 Spark 生態、延遲要求秒級、希望批次串流一套程式 | 低延遲、複雜事件處理、大狀態、精細的時間語意 |
from pyspark.sql import functions as F
clicks = (spark.readStream.format("kafka")
.option("kafka.bootstrap.servers", "kafka:9092")
.option("subscribe", "ad_clicks")
.load()
.select(F.from_json(F.col("value").cast("string"),
"campaign_id STRING, user_id STRING, event_ts TIMESTAMP").alias("e"))
.select("e.*"))
per_minute = (clicks
.withWatermark("event_ts", "2 minutes") # 允許 2 分鐘亂序
.groupBy(F.window("event_ts", "1 minute"), "campaign_id") # tumbling window
.agg(F.count("*").alias("clicks"),
F.approx_count_distinct("user_id").alias("uu"))) # HyperLogLog 近似 UU
(per_minute.writeStream
.outputMode("update")
.option("checkpointLocation", "s3://bucket/checkpoints/clicks_per_minute/")
.format("console")
.trigger(processingTime="10 seconds")
.start())
以廣告平台常見做法為例:預算 pacing 與即時花費需要秒級更新(花超了就是真金白銀的損失),適合串流;給客戶的對帳報表則需要精確、可稽核,通常以批次在資料到齊後重算,並處理去重、作弊流量過濾等規則。面試時能說出「同一個指標,不同用途對延遲與精確度的要求不同」,就展現了對業務的理解。
資料管線編排、冪等重跑與資料品質 進階
當 merge 要「每天跑」,問題就從演算法變成工程可靠性:失敗怎麼重試?上游資料晚到怎麼辦?發現 bug 要重算過去 30 天怎麼辦?資料壞掉誰會知道?
Airflow DAG 範例
Airflow 用 Python 定義 DAG(有向無環圖),每個節點是一個 task,排程器依時間與相依關係觸發。關鍵概念:每次執行對應一個邏輯日期(logical date,舊稱 execution_date),task 應該只處理「該邏輯日期」的資料,而不是「現在」的資料。以下以 Airflow 2.x 語法為例:
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.sensors.external_task import ExternalTaskSensor
default_args = {
"owner": "data-platform",
"retries": 3,
"retry_delay": timedelta(minutes=10),
"retry_exponential_backoff": True,
}
with DAG(
dag_id="daily_user_profile_merge",
start_date=datetime(2026, 1, 1),
schedule="0 2 * * *", # 每天 02:00(依 DAG 時區設定)
catchup=False, # 新部署時不自動補跑歷史;需要時手動 backfill
max_active_runs=1,
default_args=default_args,
tags=["profile"],
) as dag:
wait_crm = ExternalTaskSensor(
task_id="wait_crm_export",
external_dag_id="crm_export",
external_task_id="done",
mode="reschedule", # 等待時釋放 worker slot
timeout=6 * 3600,
)
merge = BashOperator(
task_id="spark_merge",
# {{ ds }} 是邏輯日期(YYYY-MM-DD):同一個 ds 無論重跑幾次都處理同一天的資料
bash_command="spark-submit merge_profiles.py --dt {{ ds }}",
)
validate = BashOperator(
task_id="validate",
bash_command="python validate_profile.py --dt {{ ds }}",
)
publish = BashOperator(
task_id="publish_to_online_store",
bash_command="python publish.py --dt {{ ds }}",
)
wait_crm >> merge >> validate >> publish
冪等(idempotent)與 backfill
冪等的意思是:同一個邏輯日期的 job 跑一次與跑十次,結果完全相同。這是能安全重試與 backfill 的前提。
- 輸入固定:依
{{ ds }}讀取對應分區,不要用datetime.now(),也不要讀「最新」的資料。 - 輸出覆寫而非附加:
INSERT OVERWRITE ... PARTITION (dt=...)、Sparkmode("overwrite")搭配spark.sql.sources.partitionOverwriteMode=dynamic(只覆寫有寫到的分區,避免整張表被清空)。用 append 重跑就會產生重複資料。 - 原子發佈:先寫到暫存位置,驗證通過後再切換(rename、更新指標、table format 的原子 commit),讀取端永遠看不到寫一半的資料。
- 確定性:衝突規則要有 deterministic tie-break(見第 2 節),避免
row_number()在平手時每次結果不同。 - Backfill:修了 bug 要重算 10 月:
airflow dags backfill -s 2026-10-01 -e 2026-10-31 daily_user_profile_merge(Airflow 2.x CLI)。若每天的結果依賴前一天(增量快照),backfill 必須依序執行(max_active_runs=1或depends_on_past=True)。
資料品質檢查
| 檢查類型 | 例子 | 發現異常時 |
|---|---|---|
| 完整性(volume) | 今天筆數與過去 7 天平均相差超過 30% | 阻擋發佈、通知值班 |
| 唯一性 | 輸出中 user_id 不得重複 | 阻擋發佈 |
| 非空 / 格式 | email 符合格式、country 為 ISO 代碼 | 計算錯誤率,超過門檻才阻擋 |
| 參照完整性 | 事件中的 campaign_id 都存在於 campaign 表 | 記錄 orphan 數量 |
| 新鮮度(freshness) | 最新分區不得晚於 N 小時 | 告警 |
| 分布漂移 | 某國家比例突然大變、null 比例突增 | 告警,人工確認(可能是上游 schema 變更) |
from pyspark.sql import functions as F
def validate(df, dt: str, expected_min: int) -> None:
stats = df.agg(
F.count("*").alias("rows"),
F.countDistinct("user_id").alias("uniq"),
F.sum(F.col("email").isNull().cast("int")).alias("null_email"),
).first()
errors = []
if stats["rows"] < expected_min:
errors.append(f"row count {stats['rows']} < {expected_min}")
if stats["rows"] != stats["uniq"]:
errors.append(f"duplicate user_id: {stats['rows'] - stats['uniq']}")
if stats["rows"] and stats["null_email"] / stats["rows"] > 0.5:
errors.append("null email ratio > 50%")
if errors:
raise ValueError(f"[{dt}] data quality failed: {errors}") # 讓 Airflow task 失敗
「要每天跑」這個追問,面試官期待的關鍵字是:排程與相依(Airflow)、重試、冪等(分區覆寫)、可 backfill、資料品質閘門、監控告警(執行時間、筆數、新鮮度)。如果再提到「上游 schema 改變時用 schema registry / 合約檢查提早發現」,會很加分。
CUDA / GPU 平行運算:一句話概念與何時用
一句話:GPU 有數千個簡單核心,以 SIMT(同一指令、多執行緒)方式對大量資料做同樣的數值運算;CUDA 是 NVIDIA 讓你撰寫這類 kernel 的平台。
- 適合:資料平行、運算密集、分支少的工作 —— 深度學習訓練與推論、矩陣運算、向量相似度搜尋、大規模數值模擬;在資料處理上也有 RAPIDS(cuDF、Spark RAPIDS Accelerator)把 DataFrame 運算搬到 GPU。
- 不適合:I/O-bound 的工作(瓶頸在讀檔/網路,GPU 幫不上)、分支很多的邏輯、資料量小(CPU 與 GPU 之間透過 PCIe 搬資料的成本可能大於運算省下的時間)、需要大量隨機存取的字串處理。
- 面試回答:「上億筆 merge 本質上是 I/O 與 shuffle 密集,GPU 通常不是第一選擇;但如果後續要訓練 CTR 模型或做 embedding 相似度計算,GPU 就很關鍵。」
Appier 情境:跨渠道使用者資料整合與 Identity Resolution 深入
「merge user attributes from multiple data sources」在 AdTech / MarTech 中不只是 join:同一個人可能在不同渠道有不同的 ID —— 網站 cookie、App 的裝置 ID(IDFA/GAID 或 app 自有 ID)、登入後的會員 ID、email、手機號碼、LINE ID。要先回答「哪些 ID 屬於同一個人」,才能合併屬性。這就是 identity resolution(身分解析/ID 打通)。
Appier 的 AIXON 定位為資料科學/受眾分析平台、AIQUA 為跨渠道行銷自動化,這類產品的價值都建立在「把同一位使用者在網站、App、CRM、訊息渠道的行為串起來」之上。以下以業界常見做法說明概念,並非 Appier 內部實作。
- Deterministic matching(確定性比對)
- 靠明確的共同識別碼連結:同一個登入帳號在網站與 App 都出現、同一個 hashed email。準確但覆蓋率受限於登入率。
- Probabilistic matching(機率性比對)
- 靠訊號推測:相同 IP + 相似時間 + 裝置特徵。覆蓋率高但有誤判,且受隱私法規與平台政策嚴格限制(例如裝置指紋在多數平台政策下不被允許),實務上要非常謹慎。
- Identity graph
- 節點是各種 ID,邊是「曾經一起出現」的證據。同一個人 = 圖中的一個連通分量(connected component)。
單機版:Union-Find 求連通分量
class UnionFind:
def __init__(self) -> None:
self.parent: dict[str, str] = {}
self.rank: dict[str, int] = {}
def find(self, x: str) -> str:
self.parent.setdefault(x, x)
self.rank.setdefault(x, 0)
root = x
while self.parent[root] != root: # 迭代版,避免遞迴深度問題
root = self.parent[root]
while self.parent[x] != root: # path compression
self.parent[x], x = root, self.parent[x]
return root
def union(self, a: str, b: str) -> None:
ra, rb = self.find(a), self.find(b)
if ra == rb:
return
if self.rank[ra] < self.rank[rb]: # union by rank
ra, rb = rb, ra
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]:
self.rank[ra] += 1
# 每一列是一次「同時出現」的證據
links = [
("cookie:c1", "member:m100"), # 網站登入
("device:d9", "member:m100"), # App 登入
("device:d9", "email:h(a@x.com)"),
("cookie:c7", "email:h(b@y.com)"),
]
uf = UnionFind()
for a, b in links:
uf.union(a, b)
groups: dict[str, list[str]] = {}
for node in list(uf.parent):
groups.setdefault(uf.find(node), []).append(node)
print(list(groups.values()))
# [['cookie:c1', 'member:m100', 'device:d9', 'email:h(a@x.com)'], ['cookie:c7', 'email:h(b@y.com)']]
近乎 O(α(n)) 的均攤複雜度(α 為反 Ackermann 函數,實務上視為常數)。得到每個 ID 對應的「統一 ID(root)」後,就能把各渠道的屬性依統一 ID 用前面的衝突規則合併。
上億個節點:分散式連通分量
- 迭代式 label propagation:每個節點的 label 初始為自己,每輪取鄰居中最小的 label,直到不再變化。每輪是一次 Spark join + groupBy,輪數約為圖的直徑(通常不大,但遇到長鏈會很多輪)。
- GraphFrames 的
connectedComponents()(Spark 上的圖運算套件)實作了更有效率的演算法,並需要設定 checkpoint 目錄截斷 lineage。 - 防止「超級節點」:公共 Wi-Fi 的 IP、共用平板、測試帳號、錯誤的預設值(例如所有人的 email 都是
test@test.com)會把數百萬人連成一個巨大分量。必須設定規則:某個 ID 連到超過 N 個其他 ID 就視為不可信、不參與合併。這是真實系統最常出事的地方,面試提到會很加分。
Identity graph 涉及個人資料:email / 手機要先正規化再以雜湊(如 SHA-256)處理、遵守各渠道的同意(consent)狀態、支援使用者刪除請求(GDPR 等法規下的 right to erasure),而且刪除要能傳播到所有衍生資料。設計資料模型時就要把 consent 與刪除路徑考慮進去,而不是事後補。
完整面試回答範本:逐層追問對答劇本 高頻
以下是把本章串起來的模擬對話。重點不是背台詞,而是學會每一層的回答結構:釐清 → 方案 → 複雜度 → trade-off → 主動提下一步。
第一層:「兩份資料怎麼 merge?」
「我先確認幾件事:join key 是 user_id 嗎?在單一來源內是否唯一?要保留只出現在一邊的資料嗎(我先假設是 full outer)?同一欄位兩邊值不同時以誰為準?如果沒有特別規定,我會用『欄位時間戳較新者優先,平手時依來源優先序,空值不覆蓋有值』這組規則。」
「實作上我用一個 dict 以 user_id 為 key,依序掃過兩份資料,每筆對每個欄位套用衝突規則。時間 O(n+m)、空間 O(n+m)。不用雙層迴圈,因為那是 O(n×m)。我也會先正規化 key(型別、大小寫、空白),並讓合併規則與輸入順序無關,這樣之後要平行化也不會出錯。」(接著寫出第 2 節的程式碼。)
第二層:「資料量增加到上億筆怎麼辦?」
「先估算:一億筆、每筆約 200 bytes,原始資料約 20GB;載入成 Python dict 會因物件開銷膨脹好幾倍,可能超過 50GB。所以要看兩邊的大小關係:」
「如果其中一邊小(例如 CRM 只有幾百萬筆),就只把小的那邊建成 hash map,大的一邊用 generator 串流讀,記憶體只跟小表成正比 —— 這就是 hash join 的 build/probe,或 Spark 的 broadcast join。我也會換成更省記憶體的表示法,例如只保留必要欄位、用 pandas/Arrow 的欄式結構取代 dict。」
第三層:「機器記憶體只有 8GB,而且兩份都很大?」
「那就要 out-of-core。兩個選擇:(1) Hash partition:兩份資料都用同一個穩定的 hash(例如 crc32,不能用 Python 內建 hash,因為 str 的 hash 每個 process 隨機化)對 user_id 分成例如 128 個桶寫到磁碟,相同 key 一定落在同編號的桶,接著逐桶載入記憶體做 hash join。每桶約幾百 MB,8GB 綽綽有餘,而且各桶可以多 process 平行。(2) External sort:兩份都先切塊排序再用 heap 做 k-way merge,接著兩個指標一起往前走做 sort-merge join,記憶體幾乎是常數。」
「兩者都大約讀寫資料兩到三趟。分桶通常較快,但要小心 skew:如果某個 key(例如空字串或測試帳號)特別多,那一桶會爆,要先過濾或對大桶再分一次。實務上我也會考慮直接用 GNU sort + join,或 DuckDB 這類能 spill 到磁碟的嵌入式分析引擎,開發成本最低。」
第四層:「這要每天跑,而且單機要跑好幾個小時?」
「這時就值得上 Spark。邏輯是:兩份資料 unionByName,依 user_id 用 window function 或 max_by 聚合取每個欄位最新的非空值。這會產生一次 shuffle;如果其中一邊小於幾十 MB,我會 broadcast 它避免 shuffle。我會注意 shuffle partition 數、開 AQE,並在 Spark UI 檢查 skew 與 spill。」
「排程用 Airflow:sensor 等上游資料到齊 → Spark job → 資料品質檢查(筆數、唯一性、null 比例)→ 發佈。每個 run 只處理自己邏輯日期的分區並且覆寫輸出分區,所以重試與 backfill 都是冪等的。如果每天變動的使用者只佔一小部分,可以改成增量:昨天快照 MERGE 今天的變更,用 Iceberg/Delta 提供原子 commit。輸出存成依日期分區的 Parquet 給分析用,另外推一份到 KV store 給線上服務查詢。」
第五層:「如果要即時,屬性一變就要生效?」
「改成事件驅動:各來源把屬性變更寫進 Kafka,以 user_id 作為 partition key,保證同一使用者的事件進同一個 partition。Flink job 用 keyed state 存每個使用者目前的屬性與各欄位時間戳,每來一個事件就套同一套衝突規則,以 event time 而非到達順序判斷新舊,再 upsert 到線上 KV store。狀態用 RocksDB backend,透過 checkpoint 容錯;sink 是以 user_id 為主鍵的 upsert,所以就算 at-least-once 重送也不會出錯。」
「我會保留每日批次作為校正:串流負責新鮮度,批次負責最終正確性與修復,這是 Lambda 的精神;若團隊想只維護一套邏輯,則可以走 Kappa,靠重播 Kafka 來重算。」
可能的延伸追問與一句話回答
| 追問 | 一句話回答 |
|---|---|
| ID 不一致怎麼辦(cookie、裝置、會員 ID)? | 先做 identity resolution:建 identity graph、用 union-find / connected components 求統一 ID,並排除超級節點。 |
| 怎麼驗證結果正確? | 小樣本與單機版結果比對、唯一性與筆數守恆檢查(輸出 UU 應等於兩邊 key 的聯集)、抽樣人工核對。 |
| Hash 分桶的 N 怎麼選? | 讓每桶的 build side 遠小於可用記憶體,留 skew 餘裕,同時受限於檔案描述符上限。 |
| 為什麼不用 MySQL 直接 JOIN? | 一次性中量資料可以;上億筆的全表 join 會長時間佔用線上資料庫資源,且無法水平擴展,應交給分析引擎。 |
| 成本怎麼控制? | 列式格式 + 分區裁剪減少掃描量、只處理增量、批次用 spot instance、避免不必要的 shuffle。 |
面試自測題
合併兩個 List[dict] 中相同 id 的資料,請寫出程式並說明複雜度。同一欄位衝突時怎麼處理?
用 dict 以 id 為 key 掃過兩份資料,每筆對每個欄位套用衝突規則後寫入,時間 O(n+m)、空間 O(n+m)。衝突規則常見有:最新時間戳優先、來源優先序、非空值優先(不讓 None 覆蓋)、list 取聯集、數值取 min/max/sum。要特別提:key 正規化(int vs str、大小寫)、不要修改輸入物件、tie-break 要 deterministic(例如 (ts, priority) 一起比較),讓合併結果與輸入順序無關。追問方向:多個來源怎麼擴展(把規則參數化成 field → function 的對照表)、要保留值的來源(lineage)方便除錯。
一億筆使用者資料大約需要多少記憶體?為什麼 Python 特別吃記憶體?
若每筆約 200 bytes,原始資料約 20GB。但 Python 中每個 dict、每個 str、每個 int 都是獨立的 heap 物件,有物件標頭、引用計數、型別指標等開銷(小 int 約 28 bytes、短 str 約 49 bytes 加內容、dict 本身數百 bytes),一筆記錄常常膨脹到數百 bytes 至 1KB 以上,總量可能 50–100GB。降低方式:只留必要欄位、用 __slots__/dataclass(slots=True) 或 tuple、用 numpy/pandas/Arrow 的連續陣列、對重複字串做 interning 或類別編碼、或乾脆不全部載入(串流處理)。
兩份已依 key 排序的大檔案要做 full outer join,記憶體只有 O(1),怎麼做?
用類似 merge sort 的雙指標:各自用 iterator 讀,把連續相同 key 的記錄聚成一組(groupby)。比較兩邊目前的 key:較小的一邊表示只有它有,輸出後前進;相等則合併兩組後一起前進。時間 O(n+m),只做循序 I/O。陷阱:兩邊的排序規則必須一致(字串 vs 數字、locale),否則會默默漏配對;可以在讀取時檢查 key 單調遞增,逆序就報錯。記憶體實際上是 O(同一個 key 的最大筆數)。
說明 external merge sort 的兩個階段與 I/O 成本。Python 怎麼實作 k-way merge?
階段一(run generation):每次讀入記憶體放得下的量,排序後寫成暫存的 run。階段二(k-way merge):同時開所有 run,用大小為 k 的 min-heap 每次取最小值輸出。若 run 數不超過可同時開啟的檔案與緩衝限制,總共只需讀寫資料約兩趟;否則要多輪合併,趟數約為 log_k(run 數)。Python 中 heapq.merge(*iterators, key=...) 就是 lazy 的 k-way merge,每個 iterator 同時只有一筆在 heap 裡。實務上也可以直接用 GNU sort(-S 設緩衝、-T 設暫存目錄、--parallel)。
Hash partition 分桶 join(Grace hash join)怎麼做?為什麼不能用 Python 內建的 hash()?
兩份資料都用同一個 hash 函式與同一個桶數 N,依 hash(key) % N 寫到各自的 N 個檔案,相同 key 必定落在同編號的桶;之後逐桶把較小的一邊載入成 dict,串流另一邊做 hash join,各桶可平行處理。Python 3 對 str/bytes 的 hash() 預設以每個 process 隨機的 seed 計算(PYTHONHASHSEED),不同 process 或不同次執行結果不同,若 A、B 在不同 process 分桶就會對不上,所以要用 crc32、md5、xxhash/murmurhash 等穩定雜湊。N 的選擇:讓每桶 build side 遠小於記憶體,並考慮 skew 與檔案描述符上限。
Broadcast join 與 shuffle join 的差別?Spark 何時會自動選 broadcast?
Broadcast join 把小表完整複製到每個 executor,大表不需移動,每個 partition 在本地做 hash join,省掉大表的 shuffle;前提是小表要真的小,否則會撐爆 driver 與 executor 記憶體。Shuffle join 則讓兩表都依 join key hash 重新分區,透過網路送到同一 task,再做 sort-merge join(Spark 對大表等值 join 的預設)。Spark 會在估計大小小於 spark.sql.autoBroadcastJoinThreshold(預設 10MB)時自動 broadcast,也可用 F.broadcast() hint;AQE 開啟時,還會在執行期依實際大小把 sort-merge join 改成 broadcast join。
什麼是 data skew?如何偵測與解決?
Skew 是某些 key 的資料量遠大於其他 key,使 shuffle 後少數 task 特別大、特別慢,甚至 OOM。偵測:Spark UI 中某 stage 的 task duration 或 shuffle read size 的 max 遠大於 median;或事先 groupBy(key).count() 看分布。解法:過濾或單獨處理 null/預設值 key;AQE skew join 自動拆分過大分區;salting(大表熱點 key 加隨機後綴、另一邊複製 S 份);若小的一邊夠小就改 broadcast;聚合可做兩階段(先加 salt 部分聚合,再去 salt 最終聚合)。
MapReduce 的執行流程?combiner 的作用與限制?為什麼 MapReduce 比 Spark 慢?
流程:input split → map → map 端 partition、排序、spill(可加 combiner)→ shuffle(reducer 從所有 mapper 拉自己的分區)→ merge → reduce → 寫回 HDFS。Combiner 在 map 端先做局部聚合以減少 shuffle 量,只能用於滿足交換律與結合律的運算(sum、count、max),平均值要改成 (sum, count) 才能 combine;框架也不保證 combiner 一定被呼叫或呼叫幾次。慢的原因:多個 job 之間中間結果都寫 HDFS(含複製)、shuffle 強制排序與落地、task 啟動 JVM 開銷、模型僵硬難以全域最佳化。Spark 以 DAG 合併步驟、長駐 executor、中間結果盡量在記憶體、只在 wide dependency 切 stage 來改善。
HDFS 的 NameNode 與 DataNode 各負責什麼?為什麼 HDFS 怕小檔案?
NameNode 管 metadata(目錄樹、檔案到 block、block 到 DataNode 的映射),全部存於記憶體,並以 fsimage + edit log 持久化;HA 架構用 Active/Standby NameNode + JournalNode + ZKFC。DataNode 儲存實際 block(預設 128MB、複製 3 份,跨 rack 放置),定期回報 heartbeat 與 block report。讀寫時 client 只向 NameNode 拿位置,資料直接與 DataNode 傳輸。小檔案的問題:每個檔案與 block 都要在 NameNode 記憶體中佔一筆 metadata,數億個小檔案會撐爆 NameNode;運算端每個檔案至少一個 task,排程開銷大、讀取效率低。解法是上游合併寫入、定期 compaction。
Spark 的 narrow 與 wide dependency 是什麼?Stage 如何切分?
Narrow dependency:子 partition 只依賴少數固定的父 partition(map、filter、union),可在同一 stage 內 pipeline 執行,失敗時只需重算少數 partition。Wide dependency:子 partition 依賴所有父 partition(groupBy、非 co-partitioned 的 join、distinct、repartition),需要 shuffle。DAGScheduler 在 wide dependency 處切 stage:上游 stage 寫 shuffle file,下游 stage 拉取。每個 stage 的 task 數等於該 stage 的 partition 數。Action 觸發 job,job 由多個 stage 組成。
repartition 與 coalesce 的差別?為什麼 coalesce(1) 可能很慢?
repartition(n) 會做完整 shuffle,可以增加或減少 partition,也可以依欄位重新分佈,輸出均勻。coalesce(n) 只合併現有 partition、不 shuffle,只能減少。因為 coalesce 是 narrow 操作,會與上游 pipeline 在同一個 stage,coalesce(1) 會讓整個上游計算的平行度也變成 1。需要少量輸出檔時,用 repartition(n) 多付一次 shuffle 換上游平行度,通常更快。
什麼時候該 cache?DataFrame 的 cache 預設存在哪裡?
因為 lazy evaluation,同一個 DataFrame 被多個 action 使用時每次都會重算整條 lineage。當它被重複使用且重算成本高(例如讀大檔 + 複雜 join 後的結果要被多個下游聚合使用)時才 cache。DataFrame cache() 預設為 MEMORY_AND_DISK,RDD 預設 MEMORY_ONLY。cache 本身是 lazy,要等下一個 action 才物化。用完要 unpersist(),否則佔用 executor 記憶體、擠壓執行記憶體造成 spill。需要截斷過長 lineage 時用 checkpoint。
Catalyst、Tungsten、AQE 各做什麼?
Catalyst 是查詢最佳化器:把 DataFrame/SQL 解析成邏輯計畫,套用規則最佳化(predicate pushdown、column pruning、constant folding),再依成本選擇物理計畫(例如 join 策略)並產生程式碼。Tungsten 是執行引擎層最佳化:緊湊的二進位記憶體格式、可 off-heap 減少 GC、whole-stage code generation 把多個運算子融合成一個函式。AQE(Spark 3.2 起預設開啟)在 shuffle stage 完成後依實際統計重新最佳化:合併過小分區、把 sort-merge join 改成 broadcast join、拆分傾斜分區。
為什麼分析場景偏好 Parquet 而不是 CSV/JSON?什麼是 predicate pushdown 與 column pruning?
Parquet 是列式、二進位、自帶 schema 與統計資訊:同欄位資料連續存放,壓縮率高(dictionary、RLE 等編碼 + snappy/zstd);查詢只讀需要的欄位(column pruning);footer 中每個 row group 的 min/max 統計讓引擎跳過不可能符合條件的區塊(predicate pushdown)。CSV/JSON 每次都要讀整列、解析文字、型別靠推斷,且 gzip 壓縮後不可切分。要讓 pushdown 有效,資料最好依常用過濾欄位排序或 clustering,min/max 範圍才會窄。Avro 是行式二進位格式,適合寫入密集與訊息傳輸(搭配 schema registry 做 schema evolution)。
Partitioning 與 bucketing 的差別?為什麼不該依 user_id 分區?
Partitioning 依欄位值切成目錄(例如 dt=2026-11-01),適合低基數、常用於過濾的欄位,查詢時可做 partition pruning。Bucketing 依 hash(欄位) % N 寫成固定數量的檔案,適合高基數、常用於 join/聚合的欄位,兩表用相同 key 與桶數 bucketing 時 join 可免 shuffle。依 user_id 分區會產生上億個目錄與小檔案,壓垮 metastore/NameNode、讓查詢規劃極慢,應改用 bucketing 或 clustering。
OLTP 與 OLAP 有何不同?ClickHouse 適合什麼、不適合什麼?
OLTP 是大量小交易:依主鍵讀寫少數幾筆、低延遲、ACID、行式儲存與 B+tree 索引(MySQL)。OLAP 是掃描大量資料做聚合:列式、壓縮、向量化執行、分區(BigQuery、ClickHouse)。ClickHouse 擅長對大量事件資料做極快的聚合查詢,很適合即時報表、廣告成效分析;不擅長高頻逐筆更新/刪除、大表之間的複雜 join,寫入也應批次進行而不是一筆一筆插入。線上 DB 不應直接跑分析查詢,應以 CDC 同步到分析系統。
Lakehouse(Iceberg/Delta/Hudi)解決了 data lake 什麼問題?
傳統 data lake 只是物件儲存上的一堆檔案,缺乏交易:寫到一半失敗留下殘檔、並行寫入互相覆蓋、無法有效 update/delete(GDPR 刪除困難)、schema 變更混亂。Open table format 在檔案之上加一層 metadata/交易日誌,提供原子 commit 與快照隔離、time travel、schema/partition evolution、MERGE INTO upsert、小檔案合併。Iceberg 引擎中立、支援 hidden partitioning;Delta 與 Spark/Databricks 整合最深;Hudi 著重高頻 upsert 與增量讀取,有 Copy-on-Write 與 Merge-on-Read 兩種表型。
Event time 與 processing time 差在哪?watermark 是什麼,設太大或太小會怎樣?
Event time 是事件發生的時間(事件本身攜帶),processing time 是系統處理它的時間。網路延遲、手機離線補送、重播都會讓兩者不同,以 processing time 計算的業務指標會失真。Watermark 是宣告「event time 小於等於 t 的事件應已到齊」的進度標記,window 在 watermark 越過其結束時間時觸發。設得太保守(允許亂序時間大)→ 結果延遲高、狀態保留久;太激進 → 更多遲到事件被丟棄或需透過 allowed lateness / side output 另外處理。這是延遲與完整性之間的取捨。
Tumbling、sliding、session window 的差別?各舉一個廣告情境。
Tumbling 固定大小不重疊(每分鐘的曝光數,用於即時花費統計);sliding 固定大小有重疊(過去 10 分鐘、每分鐘更新一次的 CTR,用於異常偵測,一個事件會屬於多個 window);session 依活動間隔切分、長度不固定(使用者 30 分鐘無活動則結束 session,用於分析瀏覽行為或歸因)。Session window 需要合併 window,狀態管理較複雜。
Flink 如何做到 exactly-once?「端到端 exactly-once」還需要什麼?
Flink 週期性在資料流中注入 checkpoint barrier,運算元收到 barrier 時把狀態非同步快照到持久儲存(基於 Chandy-Lamport 的分散式快照),連同 source 的讀取位置(Kafka offset)一起記錄;失敗時還原狀態並從該 offset 重播,內部狀態因此是 exactly-once。端到端還需要 sink 配合:交易式 sink(例如 Kafka transactional producer 的兩階段提交,checkpoint 完成時才 commit,下游以 read_committed 讀)或冪等 sink(以唯一鍵 upsert)。實務上常用 at-least-once + 冪等寫入達到等效的 exactly-once。
Lambda 與 Kappa 架構的差別與取捨?
Lambda 同時有批次層(全量、精確、慢)與速度層(串流、即時、可能近似),查詢時合併;好處是批次能修正串流錯誤,壞處是同一邏輯要寫兩套、容易不一致。Kappa 只有串流層,重算時從保留的事件 log 重播;一套程式碼,但需要足夠長的 log 保留與能處理重播的引擎。廣告業常見混合做法:即時指標(pacing、即時花費)走串流,計費與對帳數字用批次在資料到齊後重算覆寫。
每天跑的資料管線,如何設計成可以安全重試與 backfill?
核心是冪等:每個 run 只依邏輯日期(Airflow 的 ds)讀固定的輸入分區,不用 now();輸出以分區覆寫(INSERT OVERWRITE、Spark dynamic partition overwrite),不要 append;先寫暫存再原子發佈;衝突規則 deterministic。加上 sensor 等待上游、retries 與 backoff、資料品質閘門(筆數、唯一性、null 比例、新鮮度)、監控告警。Backfill 用 airflow dags backfill 或觸發多個邏輯日期;若結果依賴前一天(增量快照),要依序執行(depends_on_past / max_active_runs=1)。
什麼是 identity resolution?如何在上億個 ID 上計算?有什麼風險?
同一個人在不同渠道有不同 ID(cookie、裝置 ID、會員 ID、hashed email),identity resolution 是判斷哪些 ID 屬於同一人。把 ID 當節點、共同出現當邊建 identity graph,同一人即一個連通分量。單機用 union-find(路徑壓縮 + union by rank,均攤近似常數);分散式用 Spark 迭代式 label propagation 或 GraphFrames connectedComponents。風險:超級節點(公共 IP、共用裝置、測試帳號、預設值)會把大量不相關的人連在一起,需設定度數上限排除;機率性比對有誤判與隱私/平台政策問題;要尊重 consent 並支援刪除請求傳播。
GPU/CUDA 適合用來加速「上億筆資料 merge」嗎?
通常不是首選。merge/join 的瓶頸多半在磁碟與網路 I/O 以及 shuffle,而 GPU 擅長的是資料平行、運算密集、分支少的數值運算(矩陣、深度學習、向量相似度)。資料在 CPU 與 GPU 間透過 PCIe 搬移也有成本。不過像 RAPIDS cuDF / Spark RAPIDS 在部分 join、聚合工作負載上能加速,若後續有模型訓練或 embedding 計算,GPU 就很關鍵。回答時要說明判斷依據:先 profile 瓶頸在 I/O 還是計算。