用户手抖点了两次提交,订单创建了两条。网络超时自动重试,库存扣了两次。这类问题,本质上都是同一个问题——接口不幂等。
什么是幂等
幂等(Idempotent)指:同一个请求执行一次和执行多次,结果是一样的。
| 操作 | 是否幂等 | 说明 |
|---|---|---|
| GET查询 | 是 | 天然幂等 |
| PUT更新(全量) | 是 | 覆盖式更新 |
| DELETE删除 | 是 | 删了再删还是删了 |
| POST创建 | 否 | 会创建多条 |
注意:幂等强调的是结果一致,不是"不执行"。比如扣款接口,重复调用不能重复扣钱。
为什么会出现重复请求
1. 用户重复点击
2. 前端超时重试
3. 网关/Nginx重试
4. 消息队列重复投递
5. 服务端超时但实际已成功,客户端重试
第5种最阴险——服务端已经处理成功了,只是响应没发出去,客户端以为失败又发了一次。
方案一:唯一索引(最可靠)
数据库层面兜底,最靠谱。
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2),
UNIQUE KEY uk_order_no (order_no)
);
public Result createOrder(CreateOrderRequest req) {
try {
orderMapper.insert(order);
return Result.ok();
} catch (DuplicateKeyException e) {
// 订单号重复,说明是重复请求
log.warn("重复创建订单: {}", req.getOrderNo());
return Result.ok(orderMapper.selectByOrderNo(req.getOrderNo()));
}
}
优点:绝对可靠,并发下也有效。缺点:需要业务上有唯一标识(订单号、流水号)。
方案二:Token机制
流程是"先申请token,再提交带token的请求"。
步骤1:前端请求获取token
GET /api/token → 返回 uuid,存入Redis
步骤2:前端提交时带上token
POST /api/order
Header: X-Idempotent-Token: uuid
步骤3:服务端校验并删除token
public Result createOrder(CreateOrderRequest req, String token) {
// Redis的删除是原子的,只有第一次能删成功
Boolean deleted = redisTemplate.delete("idempotent:" + token);
if (Boolean.FALSE.equals(deleted)) {
return Result.fail("请勿重复提交");
}
// 执行真正的业务
return orderService.create(req);
}
这个方案适合前端可控的场景,比如表单提交。
方案三:Redis SETNX
用Redis的setIfAbsent做防重。
public Result submit(SubmitRequest req) {
String key = "idem:" + req.getUserId() + ":" + req.getBizNo();
String value = UUID.randomUUID().toString();
// 10分钟内同一个业务号只能提交一次
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, value, Duration.ofMinutes(10));
if (Boolean.FALSE.equals(success)) {
return Result.fail("请求处理中,请勿重复提交");
}
try {
return doBusiness(req);
} catch (Exception e) {
// 业务失败,删除key允许重试
redisTemplate.delete(key);
throw e;
}
}
注意:业务失败要删key,否则用户重试不了。
方案四:状态机
通过状态流转保证幂等,适合有明确生命周期的业务。
public void paySuccess(String orderNo) {
// 只更新未支付的订单,已支付的不会受影响
int affected = orderMapper.updateStatus(
orderNo,
OrderStatus.UNPAID,
OrderStatus.PAID
);
if (affected == 0) {
log.info("订单已支付或不存在,忽略本次通知: {}", orderNo);
return;
}
// 只有真正更新成功才继续后续逻辑
pointService.addPoints(orderNo);
}
UPDATE t_order
SET status = 'PAID'
WHERE order_no = #{orderNo} AND status = 'UNPAID'
这个写法天然幂等——状态已经是PAID了,再执行一次影响行数是0,后续逻辑不会重复触发。支付回调、消息消费这类场景强烈推荐。
方案对比
| 方案 | 可靠性 | 实现成本 | 适用场景 |
|---|---|---|---|
| 唯一索引 | 最高 | 低 | 有业务唯一标识 |
| Token | 高 | 中 | 表单提交 |
| Redis SETNX | 中 | 低 | 短时间防重 |
| 状态机 | 高 | 低 | 状态流转 |
实战组合拳
生产环境通常是组合使用:
接入层:Nginx限流
↓
网关层:Token校验(可选)
↓
应用层:Redis SETNX快速拦截
↓
数据层:唯一索引兜底
↓
状态机:保证业务流转正确
每一层都有兜底,重复请求几乎不可能穿透到底层。
一句话总结:幂等不是选择题,是必修课。 尤其是支付、订单、库存这类核心链路,设计的时候就要想好。