25.4 幂等设计与去重

一句话

幂等让 “同一请求执行 N 次” 与 “执行 1 次” 等价;工程上做不到 “不重发”,只能靠稳定幂等键 + 唯一约束兜底 + 结果一起存把重试变成安全操作。

先破除三个误区

误区 现实
「超时就说明失败了,重发没事」 超时只说明你没收到响应,请求可能已经在对端成功。失败与成功在网络上不可区分
「我让客户端保证只发一次」 用户连点、SDK 自动重试、网关重试、MQ 重投、运维手工重放,任何一条链路存在,重复就会发生
「幂等就是加个 if 判断一下有没有处理过」 先查后写有竞态;判重记录如果和业务结果不同生命周期,会出现「记了没做」或「做了没记」

前提:at-least-once 是默认,exactly-once 是幻觉

投递语义 保证 代价 谁提供
at-most-once 最多一次,可能丢 丢了不管 发完即忘(fire and forget)
at-least-once 至少一次,可能重 必须幂等 所有带重试 / ack 的链路,事实上的默认
exactly-once 恰好一次 只有「at-least-once + 去重」这一种实现路径 不是网络能力,是业务能力

🔑 网络上不存在 exactly-once。Kafka 的 “exactly-once” 也只在自己事务边界内成立;一旦跨出边界 (发短信、调支付、写外部库),去重责任立刻回到你的业务代码。

先分清:哪些操作天生幂等,哪些必须自己做

操作 天生幂等 说明
SELECT / GET ✅ 只读
DELETE WHERE id = ? ✅ 结果幂等:删一次和删十次,最终状态相同
UPDATE SET balance = 500(赋值) ✅ 写的是绝对值
UPDATE SET balance = balance - 100(自减) ❌ 每执行一次少 100
INSERT ❌ 除非有唯一键拦住
扣库存、扣余额、发券、发短信、调支付 ❌ 有副作用,且部分副作用不可撤回

💡 幂等设计的第一优先级不是 “加去重表”,而是把操作改造成天生幂等:能赋值就不要自减,能 DELETE 就不要 “先查再删”,能带 WHERE status = 'INIT' 就不要无条件 UPDATE。

五种实现,按代价从低到高

方式 原理 适用 缺口
① 天然幂等改造 赋值代替自增、条件更新带上旧状态 状态推进、配置下发 只对部分场景可行
② 唯一键去重 dedup 表 / 业务唯一索引,插入冲突即「已处理」 创建类、支付、下单 需要一张表和一条保留期策略
③ 状态机 + CAS UPDATE ... WHERE status = 旧状态,看 affected rows 订单流转、TCC 分支 状态设计错了就拦不住
④ 幂等令牌 服务端先发 token,执行时消费 token(一次性) 防重放、表单提交 多一次 RTT;token 存储要可靠
⑤ 结果缓存 把首次的响应体存起来,重复请求原样返回 查询类、异步任务查询 存储成本;响应含时间戳 / 随机数就不适用

**② 和 ③ 是主力,④ 是补充,⑤ 是体验优化。**只做 ② 不做 ⑤,会出现 “第一次返回成功、第二次返回失败”——调用方看到失败会继续重试,等于白做幂等。

幂等键怎么设计

幂等键 = 业务主体 + 业务动作 + 请求标识,三个都要,缺一个就会误合并或漏合并。

写法 效果 评价
user_id 一个用户的所有请求共用一个键 ❌ 太粗:第二笔订单被当成重复丢弃
user_id + 时间戳(毫秒) 每个请求都不同 ❌ 太细:重试时时间戳变了,去重完全失效
服务端收到请求后生成 UUID 每次重试都是新键 ❌ 最隐蔽的错误:看起来有键,实际零去重
order_id + "PAY" 同一订单的支付动作唯一 ✅ 自然唯一键,优先用这个
request_id(调用方生成,重试时复用) 跨服务通用 ✅ 没有自然唯一键时的标准答案

三条铁律:

  1. **幂等键必须由调用方生成,并在重试时原样复用。**服务端自己生成 = 没有键。
  2. 优先用业务自然唯一键。order_id 天然把 “同一笔业务” 映射到同一条记录,不依赖任何额外约定。
  3. **粒度对准 “一次业务意图”,不是 “一次 HTTP 请求”。**用户连点两次提交,是同一个意图,应该共用键。

幂等记录必须与业务结果同事务

这是幂等实现里唯一会 “悄悄错” 的地方。两种典型错法:

