Skip to content

分布式系统中的幂等性设计:原理、陷阱与最佳实践

引子:一次"重复扣款"引发的思考

2025 年某支付平台在大促期间遭遇了一次严重事故:

由于网络抖动,部分用户的支付请求被重试了多次。虽然支付网关有防重机制,但下游的账务系统却没有幂等性保护,导致同一笔订单被重复入账。

事后统计,约 0.3% 的订单出现了重复扣款,涉及金额数百万元。虽然最终全部退款,但用户信任度的损失无法用金钱衡量。

这个案例揭示了一个被很多开发者忽视的问题:在分布式系统中,任何网络调用都可能被重试,任何接口都必须考虑幂等性。


一、什么是幂等性?为什么它如此重要?

1.1 幂等性的数学定义

幂等性(Idempotency)是一个数学概念:

一个操作无论执行多少次,产生的结果都相同。

用公式表示:f(f(x)) = f(x)

生活中的例子

  • 电梯按钮:按一次和按十次,电梯都只来一次
  • 开关灯:连续按两次开关,灯的状态不变(开→关→开)
  • 数据库主键:插入同一条记录多次,结果只有一条

1.2 为什么分布式系统必须考虑幂等性?

在单体应用中,方法调用是确定的,不会意外重试。但在分布式系统中,任何网络调用都可能失败

客户端                服务端
   │                    │
   │──── 请求 ──────────▶│
   │                    │ 处理中...
   │                    │ 网络抖动
   │◀──── 超时 ──────────│
   │                    │
   │──── 重试 ──────────▶│ 再次处理!
   │                    │

关键洞察:网络不可靠是常态,重试是必须的,幂等性是保障。

1.3 哪些场景必须实现幂等性?

场景风险等级说明
支付/扣款🔴 极高直接涉及资金安全
库存扣减🔴 极高可能导致超卖
订单创建🟠 高重复订单影响用户体验
消息消费🟠 高重复处理导致数据错误
状态变更🟡 中如审核通过→通过(无害)
查询操作🟢 低天然幂等(只读)

二、幂等性的实现方案全景图

2.1 方案一:唯一请求 ID(Request ID)

原理:客户端生成唯一 ID,服务端记录已处理的 ID,重复请求直接返回首次结果。

实现要点

java
// 伪代码示例
public Response processRequest(Request req) {
    String requestId = req.getRequestId();
    
    // 1. 检查是否已处理
    Response cached = idempotentCache.get(requestId);
    if (cached != null) {
        return cached; // 直接返回缓存结果
    }
    
    // 2. 加锁防止并发重复
    synchronized (lock(requestId)) {
        // 双重检查
        cached = idempotentCache.get(requestId);
        if (cached != null) {
            return cached;
        }
        
        // 3. 执行业务逻辑
        Response result = doBusinessLogic(req);
        
        // 4. 缓存结果
        idempotentCache.set(requestId, result, TTL);
        
        return result;
    }
}

优点

  • 实现简单,通用性强
  • 客户端无需感知服务端状态

缺点

  • 需要存储请求 ID 和结果,有存储成本
  • TTL 设置需要权衡(太短可能漏判,太长占用空间)

适用场景:支付、订单创建等需要精确去重的场景


2.2 方案二:业务唯一键去重

原理:利用业务本身的唯一性(如订单号、流水号)进行去重。

实现示例

sql
-- 利用数据库唯一索引
CREATE TABLE orders (
    order_no VARCHAR(64) PRIMARY KEY,  -- 业务唯一键
    user_id BIGINT,
    amount DECIMAL(10,2),
    status VARCHAR(32),
    created_at TIMESTAMP
);

-- 插入时,重复的 order_no 会报唯一键冲突
INSERT INTO orders (order_no, user_id, amount) 
VALUES ('ORD20260322001', 12345, 99.00);

优点

  • 无需额外存储请求 ID
  • 利用数据库原生能力,可靠性高

缺点

  • 依赖业务有天然唯一键
  • 无法区分"重复请求"和"业务冲突"

适用场景:订单、交易等有天然业务主键的场景


2.3 方案三:状态机控制

