幂等模式:让系统经得起重复调用

各位同学,今天我们来聊一个非常接地气的话题——幂等。说白了,幂等就是同一个操作,你调用一次和调用一百次,结果是一样的。你想想看,在分布式系统里,网络超时、重试、消息重复消费,这些场景太常见了。如果没有幂等保护,用户点一次支付按钮,结果扣了两次钱,那可就出大事了。

我在做支付系统的时候,就遇到过这种问题。有一次线上排查,发现某个订单被重复扣款了,查了半天,原来是回调接口被重复调用了两次。从那以后,我对幂等的重视程度直接拉满。今天我就把幂等模式的几种核心实现方式,以及支付和订单系统里的实战经验,一次性讲清楚。

什么是幂等?先看一个简单例子

幂等(Idempotency)这个概念来自数学,但在工程里,它的含义更具体:一个操作,无论执行多少次,产生的副作用都是一样的

举个例子:

  • 查询订单状态:天然幂等,查一万次也不会改数据
  • 创建订单:不幂等,调两次就创建两个订单
  • 更新订单状态为“已支付”:如果加状态判断,可以做到幂等

嗯,这里要注意:幂等不是天然存在的,是需要设计的。你写接口的时候,默认是不幂等的,除非你刻意去实现它。

幂等模式的三种核心实现

我个人习惯把幂等实现分为三类:幂等键、去重表、状态机。下面我一个一个讲。

1. 幂等键(Idempotency Key)

这是最常用的一种方式。客户端在请求时,带一个全局唯一的幂等键(比如 UUID)。服务端收到请求后,先查一下这个幂等键有没有处理过。如果处理过,直接返回上次的结果;如果没有,就正常处理,并把结果缓存起来。

我在项目中遇到过这样一个场景:支付网关的回调接口,第三方会重试多次。我们让每次回调都带上一个唯一的幂等键(比如支付流水号),服务端用 Redis 做缓存,key 就是幂等键,value 是处理结果。这样重复回调来了,直接返回缓存结果,不会重复处理。

核心思路: 用唯一标识 + 缓存/数据库,实现“一次处理,多次返回”。

代码示例(Java 伪代码):

public Response handlePayment(PaymentRequest request) {
    String idempotencyKey = request.getIdempotencyKey();
    
    // 1. 查缓存,看是否处理过
    Response cached = cache.get(idempotencyKey);
    if (cached != null) {
        return cached;  // 直接返回上次结果
    }
    
    // 2. 加分布式锁,防止并发
    boolean locked = redisLock.tryLock(idempotencyKey, 5, TimeUnit.SECONDS);
    if (!locked) {
        throw new RuntimeException("请稍后重试");
    }
    
    try {
        // 3. 再次检查(双重检测)
        cached = cache.get(idempotencyKey);
        if (cached != null) {
            return cached;
        }
        
        // 4. 执行业务逻辑
        Response result = doPayment(request);
        
        // 5. 缓存结果
        cache.set(idempotencyKey, result, 24, TimeUnit.HOURS);
        
        return result;
    } finally {
        redisLock.unlock(idempotencyKey);
    }
}
避坑指南: 我曾经犯过一个错误——只做了单次检查,没加锁。结果两个请求同时到达,都发现缓存里没有,结果都去执行了业务逻辑。后来我加了分布式锁 + 双重检测,才彻底解决。

2. 去重表(Deduplication Table)

去重表本质上和幂等键类似,但它是用数据库的唯一索引来保证的。你建一张去重表,里面放幂等键字段,并加上唯一索引。插入时如果冲突,说明已经处理过了。

这种方式适合对数据一致性要求极高的场景。比如订单系统里,可以用订单号作为去重键。我第一次做订单系统时,就用了这种方式,简单粗暴,但非常有效。

表结构示例:

CREATE TABLE idempotency_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    idempotency_key VARCHAR(64) NOT NULL UNIQUE,  -- 唯一索引
    result TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Java 代码示例:

@Transactional
public Response createOrder(CreateOrderRequest request) {
    String key = request.getOrderNo();
    
    // 尝试插入去重记录
    try {
        idempotencyRecordDao.insert(new IdempotencyRecord(key, null));
    } catch (DuplicateKeyException e) {
        // 已存在,说明重复请求
        return idempotencyRecordDao.getResultByKey(key);
    }
    
    // 执行业务逻辑
    Order order = doCreateOrder(request);
    
    // 更新结果
    idempotencyRecordDao.updateResultByKey(key, order);
    
    return Response.success(order);
}
注意: 去重表的插入操作必须在事务的最前面,而且要和业务逻辑在同一个事务里。否则可能出现:去重记录插入了,但业务逻辑失败了,事务回滚后去重记录也没了,下次请求又进来了。

3. 状态机(State Machine)

状态机是另一种优雅的幂等实现。它通过限制状态的流转方向,来保证同一个操作不会重复执行。比如订单状态:待支付 → 已支付 → 已发货 → 已完成。每个状态只能向前流转,不能回退。

