redis系列:分布式锁

1 介绍

这篇博文讲介绍如何一步步构建一个基于Redis的分布式锁。会从最原始的版本开始,然后根据问题进行调整,最后完成一个较为合理的分布式锁。

本篇文章会将分布式锁的实现分为两部分,一个是单机环境,另一个是集群环境下的Redis锁实现。在介绍分布式锁的实现之前,先来了解下分布式锁的一些信息。

2 分布式锁

2.1 什么是分布式锁?

分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰来保证一致性。

2.2 分布式锁需要具备哪些条件

  1. 互斥性:在任意一个时刻,只有一个客户端持有锁。
  2. 无死锁:即便持有锁的客户端崩溃或者其他意外事件,锁仍然可以被获取。
  3. 容错:只要大部分Redis节点都活着,客户端就可以获取和释放锁

2.4 分布式锁的实现有哪些?

  1. 数据库
  2. Memcached(add命令)
  3. Redis(setnx命令)
  4. Zookeeper(临时节点)
  5. 等等

3 单机Redis的分布式锁

3.1 准备工作

3.1.1 定义常量类

public class LockConstants {public static final String OK = "OK";/** NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the key if it already exist. **/public static final String NOT_EXIST = "NX";public static final String EXIST = "XX";/** expx EX|PX, expire time units: EX = seconds; PX = milliseconds **/public static final String SECONDS = "EX";public static final String MILLISECONDS = "PX";private LockConstants() {}
}
复制代码

3.1.2 定义锁的抽象类

抽象类RedisLock实现java.util.concurrent包下的Lock接口,然后对一些方法提供默认实现,子类只需实现lock方法和unlock方法即可。代码如下

public abstract class RedisLock implements Lock {protected Jedis jedis;protected String lockKey;public RedisLock(Jedis jedis,String lockKey) {this(jedis, lockKey);}public void sleepBySencond(int sencond){try {Thread.sleep(sencond*1000);} catch (InterruptedException e) {e.printStackTrace();}}@Overridepublic void lockInterruptibly(){}@Overridepublic Condition newCondition() {return null;}@Overridepublic boolean tryLock() {return false;}@Overridepublic boolean tryLock(long time, TimeUnit unit){return false;}}
复制代码

3.2 最基础的版本1

先来一个最基础的版本,代码如下

public class LockCase1 extends RedisLock {public LockCase1(Jedis jedis, String name) {super(jedis, name);}@Overridepublic void lock() {while(true){String result = jedis.set(lockKey, "value", NOT_EXIST);if(OK.equals(result)){System.out.println(Thread.currentThread().getId()+"加锁成功!");break;}}}@Overridepublic void unlock() {jedis.del(lockKey);}
}
复制代码

LockCase1类提供了lock和unlock方法。
其中lock方法也就是在reids客户端执行如下命令

SET lockKey value NX
复制代码

而unlock方法就是调用DEL命令将键删除。
好了,方法介绍完了。现在来想想这其中会有什么问题?
假设有两个客户端A和B,A获取到分布式的锁。A执行了一会,突然A所在的服务器断电了(或者其他什么的),也就是客户端A挂了。这时出现一个问题,这个锁一直存在,且不会被释放,其他客户端永远获取不到锁。如下示意图

可以通过设置过期时间来解决这个问题

3.3 版本2-设置锁的过期时间

public void lock() {while(true){String result = jedis.set(lockKey, "value", NOT_EXIST,SECONDS,30);if(OK.equals(result)){System.out.println(Thread.currentThread().getId()+"加锁成功!");break;}}
}
复制代码

类似的Redis命令如下

SET lockKey value NX EX 30
复制代码

注:要保证设置过期时间和设置锁具有原子性

这时又出现一个问题,问题出现的步骤如下

  1. 客户端A获取锁成功,过期时间30秒。
  2. 客户端A在某个操作上阻塞了50秒。
  3. 30秒时间到了,锁自动释放了。
  4. 客户端B获取到了对应同一个资源的锁。
  5. 客户端A从阻塞中恢复过来,释放掉了客户端B持有的锁。

示意图如下

这时会有两个问题

  1. 过期时间如何保证大于业务执行时间?
  2. 如何保证锁不会被误删除?

先来解决如何保证锁不会被误删除这个问题。
这个问题可以通过设置value为当前客户端生成的一个随机字符串,且保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。

版本2的完整代码:Github地址

3.4 版本3-设置锁的value

抽象类RedisLock增加lockValue字段,lockValue字段的默认值为UUID随机值假设当前线程ID。

