假设你经营一家社区快递驿站。同一天,同一个包裹被快递小哥连着送了三回:第一次是网络没确认,他以为你没收到;第二次是系统自动重试;第三次换了个同事又送了一遍。如果你只看包裹外观,就会签收三件一模一样的货。消息系统里也有同样的事:生产者发出消息,中转站和消费者各自重试,同一个业务事件可能被重复投递。消息幂等与去重,就是给驿站设计一本“签收台账”,让同一个消息无论来多少遍,业务只生效一次。这篇文章会介绍三种台账方案:消息自带运单号、数据库签收表、Redis 高速盖章机,以及它们各自的边界与代价。

岗位关联

学会消息幂等与去重,你就能在高并发、高可用的异步链路里避免重复扣款、重复发券等生产事故,这是架构师做消息系统设计的基本功。

目录
  1. 可视化演示
  2. 重复消息成因
  3. 幂等核心概念
  4. 生产者序列号
  5. 消费端去重表
  6. Redis去重方案
  7. 边界与权衡
  8. 生产实践复盘
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

消息幂等与去重保障消费者在网络重试或消息重复投递时不会产生重复副作用。介绍生产者序列号、消费端去重表与Redi... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

消息幂等与去:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

重复消息成因

快递驿站为什么总是收到重复件?不是快递小哥手滑,而是整个配送链路都抱着“宁可多送,不可漏送”的信念。网络超时了,重试;签收确认丢了,重试;送完一单发现电脑没记录,再补送一单。消息系统也一样,重复不是偶然事故,而是“至少一次投递”语义下的默认行为。

在消息队列中,生产者发送消息到中转站(Broker),如果超时没有收到确认,会重新发送;中转站把消息交给消费者后,如果消费者没有及时返回确认,中转站会再次投递;消费者处理完业务、还没提交消费位置时就重启了,重启后会从上次位置重新拉取。于是同一条消息可以走三条“回头路”,从不同环节再次出现在你面前。

三次重试不是简单的三选一,而是可能叠加发生。假设网络重试概率是 \(p_1\),中转站重投概率是 \(p_2\),消费者重拉概率是 \(p_3\),那么消息至少被重复投递一次的概率如下:

所以,聪明的架构师不会天真地追求“杜绝重复”,而是让下游消费者在重复到来时“纹丝不动”。正如驿站即使收到三件同样的包裹,也只签收一次、入库一次。

P(重复)=1−∏i=13(1−pi)P(\text{重复}) = 1 - \prod_{i=1}^{3}(1 - p_i)
  1. 定义各环节概率用 \(p_1\)、\(p_2\)、\(p_3\) 分别表示网络、中转站、消费者三个环节触发重试的概率。
    p1,p2,p3p_1, p_2, p_3
  2. 计算不重复的概率只有三个环节都不触发重试,消息才可能只被投递一次。
    ∏i=13(1−pi)\prod_{i=1}^{3}(1 - p_i)
  3. 用 1 减去不重复概率得到至少发生一次重复投递的概率,工程上几乎不可能完全消除。
    1−∏i=13(1−pi)1 - \prod_{i=1}^{3}(1 - p_i)

幂等核心概念

驿站签收台有一个朴素原则:签收章盖一次,和盖一百次,库存系统里都只有一行“已签收”。这就是幂等。数学上也是如此:一个操作执行若干次,最终结果和执行一次完全相同。

消息消费中的幂等,就是重复消息不会产生重复副作用。比如支付回调只入账一次,扣减库存只扣一次,发放优惠券只发一张。反观“查询订单状态”天然幂等,查多少次答案都一样;但“给用户余额加 100 元”不是幂等,加两次就是 200 元。所以不能把所有消息都同等对待,必须识别哪些操作存在副作用。

幂等的目标不是让代码毫无变化,而是让系统状态收敛到同一个最终结果。用函数表达就是:

回到驿站:在包裹上盖一个“已签收”章是幂等的,因为章印不会叠加成两个;但拆开包裹取出商品的动作不幂等,拆两次就会多拿一件。因此,驿站必须用台账把“已签收”这件事记录下来,让拆包动作只发生一次。

