
很多 Java 开发第一次用分布式锁通常都是因为项目上了多实例。单机时没问题代码一部署成 2 台、3 台问题就出来了同一个订单被处理两次同一批库存被扣了两遍定时任务在两台机器上同时跑同一个用户抢券抢出了多张这时候最常见的解决思路就是一句话“加个分布式锁吧。”这句话当然没错但问题是很多人只知道“要加锁”却不知道锁到底该加在哪里锁 key 怎么设计锁多久过期才合理业务执行超时了怎么办解锁时怎么防止把别人的锁删掉所以分布式锁真正难的地方是怎么让锁真的锁住业务这篇文章不讲太虚的理论直接讲最常见的几个场景以及对应的正确做法。一、先说清楚为什么会需要分布式锁你可以把分布式锁理解成一句大白话在多台机器同时跑的时候保证某一段业务同一时刻只能有一个执行者。注意这里有两个关键词多台机器同一时刻只有一个执行者比如下面这些场景就很典型同一个用户只能提交一次抢购请求同一个订单只能被支付成功处理一次同一个商品扣库存时不能并发超卖同一个定时任务只能有一台机器执行如果你的服务还是单机很多问题用 synchronized 或本地锁就够了。但只要上了集群本地锁就失效了因为A 机器上的锁B 机器根本看不见。这时候才需要分布式锁。二、什么场景适合分布式锁什么场景不适合这一步特别重要。很多人一遇到并发问题就想上分布式锁结果最后系统越来越慢问题还没完全解决。适合分布式锁的场景同一业务对象必须串行处理资源竞争范围明确冲突概率较高不方便只靠数据库唯一约束解决比如userId 级别防重复提交orderNo 级别防重复处理productId 级别扣库存串行化taskName 级别防止重复跑任务不太适合分布式锁的场景纯查询类接口高频但冲突极低的场景完全可以用数据库唯一索引解决的场景完全可以用状态机或条件更新解决的场景一句话总结能不用分布式锁解决的问题尽量别上锁。因为锁本质上是在降低并发换取一致性。三、最常见的错误把 Redis setnx 当成完整分布式锁很多人会这么写Boolean success redisTemplate.opsForValue().setIfAbsent(lock:order:1001, 1); if (Boolean.TRUE.equals(success)) { try { // do business } finally { redisTemplate.delete(lock:order:1001); } }看起来像那么回事但这里至少有 3 个大问题没有过期时间如果服务宕机锁就可能一直不释放value 没有唯一标识你根本不知道这个锁是不是自己加的直接 delete 有误删风险你的业务超时了锁过期后被别人拿走你再 delete就把别人的锁删了这类代码在线上非常危险。四、正确的 Redis 分布式锁至少要满足这 3 个条件如果你要自己实现 Redis 锁最少要做到加锁和设置过期时间必须是原子操作锁的 value 必须是唯一值解锁时要先判断“锁是不是自己的”五、一个更靠谱的 Redis 锁实现1. 加锁SET key value NX EXpublic boolean tryLock(String key, String requestId, long expireSeconds) { Boolean success redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(success); }这里解决了两个问题NX只有不存在时才能设置成功EX加锁时就带过期时间避免死锁2. 解锁必须判断 value 是否匹配解锁不能直接 delete而要用 Lua 保证原子性private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end ; public boolean unlock(String key, String requestId) { Long result redisTemplate.execute( new DefaultRedisScript(UNLOCK_SCRIPT, Long.class), Collections.singletonList(key), requestId ); return Long.valueOf(1).equals(result); }为什么一定要这样因为你必须保证只有加锁的人才能解自己的锁。否则业务超时后锁过期了别人已经拿到了新锁你的 finally 再 delete就会误删别人的锁。六、真实场景 1防止同一用户重复下单很多系统都有这种问题用户连续点了两次提交两个请求几乎同时打到不同实例结果生成了两笔订单正确思路按用户或业务请求号加锁public Long createOrder(CreateOrderRequest request) { String lockKey lock:create:order:user: request.getUserId(); String requestId UUID.randomUUID().toString(); boolean locked redisLock.tryLock(lockKey, requestId, 5); if (!locked) { throw new BusinessException(请求过于频繁请稍后重试); } try { return doCreateOrder(request); } finally { redisLock.unlock(lockKey, requestId); } }这就够了吗不完全够。因为分布式锁更适合控制“并发窗口”但真正的数据一致性还应该有数据库兜底。比如订单表再加唯一约束ALTER TABLE orders ADD UNIQUE INDEX uk_request_id (request_id);最稳妥的做法是锁控制并发数据库控制最终一致性不要把所有希望都押在锁上。七、真实场景 2扣库存时怎么避免超卖库存是分布式锁最经典的应用场景之一。错误写法public void deductStock(Long productId, int quantity) { Product product productMapper.selectById(productId); if (product.getStock() quantity) { throw new BusinessException(库存不足); } productMapper.updateStock(productId, product.getStock() - quantity); }这段代码在并发下很容易出问题两个线程同时查到库存都是 10两个线程都判断“够扣”最后库存被多扣方案一分布式锁串行化商品扣减public void deductStock(Long productId, int quantity) { String lockKey lock:stock:product: productId; String requestId UUID.randomUUID().toString(); boolean locked redisLock.tryLock(lockKey, requestId, 3); if (!locked) { throw new BusinessException(系统繁忙请稍后重试); } try { Product product productMapper.selectById(productId); if (product.getStock() quantity) { throw new BusinessException(库存不足); } productMapper.updateStock(productId, product.getStock() - quantity); } finally { redisLock.unlock(lockKey, requestId); } }但这还不是最优解库存场景里很多时候更推荐直接用数据库条件更新UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity};Java 里判断更新条数int updated productMapper.deductStock(productId, quantity); if (updated 0) { throw new BusinessException(库存不足); }为什么这个更好因为它比“先查再改”更原子也比“全靠锁”更轻。所以库存场景的经验是能用数据库原子更新解决就优先别上分布式锁。八、真实场景 3定时任务重复执行怎么防止两台机器一起跑这是线上非常常见的分布式锁使用场景。比如每天凌晨 1 点跑结算任务部署了 3 台服务。如果不加控制3 台都会执行数据可能被处理三遍。正确方案任务入口加分布式锁Scheduled(cron 0 0 1 * * ?) public void settleTask() { String lockKey lock:task:settle; String requestId UUID.randomUUID().toString(); boolean locked redisLock.tryLock(lockKey, requestId, 300); if (!locked) { return; } try { settlementService.doSettle(); } finally { redisLock.unlock(lockKey, requestId); } }这里要注意什么最大的坑是过期时间不能乱设。如果任务要跑 3 分钟你只给 30 秒锁超时那 30 秒一到锁自动过期别的实例就可能又进来执行一次。所以定时任务场景下锁超时要么设置成明显大于任务执行时间要么做续约机制九、分布式锁最容易踩的 5 个坑1. 锁没有过期时间这是最危险的服务异常退出就会形成死锁。2. 过期时间太短业务没执行完锁先过期了别人又进来了结果变成“多个执行者同时工作”。3. 过期时间太长虽然不会轻易失效但一旦业务卡住其他请求会长时间拿不到锁用户体验很差。4. 解锁直接 delete这会带来误删别人的锁的问题必须校验 value。5. 把分布式锁当成万能方案很多问题本来更适合唯一索引状态机乐观锁条件更新结果一上来先加锁系统复杂度和性能成本都上去了。十、锁超时时间到底怎么设这是最常被问的问题。没有统一答案但有一个很实用的原则锁超时时间 正常业务执行时间的 2~3 倍再留一点缓冲。比如正常业务 200ms 内完成可以给 3~5 秒普通接口 1 秒内完成可以给 5~10 秒定时任务 2 分钟跑完可以给 5 分钟左右别迷信固定值关键是结合业务耗时来定。如果业务耗时波动特别大建议用成熟组件支持“自动续约”。十一、自己写 Redis 锁还是直接用 Redisson如果是生产项目我更建议优先用成熟组件比如 Redisson。因为它帮你处理了很多细节可重入锁自动续约超时控制解锁安全性更完整的分布式同步能力例如RLock lock redissonClient.getLock(lock:order: orderNo); boolean locked lock.tryLock(0, 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException(系统繁忙请稍后再试); } try { doBusiness(); } finally { lock.unlock(); }为什么很多团队最终都选 Redisson因为分布式锁看起来代码不多但真正难的是细节。自己写一版 demo 不难写到线上稳定可用就完全是另一回事。十二、什么时候该用分布式锁什么时候不该用我给你一个最实用的判断口诀该用的时候多实例并发处理同一业务对象同一时刻只能允许一个执行者冲突概率高仅靠前端防抖和本地锁不够不该优先用的时候能用唯一索引解决能用条件更新解决能用状态机解决能用乐观锁解决十三、我最推荐的落地思路如果你真要在项目里上分布式锁我最推荐这套先判断是不是非锁不可锁 key 设计到具体业务对象加锁必须带过期时间value 必须唯一解锁必须校验 value核心数据再用数据库兜底尽量用成熟组件不要手搓线上基础设施