AI 资讯 知识库 一叶一世界一篇读懂幂等性:重复请求、防重与分布式下的幂等设计

一篇读懂幂等性:重复请求、防重与分布式下的幂等设计

幂等是什么:执行一次与执行多次结果相同

幂等(idempotency):同一操作执行一次与执行 N 次,效果相同。数学来源是 f(f(x)) = f(x),工程含义是对外部世界只产生一次效果。为什么需要它?分布式世界的三大重复源:用户重复操作(双击提交按钮)、网络超时重试(客户端发了请求没收到响应——请求其实成功了,重试是理性的默认策略)、消息系统的至少一次投递(消息队列的重试语义——本库消息篇)。只要重试是默认的,幂等就是必需的——一个不幂等的支付接口 + 自动重试 = 扣两次款。

哪些操作天然幂等:先盘点免费的

按 HTTP 语义盘点:GET 天然幂等(只读不改)。PUT 幂等(「把资源设为这个状态」——设多少次都是这个状态)。DELETE 幂等(删多少次都是「已删除」——前提是删除语义是「确保不存在」而非「每次删都报错」)。POST 不幂等(「创建一个新资源」——两次就是两条记录)。业务操作层面:余额 = 100 是幂等赋值,余额 += 100 不是;「设置为已发货」幂等,「库存减一」不幂等。设计技巧:把非幂等操作改写成幂等形式——「加 100 元」改成「设置一个流水号对应的入账状态」(账务系统的记账模型天然幂等:每笔账有唯一凭证号);「扣库存」改成「按订单号扣减 UPDATE stock SET n=n-1 WHERE order_id=? AND not_used」——业务模型的幂等化,比加防重锁优雅得多。

五种防重方案:按并发与成本选型

方案一:数据库唯一约束。给业务唯一键(订单号、流水号)加 UNIQUE 索引,重复插入直接报错——最简单可靠的兜底,任何防重体系的最后一道闸。缺点:只防「插入型」重复,报错后上层要处理好错误(不能给用户看「Duplicate entry」)。方案二:去重表 / 状态机。独立记录「已处理的业务键」——处理前插入去重记录(或把业务状态机化:待支付 → 已支付,UPDATE orders SET status='paid' WHERE id=? AND status='unpaid',受影响行数为 0 就是重复请求——状态机的条件更新是最优雅的幂等实现)。方案三:Token 机制。进入页面先取一个一次性 Token(Redis 存着),提交时带上并原子删除(Redis 的 DEL 返回 1 = 首次,0 = 重复)——适合表单防重,代价是多一次交互。方案四:乐观锁。版本号机制:UPDATE ... SET version=version+1 WHERE id=? AND version=?——并发冲突时后者失败,天然防重防并发改写。方案五:分布式锁。处理前抢锁(Redis SETNX / ZooKeeper),处理完释放——防重能力强但成本最高(锁服务的可用性、超时、死锁都要管),且锁不能替代幂等(锁失效瞬间仍可能双执行——锁是「减少重复概率」,幂等是「重复了也无害」,两者是互补不是替代)。

「先查再写」为什么不是防重:并发下的检查失效

最常见的错误防重是「先查有没有,没有再插入」——这个模式在并发下必然失效:两个请求同时查(都看到「没有」)→ 同时插入 → 重复。这是检查与动作的非原子性(TOCTOU:time-of-check to time-of-use)。修复方向只有一个:把「检查 + 动作」变成一个原子操作——唯一约束(数据库层的原子)、条件更新 WHERE status=...(单语句原子)、或分布式锁(外部原子)。同一原理的变体 everywhere:库存超卖(查库存再减——两个请求都读到「还剩 1 件」)、抢券超发(同款错误)——「先查再改」在并发世界的正确翻译是「条件更新或原子操作」,这一条规则能消灭一整类并发 bug。

支付行业的完整幂等设计:一个参照系

把五方案串成支付场景的完整防御纵深。入口层:请求携带客户端生成的幂等键(Idempotency-Key,Stripe 的标准做法——客户端为每次「意图」生成唯一键,服务端按键去重)。网关层:按幂等键做短窗口去重(Redis SETNX,挡住网络层重试)。业务层:状态机条件更新(订单状态推进只能单向、按条件)。存储层:交易流水唯一约束(最后的兜底闸门)。对账层:异步对账兜住一切漏网(本库 CAP 篇的最终一致的补偿手段)。响应的细节:重复请求返回第一次的结果而非报错(幂等响应——客户端拿到的两次响应一致,重试逻辑才闭环;Stripe 会缓存首次响应按幂等键重放)。这套纵深的设计哲学:每一层的防重都不是 100%,但叠加起来,重复的效果被压到零——幂等不是某个开关,是一个贯穿四层的架构属性。

