1、Redis分布式锁简介
1.1、关于分布式锁

在一个分布式系统中,当一个线程去读取数据并修改的时候,因为读取和更新保存不是一个原子操作,在并发时就很容易遇到并发问题,进而导致数据的不正确。这种场景很常见,比如电商秒杀活动,库存数量的更新就会遇到。如果是单机应用,直接使用本地锁就可以避免。如果是分布式应用,本地锁派不上用场,这时就需要引入分布式锁来解决。

一般来说,实现分布式锁的方式有以下几种:

  1. 使用 MySQL:这种方式是通过在数据库中创建一个唯一索引的表,然后通过插入一条数据来获取锁,如果插入成功则获取锁成功,否则获取锁失败。释放锁的操作就是删除这条数据。这种方式的优点是实现简单,缺点是性能较低,因为涉及到数据库的操作。
  2. 使用 ZooKeeper:ZooKeeper 提供了一个原生的分布式锁实现。其基本思想是创建一个临时有序节点,然后判断自己是否是所有子节点中序号最小的,如果是则获取锁成功,否则监听比自己序号小的节点,当该节点删除时再次尝试获取锁。这种方式的优点是能够保证公平性,缺点是实现较为复杂。
  3. 使用 Redis:这种方式是通过 Redis 的 SETNX 命令来实现的,这个命令可以在键不存在时设置值,如果键已存在则不做任何操作。通过这个原子操作,我们可以实现在多个节点之间的互斥访问。这种方式的优点是性能高,实现简单,缺点是需要处理锁的超时和续期问题。
1.2、Redis分布式锁概述

在 Redis 中,我们可以使用 SETNX 命令来实现分布式锁。以下是具体的步骤:

  1. 加锁:客户端使用 SETNX key value 命令尝试设置一个键,其中 key 是锁的名称,value 是一个唯一标识符(例如 UUID),用于标识加锁的客户端。如果键不存在,SETNX 命令会设置键的值并返回 1,表示加锁成功;如果键已存在,SETNX 命令不会改变键的值并返回 0,表示加锁失败。

Redis分布式锁及其常见问题解决方案-LMLPHP

  1. 执行业务操作:客户端在成功获取锁后,可以执行需要保护的业务操作。
  2. 解锁:客户端在完成业务操作后,需要释放锁以让其他客户端可以获取锁。为了确保只有加锁的客户端可以解锁,客户端需要先获取锁的值(即唯一标识符),然后比较锁的值和自己的唯一标识符是否相同,如果相同则使用 DEL key 命令删除键以释放锁。

2、Redis分布式锁的问题及解决方案
2.1、锁超时机制

以下是一个基本的 Redis 分布式锁的使用流程:

  1. 客户端 A 发送一个 SETNX lock.key 命令,如果返回 1,那么客户端 A 获得锁。
  2. 客户端 A 执行完毕后,通过 DEL lock.key 命令释放锁。

然而,这种最基本的锁存在一个问题,那就是如果客户端 A 在执行完毕后,因为某些原因(比如崩溃或网络问题)无法发送 DEL 命令来释放锁,那么其他客户端将永远无法获得锁。为了解决这个问题,我们需要引入锁的超时机制。

Redis分布式锁及其常见问题解决方案-LMLPHP

下是一个带有超时机制的 Redis 分布式锁的使用流程:

  1. 客户端 A 发送一个 SETNX lock.key 命令,如果返回 1,那么客户端 A 获得锁。
  2. 客户端 A 通过 EXPIRE lock.key timeout 命令设置锁的超时时间。
  3. 客户端 A 执行完毕后,通过 DEL lock.key 命令释放锁。

这样,即使客户端 A 在执行完毕后无法释放锁,其他客户端也可以在锁超时后获得锁。

2.2、锁续期机制

然而,这种带有超时机制的锁还存在一个问题,那就是如果客户端 A 在锁即将超时时仍在执行,那么锁可能会被其他客户端获得,从而导致多个客户端同时持有锁。为了解决这个问题,我们需要引入锁的续期机制。

