京东于 2018 年对其自研的讯息伫列中介软件 JMQ 进行了一次彻底的重构,升级为 JournalQ。相比上一代产品,JournalQ 大幅提升了效能,功能上增加了 Kafka、MQTT 等协议的支援,提供更加完善的事务机制;设计上采用了储存与计算分离的模式,资料储存层从 JournalQ 中分离出来作为一个独立的中介软件产品,高可用分散式的流资料储存:JournalKeeper。基于这种储存计算分离的设计,JournalQ 在产品的定位上从单纯的讯息资料管道昇级为流资料的储存分发平台。
笔者作为架构师,全程参与了 JournalQ 和 JournalKeeper 的设计和开发。这篇文章中,我将跟大家分享在开发这两款产品过程中的一些技术心得和实践经验。
为什么需要流资料储存?
流资料储存并不是当下技术圈火热的话题之一,甚至很少人会听到过这个话题,更少的人会在实际业务中使用一款流资料储存的产品。那京东为什么要开发这样一款流资料储存呢?
一切还需要从资料治理说起。随着微服务架构的普及,服务治理的理念已经深入每个开发者的心中。我们先回顾一下服务架构的演进过程:从最原始的单体应用,发展为烟筒式架构,然后是 SOA 模式,直到现在流行的微服务架构,服务的粒度被拆分的更细,服务的复用能力更强,服务间耦合度更低,直接带来的益处是降低了总体拥有成本。
和服务治理一样,当企业拥有的资料规模发展到一定阶段,资料也需要被治理。同样回顾一下资料储存架构的发展过程:早期业务规模不大时,单体服务配合单个数据库就可以满足需求;随着业务规模逐步扩大,资料规模也越来大,单体数据库已经无法满足效能和容量的需求,普遍的解决办法是对数据库进行分库分表,并且为了提高效能和可靠性,采用读写分离的架构。具备一定规模的互联网公司,往往业务分工更加细致,对资料的使用方式也更加多样化,分库分表已经不能满足其业务需求。例如,对于同样一份资料,搜寻团队需要把资料储存在 ElasticSearch 中以便于提升搜寻效能;大资料团队希望把实时资料接入到 Kafka 中,离线资料存放到 HDFS 中,以便于其计算和分析;负责线上业务的团队,需要将资料存放到 Redis 中用于快取,获得更好的线上访问体验,等等。

为了满足不同的业务需求,同一份资料被转换成各种特定的资料格式,存放在各种各样数据库中。这种多副本的资料结构的优点是显而易见的:每个副本的资料结构都是易于特定业务的查询方式进行优化,并且选用最适合的数据库进行储存,可以达到最佳的查询效能。
为此付出的代价是耗费了大量储存和计算资源。为了维护资料新鲜,每一份资料副本都要实时或者定期从上游资料来源进行资料同步,当资料量很大的时候,这种 ETL 操作需要大量的计算资源;每一份资料为了保证查询效能和可靠性,需要存放多个数据副本,为了确保资料可靠性,还需要定期备份资料快照,这些副本和快照都需要占用大量的储存资源。另外一个问题是资料耦合,当业务需要对某个数据库的资料结构变更时,还需要考虑是否能满足下游资料的需求,这种在不同的数据库之间直接进行资料同步的方式,造成了事实上的资料依赖。
为了治理这种资料乱象,在不降低各种业务效能的前提下,减少对储存和计算资源的使用,解决资料耦合问题,我们提出了如下这种资料架构:

