Watch机制的实现和应用

介绍

查询是后台领域经常使用的一种数据同步方式。但是在一些场景,需求方需要针对一些数据变化做出响应。虽然定期轮询也可以满足部分的需求,但在以下场景中就不太合适了。

  • 存活检测:为了检测低于%1异常。轮询会一直耗费查询资源。
  • 实时响应:变化响应时间要尽可能小,但是轮询周期越小,消耗的资源也越多。

因此数据层往往在“增删改查”这4种基本接口之外还会提供一个watch接口用来实时推送数据变化事件。

Watch机制设计

watch机制是一个典型的CS架构,其中数据需求方作为client,数据提供方作为server。由client向server发起请求,server端推送数据给client。

版本号

带有watch机制的存储系统往往都是具有版本功能的。具备版本功能的系统可以实现以下两个功能:

  • 因为某些原因(崩溃、重启)client可能错失部分历史事件。恢复之后client可以利用版本号重新接收这些事件。
  • client可以在请求参数中附带版本号表示该版本号之前的历史数据已经接收。server可以过滤掉过时事件只发送新的事件给client。具体如下图所示。
图1

连接层

连接层讨论的是client和server之间通信的协议。相较于单机程序之间的IPC通信,分布式系统网络通信的IO成本是非常大的。下面就连接层实现方式、性能和开发成本展开具体分析。

http长轮询

http长轮询是一种非常容易实现的watch手段,因此也是使用最广泛的。比如etcd v2、consul等都使用了http长轮询技术来watch事件。具体实现原理如下图。

http长轮询.png

http长轮询的优点是实现简单、兼容性好,不需要额外开发客户端程序。但是这样的实现意味每接收一个event都需要至少走完一个http请求应答流程。这对于watch key非常多的系统,负荷是相当大的。假设某个后台系统需要watch 10W个key,每个http轮询超时时间为100s。计算下来即使在空闲的时候系统需要承受并发10W个连接和1K/QSP的请求量。

长连接

长连接模式是对http长轮询的一种优化。不同于http长轮询每个连接都只能处理一个事件,长连接模式一个连接可以接收多个事件,通过减少了tcp三次握手的开销,提高了资源利用率。

长连接.png

但相对而言长连接模式开发成本要比http长轮询高,主要体现在:

  • 需要定义事件流的序列化和反序列化协议。目前没有公认的标准,只能私有定制应用于内部系统。
  • 没有成熟的反向代理组件。需要考虑在大规模部署下的负载均衡问题。

长连接模式虽然减少了tcp握手的开销,但每个watch key都需要一个连接。假设某个后台系统需要watch 10w个key,就需要建立10W个连接,这很容易消耗光server的socket和内存资源。

多路复用

多路复用是通信工程上的概念,指的是将多个低速信道整合到一个高速信道进行传输,从而有效地利用了高速信道。通过使用多路复用,可以避免维护多条线路,从而有效地节约运营成本。

网络工程上有很多方面借鉴了多路复用的思想。比如L4层的TCP、UDP就是复用了L3层的IP层的通道。同理我们也可以在tcp上层定义更高层协议来复用tcp连接。如下图,虽然端到端之间只有一个tcp连接。但在逻辑层上可以抽象出多个双工的session stream。每个session stream负责一个watch任务。假设一个客户端需要watch 1k个key,原先按照需要1k个连接,但现在只需要一个连接即可。

多路复用.png

多路复用核心就是在tcp连接上构建一个逻辑层。该逻辑层负责处理一下内容:

  • 建立、关闭stream需要的控制帧。
  • 接收时将tcp连接里的数据流,拆分为一个个frame,再按照协议封装到对于的stream中。
  • 发送时将stream里的数据流分割成frame,再通过tcp层发送出去。
  • 为了防止stream之间相互干扰抢占带宽资源,需要设计流控机制公平调度。
  • 空闲stream保活通信,维持session会话心跳。
  • 封装好类似socket的Read和Write的接口,方便业务调用。

可以看出,多路复用技术是开发是比较复杂的。但值得庆幸的是,业内已经有了成熟可靠的工具和标准。HTTP/2定义了多路复用的协议,grpc实现多语言版本的接口。我们完全可以秉着拿来主义的思想直接来用,比如etcd v3版本就是使用grpc stream模式来处理watch的。

The etcd3 API multiplexes watches on a single connection. Instead of opening a new connection, a client registers 
a watcher on a bidirectional gRPC stream. The stream delivers events tagged with a watcher’s registered ID. Multiple
watch streams can even share the same TCP connection. Multiplexing and stream connection sharing reduce etcd3’s 
memory footprint by at least an order of magnitude.

