读文有感:Kafka 官方设计文档
- 数据持久化:无惧文件系统 -
磁盘的读写速度,取决于如何读写。对于线性读写方式,操作系统做了充分的优化:提前读 - 预取若干数据块,滞后写 - 将小的逻辑写操作合并成一个大的物理写操作。
研究表明:顺序读写磁盘(sequential disk access)的速度有些时候比随机访问内存还要快。
现代操作系统激进地尽可能将空闲内存用作磁盘缓存。所有磁盘读写都经过操作系统提供的统一缓存。这个特性没法轻易关闭,除非直接 I/O (direct I/O),因此,如果程序在用户进程中进行数据缓存,缓存的数据通常也是和操作系统页缓存重复的,缓存两遍,没啥意义,也浪费内存。
而且,Kafka 是构建在 JVM 之上的,了解 Java 内存使用方式的人应该都知道:
对象的内存开销非常高,通常是实际数据大小的2倍(甚至更多)
随着堆上数据量增大,Java 的 GC 表现也会更糟糕
因此,使用文件系统并依赖于操作系统内存页缓存,优于在程序中维护一块内存缓存或其它结构。至少操作系统内存页缓存的可用内存翻倍了。另外,如果使用紧凑的字节结构来缓存数据,相比使用对象,可用内存可能还会翻倍。在 32GB 内存的机器上这么搞,缓存可用到 20-30GB,还不会对 GC 造成了什么坏影响。并且,即使服务重启,这块缓存空间也是热的(除非机器重启),用户进程内的内存缓存在服务重启后得重建(10GB的数据缓存可能需要10分钟左右)。
这样也可以简化代码逻辑,因为缓存和文件系统之间的一致性由操作系统来保证了。
这样一分析,设计就简单了:我们反其道而行之,所有数据都直接写到文件系统上持久化日志文件中,不需要在程序中使用内存缓存,也不必确保将数据刷到磁盘。这实际意味着数据转移到了内核的内存页缓存。
- 常亮时间就能搞定 -
B 树的 O(log N) 时间复杂度,对于磁盘操作来说,并不能等同于常量时间复杂度。
Kafka 采用日志文件方式,确保读写操作的时间复杂度是 O(1)。
Kafka 不会在消息一被消费就立即删除,而是保留一段时间,这样对于消费者来说也更灵活一些。
- 效率 -
对于 Kafka 这类系统而言,即使像前述那样消除了糟糕的磁盘访问模式,也会遇到两个导致数据效率低的问题:过多的小 I/O 操作,以及过多的字节拷贝。
小 I/O 问题在客户端与服务端之间,以及服务端内部的数据持久化操作中都会发生。对此,Kafka 协议建立在 “消息集” (即一批消息)的抽象之上,这样网络请求读写的是一批一批的消息,减少了网络往返的时间开销(注:消息处理的实时性会相对差一点)。服务端也是一次将一批消息写到日志文件中,消费者也按序一次获取一批消息。这一简单的优化可以将吞吐能力提升几个数量级。
对于过多的字节拷贝问题,在消息量大的时候,影响比较明显。Kafka 采用了一种标准化的二进制消息格式,producer、broker、consumer 都使用这种格式,这样数据块在传输期间不需要变动。
broker 维护的消息日志只是一个目录下的一堆文件,文件内容是按序写入的消息集,消息集的数据格式同于 producer、consumer 使用的。共用一种数据格式方便了一个重要的操作优化:持久化日志块的网络传输。对于从内存页缓存(pagecache)到网络套接字(socket)的数据传输操作,现代 UNIX 操作系统提供了一种高度优化的代码执行路径。Linux 中使用 sendfile 系统调用 可以利用这个优化。
要理解 sendfile 的收益,需要先理解从文件到套接字传输数据的常规代码执行路径:
操作系统从磁盘将数据读到内核空间的内存页缓存(pagecache)
应用程序从内核空间将数据读到用户空间缓冲区
应用程序将数据从用户空间缓冲区读到内核空间的套接字缓冲区
操作系统将数据从套接字缓冲区读到 NIC 缓冲区,网卡从 NIC 缓冲区读取数据通过网络发出去
这一代码执行路径,涉及 4 次数据拷贝和 2 次系统调用,很显然是低效的。使用 sendfile,可以避免内核空间和用户空间之间一些不必要的数据拷贝,操作系统可以直接将数据从内存页缓存发送到网络。
进一步了解 sendfile 以及 Java 平台如何支持零拷贝,可以阅读这篇文章(https://developer.ibm.com/articles/j-zerocopy/)。
- 生产者(The Producer) -
负载均衡
消息应该发到哪个分区(partition)由客户端根据哈希算法(或者随机)决定,并且消息是直接由 producer 发到目标分区的 leader broker,没有任何中间路由层。
所有 Kafka 节点都可以响应元数据请求 - 告知客户端(producer 或 consumer)哪些服务节点还存活以及某个 topic 的各个分区 leader 分别是哪个节点(疑惑:如果某个分区 leader 节点挂掉之后,客户端如何获知?何时可以获知?)
消息交付语义
producer 和 consumer 之间的消息交付语义,分 3 种:
最多消费一次 - 消息可能会丢失,但不会被重复消费 最少消费一次 - 消息不会丢,但可能被重复消费 仅消费一次 - 每个消息都会被消费且仅消费一次
acks
:acks=0:表示 producer 不需要等分区 leader broker 返回任何响应,将消息存入套接字缓冲区(socket buffer)就当做消息已经发送成功。所以可靠性是没有保证的。 acks=1:表示 分区 leader broker 将消息写入自己的本地日志文件,就向 producer 响应成功,不必等待分区副本 broker 同步好消息。 acks=-1 或 acks=all:表示 分区 leader broker 需要等待所有同步副本 broker 同步好消息并响应成功,才向 producer 响应成功
节点必须维持与 Zookeeper 的 session 连接(通过 Zookeeper 的心跳机制) 如果是一个从节点(follower),则必须不断从 leader 节点同步消息数据,且同步进度没有落后太多
如果 consumer 读取消息后,先向 kafka 提交消费位置,再处理消息;如果该 consumer 挂掉或重启,会可能导致丢消息,从而只能满足“最多处理一次”交付语义。 如果 consumer 读取消息后,是先处理,再提交消费位置;如果该 consumer 挂掉或重启,则可能导致重复消费消息,从而只能满足“最少处理一次”交付语义。
- 复制 -
replica.lag.time.max.ms
配置参数判定。- 可用性和持久性保证 -
acks=all
并不是要求所有的副本都确认写入成功,而是在当前同步中副本(ISR)都确认写入成功时,分区 leader 就向 producer 响应成功。例如:某个 topic 被设置为 2 个副本,然后其中一个副本节点挂掉,此时要求 acks=all
的写操作也会成功。如果剩下的副本节点也挂了,那么就会丢消息啦。禁用脏 leader 选举 指定一个最小 ISR 集大小( min.insync.replicas
参数设置):只有当 ISR 集大小大于设定的最小值,分区 [leader] 才会接受消息写入。这个设置只有当 producer 使用acks=all
时才会生效。(注:在我们生产环境中,分区副本数通常申请为 3(包含 leader),那么min.insync.replicas
应该设定为 2,但默认是 1。使用 1,那么当分区只有一个副本(即 leader),producer 也能写入成功,但如果这个副本又挂了,就会丢数据。)
- 副本管理 -
- 消费者消费进度跟踪 -
作者:xiayf
来源:
http://blog.xiayf.cn/2019/10/13/reading-kafka-design/