我们这里面提到的“流资料”相比大家熟知的流计算中对应的概念更加宽泛一些,几乎所有的资料在产生的源头都可以认为是“流资料”,例如:
Nginx 收到的 Http 请求;微服务计算后生成的更新资料的 SQL;从页面和 APP 采集到的埋点资料;各种应用程序的日志等。将流资料从产生的源头就实时存入流资料平台,各业务系统统一从流资料平台获取资料经过必要的计算和转换后,存入对应的业务数据库中。资料使用方可以像使用讯息伫列一样从资料流平台获取订阅资料的实时推送,也可以按照指定的位置或者时间来进行资料定期的资料同步,实现了批流一体的模式。统一资料订阅避免了资料多次 ETL 浪费的计算资源。并且由于资料流的可回溯性,不需要对资料流本身备份资料快照,资料的使用方可以也可以减少资料快照的密度,节省了储存资源。使用统一的资料流平台,隔离了资料的生产者和资料使用方,有效的解决了资料耦合的问题。
当然,资料流储存也不是万能的,这种储存形式只支援按照时间和位置进行查询,并不适合业务系统直接使用,所以其定位还是一个数据储存、交换和分发的平台。
我们需要什么样的流资料储存?
数据库和中介软件这类 PaaS 层的基础设施类软件,近些年的发展趋势是越来越专业化、精细化。只在一个很窄的领域内解决一两个特定的问题,但是在这个领域内,具备极致的效能和体验,可以以极高的效能的处理海量的资料。我们的流资料储存也是这样一种设计思路,它的功能非常的简单,就是储存流资料,但需要具备储存海量资料的能力,并且具备非常高的效能。
我们在设计这款产品的时候,给它定义了如下这些特性:
有序:资料必须是严格有序的,不同顺序有可能导致完全不一样的结果。Append Only: 资料只能追加写入,并且写入成功的资料具有不可变的特性。此外,它还需要具备其它资料储存丛集相同的一些通用特性,包括:
分散式: 支援丛集模式,可以水平扩充套件;高效能:具有远超一般结构化数据库的至少一个数量级超高的性读写能,这样整个系统才不会因为引入这个流资料储存而显著的降低总体效能;可靠性:单节点损坏不会丢失资料;顺序一致性:丛集中所有节点按照一致的顺序更新资料,简单的说,刚刚写入的资料不要求立刻在所有节点都能读到,经过一个短暂的时延后资料陆续更新至所有节点是可以接受的。近乎无限的容量。效能
我们请专门的测试团队对 JournalQ 进行了极限效能的压测,测试结果显示,单节点的极限写入效能为:32,961,776 条每秒,并且在极限情况下具有非常好的稳定性,响应时延的 tp99 不超过 1ms。资料同步读取的效能与写入效能相当,可以满足同步读写的要求,做到“写入多快就读取多快”。测试环境如下:
测试服务器:32C/256G/4TB SSD/ 万兆以太网测试每条讯息大小为:1KB压缩方式:LZ4 压缩接下来分享一下我们在实现过程中效能优化的一些经验。
储存结构设计
对于资料储存类的系统,决定其读写效能的根本因素是储存结构的设计。JournalKeeper 采用了一种非常简单高效的储存结构,如下图所示:

资料按照顺序依次写入 Journal 档案中,然后将每条资料的全域性偏移量作为索引值,按照同样的顺序记录在 Index 档案中。考虑到单个档案的大小限制,把 Journal 和 Index 都拆分成多个连续的档案,每个档案的档名就是档案内第一条资料的全域性偏移量。

