收件箱模式解决什么
消息系统通常只保证“至少一次”投递,重复消息是常态而不是异常。收件箱模式(幂等消费者)的做法是:消费端维护一张已处理消息表,把消息标识的写入与业务变更放在同一个本地事务里,重复投递时因唯一约束冲突而跳过业务处理。这样即使上游重复发送或消费者重启后重读位点,业务效果也不会被重复执行。
基本信息
| 记录时机 | 与业务写入同一事务,保证原子 |
|---|---|
| 判断 | 消息 ID 已存在则跳过,但需更新消费位点 |
| 配套 | 失败重试与死信队列 |
| 表结构 | 消息 ID 唯一约束、消费者标识、处理时间,用于去重与排查 |
典型使用场景
- 支付回调处理:同一笔支付可能收到多次通知,只能入账一次
- 订单状态流转:重复的“支付成功”消息不能把订单推进两次
- 积分与券发放:重复消息会导致多发,必须按消息 ID 去重
- 对账与结算:上游补发历史消息时,已处理部分应被识别并跳过
- 跨系统同步:消费端从多来源收消息,用统一去重表收敛
实施步骤
- 建表:inbox(message_id 去重约束, consumer, processed_at)
- 同事务:业务变更与 inbox 插入在同一事务提交
- 判定:去重约束冲突即已处理,跳过业务并提交消费位点
- 清理:已处理记录按时间归档,控制表体积与查询开销
- 异常:业务失败不写 inbox,交由重试与死信流程处理
- 位点提交:所有处理分支都要提交位点,避免重复投递循环
边界情况
| 跨库场景 | 先写本地 inbox,再以本地事务驱动业务写入,或改用本地消息表方案 |
|---|---|
| 并发注意 | 同一消息并发到达时依赖数据库约束兜底,而非先查后写的应用逻辑 |
| 消息 ID 来源 | 优先使用生产者生成的全局唯一 ID;仅在消费端生成则无法跨实例去重 |
处理流程(伪代码)
处理流程(伪代码)
begin tx
insert into inbox(message_id, consumer) values (?, ?)
-- 唯一约束冲突 → 已处理,直接提交位点并返回
apply_business_change(msg) # 与 inbox 同事务
update_offset(msg.offset)
commit
# 业务异常:整体回滚,不写 inbox,交由重试或死信处理inbox 与 outbox 常成对出现,构成可靠消息链路的两端:生产端用 outbox 保证消息一定发出,消费端用 inbox 保证只生效一次。
去重判定的可靠性来自数据库唯一约束而不是“先查询再插入”:并发场景下先查后写会同时通过检查,必须让唯一约束在提交时兜底。