注意
想法及时记录,实现可以待做。
简介
涉及到竞态条件的时候,一定会有线程安全的问题出现,此时加锁无疑是一种比较好的解决方案,保证的是一个时刻,共享资源只会被一次修改,避免数据不一致出现。
在一个JVM进程里面,如果想要加锁,有很多种方法,可以使用synchronized关键字,或者JUC并发包里面的锁,都可以在进程里面保证,多线程对于资源的并发访问。
但是如果在不同的进程之间呢?上述加锁的方式都失效了,跨进程的方式,更多常见于分布式的场景,于是分布式锁呼之欲出。
分析
单体应用的锁的方案比较简单,有很多语言自带的工具即可完成锁。但是对于集群或者分布式的应用场景,原理也是为了解决多资源的竞争获取问题。
- 互斥性:这点是锁的必要条件,要保证同一个时间的资源只能被一个线程获取执行
- 可重入性:相同的线程可以再次获取锁
- 超时机制:保证不能死锁,可以过期失效
- 可用性:锁的性能要高,并且可用性要好
常见的分布式锁的方案有:
- 基于MySQL
- 基于Redis
- 基于ZK
基于MySQL
基于数据库的分布式锁,利用的是唯一键约束,保证资源作为唯一键,只能被线程写入一次表,多个线程同时写入,一定会有唯一键约束冲突的问题出现,由此保证资源的唯一访问特性。
利用唯一键约束写入数据,如果成功,则进行业务逻辑的处理,完成后,删除该条数据,保证锁的释放。
该方案写入数据的时候需要注意,需要将客户端或者说线程的信息写入表里面,这样在删除数据的时候,对客户端的值做匹配,可以保证该条数据一定只能被匹配的线程删除,否则会被其他线程删除,这个方案就有问题了,也就是锁的释放问题。
- 建表语句
CREATE TABLE `lock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`lock_id` varchar(64) NOT NULL COMMENT '锁的ID,唯一确定',
`client_id` varchar(1024) NOT NULL COMMENT '客户端ID',
`count` int(11) NOT NULL DEFAULT 0 COMMENT '锁的次数',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_lock_id` (`lock_id`) USING BTREE
)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='锁';
- 操作数据库
2.1 sql
# 加锁
insert into `lock`(`lock_id`,`client_id`,`count`) values ('lock_id','client_id',1);
# 释放锁
delete from `lock` where `lock_id`='lock_id' and `client_id`= 'client_id';
# 重入锁,加1
update `lock` set count = count + 1 where `lock_id`='lock_id' and `client_id`= 'client_id';
# 重入锁,减1
update `lock` set count = count - 1 where `lock_id`='lock_id' and `client_id`= 'client_id';
# 加行锁
select * from `lock` where `lock_id`='lock_id' for update;
2.2 数据库层伪代码
// 需要在一个事务里面
public boolean lock(String lockId, String clientId){
if (查询'lockId'有数据) {
if(clientId与数据库里存的client相等) {
重入加锁sql,更新成功 返回true
} else {
是其他线程,锁被占用,抢锁失败,返回false
}
} else {
加锁sql,成功返回true
}
}
// 需要在一个事务里面
public boolean unlock(String lockId, String clientId){
if (查询'lockId'有数据) {
if(clientId与数据库里存的client相等) {
if(count>1){
重入释放锁sql,更新成功 返回true
}else{
释放锁
}
} else {
是其他线程,锁被占用,抢锁失败,返回false
}
} else {
没有数据,返回false
}
}
- 伪代码如下:
3.1 获取锁
public void lock(String lockId, String clientId, long timeout){
for(;;){
if(数据库层抢锁){
return;
}
// 这里也可以做成超时的逻辑,避免一直尝试
sleep(timeout);
}
}
3.2 释放锁
public void lock(String lockId, String clientId){
boolean success = false;
try{
// 释放锁
success = 数据库层释放锁
} catch(Exception e) {
}
return success;
}
- 小结
优点:接触容易、理解起来比较简单,不需要额外的维护 缺点:自己实现复杂,需要考虑的场景很多;性能不一定有保证;释放锁失败,资源就一直被占用,需要解决;单个节点可用性不强。
基于Redis
基于Redis实现分布式锁,利用的是Redis的高性能、原子性操作、超时过期机制。
单节点和多节点的实现方式也有所不同
- 单节点
1.1 加锁
可以通过redis的原子性命令即可完成,键和值需要保证唯一性,过期时间保证不会死锁。
SET resource_name my_random_value NX PX 30000
1.2 解锁
需要通过Lua脚本进行解锁,保证操作是原子性
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
1.3 存在的问题
- 单点故障:这会导致分布式锁的失效,晒后会提到多节点的高可用。
- 过期时间:虽然有过期时间可以保证即使客户端挂了,锁也能过期时效,但是也会引入另一个问题:业务处理时间比过期时间大,此时失效,会导致共享资源被多个客户端使用。
- 多节点
也就是官方提出来的Redlock算法,该算法假设,现在有N个Master节点的Redis集群,他们都是互相完全独立存在,没有副本集同步。比如N=5,也就是有5个master节点的redis实例。
客户端获取锁的步骤如下:
1、客户端获取当前时间,以毫秒为单位。
2、客户端尝试获取 N 个节点的锁,(每个节点获取锁的方式和前面说的缓存锁一样),N 个节点以相同的 key 和 value 获取锁。客户端需要设置接口访问超时,接口超时时间需要远远小于锁超时时间,比如锁自动释放的时间是 10s,那么接口超时大概设置 5-50ms。这样可以在有 redis 节点宕机后,访问该节点时能尽快超时,而减小锁的正常使用。
3、客户端计算在获得锁的时候花费了多少时间,方法是用当前时间减去在步骤一获取的时间,只有客户端获得了多数,比如 3 个节点的锁,而且获取锁的时间小于锁的超时时间,客户端才获得了分布式锁。
4、客户端获取的锁的时间为设置的锁超时时间减去步骤三计算出的获取锁花费时间。
5、如果客户端获取锁失败了,客户端会依次删除所有的锁。 使用 Redlock 算法,可以保证在挂掉最多 2 个节点的时候,分布式锁服务仍然能工作,这相比之前的数据库锁和单节点缓存锁大大提高了可用性,由于 redis 的高效性能,分布式缓存锁性能并不比数据库锁差。
对于Redlock算法,也有人提出了疑问。Martin Kleppmann认为这种算法也存在不安全,How to do distributed locking
- 对于Java的GC中STW问题,有可能导致多个客户端使用资源
- 多个redis的时钟问题,会导致锁过期
- 网络延迟,跟GC的问题引起的现象很类似,都会导致多个客户端使用共享资源
针对时钟的问题,与NTP服务器同不好,问题应该不大,这种也没有特别好的方法。
2.1 redisson
redisson是第三方redis客户端框架,其中提供了分布式锁的实现,包括单节点和多节点都有,其中也主要依赖Lua脚本做最终的原子性操作实现。
2.2 redisson单节点分布式锁
RedissonClient client = Redisson.create();
RLock rLock = client.getLock("resourceName");
// 加锁
rLock.lock();
// 解锁
rLock.unlock();
2.3 redisson多节点分布式锁
Redlock算法的实现
RedissonClient client1 = Redisson.create();
RLock rLock1 = client1.getLock("resourceName");
RLock rLock2 = client2.getLock("resourceName");
RLock rLock3 = client3.getLock("resourceName");
RLock redLock = new RedissonRedLock(rLock1,rLock2,rLock3);
// 加锁
redLock.lock();
// 解锁
redLock.unlock();
- 小结
优点:实现简单、性能相比较好 缺点:觉得单点可用性不高,多节点需要单独维护,有成本。没有完美的方案,都有一定的取舍。
基于ZK
zk本身就是一个集群提供服务,它提供了一种类似文件系统的结构存储数据。zk的节点特性,比如临时节点、顺序节点等可以帮助实现分布式锁的服务。
- 基本算法
- 多个客户端在一个相同的父节点下创建临时顺序节点
- 父节点下创建的临时有序节点中,是否具有最小的序号,如果是最小节点则获取锁,其他客户端监听比自己小的节点
- 持有锁的客户端在完成业务逻辑后,自己删除这个节点
- 其他客户端收到信号后,再次确认是否具有最小节点,以此类推
- 关键点 2.1 临时顺序节点
这个是实现分布式锁的关键所在,zk有四种类型的节点
- 永久节点:是持久化存在的,如果不主动删除,则一直存在
- 永久有序节点:持久化的有序节点,zk负责保证顺序
- 临时节点:临时存在的,当客户端的链接会话断开就会被删除
- 临时有序节点:临时存在的有序节点,zk保证顺序
2.2 事件监听
Zookeeper 提供了事件监听机制,我们可以通过监听节点、节点数据和子节点来等待锁
- 现有的框架Curator
这个是zk的一种客户端实现,其中已经有挂您与分布式锁的实现,即InterProcessMutex,抢锁、释放锁、可重入等等都有提供。它是一个公平锁。节点最小的优先执行。
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", retryPolicy);
client.start();
InterProcessMutex lock2 = new InterProcessMutex(client, "/test");
try {
lock.acquire();
//business
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.release();
}
- 小结
优点:实现方便,满足基本的功能需求。 缺点:zk在创建节点的时候,这个性能比之缓存直接在内存操作,肯定要慢不少,毕竟是磁盘的读写。 zk解决死锁的原理是客户端与服务器之间通过心跳建立连接,如果因网络问题导致的失联,则锁会被释放,需要客户端重试解决。 如果监听节点的客户端比较多,当客户端释放锁的时候,这些监听都会被通知,一般只需要编号最小的那个被通知即可,避免羊群效应,引起不必要的资源浪费。
参考
- Distributed locks with Redis
- 分布式锁看这篇就够了
- 终于懂什么是分布式锁
- 再有人问你分布式锁,这篇文章扔给他
- 几种分布式锁的实现方式
- [Rotate] Distributed Lock Implementation (2): Zookeeper