分布式锁的学习笔记

分布式锁

1.1什么是锁?

– 多个线程运行的时候,共享了同一块资源(临界资源),为了解决多个线程同时访问临界资源的过程中,加上了一把锁,只允许一个线程访问资源。

1.2常见的锁

– 1.互斥锁:互斥,只允许一个线程进入(有你没我,有我没你)
– 2.自旋锁(很常用)
– 3.乐观锁
– 4.悲观锁
– 等等

2.什么是分布式锁

– 讨论分布式锁前,先假设一个业务场景

2.1 业务场景

–先假设一个电商业务场景,在电商中,用户购买商品需要扣减商品库存,一般有两种扣减库存方式:

  • 下单减库存
    优点:用户体验好,下单成功,库存直接扣除,用户支付不会出现库存不足的情况。
    缺点:用户一直不付款,这个商品有的库存一直被占用,其他人就无法购买了。
  • 支付减库存
    优点:不会导致库存被恶意锁定,对商家有利
    缺点:用户体验不好,用户可能支付的时候商品库存不足了,会导致用户交易失败。

大多数解决方案:一般为了用户体验,我们会选择下单减库存,但是为了解决下单减库存的缺陷,会创建一个定时任务,定时去清理超时未支付的订单。

在这个定时任务中,需要完成的业务主要包括:

  • 查询超时未支付的订单,获取订单中的商品信息。

  • 修改这些未支付的订单的状态,为已关闭。
    下单减库存定时任务流程
    但是,如果项目为了保证高可用,给订单服务搭建了一个100台服务节点的集群,那么就会在同一时刻有100个定时任务被触发执行,设想一下这样的场景:

  • 订单服务A执行了步骤1,但还没执行步骤2

  • 订单服务B执行了步骤1,于是查询到了与订单服务A查询到的一样的订单数据

  • 订单服务A执行步骤2和3,此时订单中对应的商品库存已经恢复了

  • 订单服务B也执行了步骤2和3,此时订单中对应的商品库存再次被增加

  • 库存错误的被恢复了多次,事实上只需要执行一次就可以了。

高并发定时任务执行问题
因为任务的并发执行,出现了线程安全问题,商品库存被错误的增加了多次。

2.2 为什么需要分布式锁

– 对于线程安全问题,我们都已经很熟悉了,传统的解决方案就是对线程操作资源的代码加锁,流程如图:

在这里插入图片描述

– 在理想状态下,加了锁以后,在当前订单服务执行时,其他订单服务需要等待当前订单服务完成业务后才能执行,这样就避免了线程安全问题的发生。
– 但是,这样能解决分布式的问题吗?不能!

2.2.1 线程锁

我们通常使用的是synchronized或者Lock都是线程锁,对同一个JVM进程内多个线程有效,因为锁的本质是内存中存放一个标记,记录获取锁的线程是谁,这个标记对多个线程都可见

  • 获取锁:就是判断标记中是否已经有线程存在,如果有,则获取锁失败,如果没有,在标记中记录当前线程。
  • 释放锁:就是删除标记中保存的线程,并唤醒等待队列中的其他线程。

因此,锁生效的前提是:

  • 互斥:锁的标记只有一个线程可以获取。
  • 共享:标记对所有线程可见。

然而当我们启动了多个订单服务,就是多个JVM时,内存中的锁显然是不共享的,每个JVM进程都有自己的锁,自然无法保证线程的互斥了,如图:
在这里插入图片描述
也就是说:线程锁是在JVM内部生效的,而当我们分布式跨服务时,显然这个锁是失效的。安全问题依然存在!此时,分布式锁就来了。

2.2.2 分布式锁

由上文可知,出现的问题就是锁在不同JVM中不共享,不同JVM中的锁各玩各的,所以,分布式锁解决的问题就是方案就是让这个锁共享,进解决跨系统的问题。
线程锁是一个多线程可见的内存标记,保证同一个任务,同一时刻只能被多线程中的某一个执行,但这个锁在分布式系统中,多线程环境下,就达不到预期的效果了。
– 而如果我们将这个标记变成多进程可见,保证这个任务同一时刻只能被多个进程中的一个执行,这就闪分布式锁了。
在这里插入图片描述
–要想实现多进程可见的分布式锁,就需要我们自己来实现了。
分布式锁实现有多重方式,其原理都基本类似,只要满足下列要求即可。

  • 多进程可见:多进程可见,否则就无法实现分布式效果。
  • 互斥(排它):同一时刻,只能有一个进程获得锁,执行任务后释放锁。

满足以上两点,就可以实现分布式锁,当然,在此基础上可以做优化,加上其他特征。满足多场景需求。

  • 可重入(可选):同一个任务再次获取该锁不会被死锁。
  • 阻塞锁(可选):获取失败时,具备重试机制,尝试再次获取锁。
  • 性能好(可选):效率高,应对高并发场景。
  • 高可用(可选):避免锁服务宕机或处理好宕机的补救措施。