Redis分布式锁及其常见问题解决方案-LMLPHP

以下是一个带有续期机制的 Redis 分布式锁的使用流程:

  1. 客户端 A 发送一个 SETNX lock.key 命令,如果返回 1,那么客户端 A 获得锁。
  2. 客户端 A 通过 EXPIRE lock.key timeout 命令设置锁的超时时间。
  3. 客户端 A 在执行过程中,定期通过 EXPIRE lock.key timeout 命令续期锁。
  4. 客户端 A 执行完毕后,通过 DEL lock.key 命令释放锁。

这样,即使客户端 A 的执行时间超过了最初的超时时间,也可以通过续期机制保证锁的互斥性。

2.3、误删锁问题

引入锁的续期机制可以解决锁提前过期的问题,但是并不能解决解锁时可能删除其他线程锁的问题。这是因为,即使有了续期机制,仍然存在这样一种情况:线程 A 在锁即将过期时仍在执行业务逻辑,此时锁过期,线程 B 获取到了锁,然后线程 A 执行完业务逻辑,尝试去删除锁,结果删除的是线程 B 的锁。

为了解决这个问题,我们可以使用 Redis 的 Lua 脚本功能,将这三个操作封装在一个 Lua 脚本中,然后使用 EVAL 命令执行这个 Lua 脚本。由于 Redis 会单线程顺序执行所有命令,因此 EVAL 命令可以保证 Lua 脚本中的操作是原子的。

以下是一个使用 Lua 脚本实现解锁的例子:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

在这个 Lua 脚本中,我们首先使用 get 命令获取锁的值,然后比较锁的值和客户端的唯一标识符,如果相同则使用 del 命令删除锁。

客户端可以使用以下命令执行这个 Lua 脚本:

EVAL script 1 key value

其中,script 是 Lua 脚本的内容,key 是锁的名称,value 是客户端的唯一标识符

2.4、脑裂问题与Redlock

在 Redis 集群中,如果主节点在同步锁到从节点之前挂掉,那么从节点在升级为主节点后可能会误认为锁不存在,从而允许其他客户端获取锁,这就导致了同一把锁被多个客户端同时持有的问题。

为了解决这个问题,我们可以使用 RedLock 算法。RedLock 是 Redis 官方推荐的一种分布式锁实现算法,其基本思想是在多个独立的 Redis 节点上同时尝试获取锁,只有当大多数的 Redis 节点都成功获取到锁时,才认为整个操作成功。

以下是 RedLock 算法的基本步骤:

  1. 获取当前时间,以毫秒为单位。
  2. 依次尝试在所有 Redis 节点上获取锁,每个尝试都有一个固定的超时时间。如果获取锁失败,立即返回,不再尝试其他节点。
  3. 如果成功获取到了大多数的 Redis 节点的锁,并且获取锁的总时间小于锁的有效期,那么整个操作成功。
  4. 如果获取锁的总时间大于锁的有效期,或者没有成功获取到大多数的 Redis 节点的锁,那么在所有 Redis 节点上释放锁。
  5. 如果整个操作成功,那么锁的有效期就是原来的有效期减去获取锁的总时间。

以上就是 RedLock 算法的基本步骤。通过在多个独立的 Redis 节点上同时尝试获取锁,RedLock 算法可以在一定程度上解决主节点挂掉导致的锁丢失问题。然而,需要注意的是,RedLock 算法并不能完全保证锁的安全性,因为在网络分区或者节点时间不同步的情况下,仍然可能出现同一把锁被多个客户端同时持有的问题。因此,在使用 RedLock 算法时,需要根据实际情况进行详细的设计和测试。

2.5、公平性问题

