Day 8 Hard Distributed Systems Messaging Kafka / SQS Delivery / Backpressure / DLQ

消息队列 — 异步是解耦的代价,也是它的全部价值Kafka vs RabbitMQ vs SQS · Delivery Semantics · Backpressure · Dead Letter Queue

问题场景与约束

设计一个电商「订单事件总线」:用户下单后,订单服务产生一条 order.created 事件,要被 5 类下游 各自独立消费——库存扣减、积分发放、推荐更新、数据仓库入仓、风控审计。这些下游处理速度天差地别:库存扣减 2ms,数仓批量入仓一条要 200ms。

硬约束:

今天讲:为什么这个场景必须用消息队列而非同步 RPC、Kafka / RabbitMQ / SQS 怎么选、投递语义的本质、backpressure 与消费积压、以及 DLQ 怎么兜底毒消息。

高层架构

graph LR Prod["订单服务
Producer"] K["Kafka Topic
order.events
按 order_id 分 partition"] subgraph CG["独立 Consumer Group (各自 offset)"] C1["库存消费者
快 2ms"] C2["积分消费者"] C3["推荐消费者"] C4["数仓消费者
慢 200ms 批量"] C5["风控消费者"] end DLQ["DLQ
order.events.dlq"] Prod -->|"ack=all
幂等 producer"| K K --> C1 K --> C2 K --> C3 K --> C4 K --> C5 C1 -.->|"重试耗尽"| DLQ C4 -.->|"毒消息"| DLQ classDef bus fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 classDef dlq fill:#3a2010,stroke:#ffb450,color:#e8eef5 class K bus class DLQ dlq

核心思路:一份事件、多个独立 Consumer Group,各自维护 offset,互不影响——这是 pub/sub fan-out。Producer 用 ack=all + 幂等保证不丢;按 order_id 分区保证单订单有序;慢消费者积压只影响自己;重试耗尽的消息进 DLQ 不阻塞主流。

关键技术点

1. 选型:Log (Kafka) vs Broker (RabbitMQ) vs Managed (SQS)

核心 trade-off:留存可重放的日志 vs 灵活路由的智能 broker vs 零运维的托管队列

原理:三者是三种不同的抽象Kafka分布式 append-only log:消息写入 partition 后不因被消费而删除,consumer 只是移动自己的 offset,因此天然支持多订阅者 fan-out、回放历史、高吞吐顺序 IO。RabbitMQ智能 broker:消息经 exchange 按 routing key 投到 queue,消费 ack 后即删除,强在灵活路由(topic/fanout/header exchange)和单条消息粒度控制。SQS 是 AWS 全托管队列:无需运维,弹性近无限,但功能最简(标准队列无序、FIFO 队列有序但限流)。

维度KafkaRabbitMQSQS
抽象持久化 logbroker + queue托管 queue
消费后保留(按 TTL)ack 即删ack 即删
重放历史✅ 重置 offset
吞吐极高(百万/s)中(万/s)高(自动弹)
路由灵活性弱(topic+key)✅ 强
运维重(自管集群)
怎么选
现实案例:

2. 投递语义:at-most-once / at-least-once / exactly-once

核心 trade-off:不丢 vs 不重 不可兼得(无幂等时);工程上的「恰好一次」= 至少一次 + 幂等。

原理:投递语义取决于 ack 与处理的先后At-most-once:先 ack(提交 offset)再处理——崩溃则消息丢失,绝不重复,适合可丢的指标采样。At-least-once:先处理再 ack——崩溃在 ack 前则重投,绝不丢失但可能重复,是绝大多数业务的默认选择Exactly-once:网络层面不可达(两将军问题),但可以通过至少一次投递 + 消费端幂等/去重在「效果上」实现每条恰好生效一次。

三种语义的取舍
伪代码 · at-least-once 消费 + 幂等去重
def consume(msg):
    if dedup.exists(msg.event_id):   # 去重表 / 唯一约束
        commit_offset(msg); return     # 重复投递, 跳过
    process(msg)                       # 业务: 扣库存 / 加积分
    dedup.insert(msg.event_id)
    commit_offset(msg)                 # 先处理后提交 = at-least-once
    # 崩在 process 后 / commit 前 → 重投, 靠 dedup 兜住
现实案例:

3. Backpressure 与消费积压:慢消费者不能拖垮上游

核心 trade-off:buffer(吸收突发) vs drop / 限流(保护系统)——队列把同步背压变成了可观测的 lag。

原理:生产速率 > 消费速率时,消息堆积。同步 RPC 里这表现为调用方阻塞、线程耗尽、级联雪崩;消息队列把它转化为 consumer lag(offset 落后量),是一个可监控、可缓冲的指标。应对手段分两类:扩容消费(加 consumer,但受 partition 数上限——一个 partition 同组内只能被一个 consumer 消费);限制生产(producer 限流 / 拒绝,把压力推回源头)。Kafka 的 log 模型让积压「躺在磁盘上」相对安全;RabbitMQ 队列积压在内存/磁盘则可能触发 flow control 阻塞 producer。

积压三种应对,代价不同
伪代码 · 监控 lag + 自适应批量
while True:
    batch = consumer.poll(max_records=500, timeout=100ms)
    lag = end_offset(partition) - current_offset
    if lag > HIGH_WATERMARK:
        scale_out_signal()        # 触发 autoscaler 加 consumer
    process_batch(batch)          # 批处理摊薄固定开销
    consumer.commit()             # 批量提交一次 offset
现实案例:

4. Dead Letter Queue:毒消息的隔离区

核心 trade-off:无限重试(阻塞队列) vs 丢弃(丢数据) vs DLQ(隔离 + 留证据)

原理:某条消息因数据畸形、下游 bug、依赖永久不可用而反复处理失败,称为毒消息(poison message)。若无限重试,它会卡住整个 partition / queue(尤其有序队列,后面的消息全被堵);若直接丢,则丢数据无追溯。DLQ 的做法:重试 N 次仍失败,把消息连同失败上下文移到一个专门的死信队列,主流继续前进;之后人工或自动分析 DLQ,修复后 redrive 回主队列重放。

关键设计点
伪代码 · 重试 → DLQ
def handle(msg):
    try:
        process(msg)
    except NonRetryable as e:     # 畸形数据, 重试无意义
        to_dlq(msg, reason=e); return
    except Retryable as e:
        if msg.attempts >= MAX_RETRY:
            to_dlq(msg, reason=e, attempts=msg.attempts)
        else:
            requeue(msg, delay=backoff(msg.attempts))
现实案例:

扩展与优化

常见陷阱与面试问题

1. 以为「用了 Kafka 就 exactly-once 了」。 EOS 只在 Kafka 内闭环。一旦消费后写外部 DB 或调第三方,仍是 at-least-once + 必须自己幂等。面试问 EOS 时,期待你点出这个边界。
2. 消费端不幂等。 at-least-once 必然重复投递。没有去重表/唯一约束,重复扣款、重复发货就来了。这是消息系统第一坑。
3. 用全局有序换来零并行。 把所有消息塞一个 partition 保证全局顺序,结果消费吞吐打不上去。99% 的场景只需按 key 局部有序
4. 没有 DLQ,靠无限重试。 一条毒消息能让有序队列彻底卡死,后面所有消息饿死。必须有 DLQ + 区分可重试错误。
5. 不监控 consumer lag。 lag 是消息系统最重要的健康指标。不监控 = 数仓积压几小时都不知道,等下游发现数据缺失才暴雷。

面试可能追问:

  1. Kafka 和 RabbitMQ 的本质区别?什么场景非 Kafka 不可、什么场景 RabbitMQ 更合适?
  2. at-least-once 怎么做到「效果上 exactly-once」?幂等 key 放哪、去重表怎么清理?
  3. 一个 topic 100 个 partition,consumer group 里 150 个 consumer,会发生什么?
  4. 消费积压了 1 亿条,怎么快速追上?有哪些手段、各自代价?
  5. 有序队列里出现一条毒消息,整条队列卡死,怎么办?DLQ 怎么设计?
  6. Producer ack=0/1/all 分别什么语义?什么时候会丢消息?

深入资源

深入思考

1. 一个 topic 有 100 个 partition,consumer group 里放了 150 个 consumer,会发生什么?为什么?

结果:50 个 consumer 完全空闲。因为 Kafka 的并行单元是 partition——同一个 consumer group 内,一个 partition 在任一时刻只能被一个 consumer 消费(保证组内不重复)。100 个 partition 最多被 100 个 consumer 瓜分,多出的 50 个抢不到任何 partition,纯属浪费。

推论:消费并行度的上限 = partition 数。想扩到 150 并行,必须先把 partition 加到 ≥150。但加 partition 有代价:(1) 基于 key 的 hash 路由会变(hash(key) % N 中 N 变了),同一 key 的历史消息和新消息可能落到不同 partition,破坏有序性;(2) partition 越多,broker 元数据、文件句柄、rebalance 时间都上升。

所以建 topic 时就要按未来峰值规划 partition 数,这是少数「事前决策远比事后补救便宜」的参数。这也解释了为什么 Uber 要做 uForwarder——用 push proxy 解耦「消费并行度」与「partition 数」。

2. 数仓消费者积压了 1 亿条消息,老板要你 1 小时内追上。给至少 3 种手段并分析代价。

先算量级:1 亿条 / 3600s ≈ 要 2.8 万/s 的净追赶速率(还得高于实时进来的速率)。单 consumer 200ms/条只有 5 条/s,差了 4 个数量级,必须并行 + 批处理。

  • ① 临时扩 consumer 到 partition 数上限:若有 200 个 partition,拉满 200 个 consumer。代价:要这么多 partition 才行;rebalance 期间短暂停顿。
  • ② 批量化:把「一条 200ms」改成「500 条一批入仓」,摊薄网络/事务固定开销,单 consumer 吞吐可能涨 10-50 倍。代价:批失败的重试粒度变粗、延迟变大。
  • ③ 旁路加速 topic:临时起一个独立 consumer group 从积压头开始猛拉,写到一个「快速通道」,原 group 保持实时。代价:架构临时复杂化,要处理两路合并。
  • ④ 降级非关键处理:积压期间数仓只落原始数据、跳过重计算,事后离线补算。代价:短期数据不完整。

更深一层:能 1 小时追上的前提是 partition 数和下游写入能力早就预留了余量。如果 partition 只有 10 个,任你怎么加 consumer 也只有 10 并行——积压恢复能力是设计期决定的,不是故障期能临时变出来的。这正是「log 模型让积压安全躺在磁盘上」的价值:它给了你恢复的时间窗口,但恢复速度仍受架构上限约束。

3. 「至少一次 + 幂等 = 恰好一次」——那为什么还要 Kafka 的 exactly-once 事务?它解决了幂等解决不了的什么问题?

消费端幂等解决的是「同一条消息重复处理」。但有一类问题它解决不了:「读-处理-写」的原子性——consumer 从 topic A 读、处理后写到 topic B,然后提交 A 的 offset。这三步若非原子,崩溃点不同会出问题:写了 B 但没提交 A 的 offset → 重启后重新处理、B 里出现重复;提交了 offset 但没写 B → B 里丢数据

Kafka 事务把「写 B + 提交 A 的 offset」包在一个原子事务里,配合幂等 producer(按序列号去重),实现流处理的 EOS:要么都成功,要么都回滚。这是消费端幂等做不到的,因为 offset 提交和外部写本就是两个系统的动作。

但关键边界:这只在「Kafka → Kafka」闭环成立。一旦你写的是外部 Postgres 或调 Stripe,Kafka 事务管不到那边——又回到 at-least-once + 业务幂等。所以 EOS 不是银弹,它精确地解决「Kafka 内流处理」这一类问题,理解它的适用边界比记住它存在更重要。

4. 同步 RPC 也能解耦服务,为什么订单这种关键链路反而要引入消息队列这个「额外的会丢消息的中间件」?

这是个反直觉点:加一个组件不是增加了故障面吗?答案在于把什么样的故障换成什么样的故障

同步 RPC 的问题:订单服务要同步调用 5 个下游。(1) 时间耦合:任一下游慢/挂,下单就慢/失败——5 个下游的可用性相乘,整体可用性暴跌;(2) 级联雪崩:数仓慢 → 订单服务线程阻塞堆积 → 订单服务自己挂 → 拖垮上游;(3) 扇出耦合:加第 6 个下游要改订单服务代码。

消息队列的转换:订单服务只需把事件可靠写入队列(一次本地 + 一次队列写,配 Outbox 可做到原子),就立即返回。下游各自异步消费——下游挂了不影响下单,恢复后从 offset 续上;加下游只需新增 consumer group,订单服务零改动;慢消费者只积压自己。

代价是:(1) 最终一致——下游状态有延迟,UI 要兜底(「处理中」);(2) at-least-once 的重复——要幂等;(3) 多了个要运维的中间件。本质是用「最终一致 + 幂等复杂度」换「时间解耦 + 抗级联 + 易扩展」。对订单这种「下单必须快且稳、下游可异步」的场景,这笔交易划算;对「必须同步拿到结果」的场景(如实时风控拒绝),则该留同步调用。