4、并发接口的幂等性设计:重复请求的坑
4.1 先聊聊什么是幂等性
幂等性,说白了就是同一个请求不管调多少次,结果都一样。你想想,如果用户手抖点了一下提交按钮,结果库存扣了两次,钱付了两回——那可就出大事了。
我刚开始做电商系统时,觉得幂等性就是个理论概念。直到线上出了个事故:用户秒杀下单,因为网络抖动,前端重试了三次,结果库存扣了三次,最后给用户发了三台手机。老板脸都绿了。
从那以后,我把幂等性当成接口设计的铁律。
4.2 重复请求最常见的几种坑
我归纳了一下,工作中碰到的重复请求问题主要有这四类:
| 场景 | 触发原因 | 后果 |
|---|---|---|
| 前端重复提交 | 用户快速点击、按钮未禁用 | 订单重复、库存超卖 |
| 网络重试 | 超时后客户端自动重发 | 资金重复扣除、积分多次发放 |
| MQ 重复消费 | 消息队列送达至少一次语义 | 状态多次更新、流水重复记录 |
| 网关或负载均衡重试 | 后端超时后网关触发重试 | 数据不一致、业务逻辑执行多次 |
你想想看,这些场景的背后,只有一个核心问题:业务逻辑是否具备天然幂等性? 大部分写操作都不是幂等的——比如“库存-1”、“余额+100”。必须靠设计来兜底。
我曾经踩过的坑: 在支付回调接口里,只做了简单的数据库唯一索引防重。结果支付系统回调了两次(间隔 50ms),第一次事务还没提交,第二次插入唯一索引没报错,直接走完了更新流程。资金对账时发现多了一笔收入,排查了两天才找到原因。
4.3 我的幂等性设计方案
经过几次血的教训,我现在设计幂等接口时,会走一套标准流程。先看 SVG 图,理解整体架构:
流程看着简单,但每一步都有坑。我讲三个核心设计点:
- 幂等键的生成:不能简单用用户ID+时间戳,并发下可能重复。我习惯用业务唯一标识+随机数,比如
order_id + uuid。如果是支付回调,用transaction_id天然唯一。 - 去重记录写入时机:必须在业务操作之前就先占位。我见过同事先扣库存再写幂等记录,结果并发请求同时到达,去重判断还没写入,两家都执行了扣减。
- 结果缓存:对于已处理过的请求,最好缓存上一次的响应结果。这样重复请求直接返回,不会重新执行业务逻辑。
一个实用的幂等键生成方案:
// 基于业务场景 + 来源 + 唯一流水
String idempotentKey = bizType + ":" + source + ":" + requestId;
// 如果请求没有requestId,由网关统一生成
String idempotentKey = request.getHeader("X-Request-Id");
我个人习惯让上游调用方传 X-Request-Id,网关校验必填,不传的直接拒绝。这样幂等性控制前移到入口,后端不用再猜。
4.4 避坑指南
这些年踩过的坑,我整理成四条铁律:
- 分布式锁不是银弹:我见过有人给每个请求加分布式锁,结果锁超时释放导致重复执行。更好的做法是“幂等键 + 数据库唯一索引”,既防重复又省锁开销。
- 注意事务边界:幂等记录插入和业务更新必须在同一个事务里。否则插入成功但业务更新失败,下一次请求查幂等记录显示已处理,就不会再执行了——业务数据就丢了。
- 重试与幂等要配合:客户端重试传同一幂等键,服务端必须保证返回相同结果。我遇到过服务端返回不同状态码,导致前端重试逻辑混乱。
- 历史数据兼容:旧接口没有幂等设计,新接口加了幂等键后,如果旧请求用新路径访问,幂等键可能为空。我一般让幂等键模块允许空值降级,但记录日志报警,推动前端改造。
我曾经的一个优化经验: 在秒杀场景中,如果每次查询去重记录都要走 DB,压力很大。我改用 Redis 做前置缓存,key 用 "idempotent:" + 幂等键,value 存请求状态(处理中/处理完)。先写 Redis 再写 DB,DB 兜底。这样 90% 的重复请求被 Redis 挡掉,性能提升了 3 倍。不过要注意 Redis 挂了怎么办——我加了本地区域缓存+唯一索引双保险。
嗯,说到最后,其实幂等性设计就一句话:让每次请求都有唯一凭证,并且凭证的消费是原子性的。你想想看,如果能把凭证的状态变更和业务逻辑绑在一个事务里,天然防重。如果做不到,那就用分布式锁或乐观锁兜底——但一定别忘了测试并发场景下的边界情况。