最近在学习 Kafka 的一些设计原理,偶然间发现 Kafka 官方文档中独列了 Design 一章。两天看完后觉得很兴奋,因为文档中很详细地从各方面阐述了 Kafka 官方对于 Kafka 设计的目标以及设计权衡等,让我恍然大悟 Kafka 的独特与简洁。这种快乐是阅读网上各种零散的博客文章无法比拟的。我此处总结更多是为了提升自己的领悟和理解程度,行文之中会夹杂个人主观理解,我建议大家抽出时间阅读原汁原味的官方文档。
设计一个系统,精准的目标是第一步。Kafka 官方在最开始的时候,对 Kafka 的设计理想是将其做成一个可以帮助大型公司应对各种可能的实时数据流处理的通用平台。这句话里边有几个重点:“大型公司”、“实时”、“通用”,对应到系统设计上,就是需要支持大量数据的低延迟处理,并且需要考虑各种不同的数据处理场景。在官方阐述中,Kafka 着眼于以下几个核心指标:
理解了 Kafka 的设计目标以及核心指标,后续对 Kafka 的整体架构设计就会有一个方向了,因为 Kafka 的整体设计细节还算比较多,但是归根结底都是围绕这几个核心指标去做的设计,我尝试分门别类先汇总一下,可能不是很准确,希望请大家看的时候顺便赐教:
核心指标 | 实现的角度 | 具体设计手段 |
---|---|---|
高吞吐量 | 读写缓存 | 依赖文件系统自身的 Page Cache,而不是自己实现内存缓存 |
高吞吐量 | 高效的数据结构 | 采用顺序读写的结构,而不是 B 树等 |
高吞吐量 | 降低大量小的 I/O | 消息分批发布,按批投递 |
高吞吐量 | 提高消息投递吞吐量 | 由消费者批量拉取 |
高吞吐量 | 支持分批消息 | 支持异步发送消息 |
低延迟 | 避免昂贵的字节拷贝 | 统一的消息格式,零拷贝技术 |
低延迟 | 优化传输性能 | 通过批量消息压缩减小传输数据量 |
低延迟 | 提升读取性能 | 顺序读,日志文件分段存储,应用二分查找 |
低延迟 | 降低负载均衡延迟 | producer 直连 broker |
离线数据加载 | 支持周期性大量数据加载 | 依赖存储层顺序读写的常量时间复杂度的访问优势以及低廉的磁盘成本要求 |
离线数据处理 | 支持并行处理 | 通过分区设计以及 consumer 的 offset,支持 Hadoop 一类的并行作业以及断点作业 |
可靠性 | 支持“有且仅有一次”的消息投递语义 | producer 的 ID 与消息 Sequence Number,类事务提交语义 |
可靠性 | 容错处理与高可用 | ISR 机制与 Leader 均匀分布设计 |
除了上表所列内容,还有少量设计思考暂时不好归类,比如:
以上的总体设计,让 Kafka 看起来也更像是一个日志型系统,而不仅仅是传统意义上的消息队列。
Kafka 的设计中,存储层直接基于文件系统实现,而不是额外实现复杂的存储层抽象,比如引入缓存和缓冲等。
一般提到文件系统或者磁盘存储,大家第一反应就是“这东西不是很慢吗”?是的,一般来说,磁盘的读写速度是很慢,但也限于随机访问的前提下,而事实上,特定条件下,磁盘的顺序读写性能堪比内存的随机访问性能!是不是很出乎意料?
另外,现代操作系统内部都已经实现对底层文件系统的统一抽象,特别是对读写性能的优化,大家可能了解的是预读(Read-Ahead)和后写(Write-Behind)。结合顺序读写的特性,这种操作系统的优化能够被发挥到极致。
如果考虑应用层的缓存设计方案,就会考虑到 Kafka 运行于 JVM 之上,JVM 中对象的封装表示都会有额外的内存开销,这种额外开销与对象本身数据的大小相当。所以,如果是在应用层自行实现缓存层,则意味着会有额外的大致两倍于消息体积的内存开销。这个成本对于大数据处理场景来说,可不是闹着玩的。开销也不仅限于内存开销,Java 本身的 GC 算法会随着应用堆内存的增加而愈加频繁且迟钝。
最后,缓存的设计还绕不开缓存预热的思考,由于操作系统本身对于读写性能优化的设计,可以认为预读和后写等特性已经帮助应用透明地实现了缓存的预热和落盘。而如果是在应用层面,则不得不重复造轮子,且需要考虑的细节很多。
综上,Kafka 官方认为直接基于文件系统实现存储,是一个非常明智的决定。
众所周知,Kafka 采用了追加写也就是顺序写的方式来完成数据持久化,消息投递过程中也是按照顺序读的方式实现。在 Kafka 看来,顺序读写带来了诸多好处。
在 B 树等数据结构上操作的时间复杂度是 log(n),一种一般看来近似于常量时间复杂度的算法。但是实际上,考虑到磁盘的特殊结构以及额外的磁盘定位(事实上,定位不是一步到位的,分为寻道和旋转两个阶段,感兴趣的可以阅读《磁盘 I/O 那些事》)等,这种数据结构的操作性能的下降速度,其实是高于数据本身体积的增长的,也就是随着数据越来越大,这种数据结构的性能下降越来越快。
而采用顺序读写,由于只需要一次磁盘定位,可以认为其操作时间复杂度为 O(1)。因为一般而言,一次 I/O 操作的总体延迟,主要是磁盘定位的延迟,而数据传输的延迟与之相比不值一提。所以这种常量时间复杂度的访问操作,天然的好处是我们可以不用担心访问数据的大小。因此,这种数据结构在面对大量数据的读写时,会有更加稳定的性能表现。在 Kafka 团队看来,Kafka 可以放心地以更低成本实现存储,特别是可以以磁盘转速换取空间,这也是 Kafka 可以放心地保留历史消息而不做即刻清除的原因。
这里补充一点来自《磁盘 I/O 那些事》)的参考信息:
目前磁盘的平均寻道时间一般在 3-15ms
7200rpm 的磁盘平均旋转延迟大约为 60*1000/7200/2 = 4.17ms
目前 IDE/ATA 能达到 133MB/s,SATA II 可达到 300MB/s 的接口数据传输率,数据传输时间通常远小于前两部分消耗时间
除此之外,由于是追加写顺序读,还可以简化读写操作并发的问题。我们不需要担心各种锁或者阻塞问题,读写互不干扰。
Kafka 中的 I/O 操作主要是两个环节:客户端和服务器端之间的网络 I/O,以及服务器内部持久化操作中的磁盘 I/O。在 Kafka 的整体设计里,大的思路就是降 I/O,增吞吐。
Kafka 在设计上支持消息分批投递,并且在持久化存储上原样保存,最后也是按批交付给消费者,全程不会对此批数据进行分解或者合并。这种设计有几个好处:
这里消息原样存储和投递还有一些零拷贝以及消息压缩方面的考虑,稍后也会聊到。
这里刚好由消息分批就想到了发布者的异步消息发送,这是由客户端 SDK 完成的功能,其可以配置在超过指定时间或者超出指定消息量的情况下触发消息投递到 broker,虽然会牺牲一些投递时机的延迟,但是赢取了分批投递所带来的吞吐量的提升。
目前为止,关于提高吞吐量的设计,画了个图,以助加深印象:
为了降低延迟,broker 最好是越少干预消息约好。为此,Kafka 设计了统一的二进制消息格式,而且在消息投递的全过程中,都需要修改消息内容,带来的好处是二进制消息无需经过 broker 的任何转化处理,原样落盘。更重要的是,由于消息原样投递给消费者,可以方便结合零拷贝技术实现消息在网络的快速传输。特别是对于多消费者组的场景,消息的投递直接从 Page Cache 读取,不用担心广播带来线性的访问开销。最后通过网络传输,理论上消息投递的速率可以逼近网络连接传输速率的上限。
如果说零拷贝是为了避免无谓的开销,那将消息体进行压缩,则是为了降低数据传输的体积。Kafka 使用了端到端的分批消息压缩协议,至于为什么是分批呢?因为一般来说,在同个 topic 里,我们倾向于传输同类或者相似的消息类型,这些类型的消息会有大量重复的字段名,如果按批压缩,能够获得远比单条消息大的压缩率。由于是端到端压缩解压,Kafka broker 也就无需考虑消息本身实际使用的压缩格式,这也符合上面说的二进制消息格式中,broker 不参与消息转换的设计思想。目前,Kafka 支持的压缩协议有 GZIP、Snappy、LZ4 以及 ZStandard。
发布者的低延迟设计主要是降低负载均衡的延迟。Kafka 采用了 producer 直连 broker 的设计,而不依赖其他任何中间的路由层,好处是直接高效,减少了一层就是去除了一个环节的回路,同时降低了系统的复杂度,无需额外考虑路由层的高可用问题。但是就要求所有 broker 节点都能够获知集群的节点分布以及每个分区的 leader 所在节点等信息,这些信息由 ZooKeeper 管理。
另外,消息投递分区由客户端也就是 producer 决定,既支持随机或者轮询等简单的均衡算法,也支持按 Key 哈希的分区算法等,这些在 producer 上完成。
消费者的低延迟,一方面是依赖前面讲的零拷贝技术的应用,另一方面是结合批量拉取消息,由于前面都有介绍,这里只是带过。
想要实现刚好一次的消息投递,需要分开从发布端和消费端来看。
在发布端,每个发布者都会获得 broker 授予的一个唯一的 ID,结合消息本身隐含的顺序的序号,可以方便 broker 识别重复投递的消息。其次,考虑到在一次事务型操作中可能会有多个消息同时发布到多个分区的需求,Kafka 也提供了类似事务的语义,具体大家可以搜索了解一下。
来到消费端,实现刚好一次的消息投递也相对简单。由于消息拉取起点由消费者控制,所以只需要思考消费者如何避免重复拉取就好了。在官方文档中,建议的方式是消费者将已消费的消息偏移量一同记录到消费消费处理结果的输出中,这样可以保证消费者(可能是原来的消费者重启了,也可能是消费者挂了后有其他消费者分担了此消费者原来的分区)在开始拉取之前确认最后消费进度。
高可用的设计主要涉及两个内容:复制和容灾选主。
复制上,Kafka 的每个分区都可以配置 0 个或多个副本数量,也就是每个分区对应 1 个或多个 broker 节点。follower 使用和消费者一致的批量拉取机制来同步 leader 节点的日志。
在节点活性方面,Kafka 认为如果一个节点满足以下两点,即可称为 In-Sync
节点:
在考虑 Leader 故障上,Kafka 放弃了大多数选举的分布式一致性方案,而是采用名为 ISR(In-Sync Replica)的方案。因为传统的大多数选举,为了容忍 n
次 leader 故障,必须部署 2n+1
个节点,对于需要存储大量数据的 Kafka 来说,这个成本显然过大。而采用 ISR 的方案,只需要 n+1
个节点,就可以做到容忍 n
次故障的情况,成本相比而言降低了接近一半。
在 ISR 的方案下,消息被成功提交的判断就是 In-Sync 集合中的所有节点返回确认成功。一个成功提交的消息可以保证不会丢失。
但是 ISR 的方案还需要考虑一种极端场景:如果所有 In-Sync 节点都故障了,怎样选取新的 Leader?有两种不同的取舍:
在 Kafka 默认选项中,使用了前面的方案,就是 Kafka 认为一般来说一致性更重要。
另外,Kafka 还会尽可能将所有分区的 Leader 均匀分散到不同的 broker 上。
熟悉 Kafka 的同学也都知道,尽管 Kafka 的 topic 会进一步分为多个分区(partition),分区也是备份的最小单元,但是单个分区的日志在磁盘上还会进一步分解为多个段,也就是多个独立的文件,逻辑上可以见下面这个官方文档的图:
好处是什么呢?当然是方便查找了,你想想,既然消息的日志是顺序存储的,那我结合二分查找算法,不就可以支持快速定位到指定的消息了吗?
作为消息队列,broker 都需要考虑一种功能:记录消息被消费的状态。一种经典的思路是标记每个消息的状态:已投递、已确认。但是这种方案有几个问题:
Kafka 的做法比较简单粗暴:限定每个分区一个消费者。这样一来,由于一个分区只能被一个消费者消费,而且消息顺序投递,这样就可以用一个简单的整数表示一个消费者组在一个分区上的消费进度,而不是记录每个消息的消费状态,这是一个极低的 O(1) 的常量空间开销。另外消息消费进度可以周期性更新,而且只需要更新 offset 信息,整体维护消息确认进度的成本显然更低。
最后,Kafka 由于保留了历史消息,配合前面说的分段存储和查找,所以 Kafka 可以方便地支持回退 offset 的场景,以便重放消息。
这里的日志压缩不同于前面提到的消息压缩,这里特指对日志进行合并重写,以只保留同个 key 的消息的最新版本。经过日志压缩后,保留下来的消息仍旧保持时序性不变,offset 也不变,但是整个分区内的消息的 offset 不再连续。
至于日志压缩的作用,应该类似 Redis 的 AOF 重写,更多是为了减小存储空间的占用吧。
本文以走马观花的方式介绍了 Kafka 官方对于 Kafka 设计思考以及诸多权衡,以便我自己能够快速理解 Kafka 中的很多设计的出发点,进而能够更好地理解 Kafka 的很多底层设计思路。此前我对于 Kafka 的认识仅限于它的分区设计以及 offset,特别是消费者组的设计等等,但是只是知其然,官方文档的设计思考内容帮我自己补全了对于 Kafka 知其所以然的认识。