跳至主要內容

程序员小富大约 6 分钟

大家好,我是小富~

《十万个why》系列持续更新中

这个问题挺有代表性的,我在做 Code Review 的时候也见过不少人把这两个东西搞混,觉得做了幂等就万事大吉了,分布式锁是多余的。

幂等和分布式锁解决的根本不是同一个问题。幂等管的是结果正确性,分布式锁管的是并发执行顺序。两者是互补关系,不是替代关系。

幂等到底在防什么

幂等的定义很简单:同一个请求执行一次和执行多次,产生的效果一样。

最常见的实现方式就是幂等 Token:前端提交前先请求一个唯一 Token,提交时带上这个 Token,后端收到后先查这个 Token 是否已经被消费过,消费过就直接返回,没消费过就执行业务逻辑。

public String submitOrder(String idempotentToken, OrderRequest request) {
    // 1. 检查 Token 是否已消费
    boolean exists = redis.hasKey("idempotent:" + idempotentToken);
    if (exists) {
        return "重复提交,请勿重复操作";
    }
    // 2. 标记 Token 已消费
    redis.opsForValue().set("idempotent:" + idempotentToken, "1", 30, TimeUnit.MINUTES);
    // 3. 执行业务逻辑
    orderService.createOrder(request);
    return "下单成功";
}

看起来没毛病吧?Token 用过了就标记,第二次来直接拒绝,完美防重复提交。

问题出在哪?

上面这段代码在低并发下确实没问题。但线上环境,用户手抖双击提交按钮,或者前端没做防抖,两个请求间隔可能只有几十毫秒,几乎同时到达后端。

画个时间线你就明白了:

两个请求都在 T1、T2 时刻检查 Token,此时 Token 都还没被标记,所以都通过了检查,最终两个请求都执行了下单逻辑,一笔订单变成了两笔

这就是经典的 检查-执行竞态条件。幂等检查和业务执行之间有一个时间窗口,并发请求可以同时穿过这个窗口。

用 Redis 原子操作能解决?

有人说把检查和标记合成一个原子操作不就行了:

public String submitOrder(String idempotentToken, OrderRequest request) {
    // SETNX:不存在才设置,原子操作
    Boolean success = redis.opsForValue()
        .setIfAbsent("idempotent:" + idempotentToken, "1", 30, TimeUnit.MINUTES);
    if (!success) {
        return "重复提交,请勿重复操作";
    }
    // 执行业务逻辑
    orderService.createOrder(request);
    return "下单成功";
}

SETNX 确实能保证只有一个请求能设置成功,解决了并发下 Token 检查的竞态问题。

在防重复提交这个简单场景下,SETNX 确实够用了。但这就等于你已经在用分布式锁的思路了,SETNX 本质上就是一个最简版的分布式锁,只不过你没显式地叫它锁而已。

那为什么实际项目中还是要单独引入分布式锁呢?因为真实业务场景比防重复提交复杂得多。

场景一:业务逻辑本身有并发互斥需求

看一个扣库存的场景:

public void deductStock(String skuId, int quantity) {
    // 1. 查当前库存
    int stock = stockMapper.getStock(skuId);
    // 2. 判断库存是否充足
    if (stock < quantity) {
        throw new BizException("库存不足");
    }
    // 3. 扣减库存
    stockMapper.deductStock(skuId, quantity);
}

这里根本没有幂等 Token 的概念,每个用户下的都是不同的订单、不同的请求,幂等校验全部放行。但 100 个用户同时买最后 1 件商品,如果没有锁来控制并发,就会出现超卖。

幂等解决的是 同一个操作别执行两次,而这里的问题是 不同的操作在并发修改同一份数据,这是幂等覆盖不到的。

加上分布式锁:

public void deductStock(String skuId, int quantity) {
    RLock lock = redisson.getLock("lock:stock:" + skuId);
    lock.lock();
    try {
        int stock = stockMapper.getStock(skuId);
        if (stock < quantity) {
            throw new BizException("库存不足");
        }
        stockMapper.deductStock(skuId, quantity);
    } finally {
        lock.unlock();
    }
}

锁保证了同一时刻只有一个线程能进入 查库存 -> 判断 -> 扣减 这个完整的流程,从根本上杜绝了并发读写数据不一致的问题。

场景二:幂等挡不住并发下的数据错乱

再看一个转账的场景:A 给 B 转 100 块,同时 C 也给 B 转 200 块。

public void transfer(String from, String to, BigDecimal amount) {
    // 1. 查余额
    BigDecimal balance = accountMapper.getBalance(to);
    // 2. 加钱
    accountMapper.updateBalance(to, balance.add(amount));
}

两笔转账是完全不同的业务操作,幂等 Token 不会拦截任何一笔。但并发执行的时候:

两笔转账都完成了,但 B 的余额应该是 1300,实际却是 1200,100 块钱凭空消失了。这就是典型的并发写覆盖问题,幂等完全无能为力,必须用锁来串行化对同一账户的操作。

场景三:分布式 synchronized 不够用

有人说加个 synchronized 就行了,在单机环境下确实可以:

public synchronized void deductStock(String skuId, int quantity) {
    // ...
}

但微服务架构下,同一个服务部署了 3 台机器,synchronized 只能锁住当前 JVM 内的线程。请求被 Nginx 分发到不同的机器上,3 台机器上的 3 个线程各自锁各自的,等于没锁。

三个节点的锁互相不可见,三个请求同时操作同一份数据,并发问题照样出现。分布式锁就是为了解决跨 JVM 的互斥问题,让多台机器像在一个 JVM 里一样排队执行。

分布式锁 + 幂等

一个线上靠谱的下单接口应该是这样的:

public String submitOrder(String idempotentToken, OrderRequest request) {

    // 第一层:分布式锁 - 控制并发,同一时刻只允许一个请求进入
    RLock lock = redisson.getLock("lock:order:" + idempotentToken);
    boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
    if (!acquired) {
        return "系统繁忙,请稍后重试";
    }

    try {
        // 第二层:幂等校验 - 锁内检查,保证不会重复执行
        Boolean firstTime = redis.opsForValue()
            .setIfAbsent("idempotent:" + idempotentToken, "1", 30, TimeUnit.MINUTES);
        if (!firstTime) {
            return "订单已提交,请勿重复操作";
        }
        // 执行业务
        orderService.createOrder(request);
        return "下单成功";
    } finally {
        lock.unlock();
    }
}

为什么锁内还要做幂等?因为分布式锁是有过期时间的。

假设锁的过期时间是 10 秒,业务逻辑因为一次慢 SQL 执行了 12 秒,锁在第 10 秒自动释放了,这时候如果有一个重试请求进来,拿到了锁,没有幂等校验的话就会重复执行。

分布式锁防的是并发,幂等防的是重复,一个管时间窗口,一个管最终结果。

说在最后

回到最开始的问题:有了幂等为什么还要分布式锁?

因为幂等的前提是能正确判断这个操作是否已经执行过,而在高并发场景下,如果没有锁的保护,连这个判断本身都可能出错。

幂等是对结果负责的,不管你调几次,效果一样。分布式锁是对过程的约束,同一时刻,只能有一个人在操作。一个管结果,一个管过程,缺一不可。

上次编辑于: