拓展阅读:Redis闲谈(1):构建知识图谱

Redis专题(2):Redis数据结构底层探秘

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

一、锁的基本了解

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

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

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

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

  1. package Thread;
  2.  
  3. import java.util.concurrent.TimeUnit;
  4.  
  5. public class Ticket {
  6.  
  7. /**
  8. * 初始库存量
  9. * */
  10. Integer ticketNum = ;
  11.  
  12. public void reduce(int num){
  13. //判断库存是否够用
  14. if((ticketNum - num) >= ){
  15. try {
  16. TimeUnit.MILLISECONDS.sleep();
  17. }catch (InterruptedException e){
  18. e.printStackTrace();
  19. }
  20. ticketNum -= num;
  21. System.out.println(Thread.currentThread().getName() + "成功卖出"
  22. + num + "张,剩余" + ticketNum + "张票");
  23. }else {
  24. System.err.println(Thread.currentThread().getName() + "没有卖出"
  25. + num + "张,剩余" + ticketNum + "张票");
  26. }
  27. }
  28.  
  29. public static void main(String[] args) throws InterruptedException{
  30. Ticket ticket = new Ticket();
  31. //开启10个线程进行抢票,按理说应该有两个人抢不到票
  32. for(int i=;i<;i++){
  33. new Thread(() -> ticket.reduce(),"用户" + (i + )).start();
  34. }
  35. Thread.sleep(1000L);
  36. }
  37.  
  38. }

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

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

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

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

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

举个例子

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

  1. public synchronized void reduce(int num){
  2. //判断库存是否够用
  3. if((ticketNum - num) >= ){
  4. try {
  5. TimeUnit.MILLISECONDS.sleep();
  6. }catch (InterruptedException e){
  7. e.printStackTrace();
  8. }
  9. ticketNum -= num;
  10. System.out.println(Thread.currentThread().getName() + "成功卖出"
  11. + num + "张,剩余" + ticketNum + "张票");
  12. }else {
  13. System.err.println(Thread.currentThread().getName() + "没有卖出"
  14. + num + "张,剩余" + ticketNum + "张票");
  15. }
  16. }

运行结果:

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

二、锁的性能优化

2.1 缩短锁的持有时间

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

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

  1. public void reduceByLock(int num){
  2. boolean flag = false;
  3.  
  4. synchronized (ticketNum){
  5. if((ticketNum - num) >= ){
  6. ticketNum -= num;
  7. flag = true;
  8. }
  9. }
  10. if(flag){
  11. System.out.println(Thread.currentThread().getName() + "成功卖出"
  12. + num + "张,剩余" + ticketNum + "张票");
  13. }
  14. else {
  15. System.err.println(Thread.currentThread().getName() + "没有卖出"
  16. + num + "张,剩余" + ticketNum + "张票");
  17. }
  18. if(ticketNum == ){
  19. System.out.println("耗时" + (System.currentTimeMillis() - startTime) + "毫秒");
  20. }
  21. }

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

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

  1. public synchronized void reduce(int num){
  2. //判断库存是否够用
  3. if((ticketNum - num) >= ){
  4. try {
  5. TimeUnit.MILLISECONDS.sleep();
  6. }catch (InterruptedException e){
  7. e.printStackTrace();
  8. }
  9. ticketNum -= num;
  10. if(ticketNum == ){
  11. System.out.println("耗时" + (System.currentTimeMillis() - startTime) + "毫秒");
  12. }
  13. System.out.println(Thread.currentThread().getName() + "成功卖出"
  14. + num + "张,剩余" + ticketNum + "张票");
  15. }else {
  16. System.err.println(Thread.currentThread().getName() + "没有卖出"
  17. + num + "张,剩余" + ticketNum + "张票");
  18. }
  19. }

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

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

