分布式锁原理与实战Redisson 可重入锁、公平锁、读写锁作者黒漂技术佬 | 系列Redis 缓存与高并发实战单机时代加锁很简单——Java 里的synchronized或ReentrantLock就能搞定。但当你把服务部署到多台机器上问题就来了synchronized只能锁住单个 JVM 进程机器 A 上的锁管不到机器 B。这时候就需要分布式锁——一把能跨机器的锁。一、为什么需要分布式锁单机锁的局限性场景两个服务实例同时处理同一笔订单 实例A (JVM-1) 实例B (JVM-2) ┌──────────┐ ┌──────────┐ │ synchronized │ │ synchronized │ │ 锁住了 ✅ │ │ 锁住了 ✅ │ └──────────┘ └──────────┘ ↓ ↓ 两个JVM各锁各的互不影响 → 同一订单被处理两次 ynchronized是 JVM 级别的锁作用范围仅限单个进程。多实例部署时每个 JVM 各自加锁互不影响锁就形同虚设。分布式锁的核心要求一把合格的分布式锁需要满足互斥性同一时刻只有一个客户端能持有锁可重入性同一客户端可以多次获取同一把锁避免死锁锁超时持锁客户端宕机后锁能自动释放防止死锁加锁解锁同源谁加的锁只能谁解不能解别人的锁常见实现方式方案优点缺点RedisSETNX性能高实现简单需要处理续期、误删等问题Zookeeper强一致性可靠性高性能较低部署运维复杂MySQL排他锁简单直接性能差不适合高并发本文聚焦 Redis 方案这也是工业界最常用的。二、Redis 分布式锁的演进之路第一版SETNX 基础版SETNXSET if Not eXistsKey 不存在才设置返回 1 表示成功返回 0 表示失败。# 加锁SETNX lock:order:1001locked# 返回 1 表示抢到锁0 表示已被占用# 解锁DEL lock:order:1001问题如果加锁后程序崩溃了没来得及DEL这把锁就永远存在——死锁。第二版SETNX EXPIRESETNX lock:order:1001locked# 加锁EXPIRE lock:order:100110# 设 10 秒过期问题这两条命令不是原子操作。如果SETNX成功后、EXPIRE执行前程序挂了锁还是没有过期时间——依然死锁。第三版SET key value NX EX原子加锁Redis 2.6.12 后SET命令支持NX和EX参数一条命令搞定# 原子操作不存在才设置 设过期时间SET lock:order:1001client_uuid_001NX EX10NXNot eXists等价于 SETNX 语义EX 10过期时间 10 秒value 设为客户端唯一标识UUID后面解锁要用问题加锁解决了但解锁有新坑。释放锁的坑误删别人的锁时间线 t1: 客户端A 加锁过期时间 10 秒 t2: 客户端A 业务执行太慢超过 10 秒锁自动过期 t3: 客户端B 加锁成功因为A的锁已过期 t4: 客户端A 执行完业务调用 DEL 删除锁 t5: 但删的是客户端B 的锁第四版Lua 脚本释放锁最终版解锁时先检查 value 是不是自己的是才删除。但检查 删除两步操作必须原子化——用 Lua 脚本保证。-- unlock.luaifredis.call(GET,KEYS[1])ARGV[1]thenreturnredis.call(DEL,KEYS[1])elsereturn0end// Java 调用Stringscriptif redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end;DefaultRedisScriptLongredisScriptnewDefaultRedisScript(script,Long.class);// 加锁StringclientIdUUID.randomUUID().toString();redis.opsForValue().set(lock:order:1001,clientId,10,TimeUnit.SECONDS);try{// 执行业务逻辑doBusiness();}finally{// 解锁Lua 脚本保证判断删除原子性redis.execute(redisScript,Collections.singletonList(lock:order:1001),clientId);}为什么用 Lua 脚本Redis 执行 Lua 脚本是原子的脚本里的多条命令不会被其他命令插队。这是 Redis 保证操作原子性的标准手段。还有一个问题锁超时但业务没执行完锁 10 秒过期了但业务要 15 秒才执行完。这时候锁被别人抢走两个客户端同时操作——怎么办解决方案看门狗Watchdog自动续期。这个功能 Redisson 帮你做好了。三、Redisson 分布式锁自己手写分布式锁坑太多了——原子性、续期、可重入、误删……工业界几乎都用Redisson这个框架。它基于 Redis 封装了完整的分布式锁实现。Redisson 简介Redisson 是一个 Java 版的 Redis 客户端提供了大量分布式数据结构和工具分布式锁、分布式集合、分布式限流器等。它的锁实现是目前最完善的。1. 可重入锁RLock可重入锁是最常用的分布式锁。“可重入”意思是同一个线程可以多次获取同一把锁不会自己把自己锁死。AutowiredprivateRedissonClientredisson;publicvoidprocessOrder(StringorderId){RLocklockredisson.getLock(lock:order:orderId);try{// 尝试加锁最多等待 5 秒锁自动过期 30 秒booleanlockedlock.tryLock(5,30,TimeUnit.SECONDS);if(locked){// 执行业务doSomething(orderId);// 可重入同一个线程可以再次获取这把锁processPayment(orderId);// 内部也会获取同一把锁不会死锁}}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}看门狗机制如果你不指定锁的过期时间只调用lock.lock()或lock.tryLock(waitTime, unit)Redisson 会启用看门狗默认锁过期时间 30 秒每隔 10 秒过期时间的 1/3检查持有锁的线程是否还活着如果还活着自动把过期时间重置为 30 秒如果线程挂了看门狗停止续期锁 30 秒后自动释放看门狗工作流程 t0s 加锁过期时间 30s t10s 看门狗续期 → 过期时间重置为 30s t20s 看门狗续期 → 过期时间重置为 30s t25s 业务执行完毕unlock (如果 t25s 线程挂了看门狗也停了锁 30s 后自动释放)关键区别lock.tryLock(5, 30, TimeUnit.SECONDS)第二个参数指定了过期时间看门狗不会启动。只有不传 leaseTime 时看门狗才生效。这是个常见的坑。Redisson 可重入锁的底层原理Redisson 的可重入锁用Hash 结构存储而不是 String# Hash 结构# Key: 锁名称# Field: 客户端ID 线程ID# Value: 重入次数HSET lock:order:1001client_uuid:thread_11# 第一次加锁HINCRBY lock:order:1001client_uuid:thread_11# 重入次数 1HINCRBY lock:order:1001client_uuid:thread_1-1# 解锁次数 -1# 次数减到 0 时DEL 整个 Key加锁的 Lua 脚本简化版-- 如果锁不存在或锁是当前线程持有的ifredis.call(exists,KEYS[1])0orredis.call(hexists,KEYS[1],ARGV[2])1thenredis.call(hincrby,KEYS[1],ARGV[2],1)-- 重入次数 1redis.call(pexpire,KEYS[1],ARGV[1])-- 设置过期时间returnnilendreturnredis.call(pttl,KEYS[1])-- 返回锁剩余时间2. 公平锁FairLock普通锁是抢的——谁先SETNX成功谁拿到和请求顺序无关。公平锁按请求顺序排队先到先得。RLockfairLockredisson.getFairLock(lock:order:orderId);fairLock.lock(30,TimeUnit.SECONDS);try{doBusiness();}finally{fairLock.unlock();}适用场景对公平性有要求的场景比如订单处理——先下的订单应该先处理。但公平锁性能比非公平锁低需要维护队列非必要不用。3. 读写锁RReadWriteLock读写锁分离读和写读锁共享锁多个客户端可以同时持有读锁写锁排他锁只有一个客户端能持有写锁且和读锁互斥读-读不互斥 ✅ (可以同时读) 读-写互斥 ✅ (读时不能写) 写-写互斥 ✅ (不能同时写)RReadWriteLockrwLockredisson.getReadWriteLock(rwlock:product:productId);// 读操作加读锁RLockreadLockrwLock.readLock();readLock.lock();try{returngetProductFromCache(productId);}finally{readLock.unlock();}// 写操作加写锁RLockwriteLockrwLock.writeLock();writeLock.lock();try{updateProductInDB(productId);refreshCache(productId);}finally{writeLock.unlock();}适用场景读多写少的场景。比如商品详情——大量用户浏览读偶尔管理员修改商品信息写。4. 信号量RSemaphore信号量不是锁而是一种许可控制——允许多个客户端同时访问但限制总数。RSemaphoresemaphoreredisson.getSemaphore(semaphore:payment:pool);semaphore.trySetPermits(10);// 设置 10 个许可// 获取许可类似限流booleanacquiredsemaphore.tryAcquire(3,TimeUnit.SECONDS);if(acquired){try{doPayment();}finally{semaphore.release();// 释放许可}}适用场景限流、资源池控制。比如支付通道最多支持 10 个并发超过的排队等待。四、RedLock 算法为什么需要 RedLock如果你的 Redis 是单节点它挂了锁就全丢了。主从架构也有问题主节点加锁后还没同步到从节点就挂了从节点晋升后没有锁信息——锁丢失。RedLock 原理由 Redis 作者 antirez 提出核心思想向多个独立的 Redis 节点同时加锁超过半数成功就算加锁成功。步骤 1. 获取当前时间 T1 2. 依次向 N 个通常 5 个Redis 节点发送加锁请求每个设短过期时间如 5~10 秒 3. 获取当前时间 T2 4. 如果成功加锁的节点数 3N/2 1且 T2 - T1 锁过期时间则加锁成功 5. 否则向所有节点发送解锁请求Redis-1: SETNX ✅ Redis-2: SETNX ✅ Redis-3: SETNX ✅ Redis-4: SETNX ❌ (超时) Redis-5: SETNX ❌ (超时) 3/5 成功 → 加锁成功 ✅超过半数Redisson 对 RedLock 的实现RLocklock1redisson1.getLock(lock:order:orderId);RLocklock2redisson2.getLock(lock:order:orderId);RLocklock3redisson3.getLock(lock:order:orderId);RedissonRedLockredLocknewRedissonRedLock(lock1,lock2,lock3);redLock.lock(30,TimeUnit.SECONDS);try{doBusiness();}finally{redLock.unlock();}争议RedLock 也有批评者最著名的是 Martin Kleppmann认为它在不可靠的时钟依赖下不能保证正确性。实际生产中如果你的场景对一致性要求极高应该用 Zookeeper如果可以接受极小概率的锁失效Redis 单主 Redisson 足够了。五、分布式锁使用注意事项1. 锁超时时间设置过短业务没执行完锁就释放了别人趁虚而入过长持锁者宕机后其他客户端要等太久建议用 Redisson 看门狗机制不设 leaseTime让它自动续期。2. 可重入性同一个线程多次获取同一把锁不会死锁。Redisson 通过 Hash 结构记录重入次数实现。但注意跨线程不可重入不同线程是不同的锁持有者。3. 死锁预防锁必须设过期时间防持锁者宕机导致死锁解锁用 Lua 脚本防误删别人的锁避免嵌套锁A 锁里获取 B 锁B 锁里获取 A 锁4. unlock 前检查// 错误写法锁可能已自动过期此时 unlock 会报错lock.unlock();// 正确写法if(lock.isHeldByCurrentThread()){lock.unlock();}六、无人售货柜订单防重分布式锁实战场景用户扫码开门取货关门后系统自动结算扣款。由于网络抖动或用户重复操作可能产生重复的结算请求。需要用分布式锁保证同一笔订单只结算一次。完整实现ServiceSlf4jpublicclassOrderSettleService{AutowiredprivateRedissonClientredisson;AutowiredprivateOrderMapperorderMapper;AutowiredprivatePaymentServicepaymentService;/** * 订单结算幂等 */publicSettleResultsettle(StringorderId){StringlockKeylock:settle:orderId;RLocklockredisson.getLock(lockKey);try{// 尝试加锁最多等 3 秒锁过期 30 秒booleanlockedlock.tryLock(3,30,TimeUnit.SECONDS);if(!locked){// 没抢到锁说明正在被其他实例处理returnSettleResult.processing(订单正在结算中请勿重复操作);}// 二次检查订单状态可能已被其他实例处理完OrderorderorderMapper.selectById(orderId);if(order.getStatus()OrderStatus.SETTLED){returnSettleResult.alreadySettled(order);}if(order.getStatus()OrderStatus.SETTLING){returnSettleResult.processing(订单正在结算中);}// 标记为结算中orderMapper.updateStatus(orderId,OrderStatus.SETTLING);// 执行结算SettleResultresultpaymentService.pay(order);// 更新订单状态if(result.isSuccess()){orderMapper.updateStatus(orderId,OrderStatus.SETTLED);}else{orderMapper.updateStatus(orderId,OrderStatus.SETTLE_FAILED);}returnresult;}catch(InterruptedExceptione){Thread.currentThread().interrupt();thrownewBusinessException(结算被中断,e);}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}}设计要点关注点处理方式防重复提交Redisson 分布式锁tryLock锁等待超时3 秒避免长时间阻塞锁自动释放30 秒过期 看门狗续期二次状态检查拿到锁后重新查订单状态防止重复处理安全解锁isHeldByCurrentThread 检查后再 unlock分布式锁的本质是在分布式系统中用一把所有人都能看到的锁来协调多机器之间的互斥操作。Redis 的 SETNX 是基础Redisson 是工程化封装RedLock 是多节点容灾。选型上绝大多数场景 Redisson 单主或主从就够了不必过度设计。