Zookeeper介绍

ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,是Google的Chubby一个开源的实现,是Hadoop和Hbase的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。

安装与配置

单机版

下载

官方下载

目前最新的稳定版本是3.4.10,压缩包解压后会出现如下目录。

grpc 重试时间间隔 grpc客户端重连机制_服务注册发现

bin目录是存放脚本的目录,其中包括windows与linux两个平台的启动与CLI客户端脚本;

conf目录是存放配置文件的目录,其中包括日志配置、zoo_sample.cfg样例配置;

contrib目录是其它语言对zk支持的工具包;

lib目录是zk的依赖包;

recipes目录是zk某些用法的示例代码;

src源代码目录

配置

config目录存放了一个名为zoo_sample.cfg的样例配置文件,将它重命名为zoo.cfg,并修改其中的配置选项,配置选项以键值对的形式存在。

tickTime=2000  // 时长单位为毫秒,是后续配置的基本单位,1 * tickTime是客户端与zk服务端的心跳时间,2 * tickTime是客户端会话的超时时间
clientPort=2181  // zk服务进程监听的TCP端口,默认情况下,服务端会监听2181端口
dataDir=F://ideaWorkspace//zookeeper-3.4.6//data
dataLogDir=F://ideaWorkspace//zookeeper-3.4.6//logs // 配置存储快照与事务日志的文件目录

启动

进入到bin目录,windows环境下直接双击zkServer.cmdlinux环境下执行命令./zkServer.sh start将服务启动。另外还提供了stop/status/restart参数。

CLI

CLI是指zookeeper提供的类似命令行的维护接口。

连接
  • 如果默认连本机的话,在bin目录下执行./zkCli.sh即可
  • 如要需要连其它远程机器的话,使用-server参数,例如./zkCli.sh -server 10.10.107.26:2181
常用命令
  • 如果不知道ZK提供了哪些命令,可在进入CLI后输入help回车,会将支持的命令列表列出来
  • 查看节点数据,使用命令ls /ls2 /
  • 创建节点,使用命令create /test "hello"create /test ""
  • 删除节点,使用命令delete /testrmr /test,表示删除节点或子节点
  • 获取节点内容,使用命令get /test
  • 修改节点内容,使用命令set /test "ohmygod"

nc四字命令

当在没有CLI的情况下,可以通过nctelnet的方式从zk那里获取相关的信息。为什么叫四字命令是由于输入的指令为四个字符,命令例如echo conf | nc 127.0.0.1 2181

  • conf输出相关服务配置的详细信息
  • cons列出所有连接到服务器的客户端的完全的连接 / 会话的详细信息。包括“接受 / 发送”的包数量、会话 id 、操作延迟、最后的操作执行等等信息
  • dump列出未经处理的会话和临时节点
  • envi输出关于服务环境的详细信息(区别于 conf 命令)
  • reqs列出未经处理的请求
  • ruok测试服务是否处于正确状态。如果确实如此,那么服务返回“ imok ”,否则不做任何相应
  • stat输出关于性能和连接的客户端的列表
  • wchs列出服务器 watch 的详细信息
  • wchc通过 session 列出服务器 watch 的详细信息,它的输出是一个与 watch 相关的会话的列表
  • wchp通过路径列出服务器 watch 的详细信息。它输出一个与 session 相关的路径

伪分布式

分布式是多个服务跑在不同的机器上,伪分布式是指在资源有限的情况下,多个服务跑在一台机器上。

配置

  • conf目录下的zoo.cfg中增加如下配置,且每个服务的clientPort要选择不一样。
// initLimit配置follower与leader之间建立连接后进行同步的最长时间为initLimit*tickTime
initLimit=10
// 配置follower和leader之间发送消息,请求和应答的最大时间长度为syncLimit*tickTime
syncLimit=5
// server.id=host:port1:port2
// id为手动给zk服务的编号,port1表示follower和leader交换消息所使用的端口,port2表示选举leader所使用的端口
server.1=10.10.107.104:2887:3887 
server.2=10.10.107.104:2888:3888
server.3=10.10.107.104:2889:3889
  • 增加myid配置,如果是分布式部署,需要在data目录下增加一个myid的配置文件,里面分别填好该进程服务在zoo.cfg文件中分配的编号

