明明接口幂等就能防重复提交,为什么还要加分布式锁?
这个问题挺有代表性的我在做 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实际却是 1200100 块钱凭空消失了。这就是典型的并发写覆盖问题幂等完全无能为力必须用锁来串行化对同一账户的操作。场景三分布式 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 秒自动释放了这时候如果有一个重试请求进来拿到了锁没有幂等校验的话就会重复执行。分布式锁防的是并发幂等防的是重复一个管时间窗口一个管最终结果。说在最后回到最开始的问题有了幂等为什么还要分布式锁因为幂等的前提是能正确判断这个操作是否已经执行过而在高并发场景下如果没有锁的保护连这个判断本身都可能出错。幂等是对结果负责的不管你调几次效果一样。分布式锁是对过程的约束同一时刻只能有一个人在操作。一个管结果一个管过程缺一不可。