摘要:今天要讲的这个问题,我会从三个角度去分析。
第一个角度:为什么会出现分布式锁以及它的出现能解决哪种场景下的问题?
第二个角度:为什么zookeeper能实现分布式锁,他的优势又在哪?
第三个角度:走进源码一探究竟,知其然知其所以然,原理是什么?
好了,废话不多说,直接进入今天的正题吧!
一.为何出现分布式锁以及能解决的场景
- 为何会出现分布式锁?
分布式锁的概念:在分布式系统中维持有序的对共享资源进行操作,通过互斥来保持一致性的一种锁机制。
在原始的单体单机部署下如何能保证在高并发情况下对共享数据操作保证一致性,即同一时间内如何确保只能被一个线程执行,我们很容易想到的是通过ReentrantLcok或synchronized锁机制对共享资源进行互斥操作。但是,随着业务发展的需要,原始的单体架构慢慢的演进成了分布式架构,由于分布式系统是部署在多台机器上的,从而衍生成为了多进程下多线程的访问。多个进程下仍能进行同时访问,这将使原单机部署情况下的并发控制锁策略失效,分布式锁就是为了解决跨JVM的互斥机制来控制共享资源的访问才出现的。
可能这么说比较抽象,举个简单的例子吧。现在有一个电商秒杀系统,当用户进行下单操作时,首先要确保当前商品的库存是否充足,确认充足才会进行一个订单欲占的操作。假设现在商品A只有最后一件库存了,此时系统同时进来了2个下单请求,在查询订单时同时执行到了如下这段代码。
//查询库存是否充足 public int queryGoodsNum(String orderId) { int nums = goodsMapper.queryGoodsNums(orderId); if (nums == 0) { sout("该商品库存不足!"); } return nums; }
都查询到了该商品只有最后一件,那么接下来就不用我多说了吧,肯定会创建2笔订单,导致出现超卖的现象。如何解决呢?可能大家第一时间想到的是对查询库存,减库存,预占订单进行加锁操作,让多线程串行执行来确保库存的一致性。没错,单原始的单体单机架构下,确实能保证数据的一致性。可能随着用户量的剧增,一台机器可能扛不住这么多的用户访问量,急需架构升级成分布式。假如原先的订单系统衍生成了2台机器,多个下单请求分别落在了2台机器上,那么问题来了,2台机器同时进行用户下单操作,恰巧都在同一个时间点进行了库存查询的操作,都查询到是1,那么同样会导致超卖的问题。所以,像这类跨进程的共享资源访问控制就需要涉及到分布式锁来解决。
- 分布式锁能具体解决哪些应用场景?
记住几个关键词:分布式架构、跨JVM、共享资源互斥访问。
例如上面所说,电商系统防止超买超卖的问题就很典型。
二.Zookeeper为何能实现分布式锁
- 为什么Zookeeper能实现分布式锁
简单的介绍下,Zookeeper是由雅虎研究院开发的一款分布式协调框架,后捐赠给Apache。这是官网的地址:http://zookeeper.apache.org/,有兴趣可以去了解了解。是一个经典的分布式数据一致性解决方案,致力于为分布式应用提供一个高性能、高可用,且具有严格顺序访问控制能力的分布式协调服务。也有人认为他是一款数据库,因为能像redis存储数据,它是一种树形结构,可以创建节点,节点类型分为持久化节点,持久化有序节点、临时节点、临时有序节点。
Zookeeper为什么能实现分布式锁,似乎已经有了答案,我们从以下几点来分析:
- 保持独占。Zookeeper的是一种类似于文件系统的树形结构,节点特性是唯一的,例如/lock/zk-001,倘若有多个客户端同时过来创建/lock/zk-001节点,那么有且仅有一个客户端能创建成功。换句话说,倘若把Zookeeper上的一个节点看做是一把锁,那么成功创建的客户端则能保持独占;
- 控制时序。Zookeeper中有一种临时有序节点,每个来尝试获取锁的客户端,都会在Zookeeper的根目录下创建一个临时有序节点,例如有四个客户端过来尝试获取锁,则会在Zookeeper的/lock节点下创建四个临时有序节点,/lock/zk-001、/lock/zk-002、/lock/zk-003、/lock/zk-004,Zookeeper的/lock节点维护一个序列,序号最小的节点获取锁成功。
- 监听机制。Watcher机制能原子性的监听Zookeeper上节点的增删改操作,每个节点设置一个监听,监听自己的前一个节点即可,例如/lock/zk-003监听/lock/zk-002,倘若/lock/zk-002节点被删除,则/lock/zk-003获取到锁。
- Zookeeper实现分布式锁的逻辑梳理
基于Zookeeper以上所述的几点非常重要的特性,我们就能通过Zookeeper来实现数据的强一致性。
- 首先创建一个根目录/lock,便于后续客户端过来获取锁的一个时序访问。
- 多个客户端过来抢占锁时,先在/lock根目录下,创建对应的临时节点。
- 根目录对子节点维持一个有序队列,从小到大排序,最小的节点占有锁,其余的节点分别监听上一个节点。
- 当当前持有该锁的客户端完成对数据的操作后,删除该节点,监听该节点的节点收到通知后,获取到锁。
大致逻辑就是这样,为了更清晰点,我画了一个流程图,如下:
三.走进源码探其究竟
- 导入zookeeper有关jar包
<dependency> <groupId>org.apache.curator</groupId> <artifactId>curator-framework</artifactId> <version>2.13.0</version> </dependency> <!--封装了一些zookeeper操作的一些高级特性--> <dependency> <groupId>org.apache.curator</groupId> <artifactId>curator-recipes</artifactId> <version>2.13.0</version> </dependency>
- 示例代码演示如何获取锁和释放锁
下面先写一个Zookeeper的基础操作类,其中包括基本的节点的增删改查。
public class ZkConnectionUtil { private static final String CONN_STR = "192.168.2.105:2181"; /** * 获取zk连接 * @return */ public static CuratorFramework getConnection() { CuratorFramework curatorFramework = CuratorFrameworkFactory.builder() .connectString(CONN_STR) .sessionTimeoutMs(500000) .retryPolicy(new ExponentialBackoffRetry(1000, 3)).build(); return curatorFramework; } }
然后写一个测试类,假设有4个线程来获取锁,根节点为/locks。
public class ZkOperator { public static void main(String[] args) { //创建连接 CuratorFramework curator = ZkConnectionUtil.getConnection(); curator.start(); InterProcessMutex lock = new InterProcessMutex(curator, "/locks"); for (int i=0;i<4;i++) { Thread thread = new Thread(new Runnable() { @Override public void run() { System.out.println("线程:"+Thread.currentThread().getName()+"尝试获取锁!"); try { lock.acquire(); System.out.println("线程:"+Thread.currentThread().getName()+"获取锁成功!"); } catch (Exception e) { e.printStackTrace(); } try { Thread.sleep(20000); } catch (InterruptedException e) { e.printStackTrace(); } try { lock.release(); System.out.println("线程:"+Thread.currentThread().getName()+"释放锁成功!"); } catch (Exception e) { e.printStackTrace(); } } }, "thread-"+i); thread.start(); } } }
打开Zookeeper服务,并使用客户端进行连接。
连接成功后,我们来测试一下。
当我们查看/locks目录下时,确实生成了4个临时有序节点。
当程序执行完成之后,所有的节点全都被删除了。之前的逻辑也得到了验证,有兴趣的同学可以自己debug调试看看,这里就不过多赘述了,接下来让我们看看源码吧。
- 进入源码
这里使用的是curator包下的InterProcessMutex类,它实现的互斥锁。当程序执行到locks.acquire()时,点进去看看。
//获取锁的主方法,此方法还有一个重载方法,可以设置获取锁的超时时间 public void acquire() throws Exception { if ( !internalLock(-1, null) ) { throw new IOException("Lost connection while trying to acquire lock: " + basePath); } } private boolean internalLock(long time, TimeUnit unit) throws Exception { Thread currentThread = Thread.currentThread(); //会有一个LockData的数据结构,用来存储当前持有的锁的线程 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中,便于下次重复获取时直接返回 LockData newLockData = new LockData(currentThread, lockPath); threadData.put(currentThread, newLockData); return true; } return false; }
大概说一下这是什么意思吧,内部维护了一个持有该锁的map,当当前线程获取锁时,先判断当前线程是否持有该锁,持有的话重入的次数加一,返回true,表示当前线程持有该锁。
private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();
如果当前线程没有持有锁,那么此时会去尝试获取锁,进入internals.attemptLock(time, unit, getLockNodeBytes())方法。
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception { String ourPath = null; boolean hasTheLock = false; boolean isDone = false; //循环获取锁 while ( !isDone ) { isDone = true; //尝试创建锁,实际上是去/locks节点下创建临时有序节点 ourPath = driver.createsTheLock(client, path, localLockNodeBytes); //获取锁,判断当前创建好的临时有序节点是否是最小的节点 hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath); } //当获取到锁后会跳出循环 if ( hasTheLock ) { return ourPath; } return null; }
大致看一下这段代码,其实很简单,就是不断循环,去获取锁,让我们继续跟进去
driver.createsTheLock(client, path, localLockNodeBytes)。
//创建锁,实际上是创建一个临时有序节点 public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception { String ourPath; //当前根据是否需要存储值来进行创建,默认lockNodeBytes为null if ( lockNodeBytes != null ) { ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path, lockNodeBytes); } else { //返回的ourPath也是Zookeeper为我们创建的,例如/locks/acdc-locks-0000001,最后的数字为递增 ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path); } return ourPath; }
这一段代码一目了然,就是在根目录下创建了一个临时有序节点而已。ourPath="/lock/_c_b523dfef-cb03-4b59-af58-8c7727e3334e-lock-0000000032";然后将当前节点的路径返回。继续往下看
//顾名思义循环获取锁,成功返回true,点进去看看 hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception { ....... while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) { //获取子节点列表按照序号从小到大排序 List<String> children = getSortedChildren(); String sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash //判断自己是否是当前最小序号节点 PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases); if ( predicateResults.getsTheLock() ) { //成功获取锁 haveTheLock = true; } else { //拿到前一个节点 String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch(); //如果没有拿到锁,调用wait,等待前一个节点删除时,通过回调notifyAll唤醒当前线程 synchronized(this) { try { //设置监听器,getData会判读前一个节点是否存在,不存在就会抛出异常从而不会设置监听器 client.getData().usingWatcher(watcher).forPath(previousSequencePath); //如果设置了millisToWait,等一段时间,到了时间删除自己跳出循环 if ( millisToWait != null ) { millisToWait -= (System.currentTimeMillis() - startMillis); startMillis = System.currentTimeMillis(); if ( millisToWait <= 0 ) { doDelete = true; // timed out - delete our node break; } //等待一段时间 wait(millisToWait); } else { //一直等待下去 wait(); } } catch ( KeeperException.NoNodeException e ) { //getData发现前一个子节点被删除,抛出异常 } } } } } ..... }
大致理一下这段代码的意思,此处有一个while循环,用来循环获取锁操作,getSortedChildren()会返回一个当前根目录下所有节点从小到大的一个排序集合。
public static List<String> getSortedChildren(CuratorFramework client, String basePath, final String lockName, final LockInternalsSorter sorter) throws Exception { List<String> children = client.getChildren().forPath(basePath); List<String> sortedList = Lists.newArrayList(children); Collections.sort ( sortedList, new Comparator<String>() { @Override public int compare(String lhs, String rhs) { return sorter.fixForSorting(lhs, lockName).compareTo(sorter.fixForSorting(rhs, lockName)); } } ); return sortedList; }
然后去尝试获取锁,driver.getsTheLock(client, children, sequenceNodeName, maxLeases)进入这个方法看看。
public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception { int ourIndex = children.indexOf(sequenceNodeName); validateOurIndex(sequenceNodeName, ourIndex); boolean getsTheLock = ourIndex < maxLeases; String pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases); return new PredicateResults(pathToWatch, getsTheLock); }
这段代码的意思是,首先获取当前创建的临时节点在list中的下标,而maxLeases=1,表示当前只能有一个节点持有锁,如果当前下标小于1,表示当前的临时节点是最小的,那么理所当然会获取到锁;如果不小于1,说明它还不是最小的,那么会返回上一个节点的路径,为后面监听做准备。
client.getData().usingWatcher(watcher).forPath(previousSequencePath);
这一行代码就是给上一个节点设置watcher监听,倘若之后节点发生变化,会第一时间得到通知。
好了,源码大致也分析完了,你会发现其实代码逻辑并不难,比起晦涩难懂的Spring源码来说,这简直就是小菜一碟。
当然,实现分布式锁的方式不仅仅是Zookeeper,还有数据库的乐观锁,redis的setnx原子操作都能实现。有兴趣的朋友自己去学习学习!