启动

  • 分布式模式启动跟单机启动一样,只需要分别启动即可
  • CLI连接时,可以使用命令./zkCli.sh -server 127.0.0.1:2181,127.0.0.1:2182,127.0.0.1:2183,它会随机选择一个服务,从日志中可以具体看出来当前连在哪个服务上

服务注册与发现

在分布式系统开发过程中,服务与服务这间的调用是跨进程或跨机器的,前面讲到的RPC或RESTFul也好,均需要有一个统一的服务管理中心,因为每个服务都存在多个服务实例,也就是常说的服务治理模块,主要负责服务的注册与发现,另外还有配置管理、负载均衡等功能。主要的示意图如下。

解决方案

早期方案

grpc 重试时间间隔 grpc客户端重连机制_分布式_02

各系统组件角色如下:

  • 服务消费者通过本地hosts中的服务提供者域名与Nginx的IP绑定信息来调用服务
  • Nginx用来对服务提供者提供的服务进行存活检查和负载均衡
  • 服务提供者提供服务给服务消费者访问,并通过Nginx来进行请求分发

这在内部系统比较少,访问量比较小的情况下,解决了服务的注册,发现与负载均衡等问题。但是,随着内部服务越来愈多,访问量越来越大的情况下,该架构的隐患逐渐暴露出来:

  • 最明显的问题是Nginx存在单点故障(SPOF),同时随着访问量的提升,会成为一个性能瓶颈
  • 随着内部服务的越来越多,不同的服务消费方需要配置不同的hosts,很容易在增加新的主机时忘记配置hosts导致服务不能调用问题,增加了运维负担
  • 服务的配置信息分散在各个主机hosts中,难以保持一致性,不便于服务的管理
  • 服务主机的发布和下线需要手工的修改Nginx upstream配置,修改的配置需要上线,不利于服务的快速部署

解决的思路如下:

  • 服务的注册信息应该统一保存,方便于服务的管理
  • 自动通过服务的名称去发现服务,而不必了解这个服务提供的end-point到底是哪台主机
  • 支持服务的负载均衡及fail-over
  • 增加或移除某个服务的end-point时,对于服务的消费者来说是透明的

目前方案

grpc 重试时间间隔 grpc客户端重连机制_分布式_03

在此架构中分为服务消费者、服务提供者及服务注册中心三个角色。当然这种架构目前有挺多成熟实现,如阿里的Dubbo及Spring Cloud组件中的EurekaConsuletcd等。

服务提供者

服务提供者作为服务的提供方在启动时将自身的服务信息注册到服务注册中心中去,服务信息一般包括但不限于隶属于哪个系统、服务的IP及端口、服务请求的URL、服务的权重等等。

服务的消费者
  • 服务消费者在启动时从服务注册中心获取需要的服务注册信息
  • 将服务注册信息缓存在本地
  • 监听服务注册信息的变更,如接收到服务注册中心的服务变更通知,则在本地缓存中更新服务的注册信息
  • 根据本地缓存中的服务注册信息构建服务调用请求,并根据负载均衡策略(随机负载均衡,Round-Robin负载均衡等)来转发请求
服务注册中心

服务注册中心主要提供所有服务注册信息的中心存储,解决了早期nginx单点问题,实现服务名与服务实现端点的对应,同时负责将服务注册信息的更新通知实时的Push给服务消费者(主要是通过Zookeeper的Watcher机制来实现的)。

ZK客户端

zookeeper本身提供的API接口太底层,使用不方便。有对API进一步封装的ZK客户端或框架,目前开源之中用得比较多的为zkclientCurator。两者分别有其优缺点。

原生API

  • 连接的创建是异步的,需要开发人员自行编码实现等待
  • 连接没有自动的超时重连机制
  • zk本身没提供序列化机制,需要开发人员自行指定,从而实现数据的序列化和反序列化
  • Watcher注册一次只会生效一次,需要不断的重复注册
  • 不支持递归创建树形节点

zkclient

  • 对原生接口封装,简化开发API
  • 解决session会话超时重连
  • Watcher反复注册
  • 参考文档少,异常处理简化
  • 没有提供各种使用场景的实现