资料写入时,由于流资料尾部追加写入的特性,只要一直储存索引和资料尾部的所在的档案和偏移量,就可以直接进行写资料操作,因此写入的时间复杂度为 O(1)
O(1)。
读取的查询过程稍微复杂一些:
首先需要根据给定的索引序号找到对应的索引档案。由于每个索引的长度固定为 16 个字节,索引序号 x16 即可以计算出索引的全域性偏移量。JournalKeeper 把每个分割槽的索引档案的档名(即这个档案第一条索引的全域性偏移量)都存放在一个跳表中,找到索引所在档案的过程相当于在跳表中进行一次搜寻,其时间复杂度为:O(logn)O(logn),其中 n 为 Index 档案的个数;找到档案用,用索引全域性偏移量减去档名就可以找到索引在档案中的位置,通过读取索引获得资料在 Journal 中的全域性偏移量 ;根据资料的全域性偏移量查询资料的过程和查询索引类似,其时间复杂度为:O(logm)O(logm),其中 m 为 Journal 档案的个数;总体的读取时间复杂度为:
O(log
n
)+O(log
m
)
O(logn)+O(logm)
其中 n 和 m 分别为 Index 档案和 Jouranl 档案的数量,考虑到 n 和 m 远远小于资料的总数,可以近似的认为:
O(log
n
)+O(log
m
)≈O(1)
O(logn)+O(logm)≈O(1)
快取设计
在 JournalKeeper 中,流资料是储存在磁盘中的,为了提高读写的效能,我们为其设计了一套定制的内存快取系统。经测试,在正常读写的情况下,这套快取的命中率约为 99.96%,几乎全部的读请求都可以命中快取,提升了读效能的同时,还可以将几乎全部的磁盘 IO 用于资料写入,进一步提升了资料写入的效能。
在快取页粒度的选择时,JournalKeeper 使用了最简单的策略:将整个档案快取在内存中。无论是 Journal 档案还是 Index 档案,每个快取页面对应一个档案。这种设计的优势在于,不需要再为快取页编写单独的查询算法,只需要复用档案的查询算法即可,并且快取页和档案的对应关系也变得非常简单。
不足之处是,如果只是为了读取档案中的一小部分资料,不得不载入整个档案,这种设计显然是不太经济的。但是考虑到流资料的顺序连续读写特性,随机的读写非常少,更多的读写方式从某个位置开始连续的向后读写,这种场景下,较大的快取粒度不仅很少会出现“资料读到内存中却最终没有被使用”的情况,反而可以避免频繁的换页带来的效能抖动。
另外一个问题是,快取页比较大,从磁盘载入整个档案到内存中的耗费的时间相对较长。我们针对这个问题做了二方面的优化。
大多数应用对流资料的访问有一个特性:越新的资料访问概率越高。比如像讯息伫列,正常情况下生产的资料马上就会被消费掉。资料在写入磁盘前一定会经过内存,那我们就没必要在读的时候再从磁盘上重新载入一次,直接从内存中读出来更快,而且节省了宝贵又特别慢的磁盘 IO,这个我们称为读写共页,这是第一项优化。
第二项优化叫异步预载入,原理非常简单但是效果很好。既然是连续读写,那上一个档案读写完成后,有非常大的概率会继续读写下一个档案。基于这个特性,当读写到接近档案的尾部时,JournalKeeper 会开启一个异步执行绪,把下一个档案先载入好,这样不仅能解决大档案载入慢的问题,还能避免同步载入档案导致的卡顿和效能抖动。
在内存管理方面,为了避免 JVM 频繁的垃圾回收造成的卡顿,JournalKeeper 选择使用堆外内存作为快取。使用堆外内存的好处是效能更好,多数情况下可以减少一次内存拷贝。JournalKeeper 自己进行内存管理,避免了不可预期的 FullGC。
最后说一下快取的淘汰策略,内存空间是有限的,不断有新的页需要快取必然要淘汰一些快取页。JournalKeeper 采用一种改进的 LRU 策略PLRU。LRU 淘汰最近最少使用的页,JournalKeeper 根据流资料储存的特点,在淘汰时增加了一个考量维度:页面位置(即档名)与尾部的距离。因为越是靠近尾部的资料,被访问的概率越大。这样综合考虑下的淘汰算法,不仅命中率更高,还能有效的避免“挖坟”问题:例如某个客户端正在从很旧的位置开始的向后读取一批历史资料,内存中的快取很快都会被替换成这些历史资料,相当于大部分快取资源都被消耗掉了,这样会导致其他客户端的访问命中率下降。加入位置权重后,比较旧的页面会很快被淘汰掉,减少挖坟对系统的影响。
执行绪模型
说完了储存接下来聊一聊程式码本身的优化。
首先更正一个在很多开发者的观念里都存在的误区:高并发并不等于高效能。在很多开发者的认知里,应用增加并发后效能确实得到了成倍的提升。其实根本的原因是单个并发的效能没有很好的优化,没有做到充分的利用计算资源,大部分时间都浪费在等待上了。
对于计算密集型的应用,瓶颈资源是 CPU,理想情况下,最高效的方式 CPU 有几个核就起几个执行绪,这样才是最充分的利用 CPU 资源。启动了过多的执行绪,反而会有一部分 CPU 时间在 CPU 上下文切换被浪费掉了。但如果程式码优化的不够好,比如说每次计算出一批结果后把计算结果写到磁盘里,在写磁盘等待 IO 的这段时间内,这个执行绪对应的 CPU 核心是处于闲置状态的。这种情况下启动更多的执行绪,操作系统会自动把 CPU 排程给其它执行绪,这样看起来提高并发确实带来了效能提升。但我们要知道,只不过是因为我们的程式码优化的不够充分,操作系统替我们的程式做了一些排程优化而已,总体的效能并没有达到最优的状态。
所以,做极致的效能优化,最先要解决的是减少等待。
实际开发过程中,可用的方法有很多,这里面分享几个比较简单实用方法:
异步化:将你的执行绪模型都改成异步化,比如使用 CompletableFuture、RxJava 等异步框架,避免等待那些可能耗时的操作结果。拆分流程:把一个很长的流程拆分成几个短的流程。减少锁:设计时尽量少的使用共享资源,减少锁的使用。减少锁等待:实在需要使用锁的的地方,尽量减少锁的粒度或者用读写锁,减少锁的等待时间;一般来说讯息伫列都是生产的时候需要处理的业务逻辑相对比较多,我们看下 JournalQ 是如何优化它这部分设计的。

