今天我们来聊一聊分布式锁的那些事。
锁?分享式锁?
相信大家对锁已经不陌生了,我们在多线程环境中,如果需要对同一个资源进行操作,为了避免数据不一致,我们需要在操作共享资源之前进行加锁操作。
在计算机科学中,锁(lock)或互斥(mutex)是一种同步机制,用于在有许多执行线程的环境中强制对资源的访问限制。比如你去相亲,发现你和一大哥同时和一个女的相亲,那怎么行呢…,搞不好还要被揍一顿。
那什么是分布式锁呢。当多个客户端需要争抢锁时,我们就需要分布式锁。这把锁不能是某个客户端本地的锁,否则的话,其它客户端是无法访问的。所以分布式锁是需要存储在共享存储系统中的,比如Redis、Zookeeper等,可以被多个客户端共享访问和获取。
Redis 分布式锁的前提
今天我们就来看一下如何使用 Redis 来实现分布式锁。
在正式开始之前,我们先来了解两个 Redis 的命令:
SETNX key value
这个命名的含义是,当 key 存在时,不做任何赋值操作;当 key 不存在时,就创建 key,并赋值成 value,即(不存在即设置)。
SET key value [EX seconds | PX milliseconds] NX
SET 后加 NX 选项,就和 SETNX 命令类似了,也实现不存在即设置的功能。此外,这个命令在执行时,可以通过 EX 或者 PX 设置键值对的过期时间。
加锁原则
开始之前,先引入一个场景:
假设要给某个商品举行秒杀活动,事先把库存数据 100 已经存入到了 redis 中,现在需要来进行库存扣减。”
如图所示,假设有 1000 个客户端来进行库存扣减操作,那该如何做,才能保证库存扣减顺序一致且不会超扣呢。
首先想到的就是加锁,在进行库存扣减之前,只有先拿到锁,才能进行扣减,最后再释放锁,没拿到锁就不能扣减,这样保证了一致性。
加锁和释放锁过程
在 redis 中创建一个 lock_key 来代表一个锁变量,它的值表示锁变量的值。
客户端 2 的请求先来了,如果 lock_key 的值为 0,就设置成 1,表示加锁操作。再有新的客户端请求过来,发现值已经为 1 了,所以就返回加锁失败。
客户端 2 处理完共享资源后,要释放锁,将 lock_key 重新设置为 0。
但这种加锁和释放方式存在不小的问题。通过这种方式加锁包含了三个操作(读取锁变量、判断锁变量的值、把锁变量设为 1)在这个过程中,如果有两个服务同时取到锁值为0后,同时加锁,会存在抢占,不知道到底是哪个加锁的,很难保证并发状态下的原子性。
加锁原子性保证
正是由于 redis 单线程的特点,所以会串行执行请求。遵循先来先加锁的串行特点。所以单点redis本身就可以保证加锁操作的原子性。
有没有一种命令可以把这三步合为一步呢?有!
原生的SETNX
命令会在 key 不存在时创建,key 存在时不做任何操作,返回设置失败。该命令本身保证了原子性,把读取和设置值两步过程合为一步,不需要再判断锁的值。
SETNX lock_key 1
正是因为SETNX命令在key 存在时不做任何操作,返回设置失败的特点,所以使用 DEL 命令来删除锁变量,便于下次执行时正常创建锁。
该方案就是最完美的了吗?
这里有两个问题,这也是我面试的时候遇到的灵魂拷问:
问题一、假如某客户端成功加锁后服务挂了,没能释放锁,这个锁就被一直占用着,其它客户端也拿不到锁了。
问题二、假如客户端1加锁,但是客户端2用DEL把锁释放了,又可以被其他客户端抢占导致数据不一致。
高频面试问题
为了问题一、需要给锁变量设置一个过期时间。这样一来,即使持有锁的客户端发生了异常,无法主动的释放锁,Redis 也会根据锁变量的过期时间把它删除。其它客户端在锁变量过期后,就可以重新进行加锁操作了。
对于问题二、需要能区分来自不同客户端的锁操作。给每个客户端生成一个唯一标记值,在进行加锁时,我们把锁变量赋值成这个唯一值。这样在释放锁的时候,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,在相等的情况下,才能释放锁。
下面来看一下如何在 Redis 中进行实现。我们可以使用 SET 加 EX/PX 和 NX 选项,来进行加锁操作。
SET lock_key uuid NX PX 100
其中lock_key是锁变量,uuid表示客户端的唯一标识,*PX 100表示 100ms 过期。由于我们在释放锁时需要对比客户端的标识和锁变量的值是否一致,这包含了多个操作,为了保证原子性,我们需要使用 lua 脚本,下面是 lua 脚本的实现。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end