谈谈基于Redis的分布式锁

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 谈谈基于Redis的分布式锁

前言

在我们没有了解分布式锁前,使用最多的就是线程锁和进程锁,但他们仅能满足在单机jvm或者同一个操作系统下,才能有效。跨jvm系统,无法满足。因此就产生了分布式锁,完成锁的工作。

分布式锁是一种用于在分布式系统中实现同步和互斥访问的机制。在分布式系统中,多个节点同时访问共享资源可能会导致数据不一致或竞争条件的发生。分布式锁提供了一种保护共享资源的方式,以确保在任意时刻只有一个节点可以访问该资源。

本文将会带你梳理基于redis分布式锁的设计演化,让你对分布式锁不再恐惧,简简单单拿捏它,让你跟别人聊的时候,做到侃侃而谈,有条不紊。

基本介绍

一个好的分布式锁应该满足以下条件

  • 互斥性:任意时刻,只能有一个客户端才能获取锁
  • 防止死锁: 分布式锁应该设计成在锁的持有者异常退出或崩溃时能够自动释放,以防止死锁的发生。一般通过设置合适的锁超时时间来避免死锁。
  • 高可用性: 在节点故障时也能正常工作,确保锁的可靠性。
  • 可重入性:允许同一个线程或客户端在持有锁的情况下多次获取同一个锁,而不会出现死锁或阻塞的情况。这对于递归函数调用等场景尤其重要。
  • 唯一标识: 分布式锁应该具备唯一的标识,以便客户端可以识别和管理不同的锁。

我们实现分布式锁借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。如果同时有多个客户端发 送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)。最基础的实现流程:

  • 多个客户端同时获取锁(setnx)
  • 获取成功,执行业务逻辑,执行完成释放锁(del)
  • 其他客户端等待重试

演化过程

防死锁

原因:我们试想一个场景,假如客户端拿到了锁,但在执行业务流程的过程中,发生了宕机,这个时候业务没有执行完成,拿到的锁也是无法释放的,导致其他客户端线程一直在阻塞,无法获取到锁。

解决:给锁添加过期时间,如果发生了宕机,让它主动释放锁。

给锁设置过期时间,自动释放锁。 设置过期时间两种方式:

1. 通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)

2. 使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)

防误删

原因:给锁上了过期时间以后,如果设置这个过期时间过短了,就可能会出现误删的情况,比如,一个业务需要执行5s,但是锁的过期时间为3s,首先线程1获得了锁,3s后锁过期自动释放,3s后被另外一个线程拿到了锁,在第5s时,线程1执行业务完成,进行锁的释放,这个时候就会把线程2的锁的释放掉了。(当然,这个设置的过期时间本身就不合理的,按照道理来说设置的过期时间应大于业务的执行时间,如果不确定,后面会提到自动续期解决这个问题)。

解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁,删除的时候需要满足原子性,即判断跟删除是原子的,可以通过lua脚本实现。

如果不是原子的话,就可能出现以下问题:

  • index1执行删除时,查询到的lock值确实和uuid相等
  • index1执行删除前,lock刚好过期时间已到,被redis自动释放
  • index2获取了lock
  • index1执行删除,此时会把index2的lock删除

脚本如下:

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

自动续期

原因:在使用 redis 分布式锁时,为避免持有锁的使用方因为异常状况导致无法正常解锁,进而引发死锁问题,我们可以使用到 redis 的数据过期时间 expire 机制,这种 expire 机制的使用会引入一个新的问题——过期时间不精准,因为此处设置的过期时间只能是一个经验值(通常情况下偏于保守),既然是经验值,那就做不到百分之百的严谨性。

试想假如占有锁的使用方在业务处理流程中因为一些异常的耗时(如 IO、GC等),导致业务逻辑处理时间超过了预设的过期时间,就会导致锁被提前释放. 此时在原使用方的视角中,锁仍然持有在自己手中,但在实际情况下,锁数据已经被删除,其他取锁方可能取锁成功,于是就可能引起一把锁同时被多个使用方占用的问题,锁的基本性质——独占性遭到破坏。

解决:原生的redis可以Timer定时器 + lua脚本实现锁的自动续期(也就是另起一个线程开启一个定时任务,不断的判断锁的过期时间,如果快到了进行自动续期即可,同时对redis)当然也可以采用redission框架中的看门狗:

  • 在执行 redis 分布式锁的上锁操作时,通过 setNEX 指令完成锁数据的设置,携带了一个默认的锁数据过期时间
  • 确认上锁成功后,异步启动一个 watchDog 守护协程,按照锁默认过期时间 1/4 ~ 1/3 的节奏(可自由设置),持续地对锁数据进行 expire 续期操作
  • 在解锁成功后,会负责关闭 watchDog,回收协程资源.(由于看门狗续期操作会先检查锁的所有权再延期数据,因此实际上使用方只要删除了锁数据,续期操作就不会生效了. 回收看门狗协程是为了规避协程泄漏问题)

需要锁续期的情况:

  1. 长时间任务: 如果获取分布式锁的业务逻辑较为复杂或耗时,那么可能需要设置锁续期,以防止持有锁的客户端在执行业务逻辑时由于各种原因无法及时释放锁。
  2. 业务处理时间不确定: 如果业务处理时间不确定,无法预测锁会持有多长时间,那么设置锁续期可以确保在业务逻辑执行期间锁不会过早地被释放。

不需要锁续期的情况:

  1. 短时间任务: 如果获取分布式锁的业务逻辑非常简单且耗时很短,可以在执行完业务逻辑后立即释放锁,不需要设置锁续期。
  2. 业务逻辑可控: 如果业务逻辑可以控制在一个较短的时间内完成,且不会出现无法释放锁的情况,也可能不需要设置锁续期。

可重入