Curator

  • 除开zkclient提供的功能外,还提供了各种场景应用(Recipe,如共享锁服务、Master选举机制和分布式计算器等)的抽象封装
  • 学习门槛比zkclient高些

服务的定义

服务的定义是指将服务对外提供的功能接口以某种方式发布出来,在此例中打算定义一个用户的服务,通过服务消费者传入用户ID,来获取该用户的相关信息的功能,利用protobuff来进行服务接口的定义如下,相关语法上一节RPC使用中己讲到,不再详述。

syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.comba.zookeeper.rpc";
option java_outer_classname = "UserProto";

package rpc;

// 定义用户接口
service User {
  // 获取用户信息
  rpc GetUser (UserRequest) returns (UserReply) {}
}

message UserRequest {
  int32 id = 1;
}

message UserReply {
  int32 id = 1;
  string name = 2;
  int32 age = 3;
}

服务提供者实现

依赖引入

<!-- 引入zkclient -->
<dependency>
  <groupId>com.101tec</groupId>
  <artifactId>zkclient</artifactId>
  <version>${zkclient.version}</version>
</dependency>
<!-- 引入grpc -->
<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-all</artifactId>
  <version>${grpc.version}</version>
</dependency>

<!-- 引入grpc的编译插件 -->
<build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.4.1.Final</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.5.0</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:3.0.0:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.0.0:exe:${os.detected.classifier}</pluginArtifact>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

服务注册

服务提供者在启动时,需要向注册中心注册服务信息,在此实现服务注册的通用方法。

/**
 * 服务注册
 * @author doublerabbit
 * @date 2017年11月2日 上午9:32:30
 */
public class ServiceRegister {

    private ZkClient zkClient;
    private String host;
    private String path;

    public ServiceRegister(String host){
        this.host = host;
        init();
    }

    public void init(){
        zkClient = new ZkClient(host, SESSION_TIMEOUT, CONNECTION_TIMEOUT);
    }

    public void addNode(String nodePath){
        // 验证节点路径的合法性
        if (!nodePath.startsWith("/")) {
            System.out.println("nodePath must be start with /");
            return;
        }
        // 如果不存在节点,就新创建一个
        if (!zkClient.exists(nodePath)) {
            zkClient.createPersistent(nodePath,true);
            path = nodePath;
        }else {
            System.out.println(nodePath + "is exists.");
        }
    }

    public void updateData(Object data){
        if (StringUtils.isNotBlank(path)) {
            updateData(path, data);
        }
    }

    public void updateData(String nodePath,Object data){
        // 验证节点路径的合法性
        if (!nodePath.startsWith("/")) {
            System.out.println("nodePath must be start with /");
            return;
        }
        if (zkClient.exists(nodePath)) {
            zkClient.writeData(nodePath, data);
        }else {
            System.out.println(nodePath + "is not exists.");
        }
    }

    public void deleteNode(String nodePath){
        // 验证节点路径的合法性
        if (!nodePath.startsWith("/")) {
            System.out.println("nodePath must be start with /");
            return;
        }
        if (zkClient.exists(nodePath)) {
            zkClient.deleteRecursive(nodePath);
        }else {
            System.out.println(nodePath + "is not exists.");
        }
    }
}

服务接口实现

服务接口实现利用GRPC方式。

public class ServiceProvider {

    private int port = 50051;
    private Server server;
    private String host = "10.10.107.104:2181,10.10.107.104:2182,10.10.107.104:2183";
    private String path = "/servers/user1";
    private ServiceRegister register;

    public ServiceProvider(){
        register = new ServiceRegister(host);
        register.addNode(path);
    }

