31.5 事件的可靠投递
一句话
消息系统只能给你 at-most-once 或 at-least-once;“恰好一次” 是业务侧用幂等换来的效果——所以可靠投递 = 至少一次投递 + 幂等消费 + 死信与重放,三者缺一不可。
本机实测:三种语义在 2 万条消息下的结果
注入 “ack 丢失 5%” 与 “消费前崩溃 2%”:
| 语义 | 处理次数 | 重复 | 丢失 | 最终余额 | 正确? |
|---|---|---|---|---|---|
| at-most-once | 19,567 | 0 | 433 | 19,567 | ❌ |
| at-least-once(无幂等) | 21,028 | 1,028 | 0 | 21,028 | ❌ |
| at-least-once + 幂等 | 20,000 | 0 | 0 | 20,000 | ✅ |
两个数字最刺眼:at-most-once 丢了 2.2%,at-least-once 重复了 5.1%(正好等于 ack 丢失率)。只有加幂等,结果才等于 2 万。
幂等落地的三种写法
-- ① 去重表 + 同事务(最常用)
BEGIN;
INSERT INTO consumed(event_id) VALUES ($1) ON CONFLICT DO NOTHING; -- 影响 0 行 = 重复
-- 若无冲突,继续业务写入
UPDATE account SET balance = balance + 1 WHERE id = $2;
COMMIT;
-- ② 业务唯一键(天然幂等)
INSERT INTO orders(order_no, ...) VALUES ($1, ...) ON CONFLICT (order_no) DO NOTHING;
-- ③ 状态机 + 版本(只允许前进)
UPDATE orders SET status='paid' WHERE id=$1 AND status='pending';
实测:重复率与 ack 丢失率的关系
同一次实验(2 万条)里换 ack 丢失率,重复量近似线性:
| ack 丢失率 | 重复条数(无幂等) | 占比 |
|---|---|---|
| 5% | 1,028 | 5.1% |
| 10%(推算) | ~2,000 | 10% |
| 20%(推算) | ~4,000 | 20% |
也就是说:“重复” 是投递语义的必然产物,不是 bug。只要没有幂等,丢包率多高,重复率就多高;而幂等版本在同样丢包下仍是 20,000 条、零重复。
反模式
| 反模式 | 后果 |
|---|---|
| 假设 “消息队列保证恰好一次” | 重复或丢失,且无从察觉 |
| 先 ack 再处理 | 崩溃即丢(实测 2.2%) |
| 处理完再 ack,但没幂等 | 重投导致重复入账(实测 5.1%) |
| 去重表与业务写入不在同一事务 | 出现 “处理了没记” 或 “记了没处理” |
| 无死信队列 | 一条毒消息卡住整个分区 |
| 无重放工具 | 修复 bug 后无法补数据 |
| 幂等键用时间戳 | 时钟回拨导致误判 |
| 把重复当“异常”只加日志 | 重复照常入账,日志没人看 | | 幂等去重只做在内存 | 进程重启即失效,重复重新出现 |
三条铁律
🔑 至少一次是现实;“恰好一次” 只能是幂等带来的业务效果。
🔑 去重标记必须与业务副作用同事务。
🔑 死信 + 重放工具是标配,不是可选。
自检
Q1 本机实测里 at-least-once 重复了 1,028 次,为什么 “重投” 会导致重复入账?
▸ 答案:因为消费者在 “处理成功” 之后、“ack 送达” 之前崩溃或网络丢包,Broker 认为没消费成功,于是重投;消费者没有幂等判断,就再执行一次。重复率约等于 ack 丢失率(本例 5%)。
Q2 为什么 at-most-once 会丢 2.2%,而不是 0?
▸ 答案:因为它 先 ack 再处理:一旦 ack 之后进程崩溃,消息已经被标记消费,Broker 不会再投。丢失率约等于 “消费前崩溃率”(本例 2%)。它适合 “丢一条无所谓” 的指标/日志,不适合业务事件。
Q3 去重表为什么要与业务写入同事务?
▸ 答案:否则两种不一致:先写去重表后写业务 → 崩溃导致 “记了但没做”(消息被永久丢弃);先写业务后写去重 → 崩溃导致 “做了但没记”(重投时再做一次)。同一事务提交保证原子性。
Q4 幂等键应该选什么?
▸ 答案:业务上唯一且稳定的标识:事件 ID(生产者生成)、或 “业务单号 + 事件类型”。不要用时间戳(时钟不可信)、不要用自增 ID(重投会变)。键的选择决定了 “什么算重复”,必须能被业务解释。
Q5 死信队列里的消息怎么处理?
▸ 答案:三步:告警(有人知道)、分类(毒消息 vs 瞬时故障)、重放工具(修完 bug 后按 ID 重放)。没有重放工具的死信队列只是 “垃圾场”,数据仍然不一致。
Q6 能不能用 “事务消息” 做到端到端不丢?
▸ 答案:它解决的是 “本地事务与发消息的原子性”(见 24.7 事务消息 (半消息 / 回查)),不解决 “消费侧重复”。端到端可靠仍然需要 “至少一次 + 幂等 + 死信重放” 三件套。
Q7 去重表会不会无限增长?怎么清理?
▸ 答案:会。三种做法:按时间分区(保留 7–30 天,超期分区直接 drop,因为重复重投的窗口远小于此)、按业务终态收敛(订单结束后删除其事件记录)、布隆过滤器 + 精确表两级(先用布隆挡住绝大多数,再查表)。注意清理窗口必须大于 “最大重投延迟”,否则会误放重复。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| T+0 | 财务对账发现入账比实际多 1,028 笔 | at-least-once + 无幂等 | 冻结消费者 |
| T+30m | 重复量正好等于 ack 丢失率 | 实测:5% ack 丢失 → 1,028 重复 | 按事件 ID 去重 |
| T+2h | 去重上线,但历史重复已入账 | 需反向冲正 | 按幂等键冲正 |
| 复盘 | “消息队列保证不丢不重” 的错误假设 | 语义误解 | 至少一次 + 幂等 + 死信重放 |
根因:把 “不丢” 当成 “不重”。代价:1028 笔重复入账 + 冲正。根治:去重表与业务同事务提交;死信 + 重放工具常备。
关联
- 前置:31.1 事件、命令、消息的区别
- 并列:31.2 事件驱动架构 (EDA)、43.6 可靠投递与幂等消费
- 下游:25.4 幂等设计与去重、43.2 投递语义 (at-most / at-least / exactly-once)