## 为什么需要分布式锁?
在微服务架构中,多个进程可能需要访问同一资源,如数据库记录或文件。分布式锁确保在任一时刻,只有一个进程可以操作资源,从而避免数据错乱和不一致性问题。
在开始讲分布式锁之前,有必要简单介绍一下,为什么需要分布式锁。
与分布式锁相对应的是「单机锁」,我们在写多线程程序时,避免同时操作一个共享变量产生数据问题,通常会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。
如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?
例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录时,为了避免操作乱序导致数据错误,此时,我们就需要引入「分布式锁」来解决这个问题了。
想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。
而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。
这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。
## 分布式锁怎么实现?
想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。
两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。
现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:
SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,发生「死锁」问题。
在 Redis 2.6.12 版本之前,需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。
但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:
这样就解决了死锁问题,也比较简单。
我们再来看分析下,它还有什么问题?
试想这样一种场景:
1.客户端 1 加锁成功,开始操作共享资源
2.客户端 1 操作共享资源的时间,超过了锁的过期时间,锁被自动释放
3.客户端 2 加锁成功,开始操作共享资源
4.客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)
这里存在两个严重的问题:
锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁
第一个问题,可能是评估操作共享资源的时间不准确导致的。
例如,操作共享资源的时间最慢可能需要 15s,而代码却只设置了 10s 过期,那这就存在锁提前过期的风险。
过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧?
这样确实可以缓解这个问题,降低出问题的概率,但依旧无法彻底解决问题。
原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。
既然是预估时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。
第二个问题在于,一个客户端释放了其它客户端持有的锁。
每个客户端在释放锁时,并没有检查这把锁是否还归自己持有,所以就会发生释放别人锁的风险,这样的解锁流程,不严谨。
## 锁被别人释放怎么办?
为防止一个客户端错误地释放了另一个客户端持有的锁,可以在加锁时使用一个唯一标识(如UUID)。释放锁时,客户端需要检查锁的值是否与自己的唯一标识匹配,确保不会错误地释放他人持有的锁。
之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:
## 锁过期时间不好评估怎么办?
锁的过期时间如果设置不当,可能会导致资源无法及时释放或提前释放。一种解决方案是使用“看门狗”机制,定时续期锁的有效期,直到客户端完成对共享资源的操作(java有Redisson 等sdk )。
到这里我们再小结一下,基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:
死锁:设置过期时间
过期时间评估不好,锁提前过期:java可以使用 Redisson 自动续期。(php暂时没找到方案)
锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放
之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。
而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。
那当「主从发生切换」时,这个分布锁会依旧安全吗?
试想这样的场景:
客户端 1 在主库上执行 SET 命令,加锁成功
此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
从库被哨兵提升为新主库,这个锁在新的主库上,丢失了。
可见,当引入 Redis 副本后,分布锁还是可能会受到影响。
怎么解决这个问题?
为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。
## 什么是Redlock ?
现在我们来看,Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。
Redlock 的方案基于 2 个前提:
不再需要部署从库和哨兵实例,只部署主库
但主库要部署多个,官方推荐至少 5 个实例
也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。
Redlock 具体如何使用呢?
整体的流程是这样的,一共分为 5 步:
1.客户端先获取「当前时间戳T1」
2.客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
3.如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 – T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
4.加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
5.加锁失败,向「全部节点」发起释放锁请求
总结一下,有 4 个重点:
客户端在多个 Redis 实例上申请加锁
必须保证大多数节点加锁成功
大多数节点加锁的总耗时,要小于锁设置的过期时间
释放锁,要向全部节点发起释放锁请求
这里有一个php的redlock包提供参考:
项目地址:https://gitcode.com/ronnylt/redlock-php/overview
Redlock是Redis分布式锁的一种实现算法,它通过在多个Redis实例上加锁来提高锁的安全性。然而,Redlock算法存在争议,有可能会存在异常场景,也是分布式系统会遇到的三座大山:NPC。
N:Network Delay,网络延迟
P:Process Pause,进程暂停(GC)
C:Clock Drift,时钟漂移
## 什么是Zookeeper?
在微服务架构中,ZooKeeper扮演着至关重要的角色,主要提供以下几个方面的服务:
服务注册与发现:微服务架构中,服务实例可能随时启动或停止,ZooKeeper可以用来注册服务实例,并允许客户端通过ZooKeeper发现可用的服务实例。
负载均衡:ZooKeeper可以用于实现客户端负载均衡,通过动态地获取服务实例列表,客户端可以进行负载均衡决策。
配置管理:微服务的配置信息可以通过ZooKeeper集中管理,当配置发生变更时,ZooKeeper可以通知所有相关的服务实例,实现配置的动态更新。
分布式锁:在微服务中,为了保证操作的原子性和一致性,ZooKeeper可以提供分布式锁服务,防止多个服务实例同时执行某些关键操作。
集群管理:ZooKeeper可以用来管理微服务集群,包括选举主节点、监控节点状态等。
消息队列:ZooKeeper可以用于构建分布式消息队列,协调服务之间的消息传递。
事务管理:在复杂的分布式事务处理中,ZooKeeper可以用来协调各个服务之间的事务状态,保证事务的一致性。
故障恢复:ZooKeeper可以帮助微服务架构实现故障检测和恢复,当服务实例失败时,可以快速地重新分配任务或重启服务。
领导选举:在需要一个主节点来协调操作的场景下,ZooKeeper可以用于选举出一个领导节点。
通过这些功能,ZooKeeper为微服务架构提供了一个稳定、可靠的协调平台,帮助开发者构建可扩展、易于管理的分布式系统。
## 基于Zookeeper的锁安全吗?
Zookeeper提供了另一种实现分布式锁的方法。它通过创建临时顺序节点来实现锁的功能,如果客户端崩溃,Zookeeper会删除对应的临时节点,从而释放锁。但Zookeeper的锁机制同样面临网络分区和客户端失联等问题。
基于它实现的分布式锁是这样的:
1.客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
2.假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
3.客户端 1 操作共享资源
4.客户端 1 删除 /lock 节点,释放锁
Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。
而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。
没有锁过期的烦恼,还能在异常时自动释放锁,是完美的吗?
其实不然。
思考一下,客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?
原因就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。
如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。
同样地,基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:
1.客户端 1 创建临时节点 /lock 成功,拿到了锁
2.客户端 1 发生长时间 GC
3.客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」
4.客户端 2 创建临时节点 /lock 成功,拿到了锁
5.客户端 1 GC 结束,它仍然认为自己持有锁(冲突)
可见,即使是使用 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。
所以,这里我们就能得出结论了:一个分布式锁,在极端情况下,不一定是安全的。
如果你的业务数据非常敏感,在使用分布式锁时,一定要注意这个问题,不能假设分布式锁 100% 安全。
总结一下 Zookeeper 在使用分布式锁时优劣:
Zookeeper 的优点:
1.不需要考虑锁的过期时间
2.watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁
但它的劣势是:
1.性能不如 Redis
2.部署和运维成本高
3.客户端与 Zookeeper 的长时间失联,锁被释放问题
## 总结
分布式锁是确保分布式系统中数据一致性的关键技术。虽然存在多种实现方式,每种都有其优势和局限性。开发者在选择和实现分布式锁时,需要根据具体的业务场景和系统特点,权衡锁的性能、安全性和实现复杂度。同时,对于任何分布式锁实现,都应该考虑到各种异常情况,并制定相应的应对策略。