使用Redis实现分布式锁

73 阅读9分钟

注:本专栏文章均为本人原创,未经本人授权请勿私自转载,谢谢。

分布式锁用于解决分布式环境下资源的并发争抢,实现资源的同步访问,它主要具备以下特点:

  1. 同一时刻只有一个实例的一个线程拿到分布式锁。
  2. 具备锁的可重入机制。
  3. 高可用、高性能地获取和释放锁。

本文讨论基于 Redis 的分布式锁,它有基于单机和集群的两种实现。

一、基于 Redis 单机的分布式锁

基于 Redis 单机的分布式锁一般是使用 setnx + expire 命令来实现,可参考 Redission 中的 RLock 实现。以下是笔者基于 Redis 单机的分布式锁的简易实现:

既然是分布式锁,就需要考虑高并发下的锁竞争,简易版本的 lock 方法实现都是直接使用 while 循环去尝试获得锁,这在高并发情形下性能极低,应考虑减少线程空转的消耗。 本文的解决方式则是增加了一个本地锁,在获取锁时,先获取本地锁,再获取分布式锁,保证同一时间内同一实例只有一个线程参与竞争分布式锁,其他线程则处于阻塞状态,也可以有效减少线程空转的消耗。

字段定义和构造函数

public class RedisDistributedLock implements DistributedLock {
​
    private static final Logger logger = LoggerFactory.getLogger(RedisDistributedLock.class);
​
    public static final String LOCK_SCRIPT = "if redis.call('setNx',KEYS[1],ARGV[1]) then " +
            "if redis.call('get',KEYS[1])==ARGV[1] then " +
            "    return redis.call('expire',KEYS[1],ARGV[2]) " +
            "else return 0 " +
            "end end";
​
    public static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) " +
            "else return 0 end";
​
    // 守护线程中 Timer 的循环周期,此处规定为 10 秒
    private static final int PERIOD_TIME = 10_000;
​
    // 分布式锁名称,同一分布式锁争抢同一个名称
    private final String lockName;
​
    // 加锁所执行的 lua 脚本
    private final DefaultRedisScript<Long> lockScript = new DefaultRedisScript<>(LOCK_SCRIPT, Long.class);
​
    // 解锁所执行的 lua 脚本
    private final DefaultRedisScript<Long> unlockScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
​
    // 保存标记当前线程的 uid,用于支持可重入和避免其它线程解锁,该数据可传递至子线程
    private final InheritableThreadLocal<String> localUid = new InheritableThreadLocal<>();
​
    // 保存线程的 timer,用于启动和停止锁的延时调度,该数据可传递至子线程
    private final InheritableThreadLocal<Timer> localTimer = new InheritableThreadLocal<>();
​
    // 当前实例内的本地全局锁
    private final ReentrantLock localLock = new ReentrantLock();
​
    // Spring 封装的 redis 操作类
    private final StringRedisTemplate redisTemplate;
​
    /**
     * 构造函数
     *
     * @param redisTemplate Redis 操作类
     * @param lockName      分布式锁名称
     */
    public RedisDistributedLock(StringRedisTemplate redisTemplate, String lockName) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
    }
}

获取分布式锁

先获取本地锁,再获取分布式锁。本地锁的存在可以保证同一时间内同一实例只有一个线程参与竞争 Redis 上的分布式锁,其他所有线程则处于阻塞状态,节省系统资源。

对于未抢占到分布式锁但抢占到本地锁的线程,有两种选择:1. 主动放弃本地锁,并重新参与争抢本地锁;2. 不放弃本地锁,一直循环获取分布式锁,直到获取成功。可根据业务需要来决定使用哪种策略,此处使用的策略1。

/**
 * 加锁,未成功阻塞
 */
@Override
public void lock() {
    localLock.lock();
    if (!tryLock()) {
        localLock.unlock();
        lock();
    }
}

尝试获取分布式锁

  1. 生成一个属于当前线程的 uid 和 timer。
  2. 尝试使用 setnx 命令设置 key = LockName、value = uid 键值对,同时设置过期时间为 PeriodTime(使用 lua 脚本将两个操作原子化)。
  3. 若 setnx 成功,则表示获取到分布式锁,与此同时启动一个用于延长锁占用时间的 timer 调度任务,从 PeriodTime / 2 开始,每 PeriodTime 时间对 key = LockName 的键值对执行一次锁延长,最终函数返回 true(注意:expire 的过期时间是从设置时间开始计算的,并不为每次的累加和。expire 的时长一定要大于 timer 的周期才能保证正确地将键的过期时间延长,否则键会在 timer 延长时间之前过期,导致锁的错误释放)。
  4. 若 setnx 失败,则判断 key = LockName 的值是否为当前线程的 uid,若是,则表示锁重入,函数返回 true,否则则表示锁被其他线程占用,函数返回 false。

