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 笔重复入账 + 冲正。根治:去重表与业务同事务提交;死信 + 重放工具常备。

关联