错法 A:先去重,后执行业务
  INSERT dedup(key)  → 成功
  执行扣款           → 进程崩溃
  结果:key 永久标记为已处理,钱没扣。重试被去重表挡住,永久丢单。

错法 B:先执行业务,后写去重
  执行扣款           → 成功
  INSERT dedup(key)  → 进程崩溃
  结果:钱扣了,去重记录没写。重试再扣一次,重复扣款。

正确做法只有一个:同库同事务。

BEGIN;
-- ① 占位:唯一键冲突 -> 说明已处理过,回滚并返回旧结果
INSERT INTO transfer_dedup (idem_key, biz_id, status, created_at)
VALUES (?, ?, 'DONE', NOW());
-- ② 业务写入(与①同一个事务)
UPDATE account SET balance = balance - ? WHERE user_id = ? AND balance >= ?;
COMMIT;

跨库做不到同事务时,退化到本地消息表 (24.6) 或事务消息 (24.7):先把 “要去重” 和 “要执行” 写进同一个本地事务,再由后台任务推进下游。不要用 “先写 Redis 再写 DB”——

⚠️ Redis 的 SETNX 成功、DB 写失败,是最常见的一类幂等 bug:标记有了,业务没了。Redis 只能做 “快速挡板”,最终裁判必须是数据库的唯一约束。

中间态:PENDING 与悬挂

真实实现里,请求会长时间处于 “已占位、未完成”,于是需要三态:

状态 含义 重复请求怎么处理
PENDING 已占位,正在执行 返回「处理中」(或短轮询),不能假装成功
DONE 已成功 原样返回首次响应
FAILED 已失败(业务性) 返回首次错误,不重试

两个必须处理的坑:

  • PENDING 过期清理:进程崩溃会留下永久 PENDING。需要一个带 “持有者 + 租约” 的清理任务,否则这个键要么永远卡住,要么被误删。
  • 删除即悬挂:清理任务 DELETE 掉过期记录后,迟到的旧请求重试会重新执行一次。清理只能把状态改成 EXPIRED 并保留一段时间,不能真删。

幂等窗口与存储成本,可以算出来

保留期有一个必须满足的不等式:

保留期 > 最大重试总时长 + 时钟偏差 + 人工重放窗口

算一个例子:客户端退避重试 5 次约 30s,MQ 最多重投 24h,财务 T+1 对账可能人工重放 → 保留期至少 48h。

再算存储:1 万 QPS 的支付接口,48h = 172800s → 17.3 亿条记录;单条 100B (键 + 状态 + 时间) → 约 173GB。所以保留期必须按业务分级,不能一刀切:

业务 重复的后果 建议保留期
支付、转账 资损,不可挽回 7–30 天
下单、扣库存 超卖 / 少卖 3–7 天
发通知、发短信 用户多收一条 1–24 小时
埋点、日志上报 统计偏差 10 分钟

可运行的 Go 实现

把 “业务表 + 去重表” 放进同一把锁里模拟同一个本地事务 (go run main.go 直接可跑):

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// Store 同时承载「业务表」和「去重表」。
// 真实实现里两者同库,由同一个本地事务保证原子性。
type Store struct {
	mu      sync.Mutex
	balance int64
	dedup   map[string]string // 幂等键 -> 首次执行的响应
}

func NewStore(balance int64) *Store {
	return &Store{balance: balance, dedup: make(map[string]string)}
}

// Transfer 扣款。返回(响应, 是否真正执行)。
func (s *Store) Transfer(idemKey string, amount int64) (string, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()

	// ① 占位:等价于 INSERT INTO transfer_dedup(idem_key),
	//    唯一键冲突就说明这个请求处理过,直接返回首次结果(幂等 + 结果缓存)。
	if resp, ok := s.dedup[idemKey]; ok {
		return resp, false
	}

	// ② 业务校验与执行。失败也必须落去重记录,
	//    否则「余额不足」被重试时会反复进入,永远收敛不了。
	if s.balance < amount {
		resp := "REJECTED: 余额不足"
		s.dedup[idemKey] = resp
		return resp, true
	}
	s.balance -= amount
	resp := fmt.Sprintf("OK: 扣款 %d 成功", amount)
	s.dedup[idemKey] = resp
	return resp, true
}

