幂等键放在哪一层:网关、Redis、唯一索引和状态机的四道拦截
下单接口上线第二周,监控里出现同一订单三条支付流水。翻日志:用户在弱网下连点三次,网关超时后又重试了一次,回调经 MQ 至少一次投递又重复消费了一次。三条记录的时间戳差了不到 800 毫秒,业务代码每一行单看都没问题——问题不在某一行,而在于没有任何一层拦住「同一个请求被处理两次」。
先把范围划清楚:本文只讲幂等键怎么设计、放在哪几层拦截、在哪几个点上会失效。订单关单之后库存、优惠券、额度按什么顺序释放,是资源补偿的话题;支付回调的验签、金额校验、状态流转,是渠道对接的话题。这两块都够单独写一篇,混在一起讲容易把「幂等」和「事务」搅成一团。
幂等只在写操作上成立
幂等是操作属性,并非接口属性。GET 天然幂等;DELETE 按主键删除天然幂等;赋值更新 UPDATE t SET status='PAID' WHERE id=? 幂等,累加更新 UPDATE t SET balance=balance-100 WHERE id=? 不幂等——执行两次扣两次。
前端按钮置灰、防抖、loading 只能降低概率,不能当作保证:刷新页面后状态就丢了、抓包可以绕过前端、网关重试和 MQ 重投根本不经过浏览器。真正的边界在服务端,并且必须落在「写」的那一步上。
重复请求不只来自用户连点
- 用户连点、刷新页面重提
- 网关与 HTTP 客户端超时重试(请求其实已经到了)
- MQ 至少一次投递、消费失败重投
- 定时任务多实例同时触发
- 第三方支付渠道多次回调
最后一条最容易被漏掉:渠道侧重试几次、间隔多久,不归你控制。
幂等键:谁生成、粒度多大、参数怎么参与
由客户端或上游生成,服务端不要替它生成。服务端生成的键,客户端重试时拿不到同一个,第二次请求会拿到一个新键,等于没有幂等。
三种生成方式:
- UUID v4:实现最简单,可读性差,客户端必须自己持久化(放在内存里,进程重启就没了)。
- 业务语义键:如
order_20260413001_pay,可读、天然去重,前提是「单号 + 动作」能唯一定位一次业务。 - 请求体 SHA-256:不需要客户端配合,但要求序列化稳定——map 遍历顺序一变,同一个请求就算出不同哈希。
粒度是业务动作,不是 HTTP 请求。同一订单的「支付」和「退款」是两个键,不能共用。
Stripe 的做法可以直接参考:请求头 Idempotency-Key(最长 255 字符),服务端把「键 + 请求参数指纹 + 响应体」一起存下来,重复请求返回首次响应;键保留 24 小时;键存在但请求参数和首次不一致时返回 422,防止客户端错误复用一个键去发起不同请求。
参数指纹用字段顺序固定的结构体序列化后再哈希:
func ParamsHash(v any) (string, error) {
b, err := json.Marshal(v) // 传 struct,不要传 map:字段顺序才稳定
if err != nil {
return "", err
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:]), nil
}
四层拦截与顺序
第一层:入口中间件
中间件只做三件事:取键、校验、往下传。校验包括长度上限(对齐 Stripe 的 255 字符)、字符集、缺失即拒绝。写操作没带键就直接 400,不要「自动补一个」——补出来的键每次都不一样。
var keyPattern = regexp.MustCompile(`^[A-Za-z0-9_.:-]{8,255}$`)
type idemKeyCtx struct{}
func IdempotencyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}
key := r.Header.Get("Idempotency-Key")
if !keyPattern.MatchString(key) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"INVALID_IDEMPOTENCY_KEY"}`))
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), idemKeyCtx{}, key)))
})
}
第二层:Redis 原子占用
两种语义,不要混用:
- 抢占用:
SET key val NX EX ttl,成功的那一个请求继续,其余快速拒绝或返回处理中。 - 一次性消费:GETDEL,存在则取值并删除。Redis 6.2 起有原生命令:
val, err := rdb.GetDel(ctx, "idem:"+key).Result()
if errors.Is(err, redis.Nil) {
// 键不存在:首次请求或已过期
}
低版本用 Lua 保证原子:
-- consume.lua:存在则删除并返回 1,不存在返回 0
local v = redis.call('GET', KEYS[1])
if v then
redis.call('DEL', KEYS[1])
return 1
end
return 0
要强调的是:GET 之后再 DEL 不是原子操作,两个并发请求可以都读到值,然后都删掉、都往下走。这不是理论问题,压测很容易复现。
第三层:MySQL 唯一索引兜底
Redis 会宕机、会过期、会被误清,最终事实必须在数据库。
幂等记录表:
CREATE TABLE idem_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
idem_key VARCHAR(255) NOT NULL,
biz_type VARCHAR(32) NOT NULL,
biz_no VARCHAR(64) NOT NULL,
params_hash CHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0=处理中 1=成功',
response JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_idem (idem_key),
KEY idx_biz (biz_type, biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
唯一键也可以直接加在业务表上(例如订单表的支付流水号),但对于「一个业务单号 + 一个动作」这种通用维度,独立记录表更好扩展,还能顺手存下首次响应。
重复插入时 MySQL 返回错误码 1062(SQLSTATE 23000)。Go 里判断错误码,不要匹配报错文本——文本随版本、字符集、驱动都可能变:
func IsDuplicateKey(err error) bool {
var me *mysql.MySQLError
if errors.As(err, &me) {
return me.Number == 1062
}
return false
}
第四层:状态机条件更新
最后一层防线是业务状态自己。把状态写进 WHERE:
res, err := tx.ExecContext(ctx,
"UPDATE orders SET status='PAID', paid_at=NOW(3) WHERE id=? AND status='PENDING'", orderID)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if rows == 0 {
// 已经被推进过,或者状态非法:回查当前状态再决定返回什么
}
RowsAffected() 为 0 有两种含义,必须再查一次当前状态,才能区分「已经处理过(返回成功)」和「状态冲突(返回错误)」。不查就一律返回失败,会把上游推进重试循环。
顺序原则:Redis 做流量削峰和快速拒绝,数据库做最终事实。只有 Redis 没有唯一索引,Redis 宕机或锁提前过期,重复写照样落到库里。
重复请求返回什么
返回和首次一致的业务结果 + 200。
返回 409 或 400 是最常见的错误:上游和支付渠道把 4xx 当失败,会继续重试,重试又打到同一层,形成重试风暴,最后把限流打穿。只有真正参数不一致、键被复用时,才返回 422(或 409)并给出明确错误码。
处理中状态(PROCESSING)需要单独的语义:首次请求还在执行,第二次进来时返回「处理中,请稍后查询」。不要返回成功(业务还没成),也不要返回失败(会触发立刻重试)。这个状态要有查询接口承接,否则上游还是只能盲等。
三个典型失效点
1. 每次重试都重新生成键
最彻底的一种失效:键由调用方每次请求现场生成,重试拿到新键,服务端看到的是两个毫不相干的请求。键必须由「用户 + 业务单号 + 动作」派生,或者在客户端第一次生成后持久化复用。
2. 先 SELECT 再 INSERT
// 错误:两个并发请求都查不到,都插入
var n int
_ = db.QueryRow("SELECT COUNT(*) FROM idem_record WHERE idem_key=?", key).Scan(&n)
if n == 0 {
_, _ = db.Exec("INSERT INTO idem_record ...")
}
查询和插入之间没有原子性。必须靠唯一索引裁决,或者用 INSERT ... ON DUPLICATE KEY UPDATE / INSERT IGNORE,或者在同一事务里先插幂等记录再执行业务。
3. 去重记录与业务写入分离,以及 TTL 短于重试窗口
去重记录先提交成功、业务事务随后回滚,后续重试被当成重复直接跳过,这笔数据就永久缺失;反过来业务成功、记录没写,就会重复执行。底线是两条写入在同一个本地事务里;跨库时改用 outbox,不要上跨库事务。
TTL 必须覆盖真实的重试窗口,而不是拍脑袋写 5 分钟:
- 下单:10–30 分钟
- 支付:24 小时(对齐渠道侧保留期)
- webhook:数小时
TTL 短于重试窗口,后排的重试就会被当成新请求,前面几层拦截全部白做。
怎么验证
- 并发打 50–100 个相同键的请求,只允许一次真正执行,其余全部返回首次结果或「处理中」。
- 模拟业务中途抛异常回滚,再拿同一个键重试,确认能正常执行,而不是被去重记录挡死。
- 对账:订单表、资金流水表、去重表三边交叉核对,数量、金额、状态都要能对上。
- 日志必须能打出幂等键、请求 ID、命中结果,「首次 / 重复 / 处理中 / 键复用冲突」四种要能区分——线上排查时这四个分支的处理方式完全不同。