public abstract class RedisLock implements Lock {//...protected String lockValue;public RedisLock(Jedis jedis,String lockKey) {this(jedis, lockKey, UUID.randomUUID().toString()+Thread.currentThread().getId());}public RedisLock(Jedis jedis, String lockKey, String lockValue) {this.jedis = jedis;this.lockKey = lockKey;this.lockValue = lockValue;}//...
}
复制代码

加锁代码

public void lock() {while(true){String result = jedis.set(lockKey, lockValue, NOT_EXIST,SECONDS,30);if(OK.equals(result)){System.out.println(Thread.currentThread().getId()+"加锁成功!");break;}}
}
复制代码

解锁代码

public void unlock() {String lockValue = jedis.get(lockKey);if (lockValue.equals(lockValue)){jedis.del(lockKey);}
}
复制代码

这时看看加锁代码,好像没有什么问题啊。
再来看看解锁的代码,这里的解锁操作包含三步操作:获取值、判断和删除锁。这时你有没有想到在多线程环境下的i++操作?

3.4.1 i++问题

i++操作也可分为三个步骤:读i的值,进行i+1,设置i的值。
如果两个线程同时对i进行i++操作,会出现如下情况

  1. i设置值为0
  2. 线程A读到i的值为0
  3. 线程B也读到i的值为0
  4. 线程A执行了+1操作,将结果值1写入到内存
  5. 线程B执行了+1操作,将结果值1写入到内存
  6. 此时i进行了两次i++操作,但是结果却为1

在多线程环境下有什么方式可以避免这类情况发生?
解决方式有很多种,例如用AtomicInteger、CAS、synchronized等等。
这些解决方式的目的都是要确保i++ 操作的原子性。那么回过头来看看解锁,同理我们也是要确保解锁的原子性。我们可以利用Redis的lua脚本来实现解锁操作的原子性。

版本3的完整代码:Github地址

3.5 版本4-具有原子性的释放锁

lua脚本内容如下

if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
复制代码

这段Lua脚本在执行的时候要把的lockValue作为ARGV[1]的值传进去,把lockKey作为KEYS[1]的值传进去。现在来看看解锁的java代码

public void unlock() {// 使用lua脚本进行原子删除操作String checkAndDelScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +"return redis.call('del', KEYS[1]) " +"else " +"return 0 " +"end";jedis.eval(checkAndDelScript, 1, lockKey, lockValue);
}
复制代码

好了,解锁操作也确保了原子性了,那么是不是单机Redis环境的分布式锁到此就完成了?
别忘了版本2-设置锁的过期时间还有一个,过期时间如何保证大于业务执行时间问题没有解决。

版本4的完整代码:Github地址

3.6 版本5-确保过期时间大于业务执行时间

抽象类RedisLock增加一个boolean类型的属性isOpenExpirationRenewal,用来标识是否开启定时刷新过期时间。
在增加一个scheduleExpirationRenewal方法用于开启刷新过期时间的线程。

public abstract class RedisLock implements Lock {//...protected volatile boolean isOpenExpirationRenewal = true;/*** 开启定时刷新*/protected void scheduleExpirationRenewal(){Thread renewalThread = new Thread(new ExpirationRenewal());renewalThread.start();}/*** 刷新key的过期时间*/private class ExpirationRenewal implements Runnable{@Overridepublic void run() {while (isOpenExpirationRenewal){System.out.println("执行延迟失效时间中...");String checkAndExpireScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +"return redis.call('expire',KEYS[1],ARGV[2]) " +"else " +"return 0 end";jedis.eval(checkAndExpireScript, 1, lockKey, lockValue, "30");//休眠10秒sleepBySencond(10);}}}
}
复制代码

加锁代码在获取锁成功后将isOpenExpirationRenewal置为true,并且调用scheduleExpirationRenewal方法,开启刷新过期时间的线程。

