Redis分布式锁实现

Redis分布式锁实现

一、分布式锁

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

MySQL Redis Zookeeper
互斥 利用MySQL本身的互斥锁机制 利用setnx互斥命令 利用节点的唯一性和有序性实现互斥
高可用
高性能 一般 一般
安全性 断开连接,自动释放锁 利用锁超时时间,到期释放 临时节点,断开连接自动释放

二、 基于Redis的分布式锁

2.1 初级版本

实现基于Redis的分布式锁需要实现两个基本的方法:

获取锁

互斥:确保只有一个线程获取锁

// 添加锁,利用setnx的互斥特性
setnx lock thread1
// 添加锁过期时间,避免服务器宕机引起的死锁
expire lock 10
// 添加锁,NX是互斥,EX是设置超时时间
set lock thread1 NX EX 10

释放锁

  • 手动释放:删除key即可
  • 超时释放:获取锁时添加一个超时时间
// 释放锁,删除即可
del key

基本逻辑

  • 业务开始时尝试获取锁
    • 获取锁失败,直接返回
    • 获取锁成功,开始执行业务
      • 如果业务超时或服务宕机,自动释放锁
      • 否则执行完业务手动释放锁

存在的问题 :分布式锁误删问题

比如如下的场景:

  • 线程1首先获取锁,正常获取到锁,开始执行自己的业务。
  • 由于某种原因,线程1的业务被阻塞,并且阻塞时间超过了锁的过期时间,导致锁被自动释放
  • 锁被释放后,线程2再来获取锁,因为线程1的锁被释放了,所以线程2也能成功获取到锁,然后开始执行自己的业务。
  • 在线程2执行业务时,线程1的业务完成,线程1手动释放锁,此时,线程1释放的是线程2的锁。
  • 这时候,线程3也来获取锁,因为线程2的锁被线程1释放了,所以线程3也能成功获取到锁,然后开始执行自己的业务。
  • 此时,线程2和线程3是并行执行业务的,会出现错误的结果。

产生原因:

  • 没有唯一的锁标识,所以会产生误删的情况。

image-20240118211030658

2.2 解决分布式锁误删问题

要想解决分布式锁误删的问题,主要的关键思路在于:如何判断是不是自己的锁。

这时就可以使用线程标识 (即线程ID)来当作分布式锁标识:

  • 如果线程标识与当前线程一致,则释放锁;
  • 如果线程标识与当前线程不一致,则不做处理;

处理逻辑

业务开始时尝试获取锁,锁的标识可以是线程ID:

  • 获取锁之前先比较当前线程ID与锁标识是否一致:
    • 不一致,获取锁失败,直接返回
    • 一致,获取锁成功,开始执行业务
      • 如果业务超时或服务宕机,自动释放锁
      • 业务执行完成,比较当前线程ID与锁标识是否一致
        • 一致,手动释放锁
        • 不一致,不做任何处理

存在的问题:分布式锁原子性问题

比如如下的场景:

  • 线程1首先获取锁,正常获取到锁,开始执行自己的业务。
  • 线程1业务执行完毕,需要手动释放锁,获取锁标识并判断是否与自己一致,结果一致,执行手动释放锁的动作,假设此时产生了阻塞,并且阻塞时间超过了锁的过期时间,导致锁被自动释放
  • 锁被释放后,线程2再来获取锁,因为线程1的锁被释放了,所以线程2也能成功获取到锁,然后开始执行自己的业务。
  • 在线程2执行业务过程中,线程1的阻塞结束了,此时继续执行手动释放锁动作,线程1手动释放锁,此时,线程1释放的是线程2的锁。
  • 这时候,线程3也来获取锁,因为线程2的锁被线程1释放了,所以线程3也能成功获取到锁,然后开始执行自己的业务。
  • 此时,线程2和线程3是并行执行业务的,会出现错误的结果。

产生原因:

  • 判断锁标识释放锁是两个操作,没有保证原子性。

image-20240118212709817

2.3 分布式锁的原子性问题

要想解决操作原子性问题,我们可以使用Lua脚本解决多条命令原子性问题。

Redis提供的调用函数,语法如下:

# 执行redis的命令
redis.call('命令名称','key','其他参数',...)

Lua脚本

-- 锁的key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]
-- 比较线程标识与锁中的标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁
return redis.call('del', KEYS[1])
end
return 0

Lua脚本处理逻辑

  • 获取锁中的线程标识
  • 判断是否与当前线程标识一致
    • 一致,手动删除锁
    • 不一致,不做处理

2.4 Redis分布式锁存在的问题

上面的实现逻辑呢,其实就已经能满足简单的分布式锁的功能了,但是由于它是我们自己实现的,所以还是会有一些问题:

(1)不可重入:同一个线程只能获取一次锁,无法多次获取同一把锁,如果我们有嵌套业务都需要用到分布式锁,那么这种Redis实现的锁就不可以使用了。

(2)不可重试:获取锁时如果获取失败就直接返回了,没有重试机制,这种对性能来说其实并不友好。

(3)超时释放:锁超时释放虽然可以避免死锁,但是如何业务执行耗时较长也会导致锁超时释放,存在不可预估的问题。

(4)主从一致性无法保证:如果Redis提供了主从集群,因为主从同步存在延迟,并且一般都是主写从读。如果线程在主节点获取了锁,并且尚未同步给从节点的过程中,突然主节点宕机,虽然Redis会重新选取一个从节点作为新的主节点,但是新主节点中没有锁的标识,所以也会出现多线程并行执行业务的情况(情况出现概率极低)。