黄东旭解析 TiDB 的核心优势
1410
2023-06-04
聊聊分布式锁——Redis和Redisson的方式
一、什么是分布式锁
分布式~~锁,要这么念,首先得是『分布式』,然后才是『锁』
分布式:这里的分布式指的是分布式系统,涉及到好多技术和理论,包括CAP 理论、分布式存储、分布式事务、分布式锁...
分布式系统是由一组通过网络进行通信、为了完成共同的任务而协调工作的计算机节点组成的系统。
分布式系统的出现是为了用廉价的、普通的机器完成单个计算机无法完成的计算、存储任务。其目的是利用更多的机器,处理更多的数据。
锁:对对,就是你想的那个,Javer 学的第一个锁应该就是 synchronized
Java 初级面试问题,来拼写下 赛克瑞纳挨日的
从锁的使用场景有来看下边这 3 种锁:
线程锁:synchronized 是用在方法或代码块中的,我们把它叫『线程锁』,线程锁的实现其实是靠线程之间共享内存实现的,说白了就是内存中的一个整型数,有空闲、上锁这类状态,比如 synchronized 是在对象头中的 Mark Word 有个锁状态标志,Lock 的实现类大部分都有个叫 volatile int state 的共享变量来做状态标志。
进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过 synchronized 等线程锁实现进程锁。比如说,我们的同一个 linux 服务器,部署了好几个 Java 项目,有可能同时访问或操作服务器上的相同数据,这就需要进程锁,一般可以用『文件锁』来达到进程互斥。
分布式锁:随着用户越来越多,我们上了好多服务器,原本有个定时给客户发邮件的任务,如果不加以控制的话,到点后每台机器跑一次任务,客户就会收到 N 条邮件,这就需要通过分布式锁来互斥了。
书面解释:分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰来保证一致性。
知道了什么是分布式锁,接下来就到了技术选型环节
二、分布式锁要怎么搞
要实现一个分布式锁,我们一般选择集群机器都可以操作的外部系统,然后各个机器都去这个外部系统申请锁。
这个外部系统一般需要满足如下要求才能胜任:
互斥:在任意时刻,只能有一个客户端能持有锁。防止死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。所以锁一般要有一个过期时间。独占性:解铃还须系铃人,加锁和解锁必须是同一个客户端,一把锁只能有一把钥匙,客户端自己的锁不能被别人给解开,当然也不能去开别人的锁。容错:外部系统不能太“脆弱”,要保证外部系统的正常运行,客户端才可以加锁和解锁。
我觉得可以这么类比:
好多商贩要租用某个仓库,同一时刻,只能给一个商贩租用,且只能有一把钥匙,还得有固定的“租期”,到期后要回收的,当然最重要的是仓库门不能坏了,要不锁都锁不住。这不就是分布式锁吗?
感慨自己真是个爱技术爱生活的程序猿~~
其实锁,本质上就是用来进行防重操作的(数据一致性),像查询这种幂等操作,就不需要费这劲
直接上结论:
分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于 Redis 的分布式锁;3. 基于 ZooKeeper 的分布式锁。
但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。
想必也有喜欢问为什么的同学,那数据库客观锁怎么就性能不好了?
使用数据库乐观锁,包括主键防重,版本号控制。但是这两种方法各有利弊。
使用主键冲突的策略进行防重,在并发量非常高的情况下对数据库性能会有影响,尤其是应用数据表和主键冲突表在一个库的时候,表现更加明显。还有就是在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象,比较好的办法是在程序中生产主键进行防重。
使用版本号策略
这个策略源于 MySQL 的 MVCC 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 SQL 每次进行判断。
第三趴,编码
三、基于 Redis 的分布式锁
我们默认指定大家用的是 Redis 2.6.12 及更高的版本,就不再去讲 setnx、expire 这种了,直接 set 命令加锁
set key value[expiration EX seconds|PX milliseconds] [NX|XX]
eg:
SET resource_name my_random_value NX PX 30000
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 :只在键已经存在时,才对键进行设置操作。
这条指令的意思:当 key——resource_name 不存在时创建这样的key,设值为 my_random_value,并设置过期时间 30000 毫秒。
别看这干了两件事,因为 Redis 是单线程的,这一条指令不会被打断,所以是原子性的操作。
Redis 实现分布式锁的主要步骤:
指定一个 key 作为锁标记,存入 Redis 中,指定一个 唯一的标识 作为 value。当 key 不存在时才能设置值,确保同一时间只有一个客户端进程获得锁,满足 互斥性 特性。设置一个过期时间,防止因系统异常导致没能删除这个 key,满足 防死锁 特性。当处理完业务之后需要清除这个 key 来释放锁,清除 key 时需要校验 value 值,需要满足 解铃还须系铃人 。
设置一个随机值的意思是在解锁时候判断 key 的值和我们存储的随机数是不是一样,一样的话,才是自己的锁,直接 del 解锁就行。
当然这个两个操作要保证原子性,所以 Redis 给出了一段 lua 脚本(Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。):
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
问题:
我们先抛出两个问题思考:
获取锁时,过期时间要设置多少合适呢?
预估一个合适的时间,其实没那么容易,比如操作资源的时间最慢可能要 10 s,而我们只设置了 5 s 就过期,那就存在锁提前过期的风险。这个问题先记下,我们先看下 Javaer 要怎么在代码中用 Redis 锁。
容错性如何保证呢?
Redis 挂了怎么办,你可能会说上主从、上集群,但也会出现这样的极端情况,当我们上锁后,主节点就挂了,这个时候还没来的急同步到从节点,主从切换后锁还是丢了
带着这两个问题,我们接着看
Redisson 实现代码
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。Redisson 提供了使用 Redis 的最简单和最便捷的方法。Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
redisson 现在已经很强大了,github 的 wiki 也很详细,分布式锁的介绍直接戳 Distributed locks and synchronizers
Redisson 支持单点模式、主从模式、哨兵模式、集群模式,只是配置的不同,我们以单点模式来看下怎么使用,代码很简单,都已经为我们封装好了,直接拿来用就好,详细的demo,我放在了 github: starfish-learn-redisson 上,这里就不一步步来了
RLock lock = redisson.getLock("myLock");
RLock 提供了各种锁方法,我们来解读下这个接口方法,
注:代码为 3.16.2 版本,可以看到继承自 JDK 的 Lock 接口,和 Reddsion 的异步锁接口 RLockAsync(这个我们先不研究)
RLock
public interface RLock extends Lock, RLockAsync { /** * 获取锁的名字 */ String getName(); /** * 这个叫终端锁操作,表示该锁可以被中断 假如A和B同时调这个方法,A获取锁,B为获取锁,那么B线程可以通过 * Thread.currentThread().interrupt(); 方法真正中断该线程 */ void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException; /** * 这个应该是最常用的,尝试获取锁 * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败 * leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完) */ boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException; /** * 锁的有效期设置为 leaseTime,过期后自动失效 * 如果 leaseTime 设置为 -1, 表示不主动过期 */ void lock(long leaseTime, TimeUnit unit); /** * Unlocks the lock independently of its state */ boolean forceUnlock(); /** * 检查是否被另一个线程锁住 */ boolean isLocked(); /** * 检查当前线线程是否持有该锁 */ boolean isHeldByCurrentThread(); /** * 这个就明了了,检查指定线程是否持有锁 */ boolean isHeldByThread(long threadId); /** * 返回当前线程持有锁的次数 */ int getHoldCount(); /** * 返回锁的剩余时间 * @return time in milliseconds * -2 if the lock does not exist. * -1 if the lock exists but has no associated expire. */ long remainTimeToLive(); }
Demo
就是这么简单,Redisson 已经做好了封装,使用起来 so easy,如果使用主从、哨兵、集群这种也只是配置不同。
原理
看源码小 tips,最好是 fork 到自己的仓库,然后拉到本地,边看边注释,然后提交到自己的仓库,也方便之后再看,不想这么麻烦的,也可以直接看我的 Jstarfish/redisson
先看下 RLock 的类关系
跟着源码,可以发现 RedissonLock 是 RLock 的直接实现,也是我们加锁、解锁操作的核心类
加锁
主要的加锁方法就下边这两个,区别也很简单,一个有等待时间,一个没有,所以我们挑个复杂的看(源码包含了另一个的绝大部分)
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException; void lock(long leaseTime, TimeUnit unit);
RedissonLock.tryLock
@Override public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { // 获取等锁的最长时间 long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); //取得当前线程id(判断是否可重入锁的关键) long threadId = Thread.currentThread().getId(); // 【核心点1】尝试获取锁,若返回值为null,则表示已获取到锁,返回的ttl就是key的剩余存活时间 Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl == null) { return true; } // 还可以容忍的等待时长 = 获取锁能容忍的最大等待时长 - 执行完上述操作流程的时间 time -= System.currentTimeMillis() - current; if (time <= 0) { //等不到了,直接返回失败 acquireFailed(waitTime, unit, threadId); return false; } current = System.currentTimeMillis(); /** * 【核心点2】 * 订阅解锁消息 redisson_lock__channel:{$KEY},并通过await方法阻塞等待锁释放,解决了无效的锁申请浪费资源的问题: * 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争 * 当 this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败 * 当 this.await返回true,进入循环尝试获取锁 */ RFuture
接着看注释中提到的 3 个核心点
核心点1-尝试加锁:RedissonLock.tryAcquireAsync
private
异步执行加锁 Lua 脚本:RedissonLock.tryLockInnerAsync
KEYS[1] 就是 Collections.singletonList(getName()),表示分布式锁的key;ARGV[1] 就是internalLockLeaseTime,即锁的租约时间(持有锁的有效时间),默认30s;ARGV[2] 就是getLockName(threadId),是获取锁时set的唯一值 value,即UUID+threadId
看门狗续期:RedissonBaseLock.scheduleExpirationRenewal
核心点2-订阅解锁消息:RedissonLock.subscribe
核心点 3 比较简单,就不说了
解锁
RedissonLock.unlock()
@Override public void unlock() { try { // 获取当前调用解锁操作的线程ID get(unlockAsync(Thread.currentThread().getId())); } catch (RedisException e) { // IllegalMonitorStateException一般是A线程加锁,B线程解锁,内部判断线程状态不一致抛出的 if (e.getCause() instanceof IllegalMonitorStateException) { throw (IllegalMonitorStateException) e.getCause(); } else { throw e; } } }
RedissonBaseLock.unlockAsync
@Override public RFuture
RedissonLock.unlockInnerAsync
// 真正的内部解锁的方法,执行解锁的Lua脚本 protected RFuture
我只列出了一小部分代码,更多的内容还是得自己动手
从源码中,我们可以看到 Redisson 帮我们解决了抛出的第一个问题:失效时间设置多长时间为好?
Redisson 提供了看门狗,每获得一个锁时,只设置一个很短的超时时间,同时起一个线程在每次快要到超时时间时去刷新锁的超时时间。在释放锁的同时结束这个线程。
但是没有解决节点挂掉,丢失锁的问题,接着来~
四、RedLock
我们上边介绍的分布式锁,在某些极端情况下仍然是有缺陷的
客户端长时间内阻塞导致锁失效
客户端 1 得到了锁,因为网络问题或者 GC 等原因导致长时间阻塞,然后业务程序还没执行完锁就过期了,这时候客户端 2 也能正常拿到锁,可能会导致线程安全的问题。
Redis 服务器时钟漂移
如果 Redis 服务器的机器时间发生了向前跳跃,就会导致这个 key 过早超时失效,比如说客户端 1 拿到锁后,key 还没有到过期时间,但是 Redis 服务器的时间比客户端快了 2 分钟,导致 key 提前就失效了,这时候,如果客户端 1 还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题。
单点实例安全问题
如果 Redis 是单机模式的,如果挂了的话,那所有的客户端都获取不到锁了,假设你是主从模式,但 Redis 的主从同步是异步进行的,如果 Redis 主宕机了,这个时候从机并没有同步到这一把锁,那么机器 B 再次申请的时候就会再次申请到这把锁,这也是问题
为了解决这些个问题 Redis 作者提出了 RedLock 红锁的算法,在 Redission 中也对 RedLock 进行了实现。
Redis 官网对 redLock 算法的介绍大致如下:The Redlock algorithm
在分布式版本的算法里我们假设我们有 N 个 Redis master 节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调机制。之前我们已经描述了在 Redis 单实例下怎么安全地获取和释放锁。我们确保将在每(N) 个实例上使用此方法获取和释放锁。在我们的例子里面我们设置 N=5,这是一个比较合理的设置,所以我们需要在 5 台机器或者虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。为了取到锁,客户端应该执行以下操作:
获取当前 Unix 时间,以毫秒为单位。
依次尝试从 5 个实例,使用相同的 key 和具有唯一性的 value(例如UUID)获取锁。当向 Redis 请求获取锁时,客户端应该设置一个尝试从某个 Reids 实例获取锁的最大等待时间(超过这个时间,则立马询问下一个实例),这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁。
客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁消耗的时间。当且仅当从大多数(N/2+1,这里是3个节点)的 Redis 节点都取到锁,并且使用的总耗时小于锁失效时间时,锁才算获取成功。
如果取到了锁,key 的真正有效时间 = 有效时间(获取锁时设置的 key 的自动超时时间) - 获取锁的总耗时(询问各个 Redis 实例的总耗时之和)(步骤 3 计算的结果)。
如果因为某些原因,最终获取锁失败(即没有在至少 “N/2+1 ”个 Redis 实例取到锁或者“获取锁的总耗时”超过了“有效时间”),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功,这样可以防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
总结下就是:
客户端在多个 Redis 实例上申请加锁,必须保证大多数节点加锁成功
解决容错性问题,部分实例异常,剩下的还能加锁成功
大多数节点加锁的总耗时,要小于锁设置的过期时间
多实例操作,可能存在网络延迟、丢包、超时等问题,所以就算是大多数节点加锁成功,如果加锁的累积耗时超过了锁的过期时间,那有些节点上的锁可能也已经失效了,还是没有意义的
释放锁,要向全部节点发起释放锁请求
如果部分节点加锁成功,但最后由于异常导致大部分节点没加锁成功,就要释放掉所有的,各节点要保持一致
关于 RedLock,两位分布式大佬,Antirez 和 Martin 还进行过一场争论,感兴趣的也可以看看
Config config1 = new Config(); config1.useSingleServer().setAddress("127.0.0.1:6379"); RedissonClient redissonClient1 = Redisson.create(config1); Config config2 = new Config(); config2.useSingleServer().setAddress("127.0.0.1:5378"); RedissonClient redissonClient2 = Redisson.create(config2); Config config3 = new Config(); config3.useSingleServer().setAddress("127.0.0.1:5379"); RedissonClient redissonClient3 = Redisson.create(config3); /** * 获取多个 RLock 对象 */ RLock lock1 = redissonClient1.getLock(lockKey); RLock lock2 = redissonClient2.getLock(lockKey); RLock lock3 = redissonClient3.getLock(lockKey); /** * 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里) */ RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3); try { /** * 4.尝试获取锁 * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败 * leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完) */ boolean res = redLock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { //成功获得锁,在这里处理业务 } } catch (Exception e) { throw new RuntimeException("aquire lock fail"); }finally{ //无论如何, 最后都要解锁 redLock.unlock(); }
最核心的变化就是需要构建多个 RLock ,然后根据多个 RLock 构建成一个 RedissonRedLock,因为 redLock 算法是建立在多个互相独立的 Redis 环境之上的(为了区分可以叫为 Redission node),Redission node 节点既可以是单机模式(single),也可以是主从模式(master/salve),哨兵模式(sentinal),或者集群模式(cluster)。这就意味着,不能跟以往这样只搭建 1个 cluster、或 1个 sentinel 集群,或是1套主从架构就了事了,需要为 RedissonRedLock 额外搭建多几套独立的 Redission 节点。
RedissonMultiLock.tryLock
@Override public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { // try { // return tryLockAsync(waitTime, leaseTime, unit).get(); // } catch (ExecutionException e) { // throw new IllegalStateException(e); // } long newLeaseTime = -1; if (leaseTime != -1) { if (waitTime == -1) { newLeaseTime = unit.toMillis(leaseTime); } else { newLeaseTime = unit.toMillis(waitTime)*2; } } long time = System.currentTimeMillis(); long remainTime = -1; if (waitTime != -1) { remainTime = unit.toMillis(waitTime); } long lockWaitTime = calcLockWaitTime(remainTime); //允许加锁失败节点个数限制(N-(N/2+1)) int failedLocksLimit = failedLocksLimit(); List
参考与感谢
《Redis —— Distributed locks with Redis》
《Redisson —— Distributed locks and synchronizers》
慢谈 Redis 实现分布式锁 以及 Redisson 源码解析
理解Redisson中分布式锁的实现
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。