Redis专题(3):锁的基本概念到Redis分布式锁实现

2020 年 2 月 07 日

Redis专题(3):锁的基本概念到Redis分布式锁实现

近来,分布式的问题被广泛提及,比如分布式事务、分布式框架、ZooKeeper、SpringCloud 等等。本文先回顾锁的概念,再介绍分布式锁,以及如何用 Redis 来实现分布式锁。


一、锁的基本了解


首先,回顾一下我们工作学习中的锁的概念。


为什么要先讲锁再讲分布式锁呢?


我们都清楚,锁的作用是要解决多线程对共享资源的访问而产生的线程安全问题,而在平时生活中用到锁的情况其实并不多,可能有些朋友对锁的概念和一些基本的使用不是很清楚,所以我们先看锁,再深入介绍分布式锁。


1568964850681038648.png


通过一个卖票的小案例来看,比如大家去抢 dota2 ti9 门票,如果不加锁的话会出现什么问题?此时代码如下:


package Thread;  
import java.util.concurrent.TimeUnit;
public class Ticket {
/** \* 初始库存量 \* */ Integer ticketNum = 8;
public void reduce(int num){ //判断库存是否够用 if((ticketNum - num) >= 0){ try { TimeUnit.MILLISECONDS.sleep(200); }catch (InterruptedException e){ e.printStackTrace(); } ticketNum -= num; System.out.println(Thread.currentThread().getName() + "成功卖出" + num + "张,剩余" + ticketNum + "张票"); }else { System.err.println(Thread.currentThread().getName() + "没有卖出" + num + "张,剩余" + ticketNum + "张票"); } }
public static void main(String[] args) throws InterruptedException{ Ticket ticket = new Ticket(); //开启10个线程进行抢票,按理说应该有两个人抢不到票 for(int i=0;i<10;i++){ new Thread(() -> ticket.reduce(1),"用户" + (i + 1)).start(); } Thread.sleep(1000L); }
}
复制代码


代码分析:这里有 8 张 ti9 门票,设置了 10 个线程(也就是模拟 10 个人)去并发抢票,如果抢成功了显示成功,抢失败的话显示失败。按理说应该有 8 个人抢成功了,2 个人抢失败,下面来看运行结果:


1568964864684097068.png


我们发现运行结果和预期的情况不一致,居然 10 个人都买到了票,也就是说出现了线程安全的问题,那么是什么原因导致的呢?


原因就是多个线程之间产生了时间差


如图所示,只剩一张票了,但是两个线程都读到的票余量是 1,也就是说线程 B 还没有等到线程 A 改库存就已经抢票成功了。


1568964876460081201.png


怎么解决呢?想必大家都知道,加个 synchronized 关键字就可以了,在一个线程进行 reduce 方法的时候,其他线程则阻塞在等待队列中,这样就不会发生多个线程对共享变量的竞争问题。


举个例子


比如我们去健身房健身,如果好多人同时用一台机器,同时在一台跑步机上跑步,就会发生很大的问题,大家会打得不可开交。如果我们加一把锁在健身房门口,只有拿到锁的钥匙的人才可以进去锻炼,其他人在门外等候,这样就可以避免大家对健身器材的竞争。代码如下:


public  synchronized void reduce(int num){          //判断库存是否够用          if((ticketNum - num) >= 0){              try {                  TimeUnit.MILLISECONDS.sleep(200);              }catch (InterruptedException e){                  e.printStackTrace();              }              ticketNum -= num;              System.out.println(Thread.currentThread().getName() + "成功卖出"              + num + "张,剩余" + ticketNum + "张票");          }else {              System.err.println(Thread.currentThread().getName() + "没有卖出"                      + num + "张,剩余" + ticketNum + "张票");          }      }  
复制代码


运行结果:


1568964895841030289.png


果不其然,结果有两个人没有成功抢到票,看来我们的目地达成了。


二、锁的性能优化


2.1 缩短锁的持有时间


事实上,按照我们对日常生活的理解,不可能整个健身房只有一个人在运动。所以我们只需要对某一台机器加锁就可以了,比如一个人在跑步,另一个人可以去做其他的运动。


对于票务系统来说,我们只需要对库存的修改操作的代码加锁就可以了,别的代码还是可以并行进行,这样会大大减少锁的持有时间,代码修改如下:


public void reduceByLock(int num){          boolean flag = false;  
synchronized (ticketNum){ if((ticketNum - num) >= 0){ ticketNum -= num; flag = true; } } if(flag){ System.out.println(Thread.currentThread().getName() + "成功卖出" + num + "张,剩余" + ticketNum + "张票"); } else { System.err.println(Thread.currentThread().getName() + "没有卖出" + num + "张,剩余" + ticketNum + "张票"); } if(ticketNum == 0){ System.out.println("耗时" + (System.currentTimeMillis() - startTime) + "毫秒"); } }
复制代码


这样做的目的是充分利用 cpu 的资源,提高代码的执行效率


这里我们对两种方式的时间做个打印:


public synchronized void reduce(int num){          //判断库存是否够用          if((ticketNum - num) >= 0){              try {                  TimeUnit.MILLISECONDS.sleep(200);              }catch (InterruptedException e){                  e.printStackTrace();              }              ticketNum -= num;              if(ticketNum == 0){                  System.out.println("耗时" + (System.currentTimeMillis() - startTime) + "毫秒");              }              System.out.println(Thread.currentThread().getName() + "成功卖出"              + num + "张,剩余" + ticketNum + "张票");          }else {              System.err.println(Thread.currentThread().getName() + "没有卖出"                      + num + "张,剩余" + ticketNum + "张票");          }      }  
复制代码


1568964908181085854.png


1568964922017059548.png


果然,只对部分代码加锁会大大提供代码的执行效率。


所以,在解决了线程安全的问题后,我们还要考虑到加锁之后的代码执行效率问题


2.2 减少锁的粒度


举个例子,有两场电影,分别是最近刚上映的魔童哪吒和蜘蛛侠,我们模拟一个支付购买的过程,让方法等待,加了一个 CountDownLatch 的 await 方法,运行结果如下:


package Thread;  
import java.util.concurrent.CountDownLatch;
public class Movie { private final CountDownLatch latch = new CountDownLatch(1); //魔童哪吒 private Integer babyTickets = 20;
//蜘蛛侠 private Integer spiderTickets = 100;
public synchronized void showBabyTickets() throws InterruptedException{ System.out.println("魔童哪吒的剩余票数为:" + babyTickets); //购买 latch.await(); }
public synchronized void showSpiderTickets() throws InterruptedException{ System.out.println("蜘蛛侠的剩余票数为:" + spiderTickets); //购买 }
public static void main(String[] args) { Movie movie = new Movie(); new Thread(() -> { try { movie.showBabyTickets(); }catch (InterruptedException e){ e.printStackTrace(); } },"用户A").start();
new Thread(() -> { try { movie.showSpiderTickets(); }catch (InterruptedException e){ e.printStackTrace(); } },"用户B").start(); }
}
复制代码


执行结果:


魔童哪吒的剩余票数为:20  
复制代码


我们发现买哪吒票的时候阻塞会影响蜘蛛侠票的购买,而实际上,这两场电影之间是相互独立的,所以我们需要减少锁的粒度,将 movie 整个对象的锁变为两个全局变量的锁,修改代码如下:


public void showBabyTickets() throws InterruptedException{          synchronized (babyTickets) {              System.out.println("魔童哪吒的剩余票数为:" + babyTickets);              //购买              latch.await();          }      }  
public void showSpiderTickets() throws InterruptedException{ synchronized (spiderTickets) { System.out.println("蜘蛛侠的剩余票数为:" + spiderTickets); //购买 } }
复制代码


执行结果:


魔童哪吒的剩余票数为:20  蜘蛛侠的剩余票数为:100  
复制代码


现在两场电影的购票不会互相影响了,这就是第二个优化锁的方式:减少锁的粒度。顺便提一句,Java 并发包里的 ConcurrentHashMap 就是把一把大锁变成了 16 把小锁,通过分段锁的方式达到高效的并发安全。


2.3 锁分离


锁分离就是常说的读写分离,我们把锁分成读锁和写锁,读的锁不需要阻塞,而写的锁要考虑并发问题。


三、锁的种类


  • 公平锁: ReentrantLock

  • 非公平锁: Synchronized、ReentrantLock、cas

  • 悲观锁: Synchronized

  • 乐观锁:cas

  • 独享锁:Synchronized、ReentrantLock

  • 共享锁:Semaphore


这里就不一一讲述每一种锁的概念了,大家可以自己学习,锁还可以按照偏向锁、轻量级锁、重量级锁来分类。


四、Redis 分布式锁


了解了锁的基本概念和锁的优化后,重点介绍分布式锁的概念。


1568964933554032611.png


上图所示是我们搭建的分布式环境,有三个购票项目,对应一个库存,每一个系统会有多个线程,和上文一样,对库存的修改操作加上锁,能不能保证这 6 个线程的线程安全呢?


当然是不能的,因为每一个购票系统都有各自的 JVM 进程,互相独立,所以加 synchronized 只能保证一个系统的线程安全,并不能保证分布式的线程安全。


所以需要对于三个系统都是公共的一个中间件来解决这个问题。


这里我们选择 Redis 来作为分布式锁,多个系统在 Redis 中 set 同一个 key,只有 key 不存在的时候,才能设置成功,并且该 key 会对应其中一个系统的唯一标识,当该系统访问资源结束后,将 key 删除,则达到了释放锁的目的。


4.1 分布式锁需要注意哪些点


1)互斥性


在任意时刻只有一个客户端可以获取锁。


这个很容易理解,所有的系统中只能有一个系统持有锁。


2)防死锁


假如一个客户端在持有锁的时候崩溃了,没有释放锁,那么别的客户端无法获得锁,则会造成死锁,所以要保证客户端一定会释放锁。


Redis 中我们可以设置锁的过期时间来保证不会发生死锁。


3)持锁人解锁


解铃还须系铃人,加锁和解锁必须是同一个客户端,客户端 A 的线程加的锁必须是客户端 A 的线程来解锁,客户端不能解开别的客户端的锁。


4)可重入


当一个客户端获取对象锁之后,这个客户端可以再次获取这个对象上的锁。


4.2 Redis 分布式锁流程


1568964945234077456.png


Redis 分布式锁的具体流程:


1)首先利用 Redis 缓存的性质在 Redis 中设置一个 key-value 形式的键值对,key 就是锁的名称,然后客户端的多个线程去竞争锁,竞争成功的话将 value 设为客户端的唯一标识。


2)竞争到锁的客户端要做两件事:


  • 设置锁的有效时间 目的是防死锁 (非常关键)


需要根据业务需要,不断的压力测试来决定有效期的长短。


  • 分配客户端的唯一标识,目的是保证持锁人解锁(非常重要)


所以这里的 value 就设置成唯一标识(比如 uuid)。


3)访问共享资源


4)释放锁,释放锁有两种方式,第一种是有效期结束后自动释放锁,第二种是先根据唯一标识判断自己是否有释放锁的权限,如果标识正确则释放锁


4.3 加锁和解锁


4.3.1 加锁


1)setnx 命令加锁


set if not exists 我们会用到 Redis 的命令 setnx,setnx 的含义就是只有锁不存在的情况下才会设置成功。


2)设置锁的有效时间,防止死锁 expire