func main() {
	const n = 100
	const amount = 10

	// 场景 ①:幂等键由调用方生成,重试时原样复用 -> 100 个并发请求只执行 1 次
	s1 := NewStore(1000)
	var exec1 int64
	var wg1 sync.WaitGroup
	for i := 0; i < n; i++ {
		wg1.Add(1)
		go func() {
			defer wg1.Done()
			if _, executed := s1.Transfer("order-9527/PAY", amount); executed {
				atomic.AddInt64(&exec1, 1)
			}
		}()
	}
	wg1.Wait()
	fmt.Printf("① 复用幂等键:%d 个并发请求,实际执行 %d 次,余额 %d\n", n, exec1, s1.balance)

	// 场景 ②:每次重试都生成新 UUID -> 幂等键形同虚设
	s2 := NewStore(1000)
	var exec2 int64
	var wg2 sync.WaitGroup
	for i := 0; i < n; i++ {
		wg2.Add(1)
		go func(i int) {
			defer wg2.Done()
			if _, executed := s2.Transfer(fmt.Sprintf("uuid-%d", i), amount); executed {
				atomic.AddInt64(&exec2, 1)
			}
		}(i)
	}
	wg2.Wait()
	fmt.Printf("② 每次新 UUID:%d 个请求,实际执行 %d 次,余额 %d(本应为 990)\n", n, exec2, s2.balance)
}

输出:

① 复用幂等键:100 个并发请求,实际执行 1 次,余额 990
② 每次新 UUID:100 个请求,实际执行 100 次,余额 0(本应为 990)

场景 ② 不是 “并发 bug”,是键设计错了:键不同,去重表就认为它们是 100 笔不同业务。这一行输出就是很多线上重复扣款的真实成因。

各层该做什么、不该做什么

层 该做 不该做
客户端 / SDK 生成稳定的 request_id、指数退避、重试时复用同一个键 每次重试重新生成 UUID
网关 / 代理 透传 request_id 自动重试 POST(不幂等的接口会被放大)
服务端 去重 + 状态机 + 存首次响应 用内存 map 做去重(重启即失效,多实例直接失效)
数据库 唯一索引兜底 依赖应用层「先查后插」
MQ 消费端 按消息里的业务键去重 假设「消息不会重投」

反模式

反模式 后果
用每次请求新生成的 UUID 当幂等键 零去重,重试即重复扣款
Redis SETNX 成功后就认为业务已完成 Redis 成功、DB 失败 → 标记有、钱没扣,永久丢单
去重表单独用一个库 失去本地事务,业务成功而标记失败,重复执行
只去重不缓存响应,第二次返回「重复请求」错误 调用方按失败处理继续重试,去重失去意义
失败请求不留去重记录 「余额不足」被无限重试
幂等键按 user_id 设计 同一用户的第二笔正常请求被吞掉
只做应用层内存去重 多实例各自去重,等于没做;重启后全丢
清理任务 DELETE 过期幂等记录 迟到重试重新执行一次
把 at-least-once 当成 exactly-once 来设计消费端 重复消费导致重复发券、重复加分

自检

Q1 你给支付接口加了去重表,压测发现 “重复请求返回成功,但钱只扣了一次”——看起来完美。上线后财务收到 3 笔重复扣款的投诉。最可能的原因是什么?

▸ 答案:去重键不是在调用方生成的,而是服务端收到请求后生成的,或者客户端两次提交来自不同入口 (App 与 H5) 各自生成了不同的 request_id。去重表天然全部命中 “新键”,一次都没挡住。判定方法:打印同一笔业务的三次请求的幂等键,如果三个不一样,问题不在去重逻辑,在键的来源。

Q2 为什么 “先写 Redis 占位,再写数据库” 的幂等方案在崩溃场景下必然出错,而 “同库同事务” 不会?

▸ 答案:跨两个存储就没有原子性,中间崩溃必然产生 “一半成功”:Redis 成功 + DB 失败 = 丢单;DB 成功 + Redis 失败 = 重复。同库同事务由数据库的原子性保证两者要么都写、要么都不写,因此不存在中间态。Redis 只能作为前置挡板减少无效请求,最终裁决权必须在数据库唯一约束上。

Q3 幂等记录的保留期该设多久?说出推导过程,而不是给一个数字。

▸ 答案:保留期必须覆盖 “最后一次可能出现的重试”:最大重试总时长 + 时钟偏差 + 人工重放窗口。它由重试策略 (客户端退避次数、MQ 重投上限、对账周期) 决定,不由业务心情决定。设短了,迟到重试会重复执行;设长了,存储成本线性上涨 (1 万 QPS × 48h ≈ 17 亿条 ≈ 173GB),所以要按 “重复一次损失多大” 分级。