写入资料的流程如下:
Producer 发讯息给 Leader Broker;Leader Broker 解析处理讯息;Leader Broker 将想讯息复制给所有的 Follower Broker,同时异步将讯息写入磁盘;Leader Broker 收到大多数 Follower Broker 的复制成功确认后,给 Producer 回响应告知讯息传送成功。对于这个流程,我们设计的执行绪模型是这样的:

图中白色的细箭头是资料流,蓝色的箭头是控制流,白色的粗箭头代表远端呼叫。
这里我们设计了 6 组执行绪,将一个大的流程拆成了 6 个小流程。并且整个过程完全是异步化的。除了 JournalCache 的载入和解除安装需要对档案加锁以外,没有用到其它的锁。每个小流程都不会等待其它流程的共享资源(没有资料需要处理时等待上游流程提供资料的情况除外),并且只要有资料就能第一时间处理。
高可用架构
说完了单节点的效能优化,我们来谈整个丛集的架构。
从实用角度出发,我们在设计一个丛集或者一个系统的总体架构时,需要在CAPC这几个方面进行取舍:
一致性 (Consistency)可用性 (Availability)效能 (Performance)复杂度 (Complexity)举个例子,现在很多微服务的应用都是用 MySQL 储存线上业务资料,为了加快业务访问会使用 Redis 快取部分 MySQL 中的资料。这种设计提升了系统整体的效能,付出的代价是牺牲了资料的一致性:从 Redis 中读出的资料有可能并不是最新的,在某些特定应用的场景下,这种暂时的资料不一致是可以接受的。
系统的复杂度是容易被忽略的考量指标。过于复杂的设计更难于实现和维护,会大幅提高系统的总体拥有成本,因此在其它三个考量因素都可以接受的范围内,尽量采用简单的设计总是一个不错的选择。
如果可能的话,可以将服务设计成无状态的。无状态服务的设计让丛集的结构更加简单,天然支援水平扩容。对于有状态的服务,可以尝试将储存和计算逻辑分离为无状态的计算服务和有状态的储存服务,然后用一致性的储存来储存状态资料。
Raft 一致性算法
很多分散式系统选择 Apache ZooKeeper(以下简称 ZK)用于储存状态资料,ZK 一主多从的架构和其自动选举机制很好的平衡了资料可靠性、一致性和可用性,并且具有相对不错的效能。JouralQ 的上一代产品 JMQ 也使用 ZK 储存元资料,但我们在运维 JMQ 的过程中也遇到了一些 ZK 的问题:
可维护性问题: 运维人员部署和运维 JMQ 丛集时,不得不一并维护 ZK 丛集,并且 ZK 丛集故障会影响到 JMQ 丛集。多机房部署的问题:京东的 JMQ 丛集包含超过 2000 个节点部署在全球多个机房中,当机房间的链路出现问题时,在拥有少数节点的机房中 ZK 丛集将处于不可用状态,不可避免的会对使用 ZK 的 JMQ 丛集产生影响。资料容量的问题:ZK 本身的容量是有上限的(我们的经验资料是 500MB 左右),否则很容易导致选举失败,陷入反复选举丛集不可用的状态。选举速度慢:ZK 选举完成后,还需要完成超过半数以上节点的资料同步过程才能提供服务,当资料量比较大时资料同步的耗时也比较长,导致不可用时间也会相应变长。考虑到上述问题,在设计 JournalKeeper 时,我们决定基于 Raft 协议自行实现分散式协调相关的服务,并把这部分功能直接整合到 JournalKeeper 的服务程序中,避免运维不必要的协调服务丛集。
JournalKeeper 不仅使用 Raft 来维护其元资料,Raft 协议也被用来维护储存的流资料的一致性。我们为对于每个资料流(可以理解为一个 Topic)都建立一个 Raft 丛集,丛集的每个节点为一个虚拟程序,Leader 节点提供流资料写入服务,所有节点都可以提供流资料的读服务。
关于 Raft 一致性算法本身,大家可以参考作者在 GitHub 上的主页:https://raft.github.io和论文:https://raft.github.io/raft.pdf。
Raft 的优点在于:
强一致:严格按照 Raft 协议实现的丛集可以提供最高等级的一致性保证。快速选举:Raft 的选举算法非常简单高效,大多数情况向通过一轮投票即可选出新的 Leader,并且选举完成后 Leader 立刻就可以提供服务,不需要等待资料同步。易于理解:Raft 相比于其它的一致性算法,更易于理解和实现。Raft 协议也存在一些不足之处:
首先,Raft 的大多数原则限制了丛集的规模,一般来说,丛集的节点数设定为 3、5 或 7 个,更多的节点数量会显著拖慢选举和复制的过程。受限于一致性的要求,Leader 只能顺序处理写入请求,处理写入请求过程中需要等待资料安全复制到大多数节点上。丛集节点越多,Leader 的出流量更高,复制的时延更大,将导致丛集的写入的效能下降。类似的,丛集节点越多,选举的过程越慢,由于选举过程中丛集是处于不可用状态的,过多的节点数量会降低丛集的可用率。
改进版的 Raft
原生的 Raft 协议并不能直接满足 JournalKeeper 的需求,我们在实现过程中对协议的算法做了一些适应性的调整,牺牲了部分一致性,用以换取效能的极大提升。
读请求分流
对于流资料储存来说,并不需要强一致,顺序一致已经可以满足需求。刚刚写入的日志在通过短暂的复制后才能读到是可以接受的。
JournalKeeper 在支援强一致的同时,提供另外一种比更宽松的高效能一致性实现:顺序一致性,来缓解效能和可用性的问题。顺序一致不要求在同一时刻所有节点的状态都保证完全相同,只要保证丛集各节点按照一致的顺序储存同一份日志即可。Raft 协议中,已经提交的日志具有不变性,也就是说在丛集任何一个节点上同一个位置,只要这个位置已经提交,读到的日志就是一样的。基于这个保证,对于流资料(也就是 Raft 的日志),可以把读请求分流到 Follower 节点上。
将一致性约束放宽至顺序一致的前提下,JournalKeeper 的所有的节点都可以提供读服务,实现了读写分离,大幅提高了丛集整体的读效能。并且,可以通过增加 Follower 的数量来水平扩容,丛集的节点数量越多,总体的读效能越好。通过将读请求的压力从 Leader 分流到 Followers 上去,相对的提高了写入效能。
我们将两种一致性混合使用,在一致性、效能和可用性三方面达到一个相对最优的平衡:
对于元资料的访问,通过 Leader 读写确保强一致;对于流资料的写请求,通过 Leader 写入保证流资料的顺序和一致性;对于流资料的读请求,不需要严格一致,通过 Follower 读取;观察者
为了提高丛集的吞吐量,需要用更多的节点数量分摊压力,但增加节点数量又会导致丛集的写效能和可用率下降。JournalKeeper 提出了一种新的角色 观察者 (OBSERVER) 来解决这一矛盾。丛集中的节点被划分为如下 2 种角色:
选民(VOTER) 拥有选举权和被选举权的节点,可以成为 Leader、Follower 或 Candidate 三种状态。观察者(OBSERVER) 没有选举权和被选举权的节点,提供只读服务,只从丛集的其它节点上覆制已提交的日志。选民节点即 Raft 中的节点,可以成为 Leader、Follower 或 Candidate,参与选举和复制过程。观察者从丛集的其它节点拉取已提交的日志,更新自己的日志和提交位置。观察者节点提供和选民节点完全相同的读服务。
观察者既可以从选民节点拉取日志,也可以从其它观察者节点拉取日志。为观察者节点提供日志的节点无需维护观察者节点的状态,观察者节点也无需固定从某一个节点上拉取资料。观察者对于选民来说是透明的,选民无需感知观察者,这样确保 Raft 中定义的选举和复制的算法无需做任何变更,不破坏原有的安全性。观察者可以提供和所有选民一样的读服务,因此可以通过增加观察者的数量来提升丛集的吞吐量。观察者不参与选举和复制的过程,增加观察者的数量不会拖慢选举和复制的效能。

