这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战
在单机环境当中,我们是使用同步方法、同步块和重锁的机制,解决多线程环境下的数据安全问题。那么基于分布式环境,我们如何通过 Zookeeper 去生成分布式锁?
什么是分布式锁
在学习 Zookeeper 实现的分布式锁之前,我们先来了解什么是分布式锁,分布式锁的实现技术,以及分布式锁常用的类型。
在日常实际开发中,我们最熟悉也常用的分布式锁场景是在开发多线程的时候。为了协调本地应用上多个线程对某一资源的访问,就要对该资源或数值变量进行加锁,以保证在多线程环境下系统能够正确地运行。在一台服务器上的程序内部,线程可以通过系统进行线程之间的通信,实现加锁等操作。而在分布式环境下,执行事务的线程存在于不同的网络服务器中,要想实现在分布式网络下的线程协同操作,就要用到分布式锁。
分布式锁实现具备的条件:1、在分布式环境下,多个线程对资源的访问必须具有顺序性。2、在获取锁和释放锁的过程中,需要高可用和高性能。3、具有锁失效的机制以及避免死锁。4、非阻塞的锁,没有获取到锁直接返回获取锁失败。 实现分布式锁的技术有很多,比如我们可以使用 Memcached、Redis、Google 的 Chubby 以及接下来我们要学习 Zookeeper 来实现分布式锁。
共享锁:它在性能上要优于排他锁,这是因为在共享锁的实现中,只对数据对象的写操作加锁,而不为对象的读操作进行加锁。这样既保证了数据对象的完整性,也兼顾了多事务情况下的读取操作。可以说,共享锁是写入排他,而读取操作则没有限制。举一个现实生活中例子,你家有一个大门,大门的钥匙有好几把,你有一把,你女朋友有一把,你们都可能通过这把钥匙进入你们家,这个就是所谓的共享锁。
排他锁:又称为写锁或独占锁,如果事务T1对数据对象O1加上了排他锁,那么整个加锁期间,只允许事务T1对O1进行读取和更新操作,其他任何事务都不能对O1进行任何操作 - 直到T1释放了排他锁。
排他锁与共享锁的区别:加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数据对所有事务都可见。
Zookeeper 实现分布式锁的设计思路
我们在 Zookeeper 的服务器上先去创建一个节点,这个节点叫 /Locks,针对于每一个来申请锁资源的 Java 客户端,我们在 /Locks 节点下为其生成一个临时有序节点,这个临时有序节点我们在创建的过程当中,它的名称基于 /Locks 这个节点来进行创建,它的节点路径为 /Locks/Lock_。由于当前我们为 Java 客户端所创建的是一个临时有序节点,所以这个节点创建完成之后,它的完整路径 /Locks/Lock_ 以及当前节点的节点序号,如:/Locks/Lock_0000000001。
Java 客户端去获取当前 /Locks 节点下的所有子节点,针对于所有的子节点来进行排序,排序完成之后,判断当前客户端所生成的节点是否在排序结果的第一位。如果是在排序结果的第一位,说明当前这个客户端已经获得到了锁。获得锁之后可以执行相应代码,执行完代码之后释放锁。
如果当前 Java 客户端所生成的这个临时有序节点在排序的结果当中不是第一位,说明前面至少应该还有相应的一个或多个节点在进行排队,等待锁资源的获取。如果是这样的一个结果的话,我们就可以使当前客户端去监听自己所创建节点的前一个节点,前一个节点又会去监听前一个节点的前一个节点。那监听前一个节点的目的是为了什么呢?比如我们所创建的节点如果是 Lock_0000000002,它的前一个节点是 Lock_0000000001,我们可以通过 Watch 机制让 Lock_0000000002 去捕获 Lock_0000000001 这个节点的删除事件。
当 Lock_0000000001 这个节点被删除时,因为所有的 Java 客户端在申请锁的过程当中,大家在排队,当前节点如果去监听前一个节点被删除这个事件的话,说明前一个节点已经获得了锁,并且执行相应的代码。当释放锁的过程当中,将前一个节点删除掉了,那前一个节点已经释放了锁了,我当前 Java 客户端就可以去尝试再次去进行锁得获取的这么一个过程。
创建锁
我们通过 Java 客户端在 ZooKeeper 服务器上创建数据节点的方式来创建锁。
// 创建锁节点
private void createLock() throws Exception {
//判断 Locks 节点是否存在,不存在,需要创建
Stat stat = zooKeeper.exists(LOCK_ROOT_PATH, false);
if (stat == null) {
zooKeeper.create(LOCK_ROOT_PATH,new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
}
//创建临时有序节点
lockPath = zooKeeper.create(LOCK_ROOT_PATH + "/" + LOCK_NODE_NAME, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
System.out.println("节点创建成功:" + lockPath);
}
获取锁
当某一个事务在访问共享数据时,首先需要获取锁。
//尝试获取锁
private void attempLock() throws Exception {
// 获取 Locks 节点下的所有字节点
List<String> list = zooKeeper.getChildren(LOCK_ROOT_PATH, false);
Collections.sort(list);
int index = list.indexOf(lockPath.substring(LOCK_ROOT_PATH.length() + 1));
if (index == 0) {
System.out.println("获取锁成功");
return;
} else {
//上一个节点的路径
String path = list.get(index - 1);
Stat stat = zooKeeper.exists(LOCK_ROOT_PATH + "/" + path, watcher);
if (stat == null) {
attempLock();
} else {
synchronized (watcher) {
watcher.wait();
}
attempLock();
}
}
}
释放锁
事务逻辑执行完毕后,需要对事物线程占有的共享锁进行释放。我们可以利用 ZooKeeper 中数据节点的性质来实现主动释放锁和被动释放锁两种方式。
主动释放锁是当客户端的逻辑执行完毕,主动调用 delete 函数删除ZooKeeper 服务上的数据节点。而被动释放锁则利用临时节点的性质,在客户端因异常而退出时,ZooKeeper 服务端会直接删除该临时节点,即释放该共享锁。
// 释放锁
private void releaseLock() throws Exception {
zooKeeper.delete(this.lockPath,-1);
zooKeeper.close();
System.out.println("锁已经释放"+this.lockPath);
}
测试
public class TicketSeller {
private void sell(){
System.out.println("秒杀开始");
// 线程随机休眠数毫秒,模拟现实中的费时操作
int sleepMillis = 5000;
try {
//代表复杂逻辑执行了一段时间
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("秒杀结束");
}
public void sellTicketWithLock() throws Exception {
MyLocks lock = new MyLocks();
// 获取锁
lock.acquireLock();
sell();
//释放锁
lock.releaseLock();
}
public static void main(String[] args) throws Exception {
TicketSeller ticketSeller = new TicketSeller();
for(int i=0;i<10;i++){
ticketSeller.sellTicketWithLock();
}
}
}