十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分布式锁实战:从Redis到Redisson,解决高并发下的资源互斥访问

分布式锁实战:从Redis到Redisson,解决高并发下的资源互斥访问 分布式锁这个在面试中高频出现、在实际项目中又让人又爱又恨的技术你真的理解对了吗很多开发者一听到“分布式锁”第一反应就是“用Redis的SETNX命令”。这没错但这只是冰山一角。更关键的问题是为什么单体应用里用synchronized或ReentrantLock就够一到分布式环境就必须引入这么一套复杂的机制你的服务在集群部署后是否曾遇到过商品超卖、优惠券重复发放、定时任务重复执行这些“幽灵”问题其根源往往就在于对共享资源的访问失去了“排他性”保障。本文将彻底讲透分布式锁。我不会只停留在概念和API调用层面而是要和你一起深挖三个核心问题第一分布式锁的本质是什么它解决的绝不仅仅是“互斥”问题。第二为什么Redis实现看似简单却暗藏玄机网络延迟、时钟漂移、客户端阻塞这些“坑”如何系统性地解决第三面对ZooKeeper、etcd等方案我们该如何根据业务场景做技术选型如果你正在为微服务架构下的数据一致性头疼或者下次面试不想再被“Redis分布式锁到底安不安全”这个问题问住那么这篇文章正是为你准备的。我们将从业务场景出发推导出分布式锁的技术要件再深入到Redis、Redisson、ZooKeeper的实战与原理最后给你一份清晰的选择指南和避坑清单。1. 分布式锁到底要解决什么问题在单机单进程的多线程环境中我们可以依赖语言或JDK提供的锁如Java的synchronized、ReentrantLock来保证一段代码或一个资源的互斥访问。这是因为所有的线程共享同一块内存锁的状态一个布尔值或一个对象对所有线程可见由同一个JVM管理。然而在分布式系统或微服务架构下情况发生了根本性变化多进程你的应用部署在多个节点Pod、容器、物理机上每个节点有自己的内存空间。一个节点内存中的锁状态对其他节点完全不可见。共享资源这些进程需要访问同一个外部资源例如同一个数据库的一行记录、同一个消息队列、同一个文件存储服务。这时单机锁就失效了。经典的“超卖”场景就是由此而生两个来自不同应用实例的请求几乎同时查询到某商品库存为1都判断可以购买然后各自执行了库存-1的操作最终导致库存变为-1。所以分布式锁的核心目标是在分布式系统或集群环境中控制多个进程对共享资源进行互斥访问的一种协调机制。它需要一个所有进程都能访问的、可靠的“外部存储”来充当锁状态的仲裁者。但请注意互斥Mutual Exclusion只是最基础的要求。一个生产级可用的分布式锁必须同时满足以下几个关键属性这也是面试官考察的重点互斥性在任意时刻只有一个客户端能持有锁。安全性锁只能由持有它的客户端释放防止其他客户端误删。避免死锁即使持有锁的客户端崩溃或发生网络分区锁最终也能被释放从而让其他客户端能够获取。容错性提供锁服务的存储系统如Redis本身部分节点宕机不应导致锁服务完全不可用或锁状态错误。高性能与高可用获取和释放锁的操作要足够快并且锁服务本身要具备高可用性。理解这些属性是我们评估所有分布式锁实现方案的标尺。2. 实现分布式锁的三种主流方式及其对比实现分布式锁本质上是找一个所有客户端都能访问的、可靠的“中心化”存储来记录锁状态。主流方案有三类它们各有优劣适用于不同场景。实现方式核心原理优点缺点典型应用场景基于数据库利用数据库的唯一约束或乐观锁版本号实现。实现简单依赖少有DB即可。性能差对数据库压力大锁无失效时间容易导致死锁非阻塞操作实现复杂。并发量极低且不允许引入额外中间件的遗留系统。基于Redis利用SET key value NX PX timeout命令实现原子性的“设置值过期时间”。性能极高实现相对简单数据结构丰富。需要处理锁续期、时钟漂移等问题主从切换时存在安全性风险RedLock算法试图解决。高并发、对性能要求苛刻、允许极低概率锁失效的场景如秒杀。基于ZooKeeper/etcd利用有序临时节点和Watch机制。客户端创建临时顺序节点判断自己是否是最小节点来获取锁。原生支持锁自动释放会话结束、公平锁、阻塞等待强一致性保证安全性高。性能比Redis差依赖维护复杂的协调服务客户端与ZK的会话管理需要小心处理。对锁的强一致性有严格要求、需要公平锁、并发量不是首要考量的场景如选主、配置管理。对于大多数互联网Java应用基于Redis的实现是性价比最高的选择因为它完美契合了高并发的需求。而ZooKeeper方案则在CP一致性优先系统中更为可靠。数据库方案通常只作为备选或特定场景下的解决方案。接下来我们将重点深入最常用也最值得深究的Redis方案。3. 基于Redis分布式锁的演进之路从SETNX到Redisson3.1 幼稚方案SETNX DEL最初级的想法是使用Redis的SETNXSET if Not eXists命令。如果key不存在则设置成功返回1视为获取锁否则返回0视为获取失败。释放锁时直接DEL删除key。# 获取锁 SETNX lock_key 1 # 释放锁 DEL lock_key问题如果客户端获取锁后崩溃没有执行DEL这个锁就永远无法释放导致死锁。3.2 基础方案SETNX EXPIRE DEL为了解决死锁我们为锁key设置一个过期时间。# 获取锁 SETNX lock_key 1 EXPIRE lock_key 10 # 释放锁 DEL lock_key问题SETNX和EXPIRE是两个命令非原子操作。如果在SETNX之后、EXPIRE之前客户端崩溃依然会导致死锁。3.3 标准方案原子命令SET ... NX PXRedis 2.6.12之后SET命令支持了NX、EX/PX等选项可以原子性地完成“设置值”和“设置过期时间”。# 获取锁设置键为lock_key值为random_value仅当键不存在时设置成功并设置过期时间为10000毫秒 SET lock_key random_value NX PX 10000random_value必须是一个全局唯一的随机字符串如UUID它的作用至关重要。释放锁时需要先获取key的值判断是否与当前客户端持有的random_value相等相等才能删除。这需要使用Lua脚本保证原子性防止误删其他客户端的锁。-- 释放锁的Lua脚本 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个方案已经满足了互斥、安全、避免死锁通过过期时间的基本要求是很多公司自研分布式锁的基础。但它仍有缺陷锁续期问题如果业务执行时间超过了锁的过期时间锁会自动释放可能导致多个客户端同时进入临界区。你需要自己实现一个“看门狗”线程来定期续期。非重入同一个线程无法多次获取同一把锁。非公平所有等待锁的客户端在锁释放时同时争抢可能产生“羊群效应”。主从切换风险在Redis主从架构下如果客户端在主节点上获取了锁但锁数据还未同步到从节点时主节点宕机从节点晋升为主新的客户端可能再次获取到同一把锁违反互斥性。这就是著名的“Redis分布式锁在故障转移下的安全性问题”。3.4 生产级方案使用Redisson客户端Redisson是一个在Redis基础上实现的Java驻内存数据网格客户端。它提供的RLock对象实现了java.util.concurrent.locks.Lock接口完美解决了上述所有问题是Java项目中使用Redis分布式锁的事实标准。Redisson RLock的核心特性自动续期Watchdog只要客户端还“活着”持有锁的线程还在执行就会每隔过期时间/3的时间去重置锁的过期时间默认是每隔10秒续期到30秒防止业务未执行完锁过期。可重入性同一个JVM内的同一线程可以多次获取同一把锁锁中维护一个计数器。公平锁支持可以创建公平锁按照请求的顺序来获取锁。锁等待支持tryLock(long waitTime, long leaseTime, TimeUnit unit)方法可以指定等待获取锁的时间。Lua脚本原子操作所有获取、释放、续期操作均通过Lua脚本完成保证原子性。4. 环境准备与Redisson实战4.1 项目环境JDK: 1.8Spring Boot: 2.3Maven或GradleRedis: 5.0 (单机、哨兵或集群模式均可)4.2 添加Redisson依赖以Maven项目为例在pom.xml中添加依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.17.0/version !-- 请使用最新稳定版本 -- /dependency如果你不使用Spring Boot可以添加核心依赖redisson。4.3 配置Redisson客户端在application.yml中配置Redis连接单机模式示例spring: redis: host: localhost port: 6379 # password: yourpassword # 如果有密码 database: 0 # Redisson特定配置 (可选大部分情况使用默认即可) redisson: config: | singleServerConfig: idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 3000 retryAttempts: 3 retryInterval: 1500 password: null subscriptionsPerConnection: 5 clientName: null address: redis://${spring.redis.host}:${spring.redis.port} subscriptionConnectionMinimumIdleSize: 1 subscriptionConnectionPoolSize: 50 connectionMinimumIdleSize: 32 connectionPoolSize: 64 database: ${spring.redis.database:0} dnsMonitoringInterval: 5000 threads: 16 nettyThreads: 32 codec: !org.redisson.codec.JsonJacksonCodec {} transportMode: NIOSpring Boot Starter会自动根据spring.redis.*配置创建Redisson客户端。你也可以通过Bean方式手动配置更复杂的集群或哨兵模式。5. 核心代码实现从简单锁到复杂场景5.1 基础加锁与释放首先我们注入RedissonClient并使用它获取一个RLock对象。import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; Service public class OrderService { Autowired private RedissonClient redissonClient; private static final String LOCK_KEY_PREFIX order_lock:; /** * 创建订单 - 使用最简单的锁 */ public String createOrderSimple(String productId) { String lockKey LOCK_KEY_PREFIX productId; RLock lock redissonClient.getLock(lockKey); try { // 尝试获取锁最多等待10秒锁持有时间30秒后自动失效 boolean isLocked lock.tryLock(10, 30, TimeUnit.SECONDS); if (!isLocked) { throw new RuntimeException(系统繁忙请稍后重试); } // ********** 临界区代码开始 ********** // 1. 查询商品库存 // 2. 判断库存是否充足 // 3. 扣减库存 (需要原子性操作如update ... set stock stock - 1 where id ? and stock 0) // 4. 创建订单 // ********** 临界区代码结束 ********** return 订单创建成功商品ID: productId; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取锁被中断, e); } finally { // 释放锁 if (lock.isHeldByCurrentThread()) { // 判断是否当前线程持有锁 lock.unlock(); } } } }关键点解释lock.tryLock(waitTime, leaseTime, unit)尝试获取锁。waitTime是获取锁的最大等待时间leaseTime是锁的持有时间超过这个时间锁会自动释放。这里设置为30秒Redisson的看门狗会帮你自动续期只要业务线程还在运行。必须在finally块中释放锁确保锁一定会被释放。lock.isHeldByCurrentThread()这是一个重要的安全检查防止释放其他线程持有的锁在复杂线程交互场景下可能发生。5.2 可重入锁演示Redisson的RLock是可重入的这意味着在同一个JVM的同一个线程内可以多次锁定同一个锁对象。public void reentrantMethod() { String lockKey reentrant_lock; RLock lock redissonClient.getLock(lockKey); lock.lock(); // 第一次加锁 try { System.out.println(进入外层方法锁计数: lock.getHoldCount()); innerMethod(lock); // 调用内层方法需要同一把锁 } finally { lock.unlock(); } } private void innerMethod(RLock lock) { lock.lock(); // 第二次加锁重入 try { System.out.println(进入内层方法锁计数: lock.getHoldCount()); // 执行一些需要同步的操作 } finally { lock.unlock(); // 释放内层锁 } } // 输出 // 进入外层方法锁计数: 1 // 进入内层方法锁计数: 2lock.getHoldCount()返回当前线程持有该锁的次数。每次lock()计数加1每次unlock()计数减1减到0时锁才真正被释放。这个特性对于递归调用或调用链中需要多次同步的场景非常有用。5.3 公平锁默认的RLock是非公平锁。如果需要严格的先来后到顺序可以使用公平锁。public void fairLockDemo() { String lockKey fair_lock; // 获取公平锁 RLock fairLock redissonClient.getFairLock(lockKey); try { fairLock.lock(); // 处理业务... } finally { fairLock.unlock(); } }公平锁能减少“饥饿”现象但性能通常比非公平锁差因为需要维护一个等待队列。5.4 联锁MultiLock与红锁RedLock联锁将多个RLock对象关联为一个锁所有锁都加锁成功才算成功。用于需要同时锁定多个资源的场景。public void multiLockDemo() { RLock lock1 redissonClient.getLock(lock1); RLock lock2 redissonClient.getLock(lock2); RLock lock3 redissonClient.getLock(lock3); RLock multiLock redissonClient.getMultiLock(lock1, lock2, lock3); try { multiLock.lock(); // 只有lock1, lock2, lock3都获取成功才会进入这里 // 操作资源123... } finally { multiLock.unlock(); } }红锁RedLock这是Redis官方提出的用于在多个独立的Redis主节点上实现分布式锁的算法旨在解决单点Redis主从切换时的安全性问题。Redisson也实现了RedLock。public void redLockDemo() { Config config1 new Config(); config1.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient client1 Redisson.create(config1); Config config2 new Config(); config2.useSingleServer().setAddress(redis://127.0.0.1:6380); RedissonClient client2 Redisson.create(config2); Config config3 new Config(); config3.useSingleServer().setAddress(redis://127.0.0.1:6381); RedissonClient client3 Redisson.create(config3); RLock lock1 client1.getLock(red_lock); RLock lock2 client2.getLock(red_lock); RLock lock3 client3.getLock(red_lock); RLock redLock client1.getRedLock(lock1, lock2, lock3); try { // 尝试获取红锁在大多数节点N/21上加锁成功才算成功 boolean isLock redLock.tryLock(1000, 30000, TimeUnit.MILLISECONDS); if (isLock) { // 成功获取红锁执行业务 } } catch (InterruptedException e) { e.printStackTrace(); } finally { redLock.unlock(); } client1.shutdown(); client2.shutdown(); client3.shutdown(); }注意RedLock算法争议很大如Martin Kleppmann的著名文章《How to do distributed locking》它增加了复杂性和成本且并不能100%保证安全。绝大多数业务场景使用单Redis节点哨兵/集群并合理设置业务超时和锁超时已经足够可靠。仅在极端要求一致性的金融场景才考虑RedLock且需充分评估。6. 运行验证与效果测试我们可以编写一个简单的测试来验证分布式锁的效果。模拟10个线程同时创建同一个商品的订单。import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; SpringBootTest public class DistributedLockTest { Autowired private OrderService orderService; private final ExecutorService executorService Executors.newFixedThreadPool(10); private final AtomicInteger successCount new AtomicInteger(0); private final AtomicInteger failCount new AtomicInteger(0); Test public void testConcurrentOrderCreation() throws InterruptedException { String productId prod_001; int threadCount 10; CountDownLatch latch new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { executorService.submit(() - { try { String result orderService.createOrderSimple(productId); System.out.println(Thread.currentThread().getName() : result); successCount.incrementAndGet(); } catch (Exception e) { System.err.println(Thread.currentThread().getName() : 失败 - e.getMessage()); failCount.incrementAndGet(); } finally { latch.countDown(); } }); } latch.await(); // 等待所有线程执行完毕 executorService.shutdown(); System.out.println(测试完成。成功: successCount.get() , 失败: failCount.get()); // 在库存充足的情况下所有请求都应成功但锁保证了临界区串行执行。 // 如果库存只有1则只有一个请求成功其余失败。 } }预期结果由于锁的存在所有线程对prod_001这个资源的操作会串行化。如果业务逻辑中库存检查与扣减是原子的例如在SQL中使用where stock 0那么即使并发很高也不会发生超卖。控制台会看到线程依次获取锁、执行业务、释放锁的过程。7. 常见问题与排查思路在实际使用分布式锁时你会遇到各种各样的问题。下面是一个常见问题排查表问题现象可能原因排查方式解决方案获取锁失败但Redis连接正常1. 锁已被其他客户端持有。2.waitTime设置过短在等待期间未获取到锁。3. Redis内存不足无法执行命令。1. 使用redis-cli查看锁key是否存在及其值。2. 检查业务日志看是否有其他线程/进程长时间持有锁。3. 检查Redis监控查看内存和命令执行情况。1. 优化持有锁期间的业务逻辑缩短执行时间。2. 适当增加waitTime但不宜过长。3. 检查Redis配置必要时扩容。锁被意外释放业务未执行完1. 锁的leaseTime设置过短且看门狗续期失败。2. 业务代码发生异常未执行到续期逻辑。3. Full GC导致应用线程暂停超过锁过期时间。1. 检查Redisson看门狗日志需开启DEBUG。2. 检查业务代码异常堆栈。3. 检查JVM GC日志和监控。1. 合理评估业务最大耗时设置足够长的leaseTime。2. 确保业务逻辑健壮做好异常处理。3. 优化JVM参数避免长时间GC。出现死锁锁永不释放1. 获取锁后释放锁的代码未执行如应用崩溃、finally块未执行。2. 锁未设置过期时间使用早期SETNX方案。1. 查看Redis中锁key的TTL如果一直存在且无TTL则是死锁。2. 检查代码确保lock.unlock()在finally中。1.必须为锁设置过期时间。2. 使用Redisson它默认会设置过期时间并自动续期。3. 建立监控对长时间持有锁的key进行告警。锁被其他客户端释放释放锁时未校验锁的value。A客户端持有的锁被B客户端误删。检查释放锁的逻辑是否使用了非原子性的GETDEL或者Lua脚本有误。必须使用原子操作Lua脚本来释放锁先校验value再删除。Redisson的unlock()已内置此逻辑。Redis主从切换后锁失效客户端在主节点加锁成功数据未同步到从节点时主节点宕机从节点升级为主新客户端可再次加锁。检查Redis架构和故障转移日志。1. 对于要求强一致性的场景考虑使用RedLock需权衡。2. 使用ZooKeeper/etcd等CP系统。3. 业务层增加幂等性或状态校验作为兜底。性能瓶颈1. 锁粒度太粗大量线程串行等待。2. Redis本身成为性能瓶颈。1. 分析业务查看锁的持有时间。2. 监控Redis的QPS和延迟。1.细化锁粒度例如从“订单锁”细化为“商品ID锁”。2. 升级Redis配置或使用集群模式分摊压力。3. 考虑是否真的需要分布式锁是否可用乐观锁、无锁队列等替代方案。8. 最佳实践与工程建议锁的粒度要尽可能小锁的粒度越细系统的并发度就越高。不要用一把“全局订单锁”而应该用“订单ID锁”或“用户ID锁”。锁的命名要有业务意义lock_key最好包含业务前缀和资源标识如order:create:{orderId}、coupon:grant:{userId}。这便于监控和排查问题。务必设置合理的超时时间锁的持有时间leaseTime必须大于业务执行的最长时间并留有余量。Redisson的看门狗机制是很好的保障但初始时间的设置仍需谨慎。释放锁必须放在finally块这是铁律确保任何情况下正常返回、异常、中断锁都能被释放。避免在锁内进行远程调用或耗时操作锁内代码应只包含与共享资源相关的必要操作。远程调用HTTP、RPC和IO操作读写大文件、复杂查询应尽量移到锁外或确保其超时时间远小于锁超时时间。考虑降级和兜底方案分布式锁是保证强一致性的手段但高并发下获取锁可能失败。设计系统时要有降级策略例如获取锁失败后快速失败给用户友好提示。使用异步队列将请求排队处理。在数据库层使用乐观锁版本号或悲观锁SELECT ... FOR UPDATE作为最终防线。建立完善的监控监控Redis中分布式锁key的数量和过期情况。监控获取锁的平均等待时间和失败率。对持有时间异常长的锁进行告警。非必要不加锁在决定使用分布式锁之前先思考是否可以通过其他方式避免并发冲突例如使用数据库唯一约束防止重复插入。使用乐观锁通过版本号或状态机更新。使用串行化队列如Kafka分区、Disruptor等将并发请求转为串行处理。9. 技术选型总结与后续方向回到最初的问题我们该如何选择追求极致性能允许极小概率的锁状态不一致选择基于RedisRedisson的方案。它适用于绝大多数互联网高并发场景如秒杀、抢券、防止重复提交。你需要理解并接受其主从切换下的理论风险并通过业务幂等性等手段进行兜底。要求强一致性性能可以做出让步选择基于ZooKeeper或etcd的方案。它适用于分布式选主、配置管理、任务调度等对一致性要求极高的场景。系统简单并发量极低不想引入新组件可以考虑基于数据库的方案但务必做好性能评估和死锁预防。后续深入学习方向深入Redisson源码研究看门狗Watchdog机制、Lua脚本的实现、各种锁可重入锁、公平锁、读写锁的原理。研究ZooKeeper的临时顺序节点和Watcher机制理解其如何实现分布式锁和集群协调。了解etcd的租约Lease和事务TransactionAPI它是云原生时代更轻量的协调服务。探索其他分布式协调算法如Google的Chubby、Consul等。在实际项目中实践和优化从简单的扣库存开始逐步应用到更复杂的分布式事务场景如结合Seata的AT模式理解分布式锁在Saga、TCC等模式中的作用与局限。分布式锁不是银弹它是你在分布式系统开发中必须熟练掌握的一把利器。理解其原理、掌握其实现、知晓其边界才能在高并发与数据一致性之间找到最佳的平衡点。希望这篇近万字的详解能帮你彻底构建起关于分布式锁的知识体系在设计和面试中都能游刃有余。建议收藏本文在遇到相关问题时随时回顾。
返回列表