前言
在上文中我们了解到, canal 可以通过订阅 binlog 日志来提供增量数据订阅和消费,通过这种方式可以实现数据库的实时备份,实时索引构建等
我们再来详细看看它的工作原理
如图示,每个 server 会启动多个实例(instance),每个实例会订阅不同表的 binlog,实例主要负责将 binlog 日志解析成程序易读的结构化数据(解析后包含变更记录的主键,字段变更后的值等),Canal Client 拿到结构化数据后再将其同步给其他 DB, MQ 等,同一时间某张表的 binlog 只能被一个 Instance 订阅处理,这是为了保障 binlog 处理的顺序性
现在问题来了,如果这台 Canal Server 挂掉了该怎么办,换句话说如何实现 server 的 HA(High Availability,即高可用)呢。
最容易想到的当然是准备几个备份的 Canal Server(以下简称备机),这样的话当主 Canal Server(工作中的 Server,以下简称主机)宕机了,备机就可以顶上去工作了。那么这里就涉及到两个问题
- 如果有三台机器(一台充当主机,两台充当备机),启动后到底哪个 Canal Server 为主机?
- 如果主机挂了,备机如何及时发现并接手主机的工作呢
显然这两个问题都涉及到多进程通信,所以最好引入一个中间层来处理,我们需要设计一个分布式协调系统来完成这两个需求,先给这个系统取个名字吧,姑且将其称为 Zookeeper,简称 ZK。
接下来我们来看看 ZK 需要如何设计才能满足我们上述的两个需求
设计分布式锁来解决主备角色问题
先来看问题一
如果有三台或更多机器启动的话,到底哪个 Canal Server 为主机?
显然分布式锁是可以满足这个需求的,三台机器启动后主动去获取这个分布式锁,获取成功的则充当主机,获取失败的则作为备机。
所以 ZK 必须要具有分布式锁的功能,可能有人说我可以引入 Redis,MySQL,因为这两者都具有分布式锁的功能,但这样相当于在一个系统里又引入了额外的组件,系统的复杂度上升了,而且也要保证新引入组件的高可用,不太可取,所以这次我们打算另辟蹊径直接在 ZK 中设计这样的分布式锁,首先要为 ZK 设计一个数据结构。
我们用类似 Linux 文件系统的树状结构来作为此分布式系统的数据结构。
根节点为 /
,每个节点下的子节点名称是唯一的(也就意味着根节点下的所有节点名称唯一),这样的话三个 Canal Server 启动后,会首先尝试着在根节点下去创建同样名字的节点(假设为 /lock1
),那么由于这个节点名称是唯一的,只能创建一个 /lock1
这样的节点,其他的再申请创建会失败,于是给我们一个思路:谁先创建成功,谁就为主节点,其余的为备机,这样的话主备问题就解决了。
如何让备机发现主机宕机了
再来看第二个问题
备机如何知道主机宕机?
前文提到主机会在 ZK 中创建一个节点 /lock1
,创建成功即为主机,在创建节点之前,主机首先要通过 TCP 与分布式协调系统建立连接,这个连接会长期保活,主机会定期发送心跳
来让 ZK 感知到它的存在,这样 ZK 就会知道主机还存活着,如果在指定的时间内(比如 2s )ZK 没有收到主机发来的心跳,就会认为主机宕机了,此时就会发通知给备机了。
这个通知该怎么发呢,ZK 不仅是为 Canal 服务的,还有很多其他的服务可能也需要在 ZK 上创建节点,不同的服务对应创建的节点都是不一样的,比如 canal 服务创建的节点是 /lock1,库存服务创建的节点为 /lock2,总不可能 canal 服务的主机宕机了,却要通知库存服务的备机吧。我们应该只通知当时创建 /lock1 失败的那些机器。
于是我们需要建立一个/lock1
与对应机器的映射列表,如下
每个创建 /lock1
节点失败的备机都被放到 key 为 /lock1
对应的列表里
我们把这个流程称为注册,这样的话每个节点对应的机器就可以查到了,如果创建 /lock1
节点的主机宕机了,ZK 发现后只要通过映射列表通知/lock1
对应的备用机器列表就行啦(同时 ZK 要把 /lock1
这个节点给删掉,代表分布式锁释放了。)我们把这种备用机器能感知到节点被删除的机制称为 watch 机制,它的运行机制如下
1、 注册: client 创建节点 /lock1
失败后会监听此结点,一旦此节点消失会收到 ZK 的通知 2、 存储:将此 watcher 保存在客户端的 watcherManager 中,我们可以简单地
认为 watcher 的格式如下
注:为了简化讲解流程,我们给 watcher 加了一个 path 字段,实际 watcher 并没有,不过原理其实是一样的
path 即我们监听的结点,如本例中的 /lock1
,keepState 即事件状态,比如连接成功,连接断开等,EventType 即节点对应的事件,如创建,删除,子节点变更等事件。
3、通知:ZK 将节点及其删除/创建事件通知到 client,client 通过这些信息在 watchManager 中找到相应的 watcher,就可以做相应的处理,比如监听到节点被删了,就可以感知到主机宕机了,它就可以尝试着去创建 /lock1
了,哪个创建成功则哪个备机就成为主机了。
通过以上的 watch 工作机制不难设计出我们的高可用方案,如下:
- 假设现在有 A,B 两台 Canal Server,为了成为主节点,它们首先向 zk 申请创建
/lock1
节点 - A 创建成功后即成为主节点开始工作, B 再去创建节点则失败,会作为备机,但同时 B 会使用 watch 机制来监听
/lock1
节点(即将 B 机器注册到 ZK 中/lock1
对应的机器列表中,/local1
被删除后会通过此机器列表,B 就能感知到 A 宕机了) - 一旦 A 不可用,则 ZK 会删除
/lock1
节点,同时也会通知 B,B 收到通知后,通过本地的 watchManager 找到此 watcher,得知是/lock1
这个节点被删除事件后会再次尝试创建/lock1
节点,创建成功后则启动作为主节点工作,此刻 Canal Client 也会注意到/lock1
创建了(Canal Client 启动后也用 watch 机制监听/lock1
节点的创建事件),lock1
节点可以存储 Canal Server 地址的,这样的话通知 Canal Client 时可以把 B 地址传过来,Canal Client 就会与此机器建立连接了。
惊群效应与解决方案
按照上述的设计方案其实已经能满足 Canal 的高可用设计了,不过我们目前设计的 ZK 系统实现的分布式锁有两个问题
- 假设有几十台备机,当主机宕机后,这几十台备机都会尝试着去创建
/lock1
这个节点,但只有一台机器创建成功并成为主机,另外几十台机器在创建节点失败后则会马上处于等待状态,这就是我们所说的惊群效应(也叫羊群效应),不难发现惊群效应会造成资源的极大浪费,那么宕机后能否只通知一个备用节点响应,这样其他备用节点就不用群惊群乍
了,能极大地节省资源。 - 当前的分布式锁是非公平锁,这样会造成饥饿现象,可能一些备机永远没有机会获取这个锁了,如何让它成为公平锁,让每个备用节点都有机会获取这个锁以让它们都有机会成为主机呢。
解决方案如下:
每个机器都会在 /lock1
下创建一个子节点,子节点的编号会按申请顺序递增,编号最小的那个节点表示其对应的机器持有了分布式锁,其余机器只会监听比它小一级的那个节点,这样当某个节点宕机了,只会通知这一个监听机器,避免了惊群效应
如图示,工作机制如下:
- 一开始,多台机器都在 /lock1 节点下创建以
sub-xxx
依序递增的节点,假设现在有一台机器创建了一个sub-000001
,则由于它的序号最小,表示它占有的分布式锁,其他机器则会依次创建sub-000002
,sub-000003
这样依序递增的节点。 - 每个节点对应的机器只会监听(watch)比它小一号的节点
- 这样的话,如果分布式锁释放了,则只会通知比它大一号的节点,如
sub-000001
节点对应的主机宕机了,只会通知机器 B(此时 ZK 也会把sub-000001
这个临时节点给删了)这样 B 持有的节点序号最小,它也就持有了分布式锁。
通过这种方式我们可以也注意到机器获取锁的顺序与其创建的节点顺序保持了一致,也就实现了公平锁。
ZK 简介
以上就是利用 ZK 来实现分布式锁的一个简单案例,当然分布式锁只是它众多功能中的一个而已,作为一个分布式协调服务,它还有配置管理
,名字服务
,分布式同步
以及集群管理
等功能。先来简单看一下 ZK 的一些概念。
节点
我们已经知道 ZK 采用的是一种类似 Linux 文件系统的树形结构,树上的每个节点我们把它称为 Znode,Znode 节点分为以下四大类
- 临时节点:前文我们提到主机宕机后
/lock1
就会被删除,这种主机与 ZK 断开链接就被删除的节点我们称其为临时节点 - 临时顺序节点: 前文我们为了实现公平锁而创建的按申请次序顺序递增的节点
- 永久节点:客户端与 ZK 的连接断开后,节点还在,甚至 ZK 重启后节点也还在
- 永久顺序节点:在永久节点的基础上加上了按申请顺序递增的功能的节点
每个 Znode 节点都是可以存储数据的,默认是 1M,这样的话可以作为配置管理中心存储一些比较重要的数据
watcher 监听事件
前文我们提到了备份机器可以通过 watcher 机制来监听节点是否存在,从而可以及时响应这些事件作出处理,除了监听节点是否存在外,ZK 还提供了以下事件
public enum EventType { None (-1), // 客户端连接状态发生变化的时候 会受到none事件 NodeCreated (1), // 节点创建事件 NodeDeleted (2), // 节点删除事件 NodeDataChanged (3), // 节点数据变化 NodeChildrenChanged (4); // 子节点被创建,删除触发该事件 } 复制代码
ZK 就是通过这些 watcher 事件来实现分布式协调服务的,接下来我们来看看 ZK 在生产上的两个应用
ZK应用
作为 dubbo 注册中心
假设现在有两个服务,用户服务和订单服务,为了高可用,订单服务会部署多台机器,每个订单服务的机器都会在 ZK 中注册,每个节点为临时节点,如下
调用方(用户服务)会获取 /orders 下的所有子节点,即所有机器列表,也会监听 /orders
节点的 NodeChildrenChanged
(子节点被创建或被删除)事件,然后通过一定的负载均衡算法选择一个来连接,假如其中一台机器如 192.168.11.1 宕机了,则其对应的临时节点会被删除,同时用户服务会能收到此 watch 事件,于是会重新获取 /orders
的子节点,此时由于宕机对应的子节点被删除了,所以只会获取 192.168.11.2 和 192.168.11.3 这两个子节点,这样就避免了连接 192.168.11.1 这个不可用的机器了
作为配置中心
在生产环境上 ,我们经常需要配置一些变动频繁,需要实时生效的数据,比如某个功能上线,我们需要针对某些用户做灰度,这个灰度规模是逐渐扩大的,我们就需要配置一下这个百分比来让每个机器实时生效,这时就可以让 ZK 作为配置中心,让每台线上的机器监听配置节点的 NodeDataChanged
(节点数据变化) 事件,这样只要这个节点数据变化了,其他机器可以立即收到通知更新,让此修改立即生效,我司用的 360 开源的分布式配置管理系统 QConf 就是基于 ZK 开发的。
总结
通过本文相信大家不难理解 ZK 的工作机制,主要要理解它的树状结构,节点及 watch 工作机制,掌握了这些就能理解 ZK 作为配置中心,分布式锁,域名服务的原理,当然如果要更深入地了解 ZK,光掌握这些还不够,比如 ZK 如果只有一台,那会有单点故障,就要配置 ZK 集群,既然是集群,那如何保证数据一致呢,你需要去了解 ZAB 协议,选举机制等,建议大家看看《ZooKeeper分布式过程协同技术详解》这本书,会让你对 ZK 有更深入的理解。