7. 分布式锁
7.1 引入
提到锁,我们就想到线程安全问题。当多个线程操作同一份资源时,由于多个线程是并发执行的,执行顺序不定,由此带来的随机性,就会引发问题,所以要加锁。
在分布式系统中,同样存在多个节点操作同一个公共资源的情况,而 java 的 synchronized 或 c++ 中的 std::mutex 这样的都是进程内部生效的,对于分布式系统这样多进程,多主机的场景就无能为力了,这时就要用分布式锁。
7.2 什么是分布式锁
所谓分布式锁,即 一个/一组 单独的服务器进程。给其他服务器提供 "加锁" 服务。
Redis 是一种典型的实现分布式锁的方案,但不是唯一,mysql / zookeeper 这样的组件也可以用来实现分布式锁。
像下面买票的场景,

由于穿插执行,就导致票卖超了。
引入 分布式锁后,在进行买票操作时要进行加锁,即在 redis 上设置一个 特殊的 key - value,完成买票后再删除(key 一般是要操作的资源名);其他服务器,也想买票时,也要尝试设置 key - value,如果 key 已存在,就认为加锁失败,后面时放弃还是阻塞,就看具体情况要求。
虽然,你可能发现了,该过程通过 mysql 事务一样可以达到同样的效果。但是要注意,分布式系统中,要访问的共享资源,不一定是 mysql,也可能是其他的存储介质,不一定有事务。
7.3 setnx
前面提到,如果不存在 key ,就进行设置;反之,则设置失败。
聪明的你一定想到了前面 redis 基本命令中的 setnx。
通过 setnx 实现 "加锁" 效果,完成后通过 del 命令删除 key 实现解锁。
加锁,解锁的操作是访问数据的服务发个 分布式锁服务 执行的。但是设想这样一种情况 ------ 服务加锁成功了,但是这个服务器后面出问题了,后续没能成功解锁。这就导致锁无人删除,其他服务器一直拿不到锁。
所以通常要给 key 设置过期时间,来解决这问题。
一般是通过 set ex nx 命令来完成设置。注意不要分开使用,即·setnx ...... expire...... 由于reids 多个命令间不保证原子性,所以,可能前一个成功了,但是后一个失败,相比之下,使用一条命令,更加稳妥。
7.4 校验 id
加锁,解锁操作怎么做已经知道了,但是如果 出现了 服务器1 进行了加锁,而服务器 2 进行了解锁呢?毕竟这里的锁只是一个不同的键值对。
当然,正常来说不会这样,但是如果出现这样的 bug 怎么办?
为了解决该问题,引入了 校验id。
1)给服务器编号,每个服务器都有一个自己的身份标识
2)进行加锁时,将 value 设为服务器的编号,标识是哪个服务器上的锁
3)后续解锁时先进行校验,看一下 value 和 当前执行解锁的服务器的编号是否一致,这样就可以避免,"误解锁"
7.5 lua脚本
进行解锁的时候,要进行两步操作:① 查询判定;② del 删除。
虽然,有了锁可以保证只有一个服务器在操作资源。但是,一个服务器内部,也可能是多线程的,此时,就可能同一服务器,两个线程都进行解锁操作,就会类似多买票一样,重复执行 del。
我们知道如果没有对于 key 的话,del 操作会直接返回。看起来没有什么问题?
实则不然,如果在两次 del 之间,有一个服务器正好刚拿到锁,并进行了加锁(首个 del 后,服务就释放锁了),那么第二个 del 就会将刚加上去的锁给解锁(第二个 del 执行前,首个 del 执行完锁释放前,已经校验,就是拿的旧的校验的)。
这里可以想到 redis 事务,就避免插队。但是实践中往往采用更好的方案 ------ lua 脚本。
类似这样:
lua
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
lua 是一个轻量的编程语言(实现一个 lua 解释器,消耗体积很小),是 redis 内嵌的脚本
lua 可以编写一些逻辑如 循环,if 等,将脚本上传到 redis 服务器上,就可以让客户端来控制 redis 执行 上述脚本
redis 执行 lua 脚本的过程,是原子的,相当于执行一条命令一样(实际 lua 中可以写 多个命令)
redis 官方文档上也说了 lua 属于 事务的替代方案
7.5 看门狗
看门狗(watch dog),主要解决过期时间续约问题。
前面谈到了在设置 key 的时候要设置过期时间,但是时间多长合适呢?长了,导致锁释放不及时;短了,可能业务逻辑没执行完,就给释放了。
对于过期时间这类 问题,较好的方法就是采用 "动态续约" ,服务器通过一个专门的线程,负责续约这个事(这个线程,就叫做 "看门狗 watch dog")
初始情况下,设置一个过期时间,线程会在到期前,提前看一下任务是否执行完,没执行完,就将时间再续上,直到任务完成。如果服务器挂了,就没人续约,到时间所就会自己释放。
7.6 redlock 算法
使用 redis 作为 分布式锁,如果 redis 自己挂了,怎么办?这就联想到前面的 主从复制和哨兵机制。
进行加锁就是在主节点上设置 key。如果主节点挂了,哨兵会将从节点晋升为主节点,进一步保障刚才的锁能用。
但是,主从节点间的数据同步是有延时的,可能主节点的set 还没同步给从节点时就挂了,后面从节点即使晋升了,刚才加的锁也没了。
为了解决该问题,redis 的作者提出了 Redlock 算法,本质就是冗余。
这里的 redis 节点不在是一组(一组中含有一个主节点和多个从节点),而是多组,组和组之间存储的数据是一致的,相互是 "备份" 关系。

加锁的时候,会按照一定的顺序,写入这些组的master中。在 写锁的时候,要设定 "超时时间" ,超时则视为 加锁失败。如果某个节点加锁失败,就会立即转向下一个。当成功节点数超总结点数一半时才视为加锁成功。
同样,解锁的时候,也要所有节点进行解锁操作。
7.7 拓展
前面的分布式锁只是一个简单的互斥锁,实际上在一些特定场景中,还有一些其他特殊的锁,如:可重入锁、公平锁、读写锁......,基于 redis 的分布式锁,也可以实现这些特性。
实际开发中,一般也不会真自己实现一个分布式锁,直接用别人封装好的库即可,如 Java 中的 Redission,C++ 中的 redis-plus-plus。