Redis分布式锁

609 阅读8分钟

前言

关于分布式锁的文章已经很多了,这里我只是做下归纳总结,记录自己的理解

什么是分布式锁

分布式锁是分布式系统才会用到的技术,与之相对的是本地锁,比如synchronize,ReentrantLock。如果在分布式系统使用本地锁是不行的,因为分布式系统后端应用服务器往往有多台,比如有服务器A和B,一个请求转发到服务器A,操作一个共享资源,这块代码加了本地锁,那么此时就会被锁住,后续只要是转发到服务器A的请求都得等这个请求处理完成才能释放资源重新获得锁;但服务器B并没有被锁住!所以在服务器A的请求操作共享资源时转发到服务器B的请求因为不受服务器A的锁的限制导致也能操作这个共享资源,这就违背使用锁来达到互斥的目的。所以要使用分布式锁,借助一个外部系统,所有进程都去这个系统获取锁,统一地去将锁管理起来

分布式锁的实现方案

  • 基于数据库实现分布式锁
  • 基于Redis实现分布式锁,有两种
    • SETNX指令
    • Redisson
  • 基于Zookeeper实现分布式锁

这篇文章主要讲redis中使用SETNX指令实现的分布式锁,而Redisson是个客户端,实现了各种锁,并且有个看门狗机制可以一直监听锁来决定是否给锁续期。redisson方式可以看这篇文章:分布式锁中的王者方案:Redisson

问题了解

先要知道会遇到什么问题,有逻辑性地去理解,这样才能更好地记住技术知识。先知道会遇到哪些问题,层次地递进地分析问题:

  • redis如何加锁
  • 知道了加锁知道如何解锁吗?
  • 发生意外情况导致无法解锁,锁得不到释放导致死锁了怎么办
  • 知道了如何避免死锁的操作, 如果这种操作来不及执行怎么办?
  • 加了锁之后,释放锁时会不会释放的不是自己的锁?
  • 当保证释放的会是自己的锁之后,如何让查询锁编号和释放锁一起操作?

分析与解决

redis如何加锁?
SETNX 命令表示SET if Not eXists,表示当 key 不存在时,设置 key 的值,存在时,什么都不做。这条指令就使得redis有了互斥能力。Redis 使用该指令实现分布式锁

SETNX <key> <value>  //加锁

当一个进程使用这条指令申请加锁并且加锁成功时,另一个进程若还想加锁就会失败。

如何解锁? 我们已经知道了使用SETNX指令进行加锁,那有加锁就有解锁,锁用完了就要释放,给其他人让出操作共享资源的机会,释放锁的指令如下:

DEL <key>   //释放锁

很简单,直接使用 DEL 命令删除key就行

死锁怎么办 现在我们知道了如何加锁,如何解锁,但世事总有意外发生,当你加了锁,因为意外情况无法释放锁,比如进程中途挂了,这种情况导致锁无法释放,那么共享资源就会被一直锁住,其他进程无法访问这块资源,那这样肯定是不行的。
如何解决呢?
我们知道redis缓存是有过期时间的,就算你不设置缓存过期时间系统也会给过期时间这个字段设为-1表示永远不会过期,而一旦到了过期时间这个缓存就会被删除。
同样地,我们也可以给锁设置一个过期时间,相当于给锁设置一个租期,过期时间一到,无论如何这个锁都会被释放,即使线程逻辑没有执行完也会被释放,这就避免了死锁的问题。

EXPIRE lock 10  // 10s后自动过期

加锁和设置锁过期的原子性 避免死锁的方式是给锁设置过期时间,但加锁与设置过期时间是两个操作,如果你加了锁,却来不及设置锁的过期时间,这就导致只执行了第一个操作没有执行第二个操作,那还是会有死锁的问题。所以要保证设置过期时间的操作一定会执行,事务的四大特性第一条就是原子性,所以要使加锁和设置锁过期时间这两步操作形成一步操作,要么一起执行,要么都不执行。
幸而redis提供了这样一条命令将加锁和设置锁过期时间一起执行:

SET <key> <value> EX <多少秒> NX

这样就实现了操作的原子性,进一步避免了死锁。

如何更好设置锁过期时间,释放别人的锁 但这样还不够完美,设想有这样一种场景:

  1. 客户端1加锁并设置锁过期时间成功,开始操作共享资源

  2. 锁的过期时间到了,但客户端1还没有操作完成,此时锁就会被自动释放

  3. 客户端2获取到释放的这个锁并加锁成功,也开始操作共享资源,此时共享资源被客户端1和客户端2同时操作,违背了互斥性,这两个操作执行任务产生了冲突,这种冲突会体现在数据层面

  4. 客户端1操作共享资源,释放锁,这个锁此时是被客户端2占用的锁。

所以这里就会有两个问题:
(1)客户端1处理任务所需要的时间大于锁设置的过期时间(开锁)
(2)客户端1把其他用户抢占到的锁给主动打开了。

第一个问题的解决方法之前提到的redission就有解决方案,redisson有个看门狗机制,说白了其实就是起一个定时任务,不断去检查锁是否过期,当锁过期而任务还没有执行完时就会自动给锁续期,延长过期时间。
第二个问题的关键点在于打开了别人占用的锁,而没有检查这把锁是否还归自己所有

那么,如何解决锁被别人释放的问题呢?
解决方法是: 设置锁的过期时间时,还需要设置唯一编号;主动删除锁的时候,需要判断锁的编号是否和设置的一致,如果一致,则认为是自己设置的锁,可以进行主动删除。
可以在加锁时设一个UUID:

SET lock $uuid EX 20 NX

之后,在释放锁时,要先判断这把锁是否还归自己持有:

String lockValue = redis.get("lock");
if lockValue == $uuid:
   redis.del("lock")

在上述给锁设置编号的方案中,释放锁的操作有两步: 查询锁的编号,若对得上,则释放锁。
先来设想两种不同的情形:
情形一:
如果在开始查询锁的编号这步操作之前 ,锁就过期自动释放被别的线程抢占,那么之后查询锁的编号自然会发现此时的锁不再是自己持有的那把,那这是没问题的。是这样的,线程a拿到锁设置锁的编号为1,后来自动释放被其他线程b抢占编号被设为2,抢占之后线程a查询这把锁的编号确认发现编号是2不再是1了,自然就明白这把锁不再属于自己。

情形二:
如果在开始查询锁的编号之后,锁就被别的线程抢占。线程a拿到锁设置编号为1执行任务,然后线程a查询锁的编号,这个操作持续了很久,在这查询操作期间锁自动释放被线程b拿到修改了编号为2,但线程a查询锁编号查了好久终于查到为编号1,然后释放锁,但这锁已经被线程b拿到,所以还是释放了别人的锁

之所以会出现情形二这种情况,就是因为线程 a 查询锁和删除锁的逻辑不是原子性的,所以将查询锁和删除锁这两步作为原子操作,查不到锁就不删除锁。

那么怎么做到原子执行呢?这次redis没有指令可以执行这种原子操作,需要用到redis专属脚本:Lua脚本

这种原子执行的lua脚本长这样:

//判断锁的编号,是自己的才释放
if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

在Java项目中,可以用redisTemplate.execute 方法执行这段脚本。

参考链接

我是看了很多篇文章才理解redis分布式锁的实现的,为了让自己理解的更清楚写了这篇笔记,可以看看下面的文章,写的很好。

深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了!
Redis 分布式锁|从青铜到钻石的五种演进方案
分布式锁原来实现起来这么简单