原理:通过状态流转的不可逆性保证幂等性。

实现示例

java
// 订单状态机
public void payOrder(String orderId) {
    // 只有"待支付"状态才能转为"已支付"
    int updated = orderMapper.updateStatus(
        orderId, 
        OrderStatus.PAID,
        OrderStatus.PENDING  // 前置条件
    );
    
    if (updated == 0) {
        // 更新失败,说明状态不匹配
        Order order = orderMapper.getById(orderId);
        if (order.getStatus() == OrderStatus.PAID) {
            // 已经是支付状态,视为幂等成功
            return;
        }
        throw new IllegalStateException("订单状态异常");
    }
}

优点

  • 无需额外存储
  • 状态流转清晰,易于调试

缺点

  • 仅适用于有明确状态流转的场景
  • 需要合理设计状态机

适用场景:订单状态变更、审核流程等


2.4 方案四:乐观锁(CAS)

原理:利用版本号或时间戳,更新时检查数据是否被修改过。

实现示例

java
// 库存扣减的乐观锁实现
public boolean deductStock(Long skuId, Integer quantity) {
    // version 是库存记录的版本号
    int updated = stockMapper.deduct(
        skuId, 
        quantity,
        currentVersion  // 乐观锁条件
    );
    
    return updated > 0; // 更新成功说明没有并发冲突
}
sql
-- SQL 层面
UPDATE stock 
SET quantity = quantity - 10, 
    version = version + 1
WHERE sku_id = 123 
  AND version = 5;  -- 乐观锁条件

优点

  • 无锁实现,性能好
  • 能检测并发冲突

缺点

  • 冲突率高时重试频繁
  • 需要业务支持版本号机制

适用场景:高并发读、低并发写的场景


2.5 方案五:分布式锁

原理:对同一资源加锁,确保同一时间只有一个请求能处理。

实现示例(Redis 分布式锁)

java
public Response processWithLock(Request req) {
    String lockKey = "lock:" + req.getResourceId();
    String lockValue = UUID.randomUUID().toString();
    
    // 尝试加锁(SET NX EX)
    boolean locked = redis.setnx(lockKey, lockValue, 30);
    if (!locked) {
        // 获取锁失败,可能是重复请求或并发请求
        return waitForResult(req);
    }
    
    try {
        // 执行业务逻辑
        return doBusinessLogic(req);
    } finally {
        // 释放锁(Lua 脚本保证原子性)
        redis.unlock(lockKey, lockValue);
    }
}

优点

  • 强一致性保证
  • 适用于复杂业务场景

缺点

  • 性能开销大
  • 需要处理锁超时、死锁等问题

适用场景:对一致性要求极高的核心业务


三、生产环境的幂等性设计陷阱

3.1 陷阱一:只防客户端重试,不防服务端并发

错误做法

java
// ❌ 只检查缓存,不加锁
public Response process(Request req) {
    if (cache.exists(req.getId())) {
        return cache.get(req.getId());
    }
    // 两个并发请求可能同时通过检查
    Response result = doLogic(req);
    cache.set(req.getId(), result);
    return result;
}

问题:在高并发场景下,两个请求可能同时通过缓存检查,导致重复执行。

正确做法:检查 + 加锁 + 双重检查(参考 2.1 节代码)


3.2 陷阱二:TTL 设置不当导致"假阴性"

场景

  • 请求 A 执行成功,缓存 TTL = 5 分钟
  • 5 分钟后,网络问题导致客户端重试
  • 缓存已过期,服务端再次执行

解决方案

  • 根据业务特点设置合理的 TTL(支付建议 24 小时以上)
  • 对于关键业务,使用持久化存储而非缓存
  • 结合业务唯一键做二次校验

3.3 陷阱三:忽略"处理中"状态

问题

请求 1 ──▶ 服务端开始处理(未返回)
请求 2 ──▶ 服务端再次处理(重复!)

解决方案:引入"处理中"状态

