分布式锁实战:防重下单、设备独占控制
同事写了个秒杀接口,测试时手速过快连点了20下鼠标,结果一个用户下了20单。他转头问我:“哥,咋防重?” 我说:“兄弟,你需要一把锁——不是锁门的那种,是锁并发的。”
一、分布式锁的应用场景
在单机时代,synchronized或ReentrantLock就能搞定并发问题。但微服务是多实例部署的,JVM 锁只能控制当前进程,跨进程就管不到了。这时候需要一把能横跨所有服务实例的锁——分布式锁。
典型场景:
| 场景 | 说明 | 不锁的后果 |
|---|---|---|
| 防重下单 | 用户快速点击多次 | 扣钱多次,只出一次货 |
| 库存扣减 | 并发减库存 | 超卖,库存变负数 |
| 设备独占 | 同一设备同一时间只能被一个用户操作 | A用户的操作被B用户覆盖 |
| 定时任务防重 | 多实例同时跑定时任务 | 重复执行,数据错乱 |
二、四种分布式锁方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| DB 唯一索引 | INSERT 冲突 → 加锁失败 | 实现简单 | 性能差,无过期释放 |
| Redis SETNX | SET key value NX EX | 高性能,易用 | 锁过期业务没完 |
| Redisson | 基于Redis的高层封装 | 功能最全 | 依赖Redis高可用 |
| ZooKeeper | 临时顺序节点 | CP一致性极强 | 性能不如Redis,维护复杂 |
推荐:业务场景优先选 Redisson(Redis 封装),因为大多数系统本来就有 Redis,开箱即用。除非对一致性要求极高(如金融资金清算),否则没必要上 ZooKeeper。
三、原生 Redis 实现分布式锁
// 加锁StringlockKey="lock:order:"+userId;Booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,uuid,30,TimeUnit.SECONDS);// NX + EXif(!Boolean.TRUE.equals(locked)){thrownewBizException("操作太频繁,请稍后再试");}// 释放锁 —— 必须用 Lua 脚本保证原子性!Stringlua="if redis.call('get', KEYS[1]) == ARGV[1] then "+" return redis.call('del', KEYS[1]) "+"else return 0 end";redisTemplate.execute(newDefaultRedisScript<>(lua,Long.class),Collections.singletonList(lockKey),uuid);为什么释放锁要用 Lua 脚本?如果分两步:先 get 再 del,中间可能发生 GC 停顿。Redis 判断 value 相等后准备删除,结果下一秒锁已过期被别人抢走——你删的就是别人的锁了。Lua 脚本让"判断+删除"变成一个原子操作,杜绝此风险。
上面这段代码还有一个致命问题:如果业务逻辑执行了 40 秒,锁在 30 秒时就自动过期了——别的人又拿到了锁,导致并发问题。
四、Redisson:专业级的分布式锁
Redisson 是 Redis 官方推荐的 Java 客户端,它对分布式锁做了很厚的封装,把上述问题全部解决。
@ConfigurationpublicclassRedissonConfig{@BeanpublicRedissonClientredissonClient(){Configconfig=newConfig();config.useSingleServer().setAddress("redis://127.0.0.1:6379");returnRedisson.create(config);}}4.1 看门狗(Watchdog)自动续期
Redisson 最核心的机制。你设置锁过期 30 秒,如果 20 秒后业务还在跑,看门狗自动帮你续到 30 秒。业务完成后锁自动释放,不会长期占用。
RLocklock=redissonClient.getLock("lock:order:"+userId);lock.lock();// 默认30秒过期,看门狗每10秒续一次try{// 业务逻辑,随便执行多久}finally{lock.unlock();// 看门狗停止续期}4.2 tryLock:带超时的非阻塞加锁
booleanlocked=lock.tryLock(5,60,TimeUnit.SECONDS);// 等待5秒,拿到后持有60秒if(!locked){thrownewBizException("系统繁忙,请稍后再试");}4.3 可重入锁
同一个线程多次lock.lock()不会阻塞自己——自动计数:
lock.lock();// 持有计数 = 1lock.lock();// 持有计数 = 2lock.unlock();// 持有计数 = 1lock.unlock();// 持有计数 = 0,真正释放4.4 公平锁/读写锁
// 公平锁:按申请顺序排队获取RFairLockfairLock=redissonClient.getFairLock("lock:order");// 读写锁:读并发、写互斥RReadWriteLockrwLock=redissonClient.getReadWriteLock("lock:device");rwLock.readLock().lock();// 多个读操作可以并发rwLock.writeLock().lock();// 写操作互斥读写锁非常适合"设备状态读取频繁,但修改低频"的场景。
五、实战1:防重下单
@ServicepublicclassOrderService{@AutowiredprivateRedissonClientredissonClient;@TransactionalpublicOrdercreateOrder(LonguserId,List<OrderItem>items){StringlockKey="lock:order:user:"+userId;RLocklock=redissonClient.getLock(lockKey);booleanacquired=false;try{acquired=lock.tryLock(3,30,TimeUnit.SECONDS);if(!acquired){thrownewBizException("请勿重复提交订单");}// 幂等性检查:30秒内同一用户的重复请求StringidempotentKey="order:submit:"+userId+":"+requestHash(items);Booleanexists=redisTemplate.opsForValue().setIfAbsent(idempotentKey,"1",30,TimeUnit.SECONDS);if(!Boolean.TRUE.equals(exists)){thrownewBizException("订单已提交,请勿重复操作");}// 创建订单...returnorder;}finally{if(acquired&&lock.isHeldByCurrentThread()){lock.unlock();}}}}双层防护:外层分布式锁(防并发),内层幂等键(防重复提交)。两者结合才是真正可靠的防重方案。
六、实战2:设备独占控制
在物联网/无人售货柜场景中,一台设备同一时间只能被一个操作员操作,否则指令冲突导致状态混乱。
@ComponentpublicclassDeviceLockManager{@AutowiredprivateRedissonClientredissonClient;privatefinalConcurrentHashMap<String,RLock>lockCache=newConcurrentHashMap<>();publicbooleanacquireDevice(LongdeviceId,LongoperatorId,inttimeoutSec){Stringkey="lock:device:"+deviceId;RLocklock=redissonClient.getLock(key);booleanacquired=lock.tryLock();if(acquired){lockCache.put(key,lock);// 记录谁持有了这把锁redisTemplate.opsForValue().set("lock:device:owner:"+deviceId,String.valueOf(operatorId),Duration.ofSeconds(timeoutSec));}returnacquired;}publicvoidreleaseDevice(LongdeviceId){Stringkey="lock:device:"+deviceId;RLocklock=lockCache.remove(key);if(lock!=null&&lock.isHeldByCurrentThread()){lock.unlock();}redisTemplate.delete("lock:device:owner:"+deviceId);}}七、分布式锁的常见陷阱
| 陷阱 | 现象 | 解决 |
|---|---|---|
| 锁过期业务没完成 | 业务跑40秒,锁30秒就没了 | Redisson看门狗 / 预估锁时间 |
| 释放别人的锁 | 用 value 匹配解决 | Lua 原子脚本 / Redisson |
| Redis 主从丢锁 | 主节点挂了,从节点没有锁数据 | RedLock 算法 / 容忍偶尔重复 |
| 死锁 | 持有锁的服务宕机了 | 设置过期时间 / 看门狗 |
7.1 RedLock 算法(附争议)
Redisson 实现了 Redis 官方提出的 RedLock 算法:在 N 个独立的 Redis 主节点上同时加锁,超过半数(N/2+1)成功才算获得锁。这样单节点故障也不会丢锁。
但 Martin Kleppmann(《设计数据密集型应用》作者)对此提出了质疑,认为 RedLock 无法解决时钟跳跃等问题。Redis 作者 antirez 也做了回应。
个人建议:如果业务不是金融级精确计费,单节点 Redis + 看门狗 + 合理的过期时间就够了。折腾 RedLock 不如把 Redis 的 Sentinel/Cluster 配置到位。
八、完整封装工具类
@FunctionalInterfacepublicinterfaceLockedTask<T>{Texecute()throwsException;}@Slf4j@ComponentpublicclassLockTemplate{@AutowiredprivateRedissonClientredissonClient;public<T>TexecuteWithLock(Stringkey,LockedTask<T>task){RLocklock=redissonClient.getLock(key);try{if(!lock.tryLock(5,30,TimeUnit.SECONDS)){thrownewBizException("获取锁失败,请稍后再试");}returntask.execute();}catch(BizExceptione){throwe;}catch(Exceptione){thrownewRuntimeException(e);}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}}用的时候一行代码搞定:
lockTemplate.executeWithLock("lock:order:"+userId,()->{returnorderMapper.insert(order);});小结
分布式锁是微服务并发控制的"定海神针"。Redisson 凭借看门狗、可重入、公平锁/读写锁等特性,是目前最成熟的 Java 分布式锁方案。使用时注意:一定要设置过期时间,一定要用 try/finally 释放锁,一定要验证锁是不是自己持有的。这三条做到了,99% 的分布式锁坑你都不会踩。