    private void start() throws IOException {
        server = ServerBuilder.forPort(port)
                .addService(new UserImpl())
                .build()
                .start();

        System.out.println("service start...");
        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                System.err.println("*** shutting down gRPC server since JVM is shutting down");
                ServiceProvider.this.stop();
                System.err.println("*** server shut down");
                System.err.println("*** service deregister");
                register.deleteNode(path);
            }
        });
    }

    private void stop() {
        if (server != null) {
            server.shutdown();
        }
    }

    // block 一直到退出程序 
    private void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }

    public ServiceRegister getRegister(){
        return this.register;
    }

    public static void main(String[] args) throws IOException, InterruptedException {

        final ServiceProvider server = new ServiceProvider();
        server.start();
        server.getRegister().updateData("127.0.0.1:50051");
        //server.blockUntilShutdown();
        Thread.sleep(2*60*1000L);
    }

    // 实现 定义一个实现服务接口的类 
    private class UserImpl extends UserGrpc.UserImplBase {

        public void getUser(UserRequest req, StreamObserver<UserReply> responseObserver) {
            System.out.println("service:"+req.getId());
            UserReply reply = UserReply.newBuilder().setId(req.getId())
                                .setName("zhangsan-service1")
                                .setAge(20).build();
            responseObserver.onNext(reply);
            responseObserver.onCompleted();
        }
    }
}

服务消费者实现

依赖引入

消费者侧与服务提供者的依赖引入一致。

服务发现

服务发现是指消费者在己知服务名的前提下,从注册中将可用的服务信息拉取的本地缓存,待需要使用时从本地缓存中随机选取某个实例。

/**
 * 服务消费者进行服务发现,并包含负载均衡功能简略实现
 * @author doublerabbit
 * @date 2017年11月2日 下午3:07:13
 */
public class ServiceDiscovery {

    private ZkClient zkClient;
    // 服务实例
    private Map<String, String> instanceMap;

    public ServiceDiscovery(){
        init();
    }

    public void init(){
        zkClient = new ZkClient(ZK_HOSTS, SESSION_TIMEOUT, CONNECTION_TIMEOUT);
        instanceMap = new ConcurrentHashMap<String, String>();
    }

    public void subDataChange(String path){
        zkClient.subscribeDataChanges(path, new IZkDataListener() {

            public void handleDataDeleted(String dataPath) throws Exception {
                System.out.println(dataPath + " delete noitfy====");
                zkClient.unsubscribeDataChanges(dataPath, this);
            }

            public void handleDataChange(String dataPath, Object data) throws Exception {
                System.out.println(dataPath + " notify,value=" + data);
                instanceMap.put(dataPath, (String)data);
            }
        });
    }

    public void subChildChange(){
        zkClient.subscribeChildChanges(ZNODE_PATH, new IZkChildListener() {

            public void handleChildChange(String parentPath, List<String> currentChilds) throws Exception {
                System.out.println("parentPath=" + parentPath);
                for (String str : currentChilds) {
                    System.out.println("service path=" + str);
                    String path = parentPath + "/" + str;
                    Object data = zkClient.readData(path);
                    if (data == null) {
                        subDataChange(path);
                    }
                    if (!instanceMap.containsKey(path)) {
                        System.out.println("path=" + path + ",data=" + (String)data);
                        instanceMap.put(path, (String)data);
                        System.out.println("map.size=" + instanceMap.size());
                        subDataChange(path);
                    }
                }
            }
        });
    }

    public Map<String, String> getServers(){
        return instanceMap;
    }

    /**
     * 随机获取一个可用的服务地址,作负载均衡使用
     * @return
     */
    public Optional<String> getServer(){
        int size = instanceMap.size();
        if (size == 0) {
            System.err.println("does not have available server.");
            return Optional.ofNullable(null);
        }
        int rand = new Random().nextInt(size);
        System.out.println("size=" + size + ",rand=" + rand);;
        String server = (String)instanceMap.values().toArray()[rand];
        return Optional.ofNullable(server);
    }

    public static void main(String[] args) throws Exception {
        ServiceDiscovery consumer = new ServiceDiscovery();
        consumer.subChildChange();
        Thread.sleep(20*60*1000L);
        for (Map.Entry<String, String> entry : consumer.getServers().entrySet()) {
            System.out.println("key=" + entry.getKey() + ",value=" + entry.getValue());
        }
    }

    public static class DataListener implements IZkDataListener{

        public void handleDataChange(String dataPath, Object data) throws Exception {
            System.out.println(dataPath + "notify,value=" + data);
        }

        public void handleDataDeleted(String dataPath) throws Exception {
            System.out.println(dataPath + " delete noitfy====");
        }
    }
}

客户端负载均衡

客户端负载均衡是指客户端从本地缓存中选取服务实例的策略,本例中实现简单,采用随机数方式,不过该功能就类似于spring cloud组件中的Ribbon

