2、重试策略基础:重试的幂等性保障——为什么重试必须是安全的?

聊重试之前,我先问你一个问题:一个操作被重复执行两次,结果和只执行一次完全一样吗?

如果答案是否定的,那重试就是个定时炸弹。我见过太多线上事故,就是因为重试没有考虑幂等性——订单重复扣款、库存多扣、短信狂发……说白了,重试本身不产生新问题,但它会把已有的问题放大十倍。

核心观点:没有幂等性保障的重试,不是容错,是灾难。

2.1 什么是幂等性?

幂等性(Idempotency)这个词听着挺学术,其实意思很简单:同一个操作,无论执行一次还是一百次,对系统产生的影响是一样的。

举个例子:

  • 查询操作:查用户信息,查一万次结果都一样——天然幂等。
  • 删除操作:删一条不存在的记录,返回结果可能不同,但系统状态不变——也算幂等。
  • 扣款操作:扣用户 100 元,执行两次就扣 200 元——不幂等,这就是重试的雷区。

我习惯把幂等性分成两个层面来看:

层面 含义 典型场景
读幂等 多次读取结果一致 GET 请求、查询数据库
写幂等 多次写入最终状态一致 PUT 更新、幂等扣款、去重插入

重试主要关心的是写幂等。因为读操作重试不会破坏数据,但写操作重试一旦缺乏保障,后果很严重。

2.2 为什么重试必须考虑幂等性?

你想想看,重试的本质是什么?是把同一个请求再发一次。如果下游服务已经成功处理了第一次请求,只是返回结果时网络断了,那第二次请求就会导致重复执行。

我在项目中遇到过这样一个案例:

  • 一个支付服务,调用银行接口扣款。
  • 第一次调用超时了,客户端触发重试。
  • 实际上银行已经扣款成功,只是响应没回来。
  • 重试又扣了一次——用户被扣了两笔钱。

这就是典型的非幂等重试事故。用户投诉、客服爆炸、运维背锅……嗯,那滋味不好受。

避坑指南:我曾经接手过一个遗留系统,重试逻辑写在了业务代码里,没有任何幂等检查。结果双十一当天,因为数据库主从延迟导致超时重试,订单表里插入了大量重复数据。修复花了整整一个通宵。从那以后,我定了一条铁律:所有可能被重试的接口,必须幂等。

2.3 幂等性的实现方式

实现幂等性,说白了就是让系统能识别出「这个请求我已经处理过了」。常用的方法有下面几种:

2.3.1 全局唯一请求 ID(Idempotency Key)

这是最通用的方案。每次请求带一个全局唯一的 ID,服务端用这个 ID 做去重。

// 客户端生成请求 ID
String requestId = UUID.randomUUID().toString();

// 服务端处理逻辑
public Response processPayment(Request req) {
    // 1. 检查请求 ID 是否已处理
    if (idempotencyStore.exists(req.getRequestId())) {
        // 直接返回上一次的结果
        return idempotencyStore.getResult(req.getRequestId());
    }
    
    // 2. 执行实际业务逻辑
    Result result = doPayment(req);
    
    // 3. 记录请求 ID 和结果
    idempotencyStore.save(req.getRequestId(), result);
    
    return result;
}

这里有个关键点:检查和处理必须是原子操作。如果两个重试请求同时到达,都检查到「不存在」,就会都去执行业务逻辑。所以一般会用 Redis 的 SET NX 或者数据库的唯一索引来做互斥。

我的习惯:用 Redis 做幂等存储时,我会把 TTL 设成业务超时时间的 2 倍。比如接口超时是 30 秒,幂等 key 就保留 60 秒。这样既能覆盖重试窗口,又不会长期占用内存。

2.3.2 数据库唯一约束

对于数据库写入操作,利用唯一索引天然防重。

-- 创建唯一索引
CREATE UNIQUE INDEX idx_order_no ON orders(order_no);

-- 插入时捕获重复键异常
try {
    INSERT INTO orders(order_no, amount, status) VALUES('ORD2024001', 100, 'PAID');
} catch (DuplicateKeyException e) {
    // 订单已存在,直接返回成功
    return getOrder('ORD2024001');
}

这种方式简单粗暴,但只适用于数据库写入场景。而且要注意:唯一索引的字段必须能唯一标识一次业务操作,比如订单号、流水号。

2.3.3 状态机与版本号

对于有状态流转的业务(比如订单状态:待支付 → 已支付 → 已发货),可以用版本号或状态机来保证幂等。

// 乐观锁更新
UPDATE orders 
SET status = 'PAID', version = version + 1 
WHERE order_id = 'ORD2024001' AND version = 1;

// 如果影响行数为 0,说明已经被更新过了
if (affectedRows == 0) {
    // 幂等处理:直接返回成功
    return success();
}

这种方式的好处是不需要额外的存储,利用业务数据本身的版本信息就能防重。但前提是业务表必须有版本号字段,而且更新逻辑要严格按版本号来。

2.4 幂等性设计的常见误区

我见过不少团队在幂等性上踩坑,总结几个典型的:

  1. 以为查询操作不需要幂等——查询确实天然幂等,但如果查询结果被缓存了,重试可能导致缓存穿透,还是要注意。
  2. 幂等 key 生成太随意——有人用时间戳做幂等 key,结果同一毫秒内的重试生成了不同的 key,完全没起到去重作用。
  3. 忽略幂等存储的高可用——幂等存储本身如果挂了,所有重试都会变成非幂等请求。我建议幂等存储至少做主从或集群部署。
  4. 幂等 key 的过期时间太短——重试窗口还没结束,key 就过期了,导致重复处理。
一句话总结:幂等性不是「有更好」,而是「必须有」。没有幂等保障的重试策略,就是在给系统埋雷。

2.5 本章知识体系图

下面这张图梳理了幂等性在重试策略中的核心位置和实现路径:

重试幂等性保障知识体系 重试策略 幂等性保障 全局唯一请求 ID (Idempotency Key) 数据库唯一约束 (唯一索引防重) 状态机 + 版本号 (乐观锁防重) 关键设计要点 原子检查 | 合理 TTL | 高可用存储 | 唯一标识生成 ⚠ 核心原则 没有幂等性保障的重试,不是容错,是灾难。 所有可能被重试的接口,必须实现幂等。

2.6 小结

幂等性是重试策略的基石。没有它,重试就不是在提升稳定性,而是在制造混乱。

我个人习惯在设计任何接口时,先问自己一个问题:这个接口被重复调用会出问题吗?如果会,那就必须加上幂等保障。不管是请求 ID、唯一约束还是版本号,选一种适合场景的就行。关键是——别等到出事了再补。

最后一个小建议:写代码时把幂等逻辑和业务逻辑分开。不要混在一起,否则后期维护会非常痛苦。我一般会封装一个幂等工具类,业务代码里只需要调用一个注解或一个方法就行。