什么是分布式锁
本质上就是使用一个个公共的服务器, 来记录 加锁状态.这个公共的服务器可以是 Redis, 也可以是其他组件(比如 MySQL 或者 ZooKeeper 等), 还可以是自己写的一个服务
在分布式系统中,有很多的进程(每个服务器,都是一个独立的进程)
之前的 锁,难以对分布式系统中的多个进程产生制约
分布式系统中,多个进程中间的执行顺序也是不确定的 ——> 随机性
分布式锁的基础实现
以买票为例:
现在存多个服务器节点,可能需要处理这个买票逻辑:先查询指定车次的余票,如果余票 >= 0,则设置余票值 -= 1
显然上述程序是存在“线程安全”问题的,需要加锁,否则会出现超卖现象
分布式锁,也是一组 / 一个 单独的服务器程序,给其他的服务器提供“加锁”服务
Redis 是一种典型的可以用来实现分布式锁的方案(MySQL,zookeeper ..)
引入 setnx
买票服务器在进行买票操作时,需要先加锁(往 Redis 上设置一个特殊的 key-value 完成上述买票操作,再把这个 key-value 删除掉)
其他服务器也想买票时,也去 Redis 上尝试设置 key-value,如果发现 key-value 已经存在,就认为“加锁失败”(根据情况设置 放弃 / 阻塞)
保证第一个服务器执行“查询 ——> 更新”过程中,第二个服务器不会执行“查询 ——> 更新”操作
Redis 中提供了 setnx 操作:key 不存在就设置,存在就直接失败
使用 setnx 加锁,del 解锁
上述的买票场景,使用 MySQL 事务 也是可以批量执行 查询 + 修改 操作
但是分布式系统中,要访问的共享资源不一定是 MySQL,可能是其他介质,没有事务,也可能是执行一段特定的逻辑,是通过统一的服务器完成执行操作的
引入过期时间
某个服务器,加锁成功(setnx 成功),执行后续逻辑时,程序崩溃(没有执行到解锁)
在之前的学习中,为了保证解锁操作能够执行到
C++ 使用 RAII
Java 使用 finally
但是上述的做法,只是针对进程内的锁,对分布式锁无用
进程的异常退出会导致 Redis 上设置的 key 无人删除,其他服务器无法获取 锁
可以给 key 设置过期时间,实现 key 到时自动删除
set ex nx 设置
setnx
expire 这样分开设置是不对的,必须使用上述的设置
Redis 命令之间无法保证原子性,两个命令不一定会同时成功
引入校验 id
此处的锁,就是 redis 上的普通键值对:
加锁,就是给 redis 上设置一个 key-value
解锁,就是把 redis 上这个 key-value 删除
如果代码出现bug,例如 服务器1执行加锁,服务器2执行解锁
为解决上述问题,需要引入校验机制:
1)给服务器编码,每个服务器有自己的身份标识
2)进行加锁时,设置 key-vlaue。key 对应针对哪个(车次 ..)资源进行加锁,value 存储服务器的编号,标识出当前这个锁是哪个服务器加上的,后续在解锁时就可以进行校验了
解锁时,先查询一下这个锁对应的服务器编号,判定这个编号是否是当前执行解锁操作的服务器的编号
如果是,执行 del;如果不是,就失败
引入 lua 脚本
在解锁时,先查询判定,再进行 del(此处是两步操作,不是原子的)
一个服务器内部可能是多线程的,此时可能是同一个服务器中的两个线程都在执行上述的解锁操作
线程C,在线程A GET 后,线程B DEL 之前,执行 GET
线程B 的 DEL 会把线程 B 获取的锁删除 ——> get 和 del 不是原子
使用事务,可以解决上述问题(redis 事务虽然弱,但是能够避免插队)
Lua 脚本是更好地替代
Lua 可以作为 redis 的内嵌脚本,Mysql 8支持 vimscript/python 作为内嵌语言
可以使用 lua 编写一些逻辑,把这个脚本上传到 redis 服务器上,然后就可以让客户端来控制 redis 执行上述脚本
redis 执行 lua 脚本的过程也是原子性的,相当于是执行一条命令(实际上 lua 中可以写多个命令)
if redis.call('get',KEYS[1]) == ARGV[1] thenreturn redis.call('del',KEYS[1])elsereturn 0end;
Lua 官方文档 Lua: aboutLua 的语法类似于 JS, 是一个动态弱类型的语言. Lua 的解释器一般使用 C 语言实现. Lua 语法简单精炼, 执行速度快, 解释器也比较轻量(Lua 解释器的可执行程序体积只有 200KB 左右).因此 Lua 经常作为其他程序内部嵌入的脚本语言. Redis 本身就支持 Lua 作为内嵌脚本
引入看门狗
在加锁时,给 key 设置的时间不一定满足实际情况:
设置的时间过短,业务逻辑还没执行完,就释放锁了
设置的时间过长,锁释放不及时,业务阻塞
更好地解决方式是“动态续约”:
初始情况下,设置一个过期时间 1s,就提前在还剩 300ms 时,如果业务已经完成,直接通过 lua 脚本的方式,释放锁;如果当前任务还没执行完,就把过期时间再续上 1s,等过期时间又快到了,任务还没执行完,再续时间 ..
如果服务器崩溃,自然就没有人续约了,锁 也能在较短的时间内被自动释放
动态续约,往往需要业务服务器这边有一个专门的线程负责(看门狗 watch dog)
redlock 算法
为了确保高可用性,需要通过一系列的“预案演习”
哨兵 ——> 在分布式锁场景中,涉及的数据量不大
进行加锁,就是把 key 设置到主节点上
如果主节点挂了,有哨兵自动的把从节点升级成主节点,进一步保证锁的可用性
主节点和从节点之间的数据同步,是存在延时的
可能主节点收到 set 请求,还没来得及同步给从节点,主节点就先挂了
及时从节点升级成主节点,刚才的加锁对应的数据也是形同虚设的,其他服务器仍然可以进行加锁(新的主节点不包含刚才的 key)——>
引入多组 redis 节点,每组 redis 节点都包含一个主节点和若干个从节点(组合组之间存储的数据都是一致的,相互之间是“备份”关系)
加锁的时候,按照一定的顺序针对多组 redis 都进行加锁
如果某个节点挂了,继续给下个节点加锁
如果写入 key 成功的节点个数超过总数的一半,就认为加锁成功
同理,进行解锁时,也会把上述过程都设置一遍解锁
上述介绍的只是一个“互斥锁”
基于 redis 也可以实现以下锁的特性:
读写锁,公平锁,可重入锁 ..