為什麼需要訊息佇列,以及它的代價 基礎 高頻
訊息佇列(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
(收到全部)"] T1 --> S2["報表服務
(收到全部)"] T1 --> S3["反詐欺服務
(收到全部)"] end
| Point-to-Point | Pub/Sub | |
|---|---|---|
| 語意 | 每則訊息只被一個消費者處理 | 每則訊息被每個訂閱者各收一份 |
| 用途 | 工作分派(寄信任務、影像轉檔) | 事件廣播(訂單建立 → 多個系統各自反應) |
| 代表 | RabbitMQ queue、SQS、Celery | SNS、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。理解它的關鍵是把它想成「一堆可以被多方獨立讀取的檔案」,而不是「佇列」。
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。
acks 與 min.insync.replicas
| 設定 | leader 何時回應 | 延遲 | 資料遺失風險 |
|---|---|---|---|
acks=0 | 不等回應,送出即視為成功 | 最低 | 高:broker 沒收到也不知道;無法重試 |
acks=1 | leader 寫入本地 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 批次送出。
(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.ms | batch 未滿時最多等多久再送(Java client 長期預設 0,Kafka 4.0 改為 5ms) | 設 5–50ms 讓 batch 變大,以少量延遲換大量吞吐 |
compression.type | none / 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.ms | send 到成功/失敗的總時限(預設 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_revokecallback 中 commit 已處理的 offset,避免 partition 移交後被新 owner 重複處理。
Offset Commit 策略與交付語意
在同一個交易中"] end
| 策略 | 設定 | 語意 | 說明 |
|---|---|---|---|
| Auto commit | enable.auto.commit=true(預設),每 auto.commit.interval.ms(5 秒)commit 上次 poll 回傳的位置 | 大致 at-least-once,但若在 poll 回來後交給其他 thread 非同步處理,可能變成 at-most-once | 簡單;當機時最多重複 5 秒內的訊息 |
| 手動同步 commit | commit(asynchronous=False) 於處理完一批之後 | at-least-once | 最清楚;每次 commit 一次 RTT,應以批次 commit |
| 手動非同步 commit | commit(asynchronous=True) | at-least-once | 不阻塞;失敗不重試(避免較舊的 commit 覆蓋較新的),關閉前用同步 commit 補一次 |
| Auto commit + 手動 store | enable.auto.offset.store=false,處理完才 store_offsets(),由背景定期 commit 已 store 的位置 | at-least-once | librdkafka 推薦模式:兼顧正確與效率 |
| 外部儲存 offset | offset 與處理結果寫在同一個 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」原子地一起生效。
- Producer 設定
transactional.id(跨重啟穩定的 id),呼叫init_transactions():transaction coordinator 依 transactional.id 分配 PID 並遞增 epoch,舊的同 id 實例(zombie)的寫入會被 fence 掉。 begin_transaction()→ 送出多則訊息到多個 partition。send_offsets_to_transaction(offsets, group_metadata):把消費的 offset 也納入同一個交易。commit_transaction():coordinator 以類似 2PC 的方式在 transaction log 記錄決定,再對所有相關 partition 寫入 commit marker。失敗則abort_transaction()。- 下游 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 的位置重新處理
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 cache | Kafka 不在 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。
| Exchange 類型 | 路由規則 | 用途 |
|---|---|---|
| direct | routing 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
| 面向 | Kafka | RabbitMQ |
|---|---|---|
| 本質 | 分散式 commit log | message broker(智慧路由) |
| 消費模型 | Pull;consumer 自己管 offset | Push(basic.consume)+ prefetch;broker 管 ack |
| 訊息保留 | 依時間/大小保留,可重播 | ack 後刪除(Stream 類型除外) |
| 吞吐 | 極高(單叢集每秒百萬級訊息常見) | 中高(每節點每秒數萬級,依持久化與 ack 設定) |
| 延遲 | 毫秒到數十毫秒(受 batch 設定影響) | 低延遲(亞毫秒到毫秒),訊息量低時尤其好 |
| 順序 | partition 內嚴格有序 | 單一 queue 單一 consumer 時有序;多 consumer 或 requeue 會打亂 |
| 路由 | 僅依 topic/partition;路由邏輯在 consumer | exchange 類型豐富,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 subscription | at-least-once;可啟用 ordering key 保序;可選 exactly-once delivery(區域內) | 自動擴展、全球化;ack deadline 機制類似 visibility timeout;支援 dead letter topic 與 seek 重播 | GCP 上的事件管線,常接 Dataflow |
| Redis Streams | Redis 內的 append-only log(XADD),支援 consumer group(XREADGROUP) | at-least-once;每個 group 有 Pending Entries List(PEL),需 XACK;以 XAUTOCLAIM 接手逾時訊息 | 極低延遲、已經有 Redis 就零額外維運;受記憶體限制,以 MAXLEN/MINID 修剪;持久化依 AOF/RDB | 中小流量的即時任務、通知;不適合長期保留大量資料 |
| NATS | Core 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 到期、上游重送。對策依場景選擇:
- 天然冪等的操作:
UPSERT ... SET status='SENT'、以 event_id 為主鍵寫入。重複寫同一筆資料無害。 - 去重表(dedup table):
processed_events(event_id PRIMARY KEY),處理與插入去重紀錄在同一個交易中;插入衝突代表已處理過,直接跳過。 - 快取去重:Redis
SET event:{id} 1 NX EX 86400,NX 失敗代表處理過。較快但與業務寫入不在同一交易,處理失敗時要刪除 key(或接受極小機率的漏處理/重複)。 - 版本號 / 序號:每個實體的事件帶遞增 version,consumer 只套用
version > 目前 version的事件,同時解決重複與亂序(CDC 常用)。 - 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 提出的模式):
等到 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 做即時排行榜)。
(驗證 + 業務規則)"] 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 不同。
兩種 relay 方式:
| Polling publisher | CDC(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)
);
以廣告平台常見做法為例:廣告主在後台修改 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 爆炸。
格式比較
| JSON | Avro | Protobuf | |
|---|---|---|---|
| 大小 | 大(欄位名重複出現) | 小(不含欄位名,依 schema 順序編碼) | 小(以 field number 標記) |
| Schema | 可選(JSON Schema) | 必須,讀取時需要 writer schema | 必須(.proto),解碼不需 writer schema |
| 演進規則 | 靠紀律 | 以欄位名比對 writer/reader schema;新增/刪除欄位需有 default | 以 field number 比對;不可重用或改變號碼,刪除的號碼標記 reserved |
| 生態 | 通用、易除錯 | Hadoop/Kafka 生態深,適合動態產生 schema | gRPC、多語言 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)等。
| Celery | RQ | 其他 | |
|---|---|---|---|
| Broker | RabbitMQ(推薦)、Redis、SQS | 只有 Redis | Dramatiq(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 章)。數字皆為示範用的假設,面試時要先跟面試官確認規模。
批次 + 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.ms10–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。
- 下游要做 per-user 狀態(sessionization、frequency cap、attribution 以 user 為單位 join)→ key =
Partition 數量估算
經驗公式:partitions ≥ max(目標吞吐 / 單 partition producer 吞吐, 目標吞吐 / 單 partition consumer 吞吐) × 成長與追趕的餘裕。通常 consumer 端的處理才是瓶頸。
- 假設尖峰曝光 30 萬則/秒、平均 1KB → 約 300MB/s(未壓縮)。
- 壓測得知單一 consumer 實例處理一個 partition 約 10MB/s(含反序列化、去重、聚合)→ 至少 30 個 partition。
- 追趕餘裕:若 consumer 停機 1 小時,希望 30 分鐘內追上,處理能力需達輸入速率的 3 倍(C·0.5h = r·(1h + 0.5h))→ 約 90 個 partition 的平行度。
- 再考慮未來 1–2 年成長,並選擇有很多因數的數字(便於 consumer 數量整除,負載平均),例如 96 或 120。
- 不要盲目開到數千:每個 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。
以廣告平台常見做法為例:曝光事件是量最大的資料源,用於報表、frequency cap、預算花費;點擊與轉換量小但直接關係到計費與模型訓練(CTR/CVR 的 label)。同一套 Kafka 事件流同時餵給:即時 pacing(秒級)、即時報表(分鐘級)、反詐欺、資料湖(每小時批次 → ML 訓練)。這正是 Kafka「多個 consumer group 各自獨立重播」的價值:新增一個下游(例如新的歸因模型)不需要改動上游任何東西,從 compacted 或保留期內的資料開始讀即可。
推播發送佇列設計
情境:行銷自動化平台要對 1,000 萬名使用者發送一則推播活動(類似 AIQUA 的場景),同時也有交易型訊息(OTP、訂單通知)需要秒級送達。Appier 也出過「設計發送 LINE 訊息的 API」的 take-home。
建立發送任務"] --> 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"]]
- Fan-out 分層:一則活動不要變成一則巨大的訊息,也不要由單一程序迴圈 1000 萬次。先由 resolver 分頁查出受眾、切成每批約 1000 人的 job 放進佇列,再由大量 worker 平行處理。批次大小要讓單一 job 在數秒內完成,失敗重試的成本才低。
- 優先級隔離(bulkhead):交易型與行銷型分成不同佇列與 worker pool,行銷活動的尖峰不能讓 OTP 延遲。RabbitMQ/SQS 很適合這種「任務型」工作;也可用 Kafka 不同 topic。
- 速率限制:各 provider 與客戶的 app 都有速率上限,用 Redis 上的分散式 token bucket 控制;收到 429 時遵守
Retry-After並全域降速(而非每個 worker 各自重試,否則形成 retry storm)。 - 冪等與去重:以
(campaign_id, user_id)作為去重 key(RedisSET NX或 DB 唯一約束),避免 job 重試時重複推播——使用者收到兩則相同推播是很明顯的客訴。呼叫 LINE Messaging API 時可帶X-Line-Retry-Key(UUID)讓 LINE 端對重試去重。 - 延遲/排程:依使用者時區在當地 10 點發送、避開靜默時段 → 延遲佇列:Redis ZSET(score = 發送時間,worker 以
ZRANGEBYSCORE取到期項目)、SQS delay(最長 15 分鐘)、或依小時切分的 topic。 - 錯誤分類:APNs 回應 token 無效(例如 410 Unregistered)→ 永久錯誤,從裝置表移除 token,不重試;429/5xx → 退避重試;超過重試上限 → DLQ 與告警。
- 可觀測性:以 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 必須以最後一筆為準。