Chapter 12

大數據處理與上億筆資料

「兩份資料怎麼 merge?資料量到上億筆怎麼辦?」是 Appier 面試真實出現過的追問。本章從單機 hash map 一路推到 external sort、hash partition、MapReduce、Spark、串流處理與資料管線,讓你能沿著面試官的每一層追問往下答,並連結到跨渠道使用者資料整合(identity resolution)這個 Appier 核心場景。

預估閱讀 75 分鐘

問題拆解:面試官到底在問什麼 高頻

「把兩份資料 merge 在一起」看似一題簡單的 coding 題,其實是一個可以無限往下挖的系統題。面試官通常會先讓你寫出能動的版本(兩個 List[dict] 依 id 合併),然後一層層加條件:資料變大、記憶體不夠、要每天跑、要即時、資料有衝突、key 分布不均……每一層都在測試不同能力:

追問層級限制條件核心技巧考的能力
L0兩個 list,幾千筆dict(hash map)依 id 合併基本 coding、時間複雜度、衝突規則
L1上千萬筆,但記憶體放得下只把小的一邊建成 hash map、串流讀大的一邊記憶體估算、generator、I/O 意識
L2上億筆、單機記憶體只有 8GBexternal 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
flowchart TD A["兩份資料要 merge"] --> B{"放得進記憶體?"} B -- "是" --> C["Hash map join
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」反而是扣分:代表你沒想過單機能不能解決。

開始寫之前要先問的問題

  1. Join key 是什麼?是 user_id 還是 email / device id?兩邊的 key 格式是否一致(大小寫、前後空白、hash 過的 email)?
  2. Key 在單一來源中是否唯一?同一個 id 在一份資料內出現多次時,要取最新還是合併?
  3. Join 類型?inner(只要兩邊都有的)、left(以主資料為準)、full outer(全部保留)?使用者屬性整合通常是 full outer。
  4. 欄位衝突規則?同一欄位兩邊值不同時:最新時間戳優先、來源優先序、非空值優先、還是 list 取聯集?
  5. 規模與資源?筆數、每筆大小、機器記憶體、能接受的執行時間、是一次性還是每天跑。
  6. 輸出要給誰用?寫回 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

外部排序分兩階段:

  1. Run generation(切塊排序):每次讀入記憶體放得下的量(例如 1GB),在記憶體內排序後寫成一個暫存檔(稱為 run)。30GB 資料 → 約 30 個已排序的 run。
  2. 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 數)。

flowchart LR subgraph P1["Phase 1: 切塊排序"] S["30GB 未排序檔案"] --> C1["chunk 1 (1GB)"] S --> C2["chunk 2"] S --> C3["..."] S --> C4["chunk 30"] C1 --> R1["run_1.sorted"] C2 --> R2["run_2.sorted"] C4 --> R4["run_30.sorted"] end subgraph P2["Phase 2: K-way merge"] R1 --> H["min-heap (k=30)"] R2 --> H R4 --> H H --> O["全域排序輸出"] end
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) 比較):

  1. Partition 階段:讀 A,對每筆算 bucket = hash(key) % N,寫到 a_bucket_{i};B 用同一個 hash 函式與同一個 N 寫到 b_bucket_{i}。相同 key 必定落在相同編號的桶。
  2. 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/OO(chunk)輸出有序、可處理 skew排序成本高
Hash partition(Grace)無O(n+m),約 2 趟 I/OO(最大桶)可平行、不用排序skew 會爆桶
Appier 情境

