大家好,我是武哥。这是《吃透 MQ 系列》之 Kafka 的第 3 篇,错过前两篇文章的,建议再温习下:
扒开 Kafka 的神秘面纱
Kafka 架构设计的任督二脉
从这篇文章开始,我将从微观角度切入,深入分析 Kafka 的设计原理。本文要讲的是 Kafka 最具代表性的:存储设计。谈到 Kafka 的存储设计,了解不多的同学,可能会有这样的疑惑:为什么 Kafka 会采用 Logging(日志文件)这种很原始的方式来存储消息,而没考虑用数据库或者 KV 来做存储?而对 Kafka 有所了解的同学,应该能快速说出一些 知识点:比如 Append Only、Linear Scans、磁盘顺序写、页缓存、零拷贝、稀疏索引、二分查找等等。我计划写两篇文章,除了解释清楚上面的疑惑,同时还会给出一个脉络,帮助大家迅速切中 Kafka 存储设计的要点,然后将上面这些零散的知识点串联起来。此外,也希望大家在了解了 Kafka 的存储设计后,能对 Append Only Data Structures 这一经典的底层存储原理认识更加深刻,因为它驱动了业界太多极具影响力的存储系统走向成功,比如 HBase、Cassandra、RocksDB 等等。为什么说存储设计是 Kafka 的精华所在?之前这篇文章做过分析,Kafka 通过简化消息模型,将自己退化成了一个海量消息的存储系统。既然 Kafka 在其他功能特性上做了减法,必然会在存储上下功夫,做到其他 MQ 无法企及的性能表现。
但是在讲解 Kafka 的存储方案之前,我们有必要去尝试分析下:为什么 Kafka 会采用 Logging(日志文件)的存储方式?它的选型依据到底是什么?
这也是本系列希望做到的,思考力胜过记忆力,多问 why,而不是死记 what。
Kafka 的存储选型逻辑,我认为跟我们开发业务需求的思路类似,到底用 MySQL、Redis 还是其他存储方案?一定取决于具体的业务场景。
1、功能性需求:存的是什么数据?量级如何?需要存多久?CRUD 的场景都有哪些?
2、非功能性需求:性能和稳定性的要求是什么样的?是否要考虑扩展性?
再回到 Kafka 来看,它的功能性需求至少包括以下几点:
1、存的数据主要是消息流:消息可以是最简单的文本字符串,也可以是自定义的复杂格式。
但是对于 Broker 来说,它只需处理好消息的投递即可,无需关注消息内容本身。
2、数据量级非常大:因为 Kafka 作为 Linkedin 的孵化项目诞生,用作实时日志流处理(运营活动中的埋点、运维监控指标等),按 Linkedin 当初的业务规模来看,每天要处理的消息量预计在千亿级规模。
3、CRUD 场景足够简单:因为消息队列最核心的功能就是数据管道,它仅提供转储能力,因此 CRUD 操作确实很简单。
首先,消息等同于通知事件,都是追加写入的,根本无需考虑 update。其次,对于 Consumer 端来说,Broker 提供按 offset(消费位移)或者 timestamp(时间戳)查询消息的能力就行。再次,长时间未消费的消息(比如 7 天前的),Broker 做好定期删除即可。
接着,我们再来看看非功能性需求:
1、性能要求:之前的文章交代过,Linkedin 最初尝试过用 ActiveMQ 来解决数据传输问题,但是性能无法满足要求,然后才决定自研 Kafka。ActiveMQ 的单机吞吐量大约是万级 TPS,Kafka 显然要比 ActiveMQ 的性能高一个量级才行。
2、稳定性要求:消息的持久化(确保机器重启后历史数据不丢失)、单台 Broker 宕机后如何快速故障转移继续对外提供服务,这两个能力也是 Kafka 必须要考虑的。
3、扩展性要求:Kafka 面对的是海量数据的存储问题,必然要考虑存储的扩展性。
1、功能性需求:其实足够简单,追加写、无需update、能根据消费位移和时间戳查询消息、能定期删除过期的消息。
2、非功能性需求:是难点所在,因为 Kafka 本身就是一个高并发系统,必然会遇到典型的高性能、高可用和高扩展这三方面的挑战。
为什么 Kafka 最终会选用 logging(日志文件)来存储消息呢?而不是用我们最常见的关系型数据库或者 key-value 数据库呢?先普及几点存储领域的基础知识,这是我们进一步分析的理论依据。1、内存的存取速度快,但是容量小、价格昂贵,不适用于要长期保存的数据。
2、磁盘的存取速度相对较慢,但是廉价、而且可以持久化存储。
3、一次磁盘 IO 的耗时主要取决于:寻道时间和盘片旋转时间,提高磁盘 IO 性能最有效的方法就是:减少随机 IO,增加顺序 IO。
4、磁盘的 IO 速度其实不一定比内存慢,取决于我们如何使用它。
关于磁盘和内存的 IO 速度,有很多这方面的对比测试,结果表明:磁盘顺序写入速度可以达到几百兆/s,而随机写入速度只有几百KB/s,相差上千倍。此外,磁盘顺序 IO 访问甚至可以超过内存随机 IO 的性能。1、加快读:通过索引( B+ 树、二份查找树等方式),提高查询速度,但是写入数据时要维护索引,因此会降低写入效率。
2、加快写:纯日志型,数据以 append 追加的方式顺序写入,不加索引,使得写入速度非常高(理论上可接近磁盘的写入速度),但是缺乏索引支持,因此查询性能低。
基于这两个极端,又衍生出来了 3 类最具代表性的底层索引结构:1、哈希索引:通过哈希函数将 key 映射成数据的存储地址,适用于等值查询等简单场景,对于比较查询、范围查询等复杂场景无能为力。
2、B/B+ Tree 索引:最常见的索引类型,重点考虑的是读性能,它是很多传统关系型数据库,比如 MySQL、Oracle 的底层结构。
3、 LSM Tree 索引:数据以 Append 方式追加写入日志文件,优化了写但是又没显著降低读性能,众多 NoSQL 存储系统比如 BigTable,HBase,Cassandra,RocksDB 的底层结构。
2.2 Kafka 的存储选型考虑
有了上面这些理论基础,我们继续回到 Kafka 的存储需求上进行思考。
1、写入操作:并发非常高,百万级 TPS,但都是顺序写入,无需考虑更新
2、查询操作:需求简单,能按照 offset 或者 timestamp 查询消息即可
如果单纯满足 Kafka 百万级 TPS 的写入操作需求,采用 Append 追加写日志文件的方式显然是最理想的,前面讲过磁盘顺序写的性能完全是可以满足要求的。剩下的就是如何解决高效查询的问题。如果采用 B Tree 类的索引结构来实现,每次数据写入时都需要维护索引(属于随机 IO 操作),而且还会引来“页分裂”等比较耗时的操作。而这些代价对于仅需要实现简单查询要求的 Kafka 来说,显得非常重。所以,B Tree 类的索引并不适用于 Kafka。相反,哈希索引看起来却非常合适。为了加快读操作,如果只需要在内存中维护一个「从 offset 到日志文件偏移量」的映射关系即可,每次根据 offset 查找消息时,从哈希表中得到偏移量,再去读文件即可。(根据 timestamp 查消息也可以采用同样的思路)
但是哈希索引常驻内存,显然没法处理数据量很大的情况,Kafka 每秒可能会有高达几百万的消息写入,一定会将内存撑爆。可我们发现消息的 offset 完全可以设计成有序的(实际上是一个单调递增 long 类型的字段),这样消息在日志文件中本身就是有序存放的了,我们便没必要为每个消息建 hash 索引了,完全可以将消息划分成若干个 block,只索引每个 block 第一条消息的 offset 即可,先根据大小关系找到 block,然后在 block 中顺序搜索,这便是 Kafka “稀疏索引” 的来源。图3:Kafka 的稀疏索引示意图
最终我们发现:Append 追加写日志 + 稀疏的哈希索引,形成了 Kafka 最终的存储方案。而这不就是 LSM Tree 的设计思想吗?
也许会有人会反驳 Kafka 的方案跟 LSM Tree 不一样,并没有用到树型索引以及 Memtable 这一层。但我个人认为,从「设计思想」从这个角度来看,完全可以将 Kafka 视为 LSM Tree 的极端应用。
此外,关于 Append Only Data Structures 和 LSM Tree,推荐 Ben Stopford (Kafka 母公司的一位技术专家) 于 2017 年 QCon 上做的一个视频分享,演讲非常精彩,值得一看。https://www.infoq.com/presentations/lsm-append-data-structures/
了解了 Kafka 存储选型的来龙去脉后,最后我们再看下它具体的存储结构。可以看到,Kafka 是一个「分区 + 分段 + 索引」的三层结构:1、每个 Topic 被分成多个 Partition,Partition 从物理上可以理解成一个文件夹。之前的文章解释过:Partition 主要是为了解决 Kafka 存储上的水平扩展问题,如果一个 Topic 的所有消息都只存在一个 Broker,这个 Broker 必然会成为瓶颈。因此,将 Topic 内的数据分成多个 Partition,然后分布到整个集群是很自然的设计方式。
2、每个 Partition 又被分成了多个 Segment,Segment 从物理上可以理解成一个「数据文件 + 索引文件」,这两者是一一对应的。一定有读者会有疑问:有了 Partition 之后,为什么还需要 Segment?如果不引入 Segment,一个 Partition 只对应一个文件,那这个文件会一直增大,势必造成单个 Partition 文件过大,查找和维护不方便。此外,在做历史消息删除时,必然需要将文件前面的内容删除,不符合 Kafka 顺序写的思路。而在引入 Segment 后,则只需将旧的 Segment 文件删除即可,保证了每个 Segment 的顺序写。本文从需求分析、到选型对比、再到具体的存储方案,一步步拨开了 Kafka 选用 logging(日志文件)这一存储方案的奥秘。也是希望大家能去主动思考 Kafka 在存储选型时的难点,把它当做一个系统设计题去思考,而不仅仅记住它用了日志存储。另外一个观点:越底层越通用,你每次多往下研究深一点,会发现这些知识在很多优秀的开源系统里都是相通的。下篇文章我将结合 Kafka 的源码,分析它在存储数据时的各个性能优化手段,我们下期见!
大家在看:
编程高手是如何练成的?
吃透 MQ 系列 | 核心基础篇
---------- END ----------
大家好,我是武哥,前亚马逊工程师,现知名独角兽技术总监,持续分享个人的成长收获,关注我一定能提升你的视野,让我们一起进阶吧!