f(f(x))=f(x)f(f(x)) = f(x)
  1. 定义一次操作\(f(x)\) 表示对状态 \(x\) 执行一次业务操作,例如“给订单标记已支付”。
    f(x)f(x)
  2. 执行第二次消息重复后,又对同样的状态执行一次同样的操作。
    f(f(x))f(f(x))
  3. 结果与一次相同重复执行后仍然等于执行一次的结果,这就是幂等的本质。
    f(f(x))=f(x)f(f(x)) = f(x)

生产者序列号

驿站每天收到几百个包裹,怎么区分“同一个包裹”和“两个长得一样的包裹”?答案是运单号。每一个包裹在发货时就印上一个唯一编号,后续无论重试、补送还是换人送,运单号都不会变。消息系统里的“运单号”就是生产者序列号。

生产者在发送消息前,会在消息头或消息体内放入一个全局唯一 ID。最简单的做法是“业务主键 + 事件类型 + 自增序号”,例如订单号 20260612001、事件类型 CREATE、序号 01,拼起来就是 20260612001-CREATE-01。也可以使用 UUID、雪花算法等全局 ID 生成器。重点是:这个 ID 标识的是“同一次业务事件”,而不是“同一条网络包”。

消费者拿到消息后,第一件事就是取出这个序列号,作为去重判定的钥匙。如果没有这把钥匙,后面所有去重方案都无法落地。构造序列号的方式可以写成:

运单号解决的是“身份问题”。驿站可以不知道包裹里装的是什么,但只要运单号相同,就必须按同一个包裹处理。生产者序列号是整套幂等体系的起点,也是后续去重表的索引钥匙。

msgId=orderId∥eventType∥seqNo\text{msgId} = \text{orderId} \parallel \text{eventType} \parallel \text{seqNo}
  1. 选取业务主键orderId 唯一标识一笔订单或一次业务行为。
    orderId\text{orderId}
  2. 区分事件类型eventType 告诉下游这是一次创建、取消还是扣减操作。
    eventType\text{eventType}
  3. 补充自增序号seqNo 避免同一业务下多次同类型事件互相混淆。
    seqNo\text{seqNo}
  4. 拼接生成消息 ID用分段连接符拼起来,形成全局唯一序列号。
    orderId∥eventType∥seqNo\text{orderId} \parallel \text{eventType} \parallel \text{seqNo}

消费端去重表

驿站门口放着一本厚厚的签收台账,每收到一个包裹,管理员先翻一翻:运单号已经登记过,就签收栏画个勾并请快递员把货带走;没登记过,才写一行新记录。这个台账在消息系统里就是一张“消费端去重表”。

去重表的结构很简单:以 msg_id 作为主键或唯一索引,再加一个写入时间。消费者收到消息后,先尝试插入去重表。如果插入成功,说明这是第一次见到这条消息,可以继续执行业务;如果插入时发生唯一键冲突,说明之前已经处理过,直接返回成功即可。

这里最关键的一点是:去重操作和业务操作必须在同一个数据库事务里。如果先写去重表、再写业务表,去重表提交了但业务失败,重试时就会被错误地当作“已处理”跳过。正确的做法是:开启事务 → 插入去重记录 → 执行业务写入 → 一起提交;任何一步失败则一起回滚。结果可以表达为:

有了这本台账,重复包裹即使来一百次,也永远只会在第一行记录里被“签收”一次。这就是消费端去重表能在生产环境中直接消灭重复扣款的原因。

