2022 年我给一个电商订单系统接消息队列:用户下单后,扣库存、加积分、发通知短信这些后续动作,全部丢进 MQ 异步去做。怎么消费这些消息?这件事我压根没多想。第一版我做得很顺手:消费者从队列里取一条消息,该扣库存扣库存、该加积分加积分,处理完了 ack 确认一下。就完事了。本地拿一批消息一测——真不错:下一单、消费一条、库存减一、积分加上,分毫不差。我心里很笃定:"消息队列嘛,我发一条、消费者收一条、处理一条,一条消息对应一次处理,天经地义。MQ 这种成熟中间件,难道还会把同一条消息投递两次?"可等这套消费逻辑真正上线、扛起生产的真实流量,一串问题冒了出来。第一种最先把我打懵:有用户反馈,一笔订单的积分被加了两次、甚至三次;对账时也发现,某些商品的库存被多扣了。第二种最难缠:用户投诉,同一条订单通知短信,他收到了好几条。第三种最头疼:每次消费者服务重启、或者发一次版本,事后总会冒出来一批"重复处理"的脏数据。第四种最莫名其妙:消费者明明把消息处理成功了,日志都打了"处理完成",可这条消息过一会儿又被投递了一次。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"MQ 会保证一条消息只被消费一次"。这句话把"消息只投递一次"当成了消息队列天然提供的保证。可它根本不是。我脑子里,消息队列是一根可靠的传送带:我放一个包裹上去,它精确地、不多不少地,送到对面一次。可现实里的消息队列,绝大多数提供的语义叫 at-least-once——至少一次,也就是说,它向你保证的是"消息不会丢",而代价是"消息可能重复"。为什么会这样?因为消费者处理完消息后,要回一个 ack 告诉 MQ"这条我处理好了,可以删了"。可这个 ack 本身要走网络,它可能丢:消费者真的处理完了、ack 也发了,但 ack 在网络上丢了,MQ 没收到,它只能认为"这条还没被处理",于是过一会儿又投一次。消费者重启也一样:消息处理到一半、还没来得及 ack,进程就被重启了,MQ 等不到 ack,重投。MQ 站在它的角度,这么做完全正确——它的首要职责是"绝不丢消息",在"可能丢"和"可能重复"之间,它只能选择可能重复,因为重复还有救(你可以去重),丢了就真没了。所以重复投递不是 MQ 的 bug,是 at-least-once 这个语义的必然产物,是它为了不丢消息而付出的、写在设计里的代价。真正的 exactly-once,根本不是靠 MQ 单方面就能实现的,它必须靠消费端自己做文章:让同一条消息,哪怕被投递了十次,处理的效果也和只处理一次完全一样。这个性质,叫幂等。真正把消息消费做扎实,核心不是"指望 MQ 不重复",而是认清几乎所有 MQ 都只保证 at-least-once、重复投递是必然,认清 exactly-once 的效果要靠消费端的幂等来兜底,学会用唯一 ID 加去重表、用业务状态机加乐观锁这两类手段实现幂等,知道生产端也可能重复发、要做端到端的防重,并把重复率这些信号接进监控。这篇文章就把消息队列幂等这个坑梳理一遍:为什么消息一定会被重复消费、幂等到底是什么、怎么用唯一 ID 加去重表实现幂等、怎么用业务状态机实现幂等、生产端的重复怎么防,以及一些把幂等做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为"消息队列"这个名字和它的 API 太有迷惑性了——你 send 一条、对面 receive 一条,这套对称的接口,让人天然以为"发一条 = 收一条"。它错得隐蔽,是因为绝大多数时候消息确实只来一次:网络正常、消费者不重启的时候,重复投递根本不发生,你开发、测试、甚至上线初期跑很久,都看不到一条重复消息,一切都对。它只在网络抖动、消费者重启发版、MQ 自身故障转移这些时刻才暴露——ack 丢了、消息处理到一半进程没了,MQ 就会重投,而那一刻,你那套没有幂等的消费逻辑,就会实实在在地把积分加两次、把库存多扣。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:积分被加两三次、库存被多扣;同一条短信收到好几条;消费者重启发版后冒出一批重复脏数据;消息处理成功了又被投一次。
- 错误认知一:以为 MQ 保证消息只投递一次。真相是绝大多数 MQ 只保证 at-least-once,重复投递是它为了不丢消息的必然代价。
- 错误认知二:以为 exactly-once 是 MQ 该提供的。真相是 exactly-once 的效果必须靠消费端的幂等来实现,MQ 单方面做不到。
- 错误认知三:以为只要消费端幂等就够了。真相是生产端也可能重复发同一条消息,防重要做到端到端。
- 真相:重复消费是必然,不能消灭;正解是让消费操作幂等——同一条消息处理一次和处理十次,结果完全相同。
一、为什么消息一定会被重复消费
先把第一版那个消费逻辑摆出来。它就是字面意思——取一条消息,直接执行业务动作:
# 第一版:直接消费,没有任何防重(反面教材)
def on_message(msg):
order = parse(msg.body)
# 收到一条"订单已支付"消息,就直接执行后续动作
add_points(order.user_id, order.amount) # 给用户加积分
deduct_stock(order.product_id, order.quantity) # 扣库存
send_sms(order.user_id, "您的订单已支付") # 发通知短信
msg.ack() # 告诉 MQ:这条处理好了
# 正常情况下,这段代码工作得很好。
# 但只要这条消息被 MQ 重投一次(ack 丢了 / 消费者重启了),
# add_points 就会再加一次积分、deduct_stock 再扣一次库存、
# send_sms 再发一条短信 —— 同一笔订单,效果被叠加了
这段代码没有任何语法错误,正常情况下它工作得很好。它唯一的问题是默认了"每条消息只来一次"。可消息队列的真实语义是 at-least-once——至少一次。要理解为什么"至少一次"必然意味着"可能多次",得看清消息从投递到确认的完整链路。关键在那个 ack:消费者处理完消息,要回一个 ack 给 MQ,MQ 收到 ack 才会把这条消息删掉。而 ack 走的是网络,网络不可靠,这中间任何一环出岔子,MQ 都会选择重投。把重复投递的几种来源画出来:
[mermaid]
flowchart TD
A[MQ 投递一条消息给消费者] --> B[消费者执行业务逻辑]
B --> C{业务处理完成 准备回 ack}
C -->|ack 在网络上丢了| D[MQ 收不到 ack]
C -->|消费者处理到一半就重启| E[MQ 收不到 ack]
C -->|MQ 故障转移 ack 记录丢失| F[MQ 收不到 ack]
D --> G[MQ 认为这条没处理 重新投递]
E --> G
F --> G
G --> H[同一条消息被消费第二次]
看懂这张图,那个"消息处理成功了又被投一次"的怪现象就有了答案:消费者确实处理成功了,但 ack 丢在了网络上,MQ 站在它的角度,只看到"投出去的消息没收到确认",它无法区分到底是"消费者没处理"还是"处理了但 ack 丢了"。在这个无法区分的局面下,MQ 的唯一正确选择就是重投——因为它的头号职责是绝不丢消息。所以重复投递不是故障,是 at-least-once 语义下完全正常的、预期之内的行为。
这里要建立的第一个、也是最重要的认知是:在一个由网络连接起来的分布式系统里,你必须接受一个残酷但根本的事实——通信的双方,永远无法对"某件事到底有没有发生"达成 100% 的确定。消费者这边千真万确处理成功了,可它没法让 MQ 那边也确信这一点,因为传递"我成功了"这个信息的渠道(ack)本身就不可靠。MQ 收不到 ack 时,它面对的是一个根本性的、无法消除的不确定:消费者可能没收到消息、可能收到了没处理完、可能处理完了 ack 丢了——这三种情况,MQ 从它能观测到的信息里,绝对区分不开。这就是分布式系统里著名的"两将军问题"的现实投影:仅靠不可靠的信道,两方永远无法保证对一件事达成共识。想明白这一点,你对待分布式系统的整个心态都会变:你不能再追求"消除不确定性"——那是做不到的;你能做的,是设计出一套"在不确定性存在的前提下,依然能得到正确结果"的机制。MQ 选择了"宁可重投也不丢",这是它在不确定性下的应对;而你要做的,是让你的消费逻辑能扛住重投。这个思维方式适用于一切分布式交互:调一个远程接口超时了,你不知道对方到底执行没执行,所以远程接口要能安全重试(也就是幂等);转账请求发出去没收到响应,你不知道钱划没划走,所以要有对账和补偿。它们的内核完全一致——承认"我无法确知对面的状态",然后把系统设计成"无论对面处于哪种可能的状态,我重试、我兜底,最终都能收敛到正确"。不和不确定性较劲,而是设计出与它共存的机制,这是分布式系统工程师和单机程序员之间,最深的一道分水岭。
二、幂等的本质:重复执行,结果不变
既然重复消费消灭不了,那唯一的出路就是:让重复消费不产生坏的后果。这个性质有个专门的名字——幂等(idempotent)。一个操作是幂等的,意思是:执行它一次,和执行它很多次,对系统状态产生的最终效果完全一样。先把哪些操作天然幂等、哪些天然不幂等分清楚:
哪些操作天然幂等,哪些天然不幂等:
天然幂等的操作(重复执行无害):
把订单状态设为"已支付" 执行 N 次,状态都是"已支付"
把某个 key 的值设为 100 执行 N 次,值都是 100
删除 id=5 的记录 执行 N 次,记录都是"不存在"
查询(只读,不改状态) 执行 N 次,什么都没改
天然不幂等的操作(重复执行会出错):
给积分 +10 执行 N 次,积分多加了 (N-1)*10
库存 -1 执行 N 次,库存多扣了 N-1
往表里 INSERT 一行 执行 N 次,插入了 N 行重复数据
发一条短信 执行 N 次,用户收到 N 条
规律:基于"绝对值赋值"的操作,天然幂等;
基于"相对增量 / 追加"的操作,天然不幂等 —— 这类必须额外做防重
第一版的三个动作——加积分、扣库存、发短信——全是"相对增量"或"追加"型的,全都天然不幂等。这就是它一重复就出错的根源。让一个不幂等的操作变得幂等,核心思路只有一个:在执行业务动作之前,先判断"这件事是不是已经做过了",如果做过了,就直接跳过。剩下的全部工作,都是在回答"怎么可靠地判断它做过没做过"。后面两节讲的两类方案,就是两种不同的判断方式。
这里要建立的认知是:幂等这个概念,真正教给你的是一种极其宝贵的系统设计思维——把"操作"设计成对"目标状态"的声明,而不是对"变化过程"的描述。一个不幂等的操作,比如"积分 +10",它描述的是一个"变化(delta)":它没有说积分该是多少,它只说"在原来的基础上变化 +10"。这种"基于变化"的操作,天生就依赖"我现在被执行了几次"——执行一次变化一次,所以它无法重复。而一个幂等的操作,比如"把积分设为 110",它描述的是一个"目标状态":它根本不关心现在是多少、之前执行过几次,它只声明"结束后,积分应该是 110"。这种"基于状态声明"的操作,执行多少次结果都一样,因为它每次都把系统朝同一个确定的终点推。这个"声明终态、而非描述过程"的思想,威力大到超乎想象,它是现代系统设计里一条隐秘的主线:Kubernetes 的精髓就是声明式——你不告诉它"启动 3 个 Pod",你告诉它"我要有 3 个 Pod 在运行",于是这个指令天然可以反复执行、自动收敛;数据库迁移工具、基础设施即代码(Terraform),也都是声明目标状态,所以能安全地重复 apply;甚至前端框架,你声明 UI 应该长什么样,而不是手动描述怎么一步步改 DOM。它们全都在利用同一个事实:描述"终态"的指令,天然幂等、天然可重试、天然容错;描述"过程"的指令,则脆弱、依赖执行次数、无法安全重复。所以当你设计任何一个可能在不可靠环境下被调用的操作时,养成一个习惯:能不能把它从"做某个变化"改写成"确保某个终态"?能改写,它就立刻获得了幂等这个珍贵的性质。从"过程思维"升级到"状态思维",是写出健壮系统的一把关键钥匙。
三、用唯一 ID + 去重表实现幂等
第一类幂等方案,是最通用的一种:给每条消息一个全局唯一的 ID,消费时先拿这个 ID 去一张"去重表"里查——查到了,说明这条消息处理过了,直接跳过;查不到,就处理,并把这个 ID 记进去重表。关键在于,"判断 + 记录"这一步必须靠数据库的唯一约束来做到原子,而不能用"先查后插"——先查后插在并发下会有两个线程同时查到"没有"然后都去处理。先看去重表的结构:
-- 去重表:用消息的唯一 ID 做主键,靠主键约束来防重
CREATE TABLE consumed_message (
msg_id VARCHAR(64) NOT NULL, -- 消息的全局唯一 ID
consumer VARCHAR(64) NOT NULL, -- 哪个消费者处理的
consumed_at DATETIME NOT NULL,
PRIMARY KEY (msg_id, consumer) -- 同一条消息 + 同一消费者,只能有一行
);
-- 核心:靠 PRIMARY KEY 的唯一性。同一个 msg_id 想插入第二次,
-- 数据库会直接拒绝(主键冲突)—— 这个"拒绝",就是我们的防重信号
有了这张表,消费逻辑就改成:用一次带唯一约束的 INSERT 来"抢占"这条消息——插入成功了,才轮到我处理;插入失败(主键冲突),说明别人/上一次已经处理过了,直接跳过:
# 用"唯一 ID + 去重表"实现幂等消费
import pymysql
def on_message(msg):
msg_id = msg.headers["msg_id"] # 生产端就给每条消息带上的全局唯一 ID
order = parse(msg.body)
conn = get_db()
try:
with conn.cursor() as cur:
# 关键:用 INSERT 去"抢占"这条消息。
# 插入成功 = 我是第一个处理它的;主键冲突 = 已被处理过
cur.execute(
"INSERT INTO consumed_message(msg_id, consumer, consumed_at) "
"VALUES (%s, %s, NOW())",
(msg_id, "order-consumer"),
)
# 能走到这,说明抢占成功,在同一个事务里执行业务动作
add_points(cur, order.user_id, order.amount)
deduct_stock(cur, order.product_id, order.quantity)
conn.commit() # 业务动作 + 去重记录,一个事务里一起提交
send_sms(order.user_id, "您的订单已支付") # 短信见第六节,放事务外
except pymysql.err.IntegrityError:
# 主键冲突 —— 这条消息之前已经处理过了,这是预期内的,直接跳过
conn.rollback()
log.info(f"消息 {msg_id} 已处理过,跳过")
finally:
conn.close()
msg.ack()
# 要害:把"写去重记录"和"业务动作"放进同一个数据库事务。
# 这样要么两者都成功,要么都回滚 —— 绝不会出现
# "业务做了、去重记录没写上"或"去重记录写了、业务没做"的半吊子状态
这里有个绝不能错的细节:写去重记录和执行业务动作,必须在同一个数据库事务里。如果分成两步——先写去重记录、再单独做业务——那么一旦业务那步失败了,去重记录却已经写进去了,这条消息重投回来时会被当成"已处理"而跳过,业务就永久丢了。放进一个事务,它们才能同生共死。
这里要建立的认知是:这个方案最值得你记住的,不是"去重表"这张表,而是它解决"判断 + 执行"这个组合时所用的核心武器——把"检查"和"动作"合并成一个不可分割的原子操作。我特意强调"不能先查后插",这背后是并发编程里一个最经典、也最致命的陷阱,叫 check-then-act(先检查、再行动)。"先查去重表有没有,没有就处理"——这两步之间存在一道缝隙,在高并发下,两个线程可能同时执行完"查",都查到"没有",于是都认为该自己处理,防重彻底失效。任何"先判断条件、再根据判断结果做事"的代码,只要判断和做事不是原子的,中间这道缝隙就一定会在并发下被踩中。而这个方案的精妙之处,在于它没有去"查",它直接用一个 INSERT 来"做"——INSERT 这个动作本身,由数据库的唯一约束保证了原子性:要么插入成功(我抢到了),要么冲突失败(别人抢到了),不存在"两个都成功"的中间态。它把"判断它是否被处理过"和"占住它"这两件事,压缩成了一次原子操作。这个"用一个原子操作替代 check-then-act"的思路,在工程里随处可见:并发计数不要"读出来加一再写回",要用原子的自增指令;抢锁不要"看锁空不空再去拿",要用原子的 CAS(比较并交换);创建文件不要"判断存在不存在再创建",要用带 O_EXCL 标志的原子创建。它们解决的是同一类问题:只要你发现自己在写"先检查 X,再根据 X 做 Y"的代码,而这段代码可能被并发执行,就要立刻警惕——这道缝隙迟早出事。正确的解法永远是:找到一个能把"检查"和"行动"捆成原子的底层机制(数据库约束、CAS、原子指令),让那道致命的缝隙根本不存在。
四、用业务状态机实现幂等
去重表是通用方案,但它要额外维护一张表。很多时候,业务数据本身就带着状态,我们可以直接利用业务的状态来判断"这件事做没做过",连去重表都省了。这就是第二类方案:业务状态机 + 乐观锁。拿订单举例,一笔订单有 待支付 → 已支付 → 已发货 这样的状态流转。处理"订单已支付"这条消息时,我要做的本来是"把订单标记为已支付并执行后续动作"——那我就把"标记"这一步,用一条带状态条件的 UPDATE 来做:
-- 用业务状态做幂等:UPDATE 带上"当前状态必须是预期的前置状态"
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE order_id = 12345
AND status = 'UNPAID'; -- 关键:只有当前还是"待支付"才更新
-- 这条 SQL 的返回值(影响行数)就是天然的幂等判断:
-- 影响行数 = 1 -> 我成功把它从 UNPAID 改成了 PAID,该我执行后续动作
-- 影响行数 = 0 -> 它已经不是 UNPAID 了(别人/上次已处理过),我跳过
-- WHERE 里的状态条件,就是"乐观锁":
-- 我不提前加锁,我赌它还没被改;真被人抢先改了,我的 UPDATE 自然落空
这条 UPDATE 的影响行数,就是一个天然的、原子的幂等判据。把它包进消费逻辑:
# 用"业务状态机 + 乐观锁"实现幂等,不需要额外的去重表
def on_message(msg):
order = parse(msg.body)
conn = get_db()
try:
with conn.cursor() as cur:
# 用带状态条件的 UPDATE 来"抢占"这次状态流转
affected = cur.execute(
"UPDATE orders SET status='PAID', paid_at=NOW() "
"WHERE order_id=%s AND status='UNPAID'",
(order.order_id,),
)
if affected == 0:
# 影响 0 行:订单已不是 UNPAID,这条消息处理过了,跳过
conn.rollback()
log.info(f"订单 {order.order_id} 非待支付态,跳过重复消息")
msg.ack()
return
# 影响 1 行:状态流转由我完成,在同一事务里执行后续动作
add_points(cur, order.user_id, order.amount)
deduct_stock(cur, order.product_id, order.quantity)
conn.commit()
finally:
conn.close()
msg.ack()
# 这个方案的好处:防重直接"长"在业务数据自己的状态上,
# 不需要额外的去重表;状态流转的合法性检查也顺带做了 ——
# 一条"已支付"消息,如果订单已经是"已发货",同样会被自然挡掉
这里要建立的认知是:对比"去重表"和"业务状态机"这两个方案,你会发现一个关于工程设计的深层智慧——最好的约束,是那些"内生于业务数据本身"的约束,而不是"额外附加在旁边"的约束。去重表是一个外部的、附加的机制:它和业务数据是分开的两张表,你要额外维护它,要小心翼翼地用事务把它和业务绑在一起,它和业务之间那种"必须保持一致"的关系,是靠你的代码纪律去维系的——代码一旦写漏,两者就会脱节。而业务状态机方案,防重能力是直接"长"在订单那个 status 字段上的:它不是一个额外的东西,它就是业务数据的一部分。正因为它内生于业务,它顺便还白送了一个去重表给不了的好处——状态流转的合法性校验:一条"支付成功"的消息如果晚到了,而订单早已是"已发货",这个 UPDATE 因为状态条件不匹配自然就落空了,你不用写任何额外代码,非法的状态跳转就被天然地挡住了。这就是"内生约束"的力量:它不需要你时时维护,因为它和它要保护的数据本就是一体的。这个思想能指导你大量的设计决策:能用数据库的外键、唯一约束、CHECK 约束表达的规则,就别只写在应用代码里——写在 schema 里,它就成了数据的内生属性,任何路径的写入都绕不过它;能用类型系统表达的约束(让非法状态根本无法被构造出来),就别靠运行时的 if 判断。判断一个约束设计得好不好,就看它是"数据想违反都违反不了"的内生约束,还是"全靠调用方自觉遵守"的外部约定。尽量让你的正确性,扎根在数据结构本身,而不是飘在一层层需要人去维护的代码纪律上——前者坚固,后者迟早会因为某一次疏忽而崩裂。
五、防重要做到端到端:生产端也会重复发
到这里消费端的幂等做扎实了,但还有一个容易被整个忽略的角落:生产端。我们一直假设"重复"只发生在投递环节,可生产者发消息这一步,同样会重复。生产者调用 send 发一条消息,如果发出去之后没收到 MQ 的确认(又是网络),它不知道 MQ 到底收没收到,为了不丢消息,它通常会重发——于是 MQ 里就实实在在地多了一条内容相同的消息。这是另一个源头的重复:
# 生产端:发消息也要带上稳定的、由业务决定的唯一 ID
import uuid
def publish_order_paid(order):
# 错误做法:每次发都现生成一个新 ID
# msg_id = str(uuid.uuid4())
# 生产者重发时会生成一个不一样的 ID,消费端就识别不出这是同一条
# 正确做法:msg_id 由"业务事实"唯一决定,而不是每次随机生成。
# 同一个业务事件(订单 12345 支付成功),无论发几次,
# 算出来的 msg_id 都必须是同一个 —— 这样消费端的去重才认得出
msg_id = f"order-paid:{order.order_id}"
mq.send(
topic="order_paid",
body=serialize(order),
headers={"msg_id": msg_id}, # 把这个稳定 ID 带给消费端
)
# 要点:唯一 ID 的"唯一性"必须锚定在业务事实上。
# "订单 12345 的支付成功"这件事,在整个系统里就该只有一个 ID,
# 不管这条消息被生产者重发几次、被 MQ 重投几次,ID 恒定不变 ——
# 消费端的去重表 / 状态机才能把它们全部识别成"同一件事"
这里的关键,是 msg_id 的生成方式。如果生产者每次发消息都 uuid4() 现生成一个随机 ID,那么它重发时会带一个不同的 ID,消费端的去重表把这两条看成两件不同的事,防重彻底失效。正确的做法是让 msg_id 由业务事实唯一决定——"订单 12345 支付成功"这个事件,无论被发多少次,算出来的 ID 永远是 order-paid:12345。这样一来,生产端的重复发、MQ 的重复投,所有的重复最终都收敛到"同一个 ID",被消费端统一拦下。
这里要建立的认知是:生产端这个被忽略的角落,要教给你一个排查和设计问题时极其重要的视角——面对一个贯穿多个环节的链路,你必须有"端到端"地审视它的能力,而不能只盯着自己最熟悉的那一段。我第一版的思维误区是:我把"重复"这个问题,框死在了"MQ 投递"这一个环节里,所以我以为"消费端做了幂等"就万事大吉了。可"一条消息被重复处理"这个最终的坏结果,它的成因散布在整条链路上:生产者可能重复发、MQ 可能重复投,这是两个独立的、来自不同环节的重复源。我只堵了下游一个口子,却没意识到上游还有一个。这里的通用认知是:一个端到端的属性(比如"这件业务事实只被处理一次"),它的成立,需要链路上每一个环节都不破坏它;你只要漏看了任何一环,整个保证就有缺口。所以分析这类问题,正确的姿势是先把完整的链路画出来——生产者、MQ、消费者,数据从哪里产生、流经哪些节点、在哪里被处理——然后逐个环节地问:这一环,会不会引入那个我不想要的东西(这里是重复)?这种"画出全链路、逐环节审视"的方法,能帮你抓住大量"只看局部永远想不通"的问题:一个请求的延迟高,瓶颈可能在客户端、网关、服务、数据库任何一环,只盯着服务代码看是找不到的;一份数据算错了,错误可能在采集、传输、清洗、计算任何一步引入。永远记住:当一个问题的"果"出现在链路末端时,它的"因"可能在链路的任何位置。养成端到端看问题的习惯,别让自己的视野,被"我只负责这一段"的思维定势给框住了。
六、工程里那些幂等的坑
幂等的两类方案理顺了,落地时还有几个工程坑反复咬人。第一个,有副作用的外部调用要单独处理。发短信、调第三方支付、发邮件这类动作,它们的"效果"不在你的数据库里,没法跟着你的事务一起回滚。所以别把它们塞进消费事务,正确做法是:事务里只改自己的数据,外部调用放到事务提交之后、并且对外部调用本身也要做幂等(比如调用时带幂等键)。第二个,去重表要定期清理。去重表会无限增长,要给记录设过期时间、定期归档删除老数据,只保留足够覆盖"消息最大可能重投窗口"的那段时间就够。第三个,幂等键要选对粒度。是"每条消息一个 ID",还是"每个业务事件一个 ID"?多数情况下该用业务事件粒度——这样生产端重发也能被识别。第四个,消费失败要重试,但要区分错误类型。可重试的临时错误(数据库抖动)就重试;不可重试的错误(消息体格式错误)要进死信队列,别让一条毒消息无限重试卡住整个队列。第五个,并发消费同一业务主体要防串。同一个订单的多条消息被多个线程并发处理时,乐观锁的状态条件能挡住乱序,但要确认你的状态机设计能正确处理乱序到达。把这些信号都接进监控,你才有数据判断消费健不健康:
消息消费上线后必须盯死的几个指标:
duplicate_hit_rate 去重命中率,被识别为重复而跳过的消息占比
长期为 0 要警惕:可能去重根本没生效
consume_lag 消费积压,队列里堆了多少没消费的消息
retry_count 消息重试次数分布,某条反复重试就是毒消息
dead_letter_count 进死信队列的消息数,非 0 要人工介入排查
consume_latency 单条消息的处理耗时
dedup_table_size 去重表的行数,持续暴涨说明清理没跟上
business_anomaly 业务侧对账:积分总额 / 库存 是否和订单对得上
这里要建立的认知是:把这一节的坑串起来看,会浮现一个对"消息队列"乃至所有"为了可靠性而引入的中间件"的总体判断——任何一个中间件,你引入它,是为了换取某种好处,但它绝不是免费的,它在给你好处的同时,一定会把一类新的复杂度也一并交到你手上,而这类复杂度,需要你主动去认领、去管理。我引入消息队列,图的是"异步"和"解耦"——下单的主流程不必等积分、短信处理完,系统各部分不必紧紧耦合在一起。这个好处是真实的、巨大的。但 MQ 在交付这个好处的同时,也把它的代价一并塞了过来:at-least-once 语义带来的重复消费、消息可能乱序、消费可能积压、会有处理不了的毒消息。这些都不是 MQ 的"缺陷",它们是"异步解耦"这个好处的另一面——你想要消息能被可靠地缓冲、重投、不丢,你就必须接受重复;你想要生产和消费解耦,你就得接受它们之间不再有同步的强一致。第一版我的失败,根子在于我只伸手接过了 MQ 的好处(异步),却完全没意识到、更没去认领它配套的复杂度(重复)。这里要建立的通用认知是:每当你打算引入一个中间件或一项新技术来解决问题时,你的评估绝不能停在"它能给我带来什么好处",你必须同样认真地问出第二个问题——"它会带来什么新问题、什么新的复杂度,而这些我准备好怎么管理了?"。引入缓存,你就得管理缓存一致性和缓存失效;引入分库分表,你就得管理跨库事务和全局 ID;引入微服务,你就得管理服务发现、链路追踪和分布式事务。成熟的工程决策,从来不是被某个技术的好处冲昏头脑就上,而是把它的好处和它配套的复杂度放在同一杆秤上一起称,确认那份复杂度你认得清、也管得起,然后才郑重地引入。天下没有免费的中间件——你享受它的好处,就得连本带利地,把它的复杂度也一起认领回家、妥善照看好。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| at-least-once | 消息至少投递一次的语义 | 绝大多数 MQ 的默认语义 必然有重复 |
| 重复投递 | 同一条消息被消费多次 | ack 丢失 消费者重启 故障转移都会触发 |
| 幂等 | 执行一次和执行多次效果完全相同 | 是消费端实现 exactly-once 效果的根本手段 |
| 天然幂等操作 | 基于绝对值赋值的操作 | 设状态 设值 删除 重复执行无害 |
| 非幂等操作 | 基于相对增量或追加的操作 | 加积分 扣库存 INSERT 必须额外防重 |
| 唯一 ID 去重表 | 用消息唯一 ID 加去重表防重 | 去重记录与业务动作必须同一事务 |
| 业务状态机 | 用带状态条件的 UPDATE 做幂等 | 影响行数为 0 即已处理过 防重内生于数据 |
| 乐观锁 | UPDATE 的 WHERE 带前置状态条件 | 不预先加锁 靠条件不匹配自然落空 |
| check-then-act | 先查再做的并发陷阱 | 判断与动作要合并成一个原子操作 |
| 死信队列 | 反复处理失败的消息的归处 | 毒消息进死信 别无限重试卡住队列 |
避坑清单
- 别指望 MQ 不重复投递,绝大多数 MQ 只保证 at-least-once,重复是必然要兜底。
- 消费操作必须做成幂等,让同一条消息处理一次和处理十次效果完全相同。
- 分清天然幂等和非幂等操作,加积分扣库存这类增量操作必须额外加防重。
- 去重记录和业务动作放同一事务,否则会出现业务做了去重没写的半吊子状态。
- 用唯一约束防重而非先查后插,先查后插在并发下有缝隙,防重会失效。
- 能用业务状态机就别建去重表,带状态条件的 UPDATE 让防重内生于业务数据。
- 生产端也要防重,msg_id 由业务事实唯一决定,别每次发都随机生成。
- 有副作用的外部调用放事务外,发短信调支付没法跟事务回滚,且自身也要幂等。
- 去重表要设过期定期清理,否则会无限增长,只留覆盖重投窗口的那段。
- 毒消息要进死信队列,不可重试的错误别无限重试,会卡死整个消费。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为消息队列会保证一条消息只投递一次,发一条、收一条、处理一条天经地义。可绝大多数 MQ 提供的语义是 at-least-once——它向你保证消息不丢,代价就是消息可能重复。重复不是 MQ 的 bug,是它在"宁可重投也不丢"这个原则下的必然行为:ack 会在网络上丢、消费者会处理到一半重启,MQ 收不到确认时,根本无法区分"没处理"和"处理了但 ack 丢了",它只能选择重投。我那套不做防重的消费逻辑,撞上重投,就实实在在地把积分加了两次、库存多扣了。
真正把消息消费做扎实,工作量不在"指望 MQ 不重复",而在一次思路的转变:承认重复必然发生、消灭不了,转而让消费操作变得幂等——处理一次和处理十次,效果完全一样。一旦接受这一点,该做的事就都浮现出来了——用唯一 ID 加去重表、或用业务状态机加乐观锁来实现幂等,把去重记录和业务动作锁进同一个事务,让生产端用业务事实决定的稳定 ID 发消息,把有副作用的外部调用挪到事务外并单独做幂等,毒消息进死信队列。每一步都不复杂,难的是先承认:你手里的不是一根精确投递一次的传送带,而是一个会重复、需要你在消费端亲手兜住重复的工具。
我后来常拿收快递来想这件事。消息队列那个 at-least-once,就像一个尽职尽责、但只认"签收回执"的快递员:他的最高原则是绝不把你的包裹弄丢。他把包裹放到你家门口,要等你的签收回执。可如果这个回执在路上丢了——他没收到——他没法判断到底是"你没拿到包裹"还是"你拿到了、回执丢了",出于"绝不弄丢"的原则,他只能再送一趟。于是同一个包裹,你可能收到两次。你不能怪快递员,他的重送恰恰是尽责。你能做的,是在自己这边立个规矩:每个包裹上都有一个唯一的单号,我收货时先看单号在不在我的签收本上——在,就说明这个包裹我已经收过了,原样退回、绝不重复入库;不在,才签收入库、并把单号记进本子。这个"单号 + 签收本"的规矩,就是幂等;那个签收本,就是去重表。快递员保证不丢,你保证不重——两边各尽其责,整件事才真正可靠。
这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你本地发一条、收一条,网络通畅、消费者不重启,重复投递根本不发生,你跑一万条消息可能一条重复都见不到,那套没有幂等的代码看起来完美无缺。它只在生产环境里、在网络抖动的那一瞬、在某次发版重启消费者的那一刻才引爆——ack 丢了、消息处理到一半进程没了,MQ 默默重投,积分悄悄多加,而这些重复没有一个会在功能测试里喊疼,它只是让你的数据一点点地对不上账。所以别等用户开始投诉"积分怎么多了""短信怎么收到好几条",才想起去补幂等:接入消息队列写下第一个消费者的那一刻,就该把"这条消息一定会被重复投递,我的处理逻辑扛得住吗"当成和写对业务逻辑同等重要的事来设计——幂等不该是"出了重复数据再补"的补丁,而该是你写消费逻辑时,和业务代码一起摆上桌的另一半。把"重复必然发生、消费必须幂等"这件事在一开始就认下来,你才算真正跳出了那个把 MQ 当成精确传送带、出了事还在盯着数据对不上账发愁的坑。
—— 别看了 · 2026