分布式锁设计与实现:MySQL、Redis、Zookeeper
1 为什么需要分布式锁
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。如果不同的系统或同一个系统的不同主机之间共享了一个或者一组资源,那么访问这些资源的时候,往往需要通过一些互斥手段来防止彼此之间干扰,保证一致性。
2 分布式锁需要具备哪些条件
- 获取锁和释放锁的性能要好
- 判断是否获得锁必须是原子性的,否则可能导致多个请求都能获取到锁
- 网络中断或者宕机无法释放锁时,锁必须被清除,不然会发生死锁
- 可重入一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁
- 阻塞锁和非阻塞锁,阻塞锁即没有获取到锁,则继续等待获取锁;非阻塞锁即没有获取到锁,不继续等待,直接返回获取锁失败
3 如何实现分布式锁
3.1 基于数据库
3.1.1 基于数据库表锁
利用数据库主键的唯一性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放的话,删除这条记录即可。
加锁
insert into Lock(method_name,desc) values ('method_name','desc');
解锁
delete from Lock where method_name = 'method_name';
存在问题
- 锁依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用
- 锁没有失效时间,解锁失败会导致死锁,其他线程无法再获取到锁
- 非阻塞锁,因为数据库的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,想要再次获得锁就要再次触发操作
- 不可重入,同一线程在没有释放锁之前无法再获取到锁
3.1.2 乐观锁
根据版本号来判读更新之前有没有其他线程更新过,如果被更新过,则获取锁失败。这个策略源于 mysql 的 mvcc 机制,存在的问题是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 SQL 每次进行判断,增加了数据库的操作次数,在并发量较高情况下,对数据库连接的开销较大
3.1.3 排他锁
在查询语句后面增加 for update
数据库会在查询过程中给数据库表增加排他锁(默认添加表级锁,可以为 metho_name
添加索引来使用行级锁),当某条记录被加上排他锁后,其他线程无法再改行记录上增加排他锁。我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行业务逻辑,执行完毕后,释放锁。
加锁
select * from Lock where method_name = xxx for update;
解锁
connection.commit();
解决的问题
- 阻塞锁:
for update
语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功 - 避免死锁:服务宕机后数据库会自己把锁释放掉
存在问题
- 数据库单点和可重入
- MySQL 会对查询进行优化,即使使用了索引,并显示使用了行级锁,但有可能 MySQL 使用表锁而不是行锁
- 一个排他锁长时间不提交,会占用数据库连接,一旦类似连接变多了,会把数据库连接池撑爆
3.2 基于 Redis
3.2.1 Redis 语句
- setnx(key, value):该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0
- expire():设置过期时间
- getset(key, newValue):该方法是原子的,对 key 设置 newValue 值,并返回 key 原来的旧值
3.2.2 基于 setnx(),expire()
- setnx(lockKey, 1) 如果返回 0 ,则说明占位失败;如果返回 1 ,则说明占位成功
- expire() 命令对 lockKey 设置超时时间,为的是避免死锁
- 执行完业务代码后,可以通过 delete 删除 key
存在问题
- 在第二步 expire() 设置超时时间前,发生了宕机现象,就依然会出现死锁
3.2.3 基于 setnx(),get(),getset()
- setnx(lockKey, 当前时间+过期时间),如果返回 1,则获取成功;如果返回 0 则没有获取到锁
- get(lockKey) 获取值为 oldExpireTime,并将这个 value 值与当前的系统时间比较,如果小于当前系统时间,则认为这个锁已经超时了,可以允许别的请求重新获取
- 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockKey, newExpireTime) 会返回当前 lockKey 的值 currentExpireTime
- 判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等说明当前 getset 设置成功,获取到了锁,如果不相等,则当前请求可以直接返回失败,或者继续重试
- 在获取到锁之后,当前线程可以开始处理自己的业务,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。
存在问题
- 锁超时机制不是十分可靠,当线程获得锁后,处理时间过长导致锁超时,就失效了锁的作用
3.2.4 RedLock 算法
- 获取当前时间
- 尝试从5个相互独立redis客户端获取锁
- 计算获取所有锁消耗的时间,当且仅当客户端从多数节点获取锁,并且获取锁的时间小于锁的有效时间,认为获得锁
- 重新计算有效期时间,原有效时间减去获取锁消耗的时间
- 如果客户端获取锁失败了,客户端会依次删除所有的锁
3.3 基于 Zookeeper
3.3.1 基本版共享锁
- 每个锁占用一个普通节点
/lock
,在需要获取锁时,所有客户端都到/lock
这个节点下面创建一个临时顺序节点 - 创建完节点后,获取
/lock
节点下的所有子节点,并对该节点注册子节点变更的 Watcher 监听 - 确定自己的节点序号在所有子节点的顺序,对于读请求,如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求 ,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑,如果有比自己序号小的子节点中有写请求,那么就需要进入等待;对于写请求,如果自己不是序号最小的子节点,那么就需要进入等待,接收到 Watcher 通知后,重复此步骤
- 执行完业务逻辑后,客户端主动将自己创建的临时节点删除,从而释放锁
存在问题
羊群效应:大量的 “Watcher 通知” 和 “子节点列表获取” 两个操作重复运行,并且绝大多数的运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次通知,会导致客户端接收到过多和自己不相干的事件通知。
3.3.2 优化版共享锁
- 每个锁占用一个普通节点
/lock
,在需要获取锁时,所有客户端都到/lock
这个节点下面调用create()
创建一个临时顺序节点 - 客户端调用
getChildren()
接口来获取所有已经创建的子节点列表,此时并不需要注册 Watcher - 如果无法获取共享锁,那么就调用
exist()
来对比自己小的那个节点注册 Watcher。读请求: 向比自己序号小的最后一个写请求节点注册 Watcher 监听;写请求: 向比自己序号小的最后一个节点注册 Watcher 监听 - 等待 Watcher 通知,重复以上步骤
- 执行完业务逻辑后,客户端主动将自己创建的临时节点删除,从而释放锁
解决的问题
-
避免死锁:使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在Zookeeper 中创建一个临时节点,一旦客户端获取到锁之后突然挂掉( Session 连接断开),那么这个临时节点就会自动删除掉,其他客户端就可以再次获得锁。
-
阻塞锁:Watcher 机制
-
可重入锁:客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了;如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
-
单点问题:Zookeeper 是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
存在的问题
- 性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。Zookeeper 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上。