public void lock() {while (true) {String result = jedis.set(lockKey, lockValue, NOT_EXIST, SECONDS, 30);if (OK.equals(result)) {System.out.println("线程id:"+Thread.currentThread().getId() + "加锁成功!时间:"+LocalTime.now());//开启定时刷新过期时间isOpenExpirationRenewal = true;scheduleExpirationRenewal();break;}System.out.println("线程id:"+Thread.currentThread().getId() + "获取锁失败,休眠10秒!时间:"+LocalTime.now());//休眠10秒sleepBySencond(10);}
}
复制代码

解锁代码增加一行代码,将isOpenExpirationRenewal属性置为false,停止刷新过期时间的线程轮询。

public void unlock() {//...isOpenExpirationRenewal = false;
}复制代码

版本5的完整代码:Github地址

3.7 测试

测试代码如下

public void testLockCase5() {//定义线程池ThreadPoolExecutor pool = new ThreadPoolExecutor(0, 10,1, TimeUnit.SECONDS,new SynchronousQueue<>());//添加10个线程获取锁for (int i = 0; i < 10; i++) {pool.submit(() -> {try {Jedis jedis = new Jedis("localhost");LockCase5 lock = new LockCase5(jedis, lockName);lock.lock();//模拟业务执行15秒lock.sleepBySencond(15);lock.unlock();} catch (Exception e){e.printStackTrace();}});}//当线程池中的线程数为0时,退出while (pool.getPoolSize() != 0) {}
}
复制代码

测试结果

或许到这里基于单机Redis环境的分布式就介绍完了。但是使用java的同学有没有发现一个锁的重要特性

那就是锁的重入,那么分布式锁的重入该如何实现呢?这里就留一个坑了

4 集群Redis的分布式锁

在Redis的分布式环境中,Redis 的作者提供了RedLock 的算法来实现一个分布式锁。

4.1 加锁

RedLock算法加锁步骤如下

  1. 获取当前Unix时间,以毫秒为单位。
  2. 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
  3. 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。

4.2 解锁

向所有的Redis实例发送释放锁命令即可,不用关心之前有没有从Redis实例成功获取到锁.


关于RedLock算法,还有一个小插曲,就是Martin Kleppmann 和 RedLock 作者 antirez的对RedLock算法的互怼。 官网原话如下

Martin Kleppmann analyzed Redlock here. I disagree with the analysis and posted my reply to his analysis here.

更多关于RedLock算法这里就不在说明,有兴趣的可以到官网阅读相关文章。

5 总结

这篇文章讲述了一个基于Redis的分布式锁的编写过程及解决问题的思路,但是本篇文章实现的分布式锁并不适合用于生产环境。java环境有 Redisson 可用于生产环境,但是分布式锁还是Zookeeper会比较好一些(可以看Martin Kleppmann 和 RedLock的分析)。

Martin Kleppmann对RedLock的分析:martin.kleppmann.com/2016/02/08/…

RedLock 作者 antirez的回应:antirez.com/news/101

整个项目的地址存放在Github上,有需要的可以看看:Github地址

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/389931.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Qt中的坐标系统

转载&#xff1a;原野追逐 Qt使用统一的坐标系统来定位窗口部件的位置和大小。 以屏幕的左上角为原点即(0, 0)点&#xff0c;从左向右为x轴正向&#xff0c;从上向下为y轴正向&#xff0c;这整个屏幕的坐标系统就用来定位顶层窗口&#xff1b; 此外&#xff0c;窗口内部也有自己…

预测股票价格 模型_建立有马模型来预测股票价格

预测股票价格 模型前言 (Preface) If you are reading this, it’s most likely because you love to solve puzzles. I’m a very competitive person by nature. The Mt. Everest of puzzles, in my opinion, is trying to find excess returns through active trading in th…

Python 模块 timedatetime

time & datetime 模块 在平常的代码中&#xff0c;我们常常需要与时间打交道。在Python中&#xff0c;与时间处理有关的模块就包括&#xff1a;time&#xff0c;datetime,calendar(很少用&#xff0c;不讲)&#xff0c;下面分别来介绍。 在开始之前&#xff0c;首先要说明几…

大数模板Java

import java.util.*; import java.math.BigInteger; public class Main{public static void main(String args[]){Scanner cinnew Scanner(System.in);BigInteger a,b;acin.nextBigInteger();bcin.nextBigInteger();System.out.println(a.add(b));//加法System.out.println(a.…

柠檬工会_工会经营者

柠檬工会Hey guys! This week we’ll be going over some ways to work with result sets in MySQL. These result sets are the outputs of your everyday queries, such as:大家好&#xff01; 本周&#xff0c;我们将介绍一些在MySQL中处理结果集的方法。 这些结果集是您日常…

229. 求众数 II

229. 求众数 II 给定一个大小为 n 的整数数组&#xff0c;找出其中所有出现超过 ⌊ n/3 ⌋ 次的元素。 示例 1&#xff1a;输入&#xff1a;[3,2,3] 输出&#xff1a;[3]示例 2&#xff1a;输入&#xff1a;nums [1] 输出&#xff1a;[1]示例 3&#xff1a;输入&#xff1a;…

写给Java开发者看的JavaScript对象机制

帮助面向对象开发者理解关于JavaScript对象机制 本文是以一个熟悉OO语言的开发者视角&#xff0c;来解释JavaScript中的对象。 对于不了解JavaScript 语言&#xff0c;尤其是习惯了OO语言的开发者来说&#xff0c;由于语法上些许的相似会让人产生心理预期&#xff0c;JavaScrip…

Pythonic---------详细讲解

作者&#xff1a;半载流殇 链接&#xff1a;https://zhuanlan.zhihu.com/p/35219750 来源&#xff1a;知乎 著作权归作者所有。商业转载请联系作者获得授权&#xff0c;非商业转载请注明出处。Pythonic&#xff0c;简言之就是以Python这门语言独特的方式写出既简洁又优美的代码…

大数据ab 测试_在真实数据上进行AB测试应用程序

大数据ab 测试Hello Everyone!大家好&#xff01; I am back with another article about Data Science. In this article, I will write about what is A-B testing and how to use it on real life data-set to compare two advertisement methods.我回来了另一篇有关数据科…

492. 构造矩形

492. 构造矩形 作为一位web开发者&#xff0c; 懂得怎样去规划一个页面的尺寸是很重要的。 现给定一个具体的矩形页面面积&#xff0c;你的任务是设计一个长度为 L 和宽度为 W 且满足以下要求的矩形的页面。要求&#xff1a; 你设计的矩形页面必须等于给定的目标面积。 宽度 …

node:爬虫爬取网页图片

前言 周末自己在家闲着没事&#xff0c;刷着微信&#xff0c;玩着手机&#xff0c;发现自己的微信头像该换了&#xff0c;就去网上找了一下头像&#xff0c;看着图片&#xff0c;自己就想着作为一个码农&#xff0c;可以把这些图片都爬取下来做成一个微信小程序&#xff0c;说干…

如何更好的掌握一个知识点_如何成为一个更好的讲故事的人3个关键点

如何更好的掌握一个知识点You’re launching a digital transformation initiative in the middle of the ongoing pandemic. You are pretty excited about this big-ticket investment, which has the potential to solve remote-work challenges that your organization fac…

centos 搭建jenkins+git+maven

gitmavenjenkins持续集成搭建发布人:[李源] 2017-12-08 04:33:37 一、搭建说明 系统&#xff1a;centos 6.5 jdk&#xff1a;1.8.0_144 jenkins&#xff1a;jenkins-2.93-1.1 git&#xff1a;git-2.9.0 maven&#xff1a;Maven 3.3.9 二、部署 2.1、jdk安装 1&#xff09;下…

638. 大礼包

638. 大礼包 在 LeetCode 商店中&#xff0c; 有 n 件在售的物品。每件物品都有对应的价格。然而&#xff0c;也有一些大礼包&#xff0c;每个大礼包以优惠的价格捆绑销售一组物品。 给你一个整数数组 price 表示物品价格&#xff0c;其中 price[i] 是第 i 件物品的价格。另有…

记录一次spark连接mysql遇到的问题

在使用spark连接mysql的过程中报错了&#xff0c;错误如下 08:51:32.495 [main] ERROR - Error loading factory org.apache.calcite.jdbc.CalciteJdbc41Factory java.lang.NoClassDefFoundError: org/apache/calcite/linq4j/QueryProviderat java.lang.ClassLoader.defineCla…

什么事数据科学_如果您想进入数据科学,则必须知道的7件事

什么事数据科学No way. No freaking way to enter data science any time soon…That is exactly what I thought a year back.没门。 很快就不会出现进入数据科学的怪异方式 ……这正是我一年前的想法。 A little bit about my data science story: I am a complete beginner…

python基础03——数据类型string

1. 字符串介绍 在python中&#xff0c;引号中加了引号的字符都被认为是字符串。 1 namejim 2 address"beijing" 3 msg My name is Jim, I am 22 years old! 那单引号、双引号、多引号有什么区别呢&#xff1f; 1) 单双引号木有任何区别&#xff0c;部分情况 需要考虑…

Java基础-基本数据类型

Java中常见的转义字符: 某些字符前面加上\代表了一些特殊含义: \r :return 表示把光标定位到本行行首. \n :next 表示把光标定位到下一行同样的位置. 单独使用在某些平台上会产生不同的效果.通常这两个一起使用,即:\r\n. 表示换行. \t :tab键,长度上相当于四个或者是八个空格 …

季节性时间序列数据分析_如何指导时间序列数据的探索性数据分析

季节性时间序列数据分析为什么要进行探索性数据分析&#xff1f; (Why Exploratory Data Analysis?) You might have heard that before proceeding with a machine learning problem it is good to do en end-to-end analysis of the data by carrying a proper exploratory …

TortoiseGit上传项目到GitHub

1. 简介 gitHub是一个面向开源及私有软件项目的托管平台&#xff0c;因为只支持git 作为唯一的版本库格式进行托管&#xff0c;故名gitHub。 2. 准备 2.1 安装git&#xff1a;https://git-scm.com/downloads。无脑安装 2.2 安装TortoiseGit(小乌龟)&#xff1a;https://torto…