文章

Redis 实现分布式锁

背景

在单机环境下,Java 中的 synchronized 关键字和 ReentrantLock 可重入锁可以满足我们在并发场景下对锁的需求。但是在单机环境满足不了业务需求的情况下,我们一般会选择分布式部署服务,在分布式环境下,synchronizedReentrantLock 的加锁方法已经失效了,因为它们是本地锁,只能在单个服务内实现锁的功能,于是分布式锁就出现了。

分布式锁场景

避免重复的工作

例如我们有一个定时任务会每天检查用户未支付的订单然后发送短信提醒用户支付,多个实例同时运行着这个任务,如果不做加锁处理,那么就会导致发出了多条相同的信息。

保证数据的正确性

在高并发情况下,如果多个实例同时操作一份资源,就有可能会导致这个资源的最终状态出现异常。例如电商平台的抢购场景,大量用户同时下单,如果不做加锁处理,那么很容易导致最终库存信息异常,发生超卖。

Redis 实现

主要用到三个命令

  • setnx(set if no exist)
    • 它的功能是在 key 不存在的情况下,将键 key 的值设为 value,如果 key 存在,则不做任何操作。设置成功时返回 1,失败返回 0。
  • expire
    • 设置指定 key 的过期时间
  • del
    • 删除指定 key

上述三个命令在实现分布式锁过程中扮演的角色分别是 setnx->加锁;del->释放锁;expire->锁超时。一个加锁释放锁的过程大致是:

  1. setnx resource_key value (申请锁)
  2. expire resource_key 30 (设置超时时间)
  3. do sth.... (执行业务逻辑)
  4. del resource_key (释放锁)

死锁问题

我们在申请到锁之后还为其设置超时时间就是为了防止死锁,如果申请了锁但是又没有显式释放锁,那么就会导致这个资源一直被锁住,其他线程就无法在申请到这个资源的锁。所以需要设置超时自动释放锁来保证不会发生死锁。但是由于 setnx 和 expire 是两个命令,不是同一原子操作,如果 setnx 执行成功,之后由于宕机或者其他原因导致 expire 没有执行,那么同样会造成死锁。在 Redis 2.8 之前我们需要用 lua 脚本来解决这个问题,2.8 之后已经支持 nx 和 ex 操作是同一原子操作。

  • lua 脚本:
1
2
3
4
5
6
7
8
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;

// 使用实例
EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100
  • 合并 nx ex
1
set resource_key value ex 5 nx

提前解锁、误解锁

前面提到为了防止死锁,我们为锁设置了一个超时时间,过期后自动释放锁。但是又带来了两个新问题:提前解锁和误解锁。

  • 提前解锁
    • 线程 A 申请到了锁,并且设置超时时间为 10s,但是线程 A 执行时间超过了 10s,锁过期自动释放了,这就是提前解锁。
  • 误解锁
    • 在提前解锁的前提下,线程 B 随之申请到了锁,但是在线程 B 执行过程中,线程 A 执行完成然后显示释放锁,此时 B 还没有执行完成,A 释放掉的其实 B 的锁,这就是误解锁。

提前解锁和误解锁过程中会出现 A B 两个线程并发操作同一资源的情况,这种情况下就失去了锁的意义,显然是不被允许的,解决这个问题的方法有一般有以下两种:

  1. 设置足够长的过期时间,确保不会出现提前解锁的情况。
  2. 为申请到锁的线程开一个守护线程,负责重置锁的超时时间,确保在线程执行完成之前,锁一直不超时。

对于误解锁的情况,我们可以在申请锁的时候返回一个唯一标识(例如 UUID),在释放锁的时候根据唯一标识来判断是否是当前线程申请的锁。

  • 申请锁
    1
    
    set resource_key UUID ex 5 nx
    
  • 解锁(伪代码)
    1
    2
    3
    4
    
    get resource_key
    if (resource_value == UUID) {
      del resource_key
    }
    

可重入锁

可重入锁是指同一线程在获取到锁之后可以再次获取这个锁。我们可以通过对锁进行重入计数,加锁时+1,解锁时-1,当计数器为 0 时释放锁。利用 Redis 中的 Hash 表就可以很容易实现可重入锁。 结合前面提到的超时时间、提前解锁、误解锁问题,在可重入锁的实现过程中,我们会创建一个 key=resource_key 的 hash 表,然后把锁的唯一标识(UUID)作为 hash 表的一个 key,同时为这个 key 设置超时时间,value 就是加锁次数,解锁时 value-1,当 value=0 时释放锁。这里引用 Redisson 中的 lua 实现脚本

1
2
3
4
5
6
7
8
9
10
11
12
if (redis.call('exists', KEYS[1]) == 0)
then
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
    then redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
return nil;

Redis 集群

在实际应用场景中,为了保证 Redis 的高可用,我们一般会采用主从部署的方式,即有一个主节点和多个从节点,数据会从主节点同步到各个从节点。在这种主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之成为主节点,但是客户端是无感知的。如果线程 A 在主节点上申请到一把锁,然后主节点挂了,这时候数据还没有同步到从节点,也就是说从节点还没有这把锁,那么线程 B 就能申请到新的锁了。A 的锁也就消失了,此时线程 A 的锁就相当于被提前解锁了。 为了解决这个问题 Redis 作者提出了 RedLock 红锁的算法,开源的 Redis 客户端 Redisson 已经对 RedLock 进行了实现,有需要我们可以直接引入使用。

Redisson

Redisson 是知名开源 Redis Java 客户端框架,其中就封装了分布式锁的实现,它实现了java.util.concurrent.locks.Lock 接口,所以我们就可以像使用本地 Lock 一样去使用 Redisson 的 Lock,非常容易上手。

  • 普通锁
1
2
3
4
5
6
7
8
9
10
// 申请锁
RLock lock = redisson.getLock("lock_key");
// 上锁
lock.lock();
// 尝试 5s 加锁,超时时间 10s
lock.tryLock(5, 10, TimeUnit.SECONDS);
// 异步加锁
lock.tryLockAsync();
// 释放锁
lock.unlock();
  • RedLock
1
2
3
4
5
6
7
8
9
10
// 三个 Redis 集群
RLock lock1 = redisson1.getLock("lock_key_1");
RLock lock2 = redisson2.getLock("lock_key_2");
RLock lock3 = redisson3.getLock("lock_key_3");

RedissonRedLock rrl = new RedissonRedLock(lock1, lock2, lock3);

rrl.lock();

rrl.unlock();

总结

在分布式场景下,分布式锁是应对高并发必不可少的工具,使用 Redis 可以方便快速的实现分布式锁,使用 Redis 中的 setnx expire del 就可以快速实现一个简单可用的分布式锁,加上 hash 表 即可以实现可重入锁。对于可能出现的死锁,提前解锁,误解锁,Redis 都有行之有效的解决方法,而对于集群部署下可能产生的问题我们可以使用 RedLock 解决。最后,Redisson 已经封装关于 Redis 锁的实现,不需要重复造轮子了。

本文由作者按照 CC BY 4.0 进行授权