Redis-分布式锁

简介

在系统中修改已有数据时,需要先读取,然后进行修改保存,此时很容易遇到并发问题。
由于修改和保存都不是原子操作,在并发场景下,部分对数据的操作可能会丢失。
所以在单服务器系统我们常用本地锁来避免并发带来的问题,然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。


实现

Redis 锁主要利用 Redis 的 setnx 命令。

  • 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
  • 解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
  • 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。

伪代码如下:

1
2
3
4
5
6
7
8
if (setnx(key, 1) == true){
expire(key, 30)
try {
//TODO 业务逻辑
} finally {
del(key)
}
}

问题

SETNX 和 EXPIRE 非原子性操作

如果 SETNX 成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间就会导致变成死锁。
1

  1. 在 Redis 2.8 版本之前可以通过使用 lua 脚本解决。
    1
    2
    3
    4
    5
    6
    7
    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
  2. 但是在 Redis 2.8 版本之后 Redis 支持 NX 和 EX 操作是同一原子操作。
    1
    set resourceName value ex 5 nx

锁误解除

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

  1. 释放锁时判断释放锁的线程是否为加锁时的线程
    通过在 value 中设置当前加锁线程的标识,在删除之前验证 value 判断锁是否为当前线程持有。
    可生成一个 UUID 标识当前线程,使用 lua 脚本做验证标识和解锁操作。
    1
    2
    3
    4
    5
    6
    7
    8
    // 加锁
    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 并发执行。
3

线程 A 和 B 并发执行肯定是不会被允许的。

  1. 过期时间设置足够长,确保代码逻辑在释放锁前能够执行完成。

  2. 为锁的过期时间设置保护线程,在将要过期时为未释放的锁增加有效时间。
    4

不可重入

当线程在持有锁的情况下再次加锁,如果一个锁支持一个线程多次加锁,则这个锁是可重入的。另外相反不支持多次加锁,则该锁是不可重入的。
Redis 通过对锁进行重入次数计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。

  1. 在本地进行重入次数计数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    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);
    }
    }
  2. 考虑到过期时间和成本问题,建议在 Redis Map 数据结构中实现分布式锁,既存锁的标识也对重入次数计数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 如果 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 的可用性,一般采取主从方式部署。
主从同步数据有同步和异步两种方式。Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边和主节点反馈数据同步情况。
在包含主从模式的集群部署方式中,当主节点挂掉时,从节点就会取而代之,但客户端没有明显感知。当客户端 A 成功加锁,指令还未同步到从节点,此时主节点挂掉,从节点提升为主节点,新的主节点还没有锁的数据,当客户端 B 加锁就会成功。
5

集群脑裂

集群脑裂是因为网络问题,导致 Redis master 节点和 slave 节点 和 sentinel 集群处于不同的网络分区。
因为 sentinel 集群无法感知到 master 节点的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。
当客户端连接不同的 master 节点时,两个客户端可以同时拥有同一把锁。
6


总结

Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要数据库的防并发手段。

在中间件的使用中应该注重多实践,多总结。


参考

分布式锁的实现之 redis 篇
漫画:什么是分布式锁?
Redis 分布式锁的正确实现方式( Java 版 )


个人备注

此博客内容均为作者学习所做笔记,侵删!
若转作其他用途,请注明来源!