https://www.jianshu.com/p/350a5f891f11
0、前言
在很多场景中,我们为了保证数据的最终一致性
,需要很多的技术方案来支持,比如分布式事务
、分布式锁
等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案:
分布式锁一般有三种实现方式:
-
数据库锁;
-
基于Redis的分布式锁;
-
基于ZooKeeper的分布式锁。
分布式锁应该是怎么样的(6点)
-
互斥性
:可以保证在分布式部署的应用集群中,同一方法
在同一时间
只能被一台机器上的一个线程
执行。 -
可重入锁
(避免死锁
) -
不会发生死锁
:持有锁的客户端由于崩溃而没有解锁,也能保证其他客户端能够加锁 -
阻塞锁
(根据业务需求考虑要不要这条
) -
高可用
的获取锁
和释放锁
-
获取锁
和释放锁
的性能要好
1、数据库锁
1.1 基于数据库表
要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
★ 获得锁:当我们要锁住
某个方法
或资源
时,我们就在该表中增加一条记录
:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
对method_name
做了唯一性约束
,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
★ 释放锁:当方法执行完毕之后,想要释放锁
的话,删除这条记录即可:
delete from methodLock where method_name ='method_name'
★ 数据库的可用性?获得锁的客户端宕机?可重入?阻塞?
1、数据库是单点,宕机后,不可用?搞两个数据库
,数据库之间双向同步
,一旦挂掉快速切换到备库上。
2、没有失效时间,解锁失败,其他线程不能获得锁?做一个定时任务
,每隔一定时间把数据库中的超时数据清理一遍
。
3、非阻塞的
,其他线程插入失败,直接报错,没有获得锁的线程并不会进入排队队列
,要想再次获得锁就要再次触发获得锁操作?搞一个while循环
,直到insert成功再返回成功。
4、非重入的
?在数据库表中加个字段,记录当前获得锁
的机器的主机信息 和 线程信息
,那么下次再获取锁的时候,先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
1.2 基于数据库的排它锁
除了可以通过增 删
操作数据表中的记录以外,其实还可以借助数据库中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。
★ 获得锁:在查询语句
后面增加for update
,数据库会在查询过程中给数据库表增加排他锁
。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
★ 释放锁:commit或者事务异常
public void unlock(){
connection.commit();
}
★ 优点
-
实现阻塞:
for update语句
会在执行成功后立即返回,在执行失败时一直处于阻塞状态
,直到成功
。 -
服务器宕机:服务由于宕机而不能释放锁,
数据库 会自己 把锁释放掉
。
★ 缺点:
- 数据库单点故障
- 锁重入
基于数据库表的排他锁 总结
总结一下使用数据库来实现分布式锁
的方式,这两种方式都是依赖数据库的一张表
-
一种是通过表中的
记录的存在情况
确定当前是否有锁存在 -
另外一种是通过
数据库的排他锁for update
来实现分布式锁。
★ 优点:直接借助数据库,容易理解。
★ 缺点:操作数据库需要一定的开销
,性能
问题需要考虑。
1.3 乐观锁
乐观锁假设认为数据一般情况下不会造成冲突,只有在进行数据的提交更新时,才会检测数据的冲突情况,如果发现冲突了,则返回错误信息。
实现方式:
时间戳
(timestamp)记录机制实现:给数据库表增加一个时间戳字段类型的字段,当读取数据时,将timestamp字段的值一同读出,数据每更新一次
,timestamp也同步更新
。当对数据做提交更新操作时,检查当前数据库中
数据的时间戳和自己更新前取到的时间戳
进行对比,若相等,则更新
,否则认为是失效数据。若出现更新冲突,则需要上层逻辑修改,启动重试机制。
同样也可以使用version
的方式。
性能对比
(1) 悲观锁
实现方式是独占数据,其它线程需要等待,不会出现修改的冲突,能够保证数据的一致性,但是依赖数据库的实现,且在线程较多
时出现等待造成效率降低
的问题。一般情况下,对于数据很敏感
且读取频率较低
的场景,可以采用悲观锁的方式
(2) 乐观锁
可以多线程同时读取数据,若出现冲突,也可以依赖上层逻辑修改,能够保证高并发下的读取,适用于读取频率很高
而修改频率较少
的场景
(3) 由于库存回写数据
属于敏感数据
且读取频率适中
,所以建议使用悲观锁
优化
2、基于redis的分布式锁
★ 加锁:jedis.set(String key, String value, String nxxx, String expx, int time)
,这个set()方法一共有五个形参:
-
第一个为key,我们
使用key来当锁
,因为key是唯一的
。 -
第二个为value,传的是requestId,为什么还要用到value?原因就是加锁和解锁必须是同一个人,通过给value赋值为requestId,我们就知道这把
锁是哪个请求加的
了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。 -
第三个为nxxx,这个参数我们填的是
NX
,意思是SET IF NOT EXIST
,即当key不存在时
,我们进行set操作
;若key已经存在
,则不做任何操作
;保证互斥。 -
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个
过期的设置
,具体时间由第五个参数决定。 -
第五个为time,与第四个参数相呼应,代表key的
过期时间
。避免死锁:保证持有锁的客户机由于宕机而没有主动解锁,其他客户端也能加锁。
★ 解锁:首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁
错误实例:
使用jedis.setnx()
和jedis.expire()
组合实现加锁
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
setnx()
方法作用就是SET IF NOT EXIST
,expire()
方法就是给锁加过期时间
。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令
,不具有原子性
,如果程序在执行完setnx()之后突然崩溃
,导致锁没有设置过期时间
,那么将会发生死锁
。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。
★ 优点
- 基于
缓存
的分布式锁的性能
会更好 - redis可以
集群部署
,只要大部分
的Redis节点正常运行,就不会出现单点机器宕机
不可用的问题。
★ 缺点:
- 通过
超时时间
来控制锁的失效时间并不是十分的合适。
3、基于Zookeeper实现分布式锁
★ 实现原理:基于zookeeper临时有序节点
可以实现的分布式锁
。大致思想即为:每个客户端对某个方法加锁
时,在zookeeper上的与该方法对应
的指定节点的目录
下,生成一个唯一的 瞬时 有序 节点
。 判断是否获取锁的方式很简单,只需要判断有序节点中 序号最小的一个
。 当释放锁
的时候,只需将这个瞬时节点 删除
即可。
3.1 获取锁 与 释放锁 过程
转自
https://blog.csdn.net/wuzhiwei549/article/details/80692278#t2
3.1.1 获取锁
首先,在Zookeeper当中创建一个持久节点ParentLock
。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面,创建一个临时顺序节点 Lock1
。
之后,Client1查找
ParentLock下面所有的临时顺序节点
并排序
,判断自己所创建的节点Lock1是不是顺序最靠前的一个
。如果是第一个节点,则成功获得锁。
这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。
Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。
于是,Client2向排序仅比它靠前的节点Lock1 注册Watcher
,用于监听Lock1节点是否存在
。这意味着Client2抢锁失败
,进入了等待状态
。
这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。
Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。
于是,Client3向排序仅比它靠前的节点Lock2 注册Watcher
,用于监听Lock2节点是否存在
。这意味着Client3同样抢锁失败,进入了等待状态。
这样一来,Client1得到了锁
,Client2监听了Lock1
,Client3监听了Lock2
。这恰恰形成了一个等待队列
,很像是Java当中ReentrantLock
所依赖的
3.1.2 释放锁
释放锁分为两种情况:
1.任务完成,客户端显示释放
当任务完成时,Client1会显示 调用删除节点Lock1
的指令。
2.任务执行过程中,客户端崩溃
获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之 自动删除
。
由于Client2一直监听
着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知
。这时候Client2会再次查询
ParentLock下面的所有节点
,确认自己创建的节点Lock2是不是目前最小的节点
。如果是最小,则Client2顺理成章获得了锁。
同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。
最终,Client3成功得到了锁。
★ 优点:
-
客户端由于宕机而 不能释放锁问题:在
创建锁
的时候,客户端会在ZK中创建一个临时节点
,一旦客户端获取到锁之后 突然挂掉
(Session连接断开),那么这个临时节点 就会 自动删除掉
。其他客户端就可以再次获得锁。 -
非阻塞:使用Zookeeper可以
实现阻塞的锁
,客户端可以通过在ZK中创建顺序节点
,并且在节点上绑定监听器
,一旦节点有变化
,Zookeeper会通知客户端
,客户端可以检查自己创建的节点
是不是当前所有节点中 序号最小的
,如果是,那么自己就获取到锁,便可以执行业务逻辑了。 -
不可重入:使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的
主机信息和线程信息
直接写入到节点
中,下次想要获取锁的时候和当前最小的节点中的数据
比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁
,如果不一样
就再创建一个临时的顺序节点
,参与排队
。 -
单点问题:使用Zookeeper可以有效的解决单点问题,ZK是
集群部署
的,只要集群中有半数以上的机器存活
,就可以对外提供服务
。
★ 缺点:
- 在
性能上
,没有redis实现的性能高。因为每次在创建锁和释放锁
的过程中,都要动态创建、销毁 瞬时节点
来实现锁功能。ZK中创建和删除节点只能通过Leader服务器
来执行,然后将数据同步
到所有的Follower
机器上。
4、三种方案的比较
从理解的难易程度
角度(从低到高):数据库 > 缓存 > Zookeeper
从实现的复杂性
角度(从低到高):Zookeeper >= 缓存 > 数据库
从性能
角度(从高到低):缓存 > Zookeeper >= 数据库
从可靠性
角度(从高到低):Zookeeper > 缓存 > 数据库