丛集节点超过一定数量时,大量的观察者节都从少量的选民节点拉取资料,可能会导致网络拥塞。这种情况下,可以使用多级复制的结构来分散日志复制的流量。需要注意的是,复制的层级越多,处于边缘的节点更新到最新状态的所需的时间越长。
并行复制
针对 Raft 线性复制的效能较差的问题,JournalKeeper 在保证一致性的前提下,给出了一种并行复制的实现,能显著降低日志复制的平均时延,提升总体吞吐量。
在 Raft 中,串行复制的流程是:
读取:Leader 读取资料,构建复制请求;网络传输:Leader 将复制请求传送给 Follower;写入:Follower 收到日志后写入内存或磁盘,构建响应;网络传输:Follower 将响应传送给 Leader;提交:Leader 收到响应,如果满足条件则提交已完成复制的日志。并行复制的思路是,Leader 并行传送复制请求,Follower 中维护一个按照日志位置排序请求列表,按照日志位置序列处理这些复制请求,Leader 按照位置顺序处理响应。也就是说整个复制流程拆分成上面的 5 个小流程,其中 1、2、4 三个小流程可以并发,3、5 为了保证资料一致性不能并发,依然序列执行。对于并发后可能出现的乱序和资料空洞问题,可以通过对请求按照资料的位置进行排序和少量资料重传解决,具体的实现细节大家可以参照 JournalKeeper 的源代码或文件。

结语
如果说单节点的效能优化更多的是一些小的方法和技巧,这个在中国传统文化里面称之为“术”。而丛集层面的架构设计更多的是一些大方向的选择和取舍,这个称之为“道”,也就是道理的“道”。没有最好的架构,只有最适合的架构,所谓有一得必有一失,一个优秀的架构师,不仅要有具备足够的技术能力,更要有足够的高度和大局观,懂得在宏观层面做好把握和取舍,方能成就优秀产品。





























