分布式协调框架ZooKeeper
摘自《Java微服务分布式架构企业实战》
ZooKeeper是一个分布式应用程序协调服务,其核心是一个文件系统。它支持集群模式的部署,同时具备监听机制。在分布式应用程序中可以通过ZooKeeper实现负载均衡、集群管理、分布式协调/通知、Master选举、分布式锁和分布式队列等功能。ZooKeeper最常用的应用场景就是在微服务系统中充当服务生产者及服务消费者注册中心的角色,解决分布式锁等问题。ZooKeeper还能够将数据保存在内存中,从而保证了数据高吞吐量和低延迟的特性。因此,ZooKeeper的使用,可以轻松解决开发过程中面临的一些常见的问题,提高系统的可靠性。
1.微服务架构需要解决的问题
致力于解决微服务架构的问题,到目前为止了解了如何使用Spring Boot+Spring Cloud来做微服务的开发架构。在接下来,将向大家介绍微服务架构的第二套解决方式,即Spring Boot+Dubbo+ZooKeeper的方式。在微服务的开发过程中,主要为解决以下四个问题:
(1)客户端如何调用服务?
(2)服务与服务之间如何进行通信?
(3)这么多服务,如何来管理?
(4)这么多服务,如果宕机或出现故障该如何处理?
学完Spring Cloud后,相信对这四个问题都会有自己的理解,通过前面所学的知识,可以知道微服务系统中的服务是非常多的,当客户端请求服务时,首先需要知道所要请求的服务的IP和端口,如果把这么多服务的IP和端口都存储在客户端当然可以解决服务调用的问题,但是这种做法是不科学的,因为一旦有一个服务出现故障或宕机,那么此时该服务的IP依然存放在客户端,客户端依然会向该服务发起调用请求,这时就会引起阻塞,严重时就会造成服务雪崩,因此,在Spring Cloud的学习中,知道了如何使用API网关聚合服务,并通过与Eureka相结合,来解决客户端服务调用的问题。服务与服务之间的通信有两种方式:一种是同步通信;另一种是异步通信。在同步通信中,可以使用HTTP或者RPC的方式,通过Spring Cloud的学习,了解了如何使用HTTP的方式进行通信,在接下来的章节中将讲解基于Dubbo的RPC通信机制。在微服务系统中,面对这么多的服务,Spring Cloud提供了Eureka来做服务的治理,在后续的章节中可以发现ZooKeeper也是做服务治理的,同样能够用来解决服务管理的问题。当服务出现宕机或故障时,Spring Cloud Hystrix基于Netflix的开源框架 Hystrix实现断路器的服务保护功能,当然,处理服务故障的方式有很多,还可以使用重试机制、服务降级、服务限流等,在以后的章节中会慢慢讲到。
2 。什么是分布式协调技术
分布式协调技术主要用来解决分布式环境当中多个进程之间的同步控制,让它们有序地去访问某种临界资源,防止造成“脏数据”的后果。图10. 1中有三台机器,每台机器各自部署一个应用程序,然后将这三台机器通过网络连接起来,构成一个系统来为用户提供服务。对用户来说这个系统的架构是透明的,他感觉不到这个系统是一个什么样的架构,用户只知道发送请求,然后接收到返回的数据就可以了,并不需要知道这个请求的流程是怎样的,那么就可以把这种系统称作一个分布式系统。在这个分布式系统中如何对进程进行调度呢?假设在第一台机器上挂载了一个资源,然后
Serverl、Server2、Server3这三个物理分布的进程都要去竞争这个资源,但又不希望它们同时进行访问,这时候就需要使用一个协调器,来让它们能够有序地访问这个资源。这个协调器就是经常提到的“锁”,例如Serverl在使用该资源的时候,会首先获得“锁”,当Serverl获得“锁”以后会对该资源保持独占,这样其他进程就无法访问该资源,Serverl用完该资源以后就将“锁”释放掉,让其他进程来竞争获得“锁”,那么通过这个锁机制,就能保证分布式系统中多个进程能够有序地访问临界资源。因此,把这个分布式环境下的这个锁叫作分布式锁,这个分布式锁也就是分布式协调技术实现的核心内容。
3。什么是分布式锁
为了防止分布式系统中的多个进程之间相互干扰,需要一种分布式协调技术来对这些进程进行调度,而这个分布式协调技术的核心就是来实现分布式锁。在开发的过程中,经常会面临临界资源合理分配的问题,要保证不会产生“脏数据”就要使用分布式锁,如图10. 2所示。
在一个大型网上商城的购物网站中,通常会有很多用户同时购买一种商品的情况,如图10. 2所示,假设同时有多个用户申请购买同一种商品,一共想要购买12件,实际上该商品的库存还剩5件。客户端通过负载均衡服务器,分别将请求分发到三个不同的服务器中,假设每个订单服务器中分别分得3、4、5个购买请求,那么此时该服务就相当于开启了三个进程,每个进程都能读取到该商品库存为5的数据。在该系统中,商品的库存量 count就相当于成员变量,而对于每个进程而言,此时想购买5件商品时是没有任何问题的,但是这样一旦并发过来就会出现“脏数据”的问题。在之前的单体应用中是不会出现这种问题的,因为单体应用在一个JVM中,只有一个进程,只要通过代码块控制住线程就可以解决。如果务中确实遇到这种场景的话,就需要用到分布式锁解决这个问题。当服务请求过来操作数据时,就要将该数据“锁住”,那么对于其他服务来说,就无法再对该数据进行操作,这样就保证了数据的安全性。
4。 分布式锁应该具备哪些条件
在分布式系统环境下,分布式锁应备具:一个方法在同一时间只能被一个机器的一个线程执行;一直可以获取锁,一直可以释放锁,也就是高可用的获取锁与释放锁;高性能的获取锁与释放锁:可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误);锁失效机制,防止死锁;非阻塞锁特性,就像之前学习的熔断器,为了防止阻塞直接返回结果。
分布式锁的实现有以下方式。
(1)Memcached:利用Memcached的add命令。此命令是原子性操作,只有在key不存在的情况下,才能add成功,也就意味着线程得到了锁。
(2)Redis:和Memcached的方式类似,利用Redis的setnx命令。此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。
(3) ZooKeeper:利用ZooKeeper的顺序临时节点,来实现分布式锁和等待队列。ZooKeeper设计的初衷,就是为了实现分布式锁服务的。
(4)Chubby:Google公司实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法。
5。通过Redis分布式锁的实现理解基本概念
分布式锁实现的三个核心要素如下。
1.加锁--最简单的方法是使用setnx命令。key是锁的唯一标识,按业务来决定命名。如想要给一种商品的秒杀活动加锁,可以给key命名为“lock_sale_商品ID”。而value设置成什么呢?我们可以姑且将其设置成1.加锁的伪代码如下:setnx(lock_sale_商品 ID,1)
当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。
2.解锁--有加锁就得有解锁。当得到锁的线程执行完任务时,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令,伪代码如下:del(lock_sale_商品 ID)
释放锁之后,其他线程就可以继续执行setnx命令来获得锁,如图10. 3所示。图10. 3中,客户端请求通过负载均衡服务器,将订单请求分发到192. 168. 0. 1和192.168. 0. 2两台服务器中。这两台服务器分别开启了两个JVM(JVM1和JVM2)代表两个进程。由于订单服务需要对商品进行操作,所以订单服务先从数据库中读取商品数据带到Redis数据库中,然后,查看Redis中是否有该商品的key,如果没有通过setnx(key,value)给该商品加锁。当第一个服务首先进入Redis中获取到锁后,第二个服务再通过setnx(key,value)希望加锁时会返回0,加锁失败,直到第一个服务释放锁之后,第二个服务才能够继续获取锁。由于服务是并发执行的,因此两个服务可能同时从数据库中获取到商品信
息,但是在进入Redis执行操作时,Redis是单进程的,两个服务会根据顺序拿锁,没抢到锁的服务会线程等待。
3.锁超时--如果一个得到锁的线程在执行任务的过程中宕机或者服务挂掉,来不及显式地释放锁,这块资源将会永远被锁住(死锁),别的线程再也别想进来,如图10. 4所示。
即使被显式释放,这把锁也要在一定时间后自动释放。
为了避免上述的现象发生,可以在del释放锁之前进行判断,验证当前的锁是否为自己加的锁。在实现时可以在加锁的时候把当前的线程ID当作value,并在删除之前验证ke对应的value是不是自己线程的ID.
加锁:String threadId=Thread.currentThread () .getId ()set (key,threadId, 30, NX)
解锁:
if (threadId.equals (redisClient.get (key) ) ) { del (key) }
但是,这样做又隐含了一个新的问题,即使误删锁的问题解决了,但是在同一时间内,有线程A和线程B同时访问代码块,这样程序一下就又回到了最初的起点,又引发了“脏数据”等问题。为了解决上述问题,可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”,如图10. 6所示。
在开启线程A时设置30s过期时长,当过去了29s,如果线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20s.守护线程将会从第29s开始执行,每20s执行一次。当线程A执行完任务,会显式关掉守护线程。另一种情况,如果节点忽然断电,由于线程A和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它“续命”,也就自动释放了。
6。 什么是 ZooKeeper
ZooKeeper是一种分布式协调服务,用于管理大型主机。在分布式环境中,协调和管理服务是一个复杂的过程。ZooKeeper 通过其简单的架构和API解决了这个问题。ZooKeeper允许开发人员专注于核心应用程序逻辑,而不必担心应用程序的分布式特性。
7。ZooKeeper 如何实现分布式锁
在了解ZooKeeper怎样实现分布式锁之前,首先来了解下节点的概念。在ZooKeeper中,节点称 Znode,对ZooKeeper的操作主要是对节点的操作。在ZooKeeper的存储结构中,没有文件和目录的概念,这些文件和目录都被抽象成节点,Znode的层次结构如图10. 7所示。
图10. 7是一个树状图,最上方为根目录,下面是不同子类级别的目录。每个目录就是一个节点,该结构图正是由这样一个一个节点组成的。Znode分为以下四种类型:持久节点(PERSISTENT)、持久节点顺序节点(PERSISTENTSEQUENTIAL)、临时节点(EPHEMERAL)、临时顺序节点(EPHEMERAL_SEQUENTIAL).
1).持久节点--该类型是ZooKeeper默认的节点类型,当创建节点的客户端与ZooKeeper 断开连接后,该节点依旧存在。
2).持久节点--顺序节点持久节点顺序节点就是在节点创建完成后,具有持久化性能,并不会因为创建节点的客户端与ZooKeeper断开连接而删除临时节点。而且,在创建节点时,ZooKeeper会根据节点创建的时间顺序给该节点的名称进行编号,每当创建顺序节点时,ZooKeeper都会在路径后面自动添加上10位的数字(计数器),该计数器可以保证在同一个父节点下的唯一性,如图10. 8所示。
3).临时节点--临时节点与持久节点恰恰相反,当创建节点的客端与ZooKeeper断开连接后,临时点会被删除,如图10. 9所示。
4).临时顺序节点--临时顺序节点在创建节点时,ZooKeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与ZooKeeper断开连接后,临时顺序节点会被删除。
8。ZooKeeper分布式锁的原理
ZooKeeper分布式锁是利用临时节点的功能实现的,在了解ZooKeeper分布式锁原理之前,先举个例子便于大家的理解。
1).获取锁--首先,在ZooKeeper当中创建一个持久节父点ParentLock.当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点Lockl,如图10. 10所示。之后,客户端1查找ParentLock节点下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。先创建的节点名称中数字编号小于后创建的,因此可以将子节点按照节点名称后缀的数字顺序从小到大排序,这样排在第一位的就是最
先创建的顺序节点,如果是第一个节点,则成功获得锁,最后当客户端1释放锁时,该临时节点也会随之删除,如图10. 11所示。
这时候,如果再有一个客户端2前来获取锁,那么将会在ParentLock下再创建一个临时顺序节点 Lock2,如图10. 12所示。
创建临时顺序节点完成后,客户端2便会查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,在Lock2之前还有Lockl的存在,这时即表明在客户端2之前已经有其他调用方获取到锁资源,此时客户端2便会向排序仅比它靠前的节点Lockl 注册 Watch事件,用于监听Lock1节点是否存在。这意味着客户端2抢锁失败,进入了等待状态,如图10. 13所示。
这时候,如果又有一个客户端3前来获取锁,同样会在ParentLock下再创建一个临时顺序节点Lock3,如图10. 14所示。
接下来客户端3也会查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。于是,客户端3向排序仅比它靠前的节点Lock2 注册 Watch事件,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态,如图10. 15所示。
这样一来,客户端1得到了锁,客户端2监听了Lockl,客户端3监听了Lock2.这恰恰形成了一个等待队列。
2).释放锁释放锁分为两种情况。(1)任务完成,客户端显示释放当任务完成时,客户端1会显示调用删除节点Lockl的指令,如图10. 16所示,删除临时节点。
(2)任务执行过程中,客户端崩溃获得锁的客户端1在任务执行过程中,如果崩溃,则会断开与ZooKeeper服务端的连接。根据临时节点的特性,相关联的节点Lock1会随之自动删除,如图10. 17所示。
由于客户端2一直监听着Lockl的存在状态,当Lock1节点被删除,客户端2会立刻收到通知。这时候客户端2会再次查询Parentlock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小的,则客户端2便顺理成章获得了锁,如图10. 18所示。
同理,如果客户端2也因为任务完成或者节点崩溃而删除了节点Lock2,那么客户端3就会接到通知,如图10. 19所示,删除Lock2节点。
最终,客户端3也能成功得到了锁,如图10. 20所示。
ZooKeeper和Redis分布式锁的比较,如表10. 1所示。
9. ZooKeeper用作注册中心的原理
1)RPC框架
在了解ZooKecper用作注册中心的实现原理之前,先了解RPC框架。RPC指的是远程过程调用,相对于单体架构中的本地调用来说,把共享的服务单独部署到其他服务器中,让别的服务去调用它。例如有两台服务器A和B,一个应用部署在A服务器上,另一个应用部署在B服务器上,A中的应用想要调用B中应用提供的某些方法,由于它们不在一个内存空间里,它们之间并不能够直接调用,需要通过网络来表达调用的语义和传达调用的数据,而且要能够像本地调用那样方便,使得调用者感受不到操作远程调用的逻辑。如现有一个方法这样定义“User getUserByName(String name);”,那么,第一步考虑到的因素是解决通信的问题,主要目的是通过在客户端和服务器之间建立TCP连接,把远程调用过程中所有交换的数据都在这个连接里传输。第二步要考虑服务器A在调用服务器B时的寻址问题,也就要考虑到,服务器A上的应用该如何知会底层的RPC框架与服务器B(如主机或IP地址)进行连接,以及特定的端口号、方法名,方便调用。第三步要考虑的是服务器A上的一个应用向服务器B发起远程过程调用的时候,一些方法的参数是需要通过网络协议(如TCP)传递到服务器B中的,由于网络协议是基于二进制的,所以需要把内存中的参数进行序列化转换成二进制的形式,然后再把序列化后的二进制发送给服务器B.第四步,当服务器B接收到来自服务器A的请求后,还需要对二进制进行反序列化,然后找到对应的方法进行本地调用得到返回值。第五步,将返回值经过序列化处理后通过网络发送至服务器A中,服务器A再通过相应的反序列化操作,将二进制恢复为内存中的表达方式即可,如图10. 27所示。
在RPC框架中有三个重要角色:注册中心、服务提供者、服务消费者。通过之前章节的学习,可以知道注册中心是运行在远程服务器端的,它保存了所有服务的名称和IP、端口等,把服务给消费者使用,管理远程服务;服务提供者也运行在服务器端,提供了供服务消费者调用的接口和实现类;服务消费者运行在客户端,它通过远程代理对象调用指定命名的服务,如图10. 28所示。
2)ZooKeeper 用作注册中心
通过Eureka的学习,了解到服务注册与发现中心的用途,本节将讲解使用ZooKeeper充当服务注册中心角色的原理。在分布式的应用中,常常让多个服务提供者形成一个集群,以保证服务的高可用和提高效率,让服务消费者通过服务注册表去获取具体的服务访问地址然后访问具体节点下的服务提供者,如图10. 29所示。
当服务提供者完成部署后,都会将自己的服务注册致ZooKeeper的一个路径上,格式为:/{service}/{version}/{ip:port},例如,将UserServer部署到两台机器上,那么ZooKeeper上就会创建两条路径结构,分别为/UserService/1. 0. 0/192. 168. 136. 01:10001、/UserService/1. 0. 0/192. 168. 136. 02: 10002.
将服务注册到ZooKeeper中,实际上就是在ZooKeeper的目录下创建了一个Znode节点,该节点保存了该服务的IP地址、占用端口以及调用方式(协议、序列化方式)等。该节点在服务提供者发布服务的时候创建,用来给服务消费者指明获取服务时要连接的节点信息和调用方式,承担着最重要的责任。
ZooKeeper服务注册、发现过程的整个过程如下:当服务提供者发布时,会将自己的服务名称、IP地址注册到配置中心,然后服务消费者在第一次调用服务时,会通过服务提供者在注册中心中存储的节点信息找到相应服务的IP地址列表,并把它们缓存到本地,供给后续的服务消费者,因此,当服务消费者再次调用服务时,不再请求注册中心,而是直接通过负载均衡算法将在缓存的IP列表中选取一个服务提供者的服务器进行调用服务。如果某台服务提供者的服务器宕机或下线时,其IP将会从服务提供者的IP列表中移除。与此同时,服务注册中心会把新的服务IP地址列表发送给部署服务消费者的机器,缓存在消费者本机之中。如果注册中心中某个服务的所有节点都移除了,那么也就意味着该服务下线了。当重新扩展某个服务提供者时,注册中心会将新发布服务的IP地址列表发送给服务消费者机器,缓存到消费者本机之中。
ZooKeeper通过心跳检测判断服务是否下线,例如,它会通过建立的Socket长连接定时向注册中心中注册的服务提供者发送请求,如果该请求长期没有响应,服务中心就会认为该服务提供者已经“挂了”,并将其移除,就像前面提到的例子中,IP为192. 168. 136. 01的机器宕机了,那么ZooKeeper上的路径就会将该分支路径移除,因此就只剩/UserService/1. 0. 0/192. 168. 136. 02:10002的路径了。除此之外,服务消费者会去监听注册中心上的路径信息变化情况,一旦注册中心上的路径数据量增加或者减少或改变,ZooKeeper都将通知服务消费者,服务提供者的地址列表信息已经发生了改变,从而会对客户端存储的提供者信息列表进行更新。
3)Eureka和ZooKeeper的区别
通过之前章节的学习,已经了解到Eureka和ZooKeeper都可以作为服务注册中心,那么接下来将讲述这二者之间的区别。单单就服务注册中心本身而言并不是没有好坏之分,只是根据场景的不同在选取上有合适和不合适的差别。在Eureka中,不会像ZooKeeper那样有主节点的存在,其去中心化的特性决定了在Eureka集群中,所有节点都是平等关系,可以通过负载均衡策略选取一个服务节点。在Eureka集群中,如果某台服务器挂掉宕机,Eureka集群中不会发生类似于ZooKeeper选举主节点的情况。在Eureka集群中的机器,不存在主节点,所以更不会选举主节点;当一台机器宕机时客户端请求会自动切换到新的Eureka节点;当宕机的服务器重新恢复后,Eureka就会再一次将宕机的机器重新收入到服务器集群管理之中。然而多个ZooKeeper之间的网络如果出现问题,就会造成出现多个主节点的现象,发生“脑裂”(脑裂是指一个集群被拆分成为多个小集群,每个小的集群中都会有各自的主节点)。当网络分割故障发生时,每个Eureka节点都会持续地对外提供服务(注:ZooKeeper不会,少于ZooKeeper的选举可用个数时,ZooKeeper集群会挂掉,至此不对外提供服务),除此之外Eureka取CAP中的AP,注重可用性。ZooKeeper取CAP中的CP,强调高的一致性。