你的接口重试三次,数据库就多扣三次钱:幂等性设计的正确姿势

2026-11-01 11 0

先讲一个我亲眼见过的翻车现场:某支付接口下游超时,网关自动重试了两次,用户卡里被扣了三笔钱。客服打电话来时,那位开发的第一句话是——"我明明加了重试啊,重试是为了可用性啊"。

没错,重试是为了可用性。只不过同一个人、同一份重试,也在同一瞬间把"幂等"两个字从字典里删掉了。

这篇不聊微服务、不聊 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 来保证的?

前者是幂等,后者只是运气好。

相关文章

你的服务不慢,是卡住了:eBPF 火焰图实战,5 分钟定位线上 P99 飙升
你的服务每秒在做几千次加密握手,而 99% 的握手都是白干的:TLS 连接复用的真相
数据库慢得像个拖拉机?先别骂数据库,这 7 个坑有一半是你自己挖的
Raft 里那个 300ms 的随机超时,可能是分布式系统里最反直觉的一行代码
你的服务每秒在 JSON 上烧掉的 CPU,比你以为的多一个数量级
Go 的调度器凭什么敢让十万个 goroutine 狂飙?GMP 三件套,一次讲透

发布评论