下单按钮被点了两次、支付回调来了三遍、消息队列把同一条事件投递了两次——这些都不是异常场景,而是分布式系统的常态。只要链路里存在超时、重试和至少一次投递,重复请求就一定会出现,区别只在于它有没有被正确处理。
这篇文章想回答一个具体问题:重复请求应该在哪一层被消解,分布式锁在其中到底该排第几位。先说明信息边界:本次运营提供的参考资料为空,没有具体站内帖子可供引用,因此下文不虚构社区个案,只讨论可公开验证的通用机制与工程取舍;涉及具体业务时,请以你们自己的数据模型和一致性要求为准。
先给结论
幂等是业务语义问题,不是中间件功能问题。 判断一个接口是否幂等,看的是“同一个业务意图被执行多次,最终状态是否等价于执行一次”,而不是“有没有加锁”。锁加上去之后接口依然是可重复执行的,只是并发窗口变窄了。
核心判断:分布式锁只能缩小并发窗口,不能消除重复语义;真正的幂等底座通常是数据库唯一约束加上带条件的原子状态更新,锁只是它们前面的性能优化。
两者的语义差别很关键。锁保证“同一时刻只有一个执行者”,而重试发生在锁释放之后:两个请求相差 800 毫秒,第一个已经提交事务并释放锁,第二个拿着同样的订单号重新走完整流程,锁全程“工作正常”,重复数据照样产生。所以用锁做幂等,本质是在赌重试一定发生在临界区内,这个假设在真实网络里并不成立。
这条结论也有边界。如果操作本身是覆盖写,比如 SET status = 'paid',或者只是纯查询,那它天然幂等,不需要额外机制;真正需要设计的是插入型、累加型和状态推进型操作,它们每多执行一次,结果就会多偏一分。
重复请求是怎么被放大的
重复请求很少来自单一环节,而是被链路逐层放大。客户端 SDK 在超时后重试,网关或负载均衡按策略重试,RPC 框架有重试开关,消息队列在消费者没有正确 ack 时重投,定时补偿任务再兜底扫一遍。每一层单独看都合理,叠在一起就是乘法关系。
流程图
渲染中...
图中最危险的一步是“未在 3 秒内返回”。超时不等于失败,它只意味着结果未知。 把超时当失败处理,就会制造出“其实已经成功、但又执行了一遍”的经典问题。支付回调最典型:渠道方在 30 秒内没收到 200 响应,就按策略重投,而你的服务其实早已扣款成功,只是响应在网络上丢了。
消息队列侧同理。Kafka、RocketMQ、RabbitMQ 在消费者未提交位点或未 ack 时都会重投,投递语义是“至少一次”而不是“恰好一次”。这意味着去重责任天然落在消费端:生产端无法保证不重复,消费端就必须能识别“这条我已经处理过了”。
四道防线的机制与代价
| 防线 | 解决什么 | 主要代价 | 适用场景 |
|---|---|---|---|
| 唯一索引 | 重复插入 | 要处理唯一键冲突异常 | 有自然唯一键,如订单号、外部流水号 |
| 条件更新 | 状态被重复推进 | 需要先设计状态机 | 待支付到已支付这类流转 |
| 幂等表 | 无自然唯一键的请求 | 多一次写,需要清理归档 | 外部回调、开放接口去重 |
| 分布式锁 | 并发窗口过大 | 锁服务依赖、续期、误删风险 | 缩小竞争,不作为正确性保证 |
合理的排序是:先问“这个业务对象有没有天然唯一标识”。有就上唯一索引,让数据库做最终裁判;没有就建幂等表,用 (业务类型, 请求ID) 做联合唯一键;状态推进用带条件的原子更新;最后才考虑锁。反过来做,用锁打头阵,往往会掩盖数据模型本身的缺陷。
唯一索引的代价在异常路径上。不少代码在捕获唯一键冲突后直接吞掉异常返回成功,这在“插入订单”场景通常是对的——说明之前已经成功过;但在“插入资金流水”场景可能是错的,因为冲突的可能是另一笔业务写入的同号记录。异常处理必须结合业务语义,不能一概而论。
幂等表的代价是存储和清理。请求 ID 保留多久?太短会漏判,太长会持续膨胀。常见做法是按业务可容忍窗口保留 7 到 30 天,再用定时任务归档到冷表。这是显式成本,但比“先加个锁试试”更值得提前算清楚,因为它至少是可度量、可监控的。
一个具体例子:订单状态推进
假设业务是把订单从待支付推进到已支付。容易出错的写法是先 SELECT 判断状态,再 UPDATE 写入,两步之间留出了并发窗口。更稳的写法是把判断和写入合并成一条语句:
SQL
UPDATE orders
SET status = 'PAID', paid_at = NOW(), version = version + 1
WHERE order_no = ?
AND status = 'PENDING'
AND version = ?;
关键在受影响行数。返回 1 表示本次推进成功;返回 0 表示要么订单不存在、要么状态已不是待支付、要么版本已被别人改过。此时应再查一次,区分“重复请求,返回上次结果”和“状态非法,返回业务错误”。并发控制交给数据库的一条语句,而不是交给应用层的锁。
为什么“先查再改”会失效?因为在两条语句之间,另一个事务可能已经提交。即使把隔离级别提到可重复读,SELECT 读到的快照也不阻止其他事务写入。条件更新把判断和写入合成一个原子操作,这是它比锁更可靠的根本原因,也是它在高并发下更省事的原因。
缓存与锁解决的是另一个问题
缓存击穿、缓存穿透、缓存雪崩属于可用性问题,幂等属于正确性问题,两者经常被混为一谈。用分布式锁防止缓存击穿、只放一个线程回源,是合理的性能优化;但它不保证业务只执行一次——缓存过期之后,重复请求依然会打到数据库。
- 缓存击穿:热点 key 失效瞬间大量请求回源,用互斥重建或逻辑过期缓解,目标是保护数据库。
- 缓存穿透:查询不存在的数据,用空值缓存或布隆过滤器,目标是减少无效查询。
- 缓存雪崩:大批 key 同时过期,用过期时间打散,目标是避免集中失效。
- 幂等:同一业务意图被执行多次,用唯一约束或状态机,目标是保证结果正确。
把这三类问题混在一起的后果是:系统在压测下表现很好,一到真实重试场景就出现重复扣款。因为压测通常只验证吞吐和延迟,很少模拟“超时之后客户端又发了一次”的语义。
实践建议
落地时建议按阶段推进,而不是一次性重构:
- 建模阶段:给每个写接口标注它属于插入型、累加型还是状态推进型,并写清楚“重复执行一次会发生什么”。
- 编码阶段:优先使用唯一索引或条件更新,幂等键选业务可解释的值,例如订单号、外部流水号,而不是随机 UUID。
- 编码阶段:统一异常处理,明确区分“重复请求,返回上次结果”和“状态非法,返回业务错误”。
- 运维阶段:为幂等表设定保留窗口和归档任务,监控唯一键冲突率,把它当作正常指标而非告警噪音。
- 验证阶段:在集成测试里显式模拟重复调用,例如用 Testcontainers 起真实 MySQL 验证唯一约束和条件更新是否按预期返回受影响行数。
也有不适用的情况。如果业务本身允许重复,比如日志写入或可累加的计数,强行加幂等表只会带来写放大;如果操作跨服务跨库,唯一索引管不到另一个库,这时需要幂等表、消息表或对账补偿来兜底。
跨服务场景的取舍更明显:Seata 这类分布式事务能提供较强一致性,但链路长、成本高;更常见的做法是本地事务加消息表,再配合消费端幂等,接受短暂最终一致。选择依据应该是业务能容忍多久的不一致,而不是团队对某个框架的偏好。
总结与讨论
第一,重复请求是重试机制的必然产物,超时只代表结果未知,把它当失败处理就是在制造重复。第二,幂等的正确性底座应该放在数据层:唯一索引负责插入,条件更新负责状态推进,幂等表负责没有自然键的请求。第三,分布式锁是性能与并发控制工具,不是正确性工具,把它当幂等方案会掩盖真正的数据模型问题。
最后一个值得讨论的问题:在你们现有的系统里,写接口的幂等键是由业务字段推导出来的,还是由客户端生成的?前者更可信但覆盖面有限,后者更通用但依赖调用方配合——你们是怎么权衡的?