当前位置: 首页 > 科技观察

如何使用Redis分布式锁来保证万无一失?

时间:2023-03-14 15:00:22 科技观察

作者介绍冷正磊,2018年2月加入去哪儿DBA团队,主要负责票务业务MySQL和Redis数据库的运维管理,以及部分功能的开发。数据库自动化运维平台。对MySQL、Redis运维管理和性能优化有浓厚兴趣,有多年经验。1.背景我们在电商网站上购物时,经常会遇到一些高并发的场景,比如电商APP上经常出现的闪购,限时优惠券的抢购,去哪儿的火车票抢购系统。com等,这些场景有一个共同的特点就是访问量激增。虽然通过限流、异步、队列等方式优化了系统设计,但整体并发度还是比平时高了好几倍。为了避免并发问题,防止库存超卖,给用户提供良好的购物体验,这些系统都会使用锁机制。对于单进程并发场景,可以使用编程语言提供的锁和相应的类库,比如Java中的synchronized语法和ReentrantLock类,来避免并发问题。如果在分布式场景下,要实现不同客户端线程对代码和资源的同步访问,保证多线程下处理共享数据的安全,就需要分布式锁技术。那么什么是分布式锁呢?分布式锁是一种控制分布式系统或不同系统之间共享资源的锁实现。如果不同的系统或者同一系统的不同主机共享某个资源,往往需要互斥来防止相互干扰,保证一致性。一个相对安全的分布式锁一般需要具备以下几个特点:互斥。互斥是锁的基本特性。同时,锁只能由一个线程持有并执行临界区操作。时间到。通过超时释放,可以避免死锁,避免不必要的线程等待和资源浪费,类似于MySQL的InnoDB引擎中的innodblockwait_timeout参数配置。重入。线程可以在持有锁的同时请求再次加锁,防止在线程执行完临界区操作之前锁被释放。高性能和高可用性。加锁和释放锁的过程性能开销应该尽可能低,同时保证高可用,防止分布式锁意外失效。可见,分布式锁的实现并不足以锁定资源,还需要满足一些额外的特性,避免死锁和锁失效等问题。2、分布式锁的实现方式分布式锁的实现方式有很多种。常见的有:Memcached分布式锁使用Memcached的add命令。这个命令是一个原子操作。只有当key不存在时,add才会成功,即线程获得了锁。Zookeeper分布式锁利用Zookeeper的顺序临时节点实现分布式锁和等待队列。ZooKeeper作为一个为分布式应用提供解决方案的框架,提供了一些非常好的特性,比如自动删除ephemeral类型znode的功能,同时ZooKeeper还提供了watch机制,允许在客户端使用分布式锁。就像本地锁:如果锁失败,它会阻塞,直到获得锁。ChubbyGoogle实现的粗粒度分布式锁服务与ZooKeeper有点相似,但也有很多不同。Chubby通过sequencer机制解决了请求延迟导致的锁失效问题。Redis分布式锁是基于Redis单机实现的分布式锁。其实现方式与Memcached类似。使用Redis的SETNX命令,这个命令也是一个原子操作。只有当key不存在时,set才会成功。基于Redis多机实现的分布式锁Redlock是Redis的作者antirez为了规范Redis分布式锁的实现而提出的一种更安全有效的实现机制。本文主要对基于Redis的分布式锁的几种实现方式和存在的问题进行探讨和分析。3、Redis分布式锁使用Redis作为分布式锁。本质上,要达到的目标是一个进程在Redis中占据唯一的“聊天”。当其他进程也想占坑的时候,却发现已经有人蹲在里面了。在那里,你必须放弃或等待,稍后再试。目前基于Redis的分布式锁主要有两种。一种是基于单机,一种是基于Redis多机。锁的核心元素。一、基于Redis单机实现的分布式锁1)使用SETNX指令加锁最简单的方法就是直接使用Redis的SETNX指令。该指令仅在键不存在时将键的值设置为值。如果密钥已经存在,则SETNX命令不执行任何操作。key是锁的唯一标识,可以根据业务需要加锁的资源来命名。例如,某商品在商城限时抢购中被锁定,则key可以设置为lock_resource_id,value可以设置为任意值。资源被使用后,用DEL删除key,释放锁。整个过程如下:显然,这种获取锁的方式很简单,但是也存在一个问题,就是我们上面提到的分布式锁的三大核心要素之一的锁超时问题,即如果获取锁的过程发生在业务逻辑处理过程中,如果出现异常,可能会导致DEL指令无法执行,无法释放锁,资源将永远被锁住。所以,使用SETNX获取锁后,必须给key设置一个过期时间,保证即使不显式释放,一定时间后锁也会自动释放,防止资源被某个人独占很久。由于SETNX不支持设置过期时间,所以需要额外的EXPIRE指令。整个过程是这样的:这样实现的分布式锁还是有一个比较严重的问题。由于SETNX和EXPIRE这两个操作是非原子的,如果进程在执行SETNX时EXPIRE和SETNX之间发生异常,但是EXPIRE没有执行,导致锁变成“不朽”。在这种情况下,可能会出现上面提到的锁超时问题,其他进程无法正常获取锁。2)使用SET扩展指令为了解决SETNX和EXPIRE这两个操作的非原子性问题,可以使用Redis的SET指令的扩展参数,使SETNX和EXPIRE这两个操作原子化执行。整个过程如下:这条SET指令中:NX表示只有lock_resource_id对应的key值不存在时,SET才能成功。保证只有第一个请求的客户端才能获取到锁,其他客户端在锁释放之前无法获取到锁。EX10表示锁10秒后自动失效,业务可根据实际情况设置时间。但是这种方法还是不能完全解决分布式锁超时问题:提前释放锁。如果线程A在加锁和释放锁之间的逻辑执行时间过长(或者线程A在执行过程中被阻塞),导致锁过期时间后释放锁,但是线程A在临界区的逻辑还没有执行完,那么此时线程B可以提前重新获取锁,这样临界区代码就不能严格串行执行了。锁被误删除。如果执行到上述情况的线程A,它并不知道此时持有锁的是线程B,线程A会继续执行DEL指令释放锁,如果线程B在临界区的逻辑有还没有执行完,线程A实际上释放了线程B的锁。为了避免出现上述情况,建议不要在执行时间过长的场景下使用Redis分布式锁。同时,更安全的做法是在执行DEL释放锁之前判断锁,验证当前锁持有者是否是自己。具体实现是加锁时将该值设置为唯一的随机数(或线程ID),释放锁时先判断该随机数是否一致,再进行释放操作,保证其他线程持有的锁不会被释放不会被误释放,除非锁过期被服务端自动释放,整个过程如下:但是判断value和删除key是两个独立的操作,不是原子的,所以这个地方需要lua脚本处理,因为Lua脚本可以保证多条指令的连续原子执行。基于Redis单节点的分布式锁基本完成了,但这并不是一个完美的解决方案,但是也算是比较完整了,因为它并没有彻底解决当前线程执行超时锁后其他线程利用情况的问题提前发布。3)使用Redisson的分布式锁如何解决锁提前释放的问题?可以利用锁的可重入特性,让获取锁的线程启动一个timerdaemon线程,每隔expireTime/3执行一次,检查线程的锁是否存在,如果存在,则重新设置过期时间将锁设置为expireTime,即使用守护线程“更新”锁,防止锁因过期而提前释放。当然,业务需要实现这个daemon进程的逻辑还是比较复杂的,可能会出现一些未知的问题。目前互联网公司在生产环境中广泛使用的开源框架Redisson很好地解决了这个问题。非常简单易用,支持Redis单实例、RedisM-S、RedisSentinel、RedisCluster等多种部署架构。感兴趣的朋友可以参考官方文档或源码:https://github.com/redisson/redisson/wiki实现原理如图(图中以Redis集群为例):2.基于关于Redis多机实现分布式锁Redlock上面基于Redis单机实现的分布式锁其实有个问题,就是加锁的时候只使用一个Redis节点,虽然Redis通过Sentinel保证了高可用,但是由于Redisreplication是异步的,主节点获取锁后没有完成数据同步就进行了故障转移。此时其他客户端上的线程仍然可以获得锁,那么锁的安全性就会丢失。整个过程如下:ClientA从Master节点获取锁。Master节点故障,在主从复制过程中锁对应的key没有同步到Slave节点。Slave升级为Master节点,但是此时Master中没有锁数据。客户端B请求一个新的Master节点,并获取同一个资源对应的锁。有多个客户端同时持有同一个资源的锁,不满足锁的互斥性。正因为如此,在Redis的分布式环境中,Redis的作者antirez提供了RedLock的算法来实现分布式锁。算法大致是这样的:假设有N(N>=5)个Redis节点,这些节点之间完全独立,没有主从复制或者其他集群协调机制,保证N个节点使用相同的方法像在Redis单实例下一样获取和释放锁。在获取锁的过程中,客户端应该执行以下操作:获取当前的Unix时间,单位为毫秒。依次尝试从5个具有相同键和唯一值(例如UUID)的实例获取锁。客户端向Redis请求锁时,应设置网络连接和响应超时时间,该超时时间应小于锁过期时间。比如自动锁过期时间为10秒,超时时间应该在5到50毫秒之间。这样,在服务端Redis已经挂掉的情况下,客户端还在等待响应结果。如果服务器在规定的时间内没有响应,客户端应该尽快尝试向另一个Redis实例请求锁。客户端用当前时间减去开始获取锁的时间(步骤1中记录的时间),得到获取锁所用的时间。当且仅当从最多(N/2+1,此处为3个节点)个Redis节点获取锁,且使用时间小于锁过期时间,才认为加锁成功。如果获得了锁,则key的实际有效时间等于有效时间减去获得锁所用的时间(第3步计算的结果)。如果因为某种原因,获取锁失败(至少有N/2+1个Redis实例没有获取到锁或者获取锁时间超过有效时间),客户端应该解锁所有的Redis实例(使用RedisLua脚本).释放锁的过程比较简单:客户端向所有Redis节点发起释放锁的操作,包括加锁失败的节点,也需要执行释放锁的操作。Antirez在算法描述中特别强调了这一点。为什么是这样?原因是节点锁定成功后返回给客户端的响应包可能会丢失。这种情况在异步通信模型中可能会出现:客户端与服务器通信是正常的,反方向却出现了问题。虽然对于客户端来说,由于响应超时导致加锁失败,但是对于Redis节点来说,成功执行SET命令就意味着加锁成功。所以,在释放锁的时候,客户端也应该向那些当时获取锁失败的Redis节点发起请求。另外,为了防止Redis节点崩溃重启后锁丢失,从而影响锁的安全性,antirez还提出了延迟重启的概念,即某个节点崩溃后,不立即重启,但要等待一段时间再重新启动。要重启,这段时间应该大于锁的有效时间。对Redlock更深入的研究,有兴趣的朋友可以参考官方文档:https://redis.io/topics/distlock4.总结分布式系统设计是为了在复杂性和收益之间取得平衡。安全可靠,也避免过度设计。Redlock确实可以提供更安全的分布式锁,但是也是有代价的,需要更多的Redis节点。在实际业务中,一般使用基于单点的Redis来实现分布式锁就可以满足大部分需求。偶尔会出现数据不一致的情况,可以通过人工干预补充数据来解决。俗话说“技术不够,手工凑”!.