为什么需要分布式锁
在一个分布式系统中,也会涉及多个节点访问同一个公共资源的情况,此时就需要通过锁来做互斥控制,避免出现类似于“线程安全”的问题,而java的synchronized这样的锁只能在当前进程中生效,在分布式的这种多个进程多个主机的场景无能为力,此时就需要分布式锁。
分布式锁的基础实现
例如:买票场景,现在车站提供了若干车次,每个车次的票数都是固定的。现在又多个服务器节点,都可能需要处理这个买票逻辑,先查询指定车次的余票,如果余票>0,则设置余票值-=1.
客户端1先查询余票,发现剩余1张,在即将执行1->0过程之前;客户端2也执行查询余票,发现也是剩余1张,也会执行1->0过程。这就造成1张票卖了给两个人,即超卖。
我们可以在上述架构中引入redis,作为分布式锁的管理器。
所谓的分布式锁,也是一个/一组单独的服务器程序(如redis),给其他服务器提供“加锁”服务。
买票服务器,在进行买票操作的时候,需要先加锁。往redis上设置一个特殊的键值对key-value,完成上述买票操作,再把这个key-value删除掉。其他服务器也想去买票的时候,也去redis上尝试设置key-value,如果发现key-value已经存在,就认为“加锁失败”(是放弃/阻塞等待,就看具体实现)。这样就可以保证,第一个服务器在执行“查询->更新"的过程中,第二个服务器不会执行”查询“,也就解决了”超卖“问题。
:::success
redis中提供的setnx
操作,正好适合上述场景。即key不存在就设置,存在则设置失败
:::
引入过期时间
某个服务器中加锁成功后(setnx成功),如果该服务器意外发生宕机,就会导致解锁操作(删除该key)不能执行,就可能引起其他服务器始终无法获取到锁的情况。
在java的多线程编程中,可以把解锁操作放到finally中,保证解锁操作一定会被执行到。但是这种做法只是针对进程内的锁有用(进程异常退出,锁也就随之销毁)。而分布式锁是无效的,服务器宕机以后会导致redis上设置的key无人删除,也就导致其他服务器无法获取到锁
:::info
引入过期时间,使用set ex nx
的方式,在设置锁的同时把过期时间设置进去,一但时间到了,key就会自动被删除掉。
:::
注意!此处设置过期时间只能使用一个命令的方式设置。
如果分开设置,比如
setnx
之后,再来个expire
。redis多个指令之间,无法保证原子性(redis的原子性是只能保证执行,不能保证成功)。此时就可能出现这两个命令,一个执行成功,一个执行失败情况
引入校验id
对于redis中写入的加锁键值对,其他节点也是可以删除的。
比如 服务器1写入一个
001:1
这样的键值对,服务器2是完全可以把001:1
给删除掉。当然,服务器2一般不会这样”恶意删除“操作,不过不能保证因为一些bug导致服务器2把锁给误删除
为了解决上述问题,我们可以引入一个校验id。
- 给服务器编号,每个服务器都有一个自己的身份标识
- 进行加锁的时候,设置key-value。key是针对哪个资源加锁(比如车次),value就可以存储刚才服务器的编号,标识出当前这个锁是哪个服务器加上的。
- 解锁的时候,先查询一下这个锁对应的服务器编号,然后判定一下value是否和当前执行解锁的服务器编号一致,如果一致,才能真正执行
del
,如果不是,就失败。
伪代码如下:
String key = [要加锁的资源 id];
String serverId = [服务器的编号];
// 加锁, 设置过期时间为 10s
redis.set(key, serverId, "NX", "EX", "10s");
// 执⾏各种业务逻辑, ⽐如修改数据库数据.
doSomeThing();
// 解锁, 删除 key. 但是删除前要检验下 serverId 是否匹配.
if (redis.get(key) == serverId) {
redis.del(key);
}
但是很明显,在解锁的时候,get
和del
是两步操作,不是原子的。
引入lua
在服务器内部,可能是多线程的。例如服务器1中有两个线程都在执行上述解锁操作。
在服务器1中,看起来只是重复执行del
操作,问题不大???但是当服务器2,执行加锁时,就可能出现问题了。
线程A执行完del
操作后,线程B执行del
操作之前,服务器2的线程C正好要执行加锁操作。此时线程A已经把锁删除了,线程C是能够加锁成功的。但是紧接着,线程B就会执行del
操作,就会把服务器2的加锁操作给解锁了。虽然del
操作中有引入校验id,但是线程B在get
操作中已经通过id校验,可以执行del
操作,虽然线程C这把锁的id不同,也能够解锁。
使用redis是事务,能够避免命令之间的插队。但是实践中往往是使用lua脚本。由于lua语言非常轻量,因此可以内嵌到redis中。我们可以使用lua编写一些逻辑,把这个脚本上传到redis服务器上,然后就可以让客服端来控制redis执行上述脚本。redis执行lua脚本的过程,是原子的。并且redis官方也明确说明,lua属于事务的替代方案。
使用lua脚本实现上述解锁功能:
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
引入看门狗(watch dog)
上述方案中仍然存在一个重要问题,在加锁的时候,需要给key设置过期时间。过期时间,设置多少合适呢?
- 设置太短,就可能业务逻辑还没执行完,就释放锁
- 设置太长,会导致”锁释放不及时“问题
因此更好的方式是”动态续约“,这就需要服务器这边有一个专门的线程,负责续约这件事。我们把这个负责的线程,叫做”看门狗“(watch dog).
举个具体的例子:
初始情况下设置过期时间10s,同时设定看门狗线程每隔3s检测一次。
当3s时间到的时候,看门狗就会判定当前任务是否完成。
- 如果任务已经完成,直接通过lua脚本的方式,释放锁(删除key)
- 如果任务未完成,则把过期时间重新设置为10s,即续约
这样就不用担心锁提前释放的问题了,而且另外一方面,如果服务器挂了,看门狗线程也会被销毁,此时无人续约,这个key自然就可以迅速过期,让其他服务器获取到锁
引入redlock算法
实践中的redis一般使用集群的方式部署的,那么就可能出现以下比较极端的情况。
服务器1向master节点进行加锁操作,这个写入key的过程刚完成,master挂了;slave节点升级成新的master节点,但是由于刚才写入的这个key未来得及同步给slave,此时就相当于服务器1的加锁操作形同虚设。服务器2仍然可以进行加锁,即给新的master写入key,因为新的master不包含刚才的key。
为了解决这个问题,redis作者提出了redlock算法。本质上是使用冗余解决可用性问题
此处加锁,就是按照一定的顺序,针对redis集群的所有分片都进行加锁操作。如果某个节点挂了(加不上锁了)继续给下一个节点加锁即可。如果写入key成功的节点个数超过总数的一半,就视为加锁成功。同理,进行解锁的时候,也就会把上述节点都设置一遍解锁。