谈谈基于Redis的分布式锁

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
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脚本


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
相关文章
|
27天前
|
存储 负载均衡 NoSQL
【赵渝强老师】Redis Cluster分布式集群
Redis Cluster是Redis的分布式存储解决方案,通过哈希槽(slot)实现数据分片,支持水平扩展,具备高可用性和负载均衡能力,适用于大规模数据场景。
136 2
|
2月前
|
存储 缓存 NoSQL
Redis核心数据结构与分布式锁实现详解
Redis 是高性能键值数据库,支持多种数据结构,如字符串、列表、集合、哈希、有序集合等,广泛用于缓存、消息队列和实时数据处理。本文详解其核心数据结构及分布式锁实现,帮助开发者提升系统性能与并发控制能力。
|
2月前
|
NoSQL Redis
Lua脚本协助Redis分布式锁实现命令的原子性
利用Lua脚本确保Redis操作的原子性是分布式锁安全性的关键所在,可以大幅减少由于网络分区、客户端故障等导致的锁无法正确释放的情况,从而在分布式系统中保证数据操作的安全性和一致性。在将这些概念应用于生产环境前,建议深入理解Redis事务与Lua脚本的工作原理以及分布式锁的可能问题和解决方案。
117 8
|
4月前
|
数据采集 存储 NoSQL
基于Scrapy-Redis的分布式景点数据爬取与热力图生成
基于Scrapy-Redis的分布式景点数据爬取与热力图生成
291 67
|
3月前
|
缓存 NoSQL 算法
高并发秒杀系统实战(Redis+Lua分布式锁防超卖与库存扣减优化)
秒杀系统面临瞬时高并发、资源竞争和数据一致性挑战。传统方案如数据库锁或应用层锁存在性能瓶颈或分布式问题,而基于Redis的分布式锁与Lua脚本原子操作成为高效解决方案。通过Redis的`SETNX`实现分布式锁,结合Lua脚本完成库存扣减,确保操作原子性并大幅提升性能(QPS从120提升至8,200)。此外,分段库存策略、多级限流及服务降级机制进一步优化系统稳定性。最佳实践包括分层防控、黄金扣减法则与容灾设计,强调根据业务特性灵活组合技术手段以应对高并发场景。
925 7
|
4月前
|
缓存 监控 NoSQL
Redis设计与实现——分布式Redis
Redis Sentinel 和 Cluster 是 Redis 高可用与分布式架构的核心组件。Sentinel 提供主从故障检测与自动切换,通过主观/客观下线判断及 Raft 算法选举领导者完成故障转移,但存在数据一致性和复杂度问题。Cluster 支持数据分片和水平扩展,基于哈希槽分配数据,具备自动故障转移和节点发现机制,适合大规模高并发场景。复制机制包括全量同步和部分同步,通过复制积压缓冲区优化同步效率,但仍面临延迟和资源消耗挑战。两者各有优劣,需根据业务需求选择合适方案。
|
4月前
|
NoSQL 算法 安全
redis分布式锁在高并发场景下的方案设计与性能提升
本文探讨了Redis分布式锁在主从架构下失效的问题及其解决方案。首先通过CAP理论分析,Redis遵循AP原则,导致锁可能失效。针对此问题,提出两种解决方案:Zookeeper分布式锁(追求CP一致性)和Redlock算法(基于多个Redis实例提升可靠性)。文章还讨论了可能遇到的“坑”,如加从节点引发超卖问题、建议Redis节点数为奇数以及持久化策略对锁的影响。最后,从性能优化角度出发,介绍了减少锁粒度和分段锁的策略,并结合实际场景(如下单重复提交、支付与取消订单冲突)展示了分布式锁的应用方法。
318 3
|
4月前
|
存储 NoSQL Java
从扣减库存场景来讲讲redis分布式锁中的那些“坑”
本文从一个简单的库存扣减场景出发,深入分析了高并发下的超卖问题,并逐步优化解决方案。首先通过本地锁解决单机并发问题,但集群环境下失效;接着引入Redis分布式锁,利用SETNX命令实现加锁,但仍存在死锁、锁过期等隐患。文章详细探讨了通过设置唯一标识、续命机制等方法完善锁的可靠性,并最终引出Redisson工具,其内置的锁续命和原子性操作极大简化了分布式锁的实现。最后,作者剖析了Redisson源码,揭示其实现原理,并预告后续关于主从架构下分布式锁的应用与性能优化内容。
224 0
|
缓存 NoSQL Java
为什么分布式一定要有redis?
1、为什么使用redis 分析:博主觉得在项目中使用redis,主要是从两个角度去考虑:性能和并发。当然,redis还具备可以做分布式锁等其他功能,但是如果只是为了分布式锁这些其他功能,完全还有其他中间件(如zookpeer等)代替,并不是非要使用redis。
1445 0
|
机器学习/深度学习 缓存 NoSQL