接口幂等性设计实践:防重复提交、重试和并发请求
很多接口问题不是“第一次请求失败”。
而是:
1 | 第一次请求其实成功了 |
如果接口没有幂等性,结果就很尴尬:
- 重复扣款
- 重复创建订单
- 重复发优惠券
- 重复提交表单
- 重复发送短信
幂等性不是高级架构概念。只要接口会被重试,只要用户可能重复点击按钮,就应该考虑。
一、什么是幂等性
幂等性简单说就是:
1 | 同一个操作执行一次和执行多次,最终结果一致。 |
比如查询用户信息:
1 | GET /users/1 |
查一次和查十次,不会改变数据。
这天然是幂等的。
但创建订单就不一样:
1 | POST /orders |
如果每次请求都插入一条新订单,那它不是幂等的。
幂等性设计的目标,是让同一次业务操作重复请求时,只产生一次业务结果。
二、哪些接口最需要幂等
不是所有接口都要做复杂幂等。
优先关注这些场景:
- 创建订单
- 支付回调
- 发放权益
- 扣减库存
- 创建用户资产
- 提交表单
- 消息消费
- 第三方 Webhook
这些接口的共同点是:
1 | 一旦重复执行,业务后果很明显。 |
而普通查询、列表分页、配置读取,一般不用做额外幂等设计。
三、客户端防重复点击不够
前端可以做按钮 loading:
1 | 点击后禁用按钮 |
这很有用。
但它不是幂等性。
因为重复请求可能来自:
- 用户刷新页面
- 浏览器重试
- 手机网络抖动
- 反向代理重试
- 客户端超时后自动重试
- 第三方平台重复回调
- 消息队列重复投递
前端防抖只能减少重复提交。
真正的幂等必须在服务端兜住。
四、用业务唯一键做幂等
最稳定的幂等方式,是找到业务天然唯一键。
比如订单系统里,客户端先生成一个业务单号:
1 | client_order_no = 202606121956001234 |
创建订单时带上:
1 | { |
数据库加唯一索引:
1 | CREATE UNIQUE INDEX uk_orders_client_order_no |
服务端创建时:
1 | 如果不存在,创建订单 |
这样同一个业务单号重复提交,不会创建多条订单。
这是我最推荐的方式。
因为它不依赖缓存,也不依赖请求时间窗口,而是把幂等约束放在数据库唯一性上。
五、用 Idempotency-Key 做通用幂等
如果接口没有天然业务单号,可以让客户端传一个幂等键:
1 | Idempotency-Key: 0b9345aa-9d0c-4f1a-9b1e-8cfc7b7a1111 |
服务端保存这个 key 和处理结果:
1 | key |
处理流程:
1 | 1. 收到请求 |
为什么要保存 request_hash?
因为同一个 key 如果配了不同请求体,说明客户端用错了。
这时应该返回错误,而不是把错误请求当作同一个操作。
六、不要只用 Redis setnx 就结束
很多人会这样做:
1 | Redis SETNX idempotency_key 1 EX 60 |
如果设置成功,就继续处理。
如果设置失败,就认为重复请求。
这个方案可以挡住短时间重复点击,但有几个问题:
- 处理成功后响应丢失,重试时无法返回原结果
- 业务处理失败时,key 是否删除很难决定
- Redis 过期时间太短会漏,太长会占空间
- Redis 成功、数据库失败时状态不一致
所以 Redis 更适合做“短时间防重复提交”,不适合作为强业务幂等的唯一依据。
真正关键的业务,还是要靠数据库唯一约束和状态机兜底。
七、状态机也能保证幂等
有些操作不是创建记录,而是推进状态。
比如订单支付:
1 | 待支付 -> 已支付 -> 已发货 |
支付回调可能重复到达。
这时可以用状态判断:
1 | UPDATE orders |
如果影响行数是 1,说明这次成功把订单从待支付改成已支付。
如果影响行数是 0,说明订单已经不是待支付状态。可能已经处理过,也可能状态不允许。
这比先查再改更稳。
因为条件更新本身就是一个原子操作。
八、消息消费必须默认会重复
消息队列里有一个现实:
1 | 至少一次投递很常见。 |
也就是说,消费者要假设消息可能重复。
消费消息时可以保存消息处理记录:
1 | CREATE TABLE processed_messages ( |
处理流程:
1 | 1. 开启事务 |
关键点是:处理记录和业务变更最好在同一个事务里。
否则可能出现“业务成功但记录失败”或者“记录成功但业务失败”的不一致。
九、幂等不是锁
幂等和锁经常被混在一起。
它们解决的问题不一样。
幂等解决:
1 | 同一个业务操作重复执行的问题。 |
锁解决:
1 | 多个操作同时竞争同一份资源的问题。 |
比如用户重复提交同一个订单,用幂等键解决。
多个用户同时抢最后一件库存,要靠库存条件更新或锁解决。
不要指望一个 idempotency_key 解决所有并发问题。
该用唯一约束用唯一约束。
该用条件更新用条件更新。
该用事务用事务。
十、一个实用设计清单
设计关键写接口时,可以问自己这些问题:
- 这个接口重复请求会不会出问题?
- 有没有天然业务唯一键?
- 数据库有没有唯一约束兜底?
- 客户端超时重试时,服务端能否返回同一个结果?
- 幂等 key 是否校验请求内容?
- 幂等记录是否有过期策略?
- 业务状态是否用条件更新推进?
- 消息消费是否允许重复投递?
- 重试失败时,日志里能否查到同一次操作?
幂等性的核心不是写一个工具类。
而是把重复执行这件事当成正常情况来设计。
十一、我的建议
普通业务系统里,可以这样选:
1 | 创建类接口:业务唯一键 + 数据库唯一索引 |
不要只相信前端按钮禁用。
也不要只相信 Redis 锁。
真正稳定的幂等性,应该落在业务唯一性、数据库约束和清晰状态机上。
这些东西看起来朴素,但比复杂中间件可靠得多。