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(调用方生成,重试时复用) | 跨服务通用 | ✅ 没有自然唯一键时的标准答案 |
三条铁律:
- **幂等键必须由调用方生成,并在重试时原样复用。**服务端自己生成 = 没有键。
- 优先用业务自然唯一键。
order_id天然把 “同一笔业务” 映射到同一条记录,不依赖任何额外约定。 - **粒度对准 “一次业务意图”,不是 “一次 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),所以要按 “重复一次损失多大” 分级。