目录
前言:
在分布式系统中,涉及多个主机访问同一块资源,此时就需要锁来做互斥控制,避免出现类似线程安全问题。而Java中的synchronized只是对当前进程中的线程有效,多个主机实际上是多个进程,那么它就无能为力了,此时就需要分布式锁。
分布式系统买票示例
客户端访问买票服务器进行买票操作,当买到票之后数据库余票执行减1操作。
客户端1先执行查询余票操作,发现余票还有一张,买票成功后服务器操作数据库执行减1操作。如果此时客户端1还没有执行数据库减1操作,客户端2执行了查询余票操作,发现余票也是一张,那么也进行数据库减1操作,那么此时客户端1和客户端2都会买到票。把一张票卖给了两个人,显然这是不合理的。
引入redis做分布式锁
引入分布式锁,所谓分布式锁实际上就是一个/一组单独的服务器程序,给其他服务提供 "加锁" 服务。redis是一种典型的实现分布式锁的方案,但不是唯一一种。
为了解决上述超卖问题,买票服务器在进行买票的时候,就需要先加锁。
加锁实际上就是在redis上设置一个特殊的键值对,完成上述买票操作再删除这个键值对。(此时认为这个键值对就是分布式锁)
其他服务器在买票的时候,也去redis尝试设置这样的键值对,如果发现键值对已经存在,就认为"加锁失败",此时该进程是放弃还是阻塞就看具体的实现策略了。
这样就可以保证第一个服务器执行查询-更新的过程中,第二个服务器不会执行查询操作,也就解决了上述超卖问题。
注意:
上述买票的场景也可以使用MySQL的事务解决,批量执行查询-更新操作。但是分布式系统中数据库不一定是MySQL,也可能是其他数据库没有事务,因此使用redis作为分布式锁是比较好的解决方案。
引入过期时间
redis中使用 set nx 命令可以实现加锁效果,解锁使用 del 命令来完成。
如果某个服务器 set nx 成功了,还没有执行 del 命令就挂了,此时redis上的锁就无法删除,其他服务器就无法获取到锁。
解决方案:
可以在set key 的时候设置过期时间,一旦时间到锁自动就释放了。redis中可以使用 set ex nx命令完成。此时这个过期时间范围设置就显得尤为重要了,后面会有解释(过期时间续约问题)。
注意:
务必使用 set ex nx 命令一次性执行加锁和设置过期时间操作。如果使用set nx 设置锁,然后使用 expire 设置过期时间,就可能出现这两个命令一个成功一个失败,就算使用redis事务只能保证两条命令一块执行,但不能保证其正确性。相比之下一条命令直接操作就比较稳妥。
引入校验id
是否会出现服务器1执行了加锁操作,被其他服务器删除的情况呢?
这种情况有可能出现,代码总会有bug,需要提前去防止。
解决方案:
给服务器编号,每个服务器都有自己的唯一标识。进行加锁的时候,设置键值对.key对应要对哪个资源加锁(比如车次),value就可以存储服务器编号,标识出这个锁是哪个服务器加的。
解锁的时候就可以进行校验,先查询这个锁的服务器编号,和自己服务器编号进行对比。如果一致则执行解锁操作,否则就失败。服务器这边需要执行校验逻辑,此时就可以有效避免误解锁。
引入lua脚本
一个服务器内部也可能是多线程的,就有可能存在两个线程执行上述判断然后解锁操作。由于不是原子的,就有可能出现问题。
服务器1的线程A和线程B都执行解锁操作,如果线程A拿到锁判断完后,还没有执行DEL操作,此时线程B也拿到锁判断也会成功,那么就会执行两次DEL操作。
如果在线程A执行DEL之后,线程B执行DEL之前,服务器2的线程C在redis中进行加锁操作(此时由于线程A已经执行了DEL操作,因此可以加锁成功)。由于线程B已经校验完成,那么执行DEL就会删除掉服务器2加的锁。该问题就是因为GET和DEL操作不是原子的,就会出现问题。
解决上述问题,可以使用redis事务,保证GET和DEL的原子性,在执行期间不会有插队情况出现。但是一般不会这样做,引入lua脚本是更加有效的解决方案。
lua是一门编程语言,作为redis内嵌脚本,lua语言特别轻量,实现一个lua的解释器消耗系统资源非常小。
可以使用lua编写一些逻辑,把这个脚本上传到服务器上,然后客户端就可以控制redis执行这些脚本了。redis执行lua脚本的时候,是原子的,相当于执行一条命令一样。(redis官方文档中提出lua属于是redis事务的替代方案)。
过期时间续约问题
在加锁的时候key需要设置过期时间,那么这个时间设置多少合适呢?
如果设置短,那么有可能在业务逻辑还没执行完,锁就被释放了。
如果设置长,那么锁释放就会不及时。
动态续约:
初始情况下设置一个时间比较短的过期时间(灵活进行调整),如果发现时间快到的时候,业务逻辑还没执行完,那就在续上一些过期时间(无限续约)。直到业务逻辑执行完成,锁也可以在较短时间内被释放。
如果服务器中途挂了,那也没有负责续约的线程了,此时锁也可以在较短时间内被释放。
动态续约往往需要服务器这边一个专门的线程负责,把这个线程就叫做看门狗(watch dog)。
redlock算法
使用redis作为分布式锁,那么就需要保证redis的高可用。
使用redis哨兵机制,当主节点挂了可以投票选举从节点作为主节点。但是如果在主节点加锁后还没来得及同步给从节点,主节点就宕机了。此时哨兵选举的从节点也就不存在该锁了。
作为分布式系统,就需要随时考虑某个节点挂了,不会影响大局。
redlock算法核心思想就是:冗余
此处加锁会按照一定顺序,对这些redis主节点都进行加锁。如果某个节点宕机了,没关系继续给下一个redis主节点加锁。如果加锁成功节点个数超过总个数一半,就视为加锁成功。同理,解锁需要把上述节点都执行解锁操作。这里就不会因为某个节点挂了,而导致加不上锁的情况。
小结:
这里实际上实现了互斥锁,还可以使用redis做读写锁,可重入锁,公平锁等等。