加锁需要两步操作,思考一下会有什么问题吗?


假如我们加锁完之后客户端突然挂了呢?那么这个锁就会成为一个没有有效期的锁,接着就可能发生死锁。虽然这种情况发生的概率很小,但是一旦出现问题会很严重,所以我们也要把这两步合为一步。


幸运的是,Redis3.0 已经把这两个指令合在一起成为一个新的指令。


来看 jedis 的官方文档中的源码:


    public String set(String key, String value, String nxxx, String expx, long time) {          this.checkIsInMultiOrPipeline();          this.client.set(key, value, nxxx, expx, time);          return this.client.getStatusCodeReply();      }  
复制代码


这就是我们想要的!


4.3.2 解锁


  • 检查是否自己持有锁(判断唯一标识);

  • 删除锁。


解锁也是两步,同样也要保证解锁的原子性,把两步合为一步。


这就无法借助于 Redis 了,只能依靠 Lua 脚本来实现。


if Redis.call("get",key==argv[1])then      return Redis.call("del",key)  else return 0 end  
复制代码


这就是一段判断是否自己持有锁并释放锁的 Lua 脚本。


为什么 Lua 脚本是原子性呢?因为 Lua 脚本是 jedis 用 eval()函数执行的,如果执行则会全部执行完成。