2.2 减少锁的粒度

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

  1. package Thread;
  2.  
  3. import java.util.concurrent.CountDownLatch;
  4.  
  5. public class Movie {
  6. private final CountDownLatch latch = new CountDownLatch();
  7. //魔童哪吒
  8. private Integer babyTickets = ;
  9.  
  10. //蜘蛛侠
  11. private Integer spiderTickets = ;
  12.  
  13. public synchronized void showBabyTickets() throws InterruptedException{
  14. System.out.println("魔童哪吒的剩余票数为:" + babyTickets);
  15. //购买
  16. latch.await();
  17. }
  18.  
  19. public synchronized void showSpiderTickets() throws InterruptedException{
  20. System.out.println("蜘蛛侠的剩余票数为:" + spiderTickets);
  21. //购买
  22. }
  23.  
  24. public static void main(String[] args) {
  25. Movie movie = new Movie();
  26. new Thread(() -> {
  27. try {
  28. movie.showBabyTickets();
  29. }catch (InterruptedException e){
  30. e.printStackTrace();
  31. }
  32. },"用户A").start();
  33.  
  34. new Thread(() -> {
  35. try {
  36. movie.showSpiderTickets();
  37. }catch (InterruptedException e){
  38. e.printStackTrace();
  39. }
  40. },"用户B").start();
  41. }
  42.  
  43. }

执行结果:

  1. 魔童哪吒的剩余票数为:

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

  1. public void showBabyTickets() throws InterruptedException{
  2. synchronized (babyTickets) {
  3. System.out.println("魔童哪吒的剩余票数为:" + babyTickets);
  4. //购买
  5. latch.await();
  6. }
  7. }
  8.  
  9. public void showSpiderTickets() throws InterruptedException{
  10. synchronized (spiderTickets) {
  11. System.out.println("蜘蛛侠的剩余票数为:" + spiderTickets);
  12. //购买
  13. }
  14. }

执行结果:

  1. 魔童哪吒的剩余票数为:
  2. 蜘蛛侠的剩余票数为:

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

2.3 锁分离

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

三、锁的种类

  • 公平锁: ReentrantLock
  • 非公平锁: Synchronized、ReentrantLock、cas
  • 悲观锁: Synchronized
  • 乐观锁:cas
  • 独享锁:Synchronized、ReentrantLock
  • 共享锁:Semaphore

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

四、Redis分布式锁

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

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

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

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

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

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

1)互斥性

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

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

2)防死锁

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

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

3)持锁人解锁

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

4)可重入

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

4.2 Redis分布式锁流程

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的官方文档中的源码:

  1. public String set(String key, String value, String nxxx, String expx, long time) {
  2. this.checkIsInMultiOrPipeline();
  3. this.client.set(key, value, nxxx, expx, time);
  4. return this.client.getStatusCodeReply();
  5. }

这就是我们想要的!

4.3.2 解锁

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

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

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

  1. if Redis.call("get",key==argv[])then
  2. return Redis.call("del",key)
  3. else return end

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

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