结果={已处理,插入去重表成功重复,唯一键冲突\text{结果} = \begin{cases} \text{已处理}, & \text{插入去重表成功} \\ \text{重复}, & \text{唯一键冲突} \end{cases}
  1. 尝试插入去重表业务执行前,先将消息 ID 写入去重表。
    INSERT INTO dedup(msg_id) VALUES(:msgId)\text{INSERT INTO dedup(msg\_id) VALUES(:msgId)}
  2. 遇到唯一键冲突主键约束保证同一消息 ID 只能成功插入一次。
    Conflict⇒重复消息\text{Conflict} \Rightarrow \text{重复消息}
  3. 同事务写业务表插入成功后继续处理业务,全部失败则一起回滚。
    Commit=dedup∪business\text{Commit} = \text{dedup} \cup \text{business}

Redis去重方案

生意变好后,驿站门口排起了长队。每一单都去数据库翻台账,翻一次就要几十毫秒,高峰期根本扛不住。于是管理员在大门口加了一台“高速盖章机”:机器只有一个章,谁先来谁先盖,后到的包裹发现章已经盖过,直接拒收。这台机器就是 Redis 的 SETNX 命令。

SETNX 的意思是“只有当 key 不存在时才能设置成功”。第一个消费者执行 SET dedup:msgId 1 NX 会返回成功,第二个消费者再用同一个 key 执行就会被拒绝。在 Redis 中,这个操作是原子性的,多个并发消费者同时到达,也只有一个能成功。如果担心 key 永远占用内存,可以加上 EX ttl 设置过期时间。于是完整的去重命令变成:

高速盖章机虽然快,但也不是万能的。Redis 重启或 key 过期后,同一消息可能再次通过。所以在资金安全等强一致场景,通常用 Redis 做短时间高频去重,再用数据库去重表做最终兜底,形成“双保险”。

acquire=SET dedup:msgId v NX EX t\text{acquire} = \text{SET}\ \text{dedup:msgId}\ v\ \text{NX}\ \text{EX}\ t
  1. NX 条件写入只有 key 不存在时才写入,确保只有一个消费者能拿到“首次处理权”。
    NX⇒仅创建成功一次\text{NX} \Rightarrow \text{仅创建成功一次}
  2. EX 设置过期时间给 key 设定存活时间,超时后自动删除,避免内存无限增长。
    EX t⇒TTL 窗口\text{EX}\ t \Rightarrow \text{TTL 窗口}
  3. 用返回值判断结果返回 OK 表示首次,返回空表示 key 已存在,消息应被拦截。
    acquire∈{1,0}\text{acquire} \in \{1, 0\}

边界与权衡

选择哪种去重方案,就像选择驿站的规模:小驿站一个手写本就能记账,大型转运中心必须上高速盖章机,但机器贵、会断电、也会丢数据。没有绝对最优方案,只有针对不同业务诉求的取舍。

三种方案在三个维度上差异明显。生产者序列号是“免费的前提”,只负责给消息一个身份,不承担拦截职责;消费端去重表拥有最强一致性,能把去重和业务绑在同一个事务里,但每来一条消息就多一次数据库写入,高并发下成本高;Redis 去重延迟低、吞吐高,但受限于 TTL 窗口,且不具备事务回滚能力。

去重的边界也随之变化。数据库去重表可以做到永久精确,适合支付、订单等核心链路;Redis 去重窗口通常只覆盖秒级到天级,适合验证码、限流、活动发券等短时间重复攻击。在消息语义上,绝大多数系统接受“至少一次投递 + 幂等消费”的组合,从效果上无限逼近“恰好一次处理”。整体成本可以用读写开销的叠加来评估:

所以,面对“重复消息”这道题,真正要回答的不是“哪个方案最好”,而是“业务能为重复窗口忍受多大的代价”。愿意多花数据库成本,就换来永久精确;追求极致性能,就必须接受 Redis 的短窗口和弱持久化。

T=Tquery+Twrite+TcommitT = T_{\text{query}} + T_{\text{write}} + T_{\text{commit}}
  1. 查询开销消费者必须先去重,产生一次查询。
    TqueryT_{\text{query}}
  2. 写入开销去重表插入或 Redis 写入,产生一次写放大。
    TwriteT_{\text{write}}
  3. 提交开销事务提交或网络往返,高并发下往往成为瓶颈。
    TcommitT_{\text{commit}}

生产实践复盘

纸上谈兵再多,不如看一次真实事故。某支付系统曾出现用户被重复扣款,监控显示同一笔支付回调被消费了两次。查到最后,根因是消费者把支付结果写入业务库后,还没来得及提交消费位置就宕机重启了,消息队列重新投递了同一回调;更糟的是,当时去重表与业务表不在同一事务里,去重记录先提交,业务更新却失败了,后续重试全部被当作“已处理”跳过。

修复方案分三步:第一,为支付回调生成全局唯一的 msg_id,由“交易流水号 + 回调事件类型”组成;第二,将去重表和支付入账写入同一个数据库事务,任何一步失败都整体回滚;第三,在监控大盘中加入“去重拦截率”和“重复消息 TOP 来源”两个指标,实时发现新的重复链路。

重复率是衡量幂等效果的关键指标,定义如下:

回到驿站视角:签收台账记得越清晰,越不会在年底盘库时发现多了一堆“不存在”的货。幂等设计不是上线后就一劳永逸,而是要像驿站管理员一样,每天看一遍拦截记录,及时发现异常重试,保证每一件“消息包裹”都只真正入一次库。

重复率=NduplicateNtotal×100%\text{重复率} = \frac{N_{\text{duplicate}}}{N_{\text{total}}} \times 100\%
  1. 统计总消息数统计一段时间内消费者收到的全部消息。
    NtotalN_{\text{total}}
  2. 统计重复拦截数统计被去重表或 Redis 判定为重复的消息数量。
    NduplicateN_{\text{duplicate}}
  3. 计算重复占比比值越高,说明链路中重试越频繁,幂等保护越需要加强。
    NduplicateNtotal×100%\frac{N_{\text{duplicate}}}{N_{\text{total}}} \times 100\%

AI 应用场景

支付回调幂等处理

阶段:数据接入与事件驱动

解决的问题:支付平台会因网络原因对同一笔支付结果发起多次回调。如果没有幂等,用户可能被重复扣款或订单重复入账。传统做法是人工核对账单,效率低且滞后。通过生产者序列号和去重表,将每次回调都视为同一个“运单号”,第一次进入业务,后续全部直接拦截。

具体案例:用户使用支付宝付款,支付回调在 5 秒内被推送了 3 次。支付服务根据 transactionId + PAY_SUCCESS 生成 msgId,插入去重表成功才更新订单状态;后两次回调因为唯一键冲突被幂等消费,最终只产生一笔入账。

异步库存扣减

阶段:异步任务消费

解决的问题:订单创建后通过 MQ 异步扣减库存,消费端崩溃或重启会造成同一订单重复扣减。传统做法是把库存扣减做成“剩余数量递减”,重复执行会超卖。利用 Redis SETNX 给订单 ID 加短窗口去重,扣减动作只执行一次,超时后由数据库兜底。

具体案例:大促期间下单量激增,库存扣减消息被重复投递。消费者先 SETNX order:10086:stock 1 NX EX 60,只有拿到锁的线程执行库存表更新,其余线程直接返回。由于去重窗口只有 60 秒,跨分钟重试的极端情况再由数据库唯一订单号兜底。

优惠券发放防重

阶段:营销活动消息推送

解决的问题:营销活动会向用户批量推送发券消息,同一个用户可能收到重复券。传统方案依赖前端按钮置灰,无法防止后端重放。使用 Redis 以 userId + activityId 为 key 做去重,再用数据库唯一索引保证长期有效。

具体案例:某平台上线“新人立减券”,消息处理器在用户维度做 SETNX 去重,同一用户 24 小时内只能领取一次。Redis 挡住高频重复,每日定时任务清理过期 key,同时在发券表中设置唯一联合索引作为最终防线。

Python 演示

python41 行
import sqlite3import time class IdempotentConsumer:    def __init__(self, db_path=":memory:"):        self.conn = sqlite3.connect(db_path)        self.conn.execute("CREATE TABLE IF NOT EXISTS dedup(msg_id TEXT PRIMARY KEY, created_at TEXT)")        self.conn.execute("CREATE TABLE IF NOT EXISTS orders(order_id TEXT PRIMARY KEY, payload TEXT)")        self.conn.commit()     def consume(self, msg):        try:            self.conn.execute("BEGIN IMMEDIATE")            cur = self.conn.execute(                "INSERT INTO dedup(msg_id, created_at) VALUES(?, ?)",                (msg["msg_id"], msg["created_at"]),            )        except sqlite3.IntegrityError:            self.conn.rollback()            return False        if cur.rowcount != 1:            self.conn.rollback()            return False        self.conn.execute(            "INSERT INTO orders(order_id, payload) VALUES(?, ?)",            (msg["order_id"], msg["payload"]),        )        self.conn.commit()        return True now = time.strftime("%Y-%m-%d %H:%M:%S")messages = [    {"msg_id": "20260612-1001-CREATE-01", "order_id": "1001", "payload": "create_order", "created_at": now},    {"msg_id": "20260612-1001-CREATE-01", "order_id": "1001", "payload": "create_order", "created_at": now},    {"msg_id": "20260612-1002-DEDUCT-01", "order_id": "1002", "payload": "deduct_stock", "created_at": now},] consumer = IdempotentConsumer()for msg in messages:    ok = consumer.consume(msg)    print(f"{msg['msg_id']} -> {'处理成功' if ok else '重复消息,已拦截'}")
目的:用内存 SQLite 模拟“去重表 + 业务表同事务”的幂等消费,重复消息会被唯一键冲突拦截。
  1. CREATE TABLE IF NOT EXISTS dedup(msg_id TEXT PRIMARY KEY用 msg_id 主键作为去重表的唯一约束。
  2. self.conn.execute("BEGIN IMMEDIATE")立即开启写事务,保证去重和业务在同一个原子单元内。
  3. INSERT INTO dedup(msg_id, created_at) VALUES(?, ?)先把消息 ID 写入去重表,判断是否第一次出现。
  4. except sqlite3.IntegrityError:主键冲突说明之前已处理过,直接回滚并返回 False。
  5. self.conn.execute("INSERT INTO orders(...)")去重成功后才执行业务写入,双方一起提交。

常见误区

误区Redis SETNX 是原子操作,消息重复来时一定被拦住,所以只要用了它,幂等就彻底解决了。

正解Redis 是内存数据库,可能宕机丢数据;key 设置了过期时间后,重复消息一旦在过期之后重试,就会穿透去重层再次进入业务。正确做法是用 Redis 处理高频短窗口,资金相关场景再用数据库去重表兜底。

为何常见:很多初学者看到 SETNX 的“原子性”和“性能高”就误以为它是银弹,忽略了持久化和过期窗口。

误区只要生产者往消息里塞了一个唯一 ID,消费端就一定不会重复处理了。

正解唯一 ID 只是去重的前提,如果消费端没有在业务处理前校验并记录这个 ID,重复消息依然是重复消息。好比包裹上有运单号,但驿站不翻台账,照样会把同一个包裹签收两次。

为何常见:把“数据完备”和“逻辑完备”混为一谈,以为身份标识天然带来防护。

误区先写去重表,如果业务失败,大不了下次重试时跳过业务,不会有大问题。

正解如果去重表和业务不在同一事务,可能出现“去重记录已提交、业务却失败”的状态。后续重试消息来了会被误判为已处理,导致人工补单甚至数据丢失。必须让两者在同一事务中同生共死。

为何常见:事务和消息是两套抽象,初学者容易把它们拆成两步看着顺畅,却忽略了崩溃时的中间态。

误区只要消费者代码写得足够好,不重复消费消息,就不需要做幂等设计。

正解消费者无法完全控制消息队列的投递行为。网络超时、Broker 重投、消费者重启后的 offset 回退都是上游不可控因素。与其祈祷不重复,不如默认重复必然发生,在下游做好幂等防御。

为何常见:人们习惯从“自己写对”的角度思考问题,忽略了分布式环境下不可控的第三方组件行为。

误区Redis 给去重 key 设置的过期时间越长,能防住的重复越多,所以越长越好。

正解过期时间过长会占用大量内存,并让真正应该重新处理的业务消息被错误拦截;过短又无法覆盖重试周期。正确做法是让 TTL 略大于消息最大重试间隔,同时用数据库长期去重表承接更长的历史窗口。

为何常见:人倾向于“多多益善”,但分布式系统里每个资原选择都有隐藏成本。

面试题

Q1. 下列哪个场景最容易产生重复副作用?

单选题 · easy

  • A.用户列表查询
  • B.验证码短信发送← 正确答案
  • C.订单状态查询
  • D.日志文件读取

答案:B。验证码短信发送会向外呼出短信,重复发送会产生费用和骚扰;查询和读日志天然幂等,不产生写入副作用。幂等设计要优先关注有外部副作用或写操作的场景。

Q2. 消息重复投递最常见的原因是?

单选题 · easy

  • A.网络超时自动重试← 正确答案
  • B.CPU 使用率过高
  • C.数据库字段过少
  • D.消费代码过于复杂

答案:A。消息系统为了保证不丢消息,通常采用“至少一次投递”语义,网络超时后生产者或 Broker 会自动重试,这是重复投递的主要来源。CPU、数据库字段和代码复杂度不直接产生重复消息。

Q3. 生产者序列号的核心作用是什么?

单选题 · mid

  • A.提供全局唯一编号← 正确答案
  • B.提高消费并发度
  • C.压缩消息体积
  • D.加密消息内容

答案:A。生产者序列号相当于快递运单号,为同一次业务事件提供全局唯一 ID,消费者才能据此判断消息是否处理过。压缩和加密是消息传输的另一个话题,不解决重复问题。

Q4. Redis SETNX 能低成本去重的根本原因是?

单选题 · mid

  • A.单线程原子执行← 正确答案
  • B.依赖事务回滚
  • C.通过消费者回执确认
  • D.通过磁盘快照恢复

答案:A。Redis 以单线程方式执行命令,SETNX 只有在 key 不存在时才会写入成功,天然具备原子性。并发请求同时到达时只有一个能成功,这正是去重所需的“仅一次授予”语义。

Q5. 消费端去重表设计的关键约束是?

单选题 · mid

  • A.放在同一个事务中← 正确答案
  • B.放在不同服务器上
  • C.使用异步分库执行
  • D.只在内存中判断

答案:A。去重表和业务表必须处于同一个数据库事务,才能保证“去重成功但业务失败”的情况不会留下错误标记。如果拆开,崩溃恢复后会出现虚假已处理状态。

Q6. 消费者重启后重复消费,哪类方案可以可靠防止重复副作用?

单选题 · hard

  • A.去重表与业务同事务← 正确答案
  • B.重启前清空积压消息
  • C.调大消费线程数量
  • D.无限延长重试间隔

答案:A。重启触发 offset 回退,消息会被重新拉取,只有下游在业务提交前做持久化去重才能兜住。清空积压会丢消息,调大线程和延长重试都不改变重复投递事实。

Q7. 下列哪种情况会导致 Redis 短窗口去重失效?

单选题 · hard

  • A.去重键过期后重试← 正确答案
  • B.消息体使用 JSON 格式
  • C.消费者客户端未启动
  • D.消费逻辑包含注释

答案:A。Redis key 过期后会被删除,此时同一条消息再次重试,SETNX 会把它当成新消息放行。消息格式、客户端未启动和注释都不影响去重算法的可用性。

Q8. 哪些属于消息重复带来的典型副作用?

多选题 · easy

  • A.用户余额被重复扣减← 正确答案
  • B.优惠券被重复发放← 正确答案
  • C.订单状态被重复查询
  • D.监控指标被重复累积← 正确答案

答案:ABD。重复扣减余额、重复发券、重复累积指标都会产生错误的结果数据;查询订单状态不发生写入,属于安全操作,通常不需要幂等保护。

Q9. 下列哪些是正确幂等设计的一部分?

多选题 · mid

  • A.使用全局唯一消息编号← 正确答案
  • B.消费前查询去重记录← 正确答案
  • C.去重记录独立于业务提交
  • D.设置合理去重时间窗口← 正确答案

答案:ABD。全局唯一编号是身份前提,消费前查去重表可以提前拦截,合理时间窗口能控制成本和覆盖范围。去重记录如果独立于业务事务提交,会在业务失败时留下错误标记。

Q10. 生产上可组合使用的可靠去重手段有哪些?

多选题 · extreme

  • A.使用数据库唯一索引← 正确答案
  • B.使用 Redis SETNX← 正确答案
  • C.打印完整业务日志
  • D.消息 ID 只存内存

答案:AB。数据库唯一索引提供持久化精确去重,Redis SETNX 提供高性能短窗口去重,两者可以叠加。打印日志只是排查手段,消息 ID 只存内存会在进程重启后丢失,无法支撑可靠幂等。