一、数据库实现分布式锁
1、悲观锁
如果获取锁失败,就一直阻塞等待。
比如,有一张资源锁表:
CREATE TABLE `resource_lock`
`id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的资源名',
`owner` varchar(64) NOT NULL DEFAULT '' COMMENT '锁拥有者',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT '' COMMENT '自动生成保存数据时间'
PRIMARY KEY(`id`),
UNIQUE KEY `uid_resource_name` (`resource_name`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET = utf8 COMMENT = '锁定中的资源';
resource_name 锁定的资源名必须有唯一索引。必须添加事务,查询和更新操作保证原子性,在一个事务里完成。
伪代码实现:
@Transcation
public void lock(String name){
ResourceLock rlock = exeSql("select * from resource_lock where resource_name = name for update");
if(rlock ! = null) {
exeSql("insert into resource_lock(resource_name,owner,count)
value (name,'ip',0)");
}
}
可以看到,使用 for update 锁定资源,如果执行成功,会执行后续的插入数据命令,直到事务提交,执行结束;否则,会一直阻塞着。
如果在数据库客户端工具上测试效果,当 在一个终端执行了 for update,不提交事务,在另外的终端上执行相同条件的 for update ,会被阻塞,(转圈圈😵)。虽然也能实现分布式锁的效果,但是存在性能瓶颈。
悲观锁优缺点:
优点:
- 简单易用,易理解,保障数据强一致性。
- 在 RR 事务级别,select 的 for update 操作是基于 间隙锁 gap lock 实现的,是一种悲观锁的实现方式,所以存在阻塞问题。
- 在高并发清空下,大量请求进来,会导致大部分请求进行排队,影响数据库稳定性,也会耗费服务的 CPU 等资源;还会造成占用过多的应用线程,导致业务无法正常响应。
- 如果优先获取锁的线程因为某些原因,一直没有释放掉锁,可能会导致死锁的发生;而且锁长时间不释放还会占用数据库连接。
- MySQL 数据库会做查询优化(即便使用了索引),优化时发现全表扫描效率更高,可能会把 行锁 升级为 表锁。
- 不支持可重入性,并且超时等待时间是全局的,不能随意改动。
2、乐观锁
如果获取锁失败,会重试或退出,不会一直阻塞等待。类似于 CAS 机制。
相比 悲观锁 中表的字段,添加了 “状态” 和 “版本号”,通过 版本号 或者 时间戳,来保证多线程同时间操作共享资源的有序性和正确性。
CREATE TABLE `resource` (
`id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的资源名',
`share` varchar(64) NOT NULL DEFAULT '' COMMENT '状态',
`version` int(4) NOT NULL DEFAULT '' COMMENT '版本号',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT '' COMMENT '自动生成保存数据时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_resource_name` (`resource_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定的资源';
伪代码实现:
Resource resource = exeSql("select * from resource where resource_name = xxx");
boolean succ = exesql("update resource set version = 'newVersion' ... where resource_name = xxx and version = 'oldVersion'");
while(! succ) {
//发起重试
}
可以看到,如果版本号不一致,就会更新失败,通过 while 循环去重新获取版本号并重试,直到更新成功。(根据具体需要,使用 if 也行)
乐观锁优缺点
优点:
- 简单易用,保障数据一致性。
缺点: - (在更新数据状态的一些情景下,如果不考虑幂等性,可以直接使用行锁来保证数据一致性:
update table set state = 1 where id = xxx and state =0
)加行锁会使性能有开销。 - 高并发情景下,线程内的 自旋 会耗费一定的 CPU 资源。
二、基于 Zookeeper 实现分布式锁
Zookeeper 是一种提供 分布式服务协调 的中心化服务,是以 Paxos 算法为基础实现的,是 Google 的 Chubby 的一个开源的实现,是 Hadoop 和 Hbase 的重要组件。
Zookeeper 数据节点 和 文件目录类似,(它俩还有一个共同的特点 就是 同一个目录下 文件名称不能重复 😄)同时具有 Watch 机制,基于这两个特性,得以实现分布式锁的功能。
1、数据节点
Zookeeper 提供一个多层级的节点命名空间,节点称为 Znode,节点是可以存储数据的,每个节点都用一个以斜杠 /
分隔的路径来表示,而且每个节点都有父节点 (根节点除外),非常类似于文件系统。可以使用 create /hjiajia nice
来创建节点,这个命令标识,在 根目录 下创建一个 hjiajia 节点,值是 nice。
节点类型可以分为 持久节点 PERSISTENT 【只要创建了这个节点,不论 Zookeeper 的客户端是否断开连接,服务器都会记录这个节点】、临时节点 EPHEMERAL 【客户端一旦断开连接,就不再保存这个节点】,每个节点还能被标记为有序性 SEQUENTIAL ,一旦节点被标记为有序性 ,那么整个节点就具有顺序自增的特点。
节点类型还有一种分类:
- 持久性节点
- 持久性顺序节点
- 临时性节点
- 临时性顺序节点
一般可以组合这几类节点 来创建我们所需要的节点,比如,创建一个持久节点作为父节点,在 父节点 下面创建临时节点,并标记临时节点为有序。
2、Watch 机制
Zookeeper 还提供了另外一个重要的特性 Watcher 事件监听器。
Zookeeper 允许客户端在指定节点上注册Watcher ,并且在一些特定事件 比如 数据改变、被删除、子目录节点增加或删除 ,触发的时候,Zookeeper服务端会将事件通知给客户端。
首先,我们需要建立一个父节点,节点类型为持久节点 PERSISTENT,如上图中的 /locks/lock_name1 节点,每当需要访问共享资源时,就会在父节点下建立相应的顺序子节点,节点类型为临时节点 EPHEMERAL,且标记为 有序性 SEQUENTIAL,并且以 临时节点名称 + 父节点名称 + 顺序号 组成特定的名字,如 /0000000001 、/0000000002、/0000000003 作为临时有序节点。
加锁流程
在建立子节点后,对 父节点下目所有 以 临时节点名称 name 开头的子节点进行排序,判断 刚刚建立的子节点顺序号是否是最小的节点,如果是,则获取锁,等 调用完共享资源后, 删除该节点,关闭 zk,进而可以触发监听事件(如果发生客户端宕机的情况 ,临时节点也会自动删除,然后触发监听器),释放该锁; 如果不是,说明之前已经有节点获取到锁了,则 调用 Object.wait() 或者 wait(timeout)阻塞 等待,并且 获得该节点的上一顺序节点,比如 /0000000002 的 上一顺序节点 是 /0000000001,在 /0000000001 节点上注册 Watcher,Watcher 会调用 Object.notifyAll() 来解除阻塞。也就是说,如果获取不到锁,只需要添加一个监听器,这样不用一直轮询,性能损耗较小。
💖
这里需要理解的是,是 客户端 在 节点上注册监视器,比如:
有 100 台服务器,服务器 1 创建了节点 /hjiajia,成功了,服务器获取到锁;接下来服务器 2 再去创建同样节点,就会失败,这时它只能监听这个节点的变化。
等到服务器 1 处理完业务,删除了节点后,服务器 2 就会接到通知,可以去创建相同的节点了,就会获取锁,处理业务,然后 删除节点,接下来的 98 台服务器都是类似的步骤。注意,这么多服务器并不是挨个去执行以上操作,而是并发的,当服务器 1 创建成功,那么剩下的 99 个服务器 就都会注册监听这个节点,等通知,依次类推。
然而这样可能出现死锁,如果服务器 1 创建了节点后宕机了,还没删除呢,那么剩下的 99 个服务器搁那一直等通知,就完球了。所以,”临时节点“ 就发挥了作用,对于临时节点,客户端一旦断开,就会丢失,这样的话,服务器 1 一旦宕机,节点就会被自动删除,后续其他服务器就可以继续创建节点了。然后还有一个问题,这个节点一有变化,就会通知剩下 99 个节点,但是最终只会有一个服务器创建成功,剩下那么多服务器还是需要等待监听,效率不高,所以 ”顺序性“就发挥作用了,不必让 99 个服务器监听一个节点,某一个服务器只是监听自己前面一个节点。
假设 100 个服务器同时发来请求,这个时候会在 /hjiajia 节点下创建 100 个临时顺序性节点 /hjiajia/000000001, /hjiajia/000000002,一直到 /zkjjj/000000100,这个编号就相当于 已经给它们设置了获取锁的先后顺序了。当 001 节点处理完毕,删除节点后,002 收到通知,去获取锁,开始执行,执行完毕,删除节点,通知 003… …以此类推。
源码:
// 加锁
// InterProcessMutex 是 Curator 实现的可重入锁
InterProcessMutex lock = new InterProcessMutex(client, lockPath);
// (一)
if ( lock.acquire(maxWait, waitUnit) )
{
try
{
// do some work inside of the critical section here
}
finally
{
lock.release();
}
}
(一)acquire():
public void acquire() throws Exception
{ // (二)
if ( !internalLock(-1, null) )
{
throw new IOException("Lost connection while trying to acquire lock: " + basePath);
}
}
(二)internalLock:
private boolean internalLock(long time, TimeUnit unit) throws Exception
{
/*
Note on concurrency: a given lockData instance
can be only acted on by a single thread so locking isn't necessary
*/
Thread currentThread = Thread.currentThread();
// (三)
LockData lockData = threadData.get(currentThread);
if ( lockData != null )
{
// re-entering
lockData.lockCount.incrementAndGet();
return true;
}
String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
if ( lockPath != null )
{
LockData newLockData = new LockData(currentThread, lockPath);
threadData.put(currentThread, newLockData);
return true;
}
return false;
}
// ... 其他代码略
代码中的注释译为:关于并发性的提醒:给定的 lockData 实例只能由单个线程操作,所以没必要锁定。
可以看到,可重入锁是记录在 ConcurrentMap<Thread,LockData> threadData
这个 Map 里面的。如果 (三) 处 threadData.get(currentThread) 不为 null,就给 lockCount 加锁次数加 1。
解锁流程
如果 可重入锁 次数减 1 后,加锁次数不为 0 则直接返回,否则 删除当前节点 和 threadDataMap 里面的可重入锁的数据。
Zookeeper 存在的缺点是:如果有大量的客户端频繁地申请加锁、释放锁,对于 ZK 集群的压力会比较大。