接口幂等性设计实践:防重复提交、重试和并发请求
wxk1991 Lv5

接口幂等性设计实践:防重复提交、重试和并发请求

很多接口问题不是“第一次请求失败”。

而是:

1
2
3
第一次请求其实成功了
但客户端没收到响应
于是又发了一次

如果接口没有幂等性,结果就很尴尬:

  • 重复扣款
  • 重复创建订单
  • 重复发优惠券
  • 重复提交表单
  • 重复发送短信

幂等性不是高级架构概念。只要接口会被重试,只要用户可能重复点击按钮,就应该考虑。


一、什么是幂等性

幂等性简单说就是:

1
同一个操作执行一次和执行多次,最终结果一致。

比如查询用户信息:

1
GET /users/1

查一次和查十次,不会改变数据。

这天然是幂等的。

但创建订单就不一样:

1
POST /orders

如果每次请求都插入一条新订单,那它不是幂等的。

幂等性设计的目标,是让同一次业务操作重复请求时,只产生一次业务结果。


二、哪些接口最需要幂等

不是所有接口都要做复杂幂等。

优先关注这些场景:

  • 创建订单
  • 支付回调
  • 发放权益
  • 扣减库存
  • 创建用户资产
  • 提交表单
  • 消息消费
  • 第三方 Webhook

这些接口的共同点是:

1
一旦重复执行,业务后果很明显。

而普通查询、列表分页、配置读取,一般不用做额外幂等设计。


三、客户端防重复点击不够

前端可以做按钮 loading:

1
2
点击后禁用按钮
请求完成再恢复

这很有用。

但它不是幂等性。

因为重复请求可能来自:

  • 用户刷新页面
  • 浏览器重试
  • 手机网络抖动
  • 反向代理重试
  • 客户端超时后自动重试
  • 第三方平台重复回调
  • 消息队列重复投递

前端防抖只能减少重复提交。

真正的幂等必须在服务端兜住。


四、用业务唯一键做幂等

最稳定的幂等方式,是找到业务天然唯一键。

比如订单系统里,客户端先生成一个业务单号:

1
client_order_no = 202606121956001234

创建订单时带上:

1
2
3
4
5
{
"client_order_no": "202606121956001234",
"product_id": 1001,
"quantity": 1
}

数据库加唯一索引:

1
2
CREATE UNIQUE INDEX uk_orders_client_order_no
ON orders(client_order_no);

服务端创建时:

1
2
如果不存在,创建订单
如果已存在,返回已有订单

这样同一个业务单号重复提交,不会创建多条订单。

这是我最推荐的方式。

因为它不依赖缓存,也不依赖请求时间窗口,而是把幂等约束放在数据库唯一性上。


五、用 Idempotency-Key 做通用幂等

如果接口没有天然业务单号,可以让客户端传一个幂等键:

1
Idempotency-Key: 0b9345aa-9d0c-4f1a-9b1e-8cfc7b7a1111

服务端保存这个 key 和处理结果:

1
2
3
4
5
6
key
request_hash
status
response_body
created_at
expired_at

处理流程:

1
2
3
4
5
1. 收到请求
2. 检查 Idempotency-Key 是否存在
3. 不存在则开始处理,并记录 key
4. 存在且请求内容一致,返回之前的结果
5. 存在但请求内容不同,拒绝请求

为什么要保存 request_hash

因为同一个 key 如果配了不同请求体,说明客户端用错了。

这时应该返回错误,而不是把错误请求当作同一个操作。


六、不要只用 Redis setnx 就结束

很多人会这样做:

1
Redis SETNX idempotency_key 1 EX 60

如果设置成功,就继续处理。

如果设置失败,就认为重复请求。

这个方案可以挡住短时间重复点击,但有几个问题:

  • 处理成功后响应丢失,重试时无法返回原结果
  • 业务处理失败时,key 是否删除很难决定
  • Redis 过期时间太短会漏,太长会占空间
  • Redis 成功、数据库失败时状态不一致

所以 Redis 更适合做“短时间防重复提交”,不适合作为强业务幂等的唯一依据。

真正关键的业务,还是要靠数据库唯一约束和状态机兜底。


七、状态机也能保证幂等

有些操作不是创建记录,而是推进状态。

比如订单支付:

1
待支付 -> 已支付 -> 已发货

支付回调可能重复到达。

这时可以用状态判断:

1
2
3
4
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = ?
AND status = 'PENDING';

如果影响行数是 1,说明这次成功把订单从待支付改成已支付。

如果影响行数是 0,说明订单已经不是待支付状态。可能已经处理过,也可能状态不允许。

这比先查再改更稳。

因为条件更新本身就是一个原子操作。


八、消息消费必须默认会重复

消息队列里有一个现实:

1
至少一次投递很常见。

也就是说,消费者要假设消息可能重复。

消费消息时可以保存消息处理记录:

1
2
3
4
CREATE TABLE processed_messages (
message_id VARCHAR(128) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);

处理流程:

1
2
3
4
5
6
1. 开启事务
2. 插入 message_id
3. 如果唯一键冲突,说明处理过,直接 ack
4. 执行业务逻辑
5. 提交事务
6. ack 消息

关键点是:处理记录和业务变更最好在同一个事务里。

否则可能出现“业务成功但记录失败”或者“记录成功但业务失败”的不一致。


九、幂等不是锁

幂等和锁经常被混在一起。

它们解决的问题不一样。

幂等解决:

1
同一个业务操作重复执行的问题。

锁解决:

1
多个操作同时竞争同一份资源的问题。

比如用户重复提交同一个订单,用幂等键解决。

多个用户同时抢最后一件库存,要靠库存条件更新或锁解决。

不要指望一个 idempotency_key 解决所有并发问题。

该用唯一约束用唯一约束。

该用条件更新用条件更新。

该用事务用事务。


十、一个实用设计清单

设计关键写接口时,可以问自己这些问题:

  • 这个接口重复请求会不会出问题?
  • 有没有天然业务唯一键?
  • 数据库有没有唯一约束兜底?
  • 客户端超时重试时,服务端能否返回同一个结果?
  • 幂等 key 是否校验请求内容?
  • 幂等记录是否有过期策略?
  • 业务状态是否用条件更新推进?
  • 消息消费是否允许重复投递?
  • 重试失败时,日志里能否查到同一次操作?

幂等性的核心不是写一个工具类。

而是把重复执行这件事当成正常情况来设计。


十一、我的建议

普通业务系统里,可以这样选:

1
2
3
4
5
创建类接口:业务唯一键 + 数据库唯一索引
支付和状态流转:状态机 + 条件更新
短时间重复点击:Redis 防抖可以辅助
第三方回调:第三方流水号唯一约束
消息消费:message_id 去重表

不要只相信前端按钮禁用。

也不要只相信 Redis 锁。

真正稳定的幂等性,应该落在业务唯一性、数据库约束和清晰状态机上。

这些东西看起来朴素,但比复杂中间件可靠得多。