【讨论】在 tryLock 获取到分布式锁之后,还应再次获取本地锁吗?

  1. 如果使用 tryLock 并发获取分布式锁,在获取分布式锁成功之后再去获取本地锁没有意义。
  2. 如果使用 lock 并发获取分布式锁,获取分布式锁成功的前提就是获取本地锁成功,再次锁定同样没有意义。
  3. 但是如果 tryLock 和 lock 一起并发获取分布式锁的情况,主要分析以下两点:1) 防止本地线程空转带来的消耗。在获取到分布式锁后,若不获取本地锁,则本地所有调用 lock 方法的线程都无法感知已经获取到了锁,不断地重复获取本地锁成功,获取分布式锁失败。2) 无法确保获取到分布式锁的线程一定顺利拿到锁。若 A 抢占到分布式锁,但可能要经历了无数次的本地锁释放 + 尝试抢占分布式锁失败后,才会给 A 拿到本地锁,显然是会造成性能下降的。综上所述,考虑到 tryLock 和 lock 一起并发获取分布式锁也是较少的情况,所以本代码中不再获取本地锁。
/**
 * 尝试加锁,未成功直接返回失败
 *
 * @return 加锁是否成功
 */
@Override
public boolean tryLock() {
    // 初始化当前线程的 ThreadLocal 变量
    localUid.set(UUID.randomUUID().toString());
    localTimer.set(new Timer());
​
    // 执行 setnx 指令,获取分布式锁(也可以使用 redisTemplate.opsForValue().setIfAbsent 方法实现)
    boolean isLockSuccess = Long.valueOf(1).equals(redisTemplate.execute(
            lockScript, Collections.singletonList(lockName), localUid.get(), String.valueOf(PERIOD_TIME)));
    if (isLockSuccess) {
        // localLock.lock(); // 此处不必获取本地锁
        logger.info("分布式锁获取成功,id = {}", localUid.get());
​
        // 获取分布式锁成功后,添加锁的延时调度任务(该调度任务每隔 PERIOD_TIME 将锁延长 2*PERIOD_TIME,这是为了防止
        // 锁失效,设定超时时间为周期的两倍,超时时间只需在一个 PERIOD_TIME 周期中延长成功即可)
        localTimer.get().schedule(new TimerTask() {
            @Override
            public void run() {
                redisTemplate.expire(lockName, PERIOD_TIME * 2, TimeUnit.MILLISECONDS);
                logger.info("分布式锁 id = {} 延长 {} ms", localUid.get(), PERIOD_TIME);
            }
        }, PERIOD_TIME, PERIOD_TIME);
        return true;
    } else {
        // 加锁失败后,判断是锁重入还是锁被其他线程占用
        return localUid.get().equals(redisTemplate.opsForValue().get(lockName));
    }
}

释放分布式锁

  1. 释放本地锁。由于可能存在锁重入,每次调用释放一次本地锁,直到完全释放本地锁,才可以释放。
  2. 判断 key = LockName 的值是否为当前线程的 uid,若是,则执行分布式锁的释放,删除 Redis 中的键值(使用 lua 脚本将两个操作原子化),否则抛出 IOException,由外部程序来保证锁错误释放时的回滚逻辑。
  3. 释放 timer。
  4. 手动清空 ThreadLocal 变量(非必须,因为每次申请锁定时都会重置 ThreadLocal 变量,但手动清空是一种好习惯,也可避免以后因为没有清空所导致的一些脏数据 Bug)。
  5. 若 key = LockName 的值不是当前线程的 uid,说明 timer 虽然启动了,但是并未成功延长锁的持续时间,导致锁仍被释放了。这常见于客户端产生的一些错误(例如 GC 导致的长时间 Stop The World)。此时的处理方式应为抛出异常,由外部程序决定如何回滚。
/**
 * 释放分布式锁
 */
@Override
public void unlock() throws IOException {
    // 释放一次本地锁,本地锁重入次数等于分布式锁的重入次数
    localLock.unlock();
    // 若本地锁重入次数全部释放完成,则释放分布式锁
    if (!localLock.isHeldByCurrentThread()) {
        boolean isUnlockSuccess = Long.valueOf(1).equals(redisTemplate.execute(
                unlockScript, Collections.singletonList(lockName), localUid.get()));
        if (!isUnlockSuccess) {
            throw new IOException("当前线程的分布式锁可能已过期");
        }
        // 释放 timer
        localTimer.get().cancel();
        // 手动清空 ThreadLocal
        if (localTimer.get() != null) {
            localTimer.remove();
        }
        logger.info("分布式锁释放成功,id = {}", localUid.get());
        if (localUid.get() != null) {
            localUid.remove();
        }
    }
}

测试分布式锁

