先讲一个我亲眼见过的翻车现场:某支付接口下游超时,网关自动重试了两次,用户卡里被扣了三笔钱。客服打电话来时,那位开发的第一句话是——"我明明加了重试啊,重试是为了可用性啊"。
没错,重试是为了可用性。只不过同一个人、同一份重试,也在同一瞬间把"幂等"两个字从字典里删掉了。
这篇不聊微服务、不聊 K8s,咱们把"幂等"拆开聊:它到底在防什么、怎么分层防,以及哪些网红方案看着很美、实则一戳就破。
一、幂等不是"重复执行结果一样",是"只生效一次"
这是最常见的理解偏差。网上一搜"幂等",满屏都是"同一个请求执行多次和执行一次的效果相同"。它有个致命漏洞:它讲的是"效果",而工程上真正要保证的是"副作用只发生一次"。
// "幂等"的写法
func Transfer(from, to string, amount int64) error {
if err := deduct(from, amount); err != nil {
return err
}
return add(to, amount)
}
连续调用两次,扣款两次、入账两次——从"账目结果"看确实一样(两边等量抵消),但余额曲线已经变形,银行流水多了两条,客户对账单更是一脸问号。
所以我更喜欢这个定义:
幂等 = 对同一份业务意图,无论请求被投递几次,业务状态的变更最多发生一次,且结果对调用方保持一致。
两个关键词:业务意图(而不是"HTTP 请求")、状态变更次数(而不是"最终值")。定义一改,很多设计就自然浮出水面了。
二、为什么它比你想的难:重试不是唯一敌人
大多数人以为幂等是防"用户手抖点两下"。真正制造重复的元凶,按危险程度排序是:
- 网关/SDK 的自动重试:超时、502、连接重置,客户端完全不知道服务端到底执行没执行。经典的"薛定谔的请求"。
- MQ 的至少一次投递:重复是常态而非异常,消费端不做幂等等于在生产环境随机埋雷。
- 用户重复点击:这个反而最好解决,因为意图清晰。
- 多副本定时任务同时执行:没有分布式锁的定时任务,等于每分钟自杀一次。
- 人工补偿操作:运维手工"再退一次款",结果退了两次。
你的接口面对的不是"恶意重复请求",而是"必然发生的重复请求"。在网络世界里,重复不是异常,重复是默认状态。
把幂等当成"防手抖"来做,等于把安全带系在后备箱上。
三、三层防线:别指望一层扛住所有
第一层:数据库唯一索引——最后的兜底
任何方案落地时都应该有数据库这层兜底,因为它是唯一"绝对不会重复"的东西。
CREATE UNIQUE INDEX uk_idem ON idempotency_records (scope, idem_key);
CREATE TABLE idempotency_records (
scope VARCHAR(64) NOT NULL,
idem_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- IN_PROGRESS / DONE / FAILED
response TEXT,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (scope, idem_key)
);
插入时直接用唯一索引做并发控制,而不是先 SELECT 再 INSERT:
-- INSERT 冲突即代表"这单已经在处理了"
INSERT INTO idempotency_records (scope, idem_key, request_hash, status)
VALUES (?, ?, ?, 'IN_PROGRESS')
ON CONFLICT (scope, idem_key) DO NOTHING;
-- 影响行数为 0 => 已存在,走"查已有记录"分支
这一步能挡掉并发重复执行。很多团队爱用 Redis 做幂等键,但 Redis 挂了、TTL 到了、key 被淘汰,幂等保证就"啪"一下没了——而且专挑生产环境最忙的时候没。Redis 可以挡在前面挡流量,兜底必须在数据库。
另一个坑:幂等记录要和业务写在同一事务里。先写幂等记录再写业务、中途崩了,幂等记录说"处理过了"但业务根本没发生——这种幽灵状态比重复扣款更难查。
第二层:幂等键 + 状态机
流派 A:幂等键由客户端生成(推荐用于写接口)。客户端首次请求生成 UUID 附在请求头,重试时沿用同一个 key。服务端按状态处理:
- 没有记录 → 插入 IN_PROGRESS,执行业务,落库结果,改成 DONE
- IN_PROGRESS → 另一次请求正在处理,返回"处理中",让客户端稍后再问
- DONE → 直接把缓存的 response 原样返回
关键点:返回第一次的原始结果,而不是重新执行一遍。否则第一个请求扣了 100 并返回成功,第二个请求重新扣了 100 也返回成功,语义已经乱了。
流派 B:请求指纹(适合无状态场景)。不要求客户端带 key,服务端对请求体做 hash(排序后的参数 + 路径 + 关键身份),同 hash 在 TTL 窗口内复用上次结果。省去前端改造,个人项目我更愿意用这个——前端加 key 总会有人忘。
注意:指纹必须包含"谁发的"。否则用户 A 和 B 传了相同参数会被判成重复请求,这种 bug 通常表现为"偶尔有用户说点了没反应",极难复现。
第三层:业务层面的自然幂等
最高级的幂等,是让操作在业务语义上就重复不了:
- 转账用唯一流水号,而不是靠"余额是否够"防重。
- 状态机只允许单向流转,用影响行数做乐观锁:
UPDATE orders SET status='PAID' WHERE id=? AND status='PENDING',影响 0 行即已处理。 - 库存扣减用
UPDATE stock SET n=n-1 WHERE id=? AND n>=1,天然幂等且不超卖。
这三层是纵深的,不是三选一:第一层挡并发,第二层挡重试,第三层挡"业务逻辑自己手抖"。
四、重试风暴:幂等做对了也能被重试打死
幂等做好了,客户端就敢放心重试;敢重试,后端负载就涨;后端越慢,重试越狠。典型正反馈。
幂等只让"重试安全",并不会让"重试聪明"。要聪明得靠这几件事:
// 指数退避 + 抖动(jitter 不是可选的,是必需的)
delay = min(base * 2^attempt, maxDelay)
delay = random(delay * 0.5, delay) // Full Jitter
为什么必须有 jitter?1000 个客户端同时被拒、退避窗口完全一致,会在同一毫秒集体砸回来——服务器刚缓过来又被自己人打回去。没有 jitter 的指数退避,本质上还是一次同步惊群。
另外三个常被忽略的点:
1. 只重试可重试的错误。 400、401、422 重试一万次结果一样,纯浪费。5xx 和网络超时才值得。
2. 给重试设预算。 不只是次数上限(最多 3 次),还要总时长上限。服务无脑重试,最后往往是重试链条把用户耐心耗光。
3. 服务端也要有熔断。 幂等让重试安全,但服务挂掉时重试只会更快把挂掉的服务埋掉。连续失败到阈值就快速失败,别让每个请求都去等 30 秒超时。
重试的正确姿势不是"失败了多试几次",而是"有限次、有抖动、有预算、只针对可重试错误"。
五、几个看着很美、实则有毒的方案
「用分布式锁保证只执行一次」——锁只能保证"同一时刻只有一个在执行",保证不了"只执行一次"。服务拿到锁后、执行完成前挂掉,锁自动释放,第二次请求拿到锁会老老实实再执行一遍。锁必须配合幂等记录一起用:锁是性能优化,幂等记录才是正确性保证。
「用 MQ 顺序消费保证不重复」——顺序消费解决的是顺序问题,不是重复问题;而且顺序消费通常意味着并发度下降,等于用吞吐换了个不该换的东西。
「直接用 Redis SETNX 当幂等键,TTL 24 小时」——除了可靠性问题,还有个更隐蔽的坑:如果业务失败回滚了,Redis 里的幂等键还在,后续所有重试都会被判为"已处理"并返回"成功"。这叫假成功,比失败更可怕——用户以为充值成功,钱没到账。解法是幂等记录要有明确状态流转,失败时主动删除或标记 FAILED,让重试能真正重跑。
「先查询再插入就不重复了」——
// 错误示范
if !exists(key) { // T1 和 T2 同时走到这里,都认为不存在
doBusiness() // 业务被执行两次
save(key)
}
教科书级的 check-then-act 竞态,实际代码里屡见不鲜,尤其在"我觉得我们 QPS 不高"的地方——然后某天上了个活动,QPS 翻十倍,事故当天你就懂了。
六、一份能直接抄的清单
- 所有写接口先问:这个操作重复执行业务上会怎样? 答案是"会多扣一次",就必须有幂等设计。
- 定好幂等键来源:客户端 UUID(写操作)或请求指纹(无状态场景),作用域按"用户 + 业务类型"隔离。
- 数据库唯一索引兜底,幂等记录与业务写入同事务。
- 幂等记录带状态机:IN_PROGRESS / DONE / FAILED,失败要能被重试。
- 请求指纹必须包含调用者身份,不能只 hash 参数。
- 重复请求返回第一次的原始结果,不要重新执行。
- 重试加指数退避 + jitter,只针对可重试错误,设次数与时长双重预算。
- 服务端加熔断和限流,别让重试把自己的服务打死。
- 业务层用状态机 + 乐观锁,把"重复执行"在语义层面也变成无害操作。
七、最后
回到开头扣三笔钱的故事。那位开发的问题不是"没想清楚幂等"这么简单,而是把幂等当成了一个需要额外开启的功能。
更现实的说法是:在有网络的地方,重复就是世界的默认状态。你的系统必须假设每条消息都可能被投递至少两次,然后在这个前提上设计出正确的行为。这不是防御性编程式的洁癖,这是分布式系统的物理定律。
所以,下次你准备写 if (alreadyProcessed) return; 的时候,请先确认:那个 alreadyProcessed 是不是由数据库的唯一索引、而不是由你的 if 来保证的?
前者是幂等,后者只是运气好。