编程 幂等防重不止唯一索引:幂等键的四层拦截与三个失效点

2026-10-19 00:04:20

幂等键放在哪一层:网关、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、命中结果,「首次 / 重复 / 处理中 / 键复用冲突」四种要能区分——线上排查时这四个分支的处理方式完全不同。

参考

复制全文 生成海报 幂等防重 幂等键 Redis MySQL 重复提交

推荐文章

程序员茄子在线接单