Chapter 11

訊息佇列與事件驅動

AdTech 的資料主幹就是事件流:每秒大量的出價、曝光、點擊、轉換事件,以及行銷自動化的推播發送,幾乎都經過訊息佇列。本章深入 Kafka(partition、ISR、acks、consumer group、exactly-once、為何快)、RabbitMQ 與雲端佇列的比較、交付語意與冪等消費者、DLQ 與重試、事件驅動架構與 outbox/CDC、schema 演進,最後實際設計一條「SDK → collector → Kafka → stream processor → storage」的事件追蹤管線。

預估閱讀 85 分鐘

為什麼需要訊息佇列,以及它的代價 基礎 高頻

訊息佇列(Message Queue, MQ)在生產者與消費者之間放一個可靠的緩衝區。面試時被問「為什麼要用 MQ」,要能講出四個好處,並且主動講代價——這是區分「用過」與「懂」的關鍵。

1. 解耦(Decoupling)

生產者只管發出「發生了什麼事」,不需知道誰要處理、處理幾份。新增一個消費者(例如新的反詐欺服務)不需修改生產者。服務之間也不再有同步的可用性依賴:下游掛掉,上游照常運作。

2. 削峰填谷(Load Leveling)

流量尖峰時訊息先堆在佇列中,消費者以自己能負荷的速度穩定處理。例如雙 11 或大型推播活動,瞬間寫入是平常的 10 倍,DB 只需要依平均流量規劃容量,而不是依尖峰。

3. 非同步(Asynchrony)

把耗時、非關鍵的工作移出請求路徑:使用者註冊後,寄信、發優惠券、更新推薦模型都非同步進行,API 延遲只剩寫 DB + 發一則訊息。

4. 擴展與重播(Scalability & Replay)

以 partition 或競爭消費者水平擴展處理能力。像 Kafka 這種 log-based 的系統,訊息被消費後仍保留,可以重設 offset 重新處理(修 bug 後回補資料、新系統從頭建立狀態)。

代價:引入 MQ 會帶來什麼問題

  • 系統複雜度與維運成本:多了一個需要高可用、監控、容量規劃、升級的分散式系統。
  • 最終一致性:寫入後下游不會立即看到;使用者可能在「訂單建立」後立刻查詢卻還看不到積分。
  • 交付語意問題:訊息可能重複(at-least-once)或遺失(at-most-once),消費者必須冪等。
  • 順序問題:多 partition / 多消費者 / 重試都可能打亂順序。
  • 除錯困難:請求的因果鏈被切斷,需要 correlation id 與分散式追蹤;「訊息去哪了」是常見的疑難雜症。
  • Dual write:同時寫 DB 與 MQ 無法原子化,需要 outbox pattern。
  • 延遲:多一跳,加上批次等待,端到端延遲從毫秒變成數十毫秒到秒級。
面試官想聽到什麼

不要把 MQ 當萬靈丹。好的回答:「我會在這三種情況引入 MQ:下游處理慢或不穩、需要扇出給多個消費者、流量有明顯尖峰。如果只是 A 呼叫 B 且需要立即結果,同步 RPC 更簡單。引入後我會處理冪等消費、DLQ、lag 監控與 outbox。」

Point-to-Point vs Publish/Subscribe

