本文目录导读:

这是一个非常经典且具有挑战性的分布式系统设计问题。超时重试本身是保证系统可靠性的手段,但幂等是保证重试后数据一致性不出错的核心防线。
幂等设计无论客户端发起多少次重试,服务端处理一次和处理多次的结果完全一致。
以下是针对“超时重试”场景下的幂等设计方案,从策略到落地代码思路:
核心思路:唯一请求标识 + 状态机 + 结果缓存
解决幂等的关键在于区分“新请求”和“重试请求”,并确保对同一业务数据的修改只生效一次。
全局唯一请求ID(核心基石)
原理:客户端在首次发起请求时,生成一个全局唯一的 requestId(如 UUID、雪花算法),服务端在处理请求时,以 requestId 为 key 记录处理状态。
- 客户端:每次请求(无论成功失败)都携带同一个
requestId。 - 服务端:在处理前,先查询
requestId是否存在。
代码伪代码(Java + Redis):
public Response processPayment(PaymentRequest request) {
String requestId = request.getRequestId();
// 1. 查重:检查该 requestId 是否已处理
// Redis setnx 是原子操作,key 不存在则设置成功,返回 1;已存在则返回 0
boolean isNewRequest = redisTemplate.opsForValue()
.setIfAbsent("PAYMENT:" + requestId, "PROCESSING", 30, TimeUnit.MINUTES);
if (!isNewRequest) {
// 2. 如果已存在,说明是重试请求
// 释放锁(如果是分布式锁场景则需注意)
// 直接返回之前处理的结果(需要缓存结果)
return getPreviousResult(requestId);
}
try {
// 3. 执行业务逻辑:扣款、入账等
// ... 数据库操作 ...
// 4. 成功后,更新状态为 SUCCESS,并缓存结果
redisTemplate.opsForValue().set("PAYMENT:" + requestId, "SUCCESS");
return Response.success();
} catch (Exception e) {
// 5. 失败时,删除标识,允许客户端重试(但需注意防并发)
redisTemplate.delete("PAYMENT:" + requestId);
throw e; // 让客户端感知失败
}
}
注意:setIfAbsent 带过期时间的做法,可以防止死锁(即请求永远不被清理)。
数据库唯一约束 + 业务键
如果不想依赖 Redis,可以利用数据库本身的唯一索引或主键约束。
- 场景:创建订单、插入流水记录等。
- 做法:在业务表中,将
requestId或业务唯一键(如order_sn + product_id)设为 唯一索引。 - 效果:第一次插入成功,第二次重试插入时,数据库会报
DuplicateKeyException,捕获该异常后,直接返回第一次的成功结果即可。
代码示例:
@Transactional
public Response createOrder(OrderDTO order) {
OrderEntity entity = new OrderEntity();
entity.setRequestId(order.getRequestId()); // 唯一索引字段
// ... 其他字段
try {
orderDao.insert(entity);
return Response.success(entity);
} catch (DuplicateKeyException e) {
// 重试请求,查询已有数据并返回
OrderEntity existed = orderDao.findByRequestId(order.getRequestId());
return Response.success(existed);
}
}
乐观锁(适用于更新操作)
对于更新操作(如扣减库存、修改余额),不能简单使用防重表,因为“状态”会变。
- 场景:支付成功后更新订单状态
from PAYING to PAID。 - 做法:在 SQL 中加入
version或status条件判断。
SQL 示例:
-- 幂等更新:只有当前状态为 PAYING 时才更新成 PAID UPDATE orders SET status = 'PAID', version = version + 1 WHERE order_id = ? AND status = 'PAYING';
如果返回的受影响行数为 0,说明已经是 PAID 状态,直接返回成功即可(幂等已保证)。
状态机(Strict State Machine)
与乐观锁类似,但更加严格,业务数据需要有明确的状态流转路径。
- 原则:只允许
A -> B,不允许B -> B或A -> C。 - 使用:每次重试时,判断当前状态是否已经到达目标状态,如果是,则直接返回成功。
| 当前状态 | 期望操作 | 结果 |
|---|---|---|
| INIT | 支付 | 允许,变成 PAID |
| PAID | 支付(重试) | 不允许,直接返回成功 |
| REFUNDING | 支付 | 不允许,返回错误或忽略 |
高级场景与设计要点
超时重试的“等幂”陷阱
- 问题:客户端发了请求,服务端处理成功,但响应超时,客户端重试。
- 如果服务端重入了业务逻辑(比如两次扣款),就出事了。
- 关键:服务端必须先做幂等检查,再执行逻辑。
- 问题:重试请求到达时,第一次请求还在处理中。
- 解决:使用分布式锁 + 防重表,如果第一次正在处理,第二次请求应等待或快速失败,而不是重复处理。
接口类型的幂等处理差异
| 接口类型 | 天然幂等 | 设计建议 |
|---|---|---|
| 查询 | 是 | 无需特殊处理 |
| 删除 | 大部分是 | 幂等删除(delete by id,多次执行结果一样) |
| 更新 | 否 | 乐观锁、状态机防重 |
| 创建 | 否 | 唯一约束、防重表 |
外部服务(如银行、第三方支付)的幂等
- 依赖:上游系统通常会要求传递
request_no。 - 方案:与第三方约定好幂等键(如订单号),由第三方保证对同一幂等键的去重。
- 注意:不可控的外部服务(如微信支付回调)也可能超时重试,因此接收方(例如你的系统)也要做幂等。
一套完整的幂等设计模板
graph LR
A[客户端] --> B(生成唯一requestId);
B --> C{发起请求};
C --> D[网关/API入口];
D --> E{检查幂等标识};
E -- 新请求 --> F[执行业务逻辑];
E -- 重复请求 --> G[返回缓存结果];
F --> H[更新幂等标识状态];
H -- 成功 --> I[返回结果];
H -- 失败 --> J[清理幂等标识];
J --> C;
黄金定律:
- 先检查,后执行:在业务逻辑入口处,以
requestId作为锁或判断依据。 - 结果缓存:如果请求已被处理,直接返回该
requestId对应的成功结果(避免重试时查不到结果而报错)。 - 防重表 + 分布式锁:在并发情况下,保证只有第一个请求能拿到锁并执行业务。
- 乐观锁/状态机:对于更新操作,利用数据库的版本号或状态字段实现原子性更新。
只要遵循“唯一ID + 前置检查 + 结果缓存”这个组合,超时重试的幂等设计就能非常稳固。