在并发编程中,锁是一个很重要的组件,也是控制并发安全的主要工具。从本质上说,锁就是一个存在公共区域的标志位,线程要访问有锁控制的区域需要先检查这个锁标志位。
本地锁如此,分布式锁也是这样。本地锁面向的是多线程,分布式锁面向的是多进程。实际本质上别无二致。
我们来审视一下zookeeper是什么,简单来看,它是一个很可靠的mini云数据库,这个数据库支持少量数据的存储,且它的数据库结构是一种树的结构,可以类比文件目录。所以首先从数据模型上看,zookeeper是一个存储树形数据结构的组件。其次再看zookeeper提供的特性,原子性、集群、有序性。这些强大的特性,使得zk可以作为一个分布式系统的仲裁者。
为什么这样说呢,首先集群的结构,使得zk具有高可用的特征,当zk的大多数节点活跃时,整个集群是可用的。
其次是原子性,原子性保证了集群中存储的数据的一致性,不会存在某个节点的数据和其他的不一样的情况。
然后是有序性,对zk而言,保证了对其操作的按序执行,不会发生后来的请求先执行的情况,这也保证了数据的一致性更是逻辑的正确性。
最后是zk的最终一致性,最终一致性是弱一致性,不同的客户端不一定能够同时看到同样的结果,但是最终会保持一致。如果需要保持一致,客户端需要主动发起sync来等待同步完成。
在使用redis来实现分布式锁的时候,我们使用setnx命令来实现,即不存在就创建来模拟锁的竞争,之所以能够使用这种简单的方式,是因为redis是单线程模型,能够保证只会有一个请求setnx成功,从而能够实现锁的竞争。顺着这个思路,我们也可以用类似的方法来实现zookeeper的分布式锁。另外由于zookeeper提供了watch机制,我们无需轮询锁的释放,可以被动的知晓锁的释放来发起竞争。
连接zk
public class Connect {
protected ZooKeeper zooKeeper;
public Connect(String address) {
try {
zooKeeper = new ZooKeeper(address, 3000, System.out::println);
} catch (IOException e) {
e.printStackTrace();
}
}
}
获取锁
public class MutexLock extends Connect implements Watcher {
private final static Logger LOGGER = LoggerFactory.getLogger(MutexLock.class);
// 本地互斥锁对象
private final Object localMutex = new Object();
// zk中充当分布式锁的zNode
private String lockNode = "/" + this.getClass().getName() + "mutex";
public MutexLock(String address) {
super(address);
}
public MutexLock(String address, String lockNode) {
super(address);
this.lockNode = lockNode;
}
// 获取锁
public void acquire() {
while (true) {
synchronized (localMutex) {
try {
// 创建成功则为成功占有互斥锁
zooKeeper.create(lockNode, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
return;
} catch (KeeperException | InterruptedException e) {
LOGGER.error("create node error:{}", e);
try {
// 否则注册监听,阻塞
zooKeeper.exists(lockNode, this);
localMutex.wait();
} catch (InterruptedException | KeeperException e1) {
e1.printStackTrace();
}
}
}
}
}
@Override
public void process(WatchedEvent event) {
synchronized (localMutex) {
localMutex.notifyAll();
}
}
}
释放锁
public void release() {
synchronized (localMutex) {
try {
Stat stat = zooKeeper.exists(lockNode, false);
if (stat != null) {
zooKeeper.delete(lockNode, stat.getVersion());
}
} catch (Exception e) {
LOGGER.error("release lock error:{}", e);
}
}
}
测试锁
public static void main(String[] args) throws InterruptedException {
String adderss = StaticUtils.ZK_ADDRESS;
String nodeName = "/" + MutexLock.class.getName();
if (args.length > 2) {
adderss = args[1];
nodeName = args[2];
}
MutexLock lock = new MutexLock(adderss, nodeName);
try {
lock.acquire();
System.out.println("I acquire the lock");
TimeUnit.SECONDS.sleep(3);
System.out.println("prepare to release the lock");
} finally {
lock.release();
}
}
这个分布式锁对单机的多线程是并发安全的,对于分布式系统的多个节点的锁获取逻辑是视图创建一个zNode,如果成功就继续后续逻辑(持有锁),否则监听这个zNode,并本地阻塞在一个object上,当这个zNode发生变化时,会触发watch event,事件的处理是唤醒阻塞在本地对象的线程,再次发起锁竞争。
对于锁的释放,则简单的多了。临时节点和会话超时都可以保证不会异常的永远的持有锁造成应用死锁,正常情况下是释放锁的时候删除创建的zNode,这一动作会触发watch event。和前面的逻辑就闭合了。
但是上述实现在大量节点情况下会有一个问题,会有潜在的herd effect,即惊群效应。如果竞争的client过多的话,一次锁释放会引起一大批的watch event的发生。对于这种情况,有一种“公平锁”的实现方式能够解决,思路是利用有序的临时zNode,序号最小的获取锁,未获取锁的监听比自己序号小1的节点,这样每次释放锁(删除最小序号的zNode)只会触发一个watch。但这个的代价是,获取锁的动作是公平的,先来先获取,没有抢占的行为。