重复提交不是用户手快,而是系统没设计好。只要接口会被重试,就必须假设它一定会被执行两次。
一、重复是从哪来的
- 用户行为:双击提交、返回再提交、网络慢反复点
- 客户端重试:超时自动重试,但服务端其实已经成功
- 网关与负载均衡:上游超时重发,请求被投递两次
- 消息重投:队列至少一次投递,消费端重复执行
- 定时任务:多实例同时触发,或上次没跑完又起一轮
只要链路上有任何一个环节会重试,"不会重复"就是假设而不是事实。
二、三种幂等实现,按场景选
| 方式 | 适用 | 注意点 |
|---|---|---|
| 业务唯一键 + 唯一索引 | 订单号、支付流水、单据编号 | 最可靠;冲突时返回已存在的结果,不要报错 |
| 幂等 Token | 表单提交、创建类接口 | 进入页面时领 token,提交时校验并消费,需设过期 |
| 状态机 | 审批、订单状态流转 | 只允许合法跃迁,重复请求落在同一状态即忽略 |
推荐优先用唯一键:它由数据库保证,不依赖应用层的锁和缓存,最不容易被绕过。Token 适合没法天然生成业务键的场景。
三、并发下的库存与余额
幂等解决"同一次请求执行两遍",并发解决"两个不同请求同时改一条数据"。两者要分开处理。
- 乐观锁:带版本号更新,
update ... set qty=qty-1, ver=ver+1 where id=? and ver=?,影响行数为 0 就重试或失败。适合冲突不频繁的场景。 - 条件更新:
where qty >= 1,把判断写进 SQL,避免先查后改的竞态。 - 悲观锁 / 行锁:热点数据、冲突频繁时用,但要注意锁范围和超时。
- 串行化:把同一单据的操作投到同一队列分区,天然串行。
最忌讳的是"先 select 再 if 再 update",这段代码在并发下必然超卖。
四、前端也要做,但不能依赖
- 提交后立即禁用按钮,请求返回再恢复(失败要恢复,否则用户没法重试)
- 防抖只能挡住手快,挡不住刷新与重放
- 提交成功后跳转到结果页,用 GET 展示,避免刷新重复 POST
前端的防护是体验优化,不是数据安全的防线。后端不幂等,前端做得再细也白搭。
五、怎么确认线上真的重复了
- 先看数据特征:同一业务键多条记录、创建时间相差几秒、操作人相同
- 查入口日志:同一 traceId 出现两次,还是一个请求被投递两次
- 看网关与客户端超时配置,确认重试策略
- 补监控:唯一键冲突计数、重复请求比例,异常上升就告警
幂等要写进接口文档
每个写接口标明是否幂等、靠什么保证、重复调用返回什么。不写清楚,调用方就会自己加重试,问题只会更多。
#接口幂等#并发控制#开发实践#系统设计