深夜线上商城报了个工单:用户付款时手机卡了五秒,他以为没付上,又点了一次"确认支付"。结果呢?如果后端没有幂等保护,这一笔订单会被扣两次款——用户炸了,客服忙了一整晚,最后还得走退款流程。问题不在用户手快,也不在网络抖动,而在于:重试这件事,在分布式系统里根本躲不掉。

网络会超时、进程会重启、消息会重复投递,任何一个环节出问题,客户端都会"再试一次"。所以真正的问题从来不是"要不要重试",而是:重试发生时,系统能不能保证副作用只发生一次? 这就引出了今天的主角——幂等性。

一、它从哪来

"幂等"(idempotent)这个词最早来自抽象代数:对某个二元运算,若元素满足 x · x = x,就称它是幂等元。19 世纪数学家本杰明·皮尔斯(Benjamin Peirce)在 1870 年首先使用了这个概念。

它被引进计算机世界,则是分布式系统发展的必然。网络不可靠是铁律(1984 年端到端原则论文里就论证过"中间网络只做尽力而为"),于是超时重试成为所有可靠通信的标配——但"重试"和"重复执行"只有一线之隔。到了 HTTP 时代,RFC 7231 把幂等性正式写进了协议语义:GET、HEAD、PUT、DELETE 被定义为幂等方法(同一请求执行多次与执行一次效果相同),POST 不是(每次执行都会产生一个新资源)。如今,幂等性已经是支付系统、消息队列、RPC 框架、事件驱动架构里绕不开的工程准则。

二、为什么需要它

没有幂等性,分布式系统里最常见的三个动作会变成三颗雷。

  • 重复扣款 / 重复下单:客户端没收到响应就重试,服务端可能已经处理成功了——再处理一次,钱扣两遍、库存扣两遍。秒杀场景尤其致命:100 件库存,一个手抖重试就能超卖。
  • 消息重复投递:消息队列普遍承诺 at-least-once(至少一次)投递,因为"恰好一次"在分布式环境下成本极高。消费者必须自己处理"同一条消息收到两次",否则积分重复加、通知重复发。
  • 超时的不确定性:请求可能根本没到服务器,也可能到了但响应丢了。客户端无法区分这两种情况,只能重试——重试是唯一的选择,能不能安全重试,全看服务端。

一句话:网络不可靠是常态,重试是必然,幂等是让"必然的重试"不闯祸的唯一护栏。

三、本质一句话

幂等 = 同一个请求执行一次和执行一百次,对系统造成的最终效果完全相同——副作用只发生一次,重复的执行只是"知道已经做过了"。

注意两个容易被混淆的点:幂等不要求"响应内容每次一样"(第一次返回"创建成功",重试返回"已存在,这是上次的结果"完全合规);幂等也不等于"永不报错",而是"报错的方式可预期、不会造成重复副作用"。

无幂等保护:一次重试 = 两次扣款 有幂等保护:重试只生效一次 客户端 扣款服务 扣款 #1 ✓ 超时重试 扣款 #2 ✗ 重复 客户端 扣款服务 扣款 #1 ✓(记下 req_id) 超时重试(同 req_id) 命中幂等表,返回上次结果 ✗ 不再扣 副作用 = 金额 × 重试次数 用户投诉 + 退款流程 副作用 = 金额 × 1,恒定 重试安全,无需人工介入

四、它有什么用

幂等性不是一个抽象口号,它有一整套可落地的工程手法。下面按"从协议到存储"的顺序看。

  • HTTP 方法语义(协议层):RFC 7231 规定 GET/HEAD/PUT/DELETE 幂等、POST 不幂等。设计 REST API 时,"创建"类操作用 POST 就必须自带幂等方案(见下),"更新"类操作用 PUT(整体覆盖)天然幂等,DELETE 重复删除也返回成功即可——这是第一道免费护栏。
  • 幂等键 + 唯一索引(应用层,最常用):客户端每次请求带一个全局唯一的 request_id(或业务单号),服务端把它作为唯一索引落库,先查后插。以支付回调为例,支付宝/微信的异步通知会重复推送多次,商户侧必须用"商户订单号"做唯一约束:
1
2
3
4
5
6
CREATE TABLE pay_notify (
order_no VARCHAR(64) PRIMARY KEY, -- 业务单号即幂等键
status TINYINT NOT NULL, -- 0未支付 / 1支付成功 / 2已退款
updated_at DATETIME
);
-- 重复通知:INSERT 撞主键 → 捕获 Duplicate Key → 只查不更新已成功状态

状态机再补一刀:只允许"未支付 → 支付成功 → 已退款"的合法迁移,已成功的单子收到重复通知直接幂等返回,绝不再改金额。本博客 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 原子命令,由存储层保证"只有一个能插进去"。
  • 带时间戳 / 随机数的请求不适用简单重试:比如"用当前时间生成签名"的请求,重试时签名已变,服务端会当成新请求。此类场景应把幂等键独立出来(业务单号),而不是依赖会变的载荷。
  • 超时重试的"结果未知"窗口要靠查询兜底:扣款请求超时后,客户端不该盲目重试扣款,而应先调用"订单查询接口"确认状态——查不到再重试。幂等键防的是"重复执行",查询接口解决的是"到底执行了没有"。
  • 幂等 ≠ 忽略并发:两个不同用户拿同一单号(或同一用户并发两个不同单号)是两种问题。幂等只承诺"同一请求重复执行安全",并发下的正确性仍需锁、唯一约束与状态机共同保证。

六、对比表 + 🐾小结

维度 无幂等保护 有幂等保护
客户端重试 副作用叠加(扣两次款) 副作用只发生一次
消息重复投递 重复消费、重复发奖 去重后安全消费
超时后处理 不敢重试或盲目重试 放心重试 + 查询兜底
实现成本 零(但事故成本高) 幂等键/唯一索引/状态机
对用户体验 双扣、超卖、投诉 无感重试、体验稳定

🐾 小结:幂等性不是让"请求不会重复",而是让"重复变得无害"。它把分布式系统里最不可控的变量——网络——从事故源头降级为普通噪声:该重试就重试,反正副作用只发生一次。设计任何"执行后会产生副作用"的接口时,都值得先问一句:如果这个请求被原样重放一百遍,系统还安全吗?答案如果是否定的,那就该给它配一把幂等键了。