1. 幂等性的本质:为什么一个看似抽象的概念会毁掉你的生产环境
先讲一个我真实踩过的坑。某次负责一个订单系统的联调,上游支付回调因为网络抖动重发了三次,结果用户账户余额被扣了三次,订单却只创建了一笔。查日志的时候发现代码逻辑里没有任何防护,纯粹以为"回调只会有一次"。那天的工单和客诉电话,让我第一次认真审视幂等性这三个字。
幂等性(Idempotency)听起来像数学术语,但放到工程里其实就是一个朴素要求:同一个操作,不管执行一次还是一百次,产生的结果都和执行一次完全一样。HTTP协议里GET天然是幂等的,因为读取不会改变资源状态;而POST、PATCH这类写操作就不一定了。
在分布式系统中,网络超时、重试机制、消息重复投递都是常态,而不是异常。你永远无法保证下游只收到一次请求,但你可以通过幂等设计,让重复请求不会产生重复副作用。
什么样的人需要认真看这篇文章?凡是系统里存在写操作、对外提供API、接了支付渠道、依赖消息队列做异步处理、设计了定时任务补偿逻辑的,都应该把幂等性当成基础设施来对待,而不是某个模块的加分项。
从我个人经验看,幂等性设计的核心难点不在于"知不知道要做",而在于把幂等方案落到具体业务场景时,面临的各种边界情况能不能想清楚——比如什么字段适合做幂等键、幂等判断放在哪一层、脏数据怎么清理。这篇文章会把原理、主流方案、工程落地细节全部过一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 幂等性的底层原理与判定标准
2.1 数学定义与工程映射
数学上,幂等操作的定义是 f(f(x)) = f(x),即对某个操作执行两次,结果与执行一次相同。映射到工程系统,就是"请求重放"与"单次执行"必须产生等价状态。
这里要区分两个容易混淆的概念:幂等和并发安全。它们经常被放在一起讨论,但关注点完全不同。
并发安全解决的是"多个不同请求同时到达如何保证数据一致",通常靠锁、事务、版本号来处理。幂等解决的是"同一个请求被重复送达如何处理",强调对重复请求的识别与忽略。一个接口可以做到幂等但并发不安全(比如先检查状态再更新的逻辑在高并发下被插队),也可以并发安全但不幂等(比如每次插入操作都生成新主键,并发没问题,但重放会产生多条记录)。设计接口时,这两件事都要做,但别混为一谈。
2.2 判定一个操作是否幂等的三个标准
我在实际评审代码时,会从三个维度判定某个接口或方法是否满足幂等要求:
第一个是状态一致性。重复执行后,数据库里的最终状态和单次执行的最终状态是否完全一致。注意这里看的是"最终状态",不是"中间过程"。比如某个操作会先更新订单状态为处理中,再更新为已完成,重放时第二次请求看到的可能已经是已完成,中间的"处理中"步骤不会再触发,但只要最终状态一致,就仍然满足幂等。
第二个是副作用受限。重复执行不会制造额外的数据记录、不会重复扣款、不会重复发送短信或邮件。这是业务上最关心的部分,也是线上事故最常见的发生点。
第三个是响应一致性。重复请求返回的响应体和首次执行成功时的响应保持一致,至少关键字段要一致。这直接关系到调用方的处理逻辑——如果第一次返回订单号A,重试时返回订单号B,调用方会把两次都当成新订单处理,状态就乱了。
之所以强调这三个标准,是因为幂等性不是"接口不报错"就叫幂等,也不是"数据库没有报唯一键冲突"就叫幂等。它是一个需要从最终状态、副作用、响应三个层面综合验收的工程属性。
3. 核心场景矩阵:你必须做幂等设计的七类典型情况
做了这么多年系统,我总结出七类高频需要幂等保护的场景。对照一下你自己的项目,如果命中多条,说明幂等设计不是你"要不要做"的问题,而是"今天必须补上"的问题。
第一类是支付与扣款。这是幂等性最刚需的场景。支付回调、退款回调、余额扣减,任何一个环节重复处理都会造成资金损失。注意这里说的不只是第三方支付平台的回调通知,还包括你自己的支付接口被人为重放。
第二类是订单提交与状态流转。用户点击下单、前端超时重试、服务端处理成功但响应丢失,就会导致同一笔订单被创建多次。订单状态从待支付到已支付、已发货、已完成,每一步流转如果被重复触发,都可能导致重复发券、重复发货等衍生问题。
第三类是消息队列消费。任何一个消息中间件都做不到 exactly once 投递,业界主流保证是 at least once,也就是消息可能重复。消费者如果不对消息ID做幂等处理,重复消费就会污染数据。
第四类是分布式定时任务。多实例部署下,同一个定时任务可能被多个节点同时触发。如果任务内部没有幂等保护,可能出现重复结算、重复对账、重复推送。
第五类是对外API接口。特别是面向C端的写接口,客户端网络不稳定会自行重试,网关层也可能有重发机制,上游系统还可能把同一个请求打到不同实例。每个写接口都应该考虑幂等。
第六类是资源创建类的接口。比如注册、创建订单、生成优惠券、提交工单。这类接口如果重复执行,会产生大量"看起来一样但主键不同"的垃圾数据,后期对账和排查会非常痛苦。
第七类是缓存与存储的回填刷新。这个容易被忽略。某个缓存失效后多个线程同时回源数据库查询并回填,虽然结果可能一致,但如果回填逻辑里包含计数器累加之类的副作用,就会出问题。
一个务实的判断规则是:凡是请求可能被重试、操作会改变数据状态、且重复执行会产生不可接受的差异成本,就必须做幂等设计。成本包括资金损失、数据污染、用户骚扰这几个维度。
4. 主流幂等实现方案全景拆解
4.1 方案类型一:数据库唯一约束兜底
最朴素也最可靠的幂等方案,是利用数据库的唯一索引来挡住重复数据。比如用户对某篇文章点赞,表结构里设置 userId + articleId 联合唯一索引,无论请求重放多少次,数据库层面只有第一条能插入成功,后续全部报唯一键冲突,业务代码捕获冲突后返回已存在的数据即可。
这个方案适用场景很广,属于"靠数据库硬约束兜底"的思路,不需要额外的中间件和存储。但要注意几个细节:
联合唯一索引的字段要选准。这类字段通常就是天然的业务幂等键,比如订单号、消息ID、userId+业务ID。不要用自增主键做唯一约束,那只能保证同一行数据本身,无法识别业务层面不同请求其实是同一操作。
冲突处理要写成"查询已存在给返回",而不是直接抛出异常让调用方感知错误。捕获到 DuplicateKeyException 之后,用幂等键去查询已有数据,并把那次操作的结果返回。这样调用方的体验是"我的请求被正常处理了",而不是"系统报错了"。
性能损耗也要考虑。每次请求都会多一次索引查询,写入频繁时,唯一索引冲突检查本身有开销。但这个开销换来的是数据层的绝对安全,通常是值得的。
不过我个人的偏好是:唯一约束只作为最后的防线,主要判断逻辑仍然放在业务代码中。因为数据库层的错误处理相对粗暴,不够灵活,而且依赖数据库报错来判断重复,日志和报警看起来会很混乱。
4.2 方案类型二:状态机与前置状态校验
业务数据本身就是一个状态机。订单从待支付到已支付只有一条合法路径,从已支付回到待支付就是非法流转。利用状态机的特性做幂等,是业务上最自然的方式。
假如订单状态有 INIT、PAYING、PAID、CLOSED 四种,支付回调到达时,处理逻辑应该是:先查订单当前状态,如果已经是 PAID,说明这笔回调是重复的,直接返回成功;如果是 INIT,执行支付成功逻辑,将状态改为 PAID;如果已经是 CLOSED,说明订单已经被关闭,回调需要单独处理。
这里要特别注意状态流转的原子性。高并发下两个请求同时读到 INIT 状态,然后同时执行状态更新,就可能出现重复处理。所以状态更新语句要带上前置状态条件,写成 UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'INIT',通过受影响行数来判断当前请求是否有资格继续往下走。
这个方案的优点是逻辑直观,完全贴合业务,不需要额外的存储成本。缺点是状态枚举必须设计严谨,遗漏某个流转路径就会出漏洞。我在做系统设计时,习惯把订单状态机画成一目了然的流转表,每个状态的合法进入、退出路径都明确标出来,再逐个检查所有回调入口和人工操作入口是否严格遵循这个流转。
4.3 方案类型三:Token/去重表机制
针对于"前端提交表单"这类场景,最实用的是Token机制。流程是:客户端先请求一个创建订单的Token,服务端把Token保存到Redis并设置过期时间,客户端提交订单时带上这个Token,服务端先校验Token存在才允许创建,创建完成后立即删除Token。
Token机制本质上是"一次性凭证",每个Token只能使用一次。因为Token是在请求真正创建之前获取的,天然支持"先锁后操作"的语义。重试时客户端还是带着同一份Token,但服务端那边Token已经被删除或标记为已使用,第二次请求就被挡住了。
这个方案有一个容易被忽略的坑:如果业务处理耗时较长,前端等不到响应就超时重试,但第一次请求实际上仍在服务端执行,此时去重表里业务还没执行完,Token也还没被删除,第二次请求看到Token存在,直接进入处理逻辑,两个线程同时处理同一笔操作,幂等被破坏。
解决办法有两个方向。一是把"获取Token"和"执行业务"之间的状态做成"处理中",Token校验通过后先更新状态为 PROCESSING,再执行业务逻辑,这样重复进来的请求看到状态不是可用态就直接返回。二是引入分布式锁,在业务处理期间锁住幂等键。实际项目中,Token结合分布式锁一起用是最稳的组合,前者负责准入控制,后者负责并发互斥。
4.4 方案类型四:状态记录表/请求流水表
消息队列场景中,最经典的方案是独立建一张"消费记录表"或者叫"请求流水表"。表结构大致包含这几个字段:业务消息唯一ID、消息内容摘要、消费状态、消费时间、处理结果。消费者拿到消息后,先按消息ID查这张表,如果表中存在且状态是 CONSUMED,直接确认消费;如果不存在,插入一条 PROCESSING 状态的记录,然后执行业务逻辑,执行成功后把状态改为 CONSUMED。
关键在于消息ID的生成策略。这个ID必须由生产端在发送消息时生成,而且全局唯一。我见过不少人用时间戳加随机数拼一个ID,这在低并发下勉强能撑住,但高并发时碰撞概率不可接受。正确的做法是用全局唯一ID生成器,或者数据库号段模式。如果消息自带业务编号,比如订单号,那直接用业务编号当幂等ID也行,但前提是该编号确实全局唯一。
这里还要提一个进阶技巧:先去重表查,再插入,这个顺序本身存在并发漏洞。两个线程同时查到不存在,同时插入,如果不加唯一约束,两条记录都进去了。所以消费记录表的主键或唯一索引一定要是消息ID。即使两个线程同时插入,也只有一个线程能成功,另一个会报唯一键冲突,捕获后当作重复消费处理即可。
4.5 方案类型五:乐观锁与版本号
乐观锁适合处理"更新类"操作的幂等,特别是余额扣减、库存扣减这类场景。思路是给数据表增加一个 version 字段,每次更新时带上 version 条件,更新成功后 version+1。同一个请求重放时,携带的还是旧的 version,更新条件不满足,数据库影响行数为 0,业务代码就能判断出这属于重复请求或者数据已被其他请求修改。
sql复制UPDATE account
SET balance = balance - #{amount}, version = version + 1
WHERE id = #{accountId} AND version = #{expectedVersion}
注意乐观锁解决的是"并发覆盖"问题,本身不提供"识别重试请求"的能力。它的语义是"谁先提交谁生效,后到的不生效",间接实现了幂等效果。适用于那些不在意请求具体身份、只关心"数据不能被重复扣减"的场景。
实际使用中,一个高频错误是把 balance = balance - #{amount} 误写成 balance = #{newBalance}。假设并发请求都携带同一个 newBalance 旧值,就会出现值回拨。使用 SQL 层面的原子扣减 balance = balance - #{amount} 永远基于当前值计算,安全性更高。
4.6 方案类型六:Redis分布式锁
前面几个方案主要靠数据库,但高并发场景下数据库压力太大,更常用的是 Redis 分布式锁。典型做法是:按业务幂等键生成锁的 key,比如 order:pay:123456,用 SET NX EX 命令获取锁,拿到锁的线程执行业务逻辑,执行完后释放锁,拿不到锁的线程直接返回"正在处理中"。
bash复制SET order:pay:123456 uuid_value NX EX 30
锁的超时时间设置要格外小心。设太短,业务还没执行完锁就自动过期了,别的请求拿到锁进来重复执行;设太长,一个请求异常没释放锁,其他请求会一直被阻塞,甚至可能引发堆积。我的经验是,锁的过期时间至少要留出正常业务耗时的三倍余量,同时结合看门狗机制——锁快要过期时,如果业务还在执行,就自动续期。
还有一点经常被忽视:释放锁时的原子性。有些同学先 GET 锁的值判断是不是自己,再 DEL 释放,这两步之间锁可能已经过期并被别的线程拿到。一个简单的解决方法是释放前先确认锁的值,且用 Lua 脚本保证判断和删除是原子操作:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
分布式锁方案做幂等,核心是"互斥"而非"去重"。它保证的是同一时间只有一个人在干活,而不是保证干过活的人不来第二次。所以它通常和其他方案组合使用。
5. 幂等键的设计:整个方案的灵魂
5.1 幂等键到底是什么
幂等键(Idempotency Key)是标识"业务操作唯一性"的字符串。同一个业务的同一个操作,幂等键必须完全一致;不同操作则必须不同。服务端拿到请求后,先查幂等键是否存在,存在就返回之前的结果,不存在就执行并缓存。
幂等键可以从前端传入,也可以由服务端从参数中推导。前者灵活度更高,客户端可以对一次业务操作生成一个全局唯一的 key,这个 key 可以放在 Header 里也可以放在请求体里。后者不需要客户端配合,服务端把关键参数用特定规则拼接,比如 userId + operationType + resourceId 拼接成一个 key。
5.2 幂等键选择的十诫
我整理过一份幂等键选择的经验清单,这里公开:
不要把时间戳单独作幂等键。同一毫秒的并发请求会产生相同的时间戳,但不同请求可能是不同操作。除非和前端的操作序号拼在一起,否则必然出问题。
不要用可变的业务字段作幂等键。比如"当前余额"这种会动态变化的值,第一次和第二次请求时值不同,幂等键也跟着变,系统会把同一个操作识别成两个操作。
推荐选那些"一次业务过程中恒定不变"的标识:订单号、流水号、消息ID、用户ID+优惠券实例ID。这些字段在业务生命周期内不会变化,且唯一性有保证。
全链路使用同一个幂等键。从 API 网关到消息队列再到数据库落库,整个链路处处都能识别这个键。不要在网关用订单号、消息消费时又重新生成一个消息ID,这样链路前半段和后半段对"同一次操作"的识别口径就不一致。
注意幂等键的长度。太长了存储浪费,而且做索引时效率低;太短了容易碰撞。一般推荐 32-64 位的字符串,UUID 或者雪花算法生成的 ID 都够用。
5.3 实战:如何生成企业级幂等键
以订单创建为例。客户端点击"提交订单"时,生成一个 UUID,作为 idempotencyKey 放在请求头中。服务端拿到请求,用这个 UUID 查 Redis,如果没有,说明是首次进入,执行订单创建逻辑,把订单结果以 UUID 为 key 写回 Redis 并设置过期时间,比如 24 小时。如果 Redis 中已经有值,说明请求是重试的,直接返回缓存的结果。
但这里有个关键的败笔设计要提示:如果把幂等键的缓存和业务表数据分离,缓存过期了而业务库里订单还在,重试请求一来,发现缓存不存在,就认为是新请求,于是再创建一单。解决方式是:幂等查询不能只查缓存,一定要去业务库确认。缓存查不到时,可以按业务幂等规则去库里查已有数据,再回填缓存。这样即使缓存因为过期或重启丢了,只要数据库在,幂等就不会失效。
6. 幂等实现层面的分层设计
6.1 前置层:网关与框架级幂等
很多问题可以在网关层统一处理,而不需要每个业务接口都自己造轮子。网关层可以做两件事:一是识别请求中的幂等键,如果多个请求的幂等键相同,网关只放行第一个,后续请求直接拦截返回第一个的处理结果;二是对写接口自动生成幂等键,不需要客户端配合。
框架级的幂等拦截器也很实用。我以前在一个内部框架里就封装过基于注解的幂等组件,业务开发只需要在方法上加一个注解,配置幂等键的表达式、锁类型、过期时间,框架自动完成"查去重表—加分布式锁—执行业务—回填结果"的流程。这样做的好处是幂等逻辑收敛到一处,不会出现"这个接口做了那个接口漏做"的混乱局面。
但分层设计要避免一个陷阱:网关幂等做成了"全局一刀切"。不同接口对幂等的需求完全不同——有的需要缓存结果,有的只是直接丢弃重复请求,有的需要并发排队。如果网关层把所有接口都用同一种策略处理,会造成某些场景下过度约束、某些场景下约束不足。正确的做法是网关只做基础的识别,具体的幂等策略仍然下放到业务层配置。
6.2 支撑层:去重表与锁的选型
去重表结构设计对方案的效果影响巨大。我推荐表结构至少包含这几列:
sql复制CREATE TABLE idempotency_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL,
request_body TEXT,
response_body TEXT,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_idempotency_key (idempotency_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
status 字段用来区分 PROCESSING 和 DONE 两种状态,含义之前提过:先插入 PROCESSING 记录,业务处理完再更新为 DONE。这样在业务执行期间,重复请求看到 PROCESSING 状态就知道"正在处理中",可以返回"稍后查询"或直接等待。
关于去重表的清理策略,不建议直接 DELETE 物理删除,历史数据对排查问题有重要价值。可以定期把超过 N 天的记录归档到冷表,或者加上一个 archived 字段做逻辑归档。查询接口默认只查活跃记录,归档表单独拉出来做审计和回溯。
Redis 和数据库怎么选?纯 Redis 做去重的读写性能好,但依赖 Redis 的持久化可靠性和过期策略。纯数据库做去重,数据落地可靠,但高并发下热点记录会成为瓶颈。实际项目中我倾向"数据库为主、Redis 做前置缓存"的组合:读请求先查 Redis,命中直接返回;未命中再去数据库查;写请求先写数据库(利用唯一索引去重),成功后回写 Redis。
6.3 业务层:幂等与事务的配合
这是很多人做幂等时会忽略的一层。幂等判断和业务写入必须放在同一个本地事务里,否则会出现"幂等标记写入成功但业务执行失败"的不一致状态。
比如消费消息的伪代码:
java复制@Transactional
public void handleMessage(Message msg) {
if (idempotentRecordRepository.exists(msg.getMessageId())) {
return;
}
idempotentRecordRepository.insert(msg.getMessageId(), "PROCESSING");
// 业务操作
orderRepository.markAsPaid(msg.getOrderId());
idempotentRecordRepository.updateStatus(msg.getMessageId(), "DONE");
}
关键在于幂等记录的插入和业务操作在同一个事务内,同时提交同时回滚。如果事务内先插入幂等记录,业务操作抛异常回滚,幂等记录也跟着回滚了,下次重试会重新走完整流程——这没问题。但如果幂等记录不在事务里,业务操作失败后记录还在,后续所有重试都会被挡在门外,而业务数据根本没发生变化,这就死锁了。
还有一个边界要小心:事务还没提交时幂等记录对别的线程是否可见。MySQL 默认隔离级别下,别的事务是看不到未提交的记录的,这时并发请求可能都认为"记录不存在",都执行业务。所以幂等与事务的配合只是基础,最终仍需要唯一索引或分布式锁兜底并发那一层。
7. 在真实业务中如何选型:完整决策框架
七种方案听下来你可能会困惑:到底该用哪种?实际项目里很少只用一种方案,通常是多种方案组合。我总结了一个选型顺序,按照这个思路来基本不会错:
先看业务类型是创建型、更新型、还是消费型。
创建型操作,比如下单、注册、发券,优先考虑去重表或 Token 机制。客户端的重复提交用 Token,服务端收到的重复请求用唯一约束兜底。
更新型操作,比如改状态、扣余额,优先考虑乐观锁或状态机。核心是用 version 字段或状态流转约束保证"旧请求不会覆盖新状态"。
消息消费型,直接上"消息 ID 去重表",这也是消息队列场景的标配。
再结合系统现状做取舍:如果你们已经有 Redis,那分布式锁和 Token 方案直接顺手用;如果数据库是强一致性的,唯一索引方案最稳。高并发场景优先 Redis,强可靠场景优先数据库,两者互不排斥,反而经常互补。
可以用一张表来做快速对照:
| 方案 | 适用场景 | 核心依赖 | 幂等粒度 | 复杂度 | 风险点 |
|---|---|---|---|---|---|
| 数据库唯一约束 | 创建类接口 | 数据库 | 字段级 | 低 | 字段选错则无效 |
| 状态机/状态校验 | 有状态流转的业务 | 业务状态 | 对象级 | 低 | 状态缺失或绕路 |
| Token机制 | 前端表单提交 | Redis | 请求级 | 中 | 超时重试并发 |
| 消息去重表 | MQ消费 | 数据库 | 消息级 | 中 | 插入与消费并发 |
| 乐观锁 | 余额/库存更新 | 数据库 | 行级 | 低 | 重试不带版本号 |
| 分布式锁 | 并发互斥 | Redis | 请求级 | 中高 | 锁超时或误删 |
8. 实战案例:支付回调幂等方案完全拆解
把前面讲的东西串起来,我用真实的支付回调场景做一次完整演示。这个案例覆盖了幂等键、状态机、去重表、分布式锁四样东西的组合使用,也是我目前觉得最完备的落地形态。
核心需求:支付平台回调通知商户系统,告知订单已支付成功。商户系统需要更新订单状态为 PAID、进行余额入账、给用户发放积分、发送支付成功通知。回调可能重复,可能出现乱序,服务可能重启。
第一步,定义幂等键。回调通知中带有支付平台流水号 transactionId,加上商户订单号 orderId,我们使用 transactionId 作为幂等键。这个值由支付平台保证全局唯一,天然适合做去重标识。
第二步,更新订单主体走状态机。订单状态初始是 INIT。收到回调后执行 UPDATE 语句:
sql复制UPDATE orders SET status = 'PAID', paid_at = NOW(), transaction_id = #{transactionId}
WHERE id = #{orderId} AND status = 'INIT'
受影响行数为 1 说明这是首次从待支付流转到已支付;受影响行数为 0 说明订单已经不是 INIT 状态,需要查一下当前状态。
第三步,处理并发的重复回调。两个相同的回调同时到达时,状态机更新只有一条能成功,另一条受影响行数为 0。但此时它并不清楚自己是"重复回调"还是"回调乱序"——比如订单先收到了关闭通知,再收到支付成功回调。所以第三步一定会查当前订单状态,如果状态是 PAID 且 transaction_id 等于本次幂等键,就礼貌地返回"支付成功已知晓";如果状态是 CLOSED,说明业务上订单已经关闭,这属于需要告警的异常情况。
第四步,处理下游的衍生操作。订单主状态更新成功后,还要发积分、发通知、记账。这些操作也要做幂等。简单做法是建立一个"支付事件处理记录表",主键就是幂等键。主事务内先插入该记录,再执行衍生操作,保证幂等记录与业务操作的原子提交。
整个过程实际跑起来后,无论支付平台重发多少次回调、无论多个实例是否同时处理,最终订单数据和用户资产都只会被更新一次。这个方案的关键在于每层都有明确的职责边界:状态机挡最主要的重复,记录表挡衍生操作,唯一索引挡极端的并发竞态。
9. 常见问题与排查技巧实录
9.1 为什么幂等键存在,但重复请求还是被执行了
这恐怕是出现频率最高的问题。排查路径按照从外到内来:先确认请求是不是真的带了同一个幂等键,有时候是网关或 SDK 层自动生成了新键覆盖了原来的键;再确认去重查询走了分库分表时,两次请求落到不同分片导致查不到彼此的记录;最后确认缓存和数据库的同步是否及时,Redis 中删除了幂等键但数据库记录还没落库,下次请求就可能漏过。
我在一个项目里见过最隐蔽的问题:Dubbo 接口在消费端有个自动重试机制,重试时虽然消息体相同,但框架层给每次尝试都重新生成一个 traceId,代码里误把 traceId 当作幂等键,等于每次重试都变成了"新请求"。
9.2 业务处理时间超过了幂等键的过期时间
Redis 中幂等键设置过期时间,初衷是防止缓存无限增长。但如果重试请求在过期之后才到达,系统会把它视为新请求。解决办法有三个方向:把过期时间设到业务的极限耗时以上(比如设为 T+ 7 天);做二级确认,从数据库侧再查一次订单是否存在;实在确认不了就拒绝请求而不是重新执行一次,让人工介入处理。
9.3 分库分表后幂等键失效
分库分表场景下,幂等键的全局唯一约束被打破了。按 user_id 分表的话,同一个用户的请求会落到同一张表,幂等仍有效;但如果是纯随机分表,同一个幂等键就可能在每次重试时落到不同的表。解决思路是:幂等键中显式冗余分片键,让同一业务操作的所有请求都路由到同一个分片;或者引入全局的去重表,单独存放所有幂等记录。
9.4 幂等判断和业务执行不在同一时机
这是并发场景下最常见的坑。代码先查幂等表判断"不存在",尚未插入,另一线程也查到"不存在",两个线程都开始执行业务。最终两个线程都插入了自己的幂等记录,业务被重复执行。面对这类问题,唯一的治本方案是数据库唯一索引或分布式锁,把"判断和占用"做成原子操作。
9.5 重试风暴与幂等记录堆积
某些场景下,上游系统对异常响应进行指数退避重试,可能会导致短时间产生大量相同幂等键的请求打过来。每个请求都走一遍"查询幂等记录"的路径,如果命中 Redis 很快,但打 DB 就会造成压力。这种场景下务必要有"边限流边判断"的策略,识别到相同幂等键高频重试时,可以先返回上次的处理结果,让重试快速失败,避免无意义的 DB 查询。
10. 幂等性设计在大型分布式系统中的扩展思考
小型单体系统做好幂等相对容易,因为只有一个数据库,事务边界清晰。但到了微服务架构,一个业务流程跨越多个服务,幂等性的维度就变得复杂了。
第一层复杂性是服务间调用链路的幂等传递。下单服务调用库存服务扣减库存,库存服务做幂等时需要识别是哪一次下单操作在扣减。这时候幂等键需要在调用链路上传递,不能每个服务各自重新生成。可以约定在 RPC 调用的附加参数中透传全局的请求 ID,所有下游服务的幂等判断都以该 ID 为准。
第二层复杂性是跨服务的事务一致性。订单服务更新订单状态、账户服务扣减余额、积分服务增加积分,三个服务操作三种数据源,本地事务无法保证整体一致性。这时候通常借助分布式事务框架(比如消息事务、TCC)来协调。而 TCC 模式的 Confirm 阶段和 Cancel 阶段本身就是天然需要幂等保护的,每个分支的事务记录都要能够被安全地重放。
第三层复杂性是异步化带来的乱序问题。消息队列虽然保证至少一次投递,但不保证顺序。同一个订单的支付成功消息和关闭消息,可能先来的是关闭、后来的是支付成功。幂等设计不能只按"重复即拦截"的思路处理,还需要结合业务语义判断"这条消息虽然从来没处理过,但它已经是过期消息了"。这需要消息中带上事件发生的时间戳和对应的业务状态版本,消费者要对比当前状态与事件版本判断是否允许执行。
第四层复杂性是最终一致性的可观测量。分布式系统里,不同节点对"同一次操作是否已生效"的观测结果可能不同。所以在幂等设计中,对外暴露的查询接口也要做状态收敛:客户端带着幂等键查询处理结果时,系统应尽可能返回确定性的结果,而不是"处理中"或"无法确认"。这一点直接决定了调用方是否能安全地执行自己的下一步逻辑。
11. 一些值得养成的工程习惯
作为总结之前的最后一部分,我分享几个自己做幂等设计时长期坚持的习惯,每一个都是实践中被坑出来的教训。
接口文档永远写清楚幂等语义。哪怕 MVP 阶段先不做防护,也要在接口文档里标注"幂等依赖什么字段、目前是否已实现"。否则半年后接手的同事对着一段"看起来能跑但不知道重发会怎样"的接口无从下手。
测试用例必须包含重放场景。单元测试和集成测试里,除了正常流程和异常流程,一定要有"相同请求连续提交多次"的用例。CI 流水线里这应该和普通测试一样被强制执行。
线上监控要能看到幂等拦截率。我会为每个埋了幂等逻辑的接口增加监控指标,比如"请求总数、首次执行数、重复拦截数"。重复拦截数突然飙升,通常意味着上游在疯狂重试,背后必然有别的故障。这个指标是很好的故障征兆。
幂等逻辑和数据分布要写在系统设计文档里。不能只存在某个开发同学的脑海里。哪天团队换人,换来的同事靠读代码理解幂等逻辑会非常痛苦,尤其是跨了多个服务和多张表的情况下。
最后想强调的一点是:幂等性不是一次性的设计冲刺,而是伴随业务持续演进的长期承诺。每新增一个写接口、每接入一个新消息源,都需要重新审视"这个新路径重放安全吗"。做多了之后你会发现,幂等设计其实塑造了一个团队的思维模式——思考每个操作的可重放性,思考每次调用的边界条件,这种思维会潜移默化地降低整个系统的故障率。