@RestController
public class TestController {
​
    private static final Logger logger = LoggerFactory.getLogger(TestController.class);
​
    @Autowired
    private StringRedisTemplate redisTemplate;
​
    private RedisDistributedLock distributedLock;
​
    private static String PRODUCT_KEY = "iPhone_stock";
​
    @PostConstruct
    public void init() {
    // 初始库存设置为 10000
        distributedLock = new RedisDistributedLock(redisTemplate, "MyDistributedLock");
        redisTemplate.opsForValue().set(PRODUCT_KEY, String.valueOf(10000));
    }
​
    @GetMapping("decrease")
    public String decrease(Integer count) {
        // 使用多线程模拟并发扣减库存
        for (int i = 0; i < count; i++) {
            new Thread(() -> {
                distributedLock.lock();
                try {
                    Integer stock = Integer.valueOf(redisTemplate.opsForValue().get(PRODUCT_KEY));
                    Integer after = stock - 1;
                    // Thread.sleep(11000); // 测试锁延时
                    redisTemplate.opsForValue().set(PRODUCT_KEY, String.valueOf(after));
                    logger.debug(String.format("库存由 %05d 变成 %05d", stock, after));
                } finally {
                    try {
                        distributedLock.unlock();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
        return "success";
    }
}

访问 http://localhost:8080/decrease?count=1000,查看控制台日志。

最终结果比较长,这里就不放出了,可以看到库存扣减指令被串行化了,最终剩余库存为 9000。

二、基于 Redis 集群的分布式锁

怎样设计基于 Redis 集群的分布式锁?

对于 Redis 的单机模式来说,使用上述的 setnx 分布式锁一般不会出现什么问题。但单机模式的可用性不高,只要机器宕机,就直接无法对外提供服务

对于 Redis 高可用集群模式来说,其主从复制采用的是异步复制策略,只要在主节点上 setnx 设值成功就会返回获取锁成功,若此时该值还未同步到从节点,主节点挂了,从节点被选举为主节点,就会导致锁丢失。

从 CAP 理论的角度来理解的话,就是因为 Redis 的高可用集群是 AP 集群,主要保证的是可用性,放弃了数据的强一致性,只保证最终一致性。而为了确保同一时间只有一个线程获得到锁,分布式锁所要求的必须是强一致性的集群,也就是类似 Zookeeper 的 CP 集群

RedLock 是一个基于 Redis 集群实现的分布式锁,它对集群的要求是这样的:

集群中所有的 N 个 Redis 节点都是相互独立的 master 节点,且不使用分布式协调方案,相互之间没有直接通信行为

在该集群的所有节点上尝试获取锁,当超过一半返回成功时,则分布式锁获取成功。也就是说,当集群中还有至少 (N/2)+1 个节点可正常提供服务时,就可以正常地获取分布式锁,否则集群就会处于不可用状态,没有任何线程可以获取到分布式锁。

要说明的一点是,这种全为主节点的集群是为了保证 CP 而建立的,如果业务也需要 redis 高可用,那只能部署两个 redis 集群,一个高可用集群用于业务,一个全主集群仅用于分布式锁。

以下为 RedLock 分布式锁的主要流程:

  1. 记录当前时间 t1,精确到毫秒。
  2. 使用单机锁在该集群的所有节点上获取锁,每个请求的超时时间远远小于锁的过期时间 te,若中间某节点请求超时或无响应,则跳过该节点,并立即向下一节点发送锁请求。
  3. 当超过一半返回加锁成功时,并且当前时间 t2 满足 t2 - t1 < te 时,分布式锁获取成功。此时锁的实际超时时间 tr 为 tr = te - (t2 - t1)。
  4. 若未超过一半返回加锁成功,或时间超出锁的过期时间时,则向所有节点发送释放锁请求,释放所有已被当前线程获得锁的 Redis 锁。

【讨论】为什么要按照顺序获取每个节点的锁? 由于 Redis 内部使用的是 IO 多路复用模型,其对于客户端请求是顺序执行的。当 A、B、C 三个客户端同时申请节点 1 的锁,但 A 执行 setnx 拿到了锁先返回,而 B、C 在 A 返回后才会执行 setnx,此时会加锁失败;那么在之后的所有中仍是 A 先获取锁,B、C 一直跟在 A 的后边。除去网络抖动的情况,基本可以保证先加锁的节点是会一直先加锁的,这也会使集群更快地达到一致,即确定分布式锁的归属。但若是并发获取每个节点的锁,那么访问每个节点的先后顺序就不确定了,从概率学上来说,同时抢夺锁的 A、B、C 三个线程,最大概率的事件是每个线程获得三分之一节点上的锁,此时每个都不大于节点数的一半,会在释放锁后再重新加锁,然后可能仍都不足三分之一,这样循环往复,反而增加了集群达到一致所需的时间。

以下为 Ression 中 RedissonRedLock 的测试代码:

private RedissonClient createRedisClient(String address) {
    Config config = new Config();
    config.useSingleServer().setAddress(address);
    return Redisson.create(config);
}
  
@Test
public void clusterDistributeLockTest() {
  // 注意:redisClient1、redisClient2、redisClient3 都为单机 Redis 节点
    RedissonClient redisClient1 = createRedisClient("redis://redis.kanra.com:6379");
    RedissonClient redisClient2 = createRedisClient("redis://redis.kanra.com:6380");
    RedissonClient redisClient3 = createRedisClient("redis://redis.kanra.com:6381");
    RLock lock1 = redisClient1.getLock("lock1");
    RLock lock2 = redisClient2.getLock("lock2");
    RLock lock3 = redisClient3.getLock("lock3");
  
  // 获取集群的分布式锁
    RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
    lock.lock();
    lock.unlock();
}