接口幂等设计:防重、防并发、防乱序#
“重复请求是常态,不是异常”——用户双击、前端重试、MQ 重投、网关重放,接口被调两次是必然事件。幂等设计的核心:同一个请求执行 N 次,效果等于执行 1 次。
一、哪些接口必须幂等#
| 接口类型 |
风险 |
是否必须幂等 |
| 查询 |
无副作用 |
天然幂等 |
| 创建订单 |
重复下单 |
必须 |
| 支付/扣款 |
重复扣钱 |
必须 |
| 转账 |
重复转账 |
必须 |
| 更新状态 |
重复更新(同值) |
天然幂等(同值覆盖) |
| 删除 |
重复删除 |
天然幂等(不存在也成功) |
判断标准:“重复执行会不会产生新副作用”——会,就必须幂等。
二、幂等实现方案#
方案 1:幂等键(客户端生成)#
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:状态机(业务状态防重)#
状态机幂等:用"状态条件"限制更新——只有符合前置状态才更新,天然防重。
三、防并发的坑(关键)#
幂等键方案在并发下有坑:
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 唯一索引兜底
...
}
|
四、防乱序(消息场景)#
五、幂等键的生成规范#
六、踩坑记录#
- 缓存幂等没做并发控制:两个请求同时进来都当"第一次" → SETNX 原子占位
- 重复请求返回"已处理":用户收到提示,但看不到订单 → 返回首次结果
- 幂等键存太久:缓存永不过期 → 内存爆 → TTL 24h 合理(业务窗口内)
- 只做代码层不做 DB 层:代码 bug/多实例缓存不一致 → DB 唯一索引兜底必须有
接口幂等的核心认知:
- 判断标准:重复执行是否产生新副作用
- 三件套:幂等键(客户端)+ 唯一索引(DB 兜底)+ 状态机(业务防重)
- 并发是最大坑:SETNX 原子占位 + DB 兜底
- 乱序要带序号:消息场景用业务序号比较
幂等不是"加个判断",是"三个防线":缓存拦截、DB 兜底、状态机校验。三线齐了,双击、重试、重投、乱序都拿你没辙。
微信公众号「福清而不淡」
后端架构 · AI 工程 · 云原生的一线实践,扫码关注,不错过更新。
本文已同步发布到公众号,欢迎留言交流。