Kafka 不是一个消息队列。更准确的说法是:Kafka 是一个分布式追加日志(append-only log)系统,消息队列的语义只是日志操作的一种投影。

本文只抓一个问题:追加日志这个数据结构,如何成为 Kafka 全部机制的起点。

追加日志:一种最朴素的数据结构

追加日志的定义可以压成三条规则:

  1. 写入只能追加到尾部(append)。
  2. 每条记录获得一个单调递增的序号(offset)。
  3. 已写入的记录不可修改(immutable)。

用 ASCII 表示一根日志的状态:

1
2
3
4
5
6
offset:  0    1    2    3    4    5    6
+----+----+----+----+----+----+----+
| m0 | m1 | m2 | m3 | m4 | m5 | | ← 写入点
+----+----+----+----+----+----+----+
^
tail (next offset = 6)

这个结构有三个直接推论:

  • 写入是顺序 I/O。磁盘的顺序写入速度接近内存随机写入速度,这是 Kafka 高吞吐的物理基础。
  • 读取是按 offset 寻址。给定一个 offset,定位到对应记录的时间复杂度是 O(log n)(通过稀疏索引二分查找)。
  • 记录不可变意味着不需要加锁。多个读者可以各自维护自己的 offset,互不干扰。

消息队列语义是日志操作的投影

传统消息队列有两种基本模型:点对点(point-to-point)和发布-订阅(pub/sub)。这两种模型在日志上的映射关系如下:

1
2
3
4
5
6
7
传统 MQ 操作          日志操作
─────────────────────────────────────────
send(message)append(record) to tail
receive()read(offset), then advance offset
acknowledge()commit(offset)
replay / rewind → seek to earlier offset
subscribe(topic) → create a new offset cursor on the log

点对点模型要求一条消息只被一个消费者处理。在日志上,这等价于一组消费者共享一个 offset 游标——任意时刻只有一个消费者持有某个 offset 的读取权。Kafka 用 consumer group 内的 partition 分配实现这个语义。

发布-订阅模型要求一条消息被所有订阅者处理。在日志上,这等价于每个订阅者维护自己独立的 offset 游标——同一条记录被多个游标扫过。Kafka 用不同 consumer group 实现这个语义。

关键区别在于:传统消息队列在 acknowledge 之后通常删除消息,而 Kafka 的日志保留消息,删除由独立的保留策略(retention policy)控制。消息不因被消费而消失,这使得重放(replay)成为一等操作。

"日志"不是 Kafka 的发明

追加日志作为系统设计的核心抽象,远早于 Kafka 出现。Jay Kreps 在 2013 年的文章 The Log: What every software engineer should know about real-time data’s unifying abstraction 中系统阐述了这个观点:日志是分布式系统中数据一致性的基础构件。

以下工程系统都以追加日志为核心数据结构:

1
2
3
4
5
6
7
8
系统                  日志名称              用途
────────────────────────────────────────────────────────
关系数据库 WAL (Write-Ahead Log) 崩溃恢复:先写日志,再写数据页
MySQL binlog 主从复制:从库重放主库的 binlog
InnoDB redo log 事务持久性:保证已提交事务在崩溃后可恢复
Kafka commit log 消息存储:每个 partition 就是一根日志
RocketMQ CommitLog 消息存储:所有 topic 共享一根物理日志
Event Sourcing event log 状态重建:当前状态 = 初始状态 + 所有事件的顺序回放

这些系统的共同模式是:状态可以从日志中重建。数据库的当前数据页状态等于 checkpoint 加上 checkpoint 之后所有 WAL 记录的回放;Kafka consumer 的当前消费位置等于初始 offset 加上所有 commit 操作的累积。

实验:用 offset 观察日志的可重复读取

以下实验用 kafka-console-producer 和 kafka-console-consumer 演示日志最核心的性质——同一个 offset 的数据可以反复读取。

启动一个单节点 Kafka(使用 KRaft 模式,无需 ZooKeeper):

1
2
3
4
5
6
# 生成集群 ID 并格式化存储目录
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format -t $KAFKA_CLUSTER_ID -c config/kraft/server.properties

# 启动 broker
bin/kafka-server-start.sh config/kraft/server.properties

创建 topic 并写入数据:

1
2
3
4
5
6
7
# 创建单 partition topic
bin/kafka-topics.sh --create --topic log-demo --partitions 1 \
--replication-factor 1 --bootstrap-server localhost:9092

# 写入 5 条消息
echo -e "alpha\nbeta\ngamma\ndelta\nepsilon" | \
bin/kafka-console-producer.sh --topic log-demo --bootstrap-server localhost:9092

第一次从头读取:

1
2
3
bin/kafka-console-consumer.sh --topic log-demo \
--from-beginning --bootstrap-server localhost:9092 \
--max-messages 5

输出:

1
2
3
4
5
alpha
beta
gamma
delta
epsilon

第二次从头读取——数据完全相同:

1
2
3
bin/kafka-console-consumer.sh --topic log-demo \
--from-beginning --bootstrap-server localhost:9092 \
--max-messages 5

输出不变:

1
2
3
4
5
alpha
beta
gamma
delta
epsilon

这个实验验证了两个事实:

  1. 消费不删除数据。两次 --from-beginning 读到的内容一致。
  2. offset 是稳定的。alpha 永远在 offset 0,epsilon 永远在 offset 4。

