消息里放指针,内容放别处
当一条业务消息大到几百 KB 甚至几 MB,第一反应通常是调大 message.max.bytes。11.1 节已经说明这条路的代价:所有 broker 和所有消费者都要为少数大消息付出内存、网络和落盘成本,而且上限一旦调大就很难再调回去。
claim-check 模式换了一个方向:消息本身只携带一张「取件凭证」,真正的内容写到对象存储,凭证里放 URI、校验和、长度和内容类型。 消费者拿到凭证后,自己去对象存储取内容。
消息体(233 字节)
{"claimUri":"file:///tmp/mq11/objstore/<sha256>.json",
"sha256":"<64 位十六进制>",
"size":307206,
"contentType":"application/json"}
对象存储(307,206 字节)
{"loanId":"L-000000","bookId":"book-0",...}
本机实测的这组数字:307,206 字节的 payload 对应 233 字节的凭证,消息体积缩小约 1,318 倍。收益不只是「能发出去」:主题的保留成本、副本复制流量、重放时的网络开销,全部按凭证大小计算;消费者取回时的内存峰值不变。
代价是链路从「一个系统一次写入」变成「两个系统两次写入」,一致性、失败处理、运维边界都要重新设计。这一节的重点不是「怎么把文件传上去」,而是取不回来的时候怎么办。
什么场景值得用
| 场景 | 是否适合 | 说明 |
|---|---|---|
| 单条事件超过 1 MB | 适合 | 调大 broker 上限的代价高于多一次对象存储调用 |
| 图片、PDF、音视频、模型文件 | 适合 | 本来就不该进日志系统 |
| 需要长期保留、反复重放的大快照 | 适合 | 对象存储的单价和生命周期管理更合适 |
| 小事件(几 KB) | 不适合 | 多一次网络调用和一套故障处理,收益为负 |
| 要求消息与内容原子可见 | 不适合 | 两个系统无法用一次事务提交,只能做补偿 |
| 消费者无法访问对象存储 | 不适合 | 网络与权限边界会直接卡死主链路 |
最后一条常被忽略:claim-check 把「消费者能访问 Kafka」变成了「消费者还能访问对象存储」。如果消费端在另一个网络域、或对象存储的鉴权体系与 Kafka 不同,这个前提要先验证,不能假设。
一致性的三个失败窗口
把「写对象」和「发消息」看成两步,顺序不能反,失败窗口有三个:
| 顺序 | 失败点 | 结果 | 处理 |
|---|---|---|---|
| 先写对象,再发消息 | 发消息失败 | 对象成为孤儿 | 生命周期规则按时间清理,或引用计数 |
| 先发消息,再写对象 | 写对象失败 | 凭证指向不存在的内容 | 不可接受,顺序必须反过来 |
| 写对象成功,发消息成功 | 消费前对象被删/改 | 取回失败 | 重试、校验和、兜底主题 |
第一条是可接受的浪费,第三条是必须设计的故障。第二条是设计错误:消息先落地、内容后写,中间崩溃就会留下一条永远取不到内容的凭证,而且没有任何重试能修复。
还有一个隐含前提:对象的保留期必须大于「主题保留期 + 最大消费延迟」。主题保留 7 天、对象也保留 7 天:第 1 天写入的消息,主题到第 8 天仍可读(消费滞后时),对象却已经过期,凭证还在、内容取不到。这不是理论风险,是生命周期配置最常见的错配。
最小实现:凭证、写入、取回
实验用一个本地目录模拟对象存储,路径 /tmp/mq11/objstore/,文件名就是内容的 SHA-256。内容寻址带来两个好处:同样的内容只存一份,天然去重;文件名本身就是校验和,不依赖额外的元数据表。
生产端先写对象、再发凭证:
String payload = payload(i, 300 * 1024);
byte[] bytes = payload.getBytes(StandardCharsets.UTF_8);
String sha = sha256(bytes);
Path file = STORE.resolve(sha + ".json");
Files.write(file, bytes); // 1. 内容先落地
String ticket = "{\"claimUri\":\"" + file.toUri() + "\",\"sha256\":\"" + sha
+ "\",\"size\":" + bytes.length + ",\"contentType\":\"application/json\"}";
producer.send(new ProducerRecord<>(TOPIC, "book-" + i, ticket)).get(); // 2. 再发凭证
生产端也要处理「写完对象、发消息失败」:重试发送;仍失败则记录孤儿对象,交给生命周期规则清理。不能因为「对象已经写了」就假装消息发出去了。
消费端的核心是三步:取回、校验、处理。任何一步失败都不能提交位移:
byte[] content = null;
Exception last = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
content = Files.readAllBytes(file);
verify(content, sha); // 长度非空 + SHA-256 必须一致
last = null;
break;
} catch (Exception e) {
last = e;
Thread.sleep(200); // 退避后重试
}
}
if (last == null) {
process(content);
} else {
ProducerRecord<String, String> dead = new ProducerRecord<>(MISSING, r.key(), r.value());
dead.headers().add(new RecordHeader("x-claim-error",
last.getClass().getName().getBytes(StandardCharsets.UTF_8)));
dead.headers().add(new RecordHeader("x-claim-error-message",
String.valueOf(last.getMessage()).getBytes(StandardCharsets.UTF_8)));
dead.headers().add(new RecordHeader("x-original-topic",
TOPIC.getBytes(StandardCharsets.UTF_8)));
dead.headers().add(new RecordHeader("x-original-offset",
String.valueOf(r.offset()).getBytes(StandardCharsets.UTF_8)));
producer.send(dead).get(); // 兜底先成功
}
consumer.commitSync(Map.of(
new TopicPartition(r.topic(), r.partition()),
new OffsetAndMetadata(r.offset() + 1))); // 兜底成功后才提交
顺序是这段代码里最重要的部分:先确认兜底写入成功,再提交原位移。 反过来写,兜底失败时消息已经提交,凭证永久丢失;而按这个顺序,最坏情况是重复处理,不是丢消息。
校验本身只有两行,但两行都不能省:
String actual = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(content));
if (!actual.equals(expectedSha))
throw new IllegalStateException("checksum mismatch expected=" + expectedSha
+ " actual=" + actual);
长度校验也值得加一条:空对象、被截断的对象、被替换成完全不同的内容,SHA-256 都能发现;但如果凭证里的 size 与内容长度不一致,说明凭证和对象已经错配,这时候连解析都不应该做。校验失败属于确定性失败,重试没有意义,直接进兜底。
实测:五条消息,两条取不回
本机实测(Temurin 25.0.5+8 / kafka-clients 4.3.0 / apache/kafka:4.3.0 单节点 KRaft / 2026-10-29):主题 library.loan.created.claim 与兜底主题 library.claim.missing 均 1 分区、RF=1,消费者 enable.auto.commit=false、max.poll.records=10。
生产 5 条,每条内容 307,206 字节,凭证 233 字节:
sent i=0 sha=d81916fba305... size=307206 topicOffset=0 ticketBytes=233
sent i=1 sha=2b08905a5bb8... size=307206 topicOffset=1 ticketBytes=233
sent i=2 sha=fa86383f1410... size=307206 topicOffset=2 ticketBytes=233
sent i=3 sha=5601d530d692... size=307206 topicOffset=3 ticketBytes=233
sent i=4 sha=73d93191f0c0... size=307206 topicOffset=4 ticketBytes=233
storeFiles=5
(以下输出中的哈希值做了截断,原文是 64 位十六进制。)注入两种故障:删除 i=2 的对象文件,把 i=4 的对象内容改成 {"tampered":true}。消费结果:
ok offset=0 key=book-0 loanId=L-000000 bytes=307206
ok offset=1 key=book-1 loanId=L-000001 bytes=307206
fetch-fail offset=2 attempt=1 exception=NoSuchFileException:/tmp/mq11/objstore/fa86383f1410...json
fetch-fail offset=2 attempt=2 exception=NoSuchFileException:/tmp/mq11/objstore/fa86383f1410...json
fetch-fail offset=2 attempt=3 exception=NoSuchFileException:/tmp/mq11/objstore/fa86383f1410...json
fallback offset=2 -> library.claim.missing reason=NoSuchFileException
ok offset=3 key=book-3 loanId=L-000003 bytes=307206
fetch-fail offset=4 attempt=1 exception=IllegalStateException:checksum mismatch expected=73d93191f0c0 actual=94c09080b629
fetch-fail offset=4 attempt=2 exception=IllegalStateException:checksum mismatch expected=73d93191f0c0 actual=94c09080b629
fetch-fail offset=4 attempt=3 exception=IllegalStateException:checksum mismatch expected=73d93191f0c0 actual=94c09080b629
fallback offset=4 -> library.claim.missing reason=IllegalStateException
handled ok=3 fallback=2
主链路的位移追平,没有被两条坏凭证拖住:
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
libraryd-claim-demo library.loan.created.claim 0 5 5 0
兜底主题里的记录仍然携带原始凭证和失败原因,人工可以据此定位是哪个对象、哪一类错误:
missing-record offset=0 key=book-2 uri=fa86383f1410...json error=java.nio.file.NoSuchFileException msg=/tmp/mq11/objstore/fa86383f1410...json
missing-record offset=1 key=book-4 uri=73d93191f0c0...json error=java.lang.IllegalStateException msg=checksum mismatch expected=73d93191f0c0 actual=94c09080b629
注意两条失败的性质不同:NoSuchFileException 可能是对象被提前清理、也可能是写入还没可见;校验和不符则意味着内容被改过或凭证写错了。前者有重试价值,后者重试多少次都不会变。本篇为了演示统一重试 3 次,生产代码应当先按异常类型分流。
重试要分类,兜底要有出口
取回失败至少有四类,处理方式不同:
| 失败类型 | 典型异常 | 可重试 | 去向 |
|---|---|---|---|
| 对象暂时不可见 | 404、连接超时、5xx | 是 | 指数退避重试 |
| 对象确定不存在 | 404 且超过宽限期 | 否 | 兜底主题 + 告警 |
| 内容损坏 | 校验和不符、解析失败 | 否 | 兜底主题 + 告警,禁止重试 |
| 权限/配置错误 | 403、桶不存在 | 否(改配置后) | 暂停分区,人工处理 |
重试次数要有上限,退避要有抖动。无限重试会把一个分区钉死,lag 涨到监控报警,而问题本身不会自愈。
兜底也不是终点。library.claim.missing 需要 owner、告警和回灌工具,和 6.1 节的死信队列是同一套治理要求。区别在于兜底主题里只有凭证,体积极小,保留 30 天的成本可以忽略;修复时按凭证重新上传对象,再把凭证回灌主主题即可。
还有一个容易踩的坑:兜底写入失败时不能提交位移。如果兜底主题也满了、权限也不对,消费者应该暂停分区并告警,而不是「先提交再说」。提交之后消息不会再来,凭证就真的没了。
对象怎么放:内容寻址与生命周期
本篇用 <sha256>.json 作为文件名,属于内容寻址。它的优点是天然去重、校验和即文件名、写入可以做成幂等(同一内容重复写覆盖同一路径,结果一致)。缺点是删除需要引用计数:两个凭证可能指向同一个对象,按凭证逐条删除会误删另一个仍在使用的对象。
| 布局方式 | 去重 | 删除 | 适用 |
|---|---|---|---|
内容寻址 <sha256> | 天然去重 | 需要引用计数或宽限期 | 内容重复度高、写多读多 |
凭证 ID 命名 <eventId>/<uuid> | 不去重 | 按凭证生命周期删除 | 一条消息一个对象、需要精确清理 |
日期分区 dt=2026-10-29/<sha> | 同一天内去重 | 按分区批量清理 | 日志型、按时间归档 |
如果选内容寻址又不做引用计数,清理只能按「最后修改时间 + 安全宽限期」做,宽限期必须覆盖最长消费延迟。否则会出现「对象被清掉、消息还在主题里」的经典事故。
容量账:省的是 Kafka,不是存储
claim-check 不会让数据消失,只是把字节从 Kafka 搬到对象存储。用一组明确标注为假设的数字算账:每天 100 万条事件、每条内容 307 KB、Kafka 副本数 3、保留 7 天。
| 项目 | 不分离 | claim-check |
|---|---|---|
| 每条进 Kafka 的字节 | 307,206 | 233 |
| Kafka 每日新增(单副本) | 约 307 GB | 约 233 MB |
| Kafka 总量(RF=3,7 天) | 约 6.4 TB | 约 4.9 GB |
| 对象存储每日新增 | 0 | 约 307 GB |
| 消费者单条内存峰值 | 约 307 KB | 约 307 KB(取回时) |
注:以上是按 1,000,000 × 307,206 字节粗算的构造场景,不是任何线上系统的数据。
结论是:Kafka 的复制、保留、重放成本大幅下降;对象存储的成本并没有消失,只是单价和生命周期策略不同。消费者取回时的内存峰值也不变,所以 claim-check 不会降低单条消息的取回内存;只有避免同时拉取多条大消息,消费端内存峰值才会下降。
和 outbox、事务的关系
claim-check 与 7.1 节的 outbox 解决的是不同问题,但会组合使用:
- outbox 保证「业务库变更」与「消息发送」同事务;
- claim-check 把消息体挪出日志,但对象写入无法加入数据库事务。
组合时的顺序是:先写对象 → 在业务事务里写业务表与 outbox 行(outbox 行存凭证)→ 事务提交 → 轮询发布凭证。对象写入失败就不写 outbox;outbox 发布失败可以重试,凭证已经存在,重试是安全的。反过来「先写 outbox、事务提交后再写对象」会留下一条指向不存在对象的 outbox 记录;事务已经结束,内容可能也不在内存里,通常无法补写。
Kafka 事务也不覆盖对象存储:sendOffsetsToTransaction 只能把位移和消息放进同一个 Kafka 事务,不能把 S3 的 PUT 包进去。任何声称「事务能保证消息和对象同时成功」的说法都是错的。
一致性与运维判据
| 判据 | 要求 | 不满足的后果 |
|---|---|---|
| 写入顺序 | 对象先写、消息后发 | 出现永远取不到内容的凭证 |
| 内容校验 | 消费前校验 SHA-256 与长度 | 静默使用损坏数据 |
| 保留期 | 对象保留 > 主题保留 + 最大 lag | 老消息消费时对象已过期 |
| 清理策略 | 按时间清理并留安全边界,或引用计数 | 误删仍被引用的对象 |
| 兜底可靠性 | 兜底成功后才提交 | 兜底失败时丢消息 |
| 权限 | 消费端的对象读权限独立于 Kafka ACL | 主链路整体不可用 |
| 可观测 | 取回失败率、兜底率、孤儿对象数 | 故障只能靠用户投诉发现 |
对象存储的可见性语义要单独确认。本篇用本地文件模拟,读写立即可见,不对真实对象存储的一致性做任何断言;上线前要用目标存储实测「写后立即读」的行为。
反模式清单
- 先发消息、后写对象。 中间崩溃会产生永久无效凭证,且无法通过重试修复。
- 不校验校验和。 对象被覆盖、传输截断、凭证写错,都会让消费者拿到错误内容却以为成功。
- 取回失败就直接提交位移。 等价于丢消息,只是丢得比较安静。
- 对象保留期等于主题保留期。 边界上的消息必然取不到内容。
- 把 claim-check 当压缩。 几 KB 的小消息走这条路,多一次网络调用和一套故障处理,纯负收益。
- 兜底主题没有 owner。 和死信队列一样,没有告警的兜底等于黑洞。
回到 libraryd
library.loan.created 的 note 字段超过 64 KiB 时,生产者把整条事件写入对象存储,消息里只留凭证。307 KB 的实测样本对应 233 字节凭证,主题的保留与复制成本按凭证计算,消费者按需取回。
两条验收线:一是正常路径,凭证能取回、校验通过、位移追平;二是故障路径,删掉一个对象、改坏一个对象,确认重试次数、兜底记录、位移提交顺序都符合预期。只测第一条,等于没测 claim-check 的可靠性。
阅读导航:上一节 11.2 压缩算法选择 | 下一节 12.1 消费者多线程模型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。