ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Redis分布式锁实战:从原理到高可用架构设计

Redis分布式锁实战:从原理到高可用架构设计 1. 从一次线上事故说起为什么我们需要分布式锁那天晚上十一点我正打算关电脑突然收到一连串的告警短信。核心业务线的订单系统出现了大量“超卖”现象——一个热门商品库存明明只剩100件却卖出了150多单。技术群里瞬间炸开了锅DBA排查数据库没有死锁应用日志显示多个服务实例都在“同时”扣减库存并且都成功了。问题根源很快被定位我们那个运行了两年多的“乐观锁”机制在瞬时高并发下彻底失效了。代码里那个经典的update stock set count count - 1 where id ? and count 0在多个服务实例、多个数据库连接同时执行的瞬间读取到的库存值可能都是100然后都成功扣减了1最终导致库存被扣成了负数。这次事故让我彻底明白在分布式系统架构下尤其是当你的服务从单体应用拆分成多个独立部署的实例后传统的、基于单进程多线程的锁机制比如Java里的synchronized或ReentrantLock已经完全失去了作用。这些锁只能管住自己JVM进程内的线程管不了另一台服务器上另一个JVM进程里的请求。于是“分布式锁”成为了我们必须引入的基础设施。它的核心目标很简单却又至关重要在分布式部署的多台机器、多个进程之间实现互斥访问确保在同一时间只有一个客户端能执行某段关键代码或访问某个共享资源比如扣减库存、生成全局唯一ID、防止重复提交等场景。而在众多实现分布式锁的技术选型中Redis因其高性能、丰富的数据结构和相对简单的模型成为了最流行、最快速上手的选择。它不像ZooKeeper那样强一致但写性能稍弱也不像基于数据库的方案那样笨重。用Redis实现分布式锁核心思路是利用其SETNXSET if Not eXists命令的原子性只有一个客户端能成功设置某个键谁先设上谁就拿到了锁。听起来很简单对吧但魔鬼藏在细节里。一个在生产环境能扛住高并发、网络抖动、服务宕机的健壮分布式锁远不止一个SETNX命令那么简单。接下来我就结合自己踩过的坑和最佳实践拆解用Redis实现一个工业级分布式锁需要闯过的重重关卡。2. 从SETNX到SET分布式锁的原子性基石最初级的Redis分布式锁实现大概长这样SETNX lock_key unique_value如果返回1表示加锁成功执行完业务后再执行DEL lock_key释放锁。这个方案有两大致命缺陷第一如果客户端加锁后宕机这个锁就永远无法释放成了“死锁”第二释放锁时直接DEL可能误删其他客户端持有的锁比如客户端A阻塞导致锁超时释放客户端B获得锁此时A恢复继续执行就会删除B的锁。为了解决死锁问题我们引入了过期时间。但注意下面这个操作是错误的SETNX lock_key unique_value EXPIRE lock_key 10因为SETNX和EXPIRE是两个独立的命令不具备原子性。如果在执行完SETNX后、执行EXPIRE前客户端崩溃锁依然没有过期时间。所以Redis 2.6.12之后我们必须使用原子性的SET命令配合NX和PX选项SET lock_key unique_value NX PX 10000这条命令的意思是当键lock_key不存在时(NX)设置其值为unique_value同时设置过期时间为10000毫秒(PX)。这是一个原子操作要么一起成功要么一起失败完美解决了锁的自动释放问题。这里的unique_value必须是一个全局唯一的标识通常可以用UUID 线程ID或者更简单的UUID。它的核心作用是实现锁的持有者校验确保只有锁的持有者才能释放锁。释放锁的逻辑不再是简单的DEL而是一个Lua脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本保证了“获取锁的值”和“删除锁”这两个操作在Redis服务器端的原子性执行。即使客户端在GET之后、DEL之前发生了网络延迟或阻塞也不会出现误删因为判断和删除在Redis单线程模型中是不可分割的。注意unique_value的生成必须确保在分布式环境下的唯一性。我曾见过有团队使用“IP地址进程ID线程ID”的组合在容器化部署IP可能变动或线程复用场景下这可能会带来风险。使用标准的UUID.randomUUID().toString()是更稳妥的选择。3. 锁的续期与看门狗应对长耗时业务的挑战设置了过期时间比如10秒解决了死锁但引入了新问题如果业务逻辑的执行时间超过了锁的过期时间怎么办例如一个复杂的数据库事务或者一个外部RPC调用耗时15秒但锁10秒就自动释放了。此时其他客户端就能获取到锁导致两段业务代码同时进入临界区数据一致性被破坏。这就是分布式锁领域经典的“锁提前释放”问题。解决方案是锁续期也称为“看门狗”(Watchdog)机制。其原理是在加锁成功后启动一个后台守护线程定期比如在过期时间的三分之一时即第3秒去检查锁是否还被当前客户端持有如果是则自动对锁的过期时间进行续期例如重新设置为10秒。这个逻辑同样需要用Lua脚本来保证原子性检查续期if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end自己实现一个健壮的看门狗机制并不简单需要考虑线程池管理、续期失败的重试、客户端优雅关闭时如何停止续期等。因此在实际生产中我强烈建议直接使用成熟的客户端库比如Redisson。Redisson内置了完善的看门狗机制默认情况下它加的锁如果没有指定leaseTime就会启动看门狗每隔lockWatchdogTimeout/ 3 的时间默认30秒/310秒去续期将锁的超时时间重置为lockWatchdogTimeout默认30秒。这大大简化了我们的工作。实操心得对于明确知道执行时间的短任务建议在加锁时直接指定一个合理的leaseTime租约时间并禁用看门狗。这样可以避免不必要的续期开销。例如一个简单的库存查询和扣减通常能在100毫秒内完成那么设置一个3秒的锁过期时间就足够了。Redisson中可以通过lock.lock(3, TimeUnit.SECONDS)来指定这样就不会启动看门狗线程。4. 可重入性让同一个线程能再次进入锁什么是可重入锁简单说就是同一个线程在外层方法获取锁之后在进入内层方法时会自动获取锁。在单机JVM中ReentrantLock和synchronized都是可重入的。在分布式锁中我们也需要这个特性。考虑这个场景你在一个加锁的方法methodA()里调用了另一个也需要相同锁的methodB()。如果锁不可重入那么线程在methodB()中尝试获取锁时就会发生死锁——它在等待一个自己已经持有的、但永远不会释放的锁。实现可重入锁需要在Redis中存储更多的信息。不能只存一个客户端标识还需要存储一个重入计数器。常见的结构是使用Redis的HashKey:lock_keyField:unique_client_id(如8743c9c0-0795-4907-87fd-6c719a6b4586:1)Value:重入次数加锁时Lua脚本判断lock_key这个Hash是否存在。如果不存在或存在的字段就是当前客户端ID则将对应字段的值加1并设置过期时间。如果存在且字段是其他客户端ID则获取锁失败。解锁时Lua脚本判断lock_key这个Hash中指定客户端ID字段的值。如果值大于1则减1。如果值等于1则删除整个Key。同样这部分逻辑非常复杂自己实现容易出错。Redisson的RLock对象天然就是可重入的它内部使用Hash结构维护了重入次数我们无需关心底层实现直接像使用ReentrantLock一样使用即可。5. 集群环境与RedLock当主从切换遇上锁失效前面讨论的都是基于单个Redis实例或代理模式下的Sentinel/Cluster。在Redis主从架构下会有一个新的致命问题主从异步复制导致的数据丢失。流程是这样的客户端A在Master节点上成功加锁设置了一个Key。在Master将这把锁同步到Slave节点之前Master宕机了。Sentinel/Cluster触发故障转移其中一个Slave被提升为新的Master。此时新的Master上没有客户端A刚才加的那把锁客户端B向新的Master申请同一把锁会成功。于是客户端A和客户端B同时持有了同一把锁系统的一致性被破坏。这就是著名的“主从切换锁失效”问题。为了解决这个问题Redis的作者Antirez提出了RedLock红锁算法。它的核心思想是不再依赖单个Redis实例而是同时向多个通常为5个独立的Redis主节点申请锁并且这些主节点之间没有主从复制关系是完全孤立的以避免同时宕机。RedLock算法流程如下获取当前时间以毫秒为单位。依次尝试向N个Redis实例如5个执行加锁命令SET key random_value NX PX timeout。这里设置一个远小于锁自动释放时间的网络超时时间例如5-50ms避免长时间阻塞。客户端计算获取锁总共消耗的时间当前时间 - 步骤1的时间并且只有当客户端在大多数N/2 1实例上成功获取锁且总耗时小于锁的有效时间锁才算是获取成功。如果获取锁成功锁的真正有效时间等于初始有效时间减去获取锁的总耗时。如果获取锁失败要么没拿到大多数实例的锁要么总耗时超过了锁有效时间客户端需要向所有Redis实例发起释放锁的请求使用那个判断unique_value的Lua脚本。Redisson提供了RedissonRedLock的实现用法如下RLock lock1 redissonInstance1.getLock(lock1); RLock lock2 redissonInstance2.getLock(lock2); RLock lock3 redissonInstance3.getLock(lock3); RedissonRedLock lock new RedissonRedLock(lock1, lock2, lock3); // 同时加锁lock1, lock2, lock3 // 红锁在大部分节点上加锁成功就算成功。 lock.lock(); ... lock.unlock();重要提醒RedLock算法在分布式系统社区存在争议比如Martin Kleppmann曾撰文质疑其安全性。它需要部署多个独立的Redis主节点成本较高且性能会有下降。因此不要盲目使用RedLock。我的经验是如果你的业务对锁的绝对安全性要求不是极端苛刻例如金融核心交易并且可以容忍在主从故障转移的极小时间窗口内出现极低概率的锁失效那么使用带有主从复制和故障转移的Redis Sentinel或Cluster并配合合理的锁超时时间和业务幂等性设计在99.99%的场景下已经足够可靠。引入RedLock会带来显著的复杂性和运维成本。6. 性能、超时与重试高并发下的优化策略分布式锁是强同步操作必然对性能有影响。在设计和使用时必须考虑以下几点1. 锁的粒度要尽可能细不要用一把大锁锁住整个系统或整个数据库。锁的Key应该与要保护的资源精确对应。例如保护“用户A的账户余额”锁的Key可以是lock:user:balance:{userId}而不是一个全局的lock:user_balance。这样不同用户的操作就不会相互阻塞。2. 设置合理的锁超时时间超时时间太短业务没执行完锁就释放了会导致数据错误。超时时间太长一旦客户端宕机其他客户端需要等待很久才能获取锁影响系统可用性。这个时间需要根据压测和业务监控来动态调整。一个技巧是在锁中记录业务开始时间在释放时检查业务执行时长并以此作为调整超时时间的依据。3. 实现非阻塞的尝试锁与重试机制不要一味地使用阻塞式锁lock()。在高并发场景下这可能导致大量线程挂起耗尽资源。应该使用尝试锁并设计退避重试策略。// Redisson 示例 RLock lock redisson.getLock(myLock); // 尝试获取锁最多等待100秒获取后锁的持有时间不超过10秒 boolean isLocked lock.tryLock(100, 10, TimeUnit.SECONDS); if (isLocked) { try { // 处理业务 } finally { lock.unlock(); } } else { // 获取锁失败可以快速失败或者记录日志、降级处理 log.warn(获取分布式锁失败执行降级逻辑...); }对于重试建议使用指数退避算法例如第一次等待100ms第二次200ms第三次400ms...避免所有客户端同时重试导致“惊群效应”。4. 避免在锁内执行耗时操作这是基本原则。锁内的代码路径应该尽可能短平快。如果需要调用外部服务、执行复杂查询要评估其超时风险并考虑是否可以将这些操作移到锁外或者使用异步方式。7. 监控、治理与容灾让锁的运行状态可视化分布式锁用上了不代表就高枕无忧了。没有监控的锁就像没有仪表盘的汽车出问题了都无从查起。我们需要建立完善的监控体系1. 锁的等待与持有时间监控在客户端代码中埋点记录每次尝试获取锁的等待时间、锁的实际持有时间。如果平均等待时间过长说明锁竞争激烈需要分析是业务热点问题还是锁粒度太粗。如果持有时间经常接近或超过超时时间说明业务逻辑可能过慢或有风险。2. Redis Key监控监控作为锁的Redis Key。如果发现某个锁Key长期存在远超过业务合理时间可能是客户端崩溃没有释放需要告警并可能人工介入清理使用Lua脚本安全清理。可以监控lock:*这类Key的模式统计其数量、TTL分布。3. 设计降级与熔断策略当Redis集群不可用或者获取锁的失败率超过某个阈值时系统不能完全崩溃。应该设计降级策略。例如对于非核心业务如更新用户浏览次数可以直接跳过加锁步骤。对于核心业务可以降级到使用数据库悲观锁性能差但可靠或者直接返回“系统繁忙”提示。在Redisson等客户端中可以配置连接失败时的行为。4. 定期演练与混沌工程定期模拟Redis节点故障、网络分区等场景观察分布式锁客户端的行为和业务的反应。这能帮助你真正理解系统的脆弱点并完善你的应急预案。8. 选型对比除了Redis我们还有什么选择虽然Redis是分布式锁的“当红炸子鸡”但它并非银弹。了解其他方案有助于我们在不同场景下做出更合适的选择。1. 基于数据库如MySQL实现利用数据库的唯一约束或for update行锁。优点实现简单利用现有组件强一致性在数据库层面。缺点性能差数据库连接开销大有死锁风险非重入。在超高并发下容易成为瓶颈。适用场景并发量很低且已经重度依赖数据库不希望引入新组件的系统。2. 基于ZooKeeper实现利用ZooKeeper的临时有序节点。每个客户端在锁对应的目录下创建临时顺序节点序号最小的节点获得锁。监听前一个节点的删除事件来实现阻塞等待。优点强一致性可靠性高原生支持阻塞锁、可重入锁通过临时节点自动解决死锁问题。缺点性能比Redis差写操作需要集群多数节点确认客户端需要维护Session和心跳引入和运维成本较高。适用场景对锁的可靠性要求极高且已经使用ZooKeeper作为协调服务的系统如Hadoop、Kafka生态。3. 基于etcd实现类似于ZooKeeper利用其Lease租约和Revision版本号机制可以实现公平的分布式锁。优点强一致性提供gRPC接口性能较好比ZooKeeper更易用。缺点同样是CP系统在高并发写场景下性能有瓶颈。适用场景云原生环境特别是Kubernetes生态中etcd是默认的键值存储可以无缝集成。总结对比追求极致性能与快速落地选择Redis。做好主从故障转移下的风险应对业务幂等、监控告警。追求绝对可靠与正确性性能非首要考量选择ZooKeeper或etcd。系统简单并发极低可以考虑数据库但要做好性能预警。在我经历的大多数互联网业务场景中Redis分布式锁凭借其出色的性能和够用的可靠性依然是平衡度最佳的选择。关键在于我们不能把它当做一个黑盒魔法来用而是要透彻理解其原理、边界和风险并围绕它构建起监控、降级和治理的完整体系。这样当深夜告警再次响起时你才能从容不迫精准定位。
返回列表