场景的分布式锁实现方案包括:基于数据库实现、基于缓存实现、基于zookeeper等等

3.Redis实现分布式锁

按照上面的分析,实现分布式锁要满足五点:多进程可见,互斥,可重入,阻塞,高性能,高可用等,我们来看看Redis如何满足这些需求。

3.1 版本1-基本实现

第一次尝试,先关注必须满足的两个条件:

  • 多进程可见
  • 互斥-锁可释放

1.多进程可见
首先Redis本身就是基于JVM之外的,因此满足多进程可见的要求。
2.互斥
互斥就是说只能一个进程获取锁标记,这个我们可以基于redis的setnx指令来实现。setnx是set when not exits的意思,当多次执行setnx命令时,只有一次执行才会返回1,其他情况返回0.
在这里插入图片描述
多个进程来对同一个key执行setnx操作时,肯定只有一个能执行成功,其他一定会失败,满足了互斥需求。
3.释放锁
释放锁其实是只需要把锁的key删除即可,使用del xxx命令,不过,仔细思考,如果我们在执行del命令之前,服务突然宕机,那么锁岂不是永远无法删除了
为了避免服务因宕机引起无法释放锁的问题,我们可以在获取锁的时候,给锁加一个有效时间,当时间超出时,就会自动释放锁,这样就不会死锁了。

设置锁命令:
设置锁:expire lock 10    (10表示10s)
查看锁时间:ttl lock
删除锁:del lock
避免在获取锁之后,设置锁过期时长之前服务宕机,需要把获取锁和设置时长合二为一执行。
set 与 设置过期时间合二为一: set num 666 nx ex 10
EX:过期时长,单位是秒
PX:过期时长,单位是毫秒
NX:等同于setnx

在这里插入图片描述
因此,获取和释放锁的基本流程如图:
在这里插入图片描述
步骤如下:
1.通过set命令设置锁
2.判断返回结果是否是OK

  • nil,获取锁失败
  • OK,获取锁成功
    3.异常情况,服务宕机,超时时间EX结束,会自动释放锁。
3.2 版本2-互斥性

在基础版本中,会有一定的安全问题。
释放锁就是用del命令把锁对应的key给删除了,但有这么一种情况。
1.假设存在三个进程ABC,在执行任务,并争抢锁,此时A拿到了锁,并设置超时时间10s.
2.A就开始执行业务代码,因为某种原因,业务阻塞,耗时超过了10s,此时锁自动释放了
3.B恰好此时开始重试获取锁,因为锁已经自动释放了,B拿到了锁
4.A业务代码执行完毕,开始执行删除锁(del),于是B的锁被释放了,而此时B还在执行业务
5.此时C尝试获取锁,并成功拿到锁,因为A把锁删了。

那么问题来了,B和C同时拿到了锁,违反了互斥性

如何解决这个问题?我们在删除锁之前,判断这个锁是否是当前进程自己设置的锁,如果不是(例如自己设置的锁已经超时释放),就不要删除锁了。

如何获取当前锁是不是自己的呢?
我们在set锁时,存入当前线程的唯一标识,删除锁前,判断下里面的值是不是与自己标识一致,如果不是,说明不是自己的锁,就不要删除了。
流程如图:
在这里插入图片描述

3.3 版本3-重入型性

接下来看看分布式锁的第三个特性:重入性

如果我们在获取锁之后,在代码的执行过程中,再次尝试获取锁,执行setnx操作肯定会失败,因为锁已经存在了,这样有可能导致死锁,这样的锁就是不可重入的。
在这里插入图片描述

3.3.1 重入锁

什么叫做可重入锁?

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

可重入锁可以避免同一线程中多次获取锁而导致死锁发生
小知识:
1.Redis锁不支持可重入
2.synchronized是可重入锁:它内部会判断获取锁的是不是当前线程,如果是,则不会限制。

如何实现可重入锁?

  • 获取锁:首先尝试获取锁,如果获取失败,判断这个锁是否是自己的,如果是,则允许再次获取,而且必须记录重复获取次数。
  • 释放锁:释放锁不能直接删除了,因为锁是可重入的,如果锁进入了多次,在最内层直接删除锁,会导致外部的代码在没有锁的情况下执行,会有安全问题。因此必须限制获取锁是累计重入的次数,如果减到0,则可以删除锁

在这里插入图片描述

因此,存储在锁中的信息就必须包含:key,线程标识,重入次数。不能再使用简单地key-value结构,这里推荐使用hash结构。

  • key:lock
  • hashKey:线程信息
  • hashValue:重入次数,默认1.
3.3.2 流程图

需要用到的一些redis命令:

  • EXISTS key:判断一个key是否存在。
  • HEXISTS key field:判断一个hash的field是否存在
  • HSET key field value:给一个hash的field设置一个值
  • HINCRBY key field increment:给一个hash的field值增加指定数值。
  • EXPIRE key second:给一个key设置过期时间
  • HGETTSLL lock:获取lock值
  • DEL key :删除指定key