public Optional<String> getServer(){
        int size = instanceMap.size();
        if (size == 0) {
            System.err.println("does not have available server.");
            return Optional.ofNullable(null);
        }
        int rand = new Random().nextInt(size);
        System.out.println("size=" + size + ",rand=" + rand);;
        String server = (String)instanceMap.values().toArray()[rand];
        return Optional.ofNullable(server);
    }

服务消费实现

服务消费实现从注册中心发现服务,并解析服务地址,从而达到调用服务的目的。

/**
 * 服务消费者
 * @author doublerabbit
 * @date 2017年11月3日 下午12:18:57
 */
public class ServiceConsumer {

    private final ManagedChannel channel; 
    private final UserGrpc.UserBlockingStub blockingStub; 

    public ServiceConsumer(String host,int port){ 
        channel = ManagedChannelBuilder.forAddress(host,port) 
                .usePlaintext(true) 
                .build();
        blockingStub = UserGrpc.newBlockingStub(channel); 
    }

    public void shutdown() throws InterruptedException { 
        channel.shutdown().awaitTermination(5, TimeUnit.SECONDS); 
    }

    public void greet(){ 
        UserRequest request = UserRequest.newBuilder().setId(10).build(); 
        UserReply  response = blockingStub.getUser(request);
        System.out.println("id="+response.getId()+",name="+response.getName()+",age="+response.getAge());
    }

    public static void main(String[] args) throws InterruptedException {
        ServiceDiscovery discovery = new ServiceDiscovery();
        discovery.subChildChange();
        Thread.sleep(30*1000L);
        for (int i = 0; i < 5; i++) {
            Optional<String> server = discovery.getServer();
            if (server.isPresent()) {
                String[] array = server.get().split(":");
                System.out.println("server host=" + array[0] + ",port=" + array[1]);
                ServiceConsumer client = new ServiceConsumer(array[0],Integer.parseInt(array[1]));
                client.greet();
                client.shutdown();
            }else {
                System.err.println("not find avaliable server====");
            }
        }
    }
}

测试结果

启动服务消费者与服务提供者,然后查看消费者与提供者的控制台输出如下。

// 消费者启动后控制台打印,可能看出消费者侧均感知到服务提供信息
parentPath=/servers
service path=user1
path=/servers/user1,data=null
/servers/user1 notify,value=127.0.0.1:50051
parentPath=/servers
service path=user1
service path=user2
path=/servers/user2,data=null
/servers/user2 notify,value=127.0.0.1:50052
parentPath=/servers
service path=user1
service path=user2
service path=user3
path=/servers/user3,data=null
/servers/user3 notify,value=127.0.0.1:50053

// 消费者进行消费,以10次为例,可以看出在随机选择服务实例进行调用
size=3,rand=2
server host=127.0.0.1,port=50053
id=10,name=zhangsan-service3,age=20
size=3,rand=2
server host=127.0.0.1,port=50053
id=10,name=zhangsan-service3,age=20
size=3,rand=2
server host=127.0.0.1,port=50053
id=10,name=zhangsan-service3,age=20
size=3,rand=2
server host=127.0.0.1,port=50053
id=10,name=zhangsan-service3,age=20
size=3,rand=1
server host=127.0.0.1,port=50052
id=10,name=zhangsan-service2,age=20
size=3,rand=1
server host=127.0.0.1,port=50052
id=10,name=zhangsan-service2,age=20
size=3,rand=0
server host=127.0.0.1,port=50051
id=10,name=zhangsan-service1,age=20
size=3,rand=2
server host=127.0.0.1,port=50053
id=10,name=zhangsan-service3,age=20
size=3,rand=0
server host=127.0.0.1,port=50051
id=10,name=zhangsan-service1,age=20
size=3,rand=1
server host=127.0.0.1,port=50052
id=10,name=zhangsan-service2,age=20

问题

为什么Eureka比ZK更适合构建服备注册与发现

CAP原理,Eureka是基于AP原则构建,ZK是基于CP原则来构建,相对于一个服务访问来讲,在出现问题时能够访问是最重要的,可能访问的数据出现不一致现象,但总比访问不到报错更合理。