在实际的项目开发过程中,许多业务场景都使用基于 Redis 进行分布式锁的实现,但其中一些场景的实现过程中往往并没有充分考虑到分布式环境中可能出现的各种陷阱问题 ~
什么是“锁”?
通常来讲,我们可以这样定义,即,锁是多个线程检查是否允许访问资源的单一参考点。因此,例如,如果一个线程想要在某处写入数据,它必须首先检查是否已经存在写锁。如果写锁存在,它必须等到锁被释放才能获得自己的锁并执行它的写操作。这样,基于锁,可以防止多个线程同时写入,否则可能会导致数据损坏等不利影响。
为什么需要“锁”?
在实际的业务场景中,引入锁的目的是确保在可能尝试执行相同工作的多个节点中,只有一个节点实际执行此操作(至少一次只有一个)。这项工作可能是将一些数据写入共享存储系统、执行一些计算、调用一些外部 API 等。在更高层次上,我们可能需要在分布式应用程序中引入锁的原因通常有两个:效率或正确性 。
效率:使用锁可以避免不必要地做两次或多次同样的工作(例如一些昂贵的计算)。如果锁失败并且两个节点最终完成相同的工作,最终结果便是成本略有增加或带来不友好的用户体验。
正确性:使用锁可以防止并发进程相互干扰并破坏系统状态。如果锁失败并且两个节点同时处理同一条数据,则结果可能会出现文件损坏、数据丢失、永久性不一致、造成用户权益损失以及其他一些严重问题。
分布式锁的应用
如果程序操作要保证正确性,那么,系统中有多个资源不能被多个进程同时使用。例如,一个文件不能被多个进程同时更新,或者打印机的使用必须同时限制在一个进程中。因此,必须确保进程对这种共享资源的独占访问。这种访问的排他性称为“进程间互斥”。需要独占访问共享资源的程序部分称为临界区。若需要通过进程授予互斥访问的解决方案,我们可以使用分布式锁来互斥地访问资源。
分布式锁基本属性
在实际的业务场景中,要实现分布式锁服务,需要满足以下属性:
1、互斥:在给定时刻只有一个 Client 可以持有锁。这是分布式锁的基本属性。
2、无死锁:每个锁请求都必须最终被授予,即使是持有锁的客户端也会崩溃或遇到异常。
3、容错性:若大部分的 Redis 节点能够正常运行,Client 就可以加锁/解锁。
分布式锁的缺陷
基于当前主要活跃的业务场景,分布式锁的缺陷主要体现在以下场景中,具体如下所示:
1、锁失效
基于 Client 长时间阻塞以导致锁失效。例如,假设 Client 1 获得了锁,在某一时刻,由于网络或者 GC 等不同原因导致长时间阻塞,在业务程序还没有执行完时锁便过期,此时, Client 2 也能够正常获取到锁,那么,在这种情况下可能会导致线程安全问题。
2、时钟漂移
若 Redis 服务器时钟发生了向前跳跃,就会导致 Key 过早地超时失效。例如, Client 1 获取到锁后,我们定义了 Key 的过期时间是 10:02 分,若 Redis 服务器本身的时钟比 Client 快了 2 分钟,那么,可能会导致 Key 在 10:00 的时候就失效,此刻,若 Client 1 还没有释放锁的话,就可能导致多个 Client 同时持有同一把锁现象发生。
3、单点安全
若我们的 Redis 集群为单 Master 模式,当这台服务宕机的时候,那么所有的 Client 都可能获取不到锁,为了提高可用性,我们需要给 Master 引入 Slave 节点,但是因为 Redis 的主从同步是异步进行,可能会发生 Client 1 设置完锁后,Master 挂掉,Slave 晋升为 Master,由于异步复制等特性,Client 1 设置的锁丢失了,此刻,Client 2 设置锁也能够成功,从而导致 Client 1 和 2 同时拥有对应的锁。
分布式锁的不同实现策略
许多分布式锁的实现都是基于分布式共识算法(Paxos、Raft、ZAB、Pacifica),比如,基于 Paxos 的 Chubby,基于 ZAB 的 Zookeeper ,基于 Raft 的 Consul。Redis 创建者还提出了一个名为 RedLock 的分布式锁。
1、Chubby 分布式锁,此策略源于 Google 公司实现的粗粒度分布式锁服务,与 ZooKeeper 较为相似,但存在较大差异性。其实现方式为通过 Sequencer 机制解决请求延迟造成的锁失效的问题。
2、Zookeeper 分布式锁,此策略基于 Zookeeper 的顺序临时节点,来实现分布式锁和队列等待。ZooKeeper 作为一个专门为分布式应用提供方案的框架,其提供了一系列较为丰富的特性,如 ephemeral 类型的 znode 自动删除的功能,同时 ZooKeeper 还提供 Watch 机制,可以让分布式锁在客户端用起来就像一个本地的锁一样,加锁失败就阻塞住,直到获取到锁为止。
3、Consul 分布式锁,此策略基于 Consul 的 Key / Value 存储 API 中的 Acquire 和 Release 操作来实现。Acquire 和 Release 操作是类似 Check-And-Set 的操作,其具体实现:
- Acquire 操作只有当锁不存在持有者时才会返回 True,并且 Set 设置的 Value 值,同时执行操作的 Session 会持有对该 Key 的锁,否则就返回 False。
- Release 操作则是使用指定的 Session 来释放某个 Key 的锁,如果指定的 Session 无效,那么会返回 False,否则就会 Set 设置 Value 值,并返回 True
4、Redis 分布式锁,基于 Redis 单机实现的分布式锁,利用 Redis 的 SETNX 命令,此命令为原子性操作,只有在 Key 不存在的情况下,才能 Set 成功。而基于 Redis 集群环境下所实现的分布式锁 Redlock,是 Redis 的作者 antirez 为了规范 Redis 分布式锁的实现,提出的一个更安全有效的实现机制。其具体流程包括:
1、获取当前时间。
2、依次 N 个节点获取锁,并设置响应超时时间,防止单节点获取锁时间过长。
3、锁有效时间=锁过期时间-获取锁耗费时间,如果第 2 步骤中获取成功的节点数大于 N/2+1,且锁有效时间大于 0 ,则获得锁成功。
4、若获得锁失败,则向所有节点释放锁。
简单总结就是:在锁过期时间内,如果半数以上的节点成功获取到了锁,则说明获取锁成功。这个有点像 ZooKeeper 的选举机制。
Redis 分布式锁场景剖析
在实际的业务场景中,若要基于 Redis 实现分布式锁。那么,加锁操作的正确姿势可参考如下所示:
1、使用 Setnx 命令保证互斥性。
2、需要设置锁的过期时间,避免死锁。
3、Setnx 和设置过期时间需要保持原子性,避免在设置 Setnx 成功之后在设置过期时间客户端崩溃导致死锁。
4、加锁的 Value 值为一个唯一标示。可以采用 UUID 作为唯一标示。加锁成功后需要把唯一标示返回给 Client 来用进行解锁操作。
同理,解锁的正确姿势,可参考如下所示:
1、需要拿加锁成功的唯一标示要进行解锁,从而保证加锁和解锁的是同一个客户端。
2、解锁操作需要比较唯一标识是否相等,相等再执行删除操作。这 2 个操作可以采用 Lua 脚本方式使 2 个命令的原子性。
在下面的内容中,笔者将逐步剖析如何基于 Redis 实现分布式锁,并且在每一步中,试图解决分布式系统中可能出现的问题。需要注意的是,如下所有的场景都是基于租用的锁,这意味着我们在 Redis 中设置了一个具有过期时间(租用时间)的密钥。之后,密钥将自动移除,并且锁将被释放,当然,前提是 Client 不刷新锁。完整的源代码可查看 GitHub :https://github.com/siahsang/red-utils。
1、Redis 单实例
在此场景中,为简单起见,假设我们有两个 Client 和一个 Redis Server 实例。 如下为一个简单的代码实现:
/** * @param lockName name of the lock * @param leaseTime the duration we need for having the lock * @param operationCallBack the operation that should be performed when we successfully get the lock * * @return true if the lock can be acquired, false otherwise */ boolean tryAcquire(String lockName, long leaseTime, OperationCallBack operationCallBack) { boolean getLockSuccessfully = getLock(lockName, leaseTime); if (getLockSuccessfully) { try { operationCallBack.doOperation(); } finally { releaseLock(lockName); } return true; } else { return false; } } boolean getLock(String lockName, long expirationTimeMillis) { // Create a unique lock value for current thread String lockValue = createUniqueLockValue(); try { String response = storeLockInRedis(lockName, lockValue, expirationTimeMillis); return response.equalsIgnoreCase("OK"); } catch (Exception exception) { releaseLock(lockName); throw exception; } } void releaseLock(String lockName) { String lockValue = createUniqueLockValue(); // Remove the key 'lockName' if it have value 'lockValue' removeLockFromRedis(lockName, lockValue); }
假设第一个 Client 请求获取锁,但是服务器响应比租用时间长,结果,Client 使用了过期的密钥,同时另一个 Client 可以获得相同的密钥,现在他们同时拥有相同的密钥。具体如下图所示:
为了解决这个问题,我们可以为 Redis Client 设置一个超时时间,并且应该小于租用时间。但是还有一个问题,如果 Redis 在它可以将数据持久化到磁盘之前重新启动(由于服务崩溃或机房断电)会发生什么?我们在下一节中进行解析。
2、Redis 单实例及节点宕机
众所周知,在 Redis 官方所定义的持久机制中,Redis 通过三种方式将内存中的数据持久保存至磁盘上,具体如下:
1、Redis 数据库 (RDB):即“快照方式”,此方式以指定的时间间隔执行数据集的时间点快照并存储在磁盘上,其分为手动触发和自动触发。
2、Append-only File (AOF):即“文件追加方式”,此方式记录服务器所有的操作命令,并以文本的形式追加到文件中。
3、RDB-AOF:即“混合持久化方式”,此方式为 Redis 4.0 之后新增的模型,混合持久化是结合了 RDB 和 AOF 的优点,在写入的时候,先把当前的数据以 RDB 的形式写入文件的开头,再将后续的操作命令以 AOF 的格式存入文件,这样既能保证 Redis 重启时的速度,又能减低数据丢失的风险。
在默认情况下,Redis 只启用 RDB 模式,配置如下(更多信息请查看 https://download.redis.io/redis-stable/redis.conf):
save 900 1 save 300 10 save 60 10000
例如,第一行表示,如果我们在 900 秒(15 分钟)内有一次写操作,那么它应该保存在磁盘上。
因此,通常在最坏的情况下,保存密钥更改需要 15 分钟。如果 Redis 在此期间重新启动(崩溃、断电,此处为没有正常关闭),我们将丢失内存中的数据,因此其他 Client 可以获得相同的锁,具体如下所示:
为了解决这个问题,我们必须在 Redis 中设置 Key 之前使用 fsync=always 选项启用 AOF。需要注意的是:启用此选项对 Redis 有一些性能影响,但我们需要此选项以实现强一致性。在接下来的场景中,笔者将展示如何在拥有主副本时扩展此解决方案。
3、Redis 主从(多副本)
在这种配置中,我们往往有一个或多个实例(通常称为从副本),它们是主设备的备用副本。
默认情况下,Redis 中的复制是异步工作的, 这意味着 Master 不会等待副本处理命令并在之前回复 Client 。问题是 Rreplication 发生之前,Master 可能会失败,发生 Failover;之后,如果另一个 Client 请求获取锁,它就会成功。或者假设有一个临时的网络问题,所以其中一个副本没有收到命令,网络变得稳定,很快就会发生故障转移;没有收到命令的节点成为主节点。最终,密钥将从所有实例中删除。具体如下图所示:
作为一种解决方案,有一个 WAIT 命令,它等待来自副本的指定数量的确认,并返回确认在 WAIT 命令之前发送的写命令的副本数量,无论是在达到指定数量的副本的情况下还是当超时已到。例如,如果我们有两个副本,以下命令最多等待 1 秒(1000 毫秒)以从两个副本获取确认并返回:
WAIT 2 1000
到目前为止,一切看起来似乎没什么异样,但还有一个问题;副本可能会丢失写入(由于错误的环境)。比如一个 Replica 在 Save 操作完成之前就失败了,同时Master 也失败了,Failover 操作选择了重启的 Replica 作为新的 Master。与新 Master 同步后,所有副本和新 Master 都没有旧 Master 的 Key 了。
为了使所有从节点和主节点完全一致,我们应该在获取锁之前为所有 Redis 实例启用带有 fsync=always 的 AOF。
注意:同样在这种方法中,为了强一致性,我们正在降低可用性。
boolean tryAcquire(String lockName, long leaseTime, OperationCallBack operationCallBack) { // same as before } boolean getLock(String lockName, long expirationTimeMillis) { // Create a unique lock value for current thread String lockValue = createUniqueLockValue(); try { // Check if key 'lockName' is set before, // If not then put it with expiration time 'expirationTimeMillis'. String response = storeLockInRedis(lockName, lockValue, expirationTimeMillis); if (!response.equalsIgnoreCase("OK")){ return false; } // wait until we get acknowledge from other replicas or throws exception otherwise waitForReplicaResponse(); return true; } catch (Exception exception) { releaseLock(lockName); throw exception; } } void releaseLock(String lockName) { // same as before }
4、自动刷新锁
在这种情况下,只要 Client 处于活动状态且连接正常,就可以持有获取的锁。我们需要一种机制来在租约到期之前刷新锁。我们还应该考虑无法刷新锁的情况;在这种情况下,我们必须立即退出(也许有例外)。
此外,其他 Client 应该能够等待获得锁并在锁的持有者释放锁后立即进入临界区,具体如下图所示:
以下为简要的伪代码,具体实现可参考 GitHub 仓库:https://github.com/siahsang/red-utils 。
要求: 1. 当 AOF = FULLSYNC 在所有 Redis 实例上时,此算法所做的任何修改都必须发生 2. 我们必须等待所有修改命令的确认 3. 当它不能刷新锁时(例如 Redis 崩溃或错误关闭)我们必须立即从当前执行中返回 4. 我们必须设置一个默认的响应超时,它远小于锁的超时时间,例如 2 秒 算法: UUID = GENERATE NEW UUID LEASE_TIME = 30 SECONDS FUNCTION ACQUIRE(LOCK_NAME) GET_LOCKED_SUCCESSFULLY = GET_LOCK(LOCK_NAME, LEASE_TIME) IF(GET_LOCKED_SUCCESSFULLY == FALSE) SUBSCRIBE FOR CHANNEL 'LOCK_NAME' // THIS IS BECAUSE THE CLIENT THAT HOLDS THE // LOCK MAY HAVE DIED BEFORE INFORM OTHERS. // ALSO THERE MAY BE RACE CONDITIONS THAT CLIENTS MISS SUBSCRIPTION SIGNAL WHILE(GET_LOCKED_SUCCESSFULLY == FALSE) TTL = GET TTL FOR KEY 'LOCK_NAME' IF(TTL>=0) WAIT CURRENT THREAD FOR TTL SECONDS ELSE GET_LOCKED_SUCCESSFULLY = GET_LOCK(LOCK_NAME , LEASE_TIME) END IF END WHILE // AT THIS POINT WE GET LOCK SUCCESSFULLY UNSUBSCRIBE FOR CHANNEL 'LOCK_NAME' END IF IF(TIMER IS NOT STARTED FOR THIS LOCK) CREATE A TIMER TO REFRESH LOCK EVERY 10 SECONDS EXECUTE OPERATION STOP TIMER RELEAS_LOCK(LOCK_NAME) PUSH MESSAGE TO SUBSCRIBERS FOR CHANNEL 'LOCK_NAME' END FUNCTION FUNCTION GET_LOCK(LOCK_NAME, LEASE_TIME) THREAD_ID = GET CURRENT THREAD_ID LOCK_VALUE = THREAD_ID + ':' + UUID RESULT = NOT_OK IF(KEY 'LOCK_NAME' DO NOT EXIST IN REDIS) CREATE KEY 'LOCK_NAME', VALUE 'LOCK_VALUE', EXPIRE_TIME 'LEASE_TIME' RESULT = OK // IN THIS CASE THE SAME THREAD IS REQUESTING TO GET THE LOCK ELSE IF(KEY 'LOCK_NAME' AND VALUE 'LOCK_VALUE' EXIST IN REDIS) INCREASE EXPIRE_TIME FOR LOCK_NAME TIME RESULT = OK ELSE RESULT = NOT_OK END IF WAIT FOR ALL REPLICAS TO PERSIST DATA RETURN RESULT END FUNCTION FUNCTION RELEAS_LOCK(LOCK_NAME) THREAD_ID = GET CURRENT THREAD_ID LOCK_VALUE = THREAD_ID + ':' + UUID DELETE KEY 'LOCK_NAME' WITH VALUE 'LOCK_VALUE' END FUNCTION
综上所述,我们一步步剖析了分布式锁的不同案例场景,并对其进行分析,基于每一个层级,我们解决了一个新问题。 但同时引入其他新的问题,笔者想在这里表达的是,很多实际的问题需要去参阅官网及代码以了解有关这些主题的更多信息:
1、假设时钟在不同节点之间同步,有关节点之间时钟漂移的更多信息,请参阅资源部分。
2、假设在获得锁之后但在使用它之前没有任何长时间的线程暂停或进程暂停。
3、获取锁是不公平的,例如,一个 Client 可能需要等待很长时间才拿到锁,然而另一个则立即获取到了锁。
总之而言,使用 Redis 来提供分布式锁服务。需要我们深入了解它们的底层工作原理及可能发生的问题,以及如何在它们的正确性和性能之间的进行权衡。
# 参考资料
- https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- Distributed Operating Systems: Concepts and Design, Pradeep K. Sinha
- https://blog.fearcat.in/a?ID=00001-e23af58b-17f7-4cba-ba76-355a53a6c896
- https://redis.io/commands/set#patterns