几种分布式锁详解
一、简介
1、介绍
在应用开发中,特别是web工程开发,通常都是并发编程,不是多进程就是多线程。这种场景下极易出现线程并发性安全问题,此时不得不使用锁来解决问题。在多线程高并发场景下,为了保证资源的线程安全问题,jdk为我们提供了synchronized
关键字和ReentrantLock
可重入锁,但是它们只能保证一个工程内的线程安全。在分布式集群、微服务、云原生横行的当下,如何保证不同进程、不同服务、不同机器的线程安全问题,jdk并没有给我们提供既有的解决方案。目前主流的实现有以下方式:
- 基于mysql关系型实现
- 基于redis非关系型数据实现
- 基于zookeeper/etcd实现
2、超卖问题简述
多线程并发安全问题最典型的代表就是超卖现象库存在并发量较大情况下很容易发生超卖现象,一旦发生超卖现象,就会出现多成交了订单而发不了货的情况
商品S库存余量为5时,用户A和B同时来购买一个商品,此时查询库存数都为5,库存充足则开始减库存:
用户A:update db_stock set stock = stock - 1 where id = 1
;用户B:update db_stock set stock = stock - 1 where id = 1
。并发情况下,更新后的结果可能是4,而实际的最终库存量应该是3才对
(可以自己搭建,用JMeter进行压力测试)
二、传统锁
1、JVM锁
1.1 添加jvm锁
使用jvm锁(synchronized
关键字或者ReetrantLock
),ReetrantLock
比较轻量
1 |
|
1.2 原理
添加synchronized
关键字之后,StockService就具备了对象锁,由于添加了独占的排他锁,同一时刻只有一个请求能够获取到锁,并减库存。此时,所有请求只会one-by-one执行下去,也就不会发生超卖现象
1.3 失效场景
-
当添加为多例模式
@Scope(value = "prototype",proxyMode = ScopedProxyMode.TARGET_CLASS)
后,锁会失效(多例模式默认没有代理,如果需要就立刻创建;JDK代理基于接口,需要定义接口;CGLIB代理基于类,SpringBoot2.x后默认CGLIB,不需要写接口,只需要写实现类) -
当用Nginx代理,开启多个JVM也会失效
-
当时有
@Transactional
和锁一起存在的时候,因为MySQL的默认隔离级别为RR,并发时前一个请求还未提交,后一个请求查询到未提交的数据,造成并发失效
2、MySQL锁
2.1 简介
除了使用jvm锁之外,还可以使用数据锁:悲观锁 或者 乐观锁
-
sql语句:直接更新时判断,在更新中判断库存是否大于0(比JVM锁快,但是是表级锁,且无法记录前后状态,只能一条语句);
update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0 ;
-
悲观锁:在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续(速度和JVM锁差不多);
select ... for update
-
乐观锁:读取数据时不锁,更新时检查是否数据已经被更新过,如果是则取消当前更新进行重试;
version 或者 时间戳(CAS思想)
2.2 悲观锁+ @Transactional
悲观锁使用行级锁条件:
-
锁的查询或更新必须是索引字段
-
查询或更新条件必须是具体值
在SELECT 的读取锁定主要分为两种方式:
-
SELECT ... LOCK IN SHARE MODE
(共享锁) -
SELECT ... FOR UPDATE
(悲观锁,推荐)
这两种方式在事务(Transaction) 进行当中SELECT 到同一个数据表时,都必须等待其它事务数据被提交(Commit)后才会执行。而主要的不同在于LOCK IN SHARE MODE
在有一方事务要Update 同一个表单时很容易造成死锁。简单的说,如果SELECT 后面若要UPDATE 同一个表单,最好使用SELECT ... FOR UPDATE
注意:使用悲观锁时最好都使用SELECT ... FOR UPDATE
,这样可以避免查询数据的不一致性,即脏数据;另外需要注意因为查询顺序不同造成的思死锁问题
2.3 乐观锁
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则重试。
使用数据版本(Version)记录机制实现,这是乐观锁最常用的实现方式。一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录 的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新。
1 | // 这里不用加事务,否则会连接超时,MDL操作update会自动锁 |
但是乐观锁在并发量越大的情况下,性能越低(因为需要大量的重试);并发量越小,性能越高;同时会产生ABA问题;读写分离情况下乐观锁不可靠
2.4 总结
性能:sql语句 > 悲观锁 > jvm锁 > 乐观锁。
如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下,优先选择一个sql;如果写并发量较低(多读),争抢不是很激烈的情况下优先选择乐观锁;如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试,优先选择mysql悲观锁;不推荐jvm本地锁。
3、Redis乐观锁
利用redis监听 + 事务;如果执行过程中stock的值没有被其他连接改变,则执行成功;如果执行过程中stock的值被改变,则执行失败(性能较低,不推荐)
1 | # watch可以监控个或者多个key的值,如果在事务(exec)执行之前,key的值发生变化则取消事务执行 |
对应spring代码
1 | public void deduct() { |
三、基于Redis实现分布式锁
1、Redis的Lua脚本
1.1 介绍
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
可以参考:Nginx二次开发
Lua 特性
-
轻量级:它用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,可以很方便的嵌入别的程序里。
-
可扩展:Lua提供了非常易于使用的扩展接口和机制:由宿主语言(通常是C或C++)提供这些功能,Lua可以使用它们,就像是本来就内置的功能一样。
-
其它特性:
-
支持面向过程(procedure-oriented)编程和函数式编程(functional programming);
-
自动内存管理;只提供了一种通用类型的表(table),用它可以实现数组,哈希表,集合,对象;
-
语言内置模式匹配;闭包(closure);函数也可以看做一个值;提供多线程(协同进程,并非操作系统所支持的线程)支持;
-
通过闭包和table可以很方便地支持面向对象编程所需要的一些关键机制,比如数据抽象,虚函数,继承和重载等。
-
因为MULTI/ EXEC方法来使用事务功能,将一组命令打包执行,无法进行业务逻辑的操作。这期间有某一条命令执行报错(例如给字符串自增),其他的命令还是会执行,并不会回滚,因此需要Lua脚本来保证原子性
1.2 Lua基本使用
1 | a = 5 -- 全局变量 |
1.3 redis执行 - EVAL指令
在redis中需要通过eval命令执行lua脚本
1 | EVAL script numkeys key [key ...] arg [arg ...] |
案例举例
1 | # 基本案例 |
1.4 使用lua保证删除原子性
删除的Lua脚本
1 | if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end |
代码实现,保证原子性
1 | public void deduct() { |
2、Redis分布式锁基本实现
2.1基本实现
借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)等待重试;获取锁成功的请求执行业务逻辑,执行完后释放锁
1 |
|
其中加锁也可以使用循环
1 | // 加锁,获取锁失败重试 |
2.2 防死锁
产生原因:setnx刚刚获取到锁,当前服务器宕机,导致del释放锁无法执行,进而导致锁无法锁无法释放(死锁)
解决:给锁设置过期时间,自动释放锁。设置过期时间两种方式:
-
通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
-
使用set指令设置过期时间:
set key value ex 3 nx
(既达到setnx的效果,又设置了过期时间)
2.3 防误删
产生原因:锁到期可能会释放其他服务器的锁,但其业务逻辑还未处理完成
解决:上锁的时候加入UUID,删除的时候先查询,如果是自己的锁就进行删除,否则不做处理
2.4 可重入锁
可重入锁原理
可重入锁加锁流程:ReentrantLock.lock ()→Nonfairsync.lock()→AQS.acquire(1)→NonfairSync.tryAcquire(1)→Sync.nonfairTryAcquire(1)
-
CAS获取锁,如果没有线程占用锁(state==0),加锁成功并记录当前线程是有锁线程(两次)
-
如果state的值不为0,说明锁已经被占用。则判断当前线程是否是有锁线程,如果是则重入(state + 1)
-
否则加锁失败,入队等侍
可重入锁解锁流程:ReentrantLock.unlock()→Ags.release (1)→Sync.tryRelease(1)
-
判断当前线程是否是有锁线程,不是则抛出异常
-
对state的值减1之后,判断state的值是否为0,为0则解锁成功,返回true
-
如果减1后的值不为0,则返回false
redis模拟可重入锁
加锁脚本
Redis 提供了 Hash (哈希表)这种可以存储键值对数据结构。所以我们可以使用 Redis Hash 存储的锁的重入次数,然后利用 lua 脚本判断逻辑。
假设值为:KEYS:[lock], ARGV[uuid, expire]
如果锁不存在或者这是自己的锁,就通过hincrby(不存在就新增并加1,存在就加1)获取锁或者锁次数加1。
1 | if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) |
解锁脚本
1 | -- 判断 hash set 可重入 key 的值是否等于 0 |
代码实现
DistributedLockClient工厂类具体实现
1 |
|
DistributedRedisLock实现
1 | public class DistributedRedisLock implements Lock { |
使用测试
1 | public void deduct() { |
2.5 自动续期
lua脚本
1 | if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then |
在RedisDistributeLock中添加renewExpire方法
1 | public class DistributedRedisLock implements Lock { |
2.6 手写分布式锁小结
特征
-
独占排他:
setnx
-
防死锁:
redis客户端程序获取到锁之后,立马宕机。给锁添加过期时间
不可重入:可重入
-
防误删:
先判断是否自己的锁才能删除
-
原子性:
加锁和过期时间之间:
set k v ex 3 nx
判断和释放锁之间:lua脚本
-
可重入性:
hash(key field value) + lua脚本
-
自动续期:
Timer定时器 + lua脚本
-
在集群情况下,导致锁机制失效:
-
客户端程序10010,从主中获取锁
-
从还没来得及同步数据,主挂了
-
于是从升级为主
-
客户端程序10086就从新主中获取到锁,导致锁机制失效
-
锁操作之加锁
-
setnx:独占排他 死锁、不可重入、原子性
-
set k v ex 30 nx:独占排他、死锁 不可重入
-
hash + lua脚本:可重入锁
-
判断锁是否被占用(exists),如果没有被占用则直接获取锁(hset/hincrby)并设置过期时间(expire)
-
如果锁被占用,则判断是否当前线程占用的(hexists),如果是则重入(hincrby)并重置过期时间(expire)
-
否则获取锁失败,将来代码中重试
-
-
Timer定时器 + lua脚本:实现锁的自动续期
判断锁是否自己的锁(hexists == 1),如果是自己的锁则执行expire重置过期时间
锁操作之解锁
-
del:导致误删
-
先判断再删除同时保证原子性:lua脚本
-
hash + lua脚本:可重入 1. 判断当前线程的锁是否存在,不存在则返回nil,将来抛出异常
-
存在则直接减1(hincrby -1),判断减1后的值是否为0,为0则释放锁(del),并返回1
-
不为0,则返回0
-
重试:递归 循环
3、红锁算法
redis官方网站针对redlock文档:https://redis.io/topics/distlock
在算法的分布式版本中,我们假设有N个Redis服务器,上述分布式锁在集群情况会失效。这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统。**前几节已经描述了如何在单个实例中安全地获取和释放锁,在分布式锁算法中,将使用相同的方法在单个实例中获取和释放锁。**将N设置为5是一个合理的值,因此需要在不同的计算机或虚拟机上运行5个Redis主服务器,确保它们以独立的方式发生故障。为了获取锁,客户端执行以下操作:
-
客户端以毫秒为单位获取当前时间的时间戳,作为起始时间
-
客户端尝试在所有N个实例中顺序使用相同的键名、相同的随机值来获取锁定。每个实例尝试获取锁都需要时间,客户端应该设置一个远小于总锁定时间的超时时间。例如,如果自动释放时间为10秒,则尝试获取锁的超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点进行通信:如果某个实例不可用,尽快尝试与下一个实例进行通信
-
客户端获取当前时间 减去在步骤1中获得的起始时间,来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例(至少3个)中获取锁时,并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁
-
如果获取了锁,则将锁有效时间减去 获取锁所花费的时间,如步骤3中所计算
-
如果客户端由于某种原因(无法锁定N / 2 + 1个实例或有效时间为负)而未能获得该锁,它将尝试解锁所有实例(即使没有锁定成功的实例)
每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来产生很小的时钟漂移。只有在拥有锁的客户端将在锁有效时间内(如步骤3中获得的)减去一段时间(仅几毫秒)的情况下终止工作,才能保证这一点。以补偿进程之间的时钟漂移
当客户端无法获取锁时,它应该在随机延迟后重试,以避免同时获取同一资源的多个客户端之间不同步(这可能会导致脑裂的情况:没人胜)。同样,客户端在大多数Redis实例中尝试获取锁的速度越快,出现裂脑情况(以及需要重试)的窗口就越小,因此理想情况下,客户端应尝试将SET命令发送到N个实例同时使用多路复用。
值得强调的是,对于未能获得大多数锁的客户端,尽快释放(部分)获得的锁有多么重要,这样就不必等待锁定期满才能再次获得锁(但是,如果发生了网络分区,并且客户端不再能够与Redis实例进行通信,则在等待密钥到期时需要付出可用性损失)
四、Redission分布式锁
1、redission介绍
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
官方文档地址:https://github.com/redisson/redisson/wiki
2、Redisson原理介绍
2.1 介绍
Redisson锁有两种模式:
-
固定有效期的锁:超过有效期leaseTime后,自动释放锁
-
没有有效期的锁:默认30秒,然后采用Watchdog进行续期,直到业务逻辑执行完毕(一般用这个)
基于Redis的Redisson分布式可重入锁RLock
Java对象实现了java.util.concurrent.locks.Lock
接口。大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout
来另行指定。
RLock
对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException
错误。另外Redisson还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
使用redisson实现分布式锁的操作步骤,三部曲
-
第一步: 获取锁 RLock redissonLock = redisson.getLock(lockKey);
-
第二步: 加锁,实现锁续命功能 redissonLock.lock();
-
第三步:释放锁 redissonLock.unlock();
1 | RLock lock = redisson.getLock("anyLock"); |
2.2 分布式锁原理
Redisson分布式锁源码分析
redisson.getLock(lockKey) 的逻辑
-
super(commandExecutor, name); 父类name赋值,后续通过getName()获取
-
commandExecutor: 执行lua脚本的executor
-
id 是个UUID, 后面被用来当做 和threadId组成 value值,用作判断加锁和释放锁是否是同一个线程的校验。
-
internalLockLeaseTime : 取自 Config#lockWatchdogTimeout,默认30秒,这个参数还有另外一个作用,锁续命的执行周期 internalLockLeaseTime/3 = 10秒
1 |
|
redissonLock.lock()的逻辑
这里讲解的是使用默认看门狗的lock方法,解锁方法类似。在调用lock方法时,会最终调用到tryAcquireAsync。调用链为:lock()->tryAcquire->tryAcquireAsync
1 | private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { |
scheduleExpirationRenewal
中会调用renewExpiration
。 这里我们可以看到是,启用了一个timeout定时,去执行延期动作
1 | private void renewExpiration() { |
最终 scheduleExpirationRenewal
会调用到 renewExpirationAsync
,执行下面这段 lua脚本。他主要判断就是 这个锁是否在redis中存在,如果存在就进行 pexpire 延期
1 | protected RFuture<Boolean> renewExpirationAsync(long threadId) { |
-
watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
-
watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
-
如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
-
要使 watchLog机制生效 ,lock时 不要设置 过期时间
-
watchlog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小。如100
-
watchdog 会每 lockWatchdogTimeout/3时间,去延时。
-
watchdog 通过 类似netty的 Future功能来实现异步延时
-
watchdog 最终还是通过 lua脚本来进行延时
3、可重入锁(Reentrant Lock)
引入依赖
1 | <dependency> |
添加配置
1 |
|
代码使用
1 |
|
4、公平锁(Fair Lock)
基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock
接口的一种RLock
对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
1 | RLock fairLock = redisson.getFairLock("anyLock"); |
5、联锁(MultiLock)
基于Redis的Redisson分布式联锁RedissonMultiLock对象可以将多个RLock
对象关联为一个联锁,每个RLock
对象实例可以来自于不同的Redisson实例。
1 | RLock lock1 = redissonInstance1.getLock("lock1"); |
6、红锁(RedLock)
基于Redis的Redisson红锁RedissonRedLock
对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock
对象关联为一个红锁,每个RLock
对象实例可以来自于不同的Redisson实例
1 | RLock lock1 = redissonInstance1.getLock("lock1"); |
7、读写锁(ReadWriteLock)
基于Redis的Redisson分布式可重入读写锁RReadWriteLock Java对象实现了java.util.concurrent.locks.ReadWriteLock
接口。其中读锁和写锁都继承了RLock接口。分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
1 | RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock"); |
8、信号量(Semaphore)
基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore
采用了与java.util.concurrent.Semaphore
相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
1 | RSemaphore semaphore = redisson.getSemaphore("semaphore"); |
9、闭锁(CountDownLatch)
基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch
采用了与java.util.concurrent.CountDownLatch
相似的接口和用法。
1 | RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch"); |
五、基于zookeeper实现分布式锁
1、zk介绍
1.1 安装启动
安装:把zk安装包上传到/opt目录下,并切换到/opt目录下,执行以下指令
1 | # 解压 |
1.2 相关概念
Zookeeper提供一个多层级的节点命名空间(节点称为znode),每个节点都用一个以斜杠(/)分隔的路径表示,而且每个节点都有父节点(根节点除外),非常类似于文件系统。并且每个节点都是唯一的。znode节点有四种类型:
-
PERSISTENT:永久节点。客户端与zookeeper断开连接后,该节点依旧存在
-
EPHEMERAL:临时节点。客户端与zookeeper断开连接后,该节点被删除
-
PERSISTENT_SEQUENTIAL:永久节点、序列化。客户端与zookeeper断开连接后,该节点依旧存在,只是Zookeeper给该节点名称进行顺序编号
-
EPHEMERAL_SEQUENTIAL:临时节点、序列化。客户端与zookeeper断开连接后,该节点被删除,只是Zookeeper给该节点名称进行顺序编号
1 | [zk: localhost:2181(CONNECTED) 0] create /aa test # 创建持久化节点 |
事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,zookeeper会通知客户端(一次性)。当前zookeeper针对节点的监听有如下四种事件:
-
节点创建:stat -w /xx
当/xx节点创建时:NodeCreated
-
节点删除:stat -w /xx
当/xx节点删除时:NodeDeleted
-
节点数据修改:get -w /xx
当/xx节点数据发生变化时:NodeDataChanged
-
子节点变更:ls -w /xx
当/xx节点的子节点创建或者删除时:NodeChildChanged
1.3 Java客户端
ZooKeeper的java客户端有:原生客户端、ZkClient、Curator框架(类似于redisson,有很多功能性封装),这里先用原生客户端
首先引入依赖
1 | <dependency> |
常用api及其方法
1 | public class ZkTest { |
2、ZK分布式锁基本实现
2.1 思路分析
分布式锁的步骤:
-
获取锁:create一个节点
-
删除锁:delete一个节点
-
重试:没有获取到锁的请求重试
参照redis分布式锁的特点:
-
互斥 排他
-
防死锁:
-
可自动释放锁(临时节点) :获得锁之后客户端所在机器宕机了,客户端没有主动删除子节点;如果创建的是永久的节点,那么这个锁永远不会释放,导致死锁;由于创建的是临时节点,客户端宕机后,过了一定时间zookeeper没有收到客户端的心跳包判断会话失效,将临时节点删除从而释放锁。
-
可重入锁:借助于ThreadLocal
-
-
防误删:宕机自动释放临时节点,不需要设置过期时间,也就不存在误删问题。
-
加锁/解锁要具备原子性
-
单点问题:使用Zookeeper可以有效的解决单点问题,ZK一般是集群部署的。
-
集群问题:zookeeper集群是强一致性的,只要集群中有半数以上的机器存活,就可以对外提供服务。
2.2 基本实现
实现思路:
-
多个请求同时添加一个相同的临时节点,只有一个可以添加成功。添加成功的获取到锁
-
执行业务逻辑
-
完成业务流程后,删除节点释放锁。
由于zookeeper获取链接是一个耗时过程,这里可以在项目启动时,初始化链接,并且只初始化一次。借助于spring特性,代码实现如下(下面只完成了lock(),trylock()方法可以按照redis仿写)
1 |
|
zk分布式锁具体实现
1 | public class ZkDistributedLock { |
改造StockService的checkAndLock方法
1 |
|
基本实现存在的问题,由于无限自旋影响性能
-
性能一般(比mysql分布式锁略好)
-
不可重入
2.3 性能优化(阻塞非自旋)
由于无限自旋影响性能,当每个请求要想正常的执行完成,最终都是要创建节点,如果能够避免争抢必然可以提高性能。这里借助于zk的临时序列化节点,实现分布式锁
对于这个算法有个极大的优化点:假如当前有1000个节点在等待锁,如果获得锁的客户端释放锁时,这1000个客户端都会被唤醒,这种情况称为“羊群效应”;在这种羊群效应中,zookeeper需要通知1000个客户端,这会阻塞其他的操作,最好的情况应该只唤醒新的最小节点对应的客户端。应该怎么做呢?在设置事件监听时,每个客户端应该对刚好在它之前的子节点设置事件监听,例如子节点列表为/locks/lock-0000000000、/locks/lock-0000000001、/locks/lock-0000000002,序号为1的客户端监听序号为0的子节点删除消息,序号为2的监听序号为1的子节点删除消息。
所以调整后的分布式锁算法流程如下:
-
客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/locks/lock-0000000000,第二个为/locks/lock-0000000001,以此类推;
-
客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通知后重复此步骤直至获得锁;
-
执行业务代码;
-
完成业务流程后,删除对应的子节点释放锁
1 | public class ZkDistributedLock { |
2.4 优化:可重入锁
引入ThreadLocal线程局部变量保证zk分布式锁的可重入性
1 | public class ZkDistributedLock { |
2.5 小结
参照redis分布式锁的特点:
-
互斥 排他:zk节点的不可重复性,以及序列化节点的有序性
-
防死锁:
-
可自动释放锁:临时节点
-
可重入锁:借助于ThreadLocal
-
-
防误删:临时节点
-
加锁/解锁要具备原子性
-
单点问题:使用Zookeeper可以有效的解决单点问题,ZK一般是集群部署的。
-
集群问题:zookeeper集群是强一致性的,只要集群中有半数以上的机器存活,就可以对外提供服务。
-
公平锁:有序性节点
3、Curator中的分布式锁
3.1 简介
Curator是netflix公司开源的一套zookeeper客户端,目前是Apache的顶级项目。与Zookeeper提供的原生客户端相比,Curator的抽象层次更高,简化了Zookeeper客户端的开发量。Curator解决了很多zookeeper客户端非常底层的细节开发工作,包括连接重连、反复注册wathcer和NodeExistsException 异常等。
通过查看官方文档,可以发现Curator主要解决了三类问题:
-
封装ZooKeeper client与ZooKeeper server之间的连接处理
-
提供了一套Fluent风格的操作API
-
提供ZooKeeper各种应用场景(recipe, 比如:分布式锁服务、集群领导选举、共享计数器、缓存机制、分布式队列等)的抽象封装,这些实现都遵循了zk的最佳实践,并考虑了各种极端情况
Curator由一系列的模块构成,对于一般开发者而言,常用的是curator-framework和curator-recipes:
-
curator-framework:提供了常见的zk相关的底层操作
-
curator-recipes:提供了一些zk的典型使用场景的参考。本节重点关注的分布式锁就是该包提供的
3.2 依赖引入与配置
1 | <dependency> |
添加curator客户端配置
1 |
|
3.3 可重入锁InterProcessMutex
Reentrant和JDK的ReentrantLock类似, 意味着同一个客户端在拥有锁的同时,可以多次获取,不会被阻塞。它是由类InterProcessMutex来实现
1 | // 常用构造方法 |
改造,如想重入,则需要使用同一个InterProcessMutex对象
1 |
|
加锁解锁原理可以参考:Curator - 分布式锁的实现原理 & 如何使用
Curator实现分布式锁的基本原理
3.4 不可重入锁InterProcessSemaphoreMutex
具体实现:InterProcessSemaphoreMutex
。与InterProcessMutex调用方法类似,区别在于该锁是不可重入的,在同一个线程中不可重入
1 | public InterProcessSemaphoreMutex(CuratorFramework client, String path); |
代码举例
1 |
|
3.5 可重入读写锁InterProcessReadWriteLock
类似JDK的ReentrantReadWriteLock
。一个拥有写锁的线程可重入读锁,但是读锁却不能进入写锁。这也意味着写锁可以降级成读锁。从读锁升级成写锁是不成的。主要实现类InterProcessReadWriteLock
1 | // 构造方法 |
注意:写锁在释放之前会一直阻塞请求线程,而读锁不会
1 | public void testZkReadLock() { |
3.6 联锁InterProcessMultiLock
Multi Shared Lock是一个锁的容器。当调用acquire, 所有的锁都会被acquire,如果请求失败,所有的锁都会被release。同样调用release时所有的锁都被release(失败被忽略)。基本上,它就是组锁的代表,在它上面的请求释放操作都会传递给它包含的所有的锁。实现类InterProcessMultiLock(不常用)
1 | // 构造函数需要包含的锁的集合,或者一组ZooKeeper的path |
3.7 信号量InterProcessSemaphoreV2
一个计数的信号量类似JDK的Semaphore
。JDK中Semaphore维护的一组许可(permits),而Cubator中称之为租约(Lease)。注意,所有的实例必须使用相同的numberOfLeases值。调用acquire会返回一个租约对象。客户端必须在finally中close这些租约对象,否则这些租约会丢失掉。但是,如果客户端session由于某种原因比如crash丢掉, 那么这些客户端持有的租约会自动close, 这样其它客户端可以继续使用这些租约。主要实现类InterProcessSemaphoreV2
1 | // 构造方法 |
代码举例
1 | public void testSemaphore() { |
3.8 栅栏barrier
DistributedBarrier构造函数中barrierPath参数用来确定一个栅栏,只要barrierPath参数相同(路径相同)就是同一个栅栏。通常情况下栅栏的使用如下:
-
主client设置一个栅栏
-
其他客户端就会调用waitOnBarrier()等待栅栏移除,程序处理线程阻塞
-
主client移除栅栏,其他客户端的处理程序就会同时继续运行
DistributedBarrier类的主要方法如下:
1 | setBarrier() - 设置栅栏 |
DistributedDoubleBarrier双栅栏,允许客户端在计算的开始和结束时同步。当足够的进程加入到双栅栏时,进程开始计算,当计算完成时,离开栅栏。DistributedDoubleBarrier实现了双栅栏的功能
1 | // client - the client |
memberQty是成员数量,当enter方法被调用时,成员被阻塞,直到所有的成员都调用了enter。当leave方法被调用时,它也阻塞调用线程,直到所有的成员都调用了leave。注意:参数memberQty的值只是一个阈值,而不是一个限制值。当等待栅栏的数量大于或等于这个值栅栏就会打开!与栅栏(DistributedBarrier)一样,双栅栏的barrierPath参数也是用来确定是否是同一个栅栏的,双栅栏的使用情况如下:
-
从多个客户端在同一个路径上创建双栅栏(DistributedDoubleBarrier),然后调用enter()方法,等待栅栏数量达到memberQty时就可以进入栅栏。
-
栅栏数量达到memberQty,多个客户端同时停止阻塞继续运行,直到执行leave()方法,等待memberQty个数量的栅栏同时阻塞到leave()方法中。
-
memberQty个数量的栅栏同时阻塞到leave()方法中,多个客户端的leave()方法停止阻塞,继续运行。
3.9 共享计数器
利用ZooKeeper可以实现一个集群共享的计数器。只要使用相同的path就可以得到最新的计数器值, 这是由ZooKeeper的一致性保证的。Curator有两个计数器, 一个是用int来计数,一个用long来计数
1 | // 构造方法 |
使用案例
1 | public void testZkShareCount() { |
DistributedAtomicNumber
DistributedAtomicNumber接口是分布式原子数值类型的抽象,定义了分布式原子数值类型需要提供的方法。DistributedAtomicNumber接口有两个实现:DistributedAtomicLong
和 DistributedAtomicInteger
DistributedAtomicLong除了计数的范围比SharedCount大了之外,比SharedCount更简单易用。它首先尝试使用乐观锁的方式设置计数器, 如果不成功(比如期间计数器已经被其它client更新了), 它使用InterProcessMutex方式来更新计数值。此计数器有一系列的操作:
-
get(): 获取当前值
-
increment():加一
-
decrement(): 减一
-
add():增加特定的值
-
subtract(): 减去特定的值
-
trySet(): 尝试设置计数值
-
forceSet(): 强制设置计数值
你必须检查返回结果的succeeded(), 它代表此操作是否成功。如果操作成功, preValue()代表操作前的值, postValue()代表操作后的值。
六、基于mysql实现分布式锁
1、介绍
不管是jvm锁还是mysql锁,为了保证线程的并发安全,都提供了悲观独占排他锁。所以独占排他也是分布式锁的基本要求
对于Mysql可以利用唯一键索引不能重复插入的特点实现
1 | CREATE TABLE `tb_lock` ( |
2、基本思路
synchronized关键字和ReetrantLock锁都是独占排他锁,即多个线程争抢一个资源时,同一时刻只有一个线程可以抢占该资源,其他线程只能阻塞等待,直到占有资源的线程释放该资源
-
线程同时获取锁(insert)
-
获取成功,执行业务逻辑,执行完成释放锁(delete)
-
其他线程等待重试
3、代码实现
最后测试发现其性能非常差
1 |
|
4、缺陷和解决方
-
这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
解决方案:给 锁数据库 搭建主备
-
这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
解决方案:只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
-
这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
解决方案:记录获取锁的主机信息和线程信息,如果相同线程要获取锁,直接重入。
-
受制于数据库性能,并发能力有限。
解决方案:无法解决。
七、总结
-
实现的复杂性或者难度角度:Zookeeper > redis > 数据库
-
实际性能角度:redis > Zookeeper > 数据库
-
可靠性角度:Zookeeper > redis = 数据库
这三种方式都不是尽善尽美,我们可以根据实际业务情况选择最适合的方案:如果追求极致性能可以选择reds方案;如果追求可靠性可以选择zk
常见锁分类:
-
悲观锁:具有强烈的独占和排他特性,在整个数据处理过程中,将数据处于锁定状态。适合于写比较多,会阻塞读操作。 乐观锁:采取了更加宽松的加锁机制,大多是基于数据版本( Version )及时间戳来实现。。适合于读比较多,不会阻塞读
-
独占锁、互斥锁、排他锁:保证在任一时刻,只能被一个线程独占排他持有。synchronized、ReentrantLock 共享锁:可同时被多个线程共享持有。CountDownLatch到计数器、Semaphore信号量
-
可重入锁:又名递归锁。同一个线程在外层方法获取锁的时候,在进入内层方法时会自动获取锁。 不可重入锁:例如早期的synchronized
-
公平锁:有优先级的锁,先来先得,谁先申请锁就先获取到锁 非公平锁:无优先级的锁,后来者也有机会先获取到锁
-
自旋锁:当线程尝试获取锁失败时(锁已经被其它线程占用了),无限循环重试尝试获取锁; 阻塞锁:当线程尝试获取锁失败时,线程进入阻塞状态,直到接收信号后被唤醒。在竞争激烈情况下,性能较高
-
读锁:共享锁 写锁:独占排他锁
-
偏向锁:一直被一个线程所访问,那么该线程会自动获取锁 轻量级锁(CAS):当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。 重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候(10次),还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。 以上其实是synchronized的锁升级过程
-
表级锁:对整张表加锁,加锁快开销小,不会出现死锁,但并发度低,会增加锁冲突的概率 行级锁:是mysql粒度最小的锁,只针对操作行,可大大减少锁冲突概率,并发度高,但加锁慢,开销大,会出现死锁
参考文章