具体流程:
在这里插入图片描述
下面假设锁的key为lock,hashKey是当前线程的id:threadId,锁自动释放的时间是20.
获取锁的步骤:

  • 1、判断lock释放存在(exists lock)
    ------存在:说明有人获取锁了,下面判断是不是自己获取的锁。
    ---------判断当前线程id作为hashKey是否存在:hexists lock threadId
    -------------不存在:说明锁已经有了,但不是自己获取的,获取锁失败,end。
    -------------存在:说明是自己获取的锁,重入次数+1:hincrby lock threadId 1
    -------不存在,说明锁可以获取:hset key thread 1
    -------设置锁自动释放时间:expire lock 20

释放锁的步骤:

  • 1、判断当前线程id作为hashKey是否存在:hexists lock threadId
    ---- 不存在,说明锁已经失效,不用管了。
    -----存在,说明锁还在,重入次数减1:hincrby lock threadId -1,获取重入次数。
  • 2、判断重入次数释放为0;
    ---- 为0:说明锁全部释放,删除key:del key
    ---- 不为0:说明锁还在使用,重置有效时间:expire lock 20
3.4 Lua脚本

上面探讨的redis锁实现方案都忽略了一个问题:原子性问题。无论是获取锁,还是释放锁的过程,都是有多行redis指令来完成的,如果不能保证这些redis命令执行的原子性,整个过程都是不安全的

而redis中支持Lua脚本来运行命令,并且保证整个脚本运行的原子性。
redis中执行Lua脚本以及Lua脚本编写相关自行查找。。。
在这里插入图片描述
在这里插入图片描述

3.4.1版本二Lua脚本实现:

先看版本二(普通互斥锁)的实现:

  • 获取锁:直接使用客户端的set nx ex 命令,无需脚本。
  • 释放锁,因为要判断锁中的标识释放是自己的,因此需要脚本,如下:
-- 判断是否是自己的
if (redis.call('GET',KEY[1]) == ARGV[1]) then
--是则删除
	return redis.call('DEL',KEYS[1])
end
--不是则直接返回
return 0

参数的含义说明:

  • KEYS[1] :就是锁的key,比如“lock”
  • ARGV[1]:就是线程的唯一标识,可以是随机字符串

版本三(可重入锁)的实现:

在这里插入图片描述
在这里插入图片描述

总结

分布式锁要满足的一些特性:

  • 多进程可见:多进程可见,否则就无法实现分布式效果。
  • 互斥(排它):同一时刻,只能有一个进程获得锁,执行任务后释放锁。
  • 可重入(可选):同一个任务再次获取该锁不会被死锁。
  • 阻塞锁(可选):获取失败时,具备重试机制,尝试再次获取锁。
  • 性能好(可选):效率高,应对高并发场景。
  • 高可用(可选):避免锁服务宕机或处理好宕机的补救措施。

目前,我们已经实现了多进程可见,互斥,可重入,剩下的几个也并非不能满足;
1.阻塞
可以修改代码,在获取锁失败后不断重试,知道某个特定时间后才结束。
2.性能好
Redis一向以出色的读写并发能力著称,因此这一点毫无问题
3.高可用
单点的redis无法保证高可用,因此一般我们会给redis搭建主从集群,但是,主从集群无法保证分布式锁的高可用性特征。在Redis官网上,也对这种单点故障做了说明:
在这里插入图片描述
不过,这种方式并不能完全保证锁的安全性,因此我们给锁设置了自动释放时间,因此某些极端情况下,依然会导致锁的失败,例如下面的情况:
在这里插入图片描述

  • 如果Client1在持有锁的时候,发生了一次很长时间的FGC,超过了锁的过期时间,锁很快就被释放了。
  • 这个时候Client2有获取了一把锁,提交数据。
  • 这时候Client1从FGC中苏醒过来,又提交了一次数据,冲突发生了。

我们可以采用看门狗(watch dog :开启一个定时任务,在这个任务获取锁之后若干时间,重新向redis发起请求,重置有效期(expire命令),保证业务代码在有锁状态顺利执行完毕)解决锁的超时问题。

但如果按照Redlock算法来实现分布式锁,加上各种安全机制,代码会比较复杂,下面简单介绍开源的Redission框架,它是在redis基础上实现的Java驻内存数据网格,它提供了一系列的分布式java常用对象,还提供了分布式服务,Redission的宗旨是促进使用者对Redis的关注分离,从而让使用者能够将精力集中的放在处理业务逻辑上。
Redission能实现的功能:

  • 可重入锁
  • 公平锁
  • 连锁
  • 红锁
  • 读写锁
  • 信息量
  • 可过期性信息量
  • 闭锁

先写到这里,后期再写Redission的使用和心得。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

木子丶Li

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值