五、Redis 分布式锁代码实现


public class RedisDistributedLock implements Lock {  
//上下文,保存当前锁的持有人id private ThreadLocal
复制代码


  • 用一个上下文全局变量来记录持有锁的人的uuid,解锁的时候需要将该uuid作为参数传入Lua脚本中,来判断是否可以解锁。

  • 要记录当前线程,来实现分布式锁的重入性,如果是当前线程持有锁的话,也属于加锁成功。

  • 用eval函数来执行Lua脚本,保证解锁时的原子性。


六、分布式锁的对比


6.1 基于数据库的分布式锁


1)实现方式


获取锁的时候插入一条数据,解锁时删除数据。


2)缺点


  • 数据库如果挂掉会导致业务系统不可用。

  • 无法设置过期时间,会造成死锁。


6.2 基于 zookeeper 的分布式锁


1)实现方式


加锁时在指定节点的目录下创建一个新节点,释放锁的时候删除这个临时节点。因为有心跳检测的存在,所以不会发生死锁,更加安全


2)缺点


性能一般,没有 Redis 高效。


所以:


  • 从性能角度: Redis > zookeeper > 数据库

  • 从可靠性(安全)性角度: zookeeper > Redis > 数据库


七、总结


本文从锁的基本概念出发,提出多线程访问共享资源会出现的线程安全问题,然后通过加锁的方式去解决线程安全的问题,这个方法会性能会下降,需要通过:缩短锁的持有时间、减小锁的粒度、锁分离三种方式去优化锁。


