剖析分布式锁

栏目: 数据库 · 发布时间: 6年前

内容简介:我们不生产代码,我们是代码的搬运工前不久,阿里大牛虾总再次抛出了分布式锁的讨论,对照之前项目中实现的redis分布式锁总结一下天才是1%的灵感,加上99%的汗水;编程是1%的编码,加上99%的在Google/StackOverflow/Github上找代码 残酷的现实是,找来的代码可能深藏bug,而不知

我们不生产代码,我们是代码的搬运工

前不久,阿里大牛虾总再次抛出了分布式锁的讨论,对照之前项目中实现的 redis 分布式锁总结一下

天才是1%的灵感,加上99%的汗水;编程是1%的编码,加上99%的在Google/StackOverflow/Github上找代码 残酷的现实是,找来的代码可能深藏bug,而不知

剖析分布式锁

在多核多线程环境中,通过锁机制,在某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性

怎么样才是把好锁?

可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。 这把锁要是一把可重入锁(避免死锁) 支持阻塞和非阻塞:和 ReentrantLock 一样支持 lock 和 trylock 以及 tryLock(long timeOut) 这把锁最好是一把公平锁(根据业务需求考虑要不要这条) 有高可用的获取锁和释放锁功能 获取锁和释放锁的性能要好

分布式锁三要素

  1. 外部存储

    分布式锁是在分布式部署环境中给多个主机提供锁服务,需要另外的存储载体

  2. 全局唯一标识

    在多线程环境中,锁可以使一个对象引用,也可以是变量,都有唯一的标识来区分锁保护的不同资源; 在分布式环境下,也需要,比如对某一特定用户资源操作,业务+userId即可唯一标识

  3. 至少有两种状态,获取和释放

    锁至少需要两种状态:加锁(lock)和解锁(unlock)。 用状态区分当前尝试获取的锁是否已经被其他操作占用, 被占用只有等待锁释放后才能尝试获取锁并加锁,保护共享资源

实现

理论知识知道得再多,还得落地才行;只要遵从三要素,就能打造一把好锁,不要拘泥于某一种工具。

网上有很多实现方式,主要是”外部存储“使用了不同的组件,比如数据库,redis,zk,由于这些组件各自特性的不同,实现复杂度各有不同

这儿主要说下在实际工作中使用到的两种方式,数据库与redis

数据库

数据库,任何系统都需要的组件,常规手法,都是使用version来实现乐观锁

version

剖析分布式锁

比如A、B操作员同时读取一余额为1000元的账户,A操作员为该账户增加100元,B操作员同时为该账户扣除50元,A先提交,B后提交。最后实际账户余额为1000-50=950元,但本该为1000+100-50=1050。这就是典型的并发问题

假设数据库中帐户信息表中有一个version字段,当前值为1;而当前帐户余额字段(balance)为1000元。假设操作员A先更新完,操作员B后更新。 a、操作员A此时将其读出(version=1),并从其帐户余额中增加100(1000+100=1100)。 b、在操作员A操作的过程中,操作员B也读入此用户信息(version=1),并从其帐户余额中扣除50(1000-50=950)。 c、操作员A完成了修改工作,将数据版本号加一(version=2),连同帐户增加后余额(balance=1100),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录version更新为2。 d、操作员B完成了操作,也将版本号加一(version=2)试图向数据库提交数据(balance=950),但此时比对数据库记录版本时发现,操作员B提交的数据版本号为2,数据库记录当前版本也为2,不满足 “提交版本必须大于记录当前版本才能执行更新 “的乐观锁策略,因此,操作员B的提交被驳回。 这样,就避免了操作员B用基于version=1的旧数据修改的结果覆盖操作员A的操作结果的可能。

version简单,除了对业务数据表有侵入性,还有一些场景是胜任不了

比如,在操作一个数量之前,需要确认一下能不能操作

这儿操作了多张表,此时就需要再配合事务,才能保证原子性

redis

由于db性能的限制,而redis性能卓越,很多时候会选择redis实现方式

怎么使用redis正确地实现分布式锁,需要了解两方面

  1. 实现分布式锁时,使用到的redis命令

  2. 网上示例可能都有毒

redis命令

setnx命令(『SET if Not eXists』(如果不存在,则 SET)的简写): 设置成功,返回 1 设置失败,返回 0 该命令是原子操作

getset命令: 自动将key对应到value并且返回原来key对应的value。如果key存在但是对应的value不是字符串,就返回错误。 返回值:返回之前的旧值,如果之前Key不存在将返回nil。 该命令是原子操作。

