基于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();
}
} @Override
public void lockInterruptibly(){} @Override
public Condition newCondition() {
return null;
} @Override
public boolean tryLock() {
return false;
} @Override
public 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);
} @Override
public void lock() {
while(true){
String result = jedis.set(lockKey, "value", NOT_EXIST);
if(OK.equals(result)){
System.out.println(Thread.currentThread().getId()+"加锁成功!");
break;
}
}
} @Override
public 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为当前客户端生成的一个随机字符串,且保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。

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.5 版本4-具有原子性的释放锁

lua脚本内容如下

if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 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-设置锁的过期时间还有一个,过期时间如何保证大于业务执行时间问题没有解决。

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{
@Override
public 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;
}

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的同学有没有发现一个锁的重要特性

那就是锁的重入,那么分布式锁的重入该如何实现呢?

可重入锁: 也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。

作用:防止在同一线程中多次获取锁而导致死锁发生。

常见的可重入锁

  • Synchronized关键字:

public class SynchronizedDemo {
class Widget{
public synchronized void doSomething(){
System.out.println("Widget calling doSomething...");
}
} class LoggingWidget extends Widget{
@Override
public synchronized void doSomething() {
System.out.println("LoggingWidget calling doSomething");
super.doSomething();
}
} public static void main(String[] args){
SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
SynchronizedDemo.Widget widget = synchronizedDemo.new LoggingWidget();
widget.doSomething();
}
} 输出结果:
LoggingWidget calling doSomething
Widget calling doSomething...

根据结果,我们可以看到Synchronized关键字是可重入锁。

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实例成功获取到锁.

5 总结

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

基于redis的分布式锁(不适合用于生产环境)的更多相关文章

  1. 基于redis的分布式锁(转)

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

  2. redis系列:基于redis的分布式锁

    一.介绍 这篇博文讲介绍如何一步步构建一个基于Redis的分布式锁.会从最原始的版本开始,然后根据问题进行调整,最后完成一个较为合理的分布式锁. 本篇文章会将分布式锁的实现分为两部分,一个是单机环境, ...

  3. 基于Redis的分布式锁真的安全吗?

    说明: 我前段时间写了一篇用consul实现分布式锁,感觉理解的也不是很好,直到我看到了这2篇写分布式锁的讨论,真的是很佩服作者严谨的态度, 把这种分布式锁研究的这么透彻,作者这种技术态度真的值得我好 ...

  4. 基于 Redis 的分布式锁

    前言 分布式锁在分布式应用中应用广泛,想要搞懂一个新事物首先得了解它的由来,这样才能更加的理解甚至可以举一反三. 首先谈到分布式锁自然也就联想到分布式应用. 在我们将应用拆分为分布式应用之前的单机系统 ...

  5. 基于Redis的分布式锁到底安全吗(下)?

    2017-02-24 自从我写完这个话题的上半部分之后,就感觉头脑中出现了许多细小的声音,久久挥之不去.它们就像是在为了一些鸡毛蒜皮的小事而相互争吵个不停.的确,有关分布式的话题就是这样,琐碎异常,而 ...

  6. 基于Redis的分布式锁和Redlock算法

    1 前言 前面写了4篇Redis底层实现和工程架构相关文章,感兴趣的读者可以回顾一下: Redis面试热点之底层实现篇-1 Redis面试热点之底层实现篇-2 Redis面试热点之工程架构篇-1 Re ...

  7. 身为一枚优秀的程序员必备的基于Redis的分布式锁和Redlock算法

    1 前言 今天开始来和大家一起学习一下Redis实际应用篇,会写几个Redis的常见应用. 在我看来Redis最为典型的应用就是作为分布式缓存系统,其他的一些应用本质上并不是杀手锏功能,是基于Redi ...

  8. 基于redis 实现分布式锁的方案

    在电商项目中,经常有秒杀这样的活动促销,在并发访问下,很容易出现上述问题.如果在库存操作上,加锁就可以避免库存卖超的问题.分布式锁使分布式系统之间同步访问共享资源的一种方式 基于redis实现分布式锁 ...

  9. 基于redis的分布式锁

    <?php /** * 基于redis的分布式锁 * * 参考开源代码: * http://nleach.com/post/31299575840/redis-mutex-in-php * * ...

随机推荐

  1. QQ的成功,远没有你想象的那么顺利和轻松

    本文来自公众号“傅老师”(ID:fustory)的原创分享,感谢作者. 1.引言 如果QQ是一个人,看似风光,其实从出生到成长,过程饱经错荡,堪算坎坷.它的人生历程确实也够励志的了.   学习交流: ...

  2. 细说MySQL表操作

    目录 语法 查看表结构 查看所有的表 删除表 查看创建表的语句 修改表的字符集和校验规则 修改表名 在表中添加新字段 修改表的字段名 修改表中字段的属性 删除表中的某个字段 设置某个字段为主键 把某个 ...

  3. 微信小程序信息展示列表

    微信小程序信息展示列表 效果展示: 代码展示: wxml <view class="head"> <view class="head_item" ...

  4. Java IO 导入导出Excel表格

    1.将excel导入到内存 1. 调用工作簿Workbook的静态方法getWorkbook(),获得工作簿Workbook对象 InputStream in = new FileInputStrea ...

  5. visual Studio 2017 扩展开发(三)《绑定快捷键到菜单项》

    如何将键盘快捷方式映射到自定义按钮,怎么使用快捷键启动自己创建的菜单,刚开始做的时候迷糊了,找了很久.可能也是因为刚开始做不是很明白,后面慢慢就懂了.其实非常简单的. 很多快捷键已经在Visual s ...

  6. Linux编程 10 (shell外部命令与内建命令,alias ,type命令)

    一.  内部命令 Linux命令有内部命令(内建命令)和外部命令之分,内部命令和外部命令功能基本相同,但也有些细微差别.内部命令不需要使用子进程来执行,它们已经和shell编译成一体,作为shell工 ...

  7. 如何正确且高效实现OSSIM中文化的解决方案(图文详解)

    前言   对于玩OSSIM的初学者或者中级水平的从业人员来说,都有一定必要性从中文看起,当然,最终还是英文的目标迈进,只是说,为了让自己更快速上手! 虽然系统说明支持中文,实际上,只是台湾的繁体中文而 ...

  8. 一套能体现 RBAC 的表结构设计

    1.RBAC 概述 2.表结构设计 2.1.用户表 2.2.角色表 2.3.权限表 2.4.用户角色(关系)表 2.5.角色权限(关系)表 3.总结 1.RBAC 概述 RBAC(Role-Based ...

  9. Apollo 6 — ConfigService 获取配置接口

    大纲 看本文之前,建议看看 apollo 的官方文档,特别是数据库设计文档. 主流程分析 2.1 聊聊细节 2.2 loadConfig() 加载配置 2.3 auditReleases() 方法记录 ...

  10. anoconda包管理汇总

    anoconda默认的seaborn版本是0.8.1 seaborn的最新版本是0.9.0  并且已经没有0.8.1的文档了. 升级anoconda的seaborn版本 进入anoconda prom ...