之后介绍了分布式锁的 4 个特点:


  • 互斥性

  • 防死锁

  • 加锁人解锁

  • 可重入性


然后用 Redis 实现了分布式锁,加锁的时候用到了 Redis 的命令去加锁,解锁的时候则借助了 Lua 脚本来保证原子性。


最后对比了三种分布式锁的优缺点和使用场景。


希望大家对分布式锁有新的理解,也希望大家在考虑解决问题的同时要多想想性能的问题。


本文转载自宜信技术学院网站。


原文链接:http://college.creditease.cn/detail/300


2020 年 2 月 07 日 20:46299

评论

发布
暂无评论
发现更多内容

我和阿里P7差的不是薪资?而是Redis+微服务+Nginx+MySQL+Tomcat

Java架构之路

Java 程序员 架构 面试 编程语言

耗时一个月整理的97道大厂Java核心面试题出炉,精心整理,无偿分享

Java架构之路

Java 程序员 架构 面试 编程语言

云图说 | 云上资源管控有神器!关于IAM,你想知道的都在这里!

华为云开发者社区

服务 权限管理 iam

一文为你详解Unique SQL原理和应用

华为云开发者社区

数据库 sql unique

太赞了!滴滴开源了一套分布式ID的生成系统...

Java架构师迁哥

Java中多线程安全问题实例分析

叫练

Java 多线程 什么是多线程 多线程与高并发

面向全场景模块化设计 京东智联云的服务器部署有多灵活?

京东智联云开发者

服务器 云主机

为了SpringBoot提交Tomcat执行,我总结了这么多

小Q

tomcat 学习 面试 微服务 springboot

熟练掌握Spring Cloud已然成为Java工程师的面试门槛,简历上没写熟悉掌握微服务连面试机会都难得!

Java成神之路

Java 程序员 架构 面试 编程语言

如何在数智化时代少走弯路? 这里有100个案例可以借鉴

京东智联云开发者

DevOps 云原生

朋友突然从某度外包人员摇身一变成为大厂架构师,在我的死缠烂打下他说出了自己的秘密武器。

Java成神之路

Java 程序员 架构 面试 编程语言

VACUUM无法从表中删除死元组的三个原因

PostgreSQLChina

数据库 postgresql

即构实时音视频多中心调度设计

ZEGO即构

第十三周学习总结

饭桶

住建部等六部门:广泛运用区块链等技术,建设智慧物业管理服务平台

CECBC区块链专委会

物业生活

从 JMM 透析 volatile 与 synchronized 原理

码哥字节

volatile JVM JMM Java 25 周年 synchronized

将原则纳入到架构的生命中

soolaugust

架构 思考 设计

一口气说出四种幂等性解决方案,面试官露出了姨母笑~

不才陈某

Java 分布式 接口

搭建网站/APP最全准备攻略

前嗅大数据

小程序 建站 APP发布

《码出高效:Java 开发手册》“码” 出高效的同时编写出高质量的代“码”。PDF文档资料免费开放下载!

Java成神之路

Java 程序员 架构 面试 编程语言

五年开发经验裸辞之后投递简历,收到阿里面试邀请四面成功斩获offer,特分享本次阿里面经希望对大家有所帮助。

Java成神之路

Java 程序员 架构 面试 编程语言

从源码的角度搞懂 Java 动态代理!

Java架构师迁哥

解析字节算法面试真题,深入探究ArrayList应用原理

小Q

Java 学习 编程 架构 面试

80%Java开发者面试都问的SpringBoot你竟不会?看完这些笔记足以

Java架构之路

Java 程序员 架构 面试 编程语言

Java进阶文档:彻底搞懂JVM+Linux+MySQL+Netty+Tomcat+并发编程

Java架构之路

Java 程序员 架构 面试 编程语言

面试被问高并发一脸懵?那是你没看过我整理得高并发回答模板

小Q

Java 学习 面试 高并发 性能调优

倾斜摄影实景三维在智慧工厂 Web 3D GIS 数字孪生应用

一只数据鲸鱼

GIS 数字化 数据可视化 3D渲染 数字工厂

好久不见!这份Spring全家桶、Docker、Redis架构大礼包免费赠送

Java架构之路

Java 程序员 架构 面试 编程语言

什么是全场景AI计算框架MindSpore?

华为云开发者社区

人工智能 AI mindspore

第13周作业

饭桶

4年Java开发经验裸辞之后闭关修炼2个多月,成功拿下美团、京东、字节跳动(Java架构师)offer。

Java成神之路

Java 程序员 架构 面试 编程语言

Redis专题(3):锁的基本概念到Redis分布式锁实现-InfoQ