什么是分布式锁?
在一个分布式系统中,也会涉及到多个节点访问同一个公共资源的情况。此时就需要通过锁来做互斥控制,避免出现类似于"线程安全"的问题
举个例子,在平时抢票时,多个用户可能会同时买票,但票数有限,现在存在多个服务器节点,都可能需要处理这个买票的逻辑:先查询指定车次的余票,如果余票>0,则设置余票值-=1 。显然,这样的情况是存在线程安全问题的,如果不加锁控制,就会导致超卖或数据错误。但是,这可能涉及到不同的机器,而不是传统的同一个线程可以加锁,不同的机器间怎么保证线程安全呢?
为了解决上述问题,我们就可以把redis作为架构中分布式锁的管理器。
分布式锁本质上就是一个架构内一台公共的服务器,用来记录加锁状态,而redis就可以完成这样的工作
工作原理
为了防止出现线程安全问题,我们可以在服务器操作数据库之前要先访问redis,在redis上设置一个键值对(mutex:1),如果设置成功,视为获取锁成功,就可以对数据库操作,操作完后,就把redis对应的数据删掉,视为释放锁。在这期间,如果其他服务器想操作数据库,也必须先设置同样的key,设置失败就不能访问数据库。
引入过期时间
但是上述方案还是有问题,如果设置key后,服务器宕机了怎么办?锁就永远无法释放了,所以我们就要设置key的过期时间
服务器在设置key的时候,同时设置key的过期事件,也就是说这个锁最多持有多久,过期自动释放。
注意!此处的过期时间只能使用一个命令的方式设置
如果分开多个操作,比如setnx之后,再来一个单独的expire,由于Redis的多个指令之间不存在关 联,并且即使使用了事务也不能保证这两个操作都一定成功,因此就可能出现setnx成功,但是expire 失败的情况
此时仍然会出现无法正确释放锁的问题
引入校验id
有没有可能自己设置的锁被别人释放了呢?请看下图
可能会因为一些潜在bug或误操作,释放了别人设置的锁,就会发生很大的问题
为了解决这个问题,我们可以引入校验id
也就是设置key时设置的value改成有意义的能表示身份的值,比如可以把客户端编号,几号客户端设置的key就把value设置成几
这样,在释放锁,也就是删除key的时候,先校验删除的key的value是不是对应当初加锁的值,如果是,再删数据
引入lua
但是上面这个操作中,先get key再del key 这两步并不是原子的,也会有线程安全问题。
为了让解锁操作原子性,可以使用redis的lua脚本功能
Lua也是一个编程语言.读作"撸啊"。是葡萄牙语中的"月亮"的意思
为什么redis会选择lua作为内嵌的脚本语言呢?因为Lua语法简单精炼,执行速度快,解释器也比较轻量,内嵌到redis中不会太过于臃肿
lua脚本可以编写成一个.lua后缀的文件,可以由 redis-cli 发送给redis,由redis服务器来执行脚本
一个lua脚本会被redis服务器以原子的方式执行
引入看门狗(watch dog)
上述方案仍然有一定问题,当我们设置过期时间(比如10s)后,如果任务还没有执行完,锁就已经过期失效了,怎么办?
那就可以引入看门狗,本质上是服务器上的一个单独的线程,通过这个线程来对锁过期时间进行"续约"
比如说,我们设置10s过期,设置看门狗每隔3s检测一次,每过3s,看门狗就去判定当前任务是否完成,如果任务完成了,就释放锁,如果任务未完成,就把过期时间重新设置成10s(续约)
值得一提的是,看门狗线程应该是业务服务器的线程,而不是redis服务器的线程
这样,不用担心锁提前过期,就算服务器挂了,看门狗线程也就挂了,就没人去续约,过期就会释放锁了
引入redlock算法
到这里,方案还是有一定问题,如果redis服务器(也就是分布式锁管理器)挂了,怎么办?那我们可以对redis服务器设计主从架构,保证redis服务器的可用性
那么就有一个问题,如果当我获取锁之后,redis主服务器挂了,从服务器变成了主服务器,但是加锁的数据还没有同步,这时候获取的锁失效了,别的服务器仍然可以获取锁
为了解决这个问题,Redis的作者提出了Redlock算法
我们引入一组Redis节点,其中每一组Redis节点都包含一个主节点和若干从节点.并且组和组之间存储的数据都是一致的,相互之间是"备份"关系
首先,获取锁
客户端依次向Redis 节点(通常是 5 个)请求加锁。每个节点使用相同的 key 和一个唯一的随机值(UUID)(校验用)。
客户端在每个节点上设置锁的过期时间(TTL),以防止锁的持有者崩溃后锁无法释放。
如果客户端无法在某个节点上设置锁(例如,实例不可用或已经被其他客户端持有锁),则立即尝试下一个节点。
其次,检查锁的有效性
客户端计算从开始尝试加锁到完成加锁操作的总时间。
如果总时间小于锁的 TTL ,且客户端至少在 N/2 + 1 个节点上成功加锁,则认为锁获取成功。如果锁获取失败(未能在足够多的节点上成功加锁),客户端应立即在所有节点上释放已获取的锁。
比如说锁过期时间为50ms,有5个redis节点,从给第一个节点加锁开始算,到最后一个节点加锁截止,如果加起来总时间小于锁的过期时间(50ms),而且至少有3个节点加锁成功,那么就获取一个锁成功(50ms)
因为如果加锁流程时间太长,比如锁过期时间是50ms,结果光去各个节点加锁就用了100ms,那肯定有部分锁都已经过期了,这时候也就不能认为加锁成功了
最后,释放锁
客户端在所有实例上依次释放锁。释放锁时,客户端需要检查锁的值是否与自己持有的随机值一致,以确保只有持有锁的客户端才能释放锁。