1. 幂等性到底解决什么问题
1.1 一个事故现场:重复支付回调引发的惨案
先讲一个我踩过的坑。几年前我做一个商户收单系统,对接某第三方支付渠道,上线第二天就出了事故:用户支付成功后,渠道的回调通知因为网络抖动连续投递了三次,我们的回调接口每收到一次就执行一次"订单置为已支付 + 用户余额增加"的逻辑。结果用户付了100元,账户里凭空多了300元,订单状态还被反复刷新。那笔差错一直到月底对账才发现,最后走了一堆人工流程才把账抹平。
这个事故的核心问题只有一个:回调接口不具备幂等性。
所谓幂等性,用一句大白话说就是:同一个请求,不管被执行多少次,产生的业务效果都和只执行一次完全一样。数学上的表达更简洁:f(f(x)) = f(x)。放到接口场景,就是客户端重复提交同一个业务操作,服务端要保证只生效一次,不会因为重复请求导致数据错乱、金额多算、状态被覆盖。
你可能觉得"网络抖动只投递三次"是小概率事件,但实际在分布式系统里,重复请求是常态而不是意外。网关超时重试、客户端双写、消息队列重新投递、人工手动补单,随便哪个环节来一遍,你的接口就会被同一个请求砸好几次。所以业内有个共识:凡是对外暴露的写接口,都必须默认具备幂等性,这不是可选项,是必选项。
1.2 幂等和"并发安全"是两回事,别搞混
我经常看到有人把幂等性和并发安全混为一谈,这俩其实是不同维度的问题。
并发安全关注的是"多个不同请求同时到达时怎么保证数据一致",典型手段是加锁、事务隔离、乐观锁。它的目标是让一把数据在多线程环境下不脏读、不丢失更新。
幂等关注的是"同一个请求重复到达时怎么避免重复处理",它的目标是让一个业务动作只能落地一次。比如用户连续点击三次"提交订单",接口虽然被调了三次,但数据库里只有一条订单记录。
两者有交集:并发场景下,两个完全相同的请求同一瞬间到达,数据库层面可能同时命中同一条数据,这时候并发控制和幂等控制都要做。但它们解决的是不同的问题,设计接口时也要分开考虑,不能以为加了分布式锁就万事大吉,锁只能保证互斥,不能保证"同一个请求只处理一次"这个语义。
当你搞清楚这两个概念的区别,后面选择方案时会清晰很多:哪些场景用锁,哪些场景用唯一索引,哪些场景用状态校验,心里就有数了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哪些场景必须考虑幂等性
不是所有接口都需要幂等设计,但下面这几类场景是重灾区。我跟不少人聊过,发现一个规律:凡是线上出过重复数据问题的,基本都逃不出这几类。
2.1 支付回调、下单这类"天然重试"场景
支付回调是最典型的例子。支付宝、微信的支付结果通知协议里明确写了"通知可能会多次发送",而且如果你返回的结果不是它们期望的报文,它们还会按照衰减策略继续重发,最长能持续好几天。所以回调接口必须把"重复通知"当成正常情况来设计,而不是异常情况。
下单场景同样高频踩坑。用户在前端点了一下"提交订单",按钮灰了但请求还在途中,用户以为没点上又点了一下;或者前端的轮询机制和手动刷新叠加,同一个订单请求就被发了好几次。如果没有幂等保护,数据库就会出现多条一模一样的订单,用户支付时看到一堆待付款单,体验直接崩掉。
还有个容易被忽略的场景是充值、提现这类金额操作。用户的手机银行、支付App在超时后往往会自动重试,渠道侧和商户侧同时认为"这笔钱应该到账",如果接口不幂等,用户充100到账200,这笔账迟早要查出来,而且查出来的时候往往已经形成坏账了。
2.2 消息队列消费、定时任务里的重复消费
消息队列是幂等问题的"批发商"。以Kafka为例,它提供的是at-least-once语义,也就是说消息在极端情况下一定会被重复投递。最典型的现象:消费者处理完一条消息,业务逻辑已经执行成功,但在提交offset之前服务宕机了,消费者重启后,这条消息会被重新拉取并再次处理。
定时任务也一样。分布式定时任务框架(比如XXL-JOB、ElasticJob)在路由策略配置不当、执行器漂移、手动触发补跑等情况下,同一个任务可能在多个节点上同时执行。如果任务内容是"扫描昨天所有未结算的订单并结算",重复执行的结果就是订单被结算两次。
所以只要你的系统里有MQ消费者、有定时任务、有对外回调接收,你就已经在幂等性的高危名单里了。与其等线上出问题再去补,不如在设计阶段就搞清楚自己的场景属于哪一类,然后对症下药。
3. 五种主流的幂等性落地方案
方案没有绝对的好坏,只有适合不适合。我按使用频率和适用场景,把常见的五种方案拆开讲清楚,包括实现原理、代码思路和要注意的坑。
3.1 数据库唯一约束:最可靠、最底层的兜底方案
数据库唯一约束是我最推崇的方案,没有之一。它的原理很简单:给业务表加一个唯一键,重复插入时数据库会直接报错,应用程序根据这个报错判断"这条数据已经处理过了"。
举个例子,支付回调表的结构可以这样设计:
sql复制CREATE TABLE pay_callback_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
channel_order_id VARCHAR(64) NOT NULL COMMENT '渠道订单号',
order_id VARCHAR(64) NOT NULL COMMENT '商户订单号',
callback_payload TEXT COMMENT '回调原始报文',
handle_status TINYINT DEFAULT 0 COMMENT '处理状态',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_channel_order (channel_order_id)
) COMMENT '支付回调记录表';
这里的channel_order_id是幂等键,它代表"同一笔渠道通知只允许插入一次"。当回调接口收到通知后,先尝试插入一条回调记录。插入成功,说明这是第一次处理,继续走业务逻辑;插入失败(抛DuplicateKeyException),说明是重复通知,直接返回成功即可。
这个方案最妙的地方在于,你不用先查再插,查了再插在并发下依然会重复,而唯一索引天然具备原子性,数据库引擎层面就帮你把"同一瞬间到达的两个相同请求"拦住了。它是真正的最终防线。
不过它有个局限:只适用于"新增数据"这种场景。如果是对
