超时重试如何幂等设计

wen 网络安全 1

本文目录导读:

超时重试如何幂等设计

  1. 核心思路:唯一请求标识 + 状态机 + 结果缓存
  2. 高级场景与设计要点
  3. 一套完整的幂等设计模板

这是一个非常经典且具有挑战性的分布式系统设计问题。超时重试本身是保证系统可靠性的手段,但幂等是保证重试后数据一致性不出错的核心防线。

幂等设计无论客户端发起多少次重试,服务端处理一次和处理多次的结果完全一致。

以下是针对“超时重试”场景下的幂等设计方案,从策略到落地代码思路:

核心思路:唯一请求标识 + 状态机 + 结果缓存

解决幂等的关键在于区分“新请求”和“重试请求”,并确保对同一业务数据的修改只生效一次。

全局唯一请求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 中加入 versionstatus 条件判断。

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 -> BA -> 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;

黄金定律

  1. 先检查,后执行:在业务逻辑入口处,以 requestId 作为锁或判断依据。
  2. 结果缓存:如果请求已被处理,直接返回该 requestId 对应的成功结果(避免重试时查不到结果而报错)。
  3. 防重表 + 分布式锁:在并发情况下,保证只有第一个请求能拿到锁并执行业务。
  4. 乐观锁/状态机:对于更新操作,利用数据库的版本号或状态字段实现原子性更新。

只要遵循“唯一ID + 前置检查 + 结果缓存”这个组合,超时重试的幂等设计就能非常稳固。

抱歉,评论功能暂时关闭!