Redis 应用--分布式锁
什么是分布式锁
在分布式结构当中,若有多个节点同时对某个数据进行操作,就很有可能出现数据不一致问题,如果是 MySQL 这种关系型数据库,我们是可以通过事务来保证数据的一致性的,但是,Redis 并不支持事务,且我们也不能保证除了 MySQL 之外的其他服务都支持事务,因此我们引入了分布式锁的概念。
分布式锁的实现非常简单,为了方便理解,我们从最基础的机制开始实现。
分布式锁的实现
基础实现
首先,分布式锁就是在执行某个需要串行执行的逻辑之前,在 Redis 中设置一对键值对,表示某个资源已经被某个节点锁定了,其他节点在执行该逻辑之前,必须先检查该资源是否被锁定,如果被锁定,则等待一段时间后再次检查,直到该资源被解锁为止。
这种策略利用了 Redis 单线程模型的特性,保证了在同一时间内,只有一个节点可以成功设置该键值对,从而实现了分布式锁的功能。
下面我们假设一个应用场景:
当前有一个抢票系统,在某一时刻,系统只剩下了一张票,而此时有多个人同时请求购买该票。
在不引入分布式锁的情况下,可能会出现以下情形:
第一步:用户1查询票数,发现票数是1,准备购买
第二步:用户2查询票数,发现票数是1,准备购买
第三步:用户1执行购买操作,系统检查票数,发现票数是1,扣减票数,更新数据库,购买成功
第四步:用户2执行购买操作,因为之前已经检测过票数是1,因此直接扣减票数,更新数据库,购买成功
那么就会出现一个非常可怕的现象,票数变成了-1,显然这是不合理的。
当我们引入了分布式锁之后,情况就会有所不同:
第一步:用户1在查询并购买票之前,先尝试获取锁,成功获取锁
第二步:用户2在查询并购买票之前,尝试获取锁,发现锁已经被用户1获取,等待
第三步:用户1查询票数,发现票数是1,准备购买
第四步:用户1执行购买操作,系统检查票数,发现票数是1,扣减票数,更新数据库,购买成功
第五步:用户1释放锁
第六步:用户2尝试获取锁,成功获取锁
第七步:用户2查询票数,发现票数是0,无法购买
第八步:用户2释放锁
显然,在这个场景中,我们利用分布式锁的特性,很好地解决了数据不一致的问题。
当然,仅仅通过设置和删除键值对的方式来保证数据一致性问题显然是不够的,在操作系统当中,锁的实现都是保证了锁只能被持有锁的线程释放,而不能被其他线程释放,否则就会出现死锁的问题。
引入校验 ID
为了避免这种情况的发生,我们可以在设置锁的时候,设置一个唯一的标识符,比如说,设置 value 为当前服务器的服务器编号,然后在释放锁的时候,先检查该标识符是否和当前持有锁的标识符一致,如果一致,则释放锁,否则不进行任何操作。
引入过期时间
还有一个我们需要考虑的情景,假如说,某个节点在持有锁的过程中,发生了宕机,或者该进程异常退出了,那么该锁就永远不会被释放,从而导致其他节点无法获取锁,进而导致整个系统的不可用。
那么这就会引起死锁问题,为了解决这个问题,我们需要引入过期时间的概念,在设置锁的时候,设置一个合理的过期时间,比如说,10 秒钟,这样即使某个节点在持有锁的过程中发生了宕机,锁也会在 10 秒钟后自动释放,从而避免了死锁的问题。
引入看门狗(Watchdog)
设置过期时间,嗯,确实,这样的确可以解决节点宕机导致的死锁问题,但是,这样又会引入另一个问题,假如说,某个节点在持有锁的过程中,执行了一个非常耗时的操作,比如说,执行了一个复杂的数据库查询操作,或者说,执行了一个复杂的计算操作,那么该节点就有可能在过期时间内无法完成该操作,从而导致锁被自动释放,而其他节点又获取了该锁,从而导致数据不一致的问题。
这时,我们就需要引入看门狗的概念,在持有锁的节点中,启动一个定时任务,在锁快要过期的时候,自动延长锁的过期时间,从而保证该节点在持有锁的过程中,能够完成该操作。
一般来说,看门狗由一个单独的线程来实现,该线程会定期检查锁的状态,如果发现锁快要过期了,就会自动延长锁的过期时间。一般来说,操作系统对线程的管理是非常高效的,看门狗线程不会说两次调用的时间跨度太长,导致锁过期了。当然,如果想要提高系统的健壮性,可以将看门狗线程设置为守护线程,这样即使主线程异常退出了,看门狗线程也会继续运行,从而保证锁的过期时间能够被及时延长,或者我们也可以将看门狗设置为单核心任务,这样就可以避免线程切换带来的性能损耗。
引入 Lua 脚本
即便我们引入了这么多的机制,仍然不可保证数据的完全一致性。即便引入了校验 ID,锁仍然存在被误释放的可能性。
下面我们假设一个场景:
第一步:节点1的线程1获取锁,设置锁的过期时间为10秒,并设置锁的标识符为1
第二步:节点1的线程2也尝试获取锁,在设置锁之前,发现自己已经持有该锁,因此直接返回成功
第三步:节点1的线程1执行完了自己的操作,因此直接释放锁
第四步:节点1的线程2执行完了自己的操作,因此直接释放锁
这里暂停一下,我们假设在第三步和第四步之间,有一个新的节点2的线程3尝试获取锁,因为此时锁已经被释放了,因此节点2的线程3成功获取了锁,并设置锁的标识符为2。
而我们在第四步当中,节点1的线程2在释放锁之前,并没有检查锁的标识符是否和自己持有的标识符一致,因此直接释放了锁,从而导致节点2的线程3持有的锁被误释放了。
当然,你可能会说,让所有的线程在执行之前都去检查自己是否持有锁不就行了么?嗯,这确实是一个解决方案,但是,这样会引入额外的网络开销,且有时候同一个服务器是允许并行访问的,锁仅仅防御的是不同节点之间的并行访问。
这时,我们可以引入 Lua 脚本的概念,在释放锁的时候,使用 Lua 脚本来保证释放锁的操作是原子性的。也就是说,我们不是直接执行 Redis 的某一个命令,而是改变为通过执行一个 lua 脚本来作为一个整体来执行。
引入 RedLock 算法
我们假设一个可能的场景:
在某一个集群当中,主节点进行了一次加锁操作,但还没来得及释放,此时主节点发生了故障,导致主节点宕机了,而此时从节点已经被提升为新的主节点了。但问题是,主节点还没来得及将锁更新给从节点,这时,主节点的锁就形同虚设了,而新的主节点并没有该锁,因此其他节点就可以直接获取该锁,从而导致数据不一致的问题,这时我们就需要引入 RedLock 算法。
RedLock 算法是由 Redis 的作者 Antirez 提出的一个分布式锁的实现方案,旨在解决分布式环境下的锁的可靠性和可用性问题。
RedLock 算法的核心思想是通过在多个独立的 Redis 实例上获取锁来提高锁的可靠性。具体来说,RedLock 算法包括以下几个步骤:
- 获取锁:客户端尝试在多个独立的 Redis 实例上获取锁。每个实例上获取锁的过程是通过设置一个唯一的标识符(如 UUID)和一个过期时间来实现的。客户端需要在大多数实例上成功获取锁(例如,在 5 个实例中至少需要 3 个实例成功获取锁)才能认为锁被成功获取。
- 使用锁:一旦客户端成功获取锁,它就可以执行需要串行化的操作。在操作完成后,客户端需要释放锁。
- 释放锁:客户端在所有获取锁的 Redis 实例上释放锁。释放锁的过程也是通过检查唯一标识符来确保只有持有锁的客户端才能释放锁。
分布式锁在分布式系统中是一个非常重要的概念,RedLock 算法通过在多个独立的 Redis 实例上获取锁来提高锁的可靠性,从而解决了分布式环境下的锁的可靠性和可用性问题。