get命令: get 获取key的值,如果存在,则返回;如果不存在,则返回nil;

del命令: del 删除key及key对应的值,如果key不存在,程序忽略

SET命令: set key value [EX seconds] [PX milliseconds] [NX|XX] 将字符串值 value 关联到 key  如果 key 已经持有其他值, SET 就覆写旧值,无视类型。 对于某个原本带有生存时间(TTL)的键来说, 当 SET 命令成功在这个键上执行时, 这个键原有的 TTL 将被清除。

可选参数从 Redis 2.6.12 版本开始,SET 命令的行为可以通过一系列参数来修改:

EX second:设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 

PX millisecond:设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 

NX:只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 

XX:只在键已经存在时,才对键进行设置操作。

示例

原来项目中使用分布式锁,整个逻辑:

  1. setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。

  2. get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。

  3. 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。

  4. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

获取锁

解锁

示例缺陷

特地从多年前的项目中把这段代码找出来,当年写完,心里还挺美

网上有很多资料也是差不多样的,但事实并不那么完美,甚至是错误的

加锁

  • 使用jedis.setnx()和jedis.expire()组合实现加锁

这个问题很明显,setnx与expire不是同一个事务,不俱备原子性;程序崩溃或者网络抖动都会出现死锁问题

  • System.currentTimeMillis()这个需要各个client时间必须一致,一旦不一致,就可能加锁失败

  • getSet()如果锁为了灵活性,会把timeout作为入参

当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖

解锁

  • jedis.del()直接删除

这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的

有种错误改进,增加参数传入requestId

还是原子性的问题如代码注释,问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了

缺陷总结

心里认为本来很简单的事,代码大概:

为了提高性能,通过redis原子性接口SETNX:

  1. 使用SETNX命令获取锁,若返回0(key已存在,锁已存在)则获取失败,反之获取成功

  2. 为了防止获取锁后程序出现异常,导致其他线程/进程调用SETNX命令总是返回0而进入死锁状态,需要为该key设置一个“合理”的过期时间释放锁

  3. 使用DEL命令将锁数据删除

结果为了弥补setnx()与expire()两个接口的原子性问题,引入了一堆问题,外强中干

缺陷修正

加锁

Redis 2.6.12版本后,增强了set()命令

加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time), 这个set()方法一共有五个入参:

  1. 第一个为key,我们使用key来当锁,因为key是唯一的

  2. 第二个为value,我们传的是requestId,通过给value赋值为requestId,就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成

  3. 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;

  4. 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定

  5. 第五个为time,与第四个参数相呼应,代表key的过期时间

高可用:

  1. set()加入了NX参数,可以保证如果已有key存在,则不会调用成功,也就是只有一个客户端能持有锁,满足互斥性

  2. 由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁

  3. 将value赋值为requestId,代表加锁的客户端请求标识,那么在解锁的时候就可以进行校验是否是同一个客户端,防止锁交叉

解锁

首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)

使用eval()配置 lua 保证原子性

在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令

有效时间

为什么需要一个有效时间呢?主要就是防止死锁

疑难

  • 执行业务代码操作共享资源的时间大于设置锁的过期时间?

客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是10s,那么接口超时大概设置5-50ms

【虽然能解决问题,但时间设置成了难点,微服务中多少接口,而且接口的timeout都是可配置的,不能每次调整接口timeout时,还是考虑一下锁的timeout】

  • GC的STW

    剖析分布式锁

客户端1获得了锁,正准备处理共享资源的时候,发生了Full GC直到锁过期。这样,客户端2又获得了锁,开始处理共享资源。在客户端2处理的时候,客户端1 Full GC完成,也开始处理共享资源,这样就出现了2个客户端都在处理共享资源的情况

续命丸

引入锁续约机制,也就是获取锁之后,释放锁之前,会定时进行锁续约,比如以3min间隔周期进行锁续约

这样如果应用重启了,最多3min等待时间,不会因为时间太长导致的死锁问题,也不会因为时间太短导致被其他线程抢占的问题,也就是锁分布式锁不需要设置过期时间,过期时间对于这个锁来说是滑动的

Redission

虾总给了总结性阐述:

