幂等性:让重试变得安全
深夜线上商城报了个工单:用户付款时手机卡了五秒,他以为没付上,又点了一次"确认支付"。结果呢?如果后端没有幂等保护,这一笔订单会被扣两次款——用户炸了,客服忙了一整晚,最后还得走退款流程。问题不在用户手快,也不在网络抖动,而在于:重试这件事,在分布式系统里根本躲不掉。
网络会超时、进程会重启、消息会重复投递,任何一个环节出问题,客户端都会"再试一次"。所以真正的问题从来不是"要不要重试",而是:重试发生时,系统能不能保证副作用只发生一次? 这就引出了今天的主角——幂等性。
一、它从哪来
"幂等"(idempotent)这个词最早来自抽象代数:对某个二元运算,若元素满足 x · x = x,就称它是幂等元。19 世纪数学家本杰明·皮尔斯(Benjamin Peirce)在 1870 年首先使用了这个概念。
它被引进计算机世界,则是分布式系统发展的必然。网络不可靠是铁律(1984 年端到端原则论文里就论证过"中间网络只做尽力而为"),于是超时重试成为所有可靠通信的标配——但"重试"和"重复执行"只有一线之隔。到了 HTTP 时代,RFC 7231 把幂等性正式写进了协议语义:GET、HEAD、PUT、DELETE 被定义为幂等方法(同一请求执行多次与执行一次效果相同),POST 不是(每次执行都会产生一个新资源)。如今,幂等性已经是支付系统、消息队列、RPC 框架、事件驱动架构里绕不开的工程准则。
二、为什么需要它
没有幂等性,分布式系统里最常见的三个动作会变成三颗雷。
- 重复扣款 / 重复下单:客户端没收到响应就重试,服务端可能已经处理成功了——再处理一次,钱扣两遍、库存扣两遍。秒杀场景尤其致命:100 件库存,一个手抖重试就能超卖。
- 消息重复投递:消息队列普遍承诺 at-least-once(至少一次)投递,因为"恰好一次"在分布式环境下成本极高。消费者必须自己处理"同一条消息收到两次",否则积分重复加、通知重复发。
- 超时的不确定性:请求可能根本没到服务器,也可能到了但响应丢了。客户端无法区分这两种情况,只能重试——重试是唯一的选择,能不能安全重试,全看服务端。
一句话:网络不可靠是常态,重试是必然,幂等是让"必然的重试"不闯祸的唯一护栏。
三、本质一句话
幂等 = 同一个请求执行一次和执行一百次,对系统造成的最终效果完全相同——副作用只发生一次,重复的执行只是"知道已经做过了"。
注意两个容易被混淆的点:幂等不要求"响应内容每次一样"(第一次返回"创建成功",重试返回"已存在,这是上次的结果"完全合规);幂等也不等于"永不报错",而是"报错的方式可预期、不会造成重复副作用"。
四、它有什么用
幂等性不是一个抽象口号,它有一整套可落地的工程手法。下面按"从协议到存储"的顺序看。
- HTTP 方法语义(协议层):RFC 7231 规定 GET/HEAD/PUT/DELETE 幂等、POST 不幂等。设计 REST API 时,"创建"类操作用 POST 就必须自带幂等方案(见下),"更新"类操作用 PUT(整体覆盖)天然幂等,DELETE 重复删除也返回成功即可——这是第一道免费护栏。
- 幂等键 + 唯一索引(应用层,最常用):客户端每次请求带一个全局唯一的
request_id(或业务单号),服务端把它作为唯一索引落库,先查后插。以支付回调为例,支付宝/微信的异步通知会重复推送多次,商户侧必须用"商户订单号"做唯一约束:
1 | CREATE TABLE pay_notify ( |
状态机再补一刀:只允许"未支付 → 支付成功 → 已退款"的合法迁移,已成功的单子收到重复通知直接幂等返回,绝不再改金额。本博客 Seckill 系列 4.5 节正是用 uk_user_sku 唯一索引压测"100 用户重复下单 0 重复",是这条规则的真实落地。
- Redis 原子命令(去重/锁):防重复提交可以用
SET key value NX EX 10(不存在才写入 + 10 秒过期)作为分布式锁;需要"先检查再操作"的复合逻辑,用 Lua 脚本保证原子性,避免 check-then-act 的竞态窗口。 - 消息队列 exactly-once 的务实近似:Kafka 生产者开
enable.idempotence=true配合幂等生产者协议,解决"发送端重试导致 broker 重复落盘";消费端则仍要自己用"业务键去重表"或"本地幂等表"兜底——因为 at-least-once 投递下,重复消费只能靠消费者去重。 - 数据库层的天然幂等:
UPDATE ... SET stock = stock - 1这类"相对更新"重复执行会重复扣减,不幂等;改成UPDATE ... SET stock = target WHERE stock = old(CAS 条件更新)或带version乐观锁,重复执行只有一次能成功,其余因条件不满足而失效——把"幂等"下沉给存储引擎。
五、反例与边界
幂等性有它的适用边界,滥用或误用都会翻车。
- 追加 / 计数类操作天然不幂等:日志追加、
counter += 1、发短信、扣库存的-=,重复执行副作用叠加。这类操作要么接受"至少一次"的语义(如日志),要么必须包一层幂等键去重,不能指望方法本身幂等。 - "幂等键"不是银弹,它要求先查后插的原子性:如果"查重"和"执行"之间有窗口(两个重试并发到达,都查到"不存在"),就会双双执行。所以幂等键必须落成数据库唯一索引或 Redis 原子命令,由存储层保证"只有一个能插进去"。
- 带时间戳 / 随机数的请求不适用简单重试:比如"用当前时间生成签名"的请求,重试时签名已变,服务端会当成新请求。此类场景应把幂等键独立出来(业务单号),而不是依赖会变的载荷。
- 超时重试的"结果未知"窗口要靠查询兜底:扣款请求超时后,客户端不该盲目重试扣款,而应先调用"订单查询接口"确认状态——查不到再重试。幂等键防的是"重复执行",查询接口解决的是"到底执行了没有"。
- 幂等 ≠ 忽略并发:两个不同用户拿同一单号(或同一用户并发两个不同单号)是两种问题。幂等只承诺"同一请求重复执行安全",并发下的正确性仍需锁、唯一约束与状态机共同保证。
六、对比表 + 🐾小结
| 维度 | 无幂等保护 | 有幂等保护 |
|---|---|---|
| 客户端重试 | 副作用叠加(扣两次款) | 副作用只发生一次 |
| 消息重复投递 | 重复消费、重复发奖 | 去重后安全消费 |
| 超时后处理 | 不敢重试或盲目重试 | 放心重试 + 查询兜底 |
| 实现成本 | 零(但事故成本高) | 幂等键/唯一索引/状态机 |
| 对用户体验 | 双扣、超卖、投诉 | 无感重试、体验稳定 |
🐾 小结:幂等性不是让"请求不会重复",而是让"重复变得无害"。它把分布式系统里最不可控的变量——网络——从事故源头降级为普通噪声:该重试就重试,反正副作用只发生一次。设计任何"执行后会产生副作用"的接口时,都值得先问一句:如果这个请求被原样重放一百遍,系统还安全吗?答案如果是否定的,那就该给它配一把幂等键了。