在传统消息队列中,第一次 receive + acknowledge 之后,第二次 receive 将拿不到相同的消息。Kafka 的日志语义从根本上改变了这个行为。

模式提炼

追加日志模式(Append-Only Log Pattern)的核心特征:

  • 写入路径只有 append,没有 update 和 delete。这使得写入路径极度简单,不需要处理并发修改冲突。
  • 每条记录有全局唯一的位置标识(offset、LSN、binlog position)。位置标识是单调递增的,因此可以用来定义 happens-before 关系。
  • 当前状态可以从日志中重建。这意味着日志是 source of truth,而当前状态是日志的派生视图。

这个模式的工程价值在于把"写"和"读"解耦:写入者只关心追加,读取者各自维护自己的游标。系统的复杂度从"如何协调多个读写者的并发访问"降低为"如何管理多个游标"。

工程迁移表

维度 Kafka commit log 数据库 WAL RocketMQ CommitLog
写入方式 追加到 partition 尾部 追加到 WAL 文件尾部 追加到全局 CommitLog 尾部
位置标识 offset(64-bit 整数) LSN(Log Sequence Number) commitlog offset(物理偏移量)
是否可变 不可变 不可变 不可变
删除策略 基于时间或大小的 retention checkpoint 之后截断 基于时间的定期删除
多读者支持 每个 consumer group 独立 offset 从库各自维护回放位置 每个 consumer group 独立 offset
读取方式 按 offset 寻址 按 LSN 顺序回放 通过 ConsumeQueue 索引间接寻址

常见误解

误解一:“Kafka 就是一个消息队列。”

Kafka 的 API 和生态确实支持消息队列的使用模式,但其内部数据结构是追加日志而非队列。队列的语义是 FIFO + dequeue(取出后删除),日志的语义是 append + seek(按位置读取,不删除)。这个区别决定了 Kafka 支持消息重放、多 consumer group 独立消费、以及基于 offset 的精确定位——这些在传统队列模型中要么不支持,要么需要额外机制。

误解二:“消费过的消息会被删除。”

Kafka 的消息删除与消费行为无关。消息的生命周期由 retention policy 控制:log.retention.hours 控制基于时间的保留,log.retention.bytes 控制基于大小的保留。一条消息即使被所有 consumer group 消费过,只要没有超过保留期限,仍然存在于日志中,可以被新的 consumer group 从头读取。

误解三:“Kafka 的高吞吐来自内存缓存。”

Kafka broker 不维护应用层的消息缓存。高吞吐的来源是三个因素的叠加:顺序磁盘 I/O(追加写入)、操作系统 page cache(由 OS 管理,对 Kafka 透明)、以及零拷贝传输(sendfile 系统调用,避免数据在内核态和用户态之间复制)。这些机制将在第 02 篇详细展开。

练习

  1. 使用 kafka-console-consumer 的 --from-beginning--offset 参数,分别从 offset 0 和 offset 3 开始读取上面实验中的 log-demo topic。观察两次读取的输出差异,理解 offset 寻址的含义。

  2. 写入更多消息到 log-demo,然后观察 Kafka 数据目录下 log-demo-0/ 中的文件。找到 .log 文件和 .index 文件,思考它们与追加日志模型的对应关系(详细解读在第 02 篇)。

  3. 查阅 MySQL binlog 的文档,找到 binlog 中与 Kafka offset 对应的位置标识(提示:binlog position 或 GTID)。思考两者在主从复制 / 消费位点管理中的相似角色。

系列导航

序号 主题
00 导读:为什么 Kafka 的核心是一根日志
01 架构总览:Broker、Controller 与元数据管理
02 日志存储:Segment、Index 与零拷贝
03 Producer 内部机制:攒批、分区与 acks
04 幂等 Producer 与序列号:消息不重不丢的第一层
05 Consumer Group 协议:分配、重平衡与静态成员
06 Offset 管理:提交、重置与消费语义
07 副本与 ISR:高可用的代价和折中
08 Controller 与 KRaft:从 ZooKeeper 到内置共识
09 Exactly-Once 与事务:跨 partition 的原子写入
10 日志压缩:把 topic 当 KV 表用
11 Kafka Streams:在日志之上构建流处理
12 Kafka Connect:标准化的数据管道
13 Schema Registry 与数据治理:给消息加上契约
14 性能模型:吞吐、延迟与调优思路
15 安全体系:认证、授权与加密
16 生产运维:集群扩缩、监控指标与故障排查
17 Kafka 与 RocketMQ:两种消息系统的设计选择

参考资料

  1. Jay Kreps. The Log: What every software engineer should know about real-time data’s unifying abstraction. LinkedIn Engineering, 2013. https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying
  2. Apache Kafka Documentation — Design: Persistence. https://kafka.apache.org/documentation/#design_persistence
  3. Jay Kreps. I Heart Logs: Event Data, Stream Processing, and Data Integration. O’Reilly, 2014.
  4. Apache Kafka Documentation — Quick Start. https://kafka.apache.org/documentation/#quickstart
  5. Martin Kleppmann. Designing Data-Intensive Applications. O’Reilly, 2017. Chapter 11: Stream Processing.