五、Redis分布式锁代码实现

  1. public class RedisDistributedLock implements Lock {
  2.  
  3. //上下文,保存当前锁的持有人id
  4. private ThreadLocal<String> lockContext = new ThreadLocal<String>();
  5.  
  6. //默认锁的超时时间
  7. private long time = ;
  8.  
  9. //可重入性
  10. private Thread ownerThread;
  11.  
  12. public RedisDistributedLock() {
  13. }
  14.  
  15. public void lock() {
  16. while (!tryLock()){
  17. try {
  18. Thread.sleep();
  19. }catch (InterruptedException e){
  20. e.printStackTrace();
  21. }
  22. }
  23. }
  24.  
  25. public boolean tryLock() {
  26. return tryLock(time,TimeUnit.MILLISECONDS);
  27. }
  28.  
  29. public boolean tryLock(long time, TimeUnit unit){
  30. String id = UUID.randomUUID().toString(); //每一个锁的持有人都分配一个唯一的id
  31. Thread t = Thread.currentThread();
  32. Jedis jedis = new Jedis("127.0.0.1",);
  33. //只有锁不存在的时候加锁并设置锁的有效时间
  34. if("OK".equals(jedis.set("lock",id, "NX", "PX", unit.toMillis(time)))){
  35. //持有锁的人的id
  36. lockContext.set(id);
  37. //记录当前的线程
  38. setOwnerThread(t);
  39. return true;
  40. }else if(ownerThread == t){
  41. //因为锁是可重入的,所以需要判断当前线程已经持有锁的情况
  42. return true;
  43. }else {
  44. return false;
  45. }
  46. }
  47.  
  48. private void setOwnerThread(Thread t){
  49. this.ownerThread = t;
  50. }
  51.  
  52. public void unlock() {
  53. String script = null;
  54. try{
  55. Jedis jedis = new Jedis("127.0.0.1",);
  56. script = inputStream2String(getClass().getResourceAsStream("/Redis.Lua"));
  57. if(lockContext.get()==null){
  58. //没有人持有锁
  59. return;
  60. }
  61. //删除锁 ③
  62. jedis.eval(script, Arrays.asList("lock"), Arrays.asList(lockContext.get()));
  63. lockContext.remove();
  64. }catch (Exception e){
  65. e.printStackTrace();
  66. }
  67. }
  68.  
  69. /**
  70. * 将InputStream转化成String
  71. * @param is
  72. * @return
  73. * @throws IOException
  74. */
  75. public String inputStream2String(InputStream is) throws IOException {
  76. ByteArrayOutputStream baos = new ByteArrayOutputStream();
  77. int i = -;
  78. while ((i = is.read()) != -) {
  79. baos.write(i);
  80. }
  81. return baos.toString();
  82. }
  83.  
  84. public void lockInterruptibly() throws InterruptedException {
  85.  
  86. }
  87.  
  88. public Condition newCondition() {
  89. return null;
  90. }
  91. }
  • 用一个上下文全局变量来记录持有锁的人的uuid,解锁的时候需要将该uuid作为参数传入Lua脚本中,来判断是否可以解锁。
  • 要记录当前线程,来实现分布式锁的重入性,如果是当前线程持有锁的话,也属于加锁成功。
  • 用eval函数来执行Lua脚本,保证解锁时的原子性。

六、分布式锁的对比

6.1 基于数据库的分布式锁

1)实现方式

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

2)缺点

  • 数据库如果挂掉会导致业务系统不可用。
  • 无法设置过期时间,会造成死锁。

6.2 基于zookeeper的分布式锁

1)实现方式

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

2)缺点

性能一般,没有Redis高效。

所以:

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

七、总结

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

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

  • 互斥性
  • 防死锁
  • 加锁人解锁
  • 可重入性

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

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

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

作者:杨亨

来源:宜信技术学院

