10 异步与解耦:队列、消息、背压、削峰
开篇提问
先给你一个真实场景:
你的电商系统,用户下单后要做这些事:扣库存、生成订单、发短信通知、加积分、更新推荐模型、写审计日志……
如果全部同步做完再返回给用户,用户要等 3 秒——体验极差,而且任何一个环节(比如短信服务)慢了,整个下单就卡住。
于是你改成异步:下单核心流程(扣库存+生成订单)同步做完就返回,其余的丢进消息队列,后台慢慢处理。用户 200ms 就拿到响应。
看起来很完美。但请回答这个要命的问题:
如果后台处理的速度跟不上消息产生的速度,队列会怎样?堆积到什么程度会发生什么?你是该”丢弃消息”、“阻塞生产者”、还是”无限扩容”?
这第三问,就是所有异步系统最终都会撞上的那堵墙——背压(Backpressure)。不理解背压的异步系统,上线之后一定会在某个流量高峰”静默地”丢数据或 OOM。
子主题一:解耦的三个维度
“异步解耦”这个词被用得很滥,我们先把它掰清楚。解耦到底解了什么?三个维度:
维度一:时间解耦。 生产者和消费者不需要同时在线。
- 生产者把消息丢进队列就走,消费者可以几秒后、几小时后、甚至几天后才处理。
- 价值:削峰填谷——流量高峰时消息堆在队列里,低谷时慢慢消费。
维度二:空间解耦。 生产者和消费者不需要知道对方在哪。
- 生产者只需要知道”队列的地址”,不需要知道有几个消费者、它们叫什么、在哪台机器上。
- 价值:可以独立扩缩容、独立部署、独立演化。
维度三:同步解耦(故障隔离)。 一个消费者挂了,不影响生产者。
- 生产者继续往队列里写,消费者恢复后继续消费。
- 价值:部分故障不会级联扩散——这是异步最被低估的价值。
这三个维度,对应了三个不同的收益。当你决定”要不要引入异步”时,先想清楚你要的是哪一个——如果只是想要”时间解耦”(削峰),用个简单的本地队列就够了;如果要”故障隔离”,才需要完整的消息中间件。
子主题二:异步的代价——那些被”藏起来”的复杂度
异步不是免费的。它把复杂度从”看得见的地方”转移到了”看不见的地方”。
代价一:一致性变弱。 同步调用时,“调用返回成功”就意味着事情做完了。异步之后,返回成功只意味着”消息发出去了”——事情到底做没做、做没成功,调用方不知道。这直接引出最终一致性问题。
代价二:调试困难。 同步调用有清晰的调用栈;异步之后,一个操作被拆成了多个独立的部分,没有统一的调用链。出问题时你很难回答”这个请求到底走到哪一步了”。(这需要分布式追踪来补,第 14 章。)
代价三:消息语义的复杂性——这是最坑的一个。 消息传递有三类语义,而选错语义会导致严重 bug:
- At-most-once(最多一次):消息可能丢,但绝不重复。实现最简单、最快,但会丢数据。
- At-least-once(至少一次):消息绝不丢,但可能重复。这是绝大多数消息队列的默认语义。
- Exactly-once(恰好一次):既不丢也不重。这是最难的,而且严格来说在分布式系统里很难真正实现。
真实系统里最常见的组合是:At-least-once 传递 + 消费者端幂等。也就是承认”消息可能重复”,然后让消费逻辑幂等(重复处理一次和处理多次结果相同)。
幂等是异步系统的基石。 没有幂等,你就必须去追求那个很难做对的 exactly-once。幂等的实现方式:唯一键去重、状态机检查、版本号/乐观锁。
代价四:顺序性。 队列能保证顺序吗?只有在”单分区、单消费者”的情况下才能保证全局顺序。 一旦为了吞吐做分区并行、多消费者,顺序就没了。需要顺序的场景(比如订单状态流转)必须按 key 分区,保证”同一个 key 的消息进同一个分区”。
子主题三:背压——异步系统的那堵墙
现在正面解决开篇的问题:消费跟不上生产,队列会怎样?
答案是:队列会无限增长,直到内存/磁盘耗尽,然后系统崩溃。 这是异步系统的第一大杀手。
解决它的机制叫背压(Backpressure)——让”下游的处理能力”反向传导到”上游的生产速度”,让上游慢下来。
背压有几种实现思路,各有适用场景:
思路一:阻塞(Blocking)。 队列满了,生产者就阻塞等待。
- 优点:简单、绝不丢数据
- 缺点:把异步系统变回了同步系统——上游被下游拖慢,失去了削峰的意义
- 适用:能接受上游被拖慢的场景(比如内存里的有界队列)
思路二:丢弃(Dropping / Load Shedding)。 队列满了就丢弃新消息。
- 优点:保护系统不被压垮,上游永远不阻塞
- 缺点:丢数据
- 适用:数据可以丢的场景——比如监控指标采样、日志(丢了影响不大)。这时候要注意”丢新还是丢旧”:实时监控丢旧数据(保留最新),而某些场景要丢新(保留已接收的)。
思路三:限流(Rate Limiting)。 在生产端就限制发送速率。
- 这是”主动的背压”——不让队列堆积到危险的程度
- 缺点:需要预先知道合理的速率
思路四:自动扩容(Elastic Scaling)。 消费慢了就自动加消费者。
- 理想方案,但有延迟(扩容需要时间),且成本高
- 而且扩容有上限——如果瓶颈在下游依赖(比如数据库的写入能力),加消费者反而会打爆数据库
真实的工业系统,往往同时用这四种:有界队列(阻塞/丢弃)+ 生产端限流 + 消费端自动扩容 + 监控告警兜底。
一个重要的设计原则:队列必须有界(bounded)。 无界队列是系统里最危险的构造之一——它把”流量高峰”变成”延迟高峰”,再变成”内存耗尽”,而故障现象往往滞后很久才暴露,极难排查。有界队列会让问题立即暴露(生产者阻塞或消息被拒),反而是好事。
子主题四:削峰填谷的量化视角
“削峰”到底是怎么工作的?给你一个可以算的模型:
核心洞察:队列的作用是把”瞬时流量”平滑成”平均流量”。
队列长度变化 ≈ (到达速率 - 处理速率) × 持续时间
- 如果到达速率 > 处理速率:队列增长,系统迟早崩溃(这是”结构性过载”,队列救不了)
- 如果到达速率 < 处理速率:队列只在峰值期间临时增长,峰值过后会被消化
关键结论:队列只能”削峰”,不能”救过载”。
如果平均到达速率持续超过处理能力,任何大小的队列最终都会满——队列只是在拖延时间。这时候正确的做法不是加大队列,而是扩容(提高处理能力)或限流(降低到达速率)。
所以削峰的适用条件是:峰值很高,但平均值在处理能力之内。 比如秒杀场景:平时 100 QPS,秒杀瞬间 10 万 QPS 持续 3 秒——总共 30 万条消息,用队列缓冲后以 1 万 QPS 消费,30 秒消化完。这是削峰的正确用法。
这个量化视角能救你一次:当你看到队列在涨时,第一件事是算出”到达速率 vs 处理速率”——如果前者持续大于后者,加队列容量毫无意义。
子主题五:消息队列的两种模型
模型一:队列模型(Point-to-Point / Queue)
- 一条消息只被一个消费者处理
- 消费者之间是”竞争”关系(竞争消费者模式)
- 用途:任务分发、负载均衡——10 个 worker 抢任务,每条任务只被做一次
- 代表:RabbitMQ、ActiveMQ、Redis List、Kafka 的消费者组
模型二:发布订阅(Pub/Sub / Topic)
- 一条消息被所有订阅者各收到一份
- 订阅者之间是”广播”关系
- 用途:事件通知、扇出——一个事件触发多个下游系统
- 代表:Kafka、Pulsar、Redis Pub/Sub
现代系统里最强大的一种模型:日志(Log)模型。 Kafka 把队列做成了”可重放的、持久化的、分区的日志”。这个设计的革命性在于:
- 消息消费后不删除(保留一段时间),可以重复消费、可以回放
- 多个消费者各自维护自己的偏移量(offset),互不影响
- 同一份数据既支持队列语义(消费者组)又支持发布订阅语义(不同组各自消费)
日志模型把”消息”变成了”可重放的事件流”——这是流处理(第 21 章)的基础,也是事件溯源(第 19 章)的基石。
子主题六:幂等——把”最多做一次”变成”做多少次都一样”
先抛一个反直觉的问题:为什么几乎没有工业系统去追求 exactly-once,而是默契地选择了”at-least-once 投递 + 消费端幂等”这个看起来很笨的组合?
要回答它,不能停在名词解释,得把三种语义的实现机制摊开看。
三种语义到底是怎么实现的
At-most-once(最多一次):发出去就不管了(fire-and-forget),不等确认、不重试。失败的代价是丢。
在 Kafka 里它还有一个更隐蔽的成因——先提交 offset,再处理业务。提交完 offset 就等于”这条我认了”,此时进程崩溃,这条消息永远不会被再投递,业务没做完就丢了。所以”自动提交”在语义上是危险的:enable.auto.commit=true 每 5 秒提交一次已拉取的最大 offset,它跟你的处理进度毫无关系。
At-least-once(至少一次):先处理业务,再提交 offset。处理完、在提交之前崩溃 → 消息被重新投递 → 重复。绝不丢,代价是重复。这是绝大多数 MQ 的默认,因为它只需要两件简单的事:确认机制(ack)+ 超时重试。
Exactly-once(恰好一次):注意,物理上你永远做不到”消息只被传输一次”——网络会重传、进程会崩溃、重试一定会发生。所以业界的 exactly-once,准确说是 effectively-once(效果恰好一次):消息可以被投递很多次,但最终效果只生效一次。
Kafka 的 EOS(Exactly-Once Semantics)是怎么逼近它的?两层机制:
- 幂等生产者:
enable.idempotence=true。每个生产者被分配一个 PID,每条消息带上 (PID, 分区, 自增序号)。Broker 记住每个 (PID, 分区) 收到的连续最大序号:序号重复 → 直接丢弃(这正是生产者内部重试造成的重复);序号跳跃过大 → 报OutOfOrderSequenceException。作用范围是”单个生产者会话内的单个分区”。 - 事务:用
transactional.id跨会话标识同一个生产者(配合 epoch 递增做”僵尸生产者隔离”),消费者设isolation.level=read_committed只读已提交消息。关键能力是把”消费到的 offset”和”要生产的消息”放进同一个事务里原子提交——于是”读-算-写”这条流水线在 Kafka 内部是原子的。
那么问题来了:这么好的东西,为什么不是人人都在用?三个硬约束:
- 闭环只在 Kafka 内部成立。 一旦你的 sink 是数据库、HTTP 接口、短信网关,事务管不到它。“消息写进了 Kafka 且 offset 已提交,但数据库写入失败”这个窗口依然存在。所以端到端 exactly-once 的实际公式是:Kafka 内 EOS + 外部 sink 幂等——绕了一圈,还是回到幂等。
- 性能代价实在。 事务要写事务标记、要两阶段提交、要受
transaction.timeout约束,吞吐通常掉 10%~30%,延迟也上去了。为”省掉写幂等逻辑的几十行代码”付这个代价,多数场景算不过账。 - 分布式事务本身有理论边界。 两阶段提交(2PC)的协调者是个单点,协调者一挂,参与者就卡在”未决”状态;FLP 不可能性告诉你,异步网络下不存在既能容错又能保证一致的确定性共识算法。这些不是工程没写好,是数学不允许。
于是”at-least-once + 幂等”胜出,五条理由都很实际:
- 可靠传递很便宜:ack + 重试,任何一个 MQ 都能做对。
- 难题被搬到了”最懂语义的地方”:什么叫”同一件事”,只有业务代码知道。让 MQ 去判断”这两条消息是不是同一个意图”,是缘木求鱼。
- 一次投入,多处收益:幂等不仅覆盖消息重投,还顺手覆盖了上游重试、用户重复点击、网络重发、人工重放、灰度回放、修数据重跑。这是”买一送五”。
- 成本极低:每个写操作多一次唯一键冲突检查,代价远小于分布式事务。
- 正确性可验证:幂等是本地逻辑,可以写单测——灌同一条消息两次,断言状态只变一次。而 exactly-once 的正确性你基本没法测。
幂等的四种实现手法
手法一:唯一键去重表。 建一张表,唯一索引建在”幂等键”上,业务写入和去重记录必须在同一个本地事务里完成:
CREATE TABLE idempotency (
idempotency_key VARCHAR(128) PRIMARY KEY,
biz_type VARCHAR(64),
result JSON,
created_at TIMESTAMP
);
-- 消费时:
BEGIN;
INSERT INTO idempotency ... ON CONFLICT DO NOTHING; -- 影响行数 0 → 已处理过
UPDATE account SET balance = 900 WHERE uid = ?;
COMMIT;三个必须注意的坑:
- 顺序不能反。 如果先插去重表再写业务,写业务失败了 → 去重记录已经留下 → 这条消息再也不会被真正处理 → 悄悄退化成 at-most-once。必须同事务。
- 幂等键怎么选? 用
msgId最简单,但生产者重试时如果重新生成了 msgId 就失效了。更稳的是业务键:bizType + 业务主键 + 操作类型,比如order:pay:20260902123456。 - 要带 TTL。 去重表不能永久膨胀,但 TTL 必须长于最大重试/重放窗口,否则一条沉寂了 8 小时的迟到消息会被当成新消息重做一遍。
手法二:状态机检查。
把业务状态做成单向流转,用 WHERE 条件自带幂等性:
UPDATE orders SET status='PAID' WHERE order_id=? AND status='UNPAID';
-- 影响行数 0 → 已经付过了(或者状态已经流转到更后面)这是最优雅的一种:不需要额外的表,靠”状态只能单向迁移”这个业务不变量天然幂等。前提是状态机必须单调——一旦允许”已发货退回到待支付”,幂等性就破了。所以它适合订单、工单、审批这类生命周期明确的对象。
手法三:乐观锁版本号。
表里加 version 列,更新时带上读到的版本:
UPDATE account SET balance=?, version=version+1 WHERE uid=? AND version=?;影响行数 0 → 版本已变,说明有人动过了。它的强项不只是”去重”,更是防止乱序覆盖:携带旧版本的迟到事件会被拒绝(配合”只接受 version 更大的事件”,还能顺手缓解子主题七的顺序问题)。
但有个常被忽略的点:乐观锁对增量操作无能为力。balance = balance - 100 执行两次就扣两百,版本号救不了你;只有 balance = 900(绝对值赋值)才幂等。能改成绝对值就改成绝对值。
手法四:一次性 token(幂等令牌)。
客户端先向服务端申请一个 token,服务端登记为 RESERVED;客户端带着 token 发起业务请求,服务端把 token 原子地改成 IN_FLIGHT 并执行业务,完成后改成 DONE 并把结果存下来。重复请求撞上 IN_FLIGHT → 返回”处理中”;撞上 DONE → 返回第一次的结果。Stripe 的 Idempotency-Key 就是这套。
它比去重表强在哪?去重表只能告诉你”处理过了,跳过”;token 有完整的生命周期,能区分”没处理 / 处理中 / 已处理”,因此能正确处理并发重复(两条相同消息同一毫秒到达,各自 SELECT 都查不到对方,都往下走——去重表只能靠唯一索引在提交那一刻拦住一个,另一个拿到冲突异常;token 则能让第二个请求明确地等待或直接拿到结果),并且能返回相同的结果——这一点很重要,调用方重试时期望拿到和第一次一样的响应,而不是一个莫名其妙的”重复请求”错误。
幂等 vs 去重:一字之差,天壤之别
这两个词经常被混用,但它们的着眼点、作用域、失效边界都不同:
| 去重(Deduplication) | 幂等(Idempotence) | |
|---|---|---|
| 着眼点 | ”这条消息/请求是不是来过" | "这个业务操作重复执行,效果是否相同” |
| 层次 | 传输层 / 基础设施层 | 业务层 / 应用逻辑层 |
| 判断依据 | 消息 ID、请求指纹 | 业务状态、业务键 |
| 谁实现 | Broker 或框架(Kafka 幂等生产者、Pulsar broker 端去重、网关去重) | 只能由业务代码实现 |
| 并发安全 | 靠唯一索引 / 原子操作 | 靠状态机 WHERE 条件 |
三条真正要命的区别:
区别一:键的选取决定失效边界。 用 msgId 去重,遇到”生产者重试时换了新 msgId”就失效;用业务键幂等,这种情况照样拦得住。反过来,如果 msgId 被错误复用(生成器 bug、跨环境复制数据),去重会把两条语义不同的消息误杀成一条——按业务键的幂等反而不会。去重信的是”信封”,幂等信的是”内容”。
区别二:并发重复的处理。 唯一索引能在提交那一刻拦住一个,但被拦的那个会拿到”唯一键冲突”异常——你得能区分”冲突 = 已处理,该跳过”和”冲突 = 真错误,该重试”。而 WHERE status='UNPAID' 这种状态机写法天然串行化,不需要额外的表,也不产生需要解释的异常。
区别三:返回值的语义。 去重后的第二次请求返回什么?如果返回”重复请求”错误,上游的重试逻辑可能会一直重试下去,或者把错误报给用户造成困惑。幂等的正确姿势是返回第一次的结果——这就是为什么 token 方案要存 result 字段。
幂等也有够不着的地方
最后必须承认一个残酷事实:不是所有操作都能幂等。
发短信、打电话、调用第三方支付、往区块链写交易——这些是外溢的副作用,你没有能力把它变成”执行 N 次等于执行一次”。三条出路:
- 把副作用本身建模成一个有状态的命令:先落库为
PENDING(这一步幂等),再由独立的投递器去执行,执行结果回写SENT/FAILED。副作用于是变成了”一个幂等的任务 + 尽力投递”,重试只是让投递器再试一次,而”是否已发送”的状态拦住了真正的重复。 - 把幂等键传给第三方:支付接口通常支持
idempotency_key/out_trade_no,把幂等责任外包出去。这是最省事的一条,能用就用。 - 接受重复,靠对账兜底:明确告诉产品”极端情况下用户可能收到两条短信”,然后做日终对账 + 补偿。“接受它”也是一种设计决策,但必须是显式的、被业务认可的,而不是藏着。
一句话收尾:exactly-once 是消息系统的承诺,幂等是业务系统的承诺。前者在分布式下永远有洞,后者你可以自己焊死。
子主题七:顺序性——为什么”全局有序”是个奢侈品
问题来了:你说队列”保证顺序”,那它到底保证的是什么顺序?如果我要”所有订单按创建时间严格先后处理”,能不能做到?
先给结论,再说为什么:
分区内有序、跨分区无序,是分布式队列的基本性质,不是缺陷。 想要全局有序,代价是放弃并行。
为什么并行必然牺牲全局顺序
想一件事:什么叫”全局有序”?就是存在一个全序(total order)——任意两条消息都能比较先后。
要有全序,就得有一个唯一的序列化点:所有消息必须流经某个单点,由它盖章编号。而”单点”就意味着——它的吞吐是整个系统的上限,而且无法水平扩展。你想加机器?对不起,加机器只会让”谁先谁后”更难判断。
有人会说:用时间戳排序不就行了?听起来合理,实际上是一场灾难:
- 物理时钟会漂移,NTP 同步后仍有几十毫秒误差,跨机房更糟,闰秒还会让时间回拨;
- 就算你用 TrueTime(Google Spanner 那套原子钟 + GPS),也要付出”等待不确定区间过去”的延迟代价;
- 逻辑时钟(Lamport Clock)能给出偏序,但要得到全序,你必须给相同计数器的事件定一个 tie-break(比如按节点 ID),而这个 tie-break 与真实世界的因果先后毫无关系。
所以现实是:分布式系统里”同时”这个概念本身就没有良定义。 你没法判断 A 机器 10:00:00.001 发出的消息和 B 机器 10:00:00.000 发出的消息谁先谁后——除非你让它们排一队。
类比一下:全局有序就是”所有人排成一列过安检”。严格、清晰、绝不会出错,但队伍有多长安检就有多慢,开十个通道也没用,因为顺序是全局的。而”按 key 分区”是”按航班号开多个通道”:不同航班之间谁先过闸机无所谓,但同一个航班的旅客相对顺序不乱——整体吞吐 × 通道数。你要的是”同一航班的秩序”,不是”全机场的秩序”。
按 key 分区:它是怎么保证”同一 key 有序”的
机制只有几步,但每一步都可能被破坏:
第一步:路由。 partition = hash(message.key) % partitionCount。哈希函数确定 → 同一个 key 永远落到同一个分区。这一步给了你”同一 key 的消息在同一条物理队列里”。
第二步:分区内 FIFO + 独占消费。 分区是 append-only 的日志,offset 严格递增,天然有序;同时一个分区在消费者组内只会被一个成员消费。于是”同一 key 的消息被同一个消费者按顺序读到”。
第三步(最容易翻车的一步):消费端不能并发。 很多人做了前两步,然后在消费端开了个 20 线程的线程池来”提升吞吐”——顺序当场归零。poll 出来 500 条丢进线程池,第 500 条完全可能比第 1 条先处理完。
所以顺序性是三层保证的乘积,任何一层断了就全断:
存储层有序(broker 的 append-only 日志)
× 拉取层有序(单消费者独占分区、顺序拉取)
× 处理层有序(处理逻辑不并发乱序、不跳过)
三个鲜为人知的顺序杀手
杀手一:扩容分区。 hash(key) % N 里的 N 变了,映射就全变了。原来 key="user-42" 落在分区 3,扩到 24 个分区后可能落在 17——扩容瞬间,同一个 key 的新老消息进了不同分区,相对顺序就断了。而且 Kafka 加分区不会帮你做数据重分布(老数据还留在老分区),也不会暂停写入。所以:如果你的业务依赖 key 顺序,分区数从一开始就要定够(或者接受扩容时的乱序窗口,并在消费端用版本号兜底)。
杀手二:重试。 这是分布式消费里最经典的死结。msg1 处理失败进入重试队列,msg2 处理成功——同一 key 的两条消息,顺序颠倒了。你只有三条路:
- 阻塞:暂停这个分区,直到 msg1 成功。副作用是队头阻塞(Head-of-Line Blocking)——一条坏消息让整个分区(可能是几万条好消息)陪葬。
- 旁路重试 + key 级暂停:把失败消息丢到重试主题,主流程继续;同时维护一个”冻结 key 集合”,同一 key 的后续消息先暂存不处理,等重试成功再放行。这是工业界最常见的折中,但实现复杂度不低(要有一套 key 级状态存储和恢复机制)。
- 业务容忍 + 版本保护:接受乱序,给每条事件带上版本号/时间戳,消费端只接受
version > 当前版本的事件,旧的直接丢弃。这把”顺序要求”从传输层搬到了应用层——应用层保护是最后一道防线,也是最实用的一道。
杀手三:多生产者 + 无单调序号。 就算只有一个分区,如果两个生产者并发写,谁先到谁先记,跟业务上的”谁先发生”无关。要真正有序,事件必须携带业务上的单调序号(比如数据库的 binlog 位点、聚合根的事件版本号)。
全局有序到底什么时候才真的需要
坦率说,极少数。真正的场景是:需要全序才能重建状态的场合——数据库 binlog(同一个库内的事务顺序不能乱)、状态机复制(Raft/Zab 的日志)、事件溯源中同一个聚合根的事件流。
注意它们的共同点:全都把范围缩小到了”一个流”——一个库、一个 Raft group、一个聚合根。所谓”全局有序”,在工业实践里几乎总是被降级成”某个键空间内的有序”——因为把键空间切细之后,每个子流都能独立并行,这才是能扩展的设计。
所以下次有人问”能不能保证顺序”,正确的反问是三连:
谁和谁之间的顺序?同一个用户的两次操作,还是任意两个用户的操作? 顺序错了会怎样?是算错账,还是只是显示上先后颠倒? 能不能用版本号在消费端兜底,而不是让传输层去保证?
第三个问题通常能省掉你 90% 的麻烦。
子主题八:消息堆积——从”看条数”到”算速率”
监控面板上 lag 显示 3,000,000 条,红色告警。你现在该做什么?
大部分人的第一反应是”加消费者”。这个反应经常是错的,而且会白白烧掉黄金止血时间。
第一步:算出两个速率
堆积这个问题,唯一正确的切入点是排队论里最朴素的那个式子:
L(t) = L(0) + ∫(P(τ) − C(τ)) dτ P = 生产速率, C = 消费速率
不用积分,看三个数就够:生产速率 P(条/秒)、消费速率 C(条/秒)、当前积压 L(条)。然后算两件事:
净消化速率 = C − P
预计消化时间 = L / (C − P)
这一个除法,能立刻把情况分成两类:
- C > P:会自愈。lag 只是”延迟”,不是”故障”,预计消化时间告诉你多久追平。此时最该做的是别乱动——很多事故是被”慌乱中的扩容 + rebalance”搞出来的。
- C ≤ P:结构性过载。任何大小的缓冲区最终都会满(子主题四的结论在这里复现)。此时加队列容量毫无意义,必须提高 C 或 降低 P。
“预计消化时间”比”lag 条数”更适合做告警指标。 lag 一亿条,如果消费速率 100 万/秒,也就 100 秒,根本不用管;lag 一百万条,如果速率 100/秒,就是 1 万秒 ≈ 2.8 小时,这是严重事故。条数会骗人,时间不会。
还有一个 Kafka 特有的陷阱:lag 必须对照保留期看。如果消费者慢到超过 retention.ms,旧数据被整段删除,Kafka 会把消费者 offset 强行前移到当前最小可用位点——数据被静默丢弃,不报错、不告警。所以告警规则里一定要有一条:预计消化时间 > 保留期剩余时间 → 立刻 P0。
第二步:定位是”慢”、“卡”、还是”多”
三个分支,症状完全不同:
分支 A:消费慢(C 变小但仍 > 0)。 特征:lag 缓慢上涨,速率下降,CPU/IO 可能不高。 排查顺序——单条处理耗时的 p99 有没有飙升?
- 下游依赖变慢:数据库慢查询(索引失效、大事务、锁等待)、外部 API 超时、缓存击穿导致回源风暴;
- 资源侧:Full GC 停顿、连接池打满、磁盘 IO 打满、容器 CPU throttling(被别的 pod 抢走 CPU);
- 数据侧:突然来了几条”巨无霸”消息(消息体 10MB),或者一批消息触发了慢路径(批量操作退化成逐条操作)。
分支 B:消费卡住(C ≈ 0,但消费者进程活着)。 特征:lag 一条不动,日志里反复刷同一条错误,或者干脆没有任何输出。 两个高频原因:
- 毒丸消息(poison pill):某条消息永远处理失败,被无限重试,主流程无法前进。本质就是子主题七说的队头阻塞。
- 反复 rebalance:单条消息处理超过
max.poll.interval.ms→ 被判定死亡 → rebalance → 新消费者又拉到同一条 → 又超时 → 死循环。表现为”消费者一直在重启,lag 一动不动”。
分支 C:生产突增(P 飙升,C 正常)。 特征:生产速率曲线有个陡峭的拐点。
- 业务侧:营销活动、秒杀、上游补数据、离线任务回刷历史;
- 故障侧:上游 bug 导致循环重发(消费失败后上游无限重试,或者有个死循环在发消息);
- 架构侧:上游扩容了,你这边没跟着扩。
一个快速判据:看 P 的曲线拐点和 C 的曲线拐点谁先动。 P 先动 → 分支 C;C 先动 → 分支 A;C 掉到 0 且日志刷屏 → 分支 B。
第三步:扩容消费者之前,先检查这七件事
加消费者是最直觉的动作,也是最容易做错的动作。按顺序检查:
1. 消费者数不能超过分区数。 这是 Kafka 的硬约束:一个分区在同一消费者组内只能被一个成员消费。你有 8 个分区、开 20 个消费者 → 12 个完全空闲,一条消息也拿不到。这是”扩容无效”最常见的原因,而且它不会报错,只会让你困惑。
2. 加消费者会触发 rebalance,rebalance 期间全组停摆。 新成员加入 → 协调者重新分配分区 → 分配期间所有消费者暂停消费(stop-the-world)。一次 rebalance 通常几百毫秒到数秒,大组更久。频繁加消费者 → 频繁 rebalance → 吞吐反而下降。现代 Kafka 用 CooperativeStickyAssignor(增量协作分配)只迁移需要迁移的分区,能把停摆范围缩到最小,务必开着。
3. 新消费者有冷启动期。 JIT 未预热、连接池未建立、本地缓存是空的、元数据要拉——刚起来的那几分钟,单实例吞吐可能只有稳态的三分之一。所以扩容效果滞后,别因为”加了没效果”就继续加。
4. 下游扛得住吗? 这是最要命的一条。加消费者的本质是加并发压力——如果瓶颈是数据库写能力,加 10 个消费者只会让数据库更快挂掉,然后消费失败率上升、lag 涨得更猛。扩容前先算下游水位:新增并发 × 单条 DB 耗时 会不会打满连接池 / 主库 IOPS?
5. 分区数不够怎么办?扩分区有副作用。 能扩,但记住三点:① 会破坏 key 的顺序保证(子主题七);② 老数据不会重新分布,历史数据还在老分区,新分区从 0 开始,看板上的 lag 会跳变;③ 扩分区本身也会触发 rebalance。分区数应该在设计阶段就留够余量(比如按峰值预估的 2 倍),而不是事到临头再扩。
6. 最便宜的一招往往被忽略:先调批量参数。 不动消费者数量,只调 max.poll.records(一次拉多少条)和消费逻辑的批量写(攒够 500 条批量 insert,而不是逐条 insert)。把逐条 DB 写入改成批量,吞吐经常能翻十倍,成本是改 10 行代码,而且不触发 rebalance。这是投入产出比最高的一步,应该排在第一位。
7. 别忘了一致性风险。 扩容后并发度变了,会不会暴露原本被低并发掩盖的竞态?你的幂等逻辑扛得住更高并发吗?
应急手段的成本排序
从便宜到昂贵:
- 调批量参数(零成本、无副作用)
- 扩容消费者(受分区数限制,有 rebalance 成本)
- 降级消费逻辑:临时关闭非关键步骤(跳过实时聚合、跳过推荐特征计算、跳过同步数仓),只保留核心落库。见效极快。
- 扩分区(有顺序性副作用)
- 分流到追赶专用消费者组:新建一个消费者组从最早位点消费,把积压数据导到离线路径慢慢处理,主组从最新位点继续——保住线上实时性,历史数据离线补。这是日志模型(子主题十)才有的特权。
- 保数据优先:极端情况下,起一个”只落盘不处理”的临时消费者,把消息原样 dump 到对象存储,保住数据不丢,之后再慢慢回放。
- 重置 offset 跳过(丢弃,最后手段,必须业务负责人签字)
最后一句:堆积的本质是延迟,不是错误。只要消息还在保留期内,你就还有时间——慌乱中的错误操作才是真正的数据杀手。
子主题九:死信队列与重试——失败是常态,你怎么收场
一条消息处理失败了。你怎么处理?
有人说”重试”。好,重试几次?隔多久?还失败呢?这个”还失败呢”的答案,就是死信队列(Dead Letter Queue, DLQ)。但在这之前,得先把”失败”这件事拆开。
第一类问题:不是所有失败都值得重试
失败分两种,混淆它们是很多事故的源头:
- 瞬时故障(transient):网络抖动、下游 503、限流被拒、数据库死锁、GC 停顿、正在滚动重启。重试有意义,大概率下次就成。
- 永久故障(permanent):JSON 解析失败、必填字段缺失、业务校验不通过、引用的数据根本不存在、schema 不兼容。重试一万次也是失败,而且白白消耗资源、拖慢整条队列。
对永久故障重试不仅无用,还会放大故障:下游已经 500 了,你还在用重试给它加压——这正是压垮骆驼的最后一根稻草。所以第一条设计原则是:错误必须分类,永久故障直接进 DLQ,一次重试都不给。
怎么分类?靠异常类型和错误码建一张映射表:ParseException / ValidationException / 4xx(除 429)/ 业务规则拒绝 → 不可重试;SocketTimeout / 5xx / 429 / 死锁 → 可重试。429 和 Retry-After 头要特殊对待——那是下游明确告诉你”慢点来”,要照做。
重试策略的三要素:次数、间隔、抖动
次数:一般 3~5 次。更重要的是总时长要有上界(比如整体不超过 10 分钟)。没有上界的重试,本质是把”快速失败”变成了”慢性堆积”——它不报错,只让 lag 悄悄涨起来,等你发现时已经晚了。
间隔:固定间隔是最糟的选择。想象一下:下游服务挂了 30 秒,期间积压的 10 万条消息全部进入重试;下游恢复那一刻,10 万次重试同时打过去——刚活过来的服务又被打死。这叫重试风暴(thundering herd)。
正确做法是指数退避 delay = min(cap, base × 2ⁿ)。但它还不够——如果所有失败消息节奏一致,退避只是把风暴推迟到了第 2ⁿ 秒,依然是同步的。所以必须加抖动(jitter):
delay = random(base × 2ⁿ × 0.5, base × 2ⁿ) # 全抖动
delay = min(cap, base × 2ⁿ) × random(0.5, 1.0) # 半抖动
抖动的作用是把同步的失败打散成异步的重试。这是分布式系统里最便宜也最有效的一个技巧。
重试放哪里:分三层——
- 内存重试:立即重试 1~2 次,处理网络抖动,最便宜。
- 延迟重试队列:失败消息移出主队列,投递到带延迟的重试通道。RabbitMQ 用死信交换 + TTL,RocketMQ 有延迟级别,Pulsar 有原生 delayed delivery;Kafka 没有原生延迟消息,常见做法是自建多级重试主题(
retry-10s / retry-1m / retry-5m / retry-30m),或者用外部调度器扫表。 - 死信队列:重试次数用尽后的归宿。
关键设计:重试必须让出主链路。 一条坏消息如果卡在主队列里重试,整个分区(几万条好消息)都得陪它等。所以失败消息必须立刻移出主队列到独立的重试通道,主流程继续往前走。这一步做不对,你的系统就会被一条坏消息拖垮。
死信队列:什么时候进,进去之后怎么办
进 DLQ 的条件(明确的、可编码的规则):
- 重试次数用尽
- 错误类型判定为不可重试(直接进,省掉无谓的重试)
- 消息本身无法处理(反序列化失败、schema 不兼容、消息体损坏)
- 超过最大存活时间(TTL)仍未成功
DLQ 里的消息必须带上下文,否则它就是垃圾场。 只丢一个原始消息体进去,运维面对几千条消息根本无从下手。一条合格的 DLQ 记录应该包含:
原始消息体(完整保留,不要做任何转换)
+ 来源:topic / partition / offset / 原始时间戳
+ 业务键(order_id、user_id —— 这是人工排查唯一的抓手)
+ 失败时间、重试次数、每次重试的时间点
+ 最后一次异常:异常类型 + 消息 + 堆栈摘要
+ 消费者实例标识(哪个 pod、哪个版本的代码)
+ 错误分类标签(用于自动聚类)
DLQ 的运营,五件事缺一不可:
- 告警:DLQ 条数 > 0 就应该告警。DLQ 的增长率是系统健康度最敏感的指标之一——它比 CPU、比 QPS 更早反映问题。
- 分类:按异常类型/错误码聚类。别一条一条看,先看量最大的那一类,往往修一个 bug 就清掉 80%。
- 工具:一键查看、一键重放(重新投递回主 topic)、一键批量丢弃、一键”修改后重投”。重放必然产生重复,所以消费端必须幂等——这里又和子主题六接上了。
- SLA:DLQ 不能是垃圾桶。堆了几千条无人管的 DLQ,比直接丢弃更危险,因为它给你一种”数据我都存着”的错觉,实际上业务效果早就丢了。要定死”DLQ 平均处理时长”这个指标。
- 可观测性验证:DLQ 长期为 0 未必是好事——很可能是代码里
catch (Exception e) { log.error(...); ack(); }把错误吞了。定期故意注入一条坏消息,验证它是否真的进了 DLQ。 这是一次性投入、回报极高的做法。
人工处理 vs 自动补偿
自动补偿适用于”确定性的、可枚举的失败”:
- 依赖数据未到达:订单事件先于用户注册事件到达 → 外键不存在 → 失败。这类”竞态型失败”最好的解法不是重试 N 次,而是等待并重新排队,或者用”本地暂存 + 定时扫描”消化。
- 按错误码路由到补偿逻辑:比如”库存不足” → 触发取消订单流程;“账户已注销” → 触发退款。这是 Saga 模式里的补偿事务。
- 幂等的自动重放:修完 bug 之后,把 DLQ 里对应类别的消息批量重放。
人工处理适用于需要判断、需要改数据、需要业务决策的失败。混合流水线是常态:
失败 → 分类 → 可重试?→ 指数退避重试(≤ N 次)
→ 不可重试 / 重试用尽 → 自动补偿(按错误码路由)
→ 补偿失败 → DLQ → 告警 → 人工
补偿自身的两个陷阱:① 补偿也可能失败,所以补偿逻辑自己也需要重试和记录;② 补偿如果不幂等,就是在一个已经出错的系统上再撒一把盐。补偿必须和主流程一样满足幂等——这是”异步系统里幂等无处不在”的又一个例证。
子主题十:Kafka 的日志模型——把”消息”变成”事实”
为什么 Kafka 能在吞吐上碾压传统 MQ?
常见的答案是”顺序写磁盘""零拷贝""分区并行”。这些都对,但都只是表象。真正的分野在数据模型:传统 MQ 是”投递信件的管道”,Kafka 是”append-only 的分布式日志”。这一个抽象的改变,连锁地改变了可靠性、吞吐和可演化性。
对比:破坏性读 vs 非破坏性读
传统 Broker(以 RabbitMQ 为代表):消息被消费者取走并 ack 之后就删除。Broker 必须为每一条消息维护状态:已发送 / 已确认 / 需重发 / 已删除。这个 per-message 状态带来两个后果:
- 消费者一多,状态维护本身就成了瓶颈;
- 消费是”破坏性”的:A 消费完就没了,B 再也读不到。想让两个系统都读同一份消息?那就复制两份队列。
Kafka:消费是非破坏性读。消息写进日志就不动了,谁读、读几次、读到哪,Broker 一概不关心——消费者的全部状态就是一个数字:offset。Broker 不维护任何 per-consumer、per-message 的状态,这是它能支撑海量消费者和高吞吐的根本原因(不是因为磁盘快,是因为它没有状态包袱)。
offset 由消费者自己维护:灵活性的三个来源
这个设计把”消费进度”的所有权交给了消费者。灵活性由此而来:
一、重放(replay)。 把 offset 改回任意位置重新消费。这在传统 MQ 里不可想象(消费即删)。三个杀手级用途:
- 修 bug 后重算:昨天那个算错积分的 bug 修好了,把消费者组 offset 回退到昨天零点,重跑一遍。
- 新消费者冷启动:新上线一个推荐模型,需要过去 30 天的用户行为做训练/预热——直接从 30 天前开始消费,不需要任何数据迁移,不需要改上游一行代码。
- 灾备演练与影子消费:起一个影子消费者组消费生产流量,对比结果,验证新版本逻辑。
二、多消费者组互不干扰。 同一个 topic 可以被任意多个组以任意速度独立消费,而且不复制数据——它们读的是同一份日志文件,靠操作系统的页缓存和 sendfile 零拷贝直接把数据从页缓存推到网卡,不进用户态。这也是 Kafka 堆内存不用设很大、但要避免和重内存进程混布的原因。
三、时间旅行。 Kafka 维护了时间戳索引,可以按时间点定位 offset:“从昨天 14:00 开始消费”。排查问题时这个能力非常救命。
代价是什么? 责任转移了。既然 offset 归消费者管,“什么时候提交”就成了一个正确性决策:
- 先提交 offset,再处理业务 → at-most-once(提交完崩溃,消息永远丢失)
- 先处理业务,再提交 offset → at-least-once(处理完、提交前崩溃,消息重复)
也就是说,Kafka 把”要不要丢数据”的选择权交给了你,同时也把犯错的自由给了你。生产环境实践:关掉 enable.auto.commit,手动提交,处理成功后再提交;用异步提交提升吞吐、关闭消费者前做一次同步提交兜底。
消费者组与 rebalance:那个”停摆”的瞬间
消费者组通过**协调者(GroupCoordinator)**管理成员,成员靠心跳维持身份,组内选出一个 leader 负责计算分区分配方案。
什么会触发 rebalance?
- 成员加入或离开(扩容、部署、崩溃、被踢)
- 订阅 topic 的分区数变化
- 消费超时:处理时间超过
max.poll.interval.ms被判定为死亡 - 心跳超时:
session.timeout.ms
第三条是生产事故的高频来源,值得单独讲:单条消息处理太久 → 心跳还在但 poll 超时 → 被踢出组 → rebalance → 新成员又拉到同一条 → 又超时 → 无限循环。 表现为”消费者一直在重启,lag 一动不动”。三个解法方向:增大 max.poll.interval.ms、减小 max.poll.records(一次少拉点)、把重活挪到异步线程池(但这样就不能自动提交了,得自己按”最大已完成 offset”精确提交,而且会牺牲顺序——见子主题七)。
rebalance 的代价:分配期间整个消费者组停止消费(stop-the-world)。一次通常几百毫秒到数秒,组越大越久。频繁的 rebalance 会让系统长期亚健康——每次都还没追上就又开始重新分配。
协议演进(这本身也是”通用问题 → 解法演进”的好例子):
- Eager 协议:rebalance 时撤销全部分区、全体重新分配,停摆明显。
- Cooperative Incremental(KIP-429):只迁移需要迁移的分区,未受影响的分区继续消费,把停摆范围缩到最小。
- 静态成员(KIP-345):给每个实例配
group.instance.id,短暂重启(比如滚动发布)不触发 rebalance——因为被识别为”同一个成员回来了”。
减少 rebalance 的四条经验:开静态成员;心跳间隔要足够小以及时发现故障、又不能太小以免网络抖动误判(通常 heartbeat.interval ≈ session.timeout / 3);max.poll.records 与 max.poll.interval 要匹配单条处理耗时;部署用滚动重启而不是全量重启。
消息不删除,只按保留期过期
Kafka 的保留策略有三类,可以并用:
- 按时间:
retention.ms(默认 7 天) - 按大小:
retention.bytes - 按 key 压实:
cleanup.policy=compact—— 同一个 key 只保留最新值(Kafka 内部的__consumer_offsets就是 compacted topic)。这把”事件流”变成了”状态快照流”,用于状态恢复。
删除的方式很关键:Kafka 把日志切成 segment 文件(默认 1GB),过期时删除整个 segment 文件——一个 O(1) 的文件系统操作,绝不在文件中间擦除。这是它能维持高吞吐的工程细节之一(顺序写 + 整段删,磁盘几乎没有随机 IO)。
这个设计的意义,远不止”省事”:
- 解耦达到极致:消费者可以任意慢,甚至可以离线几小时——只要还在保留期内,数据就在。生产者和消费者之间,连”大致同步”都不需要了。
- 数据可重放 = 系统可演化:新业务需求 = 新起一个消费者组从头消费。不需要上游改造、不需要数据迁移、不需要停服。这是”开闭原则”在架构层面的实现。
- 消息从”待办事项”变成了”事实记录”:它是存储,不只是传输。所以能做 CDC(把数据库变更做成流)、能做流处理的状态恢复(Flink 的 checkpoint + 可重放 source = 端到端状态一致性)、能做事件溯源。
- 它还是一种特殊的背压:注意这个反直觉的点——如果消费者慢到超过保留期,旧 segment 被删,Kafka 会把 offset 前移到最小可用位点。数据被静默丢弃,但系统不会 OOM。 这比无界队列”安全”(不崩),但比有界队列”阴险”(不报错)。所以 lag 监控必须和保留期联动告警。
代价也要说清楚:磁盘成本(尤其多副本 × 长保留期);以及”删除”语义难以实现——GDPR 的”被遗忘权”与不可变日志天然冲突。常见解法:compaction + 墓碑消息(tombstone,value 为 null 表示删除);数据加密后销毁密钥(crypto-shredding);或者只在日志里放引用,PII 放外部存储。
子主题十一:异步在架构层面的形态——EDA、CQRS 与”事件解耦”的六个陷阱
到目前为止我们谈的都是”两个服务之间加个队列”。但异步真正改变架构,是在你把它作为系统的骨架时。这一节讲三种形态,以及它们各自的坑。
形态一:事件驱动架构(EDA)
EDA 的核心动作只有一个:把”命令”改成”事实”。
- 命令式:订单服务调用积分服务说”加积分”、调用短信服务说”发短信”——订单服务知道所有下游,这是编排(orchestration)。
- 事件式:订单服务只广播一句”
OrderPlaced发生了”,谁关心谁自己来听——这是编排的反面:协同(choreography)。
根据事件里带多少信息,EDA 又有三个层次:
| 层次 | 事件内容 | 优点 | 缺点 |
|---|---|---|---|
| 事件通知 | 只有 ID 和事件名 | 极简,不泄露内部结构 | 消费者要回头查 API → N 个消费者 = N 倍查询压力 |
| 事件携带状态转移 | 带上消费方需要的全部字段 | 自包含,消费者无需回查 | 事件变胖;schema 演化困难;容易泄露不该给的数据 |
| 事件溯源 | 事件本身就是唯一事实来源 | 完整历史、可回放、可审计 | 查询需要重建投影;学习曲线陡 |
实践建议:默认用第二种,但只带”消费方普遍需要的最小集合”,并带上版本号。 事件通知的”查询风暴”是真实存在的——10 个消费者一起回头查订单详情,订单服务的 QPS 就翻了 10 倍,而且这 10 倍压力还是你自己造出来的。
形态二:CQRS(命令查询职责分离)
为什么要把读写分开?因为它们的敌人完全不同。
- 写模型要维护业务不变量(“库存不能为负""余额不能透支”),要规范化,QPS 相对低,但要强一致。
- 读模型要快、要灵活、要能应对千奇百怪的查询条件,天然需要反规范化(冗余、宽表、预聚合),而且可以接受短暂陈旧。
把它们塞进同一张表,就是两头都不讨好:为了查询方便加冗余字段 → 写入逻辑变复杂、一致性难保证;为了规范化拆表 → 每个查询都要 join 五张表。
CQRS 的做法:
命令侧(Write Model) 查询侧(Read Model)
接收命令 → 校验 → 改状态 按查询需求建的物化视图
↓ 产出事件 ↑
└────────→ 事件总线 ────→ 投影器(Projector)───┘
(异步)
粘合剂就是异步事件。注意:CQRS 不强制要求异步,也不强制要求分离数据库,但异步事件是它最常见的实现方式。
CQRS 的三个代价,必须提前认账:
- 读模型的最终一致。 用户改完资料立刻刷新,可能看到旧值——这是 CQRS 的原生缺陷,不是 bug。解法是写后读一致性:命令返回时带上产生的事件版本号(或聚合根版本),查询时带上”我至少要看到版本 N”,读侧若未追上该版本就等待或重放;或者简单地写穿缓存。
- 读模型要能重建。 新增一个查询维度(比如”按地区统计订单”)→ 需要重放全量事件重建投影。这要求:事件必须长期保存(又见日志模型)、投影器必须幂等(又见幂等)、重建期间不能停服(通常起一个新的读库,追平后切换,即蓝绿)。如果你的事件只保留 3 天,“重建”就是一句空话。
- 两套模型的维护成本。 一个业务概念要在命令侧和查询侧各写一遍,改名改两处,加字段改两处。团队小、业务简单时,这笔账是亏的。
什么时候用? 读写模型差异大、查询复杂多变、读 QPS 极高。在一个 CRUD 管理后台上 CQRS,是纯粹的自我伤害——你会为了一个不存在的性能问题,付出两套模型和最终一致的代价。
形态三:用事件解耦的六个陷阱
这是本节最有价值的部分。事件解耦看起来是”零成本的解耦”,但它把编译期能发现的问题,推迟到了运行期。
陷阱一:事件变成隐式契约。
同步调用时,改一个字段名,编译器会报错。事件解耦之后,代码里只剩一个字符串 "OrderCreated",谁在消费它、依赖哪些字段,全靠口头约定和运气。上游把 amount 从分改成元,下游半夜炸,第二天早上才发现。
要清醒地认识到:事件就是 API,而且是比 REST API 更难管的 API——因为它是多对多的、隐式的、没有调用栈的、跨团队边界的。四条防线:
- Schema Registry + 兼容性检查(Avro/Protobuf):CI 里自动检查新 schema 对已注册版本是否向后兼容(消费者用旧 schema 能读新数据)、向前兼容(消费者用新 schema 能读旧数据)。
- 兼容性规则:新增字段必须可选且有默认值;绝不删除字段(标记为废弃);绝不改变字段语义(
amount从分改成元等于犯罪,应该新增amount_yuan)。破坏性变更走新 topic 版本(OrderCreated.v2),双写一段时间后迁移。 - 消费者驱动契约测试(如 Pact):消费者声明”我依赖这几个字段”,生产者的 CI 必须验证没有破坏。
- 事件目录(Catalog):登记每个事件的生产者、消费者、schema、负责人。“谁在消费这个事件”这个问题,必须能在 30 秒内回答出来。
陷阱二:难以追踪。
一个用户操作跨了 5 个服务、通过事件串起来,出问题时你根本不知道它走到哪一步。解法:事件里必须携带 traceId(同一次请求的链路)+ causationId(是谁导致了这个事件)。有了 causationId,你就能把散落的事件串成一棵因果树,而不是一堆平铺的日志。再配合分布式追踪系统,可观测性才勉强够用。
陷阱三:把内部数据变更当领域事件发出去。 用 CDC 直接把 binlog 当领域事件广播——这条捷径非常诱人,也非常致命。因为你的数据库 schema 从此变成了公共 API:改个列名得通知全公司,加个中间表得考虑下游会不会误读。
正确做法:区分内部数据变更(实现细节,可以发到内部 CDC 流)和领域事件(业务事实,是稳定契约)。对外只发后者,必要时在中间加一层**防腐层(ACL)**做转换。
陷阱四:事件风暴与循环依赖。 A 发事件 → B 处理后发事件 → 又触发 A → 无限循环。这类问题排查极其痛苦,因为它表现为”系统在正常地高速运转,但什么都没完成”。
防线:事件里带来源服务和因果链,消费端做环路检测(如果这个事件是我自己直接或间接产生的,就忽略);另一条设计原则是——领域事件应该是”已发生的事实”(过去式,OrderPlaced),而不是”要去做某事”(祈使句,PlaceOrder)。祈使句事件天然容易成环。
陷阱五:双写问题(最经典的一个)。 “写数据库”和”发消息”是两个独立系统,你没法用一个事务罩住它们:
方案 A:先写库,再发消息 → 发消息前崩溃 → 业务发生了,事件丢了(下游永远不知道)
方案 B:先发消息,再写库 → 写库失败 → 事件发了,业务没发生(幽灵事件)
这是异步架构里最普遍、最致命的正确性缺口。标准解法是事务性发件箱(Transactional Outbox):
BEGIN;
UPDATE orders SET status='PAID' WHERE order_id=?;
INSERT INTO outbox (id, aggregate_id, event_type, payload) VALUES (...); -- 同一事务
COMMIT;
-- 独立的投递进程轮询 outbox:发出去 → 标记已发送(或删除)它保证了”业务发生了,就一定有事件”。代价是至少一次投递(投递器发出后崩溃 → 重发 → 重复)→ 所以下游必须幂等。你看,幂等又出现了。
替代方案是 CDC 直读 binlog(Debezium):连 outbox 表都不用写,数据库自己就是发件箱。好处是零侵入;代价是 binlog 是物理格式,需要一层转换,且对数据库拓扑变更敏感。
陷阱六:把消息队列当数据库用。 在队列里存业务状态、靠队列做查询、用”消息是否还在队列里”判断业务状态——这些都是错的。队列是传输与缓冲,不是存储与查询。要历史状态,请用事件存储或数据库。
子主题十二:另一种异步——Reactor / 异步 IO 与 Actor 模型
到目前为止,“异步”一直指”服务之间不互相等待”。但异步还有另外两种重要形态,它们在更细的粒度上解决同样的问题。把它们放在一起看,你会发现:“不要让调用方阻塞在等待上”这一条思想,贯穿了从网卡到架构的每一层。
先厘清:Reactor 到底是什么
这几个词天天被混用,先把层级钉死:
非阻塞 IO(OS 能力:O_NONBLOCK / epoll / kqueue / IOCP)
↓ 之上一层
IO 多路复用(机制:一个线程监视多个 fd,select / poll / epoll)
↓ 之上一层
Reactor 模式(设计模式:事件循环 + 把就绪事件分发给 Handler)
↓ 之上一层
编程模型(回调 → Promise/Future → async/await → 协程 → 虚拟线程)
另有一条支线:真正的异步 IO(io_uring / IOCP)——内核做完 IO 再通知,
连"等就绪再自己 read"这一步都省了。
一个常被搞混的事实:Node.js 的”异步”并不全是真异步。 它的核心是 Reactor + 非阻塞 IO + 单线程事件循环,但底下的 libuv 有一个默认 4 线程的线程池,专门处理那些操作系统没法非阻塞化的操作——文件 IO、DNS 解析、crypto、zlib。文件读写其实是线程池模拟出来的异步。 这也是为什么 Node 上大量文件 IO 会把线程池打满,进而拖慢整个进程。
Netty 是 Reactor 的工程化范本,三个设计值得学:
- Boss / Worker 线程组:Boss 只负责 accept 连接,Worker(默认 CPU 核数 × 2)负责读写。
- 串行化设计:一个
EventLoop绑定一个线程和多个 Channel,这个线程内的所有操作串行执行。这是故意的——用”单线程的顺序性”换掉了锁和上下文切换的复杂度。(思路和第 9 章正好相反:那里是”用锁保护共享状态”,这里是”根本不共享”。) - ByteBuf 池化 + 零拷贝:
CompositeByteBuf把多个 buffer 逻辑上拼起来而不复制内存;FileRegion用sendfile把文件数据直接推到网卡。
Reactor 要解决的是什么问题? C10K:一万个并发连接,如果”一连接一线程”,就是一万个线程——按 1MB 栈算就是 10GB 内存,再加上下文切换开销,机器直接跪。Reactor 用少量线程(≈ CPU 核数)服务海量连接:线程只在”有数据可读写”时才工作,等待期间零成本。
但 Reactor 的代价同样明显:
- 回调地狱:异步逻辑被拆成碎片,控制流不再连续。
- 栈被切断:异常抛出时,调用栈里只有事件循环那一层,你根本看不出是谁发起的这个操作。排查时极其痛苦。
- 阻塞是禁忌:在 EventLoop 线程里做任何阻塞操作(同步查库、
Thread.sleep、同步 HTTP 调用),这个线程上挂着的几千个连接会一起卡住。这是 Node/Netty 生产事故的头号原因。 - CPU 密集任务是毒药:事件循环被一个计算任务占住,整个进程的响应性归零。CPU 密集的活必须扔到独立线程池。
演进路线:回调 → Promise 链 → async/await(把异步写成同步的样子,编译器/解释器把它变成状态机,栈信息部分恢复)→ 协程。Go 用 goroutine + netpoller 把异步 IO 包装成同步阻塞的写法,用户完全无感,这是”用运行时复杂度换开发体验”;Java 21 的虚拟线程同理,让”一请求一线程”这个曾被判死刑的模型重新可行(注意虚拟线程在 synchronized 块里会被 pin 住,需要换成 ReentrantLock)。
一个必须说清楚的边界:Reactor 提升的是并发连接数(IO 等待时不浪费线程),它不提升单个请求的处理速度。如果你的业务逻辑本身要算 500ms,异步化救不了你——它只会让排队变得更隐蔽、更难观测。
Actor 模型:把”异步解耦”下沉到语言级
如果说消息队列是”服务之间”的异步,那 Actor 就是”对象与组件之间”的异步。
Actor 的定义极简,三句话:
- Actor 是最小的计算单元,拥有私有状态,外部无法直接访问(没有共享内存,就没有锁);
- Actor 有一个邮箱(mailbox),消息排队进来;
- Actor 一次只处理一条消息,串行执行——处理一条消息时可以改自己的状态、可以发消息给别人、可以创建新的 Actor,就这三件事。
看出门道了吗?“串行处理 + 私有状态 + 消息传递”这三件套,正是第 9 章并发问题的另一种解法——不是用锁去保护共享状态,而是从根上取消共享。一个 Actor 内部永远单线程,所以它内部的代码不需要任何同步,可以直接用非线程安全的容器。
与消息队列的关键差异:粒度。 MQ 里一个队列通常对应一类业务,几到几百个;Actor 系统里,一个 Actor 可以是一个用户、一个订单、一个设备、一个 WebSocket 会话——Akka 单机跑百万 Actor 是常规操作。所以 Actor 不是”更轻量的 MQ”,它是并发与状态的组织方式;MQ 则是服务间的传输基础设施。
Erlang/Akka 的三件法宝:
法宝一:位置透明。 发消息给一个 PID(Erlang)或 ActorRef(Akka),对方在本机还是另一台机器上,你的代码写法完全一样。分布式不是后加的功能,而是模型自带的属性。
法宝二:监督树 + “让它崩”(let it crash)。 这是最反直觉、也最有价值的一条。传统做法是防御式编程——到处 try/catch,试图处理每一种异常。Erlang 说:别费劲了,你不可能穷举所有错误。让出错的 Actor 直接崩掉,由它的监督者(Supervisor)按策略重启它。
重启为什么能修好问题?因为重启把状态重置为”已知良好的初始值”,清掉了导致崩溃的那个畸形状态。实践中效果惊人——大量偶发故障本质上是状态腐化,而重启是状态腐化的通用解药。
这套机制成立有两个前提,你会发现它们似曾相识:
- 状态不能只放在会崩的 Actor 里——重要状态要放数据库 / ETS / 持久化的 Actor;
- 消息处理要能被安全地重做——因为崩溃时正在处理的、以及邮箱里未处理的消息,重启后可能要重来。是的,又是幂等。 幂等不是”消息队列场景的技巧”,它是所有异步系统的通用前置条件。
这个思想的威力有个硬证据:Ericsson 的 AXD301 交换机用 Erlang 做到了 **99.9999999%(九个九)**的可用性——一年停机约 31 毫秒。秘诀不是”不犯错”,而是”错误隔离 + 极快恢复”。这和子主题一讲的”同步解耦 = 故障不级联”是同一个思想,只是落实到了语言运行时层面。
法宝三:邮箱与背压。 Actor 的邮箱满了怎么办?这是子主题三的背压问题在语言级的复现,而且答案同样不轻松:
- Erlang 的
!发送原语是非阻塞的,邮箱默认无界 → 慢 Actor 会无限堆积消息 → OOM。Erlang 的应对是监控邮箱长度并告警/限流,以及系统级的过载保护。 - Akka 提供了有界邮箱 + 溢出策略:丢最新、丢最旧、阻塞发送者、发到死信。
结论很清晰:背压不是”消息队列才有的问题”,它是任何异步系统的共性难题。只要存在”生产者快于消费者 + 缓冲”这个结构,背压就一定存在,只是缓冲区在磁盘上还是在内存里的区别。
三者对照:什么时候用哪个
| 消息队列 / 日志 | Actor 模型 | Reactor / 异步 IO | |
|---|---|---|---|
| 解决什么 | 服务之间解耦、削峰、可重放 | 组件之间的并发与状态封装 | 单进程内的高并发 IO |
| 粒度 | 服务级 | 实体级(可达百万) | 连接 / 请求级 |
| 消息是否持久化 | 是(落盘,按保留期过期) | 默认在内存 | 否 |
| 能否重放 | 能(核心能力) | 一般不能(消费即消失) | 不能 |
| 消费者数量 | 多消费者 / 多消费者组 | 一对一(邮箱有明确主人) | 无此概念 |
| 消息语义 | 事实(给不确定的多方) | 指令 / 调用(给确定的一方) | 数据就绪通知 |
| 背压表现 | 堆积(落盘,容量大) | 邮箱涨 → OOM(内存,容量小) | 事件循环延迟上升 |
| 典型实现 | Kafka、Pulsar、RabbitMQ | Erlang/Elixir、Akka/Pekko、Orleans | Node.js、Netty、Go netpoller |
选择判据很简单:
- 跨服务、要持久化、要重放、要多方消费 → 消息队列。
- 进程内、要封装状态、要避免锁(一个游戏房间、一个设备的状态机、一个长连接会话、一个爬虫的 URL 去重器)→ Actor。
- 海量连接、IO 密集、几乎不计算(网关、推送服务、代理、IM 接入层)→ Reactor。
最后一个有意思的闭环:RabbitMQ 这个消息队列本身,就是用 Erlang 写的——用一个 Actor 语言,去实现一个消息中间件。因为”可靠地传递消息、隔离故障、监督重启”本来就是 Erlang 的看家本领。而 Go 社区那句著名的谚语——“不要通过共享内存来通信,而要通过通信来共享内存”——就是 Actor/CSP 思想最精炼的表达。(顺带一提:Go 的 channel 属于 CSP 而非严格 Actor:channel 是有名字的管道、点对点;Actor 是有地址的实体、带邮箱、天生分布式。差异不大,但会实实在在影响你的设计直觉。)
子主题十三:什么时候”不该”用消息队列
讲了一整章异步的好处,最后必须唱反调。不会拒绝异步的工程师,比不会用异步的工程师更危险——因为他会把一个 CRUD 系统改造成一个需要 24 小时值守的分布式事故现场。
情形一:调用方需要结果才能继续
这是最直白的判据。A 调 B,如果 A 拿到 B 的结果之前什么都做不了,那这就是一次 RPC,不是一条消息。
比如”校验优惠券是否可用”——校验结果直接决定订单金额,你必须等。硬要异步化,就得搭一套”异步请求-响应”:请求 topic + 关联 ID(correlationId)+ 响应 topic + 客户端侧的等待与超时 + 超时后的兜底。你把一个函数调用,改造成了一个分布式状态机,还引入超时、乱序、孤儿响应(响应回来了但请求方已超时放弃)三个新问题。
自问一句:如果我把这个调用变成异步,调用方接下来做什么?如果答案是”等着拿结果”,那就别异步。
情形二:业务要求强一致,且中间态不可接受
异步引入的是最终一致,而最终一致意味着存在中间态:扣了库存但订单还没生成、余额已扣但积分未加。
如果中间态对用户不可见(比如 200ms 后就一致了),那没问题。但如果中间态会被用户看到、且无法解释(“我的钱扣了,订单呢?”),或者涉及资金、账务、库存这类必须原子成功或原子失败的领域,异步就是在给自己挖坑。
这里要给一个很实在的警告:很多团队”为了避免分布式事务而上 MQ”,结果把”分布式事务”换成了”分布式补偿 + 日终对账 + 人工兜底 + 一堆状态机”。 复杂度有没有真的降低?多数时候没有,只是从”显式的、集中的”变成了”隐式的、分散的”。
判据:你能清晰定义中间态吗?中间态用户会看到吗?看到了你怎么解释? 三个问题有一个答不上来,别异步。
情形三:流量小到不值当
日活几百、QPS 个位数,引入 Kafka 意味着:部署一套分布式集群(KRaft/ZK + broker)、配监控告警、处理分区与 rebalance、处理堆积、运营 DLQ、半夜起来看 lag。MQ 的运维固定成本,不会因为你流量小而变小。
小而美的替代方案:
-
数据库当队列(强烈推荐):PostgreSQL 的
SKIP LOCKED就是个很好的队列:UPDATE tasks SET status='RUNNING', owner=? WHERE id IN ( SELECT id FROM tasks WHERE status='PENDING' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 10 ) RETURNING *;多个 worker 并发取任务互不阻塞,失败了把 status 改回 PENDING 并累加重试次数就行。事务、持久化、查询、重试、优先级全都白送,零新增组件。MySQL 8 也支持
SKIP LOCKED。 -
Redis List / Stream:需要一点点跨进程能力时用;Stream 有消费者组和 ack,够用。
-
进程内线程池 + 内存队列:只在单机内解耦、允许重启丢失的场景(比如发日志、异步刷新本地缓存)。
判据:峰值 QPS 低于几百、且没有跨服务可靠传递需求 → 一张表就够了。
情形四:需要立即读到自己刚写的结果
异步天然违反 read-your-writes。用户改完昵称刷新页面看到旧值,客服就得接工单。这类”写完立刻查”的路径,要么走同步,要么在写侧做写穿缓存 / 版本等待(见子主题十一的 CQRS 部分)。
情形五:团队没有运维能力和监控
异步系统的失败是静默的。 同步调用失败会立刻抛错、立刻有用户投诉;异步失败会悄悄堆在队列里,等到第二天有人看 lag 才发现——或者永远没人发现,直到对账时差了钱。
上异步之前,先确认这四件事已经存在:
- lag 监控与告警(用预计消化时间,不是条数)
- DLQ 监控与告警,以及明确的 DLQ 处理责任人
- 消费成功率、处理耗时 p99 的监控
- 端到端的对账机制(能发现”事件丢了”这件事本身)
缺一个都别上。没有监控的异步系统不是解耦,是定时炸弹。
情形六:消息丢失代价极高,而你又没能力做对账补偿
如果丢一条消息意味着资金差错,而你的系统既没有 exactly-once 保障、又没法每天对账、又没人为补偿流程负责——那异步带来的收益,远不抵这个风险。
一个决策清单
把这个清单贴在墙上,每次想”要不要上 MQ”时过一遍:
- 这个操作允许延迟多久? 秒级 / 分钟级 / 小时级 / 不允许?
- 失败后能重试吗?重试会重复吗?业务幂等吗?幂等键是什么?
- 顺序有要求吗?是全局有序,还是按某个 key 有序?
- 消息能丢吗?丢了怎么发现(对账)?发现了怎么补偿?
- 中间态用户能看到吗?看到了怎么解释?
- 谁来运维?堆积了谁处理?DLQ 谁看?半夜谁被叫醒?
- 不用 MQ 的最简单方案是什么?它的成本是多少?
如果第 2 问答不出幂等键,或者第 6 问答不出责任人——先别上。
最后一个类比收尾:消息队列像高速公路。它能让车流又快又稳,但你得有收费站(监控)、有应急车道(DLQ)、有交警(值班人)、还得有明确的目的地(消费语义)。如果你只是要从楼下小卖部买瓶酱油,走楼梯比上高速快得多。
演进脉络
- 早期:进程内队列、Unix 管道,用于简单的解耦。
- 1990s:企业级消息中间件(MQSeries/IBM MQ、TIBCO),强调可靠传递和事务,重且贵。
- 2000s:开源消息中间件普及(ActiveMQ、RabbitMQ),AMQP 协议标准化,强调灵活的路由。
- 2010s:Kafka 用”分布式日志”重新定义了消息队列——高吞吐、可重放、分区并行,成为流处理的事实标准。
- 现代:Pulsar 等存算分离架构,云原生消息服务;流批一体(Flink)让消息队列和流处理的边界模糊。
主线:消息队列的演进,是从”传递消息”走向”存储事件”——从”送完就删的管道”,变成”可重放、可回溯的事件日志”。这个转变的背后,是人们对”数据价值”认识的深化:消息不只是要传送的信件,更是值得保存的事实记录。
本章思考题
【思考题】
-
异步解耦解了哪三个维度?它们对应的收益分别是什么?
-
什么是背压?为什么”无界队列”是危险的?四种背压策略各适用于什么场景?
-
“队列只能削峰,不能救过载”——请解释这个结论,并说明看到队列增长时第一步该做什么。
-
消息传递的三种语义是什么?为什么”at-least-once + 幂等”是最常见的组合?
-
为什么说”exactly-once”实际上是”effectively-once”?Kafka 的 EOS 由哪两层机制构成,边界在哪里?
-
幂等的四种实现手法分别是什么?“幂等”与”去重”的细微区别体现在哪三个地方?哪些操作本质上无法幂等,出路是什么?
-
为什么”分区内有序、跨分区无序”是分布式队列的基本性质?按 key 分区保证顺序依赖哪三层保证?扩容分区和重试为什么会破坏顺序?
-
lag 涨到 300 万条,你的诊断顺序是什么?为什么”预计消化时间”比”lag 条数”更适合做告警?临时扩容消费者前要检查哪几件事?
-
重试策略的三要素是什么?为什么必须加抖动?什么是毒丸消息,为什么失败消息必须移出主队列?一条合格的 DLQ 记录应包含哪些上下文?
-
Kafka 的 offset 由消费者自己维护,带来了哪些灵活性和哪些代价?rebalance 由哪些原因触发、代价是什么、有哪些缓解手段?“消息不删除只按保留期过期”的设计意义与隐患分别是什么?
-
事件驱动架构中”事件通知”与”事件携带状态转移”的权衡是什么?CQRS 的三个代价是什么?什么是”双写问题”,事务性发件箱如何解决它?
-
Reactor 模式、异步 IO、协程三者是什么关系?Actor 模型与消息队列在粒度、持久化、消息语义上的关键差异是什么?哪些场景不应该引入消息队列?
【参考答案】
-
三个维度:时间解耦(生产者和消费者不必同时在线,收益是削峰填谷)、空间解耦(双方不必知道对方位置,收益是独立扩缩容和演化)、同步解耦/故障隔离(消费者挂了不影响生产者,收益是故障不级联)。引入异步前应先明确要的是哪一个——只要削峰用简单本地队列即可,要故障隔离才需完整消息中间件。
-
背压是让下游处理能力反向传导到上游生产速度的机制。无界队列危险:它把流量高峰变成延迟高峰再变成内存耗尽,且故障滞后很久才暴露、极难排查;有界队列让问题立即暴露(阻塞或拒绝),反而是好事。四种策略:阻塞(能接受上游被拖慢、绝不丢)、丢弃(数据可丢,如监控采样)、限流(生产端主动限速)、自动扩容(消费慢就加消费者,但有延迟和上限)。工业系统通常四者并用。
-
队列长度变化 ≈ (到达速率 - 处理速率) × 持续时间。若平均到达速率持续超过处理能力,任何大小的队列最终都会满——队列只是拖延时间,不是解决过载。所以队列只适用于”峰值高但平均值在能力内”的削峰场景。看到队列增长,第一步是算出”到达速率 vs 处理速率”:若前者持续大于后者,加大队列容量毫无意义,正确做法是扩容(提能力)或限流(降到达)。
-
三种语义:at-most-once(可能丢、绝不重,最简单最快)、at-least-once(绝不丢、可能重,绝大多数 MQ 的默认)、exactly-once(既不丢也不重,最难、分布式下很难真正实现)。“at-least-once + 幂等”最常见,因为可靠的传递容易实现(确认+重试),而严格的 exactly-once 很难做对;不如承认消息会重复,让消费逻辑幂等(唯一键去重、状态机检查、乐观锁),把难题从”消息系统”转移到”应用逻辑”,后者的实现成本和正确性都更可控。
-
物理上做不到”只传输一次”——网络重传、进程崩溃、重试必然发生,所以业界的 exactly-once 准确说是 effectly-once(效果恰好一次):可投递多次,但最终效果只生效一次。Kafka EOS 两层:① 幂等生产者(PID + 分区 + 自增序号,broker 丢弃重复序号,限于单会话单分区);② 事务(transactional.id + epoch 隔离僵尸生产者 + read_committed,可把消费 offset 与生产消息放进同一事务原子提交,实现 Kafka 内部”读-算-写”原子)。边界:闭环只在 Kafka 内部成立,sink 是 DB/HTTP/短信时事务管不到,公式是”Kafka 内 EOS + 外部 sink 幂等”;此外有 10%~30% 吞吐损失,且 2PC 协调者是单点、FLP 不可能性是理论天花板。
-
四种手法:① 唯一键去重表(与业务写入同事务,顺序不能反,键优先用业务键,需设长于重试窗口的 TTL);② 状态机检查(
UPDATE ... WHERE status='UNPAID',靠单调状态流转天然幂等);③ 乐观锁版本号(防乱序覆盖,但对增量操作无效,宜改绝对值);④ 一次性 token(RESERVED/IN_FLIGHT/DONE 生命周期,能处理并发重复并返回第一次的结果)。幂等 vs 去重的三个差别:着眼点(消息是否来过 vs 业务效果是否相同)、键的失效边界(msgId 重复生成会击穿去重,业务键幂等仍有效;msgId 误复用则去重会误杀)、返回值语义(幂等应返回首次结果而非”重复请求”错误)。无法幂等的是外溢副作用(发短信、第三方支付),出路:建模为有状态命令(先落 PENDING 再投递,结果回写)、把幂等键传给第三方、或显式接受重复并对账补偿。 -
全局有序要求存在全序,就必须有唯一序列化点,其吞吐即系统上限且无法水平扩展;物理时钟有漂移、逻辑时钟只能给偏序,分布式下”同时”没有良定义。所以并行必然牺牲全局顺序,分区内有序、跨分区无序是基本性质而非缺陷。按 key 分区的顺序依赖三层乘积:存储层(append-only 日志 offset 递增)× 拉取层(分区在组内被单消费者独占、顺序拉取)× 处理层(消费逻辑不能并发乱序,开线程池顺序立刻归零)。破坏来源:扩容分区使
hash(key) % N映射改变,同一 key 的新老消息落入不同分区;重试使失败消息晚于后续消息完成;多生产者并发写且事件无业务单调序号。 -
诊断顺序:先算速率——净消化速率 C − P、预计消化时间 L / (C − P)。C > P 会自愈,C ≤ P 是结构性过载,加缓冲区无意义。再分三支定位:C 变小看单条处理 p99(下游慢查询/外部超时/GC/连接池);C ≈ 0 且日志刷屏是毒丸消息或 rebalance 死循环;P 陡增看生产速率拐点(活动/回刷/上游循环重发)。用”预计消化时间”而非”lag 条数”告警,因为条数会骗人(亿条 @100 万 tps 只要 100 秒;百万条 @100 tps 要约 2.8 小时),且必须与保留期联动(超过 retention 会静默丢数据)。扩容消费者前检查:消费者数 ≤ 分区数(超出的完全空闲且不报错)、rebalance 会全组停摆(开 CooperativeStickyAssignor)、新消费者有冷启动期、下游能否承受新增并发、扩分区会破坏 key 顺序、优先调批量参数(最便宜且不触发 rebalance)、以及更高并发是否暴露竞态。
-
三要素:次数(3~5 次且总时长有上界)、间隔(指数退避而非固定间隔,避免下游恢复瞬间被重试风暴打死)、抖动(把同步失败打散成异步重试)。毒丸消息指永远处理失败的消息,若它在主队列中重试会阻塞整个分区(队头阻塞),所以失败消息必须立刻移出主链路到独立重试通道。合格 DLQ 记录要包含:完整原始消息体、来源(topic/partition/offset/时间戳)、业务键、失败时间与重试历史、最后异常类型+堆栈摘要、消费者实例标识、错误分类标签;此外需告警、按错误码聚类、一键重放/丢弃工具、处理 SLA,并定期注入坏消息验证 DLQ 真的生效。
-
灵活性:offset 归消费者 → 可重放(修 bug 重算、新消费者从历史冷启动、影子消费)、多消费者组互不干扰且零数据复制(页缓存 + sendfile)、可按时间戳定位。代价:提交时机的选择变成正确性决策——先提交再处理是 at-most-once,先处理再提交是 at-least-once,责任从 broker 转移到应用。rebalance 触发原因:成员加入/离开、分区数变化、poll 超时(max.poll.interval)、心跳超时(session.timeout);代价是全组 stop-the-world(数百毫秒到数秒),频繁触发会让系统长期亚健康。缓解:CooperativeStickyAssignor(增量迁移)、静态成员 group.instance.id(滚动重启不触发)、心跳约为 session.timeout 的 1/3、max.poll.records 与处理耗时匹配、滚动重启。“只按保留期过期”的意义:消费可任意慢甚至离线、数据可重放使系统可演化(新需求 = 新消费者组,零迁移)、消息成为可存储的事实(支撑 CDC/流处理/事件溯源)、且它是一种不 OOM 的特殊背压;删除以整 segment(默认 1GB)为单位,O(1) 且几乎无随机 IO。隐患:超过保留期会静默丢数据(offset 被前移,不报错)、磁盘成本、以及”删除”语义难实现(GDPR 被遗忘权与不可变日志冲突,靠 compaction + 墓碑消息、crypto-shredding 或 PII 外置解决)。
-
权衡:事件通知极简且不泄露内部结构,但 N 个消费者回头查 API 会造成 N 倍查询风暴;事件携带状态转移自包含无需回查,但事件变胖、schema 演化难、易泄露不必要数据(实践上取”最小必要集合 + 版本号”)。CQRS 三个代价:读模型最终一致(用户写完立刻查可能看到旧值,需版本等待或写穿缓存)、读模型要能重建(依赖长期保存的事件 + 幂等投影器 + 蓝绿切换,事件只留 3 天则重建是空话)、两套模型的维护成本。双写问题:写库与发消息无法用同一事务罩住——先写库后发消息会丢事件,先发消息后写库会产生幽灵事件。事务性发件箱:在同一个本地事务里写业务表 + 写 outbox 表,由独立投递进程轮询 outbox 发出并标记,保证”业务发生了就一定有事件”;代价是至少一次投递,故下游必须幂等。替代方案是 CDC 直读 binlog(Debezium),零侵入但需格式转换且对拓扑变更敏感。
-
层级关系:非阻塞 IO(OS 能力)→ IO 多路复用(epoll/kqueue,单线程监视多 fd)→ Reactor 模式(事件循环 + 分发就绪事件到 Handler)→ 编程模型(回调 → Promise → async/await → 协程/虚拟线程);另有真异步 IO 支线(io_uring/IOCP,内核完成后通知)。Reactor 解决 C10K(少量线程服务海量连接),但不提升单请求处理速度;Node 的文件 IO 实际是 libuv 线程池模拟的异步;Netty 靠 EventLoop 串行化避免锁。协程(Go goroutine + netpoller、Java 虚拟线程)把异步包装成同步写法。Actor vs 消息队列:粒度(实体级可达百万 vs 服务级)、持久化(默认内存 vs 落盘)、能否重放(一般不能 vs 核心能力)、消费者(一对一 vs 多组)、语义(指令给确定一方 vs 事实给不确定多方)、背压(邮箱涨致 OOM vs 堆积落盘)。不该用 MQ 的场景:调用方需要结果才能继续(那是 RPC)、需要强一致且中间态不可见、流量小到不值当(用带 SKIP LOCKED 的数据库表即可)、要求 read-your-writes、团队无监控与运维能力、丢消息代价极高且无对账补偿能力。