java
public Response process(Request req) {
    String key = "req:" + req.getId();
    
    // 尝试设置"处理中"状态
    Boolean isNew = redis.setnx(key, "PROCESSING", 30);
    if (Boolean.FALSE.equals(isNew)) {
        String status = redis.get(key);
        if ("PROCESSING".equals(status)) {
            // 等待处理完成(轮询或长轮询)
            return waitForCompletion(req.getId());
        } else {
            // 已处理完成,返回结果
            return getStoredResult(req.getId());
        }
    }
    
    try {
        Response result = doBusinessLogic(req);
        redis.set(key, "DONE:" + result.toJson(), 3600);
        return result;
    } catch (Exception e) {
        redis.del(key); // 失败则清理状态
        throw e;
    }
}

3.4 陷阱四:异步场景下的幂等性缺失

场景:消息队列消费、异步任务处理

问题

  • 消息队列可能重复投递(至少一次语义)
  • 异步任务可能因超时被重试

解决方案

java
// 消息消费的幂等性处理
@RabbitListener(queues = "order.queue")
public void consumeOrderMessage(Message msg) {
    String messageId = msg.getMessageId();
    
    // 检查是否已处理
    if (processedMessages.exists(messageId)) {
        log.info("重复消息,跳过:{}", messageId);
        return;
    }
    
    // 处理消息
    processOrder(msg.getBody());
    
    // 标记已处理(与业务操作在同一事务中)
    processedMessages.add(messageId);
}

关键点:消息处理与去重标记必须在同一事务中,否则可能出现"处理成功但标记失败"的情况。


四、幂等性方案的选型策略

4.1 选型决策树

                    ┌─────────────────┐
                    │  是否有业务唯一键? │
                    └────────┬────────┘

              ┌──────────────┴──────────────┐
              │是                          │否
              ▼                            ▼
    ┌─────────────────┐          ┌─────────────────┐
    │ 业务唯一键去重    │          │ 是否需要精确去重? │
    │ + 状态机控制     │          └────────┬────────┘
    └─────────────────┘                   │
                              ┌───────────┴───────────┐
                              │是                    │否
                              ▼                      ▼
                    ┌─────────────────┐    ┌─────────────────┐
                    │ 唯一请求 ID      │    │ 乐观锁/状态机    │
                    │ + 分布式锁       │    │ 即可满足        │
                    └─────────────────┘    └─────────────────┘

4.2 不同场景的推荐方案

场景推荐方案理由
支付接口唯一请求 ID + 分布式锁资金安全,要求最高
订单创建业务唯一键(订单号)天然唯一,实现简单
库存扣减乐观锁 + 状态机高并发,性能敏感
消息消费消息 ID 去重 + 事务至少一次语义保障
状态变更状态机控制状态流转天然幂等
查询接口无需处理天然幂等

五、最佳实践总结

5.1 设计原则

  1. 默认假设网络会失败:任何外部调用都要考虑重试
  2. 幂等性前置:在接口设计阶段就考虑,而非事后补救
  3. 分层防御:网关层 + 业务层 + 数据层多重保障
  4. 可追溯:记录请求日志,便于问题排查

5.2 技术要点

  1. 唯一 ID 生成:使用 UUID 或雪花算法,确保全局唯一
  2. 存储选择
    • 高频场景:Redis(性能好)
    • 关键业务:数据库(可靠性高)
  3. 锁的粒度:尽可能细化(如按订单 ID 而非全局锁)
  4. 超时处理:设置合理的锁超时和业务超时

5.3 测试建议

  1. 自动化测试:模拟网络超时、重复请求等场景
  2. 压测验证:高并发下验证幂等性是否生效
  3. 混沌工程:主动注入故障,验证系统韧性

结语

幂等性不是"可选项",而是分布式系统的"必选项"。

核心观点

  • 网络不可靠是常态,重试是必须的
  • 幂等性设计要前置,不能事后补救
  • 没有银弹,需要根据业务场景选择合适的方案

一个成熟的分布式系统,必然有一套完善的幂等性机制。这不仅是技术问题,更是对用户负责的态度。


参考资料

  • 《设计数据密集型应用》- Martin Kleppmann
  • 《分布式系统原理与范型》- Andrew S. Tanenbaum
  • RFC 9110: HTTP Semantics - 幂等性方法定义