以廣告/行銷平台常見做法為例:客戶上傳一份 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 的大表,以寫入時的成本換讀取時的效能。
flowchart LR subgraph BJ["Broadcast join"] SM["小表 (數 MB)"] -- "複製到每個 executor" --> E1["Executor 1
大表 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,面試很常從這裡問起。

執行流程

  1. Input split:輸入檔案依 HDFS block(預設 128MB)切成 split,每個 split 啟動一個 map task,盡量排在存有該 block 的機器上(data locality:搬運算而不是搬資料)。
  2. Map:每筆記錄 → 0 到多個 (key, value)。輸出先寫進記憶體環形緩衝區,滿了就 spill 到本地磁碟。
  3. Partition + Sort(map 端):依 hash(key) % R 決定要送給哪個 reducer,並在每個分區內依 key 排序。可選的 combiner 在這裡先做局部聚合。
  4. Shuffle:每個 reducer 透過網路從所有 mapper 拉取屬於自己的分區(M × R 次傳輸)。
  5. Merge + Reduce:reducer 對拉來的已排序片段做 merge(就是外部排序的 k-way merge),相同 key 的值聚在一起交給 reduce(key, values)。
  6. Output:結果寫回 HDFS(會被複製 3 份)。
flowchart LR I1["split 1"] --> M1["map"] I2["split 2"] --> M2["map"] I3["split 3"] --> M3["map"] M1 --> C1["combine + partition + sort
(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

RDDDataFrameDataset
抽象分散式的物件集合,搭配函式式 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 到本地磁碟,下游跨網路拉取。
flowchart LR subgraph S1["Stage 1 (narrow, pipelined)"] R1["read parquet A"] --> F1["filter dt"] --> P1["select cols"] end subgraph S2["Stage 2 (narrow)"] R2["read parquet B"] --> F2["filter"] end subgraph S3["Stage 3 (after shuffle)"] J["sort-merge join on user_id"] --> W["window: 取最新"] --> O["write parquet"] end P1 -- "shuffle (wide)" --> J F2 -- "shuffle (wide)" --> J

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 skewSpark UI stage 頁面:task duration / shuffle read size 的 max 遠大於 medianAQE 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 OOMcollect()、toPandas() 大資料、broadcast 前在 driver 組表、過多小檔案的 metadatadriver log改用 write 到儲存;用 limit 取樣;合併小檔案
讀檔 stage 的 task 數極多、每個只有幾 KBSmall 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 的 DataFrameSQL 頁籤看到相同的 scan 出現多次cache() 後再用多次
GC 時間佔比高大量物件、RDD of Python objectsexecutor 頁的 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 讀
Appier 情境

以廣告平台常見做法為例: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、MongoDBBigQuery、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 引擎

系統架構特色適用注意
BigQueryServerless,儲存與運算分離,列式儲存,依掃描量或預留 slot 計費不想管叢集的大規模 ad-hoc 分析務必用 partition + clustering,避免 SELECT * 掃全表燒錢
RedshiftAWS 的 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預聚合會失去明細;運維元件較多
flowchart LR SDK["SDK / Bidder / Web"] --> K["Kafka"] OLTP["MySQL / MongoDB"] -- "CDC" --> K K --> RAW["Raw zone (bronze)
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),隔天用批次重算出精確的帳務數字並覆寫。這對廣告業特別常見:即時數字用於決策,批次數字用於計費與對帳。

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 才看不到未提交的資料。
sequenceDiagram participant K as Kafka (source) participant O as Operator (keyed state) participant S as State backend / S3 participant T as Kafka sink (txn) K->>O: events ... barrier n O->>S: snapshot state @ n O->>T: pre-commit txn n S-->>O: checkpoint n complete O->>T: commit txn n Note over K,T: 失敗時回到 checkpoint n:
還原 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 StreamingApache 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())
Appier 情境

以廣告平台常見做法為例:預算 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=...)、Spark mode("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 情境

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 就視為不可信、不參與合併。這是真實系統最常出事的地方,面試提到會很加分。
flowchart LR C1["cookie: c1"] --- M["member: m100"] D9["device: d9"] --- M D9 --- E["hashed email A"] M --> U["統一 ID: U1"] C7["cookie: c7"] --- E2["hashed email B"] E2 --> U2["統一 ID: U2"] IP["公共 Wi-Fi IP"] -. "連到上萬個 ID: 排除" .- C1
常見陷阱

Identity graph 涉及個人資料:email / 手機要先正規化再以雜湊(如 SHA-256)處理、遵守各渠道的同意(consent)狀態、支援使用者刪除請求(GDPR 等法規下的 right to erasure),而且刪除要能傳播到所有衍生資料。設計資料模型時就要把 consent 與刪除路徑考慮進去,而不是事後補。

完整面試回答範本:逐層追問對答劇本 高頻

以下是把本章串起來的模擬對話。重點不是背台詞,而是學會每一層的回答結構:釐清 → 方案 → 複雜度 → trade-off → 主動提下一步。

sequenceDiagram participant I as 面試官 participant C as 你 I->>C: 兩份資料怎麼 merge? C->>I: 釐清 key/衝突規則, hash map O(n+m) I->>C: 上億筆呢? C->>I: 估算記憶體, 小表 hash + 大表串流 I->>C: 機器只有 8GB, 兩邊都很大? C->>I: external sort / hash 分桶, 逐桶 join I->>C: 要每天跑? C->>I: Spark + Airflow, 分區覆寫, 冪等, 品質檢查 I->>C: 要即時? C->>I: Kafka + Flink keyed state, event time, 冪等 upsert

第一層:「兩份資料怎麼 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 還是計算。