Redis专题(3):锁的基本概念到Redis分布式锁实现的更多相关文章

  1. 一般实现分布式锁都有哪些方式?使用redis如何设计分布式锁?使用zk来设计分布式锁可以吗?这两种分布式锁的实现方式哪种效率比较高?

    #(1)redis分布式锁 官方叫做RedLock算法,是redis官方支持的分布式锁算法. 这个分布式锁有3个重要的考量点,互斥(只能有一个客户端获取锁),不能死锁,容错(大部分redis节点创建了 ...

  2. 分布式锁(3) ----- 基于zookeeper的分布式锁

    分布式锁系列文章 分布式锁(1) ----- 介绍和基于数据库的分布式锁 分布式锁(2) ----- 基于redis的分布式锁 分布式锁(3) ----- 基于zookeeper的分布式锁 代码:ht ...

  3. 【Redis的那些事 · 上篇】Redis的介绍、五种数据结构演示和分布式锁

    Redis是什么 Redis,全称是Remote Dictionary Service,翻译过来就是,远程字典服务. redis属于nosql非关系型数据库.Nosql常见的数据关系,基本上是以key ...

  4. redis整理:常用命令,雪崩击穿穿透原因及方案,分布式锁实现思路,分布式锁redission(更新中)

    redis个人整理笔记 reids常见数据结构 基本类型 String: 普通key-value Hash: 类似hashMap List: 双向链表 Set: 不可重复 SortedSet: 不可重 ...

  5. Redis分布式锁 (图解-秒懂-史上最全)

    文章很长,而且持续更新,建议收藏起来,慢慢读! 高并发 发烧友社群:疯狂创客圈(总入口) 奉上以下珍贵的学习资源: 疯狂创客圈 经典图书 : 极致经典 + 社群大片好评 < Java 高并发 三 ...

  6. 基于redis分布式锁实现“秒杀”

    转载:http://blog.5ibc.net/p/28883.html 最近在项目中遇到了类似“秒杀”的业务场景,在本篇博客中,我将用一个非常简单的demo,阐述实现所谓“秒杀”的基本思路. 业务场 ...

  7. 分布式锁的几种使用方式(redis、zookeeper、数据库)

    Q:一个业务服务器,一个数据库,操作:查询用户当前余额,扣除当前余额的3%作为手续费 synchronized lock db lock Q:两个业务服务器,一个数据库,操作:查询用户当前余额,扣除当 ...

  8. 分布式锁实现秒杀 - 基于redis实现

    业务场景 所谓秒杀,从业务角度看,是短时间内多个用户“争抢”资源,这里的资源在大部分秒杀场景里是商品:将业务抽象,技术角度看,秒杀就是多个线程对资源进行操作,所以实现秒杀,就必须控制线程对资源的争抢, ...

  9. 如何优雅地用Redis实现分布式锁?

    转: 如何优雅地用Redis实现分布式锁?   BaiduSpring 01-2500:01 什么是分布式锁 在学习Java多线程编程的时候,锁是一个很重要也很基础的概念,锁可以看成是多线程情况下访问 ...

随机推荐

  1. 基于STM32F429和Cube的ov2640程序

    1.ov2640和DCMI介绍 OV2640 是 OV(OmniVision)公司生产的一颗 1/4 寸的 CMOS UXGA(1632*1232)图 像传感器.该传感器体积小.工作电压低,提供单片 ...

  2. Spring-Boot:Profile简单示例

    //Resources目录下创建 application.properties spring.profiles.active=prod //Resources目录下创建 application-pro ...

  3. Flink的TaskManager启动(源码分析)

    通过启动脚本已经找到了TaskManager 的启动类org.apache.flink.runtime.taskexecutor.TaskManagerRunner 来看一下它的main方法中 最后被 ...

  4. 再读faster rcnn,有了深层次的理解

    1. https://www.wengbi.com/thread_88754_1.html (图) 2. https://blog.csdn.net/WZZ18191171661/article/de ...

  5. 池化层的back proporgation 原理

    转载:https://www.jianshu.com/p/6928203bf75b

  6. 利用canvas绘制带干扰线的验证码

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...

  7. 手把手教你用深度学习做物体检测(六):YOLOv2介绍

    本文接着上一篇<手把手教你用深度学习做物体检测(五):YOLOv1介绍>文章,介绍YOLOv2在v1上的改进.有些性能度量指标术语看不懂没关系,后续会有通俗易懂的关于性能度量指标的介绍文章 ...

  8. cogs1709. [SPOJ 705] 不同的子串(后缀数组

    http://cogs.pro:8080/cogs/problem/problem.php?pid=vyziQkWaP 题意:给定一个字符串,计算其不同的子串个数. 思路:ans=总共子串个数-相同的 ...

  9. CodeForces 677D. Vanya and Treasure 枚举行列

    677D. Vanya and Treasure 题意: 给定一张n*m的图,图上每个点标有1~p的值,你初始在(1,1)点,你必须按照V:1,2,3...p的顺序走图上的点,问你如何走时间最少. 思 ...

  10. codeforces E. Mahmoud and Ehab and the function(二分+思维)

    题目链接:http://codeforces.com/contest/862/problem/E 题解:水题显然利用前缀和考虑一下然后就是二分b的和与-ans_a最近的数(ans_a表示a的前缀和(奇 ...