consul的watch也采用了多路复用技术。它自己实现了一个多路复用库yamux,虽然没有http/2和grpc那么完备,但是还是可以供愿意自己练手的同学参考学习。

watch gateway

对于一般的场合,多路复用已经有很好的性能表现了。但是etd v3.2版本提出一个进一步提高watch性能的优化方案watch gateway。

考虑到k8s使用场合可能存在有上万个watcher。一旦事件触发etcd需要广播给所有的watcher,就会带来相当大的性能消耗,甚至会影响的读写性能。但在实际场景,这些watcher很有可能监听的资源是重复的,比如每个api-server监听的资源都是一样的。为了优化这种大量重复监听watcher的场景,etcd v3.2版本设计了gateway组件。gateway可以聚合watch相同范围key的watcher。举例说明如下图,client1、client2、client3都对事件a感兴趣,如果直接请求server,server需要负担3个watcher。但如果通过gateway聚合,可以合并3个watch变成1个,这样可以降低server的压力

gateway.png

本质上来说,gateway只是将广播的压力从server转移到自己身上去了。但是server作为存储服务器,一般都是有状态的。无论是扩容还是迁移都是有一定成本的。但是gateway是一个无状态的服务,完全可以根据实际需求横向部署gateway服务器来降低存储层的压力。

