接口幂等设计:防重、防并发、防乱序

“重复请求是常态,不是异常”——用户双击、前端重试、MQ 重投、网关重放,接口被调两次是必然事件。幂等设计的核心:同一个请求执行 N 次,效果等于执行 1 次。

一、哪些接口必须幂等

接口类型 风险 是否必须幂等
查询 无副作用 天然幂等
创建订单 重复下单 必须
支付/扣款 重复扣钱 必须
转账 重复转账 必须
更新状态 重复更新(同值) 天然幂等(同值覆盖)
删除 重复删除 天然幂等(不存在也成功)

判断标准:“重复执行会不会产生新副作用”——会,就必须幂等。

二、幂等实现方案

方案 1:幂等键(客户端生成)

客 服 户 务 端 端 : : 每 以 次 幂 " 等 业 键 务 为 操 唯 作 一 " 索 生 引 成 , 一 首 个 次 唯 执 一 行 键 、 ( 后 u 续 u 直 i 接 d 返 ) 回 首 次 结 果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 客户端:POST /orders
// Header: Idempotency-Key: 5b2c8f1e-...
func CreateOrder(ctx, req) (*Order, error) {
    key := req.IdempotencyKey    // 客户端生成的唯一键
    // 查已有结果
    if result, ok := idemCache.Get(key); ok {
        return result, nil       // 重复请求:返回第一次的结果
    }
    order, err := createOrderTx(req)
    if err == nil {
        idemCache.Set(key, order, 24*time.Hour)   // 存首次结果
    }
    return order, err
}

关键:重复请求必须返回"第一次的结果"(不是"已处理"提示,是同一个响应)。

方案 2:唯一索引(数据库兜底)

1
2
3
4
5
6
7
8
9
-- 订单表:业务唯一键唯一索引
CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  biz_key VARCHAR(128) NOT NULL,     -- 业务幂等键
  ...
  UNIQUE KEY uk_biz_key (biz_key)
);

-- 重复插入 → 唯一索引冲突 → 捕获后查已存在的订单

幂等键 + 唯一索引双保险:代码层缓存拦截(快)+ DB 唯一索引兜底(稳)。

方案 3:状态机(业务状态防重)

订 场 实 单 景 现 状 : P D : 态 支 A O U 机 付 I N P : 回 D E D 影 C 调 A W 响 R 状 状 T H 行 E 态 态 E E 数 A 收 收 R T 到 到 o E 0 E 支 支 r D 付 付 d i = 回 回 e d → 调 调 r = 状 s ? 态 P → → 已 A S A 变 I 幂 忽 E N , D 等 略 T D 重 ( ( 复 → 已 不 s s 调 经 能 t t 用 S 是 倒 a a , H 退 t t 直 I P ) u u 接 P A s s 忽 P I = = 略 E D ' ' D ) P C A R → I E D A D ' T O E N D E '

状态机幂等:用"状态条件"限制更新——只有符合前置状态才更新,天然防重。

三、防并发的坑(关键)

幂等键方案在并发下有坑:

两 解 个 决 并 请 请 → : 1 2 发 求 求 . . 请 创 求 A B 建 缓 S 成 D , 了 存 E 功 B 同 查 查 两 T 一 缓 缓 次 S = 唯 个 存 存 E i 一 幂 T d 我 索 等 → → N e 是 引 键 X m 第 兜 : 没 没 ( : 一 底 结 结 原 k 次 ( 果 果 子 e ( 最 占 y 执 终 → → 位 行 还 ) 1 ) 是 开 也 : ; 始 开 N 失 D 创 始 X 败 B 建 创 建 E = 拦 ( X ) 都 已 以 6 有 为 0 请 自 求 己 在 是 跑 第 ( 一 等 次 待 ! / ) 返 回 处 理 中 )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 并发安全的幂等(SETNX + 唯一索引)
func CreateOrder(ctx, req) (*Order, error) {
    key := "idem:" + req.IdempotencyKey
    ok, err := redis.SetNX(ctx, key, "1", 60*time.Second).Result()
    if err == nil && !ok {
        // 已有请求在处理:等首次完成,返回结果
        return waitForResult(ctx, req.IdempotencyKey)
    }
    defer redis.Del(ctx, key)   // 处理完释放
    order, err := createOrderTx(req)   // DB 唯一索引兜底
    ...
}

四、防乱序(消息场景)

M 解 Q 决 " : 1 2 3 场 订 . . . 景 单 : 已 消 处 或 消 支 息 理 用 息 付 带 时 状 乱 " " 比 态 序 先 业 较 机 ( 到 务 : : 重 , 时 新 乱 投 " 间 消 序 / 订 / 息 消 延 单 序 的 息 迟 已 号 序 不 ) 创 " 号 满 → 建 足 " > 前 后 后 置 到 到 当 状 的 前 态 先 → 状 处 态 → 理 状 态 → 丢 倒 弃 退 才 / 处 延 理 迟 处 理

五、幂等键的生成规范

❌ ❌ ✅ ✅ 服 时 客 键 务 间 户 的 端 戳 端 语 随 ( 在 义 机 同 " : 生 一 业 业 成 毫 务 务 ( 秒 发 类 客 重 起 型 户 试 " 端 时 + 重 = 生 试 成 业 时 不 ( 务 拿 同 进 标 不 键 入 识 到 ) 页 ( 同 面 一 r 个 点 d 键 击 e ) 时 r ) : , u 重 s 试 e 复 r 用 - 同 1 一 2 个 3 键 : s k u - 4 5 6 )

六、踩坑记录

  1. 缓存幂等没做并发控制:两个请求同时进来都当"第一次" → SETNX 原子占位
  2. 重复请求返回"已处理":用户收到提示,但看不到订单 → 返回首次结果
  3. 幂等键存太久:缓存永不过期 → 内存爆 → TTL 24h 合理(业务窗口内)
  4. 只做代码层不做 DB 层:代码 bug/多实例缓存不一致 → DB 唯一索引兜底必须有

总结

接口幂等的核心认知:

  1. 判断标准:重复执行是否产生新副作用
  2. 三件套:幂等键(客户端)+ 唯一索引(DB 兜底)+ 状态机(业务防重)
  3. 并发是最大坑:SETNX 原子占位 + DB 兜底
  4. 乱序要带序号:消息场景用业务序号比较

幂等不是"加个判断",是"三个防线":缓存拦截、DB 兜底、状态机校验。三线齐了,双击、重试、重投、乱序都拿你没辙。