幂等的消息系统实践:至少一次与幂等消费的配合

幂等(正文五方案)在消息系统(本库 MQ 主题)里有专门形态。背景:消息的投递语义。至多一次(可能丢)、至少一次(可能重——主流默认)、恰好一次(理想但昂贵——Kafka 的事务与幂等生产者只覆盖特定链路)。既然「至少一次 + 重复」是常态,幂等消费就是标配:消费者处理消息前按「消息的业务键」(订单号 + 动作类型)查重——去重表或 Redis SETNX,已处理直接 ACK 跳过。三个工程细节。细节一:去重键的业务语义——用「业务 ID + 幂等版本」而非「消息 ID」(同一业务事件经不同路由产生不同消息 ID,但业务键相同)。细节二:处理与去重的原子性——「先记去重再处理」的窗口(记了但处理失败,消息永远跳过)与「先处理再记去重」的窗口(处理了没记住,重复消费)——正解是业务表与去重表同库同事务(一个事务里既写业务状态又写去重记录——原子性由数据库保证)。细节三:乱序的处理——至少一次不保证顺序,状态机式消费(条件更新 WHERE status=前置状态)天然容忍乱序与重复(旧消息迟来时条件不满足,无害跳过)。消息幂等的元结论:不要追求「恰好一次」的传输,要建设「重复无害」的消费——前者受制于分布式物理,后者只是工程纪律。

幂等的接口设计规范:Idempotency-Key 的完整协议

把幂等键(正文提过 Stripe 做法)展开成完整的接口设计规范,值得一份可抄的协议。客户端义务:为每个「用户意图」生成唯一键(UUID,在用户点击提交时生成、重试时复用同一个键——键在客户端生成并跨重试保持,是协议的核心约定)。服务端义务。收到带键的请求:首次——处理、缓存响应(键 → 响应的映射,含状态码)、返回。重复(键已存在且在处理中)——返回 409/425(重试提示),客户端稍后重试。重复(键已存在且处理完成)——重放缓存的原响应(客户端拿到与首次完全一致的结果)。键冲突但请求体不同——返回 422(键被误用,防串数据)。缓存的管理:响应缓存的保留期(24–72 小时足够覆盖重试窗口)、容量与淘汰(高频接口的键存储压力)。协议的适用边界:读接口不需要(GET 天然幂等)、流式与超长处理慎用(响应缓存的体积)、以及与业务幂等的分层——协议幂等(网络层重试)管的是「同一意图只算一次」,业务幂等(状态机)管的是「同一业务事实只生效一次」——两层都要有,缺一层就有缝。这套协议的成熟标志:支付与云厂商 API 的公开文档里,Idempotency-Key 已经是「创建类接口」的标准参数——幂等从工程美德变成了接口契约。

幂等的边界场景:幂等与顺序的纠缠

幂等(正文)的边界场景是与顺序性的纠缠——「重复无害」容易设计,「乱序无害」更难。场景:消息乱序。至少一次投递不保序——「订单创建」「订单取消」两条消息,取消先到创建后到:无脑处理会把已取消的订单又「创建」出来。解法按强度:其一,版本号/时间戳门槛——每条消息带版本,UPDATE WHERE version < 新版本(旧消息被版本门挡住);其二,状态机门(本库正文的条件更新)——「取消」只能作用于「未取消」状态,迟到的「创建」消息在状态机里找不到入口;其三,序列化到单分区(Kafka 按业务键分区——同一实体的消息进同一分区保序,代价是热点与并行度限制)。另一纠缠:幂等与并发的纠缠。同一幂等键的两个请求并发到达(不是重复而是竞争)——去重表此时挡不住(都查到「不存在」)——需要唯一约束兜底(本库正文的方案一在此显出不可替代性)。组合的真谛:幂等键管「重复」、唯一约束管「竞争」、状态机管「乱序」——三件事三个工具,混用任何一个都会留缝。把它们当成一个组合拳来设计,消息与重试的世界才会真正「无害化」。

小结

幂等 = 执行一次与多次效果相同——在「重试是默认策略」的分布式世界是必需品。先盘点天然幂等(GET/PUT/DELETE、赋值型操作、状态机化),能用业务模型幂等化(唯一流水号)就别上锁。五种防重方案:唯一约束(最可靠兜底)、状态机条件更新(最优雅)、Token(表单场景)、乐观锁(并发改写)、分布式锁(增强而非替代)。警惕「先查再写」的非原子性——并发下检查必然失效,修复方向是条件更新与原子操作。支付级纵深:幂等键 → 网关去重 → 状态机 → 唯一约束 → 对账,重复请求返回首次结果。幂等不是开关,是架构属性。

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?