几乎所有做过支付的同学都写过“幂等”两个字,但真正把它做对的人并不多。一个常见的误区是:给请求加个 request_id、建张唯一索引,就以为万事大吉。可当网络抖动、用户狂点、回调乱序同时发生时,系统依然会重复扣款。
幂等的本质不是“去重”,而是“让同一个业务意图,在任意重试次数下只产生一次副作用”。
一、先分清三种“重复”
在动手之前,我们必须区分三种不同层面的重复,它们的解法完全不同:
- 传输层重复:客户端超时重试,同一个 HTTP 请求到达多次。
- 业务层重复:用户真的点了两次“支付”,生成了两个不同的请求。
- 回调层重复:上游渠道的通知被重复推送,或乱序到达。
把这三者混为一谈,是大多数线上事故的根源。传输层重复靠 request_id 解决;业务层重复要靠业务幂等键;回调层重复则必须依赖状态机。
二、用状态机约束一切
支付单的生命周期天然是一个状态机。任何一次状态跃迁,都必须满足前置条件,并且只能沿着合法边进行。这样即使回调重复到达,第二次也会因为“状态已终态”而被安全丢弃。
UPDATE payment
SET status = 'SUCCESS', updated_at = NOW()
WHERE order_no = ?
AND status = 'PROCESSING'; -- 前置状态约束
-- affected_rows = 0 说明已经被处理过,直接返回成功即可
注意最后一行:重复请求应当返回与首次相同的结果,而不是报错。很多团队在这里返回“重复请求”,结果客户端以为失败又重试了一次。
三、去重表:别把幂等键塞进业务表
如果所有幂等逻辑都挂在业务表上,表结构会迅速腐化。更好的做法是引入一张独立的幂等去重表,它只做一件事:记录“这个幂等键处理到哪一步了”。
CREATE TABLE idempotency_record (
idem_key VARCHAR(128) PRIMARY KEY, -- 幂等键
biz_type VARCHAR(32) NOT NULL,
status TINYINT NOT NULL, -- 0处理中 1成功 2失败
response TEXT, -- 首次结果快照
expire_at DATETIME NOT NULL
);
四、小心“处理中”这个中间态
上面的方案有个经典陷阱:如果首次请求在处理中途宕机,幂等键会永远停在“处理中”,后续请求全部被拒。这就是所谓的僵尸锁。解决办法是给“处理中”加一个租约(lease),超时后允许其他请求接管。
永远假设“处理到一半就崩溃”是常态,而不是异常。
五、一个可落地的检查清单
- 幂等键由谁生成?—— 推荐服务端基于业务语义生成。
- 幂等键的粒度是什么?—— 必须与“一次不可分割的业务意图”对齐。
- 重复请求返回什么?—— 首次结果的快照,而不是错误码。
- 中间态有超时机制吗?—— 必须有租约与接管逻辑。
- 有没有对账兜底?—— 幂等防重复,对账防遗漏,两者缺一不可。
幂等不是一个函数,而是一整套围绕状态与副作用建立的纪律。当你把状态机、去重表、租约和对账组合起来,支付链路才真正称得上“可靠”。