首先启动Daemon线程,一直循环检测所有的分布式key,异步递延分布锁的过期时间,只要在处理业务逻辑,就递延分布锁过期时间3min。 每次添加分布式锁key,同时会生成一个uuid token,定义一个ConcurrentHashMap构造一个全局map维护所有的分布式key,上面Daemon线程会遍历这个map,每次解锁需要比对这个token,token一致才能解锁。 这样以来如果应用重启了,最多会有3min等待时间,不会导致时间太长导致的死锁问题,也不会因为时间太短导致的被其他线程抢占的问题,也就是锁分布式锁不需要设置过期时间,过期时间对于这个锁来说是滑动的

跟随虾总思路,找到了一个开源组件:Redisson

Redisson是一个在Redis的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。

相对于平时使用的jedis,redission进行比较高的抽象

redission中的lock主要是RLock接口,继承的juc的Lock接口

剖析分布式锁 剖析分布式锁

Lock

先看lock(),有两种形式,一个不带leaseTime,一个带leaseTime

边看源码,边解释

两个方法共用了lockInterruptibly()

  1. 尝试获取锁tryAcquire

  2. 获取失败,订阅此channel的消息(订阅的意义,在解锁时就会发现)

  3. 进入循环,不停的尝试获取锁,其中使用了JUC的Semaphore

  4. 一旦获取成功,则跳出循环

  5. 取消订阅

尝试获取锁tryAcquire里面会用到两个核心方法tryAcquireAsync(),tryLockInnerAsync()

  • 1.根据锁的持续时间不同,处理也不同

  • 2.没有设置持续时间,那就是阻塞型,一直等待

    • 2.1.为了防止业务方法执行时间超过锁timeout,则定时续约scheduleExpirationRenewal()

  • 3.设置了持续时间,则不需要进行续约

  1. 以internalLockLeaseTime/3间隔时间,定时续约

  2. 如果当前client自身有并发时,通过putIfAbsent保证只有一个task

  3. 续约:当lock存在时,使用pexpire设置过期时间

  • 1.lockname不存在

    • 1.1.hset(lockname,uuid+threadid,1),value=uuid+threadid,有uuid可以区分各个client,有threadid区分各个线程,这样锁就具备了可重入性

    • 1.2.pexpire设置过期时间,防止client挂掉,造成死锁

  • 2.lockname存在

    • 2.1.hexists(lockname,uuid+threadid),这样保证了是同一个锁在同一个client

    • 2.2.hincrby 再次进锁,计数器+1

    • 2.3.pexpire 再次设置超时

  • 3.lockname存在,并且不在同一client

    • 3.1.pttl 返回剩余有效时长

unLock

  1. 从方法名看,虽然对外好像是直接解锁,但内部是异步执行的

  2. unlockInnerAsync()进行解锁

  3. 从expirationRenewalMap移除,并把task.cancel()

  1. lockname不存在,说明已经解锁,publish channelname unlockmessage;return 1

  2. lockname存在,但对于uuid+id不存在,说明不是加锁的client,return nil

  3. lockname存在,并且是当前加锁client

  4. 对lockname uuid+id进行-1,如果counter>0则走5,如果=0 则走6

  5. counter>0 说明锁重入了,计数器-1,并expire

  6. counter=0 说明最终解锁,直接del key,并publish channelname unlockmessage;return 1

redission缺陷

使用cluster时

一个场景:A在向主机1请求到锁成功后,主机1宕机了。现在从机1a变成了主机。但是数据没有同步,从机1a是没有A的锁的。那么B又可以获得一个锁。这样就会造成数据错误。

redlock主要思想就是做数据冗余。建立5台独立的集群,当我们发送一个数据的时候,要保证3台(n/2+1)以上的机器接受成功才算成功,否则重试或报错

redlock实现会更复杂,但从他的算法上看,有zk选举的味道。对于更高可用分布锁,可以借助zk本身特性去实现

总结

对于锁,主要考虑 性能与安全 ,即要保持锁的活跃性,又得保证锁的安全性

分布式锁,除了以上两点,还要考虑实现时的三要素

对于redission,对于锁部分的源码,还有很多的内容,很多的细节需要挖掘,此篇就不写了,太长。

后面再结合JUC,写篇更详细的源码分析

参考资料

Redis分布式锁的正确实现方式

redission


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Visual Thinking

Visual Thinking

Colin Ware / Morgan Kaufmann / 2008-4-18 / USD 49.95

Increasingly, designers need to present information in ways that aid their audiences thinking process. Fortunately, results from the relatively new science of human visual perception provide valuable ......一起来看看 《Visual Thinking》 这本书的介绍吧!

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具