分布式锁
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的使用和心得。