flowchart LR subgraph P2P["Point-to-Point(競爭消費者)"] P1[Producer] --> Q1[["Queue"]] Q1 --> W1["Worker 1"] Q1 --> W2["Worker 2"] Q1 --> W3["Worker 3"] end subgraph PS["Publish/Subscribe(扇出)"] P2[Producer] --> T1[["Topic"]] T1 --> S1["計費服務
(收到全部)"] T1 --> S2["報表服務
(收到全部)"] T1 --> S3["反詐欺服務
(收到全部)"] end
Point-to-PointPub/Sub
語意每則訊息只被一個消費者處理每則訊息被每個訂閱者各收一份
用途工作分派(寄信任務、影像轉檔)事件廣播(訂單建立 → 多個系統各自反應)
代表RabbitMQ queue、SQS、CelerySNS、RabbitMQ fanout exchange、Google Pub/Sub topic

Kafka 同時支援兩者:同一個 consumer group 內是競爭消費(每個 partition 只給組內一個 consumer),不同 consumer group 之間是 pub/sub(每個 group 各自讀完整份資料、各自維護 offset)。

傳統佇列 vs Log-based 訊息系統

傳統 Message Broker(RabbitMQ、SQS)Log-based(Kafka、Pulsar、Kinesis)
訊息被 ack 後刪除保留(依 retention),消費者只是移動 offset
消費者狀態broker 追蹤每則訊息的 ack 狀態消費者(或 broker 代管)只記一個 offset 數字
重播無法(已刪除)可以,重設 offset 即可
平行度任意多個消費者競爭同一佇列受 partition 數限制
單則訊息處理慢不影響其他訊息(別的 worker 拿下一則)阻塞同一 partition 後面的訊息(head-of-line blocking)
順序多消費者時不保證partition 內嚴格有序
適合任務分派、每則訊息處理成本差異大、需要個別重試/延遲高吞吐事件流、需要重播、多個下游、stream processing

Kafka 核心概念:Topic、Partition、Offset、Segment 進階 高頻

Kafka 本質上是一個分散式、分區、多副本的 append-only commit log。理解它的關鍵是把它想成「一堆可以被多方獨立讀取的檔案」,而不是「佇列」。

flowchart LR PR["Producers"] --> T subgraph T["Topic: ad-events(3 partitions)"] direction TB P0["Partition 0: 0 1 2 3 4 5 6 → append"] P1["Partition 1: 0 1 2 3 4 → append"] P2["Partition 2: 0 1 2 3 4 5 → append"] end T --> G1["Consumer group: billing
C1 讀 P0,P1 / C2 讀 P2"] T --> G2["Consumer group: reporting
C3 讀 P0,P1,P2"]
Topic
邏輯上的訊息分類(如 impressions、clicks)。一個 topic 由多個 partition 組成。
Partition
Kafka 平行度、順序性、儲存的基本單位。每個 partition 是一個有序、不可變的訊息序列,只能在尾端 append。順序只在 partition 內保證,跨 partition 沒有全域順序。partition 數可以增加但不能減少,且增加會改變 key → partition 的對應。
Offset
訊息在 partition 中的序號(64-bit 單調遞增)。consumer 以 (topic, partition, offset) 定位讀取位置。commit offset 代表「下一則要讀的位置」。
Broker
Kafka 伺服器節點。每個 partition 的多個副本分散在不同 broker 上,其中一個是 leader。
Segment
partition 在磁碟上被切成多個 segment 檔(預設 log.segment.bytes = 1GB,或依時間 roll)。每個 segment 有 .log(資料)、.index(offset → 檔案位置的稀疏索引)、.timeindex(時間戳 → offset)。只有最新的 active segment 會被寫入;retention 與 compaction 以 segment 為單位刪除或清理,所以刪除舊資料就是刪檔案,非常便宜。
Record
key(可為 null)、value、timestamp、headers。多筆 record 組成 record batch,batch 是壓縮與傳輸的單位。
# partition 在磁碟上的樣子
$ ls /var/lib/kafka/data/ad-events-0/
00000000000000000000.index      00000000000000000000.log      00000000000000000000.timeindex
00000000000005367851.index      00000000000005367851.log      00000000000005367851.timeindex
leader-epoch-checkpoint         partition.metadata
# 檔名 = 該 segment 第一則訊息的 offset;找 offset 5400000:二分搜尋檔名 → 查 .index 稀疏索引 → 從該位置循序掃描

# 建立 topic
kafka-topics.sh --bootstrap-server kafka:9092 --create --topic ad-events \
  --partitions 48 --replication-factor 3 \
  --config min.insync.replicas=2 --config retention.ms=259200000 --config compression.type=zstd

Retention 與 Log Compaction

cleanup.policy=delete(預設)

依時間(retention.ms,broker 預設 log.retention.hours=168,即 7 天)或大小(retention.bytes,預設不限)刪除整個舊 segment。適合事件流:曝光、點擊、日誌。

cleanup.policy=compact

保證每個 key 至少保留最新的一筆值,舊值在背景被清掉。value 為 null 的訊息是 tombstone,代表刪除該 key(保留 delete.retention.ms 後才真正移除,讓消費者有機會看到刪除)。適合「狀態變更流」:使用者設定、campaign 設定、CDC 表格快照。Kafka 自己的 __consumer_offsets 就是 compacted topic。

也可以 compact,delete 同時使用:compact 之外仍刪除過舊資料。Compacted topic 讓新服務可以「從頭讀一遍」就重建出每個 key 的最新狀態,是 event-carried state transfer 與 Kafka Streams KTable 的基礎。

KRaft:取代 ZooKeeper

舊版 Kafka 用 ZooKeeper 存放 metadata(broker 列表、topic 設定、partition leader 與 ISR)並選出單一 controller。問題:要維運兩套分散式系統;controller failover 時要從 ZooKeeper 重新載入所有 metadata,partition 數多(數十萬)時非常慢;metadata 傳播不一致。

KRaft(KIP-500)把 metadata 管理移進 Kafka 本身:一組 controller 節點以 Raft 協定維護一個內部的 metadata log(__cluster_metadata),其中一個是 active controller;broker 像 consumer 一樣訂閱這個 log 取得 metadata。好處:單一系統、controller failover 幾乎即時(standby controller 已有完整 metadata)、可支援更多 partition。KRaft 在 3.3 宣告可用於正式環境,Kafka 4.0(2025)完全移除 ZooKeeper 模式。

面試官想聽到什麼

能說出「partition 是平行度、順序與儲存的單位;同 key 同 partition 才有序;consumer group 內一個 partition 只給一個 consumer;不同 group 各讀一份」。被問 segment 時能說明 index 是稀疏索引、刪資料就是刪 segment 檔,這也是 Kafka 保留大量資料仍很便宜的原因。

Kafka 複製:Leader/Follower、ISR、acks 深入 高頻

每個 partition 有 replication.factor 個副本(通常 3),分散在不同 broker(最好不同機架/AZ,透過 broker.rack 設定)。其中一個是 leader,所有讀寫預設都走 leader(2.4 之後 consumer 可以從同機架的 follower 讀,以節省跨 AZ 流量);follower 像 consumer 一樣持續從 leader 拉資料。

ISR(In-Sync Replicas)、HW 與 LEO

LEO(Log End Offset)
每個副本自己 log 的下一個寫入位置。
ISR
與 leader「保持同步」的副本集合(含 leader 自己)。follower 若超過 replica.lag.time.max.ms(目前預設 30 秒)沒有追上 leader 的 LEO,就會被移出 ISR;追上後再加回來。
HW(High Watermark)
ISR 中所有副本都已擁有的最大 offset。只有 HW 以下的訊息(committed)才對 consumer 可見,避免 consumer 讀到之後可能因 leader 切換而消失的訊息。
Leader epoch
每次換 leader 遞增,用於副本恢復時截斷不一致的 log(取代早期只依 HW 截斷造成的資料遺失/分歧問題),概念上就是 Raft 的 term。
sequenceDiagram participant P as Producer (acks=all) participant L as Leader (broker 1) participant F1 as Follower (broker 2, ISR) participant F2 as Follower (broker 3, ISR) P->>L: ProduceRequest (batch @ offset 100-109) L->>L: append 到 page cache,LEO=110 F1->>L: Fetch(offset=100) L-->>F1: records 100-109 F2->>L: Fetch(offset=100) L-->>F2: records 100-109 F1->>L: Fetch(offset=110)(隱含確認已有 100-109) F2->>L: Fetch(offset=110) Note over L: 所有 ISR 都已到 110 → HW=110 L-->>P: ack(offset 100)

acks 與 min.insync.replicas

設定leader 何時回應延遲資料遺失風險
acks=0不等回應,送出即視為成功最低高:broker 沒收到也不知道;無法重試
acks=1leader 寫入本地 log 後低中:leader 回應後、follower 複製前當機 → 該訊息遺失
acks=all(-1)所有 ISR 都寫入後較高(等最慢的 ISR)低,但需搭配 min.insync.replicas

重點:acks=all 等的是「目前的 ISR」,如果 ISR 縮到只剩 leader 一個,acks=all 就退化成 acks=1。所以要設 min.insync.replicas(topic 或 broker 層級,預設 1):ISR 數量少於此值時,leader 拒絕 acks=all 的寫入(回 NotEnoughReplicas),寧可不可用也不冒遺失風險。

實戰提示:標準高可靠設定

replication.factor=3、min.insync.replicas=2、acks=all。可容忍 1 個 broker 故障仍可寫入且不遺失已確認的資料;2 個故障時寫入失敗(CP 取向)。Kafka 3.0 起 producer 預設就是 acks=all 與 enable.idempotence=true,但 min.insync.replicas 預設仍是 1,要自己調。

Unclean Leader Election

如果 leader 掛了,而 ISR 中沒有任何其他存活副本(例如所有 follower 早就落後被踢出 ISR),該怎麼辦?

  • unclean.leader.election.enable=false(預設,自 0.11 起):等待某個 ISR 副本恢復,期間該 partition 不可用。選一致性(C)。
  • true:允許非 ISR 的落後副本當 leader,立即恢復可用,但它缺少的訊息永久遺失,且之後舊 leader 回來時會截斷自己的 log 以對齊。選可用性(A)。

這是 CAP 取捨在 Kafka 中的具體呈現:計費、交易類 topic 保持 false;可容忍少量遺失的 metrics/log topic 可以考慮 true。

常見陷阱

Kafka 預設依賴「多副本」而非「每則訊息 fsync」來保證持久性(flush.messages 預設不強制,資料寫入 page cache 即算寫入)。所以 RF=1 或所有副本在同一機架同時斷電時可能遺失資料。這是刻意的設計:fsync 每則訊息會嚴重降低吞吐,而跨機架的多副本同時失效機率很低。

Kafka Producer:批次、壓縮、冪等、Partitioner 與順序 進階 高頻

Producer 的 send() 是非同步的:訊息先放進記憶體中依 partition 分組的 buffer(RecordAccumulator),由背景 sender thread 批次送出。

flowchart LR APP["app: send(topic, key, value)"] --> SER["Serializer
(key/value → bytes)"] SER --> PART["Partitioner
決定 partition"] PART --> ACC["RecordAccumulator
每個 partition 一個 batch 佇列"] ACC -->|"batch 滿 (batch.size) 或
等滿 linger.ms"| SND["Sender thread
依 broker 合併請求、壓縮"] SND --> B1["Broker(partition leader)"] B1 -->|"ack / error → 重試"| SND

Batching、linger.ms、Compression

參數說明調校方向
batch.size每個 partition batch 的位元組上限(Java client 預設 16384 = 16KB)高吞吐時調大(64KB–1MB)
linger.msbatch 未滿時最多等多久再送(Java client 長期預設 0,Kafka 4.0 改為 5ms)設 5–50ms 讓 batch 變大,以少量延遲換大量吞吐
compression.typenone / gzip / snappy / lz4 / zstd,以 batch 為單位壓縮JSON 事件壓縮比常見 4–10 倍;zstd 壓縮比好、lz4 CPU 最省
buffer.memory整個 producer 的 buffer 上限(預設 32MB)滿了 send() 會阻塞最多 max.block.ms,這就是 producer 端的 backpressure
max.in.flight.requests.per.connection每條連線未確認的請求數(預設 5)非冪等時 >1 且重試可能亂序
delivery.timeout.mssend 到成功/失敗的總時限(預設 120 秒,含重試)依業務容忍度設定,逾時進 callback 的錯誤處理

壓縮的 batch 在 broker 端不解壓直接存(若 topic 的 compression.type 與 producer 相同或為 producer),consumer 收到後才解壓——端到端壓縮同時省網路、磁碟與 broker CPU。所以 batch 越大,壓縮比越好,吞吐越高。

Idempotent Producer

問題:producer 送出 batch,broker 已寫入但 ack 在網路上遺失,producer 重試 → 重複訊息。另外 in-flight > 1 時,batch 1 失敗重試、batch 2 成功,會造成亂序。

enable.idempotence=true:broker 為每個 producer 分配 Producer ID(PID),producer 為每個 partition 的每個 batch 加上遞增的 sequence number。broker 記住每個 (PID, partition) 最近的 sequence:重複的直接丟棄並回成功、跳號的拒絕。效果:單一 producer session 對單一 partition 的寫入不重複、不亂序(允許 in-flight ≤ 5)。限制:producer 重啟會拿到新 PID,跨 session 的重複無法消除——這需要 transactions 的 transactional.id。

Partitioner、Key 選擇與順序保證

  • 有 key:預設以 murmur2(key) % partition 數決定 partition。同 key → 同 partition → 有序。
  • 無 key(null):2.4 起用 sticky partitioner:先把訊息黏在一個 partition 直到 batch 送出再換,讓 batch 更大(舊版 round-robin 每則換 partition 會讓 batch 很小)。
  • 也可以自訂 partitioner,或直接指定 partition。
Key 選擇得到的順序保證風險
user_id / device_id同一使用者的事件有序;可在 consumer 端做 per-user 狀態(sessionization、frequency cap)極活躍使用者或機器人造成熱 partition
campaign_id同一 campaign 的事件有序、方便聚合大廣告主的 campaign 流量極不平均 → 嚴重 skew
order_id / 實體 id同一實體的狀態變更有序(CDC 必須以主鍵為 key)通常分佈良好
null無順序保證分佈最均勻、吞吐最好
常見陷阱

(1)增加 partition 數會改變 key 的歸屬:同一 key 的新訊息跑到新 partition,在新舊 partition 上的訊息之間失去順序;compacted topic 會出現同一 key 存在於兩個 partition 的情況。需要嚴格順序的 topic 應一開始就規劃足夠的 partition。(2)非冪等 producer 搭配 retries 與 in-flight > 1 會亂序。(3)以為「topic 有序」:只有 partition 內有序。

Python Producer 範例(confluent-kafka)

import json
import socket
import uuid
from confluent_kafka import Producer, KafkaException

producer = Producer({
    "bootstrap.servers": "kafka-1:9092,kafka-2:9092,kafka-3:9092",
    "client.id": socket.gethostname(),
    "acks": "all",                    # 等所有 ISR 確認
    "enable.idempotence": True,       # PID + sequence,避免重試造成的重複與亂序
    "compression.type": "zstd",
    "linger.ms": 20,                  # 最多等 20ms 湊 batch
    "batch.size": 256 * 1024,
    "delivery.timeout.ms": 120_000,
    "queue.buffering.max.kbytes": 512 * 1024,   # librdkafka 本地 buffer 上限
})

def on_delivery(err, msg):
    # 在 poll()/flush() 呼叫時於呼叫端 thread 執行;不要在這裡做重活
    if err is not None:
        # 已經超過 delivery.timeout.ms 的重試仍失敗 → 寫入本地備援(磁碟/DLQ)並告警
        print(f"delivery failed key={msg.key()} err={err}")

def send_event(event: dict) -> None:
    event.setdefault("event_id", str(uuid.uuid4()))          # 給下游去重用
    while True:
        try:
            producer.produce(
                topic="ad-events",
                key=event["user_id"].encode(),                # 同 user 同 partition → 有序
                value=json.dumps(event, separators=(",", ":")).encode(),
                headers={"schema": b"ad_event.v3"},
                on_delivery=on_delivery,
            )
            break
        except BufferError:
            producer.poll(0.1)        # 本地 buffer 滿:處理 callback 讓空間釋放(backpressure)
    producer.poll(0)                  # 觸發已完成的 delivery callback

# 程式結束或 SIGTERM 時務必 flush,否則 buffer 中的訊息會遺失
def shutdown():
    remaining = producer.flush(10)
    if remaining:
        print(f"{remaining} messages not delivered")
面試官想聽到什麼

「如何保證 Kafka 訊息不遺失?」要從三端回答:Producer:acks=all、idempotence、處理 delivery callback 錯誤、關閉前 flush;Broker:RF=3、min.insync.replicas=2、unclean.leader.election=false、跨機架;Consumer:關閉 auto commit,處理完成後才 commit offset。「如何保證順序?」:相同 key 進相同 partition、開 idempotence、不要隨意增加 partition、consumer 端單 partition 循序處理。

Kafka Consumer:Consumer Group、Rebalance、Offset Commit、Lag 深入 高頻

Consumer Group

同一個 group.id 的 consumer 組成一個 group,topic 的 partition 被分配給組內成員,每個 partition 同一時間只屬於組內一個 consumer。推論:

  • 組內 consumer 數 > partition 數時,多出來的 consumer 閒置。partition 數就是單一 group 的最大平行度。
  • 一個 consumer 可以處理多個 partition。
  • 每個 group 有一個 group coordinator(某個 broker),負責成員管理與 offset 儲存(寫入 __consumer_offsets)。

若需要比 partition 數更高的處理平行度(例如每則訊息要呼叫 200ms 的外部 API),可以在 consumer 內部用 worker pool 平行處理,但要自行追蹤「哪些 offset 已完成」,只 commit 連續完成的最小 offset(Confluent 的 Parallel Consumer 函式庫就是做這件事,並可按 key 保持順序)。

Rebalance:Eager vs Cooperative

成員加入、離開、被判定死亡(超過 session.timeout.ms 沒心跳,預設 45 秒;或兩次 poll() 間隔超過 max.poll.interval.ms,預設 5 分鐘),或 topic partition 數改變時,會觸發 rebalance 重新分配 partition。

Eager rebalance(傳統)

所有 consumer 先放棄全部 partition(stop-the-world),重新 join group,由 leader consumer 計算新分配後再各自開始。期間整個 group 停止消費。成員多、頻繁重啟(例如 K8s rolling update)時,lag 會周期性暴增。Assignor:Range、RoundRobin、Sticky。

Cooperative(incremental)rebalance

只有需要移動的 partition 被撤銷,其他 partition 持續消費。代價是可能需要兩輪 rebalance。使用 CooperativeStickyAssignor(Python confluent-kafka:partition.assignment.strategy=cooperative-sticky)。Kafka 4.0 GA 的新 consumer group 協定(KIP-848)更進一步把分配邏輯移到 broker 端,完全增量且不需要全組同步。

減少不必要 rebalance 的手段:

  • Static membership(group.instance.id,KIP-345):給每個 consumer 固定身分(例如 StatefulSet 的 pod 名稱),短暫重啟在 session timeout 內回來就不觸發 rebalance。
  • 單批處理時間要遠小於 max.poll.interval.ms:減少 max.poll.records(預設 500),或把慢的處理移到別的 thread 並用 pause/resume。
  • 在 on_revoke callback 中 commit 已處理的 offset,避免 partition 移交後被新 owner 重複處理。

Offset Commit 策略與交付語意

flowchart TB subgraph AMO["At-most-once"] A1["poll"] --> A2["commit offset"] --> A3["process"] A3 -.->|"process 中當機 → 訊息遺失"| A1 end subgraph ALO["At-least-once(最常用)"] B1["poll"] --> B2["process"] --> B3["commit offset"] B2 -.->|"commit 前當機 → 重新處理 → 重複"| B1 end subgraph EO["Exactly-once(效果)"] C1["poll"] --> C2["process + 寫結果 + 寫 offset
在同一個交易中"] end
策略設定語意說明
Auto commitenable.auto.commit=true(預設),每 auto.commit.interval.ms(5 秒)commit 上次 poll 回傳的位置大致 at-least-once,但若在 poll 回來後交給其他 thread 非同步處理,可能變成 at-most-once簡單;當機時最多重複 5 秒內的訊息
手動同步 commitcommit(asynchronous=False) 於處理完一批之後at-least-once最清楚;每次 commit 一次 RTT,應以批次 commit
手動非同步 commitcommit(asynchronous=True)at-least-once不阻塞;失敗不重試(避免較舊的 commit 覆蓋較新的),關閉前用同步 commit 補一次
Auto commit + 手動 storeenable.auto.offset.store=false,處理完才 store_offsets(),由背景定期 commit 已 store 的位置at-least-oncelibrdkafka 推薦模式:兼顧正確與效率
外部儲存 offsetoffset 與處理結果寫在同一個 DB 交易,啟動時 seek() 到 DB 記錄的位置exactly-once(效果)sink 是 DB 時最可靠的做法

auto.offset.reset:group 沒有已 commit 的 offset(新 group 或 offset 已過期被刪)時從哪開始:latest(預設,只讀新訊息)或 earliest(從頭讀)。新的關鍵消費者通常用 earliest,否則上線前的訊息會被跳過。

Python Consumer:手動 commit 的 at-least-once 範例

import json
import signal
from confluent_kafka import Consumer, KafkaError, KafkaException, TopicPartition

running = True
def _stop(*_):
    global running
    running = False
signal.signal(signal.SIGTERM, _stop)   # K8s 終止 pod 時先送 SIGTERM,給我們優雅關閉的機會

consumer = Consumer({
    "bootstrap.servers": "kafka-1:9092,kafka-2:9092",
    "group.id": "impression-aggregator",
    "group.instance.id": "impression-aggregator-0",       # static membership(例如用 pod 名)
    "enable.auto.commit": False,                           # 自己決定何時 commit
    "auto.offset.reset": "earliest",
    "partition.assignment.strategy": "cooperative-sticky",
    "max.poll.interval.ms": 300_000,
    "isolation.level": "read_committed",                   # 不讀未提交的交易訊息
})

def on_revoke(c, partitions):
    # partition 即將被移走:先 commit 已處理完的位置,降低新 owner 重複處理量
    try:
        c.commit(asynchronous=False)
    except KafkaException:
        pass

consumer.subscribe(["ad-events"], on_revoke=on_revoke)

def process_batch(msgs) -> None:
    rows = [json.loads(m.value()) for m in msgs]
    sink.upsert_many(rows)          # 冪等寫入(以 event_id 為唯一鍵),重複處理也安全

try:
    while running:
        msgs = consumer.consume(num_messages=500, timeout=1.0)   # 批次取,提升吞吐
        if not msgs:
            continue
        good = []
        for m in msgs:
            if m.error():
                if m.error().code() == KafkaError._PARTITION_EOF:
                    continue
                raise KafkaException(m.error())
            good.append(m)
        process_batch(good)                       # 先處理(寫入成功)……
        consumer.commit(asynchronous=False)       # ……再 commit:at-least-once
finally:
    consumer.close()    # 會離開 group 並觸發 rebalance(static member 則等 session timeout)
常見陷阱

(1)commit() 不帶參數會 commit「目前 consume 到的位置」,若處理是非同步的(丟給 thread pool),可能 commit 了尚未處理完的訊息 → 實質 at-most-once。這種情況要明確 commit TopicPartition(topic, partition, offset + 1)(注意 +1:commit 的是下一則要讀的位置)。(2)處理一批太久超過 max.poll.interval.ms,consumer 被踢出 group,接著 commit 失敗、partition 被別人重複處理,形成「永遠處理不完」的循環。(3)忘了 close(),group 要等 session timeout 才發現成員離開。

Consumer Lag 監控

Lag = partition 的 log end offset − group 已 commit 的 offset。它是 consumer 健康度最重要的指標。

$ kafka-consumer-groups.sh --bootstrap-server kafka:9092 --describe --group impression-aggregator
GROUP                  TOPIC      PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG     CONSUMER-ID
impression-aggregator  ad-events  0          91823311        91823950        639     impression-aggregator-0-...
impression-aggregator  ad-events  1          90011020        90950021        939001  impression-aggregator-1-...
# partition 1 的 lag 特別大 → 熱 partition(key skew)或該 consumer 卡住

# 重設 offset(group 必須沒有活躍成員):修 bug 後重播 2 小時前的資料
$ kafka-consumer-groups.sh --bootstrap-server kafka:9092 --group impression-aggregator \
    --topic ad-events --reset-offsets --to-datetime 2026-11-03T10:00:00.000 --execute
  • 以「時間」衡量 lag 比「訊息數」直觀:100 萬則 lag 在尖峰可能只是 2 秒,在離峰可能是 1 小時。可以用「目前處理訊息的 timestamp 與現在的差距」或 lag ÷ 消費速率估算。
  • 告警看趨勢而非單點:lag 持續上升超過 N 分鐘、或 lag 時間超過業務 SLA(例如報表允許 5 分鐘延遲)。LinkedIn 的 Burrow 就是用滑動視窗評估 lag 趨勢與 consumer 是否停滯。
  • 工具:kafka-consumer-groups.sh、Burrow、kafka-lag-exporter / kafka_exporter + Prometheus、雲端服務(MSK、Confluent Cloud)的內建指標。
  • Lag 持續增加的處置:擴 consumer(不超過 partition 數)→ 優化處理(批次寫入、減少同步呼叫)→ 增加 partition → 檢查熱 partition 與 GC/外部依賴。

Kafka Exactly-once Semantics 與為什麼 Kafka 這麼快 深入

Exactly-once:Idempotent Producer + Transactions

Kafka 的 EOS(0.11 起)針對的是 consume-transform-produce 流程:從 topic A 讀、處理、寫到 topic B,並 commit A 的 offset。目標是「寫出 B 的結果」與「commit A 的 offset」原子地一起生效。

  1. Producer 設定 transactional.id(跨重啟穩定的 id),呼叫 init_transactions():transaction coordinator 依 transactional.id 分配 PID 並遞增 epoch,舊的同 id 實例(zombie)的寫入會被 fence 掉。
  2. begin_transaction() → 送出多則訊息到多個 partition。
  3. send_offsets_to_transaction(offsets, group_metadata):把消費的 offset 也納入同一個交易。
  4. commit_transaction():coordinator 以類似 2PC 的方式在 transaction log 記錄決定,再對所有相關 partition 寫入 commit marker。失敗則 abort_transaction()。
  5. 下游 consumer 設 isolation.level=read_committed,只讀到已 commit 交易的訊息(讀到 LSO,Last Stable Offset 為止),aborted 的訊息被過濾掉。
from confluent_kafka import Consumer, Producer

consumer = Consumer({"bootstrap.servers": BROKERS, "group.id": "enricher",
                     "enable.auto.commit": False, "isolation.level": "read_committed"})
producer = Producer({"bootstrap.servers": BROKERS, "transactional.id": "enricher-0"})
producer.init_transactions()
consumer.subscribe(["raw-clicks"])

while True:
    msgs = consumer.consume(500, timeout=1.0)
    if not msgs:
        continue
    producer.begin_transaction()
    try:
        for m in msgs:
            if m.error():
                continue
            producer.produce("enriched-clicks", key=m.key(), value=enrich(m.value()))
        # offset 與輸出在同一交易:要嘛都生效,要嘛都不生效
        producer.send_offsets_to_transaction(
            consumer.position(consumer.assignment()),
            consumer.consumer_group_metadata())
        producer.commit_transaction()
    except Exception:
        producer.abort_transaction()
        rewind_to_last_committed(consumer)   # seek 回上次 commit 的位置重新處理
常見陷阱:EOS 的邊界

Kafka transactions 只保證「Kafka 進、Kafka 出」的原子性。處理過程中若呼叫外部 API、寫 MySQL、寄 email,這些副作用不在交易內,abort 後重試會再執行一次。寫外部系統時仍需冪等 sink(upsert by event_id),或把 offset 存在外部 DB 的同一個交易中。另外 transactions 有額外延遲(commit marker、coordinator 往返),交易越小開銷占比越高,通常每數百毫秒 commit 一次。Kafka Streams 只要設 processing.guarantee=exactly_once_v2 即可自動處理以上流程;Flink 則以 checkpoint + 兩階段提交 sink 實現端到端 exactly-once。

為什麼 Kafka 這麼快

技術說明
循序 I/O(sequential I/O)只在 segment 尾端 append、consumer 循序讀。磁碟(即使是 HDD)的循序吞吐遠高於隨機 I/O,作業系統的 read-ahead 與 write-behind 也能充分發揮。
依賴 OS page cacheKafka 不在 JVM heap 內自建快取,寫入就是寫進 page cache,由 kernel 非同步刷盤;即時 consumer 讀的是剛寫入的資料,幾乎全部命中 page cache。避免 JVM GC 壓力與資料在 heap 與 page cache 存兩份,broker 重啟後快取仍是熱的。
Zero-copy(sendfile)傳統讀檔再送出要經過:磁碟 → page cache → user buffer → socket buffer → NIC,4 次複製與多次 context switch。Kafka 用 sendfile()(Java FileChannel.transferTo)讓資料直接從 page cache 送到 NIC(搭配 DMA scatter-gather),省掉進出 user space 的複製。前提是 broker 不需要修改資料——這也是為什麼訊息格式在 producer、磁碟、consumer 之間保持一致。注意:啟用 TLS 時無法 zero-copy(資料要在 user space 加密)。
Batching 與端到端壓縮producer 批次送、broker 批次寫、consumer 批次拉,攤平每次請求的網路與系統呼叫開銷;壓縮以 batch 為單位且 broker 不解壓。
Partition 平行不同 partition 分散在不同 broker 與磁碟,讀寫水平擴展。
簡單的 consumer 狀態broker 不追蹤每則訊息的 ack,只存每個 group 每個 partition 的 offset 數字。
Pull 模型consumer 依自己的能力拉取,天然 backpressure;可以一次拉大量資料(long polling 避免空轉)。
面試官想聽到什麼

「Kafka 為什麼快」的標準答案就是:循序寫入 + page cache + zero-copy + 批次與壓縮 + partition 平行。加分點:能解釋 zero-copy 省掉了哪幾次複製(這題常與第 3 章的 OS 知識串在一起問)、知道 TLS 會讓 zero-copy 失效、知道 Kafka 用多副本而非每則 fsync 保證持久性。這些也呼應職缺的「Linux 高效能網路服務」。

RabbitMQ 與 Kafka 比較 進階

RabbitMQ 是實作 AMQP 0-9-1 的傳統 message broker(Erlang 撰寫),核心是「智慧的 broker、單純的 consumer」:broker 負責路由、追蹤每則訊息的 ack、重新投遞。

模型:Producer → Exchange → Binding → Queue → Consumer

Producer 不直接寫 queue,而是寫到 exchange,exchange 依 binding 規則與訊息的 routing key 決定送到哪些 queue。

flowchart LR P["Producer"] -->|"routing key: push.ios.vip"| X{{"Topic exchange: notifications"}} X -->|"binding: push.ios.*"| Q1[["queue: ios-push"]] X -->|"binding: push.#"| Q2[["queue: push-audit"]] X -->|"binding: *.*.vip"| Q3[["queue: vip-priority"]] Q1 --> C1["iOS sender workers"] Q2 --> C2["Audit logger"] Q3 --> C3["VIP handler"] Q1 -.->|"reject / TTL 到期"| DLX{{"DLX: notifications.dlx"}} DLX --> DLQ[["queue: ios-push.dlq"]]
Exchange 類型路由規則用途
directrouting key 完全相等依類型分派任務(email、sms)
topic以 . 分段的模式比對:* 恰好一段、# 零或多段多維度訂閱(push.ios.*、*.*.vip)
fanout忽略 routing key,送到所有綁定的 queue廣播(pub/sub)
headers依訊息 headers 的屬性比對(x-match: all/any)路由條件不適合放在字串 key 時

Ack/Nack、Prefetch、DLX

Consumer ack
手動 ack 模式下,consumer 處理完送 basic.ack,broker 才刪除訊息。若 consumer 的連線斷了而尚未 ack,訊息會重新投遞給其他 consumer(at-least-once)。basic.nack/basic.reject 可選擇 requeue=true(放回佇列)或 false(丟棄或進 DLX)。
Prefetch(basic.qos)
每個 consumer 最多可持有多少則未 ack 的訊息。沒設定(無上限)時 broker 會把大量訊息推給第一個 consumer,造成負載不均與記憶體暴增;設太小則吞吐低(每則都要等 RTT)。常見從 10–300 之間依處理耗時調整。這是 RabbitMQ 的 push 模型下的 backpressure 機制。
Publisher confirms
producer 端的 ack:broker 把訊息持久化(或複製到 quorum)後回 confirm。與 persistent message、durable queue 一起才能保證不遺失。
DLX(Dead Letter Exchange)
queue 設定 x-dead-letter-exchange,訊息在以下情況被轉送到 DLX:被 reject/nack 且 requeue=false、訊息 TTL 到期、queue 超過長度上限。結合 TTL 可以實作延遲重試:失敗訊息送到「等待 queue」(TTL 30 秒、無 consumer),到期後經 DLX 回到原 queue。
Queue 類型
Classic queue(單節點)、Quorum queue(以 Raft 複製,取代已被移除的 classic mirrored queue,是高可用的推薦選擇)、Stream(3.9 起,append-only log,可重播,類似 Kafka)。
常見陷阱

處理失敗就 nack(requeue=true) 會造成 poison message 無限迴圈:一則格式錯誤的訊息不停被重新投遞、佔滿 CPU。要記錄重試次數(quorum queue 會在 header 帶 delivery count,並可設定 delivery-limit),超過就送 DLQ。

Kafka vs RabbitMQ

面向KafkaRabbitMQ
本質分散式 commit logmessage broker(智慧路由)
消費模型Pull;consumer 自己管 offsetPush(basic.consume)+ prefetch;broker 管 ack
訊息保留依時間/大小保留,可重播ack 後刪除(Stream 類型除外)
吞吐極高(單叢集每秒百萬級訊息常見)中高(每節點每秒數萬級,依持久化與 ack 設定)
延遲毫秒到數十毫秒(受 batch 設定影響)低延遲(亞毫秒到毫秒),訊息量低時尤其好
順序partition 內嚴格有序單一 queue 單一 consumer 時有序;多 consumer 或 requeue 會打亂
路由僅依 topic/partition;路由邏輯在 consumerexchange 類型豐富,broker 端路由
個別訊息操作不支援單則 ack/延遲/優先級單則 ack、TTL、優先級佇列、延遲(plugin)
平行度受 partition 數限制任意增加 consumer
適用事件流、日誌、CDC、stream processing、需要重播與多下游任務佇列、RPC、複雜路由、每則處理時間差異大的工作
面試官想聽到什麼

不是「Kafka 比較快所以選 Kafka」。要從需求推導:需要重播/多個獨立下游/高吞吐事件流/stream processing → Kafka;需要單則訊息的 ack、優先級、延遲重試、複雜路由,或每則任務處理時間差異很大(不希望一則慢任務卡住同 partition 的後續訊息)→ RabbitMQ 或 SQS。很多公司兩者並用:Kafka 當事件主幹,RabbitMQ/SQS/Celery 當任務佇列。

其他訊息系統:SQS/SNS、Google Pub/Sub、Redis Streams、NATS、Pulsar 進階

系統模型交付語意 / 順序特點適用
AWS SQS全託管 queue(pull,支援 long polling 最長 20 秒)Standard:at-least-once、盡力而為的順序、幾乎無上限吞吐。FIFO:以 message group 保序、5 分鐘去重視窗(exactly-once processing)、吞吐有上限Visibility timeout(預設 30 秒):被取走的訊息暫時隱形,逾時未刪除就重新可見;redrive policy 設定 maxReceiveCount 後進 DLQ;保留最長 14 天AWS 上的任務佇列、Lambda 觸發、免維運
AWS SNS全託管 pub/sub(push)at-least-once推送到 SQS、Lambda、HTTP、email、行動推播;支援訊息過濾政策SNS → 多個 SQS 的 fan-out 是 AWS 經典模式
Google Cloud Pub/Sub全託管 pub/sub,push 或 pull subscriptionat-least-once;可啟用 ordering key 保序;可選 exactly-once delivery(區域內)自動擴展、全球化;ack deadline 機制類似 visibility timeout;支援 dead letter topic 與 seek 重播GCP 上的事件管線,常接 Dataflow
Redis StreamsRedis 內的 append-only log(XADD),支援 consumer group(XREADGROUP)at-least-once;每個 group 有 Pending Entries List(PEL),需 XACK;以 XAUTOCLAIM 接手逾時訊息極低延遲、已經有 Redis 就零額外維運;受記憶體限制,以 MAXLEN/MINID 修剪;持久化依 AOF/RDB中小流量的即時任務、通知;不適合長期保留大量資料
NATSCore NATS:輕量 pub/sub、request-reply;JetStream:持久化串流Core:at-most-once(無持久化);JetStream:at-least-once,可透過 Msg-Id 去重單一小 binary、延遲極低、subject 萬用字元路由微服務間訊息、IoT、edge
Apache Pulsar分層架構:無狀態 broker + BookKeeper 儲存at-least-once;支援 transaction;Key_Shared 訂閱可在多 consumer 下按 key 保序計算與儲存分離(擴容不需搬資料)、原生 multi-tenancy、geo-replication、tiered storage;訂閱類型:exclusive / failover / shared / key_shared,同時涵蓋 queue 與 stream多租戶平台、跨區複製需求強
AWS Kinesis Data Streams託管的 log(shard ≈ partition)at-least-once,shard 內有序每個 shard 有固定的寫入與讀取吞吐上限,需依流量規劃 shard 數(或用 on-demand 模式)AWS 上不想維運 Kafka 時(另有 Amazon MSK 託管 Kafka)
# SQS 消費者:處理成功才 delete;失敗就不刪,visibility timeout 到期後自動重投
import boto3, json
sqs = boto3.client("sqs")
URL = "https://sqs.ap-northeast-1.amazonaws.com/123456789012/push-jobs"

while True:
    resp = sqs.receive_message(QueueUrl=URL, MaxNumberOfMessages=10,
                               WaitTimeSeconds=20,           # long polling,減少空請求費用
                               VisibilityTimeout=60)         # 需大於單則最長處理時間
    for msg in resp.get("Messages", []):
        try:
            handle(json.loads(msg["Body"]))                  # 必須冪等:同一則可能被投遞多次
            sqs.delete_message(QueueUrl=URL, ReceiptHandle=msg["ReceiptHandle"])
        except Exception:
            pass   # 不刪除 → 逾時後重投;超過 maxReceiveCount 由 redrive policy 送進 DLQ
# Redis Streams consumer group
import redis
r = redis.Redis()
try:
    r.xgroup_create("push:stream", "senders", id="0", mkstream=True)
except redis.ResponseError:
    pass                                                    # group 已存在

r.xadd("push:stream", {"user_id": "u42", "campaign": "c7"}, maxlen=1_000_000, approximate=True)

while True:
    # ">" 代表只拿從未投遞給本 group 的新訊息
    resp = r.xreadgroup("senders", "worker-1", {"push:stream": ">"}, count=100, block=5000)
    for _stream, entries in resp or []:
        for msg_id, fields in entries:
            send_push(fields)
            r.xack("push:stream", "senders", msg_id)
    # 定期接手其他 worker 掛掉後留在 PEL 中超過 60 秒的訊息
    r.xautoclaim("push:stream", "senders", "worker-1", min_idle_time=60_000, count=100)

交付語意、冪等消費者、順序、DLQ 與 Backpressure 深入 高頻

三種交付語意

語意意義實作適用
At-most-once最多一次,可能遺失,不會重複先 ack/commit 再處理;producer 不重試(acks=0)metrics 取樣、可丟的 log
At-least-once至少一次,不會遺失,可能重複處理完才 ack/commit;producer 重試預設選擇,搭配冪等消費者
Exactly-once效果上恰好一次at-least-once + 冪等/去重,或交易性地同時寫結果與 offset計費、扣款、計數
核心觀念

端到端的 exactly-once 不是 MQ 單方面能提供的,它是整條管線的性質:source 可重播 + 處理可重試 + sink 冪等(或交易性)。實務上幾乎所有系統都是「at-least-once 傳遞 + 冪等消費者」。

冪等消費者設計

重複的來源:producer 重試、consumer 處理完但 commit 前當機、rebalance、SQS visibility timeout 到期、上游重送。對策依場景選擇:

  1. 天然冪等的操作:UPSERT ... SET status='SENT'、以 event_id 為主鍵寫入。重複寫同一筆資料無害。
  2. 去重表(dedup table):processed_events(event_id PRIMARY KEY),處理與插入去重紀錄在同一個交易中;插入衝突代表已處理過,直接跳過。
  3. 快取去重:Redis SET event:{id} 1 NX EX 86400,NX 失敗代表處理過。較快但與業務寫入不在同一交易,處理失敗時要刪除 key(或接受極小機率的漏處理/重複)。
  4. 版本號 / 序號:每個實體的事件帶遞增 version,consumer 只套用 version > 目前 version 的事件,同時解決重複與亂序(CDC 常用)。
  5. Idempotency key 傳到外部系統:呼叫第三方(支付、LINE 推播)時帶上 idempotency/retry key,讓對方去重。
# 冪等消費者:去重紀錄與業務寫入在同一個 DB 交易中(PostgreSQL + psycopg 3)
import json

def handle_conversion(conn, msg) -> None:
    ev = json.loads(msg.value())
    with conn.transaction():
        cur = conn.execute(
            "INSERT INTO processed_events (consumer, event_id) VALUES (%s, %s) "
            "ON CONFLICT DO NOTHING",
            ("conversion-attributor", ev["event_id"]))
        if cur.rowcount == 0:
            return                                    # 已處理過:跳過(但仍允許 commit offset)
        conn.execute(
            "INSERT INTO campaign_daily_stats (campaign_id, day, conversions, revenue) "
            "VALUES (%s, %s, 1, %s) "
            "ON CONFLICT (campaign_id, day) DO UPDATE "
            "SET conversions = campaign_daily_stats.conversions + 1, "
            "    revenue = campaign_daily_stats.revenue + EXCLUDED.revenue",
            (ev["campaign_id"], ev["day"], ev["revenue"]))
    # 交易提交後才 commit Kafka offset;若此時當機,重投的訊息會被去重表擋下

# processed_events 需定期清理(保留時間 > 最大可能的重投視窗,例如 7 天),或用時間分區表直接 DROP 舊分區
常見陷阱

「先查有沒有處理過,沒有再處理」(check-then-act)在多個 consumer 並行時有競態。必須依賴唯一約束或原子操作。另外 counter += 1 這種非冪等更新,一定要搭配去重;或者改成以 event_id 寫入明細、查詢時再聚合。

順序性保證

  • Kafka:partition 內有序。要讓某實體的事件有序,就以實體 id 為 key。全域順序只能用單一 partition(犧牲平行度)。
  • Consumer 端的亂序來源:在 consumer 內用 thread pool 平行處理同 partition 的訊息;重試(失敗的訊息送到 retry topic 後,後面的訊息先被處理)。如需保序,平行處理要以 key 分派到固定 worker(同 key 同 worker)。
  • 重試與順序的取捨:嚴格保序時,一則訊息失敗就必須阻塞該 partition(或該 key)直到成功;要不阻塞就得接受亂序,並在 consumer 用版本號處理「舊事件晚到」。
  • SQS FIFO / Pub/Sub ordering key / Pulsar Key_Shared:都是「以 key 分組保序」的概念。

Dead Letter Queue 與重試策略

錯誤要分兩類處理:

暫時性錯誤(transient)

下游逾時、DB 連線中斷、429 限流。應該重試,搭配 exponential backoff。

永久性錯誤(permanent / poison)

JSON 格式錯誤、schema 不符、引用不存在的資料、業務規則違反。重試無用,直接送 DLQ 並告警,避免卡住整個 partition。

Kafka 本身沒有「延遲投遞」與「單則重試」,常見做法是 retry topic 階梯(Uber 提出的模式):

flowchart LR M["main topic"] --> C["consumer"] C -->|"成功"| OK(("done")) C -->|"暫時性失敗"| R1["retry-1m topic"] C -->|"永久性失敗"| DLQ["dlq topic"] R1 --> RC1["retry consumer
等到 msg.ts + 1 分鐘才處理"] RC1 -->|"再失敗"| R2["retry-10m topic"] R2 --> RC2["retry consumer
延遲 10 分鐘"] RC2 -->|"再失敗"| DLQ DLQ --> OPS["告警 + 人工檢查 / 修復後 replay 回 main"]
  • 主 consumer 不阻塞:失敗的訊息移出主流程,後續訊息繼續處理(代價:該 key 的順序被打破)。
  • Retry consumer 讀到訊息時檢查「是否已到可處理時間」,未到就 pause() 該 partition 並等待(因為同一 retry topic 中後面的訊息只會更晚到期),不要 sleep 阻塞 poll 迴圈太久而超過 max.poll.interval.ms。
  • 在 header 記錄原始 topic/partition/offset、錯誤原因、重試次數、首次失敗時間,方便追查與 replay。
  • DLQ 一定要有監控與告警以及 replay 工具,否則 DLQ 只是資料的墳墓。
  • Spring Kafka 的 @RetryableTopic、Kafka Connect 的 errors.deadletterqueue.topic.name 都有內建支援。
import json, time
from confluent_kafka import Producer

RETRY_CHAIN = [("ad-events.retry-1m", 60), ("ad-events.retry-10m", 600)]

class PermanentError(Exception): ...

def route_failure(producer: Producer, msg, err: Exception) -> None:
    headers = dict(msg.headers() or [])
    attempt = int(headers.get("x-retry-attempt", b"0"))
    base = {
        "x-original-topic": headers.get("x-original-topic", msg.topic().encode()),
        "x-original-offset": headers.get("x-original-offset", str(msg.offset()).encode()),
        "x-error": repr(err)[:500].encode(),
    }
    if isinstance(err, PermanentError) or attempt >= len(RETRY_CHAIN):
        target, delay = "ad-events.dlq", 0
    else:
        target, delay = RETRY_CHAIN[attempt]
    base["x-retry-attempt"] = str(attempt + 1).encode()
    base["x-not-before"] = str(int((time.time() + delay) * 1000)).encode()
    producer.produce(target, key=msg.key(), value=msg.value(), headers=base)
    # 注意:要確認轉送成功(flush / delivery callback)後,才 commit 原訊息的 offset

Backpressure

當消費速度長期低於生產速度,佇列只是延後問題。各層的背壓機制:

  • Pull 模型天然背壓(Kafka、SQS):consumer 只拿自己處理得了的量,訊息堆積在 broker(磁碟便宜)而不是 consumer 記憶體。要監控的是 lag。
  • Push 模型要靠 prefetch(RabbitMQ)或 flow control(Pub/Sub streaming pull 的 max outstanding messages/bytes)。
  • Consumer 內部:用有界佇列連接 poll thread 與 worker pool;佇列滿時 consumer.pause(partitions),消化後 resume(),期間仍持續呼叫 poll 以維持 group 成員資格。
  • Producer 端:buffer 滿時 send() 阻塞或丟 BufferError;collector 收到後應回 503/429 讓 SDK 退避,而不是無限緩衝到 OOM。
  • Broker 端:Kafka quota(限制每個 client 的 produce/fetch 速率)、RabbitMQ memory/disk alarm 會阻擋 publisher。
面試官想聽到什麼

「consumer 處理失敗怎麼辦?」→ 區分暫時性與永久性錯誤;暫時性用退避重試(不阻塞主流程用 retry topic,需保序則阻塞該 partition);永久性進 DLQ;DLQ 有告警與 replay;所有處理冪等。「怎麼保證不重複扣款?」→ at-least-once + 去重表與業務寫入同交易,或帶 idempotency key 呼叫下游。

事件驅動架構模式:Event Notification、ECST、Event Sourcing、CQRS、Outbox + CDC 深入

Martin Fowler 把「事件驅動」這個模糊的詞拆成幾種不同的模式,它們的取捨差異很大,面試時能分清楚會很加分。

1. Event Notification(事件通知)

事件只說「發生了什麼」並帶上 id:{"type":"CampaignUpdated","campaign_id":42}。接收方若需要細節,回頭呼叫來源服務的 API 查詢。

  • 優點:事件小、來源服務的資料模型不外洩、接收方總是拿到最新資料。
  • 缺點:回查造成來源服務負載與耦合(來源掛了接收方也做不了事);事件與回查之間資料可能又變了。

2. Event-Carried State Transfer(ECST)

事件攜帶完整(或足夠的)狀態:{"type":"CampaignUpdated","campaign_id":42,"budget":1000,"status":"ACTIVE","targeting":{...}}。接收方在本地維護一份副本,不需回查。

  • 優點:接收方完全自主(來源服務掛了也能運作)、低延遲讀取。非常適合 bidder:campaign 設定以 ECST 推到各區,出價時只讀本地記憶體。
  • 缺點:資料重複存放、最終一致、事件較大;schema 變成服務間契約,演進需要管理。搭配 compacted topic,新的接收方可以重建完整狀態。

3. Event Sourcing

不儲存「目前狀態」,而是儲存所有改變狀態的事件作為唯一真相來源(source of truth),目前狀態由重播事件推導(fold)。例如帳戶不存餘額,而是存所有 Deposited/Withdrawn 事件。

  • 優點:完整稽核軌跡、可回到任何時間點的狀態(time travel)、修 bug 後可重新計算、天然產生事件流給其他系統。
  • 缺點:學習曲線高;查詢目前狀態需要 projection 或 snapshot;事件 schema 永遠要相容(舊事件不能改);「刪除個資」(GDPR)困難(常用 crypto shredding:個資加密,刪除金鑰即可)。
  • 適合:金流、帳務、需要稽核的領域。不要對整個系統都套用。

4. CQRS(Command Query Responsibility Segregation)

把寫入模型(command,負責驗證與狀態變更)與讀取模型(query,針對查詢最佳化)分開。寫入端發出事件,讀取端訂閱事件建立各種 read model(例如 Elasticsearch 做搜尋、ClickHouse 做報表、Redis 做即時排行榜)。

flowchart LR U["Client"] -->|"Command: 更新出價策略"| W["Write service
(驗證 + 業務規則)"] W --> DB[("寫入 DB
MySQL")] DB -->|"CDC / outbox"| K[["Kafka"]] K --> P1["Projector"] --> R1[("Elasticsearch
campaign 搜尋")] K --> P2["Projector"] --> R2[("ClickHouse
報表")] K --> P3["Projector"] --> R3[("Redis
bidder 本地快取")] U -->|"Query"| R1

CQRS 常與 event sourcing 一起出現,但兩者獨立:可以只做 CQRS(寫入用一般 DB,再用 CDC 同步出 read model)。代價是讀取模型最終一致:寫完立刻讀可能讀不到(需要 read-your-writes 的處理,例如寫入後短時間從寫入端讀)。

Outbox Pattern + CDC:解決 Dual Write

服務需要「更新 DB」並「發出事件」。直接兩邊都寫(dual write)的失敗情境:

  • DB commit 成功 → 發 Kafka 前 process 被 kill → 事件永遠遺失,下游不一致。
  • 先發 Kafka → DB commit 失敗 → 下游收到「幽靈事件」。
  • 兩個並行請求 A、B:DB 中 B 後寫,但 Kafka 中 A 後到 → 下游最終狀態與 DB 不同。
sequenceDiagram participant S as Campaign Service participant DB as MySQL participant D as Debezium (Kafka Connect) participant K as Kafka participant C as Consumers S->>DB: BEGIN S->>DB: UPDATE campaigns SET budget=... S->>DB: INSERT INTO outbox(event_id, aggregate_id, type, payload) S->>DB: COMMIT(原子:兩者都成功或都失敗) D->>DB: 以 replication client 讀取 binlog D->>K: 發佈 outbox 事件(key = aggregate_id,保序) K->>C: 消費(可能重複 → 以 event_id 冪等處理) Note over S,DB: 可定期刪除已發佈的 outbox 列(CDC 讀的是 binlog,刪除不影響)

兩種 relay 方式:

Polling publisherCDC(log tailing,如 Debezium)
做法背景程式定期 SELECT ... WHERE published=false ORDER BY id FOR UPDATE SKIP LOCKED,發送後標記讀 MySQL binlog(row 格式)/ PostgreSQL WAL(logical decoding),把 outbox 表的 insert 轉成 Kafka 訊息
延遲取決於輪詢間隔低(接近即時)
DB 負擔持續查詢與更新極低(讀 log)
維運簡單,不需額外元件需要 Kafka Connect + Debezium,需設定 binlog/replication 權限
順序需小心多 relay 實例並行依 commit 順序

Debezium 提供 Outbox Event Router(SMT),可以依 outbox 表的欄位把事件路由到對應 topic、以 aggregate_id 當 key。CDC 也可以不用 outbox 直接捕捉業務表變更(例如把 MySQL 的 campaigns 表同步到 Elasticsearch),但這會讓下游依賴你的表結構;outbox 則讓你明確設計對外的事件契約。

CREATE TABLE outbox (
  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
  event_id      CHAR(36)     NOT NULL UNIQUE,     -- 下游去重用
  aggregate_type VARCHAR(64) NOT NULL,            -- 例如 campaign → 路由到 campaign.events topic
  aggregate_id  VARCHAR(64)  NOT NULL,            -- 作為 Kafka key,保證同一實體有序
  event_type    VARCHAR(64)  NOT NULL,
  payload       JSON         NOT NULL,
  created_at    TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);
Appier 情境:campaign 設定同步到 bidder

以廣告平台常見做法為例:廣告主在後台修改 campaign(預算、出價、定向、暫停)時,管理後台服務在 MySQL 交易中更新 campaign 並寫入 outbox;Debezium 把事件以 campaign_id 為 key 送到 compacted topic campaign.state(ECST,帶完整設定)。各區 bidder 訂閱該 topic 更新本地記憶體中的 campaign 索引;新啟動的 bidder 從 compacted topic 開頭讀一遍即可重建完整狀態。「暫停」這類事件會在數秒內生效,且不會因為後台服務當機而遺失。

Schema 演進:Avro、Protobuf 與 Schema Registry 進階

事件一旦發佈,就有多個你不認識的消費者依賴它的格式;而且 Kafka 中保留的舊訊息會被重播。Schema 管理不好的典型災難:producer 把欄位從字串改成數字,所有 consumer 反序列化失敗、lag 爆炸。

格式比較

JSONAvroProtobuf
大小大(欄位名重複出現)小(不含欄位名,依 schema 順序編碼)小(以 field number 標記)
Schema可選(JSON Schema)必須,讀取時需要 writer schema必須(.proto),解碼不需 writer schema
演進規則靠紀律以欄位名比對 writer/reader schema;新增/刪除欄位需有 default以 field number 比對;不可重用或改變號碼,刪除的號碼標記 reserved
生態通用、易除錯Hadoop/Kafka 生態深,適合動態產生 schemagRPC、多語言 codegen

Schema Registry 與相容性

Confluent Schema Registry(或 Apicurio、AWS Glue Schema Registry)集中保存每個 subject(通常 = topic 名 + -value)的 schema 版本。Producer 序列化時註冊/查找 schema 取得 id,訊息格式為:magic byte (0) + 4 bytes schema id + payload。Consumer 依 id 取回 writer schema 解碼。註冊新版本時,registry 依相容性規則檢查,不相容就拒絕——這是在 CI/部署時就擋下破壞性變更的關鍵。

相容性模式定義允許的變更升級順序
BACKWARD(Confluent 預設)新 schema 能讀取舊 schema 寫的資料刪除欄位、新增有預設值的欄位先升級 consumer,再升級 producer
FORWARD舊 schema 能讀取新 schema 寫的資料新增欄位、刪除有預設值的欄位先升級 producer,再升級 consumer
FULL同時滿足 backward 與 forward只能新增/刪除有預設值的欄位任意順序
*_TRANSITIVE與所有歷史版本相容(而非只與上一版)同上但更嚴格保留期長、需要重播舊資料時使用
NONE不檢查任意危險
{
  "type": "record",
  "name": "AdEvent",
  "namespace": "com.example.ads",
  "fields": [
    {"name": "event_id",    "type": "string"},
    {"name": "event_type",  "type": {"type": "enum", "name": "EventType", "symbols": ["IMPRESSION", "CLICK", "CONVERSION"]}},
    {"name": "ts_ms",       "type": {"type": "long", "logicalType": "timestamp-millis"}},
    {"name": "campaign_id", "type": "long"},
    {"name": "user_id",     "type": ["null", "string"], "default": null},
    {"name": "price_micros","type": ["null", "long"],   "default": null, "doc": "v2 新增:有 default,向後相容"}
  ]
}
常見陷阱

(1)重新命名欄位 = 刪除舊欄位 + 新增新欄位,在 Avro 中要用 aliases。(2)Avro enum 新增 symbol 對舊 reader 不相容(除非 enum 有 default)。(3)Protobuf 重用已刪除的 field number 會讓舊資料被錯誤解讀,務必 reserved。(4)把 nullable 欄位改成必填會破壞相容性。(5)把 JSON 當「無 schema」用,等於把 schema 檢查延遲到 consumer 在 production 爆炸的那一刻。

面試官想聽到什麼

能說出 backward/forward 的定義與「先升級誰」。實務策略:事件 topic 使用 BACKWARD_TRANSITIVE(或 FULL_TRANSITIVE),新增欄位一律給預設值、不刪必填欄位;破壞性變更就開新 topic(ad-events.v2),新舊並行一段時間後遷移。Schema 檢查放進 CI。

任務佇列:Celery、RQ 與 MQ 的關係 基礎

任務佇列(task queue)是建立在 MQ 之上的更高層抽象:訊息的內容是「要執行的函式名稱 + 參數」,由 worker 程序取出執行。MQ 關心「傳遞訊息」;task queue 額外提供重試、排程、結果儲存、速率限制、工作流(chain/group/chord)等。

CeleryRQ其他
BrokerRabbitMQ(推薦)、Redis、SQS只有 RedisDramatiq(RabbitMQ/Redis)、arq(asyncio + Redis)、Go 的 Asynq
功能完整:重試、ETA/countdown、定期任務(beat)、canvas 工作流、rate limit簡單、易讀、易除錯Temporal:持久化工作流引擎(適合長流程/Saga)
適合中大型專案、複雜工作流小型專案、簡單背景任務
# tasks.py — Celery 可靠性相關設定
from celery import Celery

app = Celery("push", broker="amqp://rabbitmq//", backend="redis://redis/1")
app.conf.update(
    task_acks_late=True,               # 任務「執行完」才 ack(預設是取出即 ack → worker 當機任務遺失)
    task_reject_on_worker_lost=True,   # worker 被 kill 時把任務放回佇列
    worker_prefetch_multiplier=1,      # 每個 worker process 只預取 1 個,長任務時避免囤積
    task_time_limit=300, task_soft_time_limit=270,
    broker_transport_options={"visibility_timeout": 3600},   # 僅 Redis/SQS broker:需大於最長任務時間
)

class TransientError(Exception): ...

@app.task(bind=True, autoretry_for=(TransientError,), retry_backoff=True,
          retry_backoff_max=600, retry_jitter=True, max_retries=5, rate_limit="1000/s")
def send_push(self, campaign_id: int, user_id: str, idem_key: str):
    # acks_late 代表任務可能執行兩次 → 必須冪等
    if already_sent(idem_key):
        return "dup"
    resp = push_provider.send(user_id, build_payload(campaign_id), idempotency_key=idem_key)
    if resp.status in (429, 500, 502, 503):
        raise TransientError(resp.status)
    mark_sent(idem_key)
常見陷阱

(1)Celery 預設 acks_late=False:worker 拿到任務就 ack,執行中當機任務就消失(at-most-once)。(2)以 Redis 為 broker 時,若任務執行超過 visibility_timeout,會被重新派給另一個 worker,造成重複執行。(3)把大物件(整份 DataFrame)當參數傳:應該傳 id,worker 自己去讀。(4)任務參數用 pickle 序列化有安全風險,用 JSON。(5)Celery 的 rate_limit 是每個 worker 的限制,不是全域限制。

實戰設計:事件追蹤管線與推播發送佇列 深入 高頻

Appier 真實面試題之一是「客戶端 SDK 回報大量 JSON,設計統計報表系統」。以下聚焦在其中佇列層的設計細節(完整的系統設計流程見 第 13 章,批次處理見 第 12 章)。數字皆為示範用的假設,面試時要先跟面試官確認規模。

flowchart LR SDK["Web/App SDK
批次 + gzip + 重試
client 產生 event_id"] -->|"HTTPS POST /v1/events"| LB["LB / Nginx"] LB --> COL["Collector(無狀態)
驗證、補 server_ts、geo、
非同步 produce"] COL -->|"acks=all, zstd"| K1[["Kafka: events.impression"]] COL --> K2[["Kafka: events.click"]] COL --> K3[["Kafka: events.conversion"]] COL -.->|"Kafka 不可用時"| SPOOL[("本地磁碟 spool
恢復後重送")] K1 --> SP["Stream processor(Flink)
去重、event-time 視窗、
click↔impression join"] K2 --> SP K3 --> SP SP --> OLAP[("ClickHouse / Druid
報表")] SP --> RT[("Redis
即時計數 / pacing")] K1 --> S3W["Connector"] --> LAKE[("S3 / GCS Parquet
資料湖")] SP -->|"無法解析"| DLQ[["events.dlq"]]

SDK 與 Collector 端

  • SDK:在裝置端累積事件(例如每 20 則或每 10 秒)批次送出並 gzip,減少請求數與電量;失敗時存在本地、指數退避重送,所以 server 端一定會收到重複 → 每則事件在 client 端產生 event_id(UUID v7)供下游去重。同時帶 client_ts 與 sent_at,server 用 server_ts - sent_at 估算並校正裝置時鐘偏差。
  • Collector:無狀態、水平擴展;只做輕量驗證(大小上限、必要欄位、API key)與補充欄位,不要同步寫 DB。Producer 非同步送 Kafka,收到請求就回 202/204。
  • Kafka 不可用時:collector 把事件寫進本地磁碟 spool(有上限),恢復後重送;spool 也滿了才回 503 讓 SDK 退避——這就是端到端的 backpressure 鏈。
  • Producer 設定:acks=all、idempotence、linger.ms 10–50ms、zstd。曝光事件量大但單筆價值低,可接受稍高延遲換吞吐;轉換事件量小但與計費相關,可靠性優先。

Topic 切分與 Key 選擇

  • 依事件類型分 topic:曝光量通常比點擊大一到兩個數量級,轉換更少但最有價值。分開後可以各自設定 partition 數、retention、SLA 與 consumer,避免大量曝光事件拖慢轉換的處理(head-of-line)。
  • Key 的選擇取決於下游需要的順序與狀態:
    • 下游要做 per-user 狀態(sessionization、frequency cap、attribution 以 user 為單位 join)→ key = user_id/device_id,同一使用者的事件落在同一 partition,stream processor 的 keyed state 不需 shuffle。
    • 下游只做全域聚合、不需順序 → key = null(sticky partitioner),分佈最均勻。Flink 內部會再依需要 keyBy 重新分區。
    • 避免 key = campaign_id:大型 campaign 的流量可能是小 campaign 的萬倍,造成嚴重 skew 與單一 partition lag。
    • 機器人或異常裝置可能讓單一 user_id 爆量 → 在 collector 做速率限制或把異常流量導到獨立 topic。

Partition 數量估算

經驗公式:partitions ≥ max(目標吞吐 / 單 partition producer 吞吐, 目標吞吐 / 單 partition consumer 吞吐) × 成長與追趕的餘裕。通常 consumer 端的處理才是瓶頸。

  1. 假設尖峰曝光 30 萬則/秒、平均 1KB → 約 300MB/s(未壓縮)。
  2. 壓測得知單一 consumer 實例處理一個 partition 約 10MB/s(含反序列化、去重、聚合)→ 至少 30 個 partition。
  3. 追趕餘裕:若 consumer 停機 1 小時,希望 30 分鐘內追上,處理能力需達輸入速率的 3 倍(C·0.5h = r·(1h + 0.5h))→ 約 90 個 partition 的平行度。
  4. 再考慮未來 1–2 年成長,並選擇有很多因數的數字(便於 consumer 數量整除,負載平均),例如 96 或 120。
  5. 不要盲目開到數千:每個 partition 都有檔案 handle、記憶體 buffer、複製流量與 leader 選舉成本;partition 太多會拉長 rebalance 與 failover 時間,並讓 producer batch 變小(每個 partition 各自 batch)。

容量:zstd 壓縮約 5 倍 → 60MB/s → 每天約 5.2TB;RF=3 → 約 15.5TB/天;保留 3 天 → 約 47TB 磁碟,再加上 30–50% 餘裕。長期資料放資料湖(S3/GCS Parquet),Kafka 只保留重播視窗;或使用 tiered storage(Kafka 3.6 起提供,把舊 segment 卸載到物件儲存)。

Lag 告警與 SLO

Consumer業務 SLO告警條件(示例)
即時 pacing 計數花費數據延遲 < 30 秒lag 時間 > 30 秒持續 2 分鐘 → page(可能導致超支)
報表聚合報表延遲 < 5 分鐘lag 時間 > 5 分鐘持續 10 分鐘 → ticket;> 30 分鐘 → page
資料湖寫入小時級lag 時間 > 1 小時
所有 consumer—lag 持續上升(斜率 > 0)超過 15 分鐘、consumer 停止 commit、DLQ 有新訊息、單一 partition lag 遠高於其他(skew)

另外監控:producer 的 error rate 與 record-queue-time、broker 的 under-replicated partitions(URP)與 offline partitions、ISR shrink 頻率、磁碟使用率、請求延遲 p99。

Stream Processor 的關鍵處理

  • 去重:以 event_id 為 key 的 state(TTL 數小時到一天)或 Bloom filter 過濾 SDK 重送與 at-least-once 造成的重複。
  • Event time 與 watermark:依事件發生時間(而非到達時間)分配視窗;行動裝置離線回補的事件可能晚到數小時,用 watermark + allowed lateness 決定何時輸出結果、晚到資料走 side output 修正。
  • Click 與 impression 的 join:以 impression_id 做 interval join(例如點擊在曝光後 30 分鐘內),用於反詐欺與歸因。
  • Sink 冪等:聚合結果以 (campaign_id, window_start) upsert,或 ClickHouse ReplacingMergeTree;Flink 以 checkpoint + 交易性 sink 達成端到端 exactly-once。
Appier 情境:曝光、點擊、轉換事件流

以廣告平台常見做法為例:曝光事件是量最大的資料源,用於報表、frequency cap、預算花費;點擊與轉換量小但直接關係到計費與模型訓練(CTR/CVR 的 label)。同一套 Kafka 事件流同時餵給:即時 pacing(秒級)、即時報表(分鐘級)、反詐欺、資料湖(每小時批次 → ML 訓練)。這正是 Kafka「多個 consumer group 各自獨立重播」的價值:新增一個下游(例如新的歸因模型)不需要改動上游任何東西,從 compacted 或保留期內的資料開始讀即可。

推播發送佇列設計

情境:行銷自動化平台要對 1,000 萬名使用者發送一則推播活動(類似 AIQUA 的場景),同時也有交易型訊息(OTP、訂單通知)需要秒級送達。Appier 也出過「設計發送 LINE 訊息的 API」的 take-home。

flowchart LR API["Campaign API
建立發送任務"] --> SCH["Scheduler
依時區/靜默時段排程"] SCH --> SEG["Audience resolver
查出 1000 萬 user
切成每批 1000 人"] SEG --> QB[["bulk queue
(行銷)"]] TX["交易型事件
OTP / 訂單"] --> QT[["priority queue
(交易)"]] QB --> W["Sender workers
依通道分組"] QT --> W W --> RL{"Rate limiter
每個 provider / app 的
token bucket"} RL --> APNS["APNs"] RL --> FCM["FCM"] RL --> LINE["LINE Messaging API"] W -->|"429 / 5xx"| RETRY[["retry queue
退避 + Retry-After"]] W -->|"無效 token / 永久錯誤"| DLQ2[["DLQ + 清除無效 token"]] APNS -.->|"回執 / 開啟事件"| EV[["events topic"]]
  1. Fan-out 分層:一則活動不要變成一則巨大的訊息,也不要由單一程序迴圈 1000 萬次。先由 resolver 分頁查出受眾、切成每批約 1000 人的 job 放進佇列,再由大量 worker 平行處理。批次大小要讓單一 job 在數秒內完成,失敗重試的成本才低。
  2. 優先級隔離(bulkhead):交易型與行銷型分成不同佇列與 worker pool,行銷活動的尖峰不能讓 OTP 延遲。RabbitMQ/SQS 很適合這種「任務型」工作;也可用 Kafka 不同 topic。
  3. 速率限制:各 provider 與客戶的 app 都有速率上限,用 Redis 上的分散式 token bucket 控制;收到 429 時遵守 Retry-After 並全域降速(而非每個 worker 各自重試,否則形成 retry storm)。
  4. 冪等與去重:以 (campaign_id, user_id) 作為去重 key(Redis SET NX 或 DB 唯一約束),避免 job 重試時重複推播——使用者收到兩則相同推播是很明顯的客訴。呼叫 LINE Messaging API 時可帶 X-Line-Retry-Key(UUID)讓 LINE 端對重試去重。
  5. 延遲/排程:依使用者時區在當地 10 點發送、避開靜默時段 → 延遲佇列:Redis ZSET(score = 發送時間,worker 以 ZRANGEBYSCORE 取到期項目)、SQS delay(最長 15 分鐘)、或依小時切分的 topic。
  6. 錯誤分類:APNs 回應 token 無效(例如 410 Unregistered)→ 永久錯誤,從裝置表移除 token,不重試;429/5xx → 退避重試;超過重試上限 → DLQ 與告警。
  7. 可觀測性:以 campaign 為單位追蹤 queued → sent → delivered → opened 漏斗,佇列深度與發送速率儀表板,預估完成時間;提供暫停/取消活動的開關(worker 每批開始前檢查 campaign 狀態)。
面試官想聽到什麼

推播或「發送 LINE 訊息 API」題的重點不在「用哪個 MQ」,而在:fan-out 分批、交易型與行銷型隔離、provider 速率限制與 429 處理、重試冪等(不重複發送)、排程與時區、失敗分類與 DLQ、可暫停取消、送達漏斗監控。API 本身應該是非同步的:POST /campaigns/{id}/send 回 202 Accepted 與 job id,client 輪詢或透過 webhook 取得進度。

面試自測題

為什麼要用訊息佇列?它會帶來哪些問題?

好處:解耦(生產者不需知道消費者、服務間沒有同步可用性依賴)、削峰填谷(尖峰時緩衝、下游依平均流量規劃)、非同步(耗時工作移出請求路徑)、擴展與重播(Kafka 可多 group 獨立消費、重設 offset 重處理)。代價:多一個需高可用維運的分散式系統、最終一致、可能重複或遺失(需冪等)、順序問題、除錯困難(需 correlation id/tracing)、dual write 問題(需 outbox)、延遲增加。追問方向:什麼時候不該用(需要同步結果的簡單呼叫)。

Kafka 的 partition 有什麼作用?怎麼保證訊息順序?

Partition 是 Kafka 平行度、順序與儲存的單位:每個 partition 是 append-only 有序 log,不同 partition 分散在不同 broker;consumer group 內一個 partition 同時只給一個 consumer,所以 partition 數 = 該 group 的最大平行度。順序只在 partition 內保證。要讓某實體的事件有序:以實體 id 為 key(murmur2 hash 決定 partition);開啟 idempotent producer(避免重試亂序,in-flight ≤ 5 仍保序);不要隨意增加 partition(會改變 key 對應);consumer 端同 partition 循序處理,若要平行就依 key 分派到固定 worker。全域順序只能用單一 partition。

解釋 acks=0/1/all 與 min.insync.replicas,如何設定才不會遺失資料?

acks=0 不等回應;acks=1 leader 寫入本地即回(leader 在複製前掛掉會遺失);acks=all 等所有 ISR 寫入。但 ISR 可能縮到只剩 leader,此時 acks=all 等同 acks=1,所以要設 min.insync.replicas:ISR 少於此值就拒絕寫入。標準高可靠設定:RF=3、min.insync.replicas=2、acks=all、unclean.leader.election.enable=false、副本跨機架、producer 開 idempotence 並處理 delivery callback 錯誤、關閉前 flush;consumer 處理完再 commit。可容忍 1 台 broker 故障。

什麼是 ISR?High Watermark 有什麼作用?

ISR 是與 leader 保持同步的副本集合,follower 超過 replica.lag.time.max.ms(預設 30 秒)未追上 leader 就被移出,追上再加回。High Watermark 是 ISR 全部都已複製到的位置,只有 HW 以下的訊息對 consumer 可見,避免 consumer 讀到之後可能因 leader 切換而被截斷的訊息。leader 換手時用 leader epoch 決定 follower 應截斷到哪裡,避免舊版只靠 HW 截斷造成的遺失或分歧。

Consumer rebalance 是什麼?eager 與 cooperative 有何不同?如何減少 rebalance 的影響?

Group 成員變動(加入、離開、心跳逾時、poll 間隔超過 max.poll.interval.ms)或 partition 數改變時重新分配 partition。Eager:所有成員先放棄所有 partition 再重新分配,整個 group 停止消費。Cooperative(incremental):只撤銷需要移動的 partition,其他繼續消費。減少影響:用 cooperative-sticky assignor 或 Kafka 4.0 的新協定(KIP-848);static membership(group.instance.id)讓短暫重啟不觸發 rebalance;控制每批處理時間(調小 max.poll.records、慢處理移到其他 thread 搭配 pause/resume);在 on_revoke 中 commit 已處理的 offset。

說明 at-most-once、at-least-once、exactly-once 在 consumer 端如何實現。

At-most-once:先 commit offset 再處理,處理中當機則訊息遺失。At-least-once:先處理再 commit,commit 前當機會重新處理,造成重複——最常用,搭配冪等消費者。Exactly-once(效果):把處理結果與 offset 原子地一起保存——Kafka 到 Kafka 用 transactions(send_offsets_to_transaction + read_committed);寫外部 DB 則把 offset 與結果存在同一個 DB 交易中,啟動時 seek 到該 offset;或 at-least-once + 以 event_id 去重的冪等 sink。注意 auto commit 搭配非同步處理可能變成 at-most-once。

Kafka 的 exactly-once semantics 是如何實作的?它的邊界在哪?

兩部分:(1)Idempotent producer:broker 依 PID + 每 partition 的 sequence number 丟棄重複、拒絕跳號,解決單一 session 內重試造成的重複與亂序。(2)Transactions:producer 設定穩定的 transactional.id,init 時遞增 epoch 以 fence 舊實例;在交易中寫多個 partition 並用 send_offsets_to_transaction 把消費 offset 納入;commit 時 coordinator 寫 transaction log 並在各 partition 寫 commit marker;consumer 用 read_committed 只讀已提交資料。邊界:只涵蓋 Kafka 內的讀寫,外部副作用(寫 DB、呼叫 API、寄信)不在交易內,需冪等設計;交易有額外延遲。

為什麼 Kafka 的吞吐量這麼高?

循序 append 寫入與循序讀取(磁碟循序 I/O 遠快於隨機);依賴 OS page cache 而非 JVM heap 快取(避免 GC、避免雙份快取、重啟仍熱);zero-copy 用 sendfile 讓資料從 page cache 直接送到 NIC,省掉 user space 複製與 context switch(TLS 時無效);producer/broker/consumer 全程批次,且以 batch 為單位端到端壓縮、broker 不解壓;partition 分散在多 broker 平行讀寫;broker 不追蹤每則訊息 ack,只存 offset;pull 模型讓 consumer 批次拉取。持久性靠多副本而非每則 fsync。

Kafka 與 RabbitMQ 怎麼選?

Kafka 是分散式 log:訊息保留可重播、多個 consumer group 獨立消費、極高吞吐、partition 內有序,但平行度受 partition 數限制、沒有單則訊息的延遲/優先級/ack,慢訊息會阻塞同 partition。RabbitMQ 是 broker:exchange 路由彈性大、單則 ack/nack、TTL、DLX、優先級,consumer 可任意擴展,訊息 ack 後刪除、吞吐較低。事件流、CDC、stream processing、多下游與重播 → Kafka;任務分派、每則處理時間差異大、需要複雜路由或延遲重試 → RabbitMQ/SQS。很多系統兩者並用。

如何設計冪等的 consumer?

先找出重複來源(producer 重試、commit 前當機、rebalance、visibility timeout、SDK 重送),然後:讓每則事件有全域唯一 event_id(最好在源頭產生);寫入用 upsert 或以 event_id 為唯一鍵;非冪等操作(計數 +1、扣款)用去重表,且去重紀錄與業務寫入在同一個 DB 交易中,依賴唯一約束而非 check-then-act;帶版本號的實體只套用較新版本(同時處理亂序);呼叫外部服務帶 idempotency key。去重表需有 TTL 或分區清理,保留時間要大於最大重投視窗。

Consumer 處理訊息失敗時該怎麼辦?如何設計 DLQ 與重試?

區分暫時性錯誤(逾時、429、連線失敗)與永久性錯誤(格式錯、schema 不符、業務規則違反)。永久性直接送 DLQ、不重試。暫時性:需保序時阻塞該 partition 以退避重試;不需保序時送到 retry topic 階梯(1 分鐘、10 分鐘…),retry consumer 依 header 中的 not-before 時間處理,未到期就 pause partition;超過次數進 DLQ。Header 記錄原始 topic/offset、錯誤、重試次數。要先確認轉送成功再 commit 原 offset。DLQ 必須有告警與 replay 工具。RabbitMQ 用 DLX + TTL 實作延遲重試,SQS 用 redrive policy 的 maxReceiveCount。

什麼是 dual write 問題?outbox pattern 與 CDC 如何解決?

服務要同時更新 DB 並發出事件,但 DB 與 MQ 沒有共同交易:DB 成功、發訊息前當機 → 事件遺失;先發訊息、DB 失敗 → 幽靈事件;並行請求還可能在兩邊順序不同。Outbox:在同一個本地交易中寫業務表與 outbox 表,保證兩者原子;再由 relay 送出:polling publisher(定期查未發送的列,FOR UPDATE SKIP LOCKED)或 CDC(Debezium 讀 binlog/WAL,延遲低、DB 負擔小,可用 Outbox Event Router 路由並以 aggregate_id 為 key 保序)。relay 可能重送,下游以 event_id 冪等處理。

Event notification、event-carried state transfer、event sourcing、CQRS 有什麼差別?

Event notification:事件只帶 id,接收方回查來源 API,事件小但有回查耦合。ECST:事件帶完整狀態,接收方維護本地副本、完全自主、低延遲,代價是資料重複與最終一致(適合把 campaign 設定推給 bidder,搭配 compacted topic 重建)。Event sourcing:以事件序列為唯一真相,狀態由重播推導,有完整稽核與時間旅行,但查詢需 projection、事件 schema 永久相容、刪除個資困難。CQRS:讀寫模型分離,寫入端產生事件、讀取端建立針對查詢最佳化的 read model,常與 event sourcing 搭配但不必然。

Schema registry 的 BACKWARD 與 FORWARD 相容性是什麼意思?升級順序為何?

BACKWARD:新 schema 能讀舊資料,允許刪除欄位、新增有預設值的欄位,應先升級 consumer 再升級 producer(Confluent 預設)。FORWARD:舊 schema 能讀新資料,允許新增欄位、刪除有預設值的欄位,先升級 producer。FULL:兩者皆滿足,可任意順序。*_TRANSITIVE:與所有歷史版本相容,適合長保留期、需重播舊資料的 topic。訊息格式為 magic byte + 4 bytes schema id + payload。破壞性變更就開新 topic 並行遷移;Protobuf 不重用 field number,Avro 改名用 aliases。

如何估算一個 Kafka topic 需要多少 partition?partition 越多越好嗎?

partitions ≥ max(目標吞吐 / 單 partition producer 吞吐, 目標吞吐 / 單 partition consumer 吞吐),通常 consumer 處理能力是瓶頸,需壓測得出。再乘上追趕餘裕(停機 1 小時要在 30 分鐘內追上,處理能力需為輸入的 3 倍)與成長空間,並選擇因數多的數字(如 96、120)方便 consumer 平均分配。不是越多越好:每個 partition 有檔案 handle、記憶體、複製與選舉成本;太多會拉長 failover 與 rebalance、producer batch 變小、端到端延遲上升。另外 partition 只能增加,且增加會改變 key 對應,需要嚴格順序的 topic 一開始就要規劃足夠。

Consumer lag 持續上升,你會怎麼排查與處理?

先看是全部 partition 都上升還是少數:少數 partition → key skew(熱 key)或該 consumer 實例有問題(GC、卡在外部呼叫);全部上升 → 輸入流量變大或處理變慢。檢查 consumer 是否頻繁 rebalance(日誌、max.poll.interval.ms 逾時)、外部依賴延遲(DB 寫入、API)、CPU/GC、是否有 poison message 卡住重試。處理:擴充 consumer 數(不超過 partition 數)、批次寫入、減少同步呼叫、熱 key 加鹽或預聚合、必要時增加 partition。用時間衡量 lag 並依業務 SLO 告警,看趨勢而非單點(Burrow 的做法)。

Celery 任務在 worker 當機時會遺失嗎?如何讓它可靠?

預設 acks_late=False,worker 取出任務就 ack,執行中當機任務遺失(at-most-once)。設定 task_acks_late=True 與 task_reject_on_worker_lost=True 讓任務執行完才 ack、worker 被 kill 時重新排入,變成 at-least-once,因此任務必須冪等。worker_prefetch_multiplier=1 避免長任務被單一 worker 囤積。以 Redis/SQS 為 broker 時,visibility_timeout 要大於最長任務時間,否則會被重複派發。用 autoretry_for + retry_backoff + jitter 處理暫時錯誤,設 time limit 避免卡死,參數傳 id 而非大物件並用 JSON 序列化。

設計一個對 1000 萬使用者發送推播的系統,佇列層要注意什麼?

分層 fan-out:resolver 分頁查受眾、切成每批約 1000 人的 job,大量 worker 平行處理,失敗重試成本低。交易型(OTP)與行銷型分不同佇列與 worker pool(bulkhead)。各 provider(APNs、FCM、LINE)以分散式 token bucket 限速,429 遵守 Retry-After 並全域降速。以 (campaign_id, user_id) 去重避免重複推播,對 LINE 使用 retry key。依時區與靜默時段用延遲佇列排程。錯誤分類:無效 token 清除不重試、暫時錯誤退避重試、超限進 DLQ。可暫停/取消活動,並監控 queued/sent/delivered/opened 漏斗與佇列深度。API 為非同步,回 202 與 job id。

Log compaction 適合什麼場景?tombstone 是什麼?

cleanup.policy=compact 保證每個 key 至少保留最新一筆值,適合狀態變更流:設定表、CDC 的表格狀態、使用者偏好、Kafka 自己的 __consumer_offsets。新 consumer 從頭讀即可重建每個 key 的最新狀態(ECST、Kafka Streams KTable 的基礎)。Tombstone 是 value 為 null 的訊息,表示刪除該 key;它會保留 delete.retention.ms 讓 consumer 有機會看到刪除,之後才被清除。Compaction 在背景對非 active segment 進行,所以短時間內仍可能看到同 key 的多筆舊值,consumer 必須以最後一筆為準。