去重表与消费幂等

用数据库唯一约束或去重表记录已处理的消息标识,重复投递时直接跳过;与业务更新放在同一事务中,才能保证“处理+记录”原子。

去重表与消费幂等是什么

消息投递通常是至少一次,重复不可避免,因此消费方必须幂等。最常见的实现是用去重表:把已处理的消息标识写进一张带唯一约束的表,重复投递时唯一键冲突即判定为已处理、直接跳过。关键在于去重记录与业务更新要放在同一个本地事务里,否则中间失败会造成「业务已改但标记没写」或「标记已写但业务没改」,两种情况都会破坏幂等。除消息外,这个模式也适用于回调、重试任务与人工补单。

基本信息

核心机制唯一约束记录已处理标识,重复投递触发冲突即跳过
原子性去重记录与业务更新在同一事务中提交或回滚
去重键业务单号、消息 ID 或幂等键,需全局唯一且稳定
保留窗口按最大重投递延迟设置并定期归档,避免无限增长
替代方案业务表上的幂等键唯一索引;外部系统不支持事务时用状态机前置判断

典型使用场景

  • 消费 MQ 消息:订单支付成功事件重复投递时只处理一次
  • 接收 Webhook 回调:第三方重试导致同一事件多次到达
  • 支付与退款:资金类操作必须防止重复扣款或重复退款
  • 定时任务补偿:任务重启后重跑时按业务单号去重
  • 批量导入:文件重复上传时不产生重复数据
  • 人工修复工具:重投工具多次点击也不会造成重复业务发生

同事务去重示例

同事务去重示例
-- 去重表:msg_id 上建唯一索引
CREATE TABLE msg_dedupe (
  msg_id      VARCHAR(64) PRIMARY KEY,   -- 消息唯一标识
  created_at  DATETIME NOT NULL,         -- 首次处理时间,用于归档
  biz_no      VARCHAR(64) NOT NULL       -- 业务单号,便于排查
);

BEGIN;
  -- 唯一键冲突则回滚,视为已处理,直接返回成功
  INSERT INTO msg_dedupe(msg_id, created_at, biz_no) VALUES (?, NOW(), ?);
  UPDATE account SET balance = balance - 100 WHERE id = ?;
COMMIT;   -- 两步必须同一事务,否则幂等仍会被破坏

设计检查

检查项要求缺失后果
事务范围去重记录与业务更新同事务重复处理或漏处理,幂等失效
唯一约束数据库中真实存在的唯一索引并发插入两条记录,去重形同虚设
保留窗口覆盖最大重投递延迟并定期归档窗口过短漏拦,过长则表无限膨胀
并发控制依赖数据库唯一约束而非先查后插并发下先查后插仍会重复处理
观测统计拦截次数与唯一键冲突率重复投递量突增无人发现
去重表要设置保留窗口并定期归档:窗口过短会漏拦延迟重投的消息,窗口过长则存储与索引成本持续上升,需要按实际重投递延迟来定。
并发安全靠数据库唯一约束而不是先查后插:先查后插在并发下仍有竞态窗口,只有唯一索引能真正兜住重复。

易错点

  • 去重表与业务更新不在同一事务:仍会出现重复处理或漏处理
  • 去重窗口过短:延迟重投递的消息未被拦截,重复业务发生
  • 先查后插代替唯一约束:并发场景下两个请求同时通过检查
  • 去重键不稳定:上游重试时换了消息标识,同一事件被判为新消息
  • 去重表长期不清理:单表膨胀到影响写入性能,归档与索引维护成本上升

数据来源与参考

  • Chris Richardson《Microservices Patterns》
  • Apache Kafka 官方文档:消息投递语义
​‌‌​​‌​​​‌‌​‌‌‌‌​‌‌​​​‌‌​‌‌‌​​‌‌​​‌​‌‌‌​​‌‌‌​​​‌​‌‌​‌​​‌​‌‌‌​​​‌​‌‌​‌​​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌‌‌‌​​‌​‌‌‌​​‌‌​​​‌‌​‌‌​‌‌‌‌​‌‌​‌‌​‌