如何使用Redis实现分布式锁?

383 阅读4分钟

单机锁和分布式锁

在说分布式锁之前,我们先来看一下单机锁,了解一下两者的区别。 实际上,对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示。比如:

  • 变量值为0时,表示没有线程获取锁。
  • 变量值为1时,表示已经有线程获取到锁了。 伪代码如下,其中lock为锁变量:
function lock(){
    if lock == 0
        lock = 1
        return 1
    else
        return 0
}

function unlock(){
    lock = 0
    return 1
}

类似地,分布式锁也可以用一个变量来表示,获取锁与释放锁的逻辑也一致。但是由于是多个客户端,所以需要有一个共享的存储系统来维护锁变量,以确保每个客户端都可以访问到锁变量。于是加锁与释放锁的动作就可以转化为对共享存储系统中锁变量的读取、判断、设置。

为什么是redis

  1. redis是单进程单线程模式,操作命令都以队列模式依次执行,这样一来并行访问也会被处理为串行访问,且多客户端对Redis的连接并不存在竞争关系。
  2. setNX命令很方便很适用于分布式锁场景,只在键 key 不存在的情况下,将键 key 的值设置为 value。若键key已经存在, 则 SETNX 命令不做任何动作。SET if Not eXists

具体实现

1. 简单粗暴setNX:

假设令redis的key为lock:order_sn:210606001,那么加锁释放锁的步骤大致如下:

  1. 处理客户端A的请求时,执行命令setNX('lock:order_sn:210606001', 'lock'),发现执行成功,获得锁
  2. 处理客户端B的请求时,执行命令setNX('lock:order_sn:210606001', 'lock'),发现执行失败,未获得锁,此时返回失败,无法进行进一步的业务逻辑
  3. A释放锁
  4. B重新请求获取锁,获取成功,可进行下一步的业务处理
if(lock){
    // 加锁成功,执行业务
    // *****
    // 业务执行结束,释放锁
    redis->del(key)
}else{
    // 加锁失败,休眠重试
    usleep(time)
    redis->setNX(key, value)
}

image.png

对于上面这个操作,如果客户端A获取到锁之后,并没有执行成功删除锁逻辑,那么将会导致死锁。所以就有了下面的方案,给key设置过期时间,同时设置过期时间和设置key必须是原子操作,否则的话,在多个请求过来的时候,虽然只有一个人setNx执行成功,但是多个expire命令都能执行成功,这就导致了锁一直有效,还是解决不了问题。

2. 给key设置过期时间

懒得画图了,直接上逻辑代码

if(lock){
    // 加锁成功,执行业务
    // *****
    // 业务执行结束,释放锁
    redis->del(key)
}else{
    // 加锁失败,休眠重试
    usleep(time)
    redis->set(key, value, ['NX', 'EX'=>ttl]) //ttl:键过期时长
}

以上,如果客户端A执行业务还没来得及主动释放锁,锁就已经过期了,这样的话客户端B加锁成功后,客户端A执行完业务删除自己原先设置的key就不是他自身的而是B的锁了。这样一来就误删了别人的锁,破坏了锁的原则,所以应该用请求的唯一token来解锁,解锁前判断是否与key值一致,避免锁过期导致的交叉请求相互解锁。

3. key值设置为唯一token

if(lock){
    // 加锁成功,执行业务
    // *****
    // 业务执行结束,释放锁
    redis->del(key)
}else{
    // 加锁失败,休眠重试
    usleep(time)
    redis->set(key, uuid, ['NX', 'EX'=>ttl]) //ttl:键过期时长
}

到这里,并没有完全解决上面的交叉解锁问题。如果正好判断token是当前值,正要删除锁时,锁已过期,别人已设置成功新值,那删除的还是别人的锁。换句话说,删除锁必须保证原子性,所以就得使用lua脚本来进行删除锁。

4. Lua脚本删除锁

$script = '
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
';

到了这里,上面的问题其实还没有完全解决。我们回过头去看看上面的场景以及解决方案,不难发现还有一种隐患:在第3点和第4点中,我们假设的是当前值与key对应的token一致,如果是不一致的呢,也就是说A获得的锁过期了,而B对应的获取到了锁并且设置了新的token,这样一来就A删除不了锁。所以应该还得考虑锁的续期问题。

5. 锁的续期

锁的自动续期是一件很麻烦的事情,Java那边有redission跟watchdog无需重复造轮子,而PHP的话目前还没一个比较好的解决方案,可能最粗鲁的解决方案是定时任务监听?研究后再补充了。