Redis 分布式锁

411 阅读8分钟

我们在系统中修改已有数据时,需要先读取,然后进行修改保存,此时很容易遇到并发问题。

由于修改和保存不是原子操作,在并发场景下,部分对数据的操作可能会丢失。

在单服务器系统我们常用本地锁来避免并发带来的问题,然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。

整体大纲

image.png

什么是锁?

锁是一种常用的并发控制机制,用于保证一项资源在任何时候只能被一个线程使用,如果其他线程也要使用同样的资源,必须排队等待上一个线程使用完。

就相当于给资源加了一扇门,获取到锁的线程可以进入门里面使用资源,获取不到锁的线程在门外排队等候。

什么是分布式锁?

提起锁我们会想到 Java 中的 synchronizedReentrantLock,但它们只适合在单应用中使用,在多应用中就不适合使用了,多应用就是现在非常流行的分布式环境。可以把 Java 中的 synchronizedReentrantLock 称为本地锁。

分布式锁就是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用

image-20220128143613186

怎么实现分布式锁?

分布式锁比较常见的实现方式有三种

  1. Memcached 实现的分布式锁:使用 add 命令,添加成功的情况下,表示创建分布式锁成功。
  2. ZooKeeper 实现的分布式锁:使用 ZooKeeper 顺序临时节点来实现分布式锁。
  3. Redis 实现的分布式锁。

Redis 分布式锁的实现思路是使用 setnx(set if not exists),如果创建成功则表明此锁创建成功,否则代表这个锁已经被占用创建失败。

分布式锁实现

127.0.0.1:6379> setnx lock true
(integer) 1 # 创建锁成功
# 逻辑业务处理...
127.0.0.1:6379> del lock
(integer) 1 # 释放锁

如果在锁未被删除之前,其他程序再来执行 setnx 是不会创建成功的。

setnx 的问题

如果此程序在创建了锁之后,程序异常退出了,那么这个锁将永远不会被释放,就造成了死锁的问题

我们可以使用 expire key seconds 设置超时时间,即使出现程序中途崩溃的情况,超过超时时间之后,这个锁也会解除,不会出现死锁的情况了。

但这样依然会有问题,因为命令 setnxexpire 处理是一前一后非原子性的,因此如果在它们执行之间,出现断电和 Redis 异常退出的情况,因为超时时间未设置,依然会造成死锁。

img

可以使用 LUA 脚本来解决这个问题。示例:

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

带参数的 Set

因为 setnxexpire 前后操作存在原子性的问题,这个问题到 Redis 2.6.12 时得到了解决,因为这个版本可以使用 set 并设置超时和非空判定等参数了。

127.0.0.1:6379> set lock true ex 30 nx
OK # 创建锁成功
127.0.0.1:6379> set lock true ex 30 nx
(nil) # 在锁被占用的时候,企图获取锁失败

ex n 为设置超时时间,nx 为元素非空判断,用来判断是否能正常使用锁的。

分布式锁的问题

执行中的极端问题,和释放锁极端问题,我们依旧要考虑

锁误解除

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。

img

通过在 value 中设置当前线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是当前线程持有。可生成一个 UUID 标识当前线程,使用 LUA 脚本做验证标识和解锁操作。

// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");

SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
else return 0
end

超时解锁导致并发

如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。

img

A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:

  • 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成
  • 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间

img

不可重入

当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。

如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。

在本地记录记录重入次数,如 Java 中使用 ThreadLocal 进行重入次数统计,简单示例代码:

private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.containsKey(key)) {
    lockers.put(key, lockers.get(key) + 1);
    return true;
  } else {
    if (SET key uuid NX EX 30) {
      lockers.put(key, 1);
      return true;
    }
  }
  return false;
}
// 解锁
public void unlock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.getOrDefault(key, 0) <= 1) {
    lockers.remove(key);
    DEL key
  } else {
    lockers.put(key, lockers.get(key) - 1);
  }
}

本地记录重入次数虽然高效,但如果考虑到过期时间和本地、Redis 一致性的问题,就会增加代码的复杂性。另一种方式是 Redis Map 数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。Redission 加锁示例:

// 如果 lock_key 不存在
if (redis.call('exists', KEYS[1]) == 0)
then
    // 设置 lock_key 线程标识 1 进行加锁
    redis.call('hset', KEYS[1], ARGV[2], 1);
    // 设置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识
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 redis.call('pttl', KEYS[1]);

无法等待锁释放

上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。

  • 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
  • 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。如下:

img

集群

主备切换

为了保证 Redis 的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。

在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。

img

集群脑裂

集群脑裂指因为网络问题,导致 Redis master 节点跟 slave 节点和 sentinel 集群处于不同的网络分区,因为 sentinel 集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。Redis Cluster 集群部署方式同理。

当不同的客户端连接不同的 master 节点时,两个客户端可以同时拥有同一把锁。如下:

img

代码实战

import org.apache.commons.lang3.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import utils.JedisUtils;

import java.util.Collections;
public class LockExample {
    static final String _LOCKKEY = "REDISLOCK"; // 锁 key
    static final String _FLAGID = "UUID:6379";  // 标识(UUID)
    static final Integer _TimeOut = 90;     // 最大超时时间

    public static void main(String[] args) {
        Jedis jedis = JedisUtils.getJedis();
        // 加锁
        boolean lockResult = lock(jedis, _LOCKKEY, _FLAGID, _TimeOut);
        // 逻辑业务处理
        if (lockResult) {
            System.out.println("加锁成功");
        } else {
            System.out.println("加锁失败");
        }
        // 手动释放锁
        if (unLock(jedis, _LOCKKEY, _FLAGID)) {
            System.out.println("锁释放成功");
        } else {
            System.out.println("锁释放成功");
        }
    }
    /**
     * @param jedis       Redis 客户端
     * @param key         锁名称
     * @param flagId      锁标识(锁值),用于标识锁的归属
     * @param secondsTime 最大超时时间
     * @return
     */
    public static boolean lock(Jedis jedis, String key, String flagId, Integer secondsTime) {
        SetParams params = new SetParams();
        params.ex(secondsTime);
        params.nx();
        String res = jedis.set(key, flagId, params);
        if (StringUtils.isNotBlank(res) && res.equals("OK"))
            return true;
        return false;
    }
    /**
     * 释放分布式锁
     * @param jedis   Redis 客户端
     * @param lockKey 锁的 key
     * @param flagId  锁归属标识
     * @return 是否释放成功
     */
    public static boolean unLock(Jedis jedis, String lockKey, String flagId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(flagId));
        if ("1L".equals(result)) { // 判断执行结果
            return true;
        }
        return false;
    }
}

参考

分布式锁的实现之 redis 篇

分布式锁中的王者方案-Redisson

尚硅谷--周阳--分布式锁