下图是etcd v3.2使用watch-gateway性能提升的对比图。在不使用gateway时,随着watcher的增多,写和watch速率下降。但是使用了gateway之后,watch数量增加对性能没有影响。详细文档见(https://coreos.com/blog/etcd-3.2-announcement)

etcd gateway.png

存储

watch机制实现的另一个核心问题就是如何存储数据。按照存储类型来分,可以分为内存和硬盘里两大类。下面会根据具体场景来讨论这两种类型的数据格式的设计。

历史数据存储

watch机制的特点决定了存储系统需要保存历史数据。举例说明,如下图一个数据同步场景。client向server watch同步数据,历史同步数据已经达到1G。某个时间点网络异常导致client和server之间通信中断,watch被迫停止。在网络中断时间内,server的数据发生了变更。当网络恢复的时候,client重新发送watch请求,希望能够从version=10001继续获取事件。但此时server端的version=10010,并且没有保存历史数据。客户端发现数据丢失,只好作废之前同步的数据,重新同步高达1G的数据,等追上之后再继续watch。

历史数据.png

保留历史数据可以简化客户端的工作,但是这也给存储方带来了极大的压力。

滑动事件窗口

内存型数据库一个缺点是容量相对有限。如果在数据更改频繁的情况下保留历史数据的话,有可能导致内存溢出。因此内存型数据库往往采用滑动事件窗口来作为妥协方案。

滑动事件窗口就是一个简单的回环数组。不断的插入新事件、淘汰掉超过大小的旧事件。因为窗口的大小是固定的,因此不会出现内存溢出。
如果watch的版本命中了滑动事件窗口里的事件版本,就可以返回给client。

滑动事件窗口.png

滑动事件窗口的缺点是显而易见的。对于修改频繁的系统,滑动事件窗口可以保存的事件时间非常短,很有可能丢失事件。这个是内存型存储系统的硬伤,没办法根本解决。目前etcd v2、consul和k8s api server都是采用这样的机制。

多版本存储

相较于受限容量的内存型数据库,磁盘数据库的空间就大很多了。有能力存储足够旧的历史版本数据。比如etcd v3就是存储了多个版本的数据。

简单来说,etcd v3在内存里维护一个B树,存储的是Key和这个Key所有的版本列表。磁盘里维护了一个B+树,存储的是版本和KV的实际内容。磁盘B+树是实际的数据,内存B数一个二级索引。查找某个Key某个版本的数据可以分为以下两步:

  • 通过内存B数查找到Key对应的版本列表。再从版本列表中找到里查询版本参数最近的一个版本号Version。
  • 再到磁盘B+树中查找Version对应的数据信息。
etcd.png

因为需要将Key存储在内存中,etcd的实际存储量也是非常有限的。按照etcd文档,默认存储是2G、最大可配置到8G。当然这个比内存型的consul容量还是大的多了。

事件触发

和其他的存储系统不一样的是,watch存储系统需要在某个key变更的时候通知到client。这就需要设计对应的触发响应机制。watcher往往不仅仅监听单个的key,还可能是监听某个前缀或是范围key,只要其中之一有变化,就需要触发事件。

事件触发最简单的实现方式就是采用遍历的方法:当某个Key发生变化时,逐个遍历watcher,一旦发现满足条件的watcher就发送数据。这种O(n)复杂度的处理方式固然简单,但随着watcher数量的增多,带来的性能损失也是越来越大的。下面介绍两种应用于工程的数据结构。

radix树

说到前缀匹配,很容易想到和前缀匹配相关的数据结构radix树。在计算机科学中,基数树,或称Patricia trie/tree,或crit bit tree,压缩前缀树,是一种更节省空间的Trie(前缀树)。对于基数树的每个节点,如果该节点是唯一的子树的话,就和父节点合并。

radix.png

如图当前缀匹配watch ro的时候,可以通过radix树找到om节点。之后切割o、m节点并将watcher挂载在o节点上。如果o下面的节点有任何的变化,都会通过回调通知watcher触发事件。

consul就是采用radix树来存储KV数据的。但是radix树只能解决前缀匹配的问题,无法解决范围Key的问题。因此consul是不支持范围key watch的。

区间树

radix作为一个树的问题在于它太长了,需要大量使用间接指针。对于内存型存储结构还算好,但对于磁盘数据结构而言,多次间接查找是非常消耗性能的。目前B+树还是最适合查找的磁盘数据结构。但B+树没法反向查找某个Key是否在某个watcher范围内。为了解决这个问题,etcd v3采用了区间树。

区间树是在红黑树基础上进行扩展得到的支持以区间为元素的动态集合的操作,其中每个节点的关键值是区间的左端点。通过建立这种特定的结构,可是使区间的元素的查找和插入都可以在O(lgn)的时间内完成。

区间树.png

关于区间树原理本文不再赘述,感兴趣的同学可以查阅算法导论。简而言之,每个watcher将自己的监听范围[start,end]封装成一个节点插入区间树。当某个Key发生变化需要查找对应watcher的时候,就可以利用区间树快速查找到重叠的watcher。

应用场景

之前说的是watch的实现机制。下面谈谈watch的应用场景。利用(list watch机制)的方式解决以下场景的读性能瓶颈问题:

  • 读多写少
  • 可以接受最终一致性
  • 数据量不大,可以存储在内存中

服务发现

服务发现.png

服务发现场景恰好满足了上述的3个条件,因此非常适合采用watch同步机制来减缓服务发现服务器的读压力。每个客户端可以利用list watch缓存一份同步数据到本地,程序直接查询本地缓存,性能非常优异。

k8s api-server

k8s-server.png

k8s api 每个server利用list watch机制保留一份和etcd数据同步的缓存。当接收到查询请求时,直接读取缓存数据返回给客户端。对于新增、修改和删除请求直接透传给etcd。

需要注意的是,在服务发现场景里客户端不会修改缓存数据,但api-server是可以修改数据的。一旦涉及数据修改,就会有数据一致性的问题。假设原先数据a=1,之后客户端写入a=2。写入成功后让客户端读取另外一个server的数据,有可能读取到a=1(watch有时间差)。这就产生了读写不一致的问题。

api-server读写不一致.png

当然实际情况k8s是不会出现上述读写不一致的现象的。解决方法是ResourceVersion管理。k8s里每个Object都有对应的ResourceVersion,其实这个就是etcd的revision,也就是watch的版本号。这个版本号是自增的,对于每个请求k8s都要求客户端在请求里带上特点的版本号。api-server在收到客户端请求后会对比自身缓存里的版本信息,如果小于客户端的版本信息则需要阻塞等待新数据同步。只有缓存数据版本大于等于客户端请求的版本信息才可以返回数据给客户端。

k8s-api-version.png

总结

本文主要讨论了watch机制的具体实现和一些应用场景。虽然要实现一个简易的watch机制很容易,但随着业务发展,数据量和请求量逐步上升,就不得不就各个环节进行优化。虽然etcd和consul都是基于raft的KV数据库,但两者发展的方向已经越来越不相同。etcd是伴随着k8s不断成长,在性能优化上一步步改进。consul则是向着服务发现场景不断进步。当从watch机制实现上来看,consul做的确不如etcd做的好,但在实际应用上,很难找到一个像k8s一样对性能要求如此严苛的场景。可以说k8s采用了etcd,也是etcd的幸运。

参考

  • https://github.com/etcd-io/etcd
  • https://github.com/hashicorp/consul
  • https://github.com/kubernetes/kubernetes
  • etcd3 | A New Version of etcd from CoreOS
  • etcd 3.2 now with massive watch scaling and easy locks

你可能感兴趣的:(Watch机制的实现和应用)