《吃透 MQ 系列》之 Kafka 存储选型的奥秘
大家好,我是武哥。这是《吃透 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 等等。
1. Kafka 的存储难点是什么?
为什么说存储设计是 Kafka 的精华所在?之前这篇文章做过分析,Kafka 通过简化消息模型,将自己退化成了一个海量消息的存储系统。
既然 Kafka 在其他功能特性上做了减法,必然会在存储上下功夫,做到其他 MQ 无法企及的性能表现。
图1:Kafka 的消息模型
但是在讲解 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 面对的是海量数据的存储问题,必然要考虑存储的扩展性。
再简单总结下,Kafka 的存储需求如下:
1、功能性需求:其实足够简单,追加写、无需update、能根据消费位移和时间戳查询消息、能定期删除过期的消息。
2、非功能性需求:是难点所在,因为 Kafka 本身就是一个高并发系统,必然会遇到典型的高性能、高可用和高扩展这三方面的挑战。
2. Kafka 的存储选型分析
有了上面的需求梳理,我们继续往下分析。
为什么 Kafka 最终会选用 logging(日志文件)来存储消息呢?而不是用我们最常见的关系型数据库或者 key-value 数据库呢?
2.1 存储领域的基础知识
先普及几点存储领域的基础知识,这是我们进一步分析的理论依据。
1、内存的存取速度快,但是容量小、价格昂贵,不适用于要长期保存的数据。
2、磁盘的存取速度相对较慢,但是廉价、而且可以持久化存储。
3、一次磁盘 IO 的耗时主要取决于:寻道时间和盘片旋转时间,提高磁盘 IO 性能最有效的方法就是:减少随机 IO,增加顺序 IO。
4、磁盘的 IO 速度其实不一定比内存慢,取决于我们如何使用它。
关于磁盘和内存的 IO 速度,有很多这方面的对比测试,结果表明:磁盘顺序写入速度可以达到几百兆/s,而随机写入速度只有几百KB/s,相差上千倍。此外,磁盘顺序 IO 访问甚至可以超过内存随机 IO 的性能。
图2:磁盘和内存的 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 的存储需求上进行思考。
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/
3. Kafka 的存储设计
了解了 Kafka 存储选型的来龙去脉后,最后我们再看下它具体的存储结构。
图4: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 的顺序写。
4. 写在最后
本文从需求分析、到选型对比、再到具体的存储方案,一步步拨开了 Kafka 选用 logging(日志文件)这一存储方案的奥秘。
也是希望大家能去主动思考 Kafka 在存储选型时的难点,把它当做一个系统设计题去思考,而不仅仅记住它用了日志存储。
另外一个观点:越底层越通用,你每次多往下研究深一点,会发现这些知识在很多优秀的开源系统里都是相通的。
公众号:铭毅天下Elasticsearch