Article
API 幂等设计:重试后为什么还是重复下单
API 幂等设计:重试后为什么还是重复下单
用户连点提交、网关超时重试、前端刷新重放——这些场景都会把同一个意图送给服务器不止一次。如果写接口不是幂等的,结果就是重复文章、重复评论、甚至重复扣款。个人博客也有「重复发布」「重复评论」问题;理解幂等,是为整条写路径建立正确心智。这篇文章把概念落到可执行的手段上,并说明博客场景怎样用最小成本先做对。
幂等到底保证什么
幂等的意思是:同一操作执行一次与执行多次,对系统状态的影响相同。注意:HTTP 方法的语义约定(GET 幂等、POST 不幂等)只是规范建议,真正决定行为的是你的实现。你可以写出幂等的 POST,也可以写出不幂等的 PUT。
关键拆分两个概念:
- 请求重复:同一网络请求被重放(超时重试、中间件重放、用户双击但属于同一次意图)
- 业务重复:用户确实发起了两次意图(隔几分钟又点了一次「发布」)
前者应用通过幂等键消化;后者可能需要产品层确认(「你有一篇同标题草稿」)。混为一谈时,你会做出错误产品:把两次真实意图吞掉,或把一次重放做成两条脏数据。
常见落地手法
1. 幂等键(Idempotency-Key)
客户端为一次业务意图生成唯一键,服务端以该键做去重:
- 收到请求,先查幂等表:键是否已处理
- 已处理则返回首次的成功响应(或统一成功语义)
- 未处理则进入业务,成功后记录键与结果
实现时要把「处理中」状态也考虑进去:两个并发请求带同一键,不能一起进入业务。可以用唯一约束 + 事务,或 SETNX 一类原子占坑。键的生命周期要有过期,避免表无限膨胀。键空间要按用户或租户隔离,防止碰撞与误用。
客户端生成键的时机很重要:应在「用户点击提交」时生成并固定,重试沿用同一键;如果每次重试都 new 一个 UUID,幂等键等于没写。
2. 数据库唯一约束
这是最硬的兜底。例如评论在短时间窗口内「同用户 + 同文章 + 同内容哈希」唯一;支付单有业务单号唯一。应用层去重失败时,唯一约束会把重试变成可预期的冲突错误,再映射成成功或友好提示。
没有唯一约束的「先查后插」在并发下一定会穿。两个请求同时查到「不存在」,然后都插入——日志里你会觉得「怎么可能」。任何你认为「不应该重复」的业务键,最终都应落到约束上,而不是只靠 if。
3. 状态机
对有生命周期的资源(草稿→发布→下架),用状态迁移约束操作。重复「发布」在已发布状态下应返回成功或明确「已是发布态」,而不是再插一行。把非法迁移明确返回 409/400,比静默成功后留下奇怪数据更好排查。
和重试策略的关系
客户端、网关、消息队列都会重试。没有幂等的重试是事故放大器。约定:
- 读请求可放心重试
- 写请求必须带幂等键或可去重业务键
- 超时后的重试要假设「上次可能已经成功」
超时是最容易误导人的点:你没收到响应,不代表服务器没处理完。所以写路径的客户端重试必须携带同一幂等键;服务端日志应能关联到这个键,方便回答「到底处理了几次」。
对消息队列消费者同样适用:至少一次投递是常态,消费者必须幂等,否则积压重放会把副作用放大。
博客场景怎么落地
不必上来做完整支付级幂等,但可以先做:
- 后台创建文章:对 slug 做唯一约束
- 评论提交:短时窗口去重 + 用户维度频率限制
- 上传:以内容哈希或对象键避免同文件反复入库
- 管理端按钮:提交中禁用,防止同页面双击;这是体验层,不能替代服务端去重
这些约束成本低,却能消掉最尴尬的脏数据。等以后有「收藏」「点赞」这类可重复点击动作,再引入正式幂等键也不迟;但唯一约束与状态机最好从第一天就在。
测试时如何证明它有效
写两个并发请求打同一写接口:一个成功、一个冲突或同结果,而不是两条业务记录。再模拟超时重试:第一次成功但客户端以为失败,第二次带同一键,库里仍只有一条。把这类用例留在自动化测试里,比口头说「我们考虑过幂等」更有意义。
小结
幂等不是银弹文案,而是「重试存在时系统仍正确」的设计纪律。唯一约束做底线,幂等键处理协议层重放,状态机管理业务生命周期。下次再遇到「用户说点了一次,库里有两条」,先问系统有没有把「一次意图」编码成「一个键」。能回答这个问题,你就已经超过很多「功能能跑」的系统。