原因:加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加 锁,将会加锁失败。当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的子任务代码,需要的这把锁就是我们现在拥有的这把锁,锁明明是被我们拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己。

解决:当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。可以使用redis中的Hash数据类型完成。

利用 lua 脚本判断逻辑:

加锁:

if (redis.call('exists', KEYS[1]) == 0 or 
    redis.call('hexists', KEYS[1], ARGV[1]) == 1) 
then
    redis.call('hincrby', KEYS[1], ARGV[1], 1);
    redis.call('expire', KEYS[1], ARGV[2]);
    return 1;
else
 return 0;
end

假设值为:KEYS:[lock], ARGV[uuid, expire]如果锁不存在或者这是自己的锁,就通过hincrby(不存在就新增并加1,存在就加1)获取锁或者锁次数加1。

解锁:

if(redis.call('hexists', KEYS[1], ARGV[1]) == 0) then 
    return nil; 
elseif(redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then 
    return 0; 
else 
    redis.call('del', KEYS[1]); 
    return 1; 
end;

判断 hash set 可重入 key 的值是否等于 0

  • 如果为 nil 代表 自己的锁已不存在,在尝试解其他线程的锁,解锁失败
  • 如果为 0 代表 可重入次数被减 1
  • 如果为 1 代表 该可重入 key 解锁成功

主从一致  

原因:当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。

解决: 可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。

如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁

总结

独占排他:setnx

防死锁:

  •        redis客户端程序获取到锁之后,立马宕机。给锁添加过期时间
  •        不可重入:可重入

防误删:先判断是否自己的锁才能删除

原子性:

  •        加锁和过期时间之间
  •        判断和释放锁之间

可重入性:hash + lua脚本

自动续期:Timer定时器 + lua脚本


相关文章
|
2月前
|
存储 负载均衡 NoSQL
【赵渝强老师】Redis Cluster分布式集群
Redis Cluster是Redis的分布式存储解决方案,通过哈希槽(slot)实现数据分片,支持水平扩展,具备高可用性和负载均衡能力,适用于大规模数据场景。
205 2
|
2月前
|
存储 缓存 NoSQL
【📕分布式锁通关指南 12】源码剖析redisson如何利用Redis数据结构实现Semaphore和CountDownLatch
本文解析 Redisson 如何通过 Redis 实现分布式信号量(RSemaphore)与倒数闩(RCountDownLatch),利用 Lua 脚本与原子操作保障分布式环境下的同步控制,帮助开发者更好地理解其原理与应用。
111 0
|
3月前
|
存储 缓存 NoSQL
Redis核心数据结构与分布式锁实现详解
Redis 是高性能键值数据库,支持多种数据结构,如字符串、列表、集合、哈希、有序集合等,广泛用于缓存、消息队列和实时数据处理。本文详解其核心数据结构及分布式锁实现,帮助开发者提升系统性能与并发控制能力。
|
26天前
|
NoSQL Java 调度
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
分布式锁是分布式系统中用于同步多节点访问共享资源的机制,防止并发操作带来的冲突。本文介绍了基于Spring Boot和Redis实现分布式锁的技术方案,涵盖锁的获取与释放、Redis配置、服务调度及多实例运行等内容,通过Docker Compose搭建环境,验证了锁的有效性与互斥特性。
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
|
20天前
|
缓存 NoSQL 关系型数据库
Redis缓存和分布式锁
Redis 是一种高性能的键值存储系统,广泛用于缓存、消息队列和内存数据库。其典型应用包括缓解关系型数据库压力,通过缓存热点数据提高查询效率,支持高并发访问。此外,Redis 还可用于实现分布式锁,解决分布式系统中的资源竞争问题。文章还探讨了缓存的更新策略、缓存穿透与雪崩的解决方案,以及 Redlock 算法等关键技术。
|
3月前
|
NoSQL Redis
Lua脚本协助Redis分布式锁实现命令的原子性
利用Lua脚本确保Redis操作的原子性是分布式锁安全性的关键所在,可以大幅减少由于网络分区、客户端故障等导致的锁无法正确释放的情况,从而在分布式系统中保证数据操作的安全性和一致性。在将这些概念应用于生产环境前,建议深入理解Redis事务与Lua脚本的工作原理以及分布式锁的可能问题和解决方案。
134 8
|
4月前
|
缓存 NoSQL 算法
高并发秒杀系统实战(Redis+Lua分布式锁防超卖与库存扣减优化)
秒杀系统面临瞬时高并发、资源竞争和数据一致性挑战。传统方案如数据库锁或应用层锁存在性能瓶颈或分布式问题,而基于Redis的分布式锁与Lua脚本原子操作成为高效解决方案。通过Redis的`SETNX`实现分布式锁,结合Lua脚本完成库存扣减,确保操作原子性并大幅提升性能(QPS从120提升至8,200)。此外,分段库存策略、多级限流及服务降级机制进一步优化系统稳定性。最佳实践包括分层防控、黄金扣减法则与容灾设计,强调根据业务特性灵活组合技术手段以应对高并发场景。
1064 7
|
NoSQL Redis 数据库
用redis实现分布式锁时容易踩的5个坑
云栖号资讯:【点击查看更多行业资讯】在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来! 近有不少小伙伴投入短视频赛道,也出现不少第三方数据商,为大家提供抖音爬虫数据。 小伙伴们有没有好奇过,这些数据是如何获取的,普通技术小白能否也拥有自己的抖音爬虫呢? 本文会全面解密抖音爬虫的幕后原理,不需要任何编程知识,还请耐心阅读。
用redis实现分布式锁时容易踩的5个坑
|
NoSQL Java 关系型数据库
浅谈Redis实现分布式锁
浅谈Redis实现分布式锁
|
存储 canal 缓存

热门文章

最新文章