此外,在 Redis 分布式锁的实现中,锁的公平性可能会成为一个问题。所谓公平性,是指当多个客户端同时请求锁时,锁应该被按照请求的顺序分配。然而,由于网络延迟和 Redis 的单线程模型,Redis 分布式锁无法保证公平性。具体来说,当多个客户端同时请求锁时,由于网络延迟,这些请求可能会在不同的时间到达 Redis,而 Redis 会按照请求到达的顺序分配锁,这可能与客户端的请求顺序不同。此外,即使多个请求同时到达 Redis,由于 Redis 的单线程模型,Redis 也只能依次处理这些请求,而处理的顺序可能与客户端的请求顺序不同。

因此,如果你的应用需要公平的分布式锁,你可能需要使用其他的分布式锁实现,例如基于 ZooKeeper 的分布式锁。ZooKeeper 的分布式锁通过在锁的节点下创建顺序临时节点,并通过比较自己的节点是否为最小节点来判断是否获取到锁,从而保证了锁的公平性。


3、Java下Redis分布式锁实现
3.1、Jedis实现

在 Java 中,我们可以使用 Jedis 或 Lettuce 这样的 Redis 客户端库来实现 Redis 分布式锁。以下是一个基本的实现示例:

import redis.clients.jedis.Jedis;

public class RedisLock {
    private Jedis jedis;
    private String lockKey;
    private String lockValue;
    private int expireTime;
    private boolean locked = false;

    public RedisLock(Jedis jedis, String lockKey, int expireTime) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.expireTime = expireTime;
        this.lockValue = Thread.currentThread().getId() + "-" + System.nanoTime();
    }

    public boolean lock() {
        long startTime = System.currentTimeMillis();
        while (true) {
            String result = jedis.set(lockKey, lockValue, "NX", "PX", expireTime);
            if ("OK".equals(result)) {
                locked = true;
                return true;
            }
            // 如果没有获取到锁,需要稍微等待一下再尝试
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 如果尝试获取锁超过了expireTime,那么返回失败
            if (System.currentTimeMillis() - startTime > expireTime) {
                return false;
            }
        }
    }

    public void unlock() {
        if (!locked) {
            return;
        }
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        jedis.eval(script, 1, lockKey, lockValue);
    }
}

在这个示例中,我们使用 set 命令的 NXPX 选项来实现锁的获取和超时设置,使用 Lua 脚本来实现安全的解锁操作。我们还使用了一个 while 循环来不断尝试获取锁,直到成功获取锁或者超过了尝试的时间。

然而,这个示例并没有实现锁的续期机制。为了实现续期机制,我们需要在另一个线程中定期检查锁的剩余时间,如果剩余时间不足,那么就需要使用 expire 命令来重新设置锁的超时时间。这需要更复杂的代码来实现,例如使用 Java 的 ScheduledExecutorService 来定期执行续期操作。

3.2、SpringBoot实现

在 Spring Boot 中,我们可以使用 Redisson 这个 Redis 客户端库来实现 Redis 分布式锁。Redisson 提供了一套丰富的分布式服务,包括分布式锁、分布式集合、分布式队列等,而且 Redisson 已经内置了锁的超时、续期机制,并解决了误删锁问题。

以下是一个基本的使用 Redisson 实现 Redis 分布式锁的示例:

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;

@Component
public class RedissonDistributedLocker {

    private RedissonClient redissonClient;

    @PostConstruct
    public void init() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        redissonClient = Redisson.create(config);
    }

    public void lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        // Wait for 100 seconds and automatically unlock it after 10 seconds
        lock.lock(10, TimeUnit.SECONDS);
    }

    public void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }
}

在这个示例中,我们首先在 init 方法中创建了一个 RedissonClient 实例,然后在 lock 方法中获取了一个 RLock 对象,并调用其 lock 方法来获取锁。在 unlock 方法中,我们同样获取了一个 RLock 对象,并调用其 unlock 方法来释放锁。

需要注意的是,Redisson 的 lock 方法会自动续期,只要持有锁的线程还在运行,锁就会一直被续期,直到线程结束或者显式调用 unlock 方法。因此,我们不需要手动实现续期机制。此外,Redisson 的 unlock 方法会检查当前线程是否持有锁,只有持有锁的线程才能释放锁,这解决了误删锁问题。

09-21 11:17