Kafka 将事件保存为可回放的分区日志。理解它可以从两个独立问题开始:记录保存在哪里,读者读到了哪里。前者由 broker 的日志与复制机制负责,后者由消费者的位置与消费组负责。
本文以 Kafka 4.1 文档中的常规 topic、复制日志和 consumer group 为基准。具体客户端默认值与集群功能应按部署版本确认;share group 不属于本文的分区独占消费模型。
日志为什么适合多个独立读者
设订单服务依次产生三条事件:创建、付款、发货。库存服务关注库存,统计服务关注成交额,审计服务要保留完整历史。三者速度不同,也可能分别重启。
如果“某个读者读过”就删除事件,其他读者便无法独立推进。日志模型把保留策略和消费进度分开:事件继续留在日志中,每个读者维护自己的位置。消费者恢复时移动游标,而不必要求生产者重发整段历史。
offset 是分区内的位置标识,不是全局消息 ID,也不等于“剩余消息数”。日志压缩和事务控制记录等因素会让可见业务记录的 offset 不连续。
Topic、Partition、Replica 的分工
| 概念 | 职责 | 不应混淆的边界 |
| Topic | 给事件流命名并配置保留等策略 | 一个 topic 可以有多个独立有序分区 |
| Partition | 追加与排序的基本单位 | 分区之间没有天然全序 |
| Replica | 保存某分区的副本 | 增副本主要提高冗余,不等于增加写入分片 |
| Broker | 托管多个分区副本并服务请求 | 一台 broker 通常同时承担不同分区的 leader/follower |
| Controller | 管理集群元数据与领导权变化 | 控制面的元数据共识不等于每条业务记录都写进控制器日志 |
分区数量决定数据可分散到多少个写入与消费单元;副本数量决定同一份数据被保存多少份。两者都会消耗资源,但解决的问题不同。KRaft 控制面负责元数据管理,数据分区仍有自己的复制路径。参见 Kafka KRaft。
一条记录的写入路径
客户端批处理能摊薄网络与协议开销,但等待组批也会增加延迟。压缩通常作用于批次;更多分区可能把流量切成更小的批次。因此“分区越多越快”不成立,还要考虑热点、批次大小、文件句柄、复制连接和恢复成本。
对需要同一业务实体有序处理的流,常把 order_id 用作 key。保证依赖分区器、key 序列化和分区数量保持一致。扩分区改变映射后,同 key 的新旧记录可能落在不同分区,迁移时需要单独考虑顺序。
无 key 的记录如何分配由客户端实现决定,不能统一写成逐条 round-robin;Java 客户端的批处理与分区策略应查 生产者配置。同 key 也只确定排序所在的分区:多个独立生产者之间的业务先后关系需要应用自己建立。
复制、ISR 与确认条件
Leader 接受写入,follower 持续拉取。ISR 是被认为跟得上的副本集合,包含 leader;副本是否留在集合中与其复制进展及相关配置有关。
| 配置 | 生产者何时收到成功 | 需要理解的代价 |
acks=0 | 不等待 broker 确认 | 发送成功不证明 broker 已接收 |
acks=1 | leader 接受写入后 | leader 故障时尚未复制的数据可能丢失 |
acks=all | 满足当前 ISR 的复制确认要求 | 确认等待与副本可用性耦合 |
min.insync.replicas 与 acks=all 配合,为可接受写入设置最小 ISR 条件。它不是“配置为 2 就只等任意两个副本”。例如复制因子为 3、最小 ISR 为 2:ISR 剩一个副本时,系统宁可拒绝这类写入,也不把单副本成功伪装成足够冗余的成功。详细语义见 Kafka Replication。
还要区分“复制到多个节点”和“每条消息已在每块盘上执行 fsync”。确认策略不能脱离日志刷盘、故障域、供电与恢复策略单独解释为绝对持久。允许落后副本成为 leader 的恢复策略也可能以丢失已确认记录换取可用性。
三种位置:末尾、复制提交与事务可见
Kafka 的读写位置不是同一个数字。以下用“下一条的位置”为边界,并作概念性简化:
- LEO(Log End Offset):某副本日志末尾的下一位置。各副本可能不同。
- HW(High Watermark):复制提交边界,普通读取不能越过它读取未提交的复制尾部。
- LSO(Last Stable Offset):事务稳定边界,
read_committed 读取还要受它限制,并跳过中止事务。
这个区分能解释一种现象:磁盘日志已经增长,消费者却暂时看不到后面的记录。原因可能是复制进度,也可能是前面的未完成事务。只看“文件里有没有这条消息”无法判断它是否应对消费者可见。
同一个 HW,为什么两种隔离级别读到不同记录
沿用图中的位置:10、11 是普通记录,事务 T 从 12 开始,13 又是一条普通记录。HW=14 表示这些位置都已进入复制提交范围,但 T 仍未完成。read_uncommitted 能返回 10、11、12、13;read_committed 只能返回 10、11。13 虽然不属于 T,也不能越过前面的事务稳定边界。
下面将边界作为输入,模拟可见性过滤。事务状态 committed 或 aborted 表示完成标记也已达到稳定条件,不是生产者刚发出了提交请求。模型省略 broker 复制和事务协调过程。
records = [(10, None), (11, None), (12, "T"), (13, None)]
def visible(records, hw, transactions, committed_only):
first_open = [first for first, status in transactions.values()
if status == "open"]
lso = min([hw] + first_open)
boundary = lso if committed_only else hw
return [offset for offset, tx in records
if offset < boundary
and (not committed_only or tx is None
or transactions[tx][1] == "committed")]
assert visible(records, 14, {"T": (12, "open")}, False) == [10, 11, 12, 13]
assert visible(records, 14, {"T": (12, "open")}, True) == [10, 11]
assert visible(records, 15, {"T": (12, "committed")}, True) == [10, 11, 12, 13]
assert visible(records, 15, {"T": (12, "aborted")}, True) == [10, 11, 13]
assert visible(records, 11, {"T": (12, "open")}, True) == [10]
assert visible([], 0, {}, True) == []
后两个完成场景假设 offset 14 存放事务完成标记、HW 已到 15;标记不作为业务记录放入模型。T 中止时,12 被过滤,13 恢复可见,因而读取结果出现空洞。这种空洞不表示消费者丢了一条需要处理的记录。
这里的 LSO 统一采用排他上界:读取 offset 小于 LSO 的记录;未完成事务从 12 开始,边界就是 12。endOffsets() 在 read_committed 下返回 min(HW, 最早未完成事务起点),可查 KafkaConsumer 的事务读取与末尾位置定义。它也解释了为什么只看相对于 LSO 的 lag 为零,不能证明复制日志末尾没有被事务阻挡的积压。
记录已经复制,为什么仍读不到?
“请求提交事务”与“提交标记已经稳定”也要分开。下面比较三种独立历史;完成历史在 offset 14 有标记,但只有 HW 到达 15,事务才被视为稳定结束。所有可见性边界均为排他上界;中止后的空洞来自过滤,不应让消费者去等待一个永远不会交付的 offset 12。
正在呈现知识画面
记录已经复制,为什么仍读不到?read_committed 受排他的 LSO 限制。未完成事务会挡住后面的普通记录;中止后,事务记录被过滤,后续普通记录可恢复可见。
{"id":"kafka-transaction-visibility","title":"记录已经复制,为什么仍读不到?","summary":"read_committed 受排他的 LSO 限制。未完成事务会挡住后面的普通记录;中止后,事务记录被过滤,后续普通记录可恢复可见。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"open\" data-i18n=\"open\"></button><button data-mode=\"committed\" data-i18n=\"committed\"></button><button data-mode=\"aborted\" data-i18n=\"aborted\"></button></div><div class=\"control\"><label for=\"hw\" data-i18n=\"hw\"></label><output for=\"hw\" id=\"hw-value\"></output><input id=\"hw\" type=\"range\" min=\"10\" max=\"15\" value=\"14\" step=\"1\"></div><div class=\"metrics\"><div><span data-i18n=\"leo\"></span><strong id=\"leo\"></strong></div><div><span data-i18n=\"lso\"></span><strong id=\"lso\"></strong></div></div><div class=\"track-row\"><span data-i18n=\"uncommitted\"></span><div id=\"raw\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"visible\"></span><div id=\"visible\" class=\"track\"></div></div><div id=\"marker\" class=\"formula\"></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"waiting\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"done\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.track .cell{min-height:58px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let mode='open';function draw(){const leo=mode==='open'?14:15;let hw=Math.min(leo,+$('#hw').value);$('#hw').value=hw;set('hw-value',hw);const stable=mode!=='open'&&hw===15,lso=stable?hw:Math.min(hw,12);set('leo',leo);set('lso',lso);const rows=[10,11,12,13];for(const [id,ok]of [['raw',x=>x<hw],['visible',x=>x<lso&&!(x===12&&mode==='aborted'&&stable)]])cells(id,rows.map(x=>({text:x+(x===12?' · T':''),cls:ok(x)?'on':'empty'})));$('#visible').dataset.offsets=rows.filter(x=>x<lso&&!(x===12&&mode==='aborted'&&stable)).join(',');set('marker','14: '+(mode==='open'?'—':mode.toUpperCase())+'; HW='+hw);set('explanation',t(stable?'done':'waiting'));pressed(mode);}document.querySelectorAll('[data-mode]').forEach(e=>e.onclick=()=>{mode=e.dataset.mode;draw();});$('#hw').oninput=draw;draw();","audio":false,"strings":{"aborted":"追加中止标记","committed":"追加提交标记","done":"完成标记已稳定;提交保留 12,中止过滤 12,普通记录 13 可见。","heading":"记录已经复制,为什么仍读不到?","hw":"复制边界 HW","intro":"10、11 是普通记录,12 属于事务 T,13 又是普通记录。选择一段独立的事务历史,改变复制边界 HW。","leo":"LEO","lso":"LSO","mode":"对比独立历史","open":"尚未决定","reset":"重置","scope":"切换标签比较不同执行,不允许真实事务改变终态。单分区固定日志。open 时 LEO=14;完成标记追加在 14,必须 HW=15 才视为稳定完成。标记不作为业务数据返回;省略真实 broker 协调。","scopeLabel":"模型范围与约定","uncommitted":"read_uncommitted","visible":"read_committed","waiting":"完成标记还未稳定;LSO 不能越过事务起点 12。"}}
消费位置与已提交位置
一次 poll() 返回若干记录,客户端的读取位置随之推进,但业务处理未必完成。已提交 offset 是故障恢复时的进度承诺,两者应分开。
常规 consumer group 中一个分区在同一时刻分配给一个成员,不同 group 可以独立读取同一分区。客户端按 offset 读取也不自动保证异步工作线程按同样顺序完成。接管过程与并发提交规则见 消费组与协作。
Retention 与 Log Compaction
两种清理方式对应两种数据用途:
| 方式 | 关注什么 | 适合表达什么 |
| 按时间或大小删除旧日志段 | 历史保存多久 | 有期限的事件回放 |
| 按 key 压缩旧版本 | 每个 key 的最新状态 | 从变更日志重建状态 |
压缩在后台进行,不保证读取时每个 key 只出现一次,也不重排或重编号剩余记录。删除状态通常用 tombstone 表达,过了相应保留期后连 tombstone 也可能被清理;落后太久的读者可能无法靠剩余日志恢复出正确删除历史。Log Compaction 详细说明了这些保证。
因此,是否能回放取决于保留边界,是否能重建状态取决于压缩键与删除语义。事件审计与最新状态同步不能仅靠“都存在 Kafka 里”就视为相同需求。
删除标记消失后,空库重建与旧快照续接并不等价
设日志依次为 0:A=1、1:B=7、2:A=2、3:B=null。最后一条删除 B。下面模拟一次“所有旧版本都已清理”的结果,故意不模拟实际 cleaner 的异步调度:
log = [(0, "A", 1), (1, "B", 7), (2, "A", 2), (3, "B", None)]
def compact_snapshot(records, keep_tombstones):
latest = {}
for record in records:
latest[record[1]] = record
return sorted((record for record in latest.values()
if keep_tombstones or record[2] is not None),
key=lambda record: record[0])
def replay(initial, records):
state = dict(initial)
for _, key, value in records:
if value is None:
state.pop(key, None)
else:
state[key] = value
return state
with_delete = compact_snapshot(log, True)
without_delete = compact_snapshot(log, False)
assert with_delete == [(2, "A", 2), (3, "B", None)]
assert without_delete == [(2, "A", 2)] assert replay({}, log) == replay({}, without_delete) == {"A": 2}
stale_snapshot = {"A": 1, "B": 7}
assert replay(stale_snapshot, with_delete) == {"A": 2}
assert replay(stale_snapshot, without_delete) == {"A": 2, "B": 7}
旧快照里的 B 不会因为日志里“没有 B”就自动删除。消费者无法区分“B 从来没有变更”与“B 被删除,但删除证据已过期”。所以恢复协议必须把快照对应的 offset、增量日志可用范围与删除保留范围一起约定;不能只检查还能连接 broker。
这里空库重建能得到正确结果,是因为输入是固定、完整的压缩后集合。真实重建期间 cleaner 仍在运行:若读者先读到 B 的旧值,却没来得及读到随后被清理的 tombstone,同样可能保留错误状态。因此从空库开始还需要满足读取进度与删除保留条件,不能把模型等式当成无限时长恢复的保证。相关条件见 Log Compaction 的保证。
架构推演:订单流如何分工
一个合理的拆分是用订单 ID 分区,让同一订单的事件具有分区内顺序;库存组与统计组各自提交进度;审计需求决定历史保留期;复制与确认策略决定故障时的写入承诺。
若某个大客户产生一半流量,其 key 可能形成热点。增加消费者无法把一个独占分区自动拆开。改用更细的 key 能提高并行度,但必须重新定义需要保持顺序的业务范围。性能取舍因此来自业务关系,而不只是调大配置。
参考
关键词:Kafka、partition、replica、ISR、acks、offset、HW、LSO、retention、log compaction