你想想看,如果订单已经是“已支付”状态,你再调用“支付”接口,状态机发现当前状态不允许再执行“支付”操作,就会直接返回成功,而不会重复扣款。

状态机实现的核心是:每个操作都定义允许的当前状态。比如:

  • 支付操作:只允许在“待支付”状态执行
  • 发货操作:只允许在“已支付”状态执行
  • 确认收货:只允许在“已发货”状态执行

代码示例(Java 状态机):

public enum OrderStatus {
    PENDING_PAYMENT,
    PAID,
    SHIPPED,
    COMPLETED,
    CANCELLED;
    
    private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = new HashMap<>();
    
    static {
        TRANSITIONS.put(PENDING_PAYMENT, Set.of(PAID, CANCELLED));
        TRANSITIONS.put(PAID, Set.of(SHIPPED, CANCELLED));
        TRANSITIONS.put(SHIPPED, Set.of(COMPLETED));
        TRANSITIONS.put(COMPLETED, Set.of());
        TRANSITIONS.put(CANCELLED, Set.of());
    }
    
    public boolean canTransitionTo(OrderStatus target) {
        return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(target);
    }
}

// 使用
public Response payOrder(Long orderId) {
    Order order = orderDao.getById(orderId);
    
    if (!order.getStatus().canTransitionTo(OrderStatus.PAID)) {
        // 状态不允许,直接返回成功(幂等)
        return Response.success("订单已支付");
    }
    
    // 执行支付逻辑
    doPay(order);
    
    order.setStatus(OrderStatus.PAID);
    orderDao.update(order);
    
    return Response.success("支付成功");
}
我的经验: 状态机特别适合业务流程固定的场景。我在做物流系统时,把运单状态用状态机管理,每个操作都检查状态是否合法。这样即使回调重复了,也不会出现“已签收的包裹又被派送一次”这种荒唐事。

支付与订单系统的幂等设计实战

好了,理论讲完了,我们来看看实际系统里怎么用。支付和订单系统是幂等设计的重灾区,我直接说几个关键点。

支付系统的幂等设计

支付系统里,最怕的就是重复扣款。我总结了几条铁律:

  1. 支付请求必须带幂等键:通常用支付流水号或订单号。服务端用幂等键去重。
  2. 支付回调必须幂等:第三方支付回调可能重复,服务端要用支付流水号做去重。
  3. 退款操作也要幂等:退款单号作为幂等键,防止重复退款。
  4. 状态机保护:支付状态只能从“待支付”变为“已支付”,不能从“已支付”再变一次。

我曾经遇到过一个线上事故:支付回调重复了三次,第一次成功了,第二次和第三次因为没做幂等,又执行了两次入账逻辑,导致用户账户余额多了三倍。嗯,那次事故之后,我们团队把幂等检查加到了所有支付相关的接口上。

订单系统的幂等设计

订单系统里,幂等主要体现在这几个地方:

  • 创建订单:用订单号或幂等键去重,防止重复下单
  • 取消订单:状态机限制,只有“待支付”或“已支付”的订单才能取消
  • 修改订单:用版本号或乐观锁,防止并发修改

这里我特别想提一下乐观锁。它虽然不是严格意义上的幂等模式,但能很好地解决并发问题。比如:

UPDATE orders 
SET status = 'PAID', version = version + 1 
WHERE id = ? AND version = ?

这样即使两个请求同时来,也只有一个能更新成功。另一个会失败,然后重试时发现状态已经变了,自然就幂等了。

一张图看懂幂等模式

下面我用一张 SVG 图,把幂等模式的三种实现和它们在支付订单系统中的应用串起来。

幂等模式核心实现与实战应用 幂等键 去重表 状态机 唯一标识 + 缓存 分布式锁 + 双重检测 直接返回缓存结果 数据库唯一索引 插入冲突即重复 事务内保证一致性 状态流转方向控制 不允许回退或跳跃 重复操作直接返回 支付与订单系统实战应用 支付系统 • 支付请求:幂等键去重 • 支付回调:去重表 + 状态机 • 退款操作:幂等键 + 状态机 订单系统 • 创建订单:去重表防止重复下单 • 取消订单:状态机限制 • 修改订单:乐观锁 + 版本号

总结一下

幂等模式不是什么高深的技术,但它能救你的命。我做了这么多年系统,见过太多因为没做幂等而出的事故。说白了,你只要记住三点:

  • 幂等键:适合接口级别的去重,用缓存实现,性能好
  • 去重表:适合数据一致性要求高的场景,用数据库唯一索引
  • 状态机:适合业务流程固定的场景,从设计上杜绝重复操作

在实际项目中,这三种方式经常组合使用。比如支付系统里,幂等键做第一道防线,去重表做第二道,状态机做兜底。这样即使某一层出了问题,其他层也能拦住。

嗯,今天就讲到这里。幂等模式看起来简单,但真正用好它,需要你对业务场景有深刻的理解。希望今天的分享能帮到你。


公众号:蓝海资料掘金营,微信 deep3321