《Java 消息中间件·事件驱动架构》7.2 状态存储

Kafka Streams 的状态存储是本地 RocksDB 实例,与 changelog topic 配合实现故障恢复。本节通过编译运行带状态存储的拓扑,观察 state.dir 目录下的文件结构与大小,对比 KeyValueStore、WindowStore 和 SessionStore 三种类型,并给出状态丢失的重建策略与监控指标。

状态存储不是远程数据库

Kafka Streams 的状态存储位于本地磁盘,每个实例维护自己的 RocksDB 文件集合。这意味着状态访问是本地文件系统级别的读取,延迟在微秒到毫秒级,而不是网络级别的毫秒到秒级。

但也意味着三个限制:状态不能跨实例共享(除非使用 Interactive Queries)、磁盘损坏会导致状态丢失(需要 changelog topic 恢复)、状态大小受限于本地磁盘容量。

RocksDB 作为默认存储

Kafka Streams 使用 RocksDB 作为默认的状态存储后端。RocksDB 是一个嵌入式键值存储,基于 LSM-Tree(Log-Structured Merge Tree),优化了写吞吐量和有序的键迭代。

KTable<String, Long> counts = loans
    .groupByKey()
    .count(Materialized.as("loan-counts-store"));

这段代码会在本地创建以下文件结构:

/tmp/kafka-streams/libraryd-streams-app/
├── 0_0/
│   ├── rocksdb/
│   │   ├── loan-counts-store/
│   │   │   ├── 000003.log
│   │   │   ├── CURRENT
│   │   │   ├── IDENTITY
│   │   │   ├── LOCK
│   │   │   ├── MANIFEST-000001
│   │   │   └── OPTIONS-000005
│   │   └── ...
│   └── .checkpoint
├── 0_1/
│   └── rocksdb/
│       └── loan-counts-store/
└── ...

每个子目录 0_N/ 对应一个 task,由 topic 的分区决定。如果一个 topic 有 3 个分区,Streams 应用会创建 3 个 task,每个 task 维护自己的 RocksDB 实例。

状态存储的创建实验

以下代码运行一个带 count 聚合的拓扑,观察 state.dir 目录:

import org.apache.kafka.streams.*;
import org.apache.kafka.streams.kstream.*;

public class StateStoreProbe {
    public static void main(String[] args) {
        StreamsBuilder builder = new StreamsBuilder();
        builder.stream("libraryd.loan.events")
               .groupByKey()
               .count(Materialized.as("loan-counts"));
        
        Topology topology = builder.build();
        System.out.println(topology.describe());
        System.out.println("State store created: loan-counts");
    }
}

编译运行后,检查 state 目录:

find /tmp/kafka-streams -name "loan-counts" -type d 2>/dev/null | head -5

# 输出示例:
# /tmp/kafka-streams/TopologyProbe-.../0_0/rocksdb/loan-counts
# /tmp/kafka-streams/TopologyProbe-.../0_1/rocksdb/loan-counts

每个分区对应一个独立的 RocksDB 目录,互不共享。这是 Streams 的并行基础:分区数 = task 数 = RocksDB 实例数。

三种状态存储类型

类型适用场景键结构清理策略
KeyValueStore键值查找、聚合计数key无自动清理,需手动删除
WindowStore时间窗口聚合(key, windowStartTime)retention.ms 后清理
SessionStore会话窗口(key, sessionId)session.timeout 后清理

Materialized.as("store-name") 默认创建 KeyValueStore。要创建 WindowStore,需要使用 .windowedBy() 并在窗口操作中隐式创建。SessionStore 通过 SessionWindows.ofInactivityGap() 创建。

Changelog topic:状态的备份与恢复

当状态存储创建时,Streams 自动在 Kafka 中创建对应的 changelog topic:

{application.id}-loan-counts-changelog

Changelog topic 的写入时机:每次状态更新时,RocksDB 写入新值后同步或异步写入 changelog topic。故障恢复时,实例从 changelog topic 读取历史数据,重建状态存储到最新状态。

Changelog topic 的清理策略:默认 cleanup.policy=compact,只保留每个 key 的最新值,日志大小可控。默认 retention.ms 为 Long.MAX_VALUE,几乎不清理。如果 topic 被误删或损坏,状态存储无法恢复,必须从源头重放所有事件。

state.dir 与多实例

state.dir 是每个实例的本地目录,不同实例不能共享。如果两个实例配置相同的 state.dir,启动会报 LockException,因为 RocksDB 的 LOCK 文件被第一个实例占用。

多实例部署时,每个实例应配置独立的 state.dir:

props.put(StreamsConfig.STATE_DIR_CONFIG, "/var/lib/kstreams/instance-1");

状态存储的清理与 TTL

RocksDB 本身不提供 TTL 功能,Streams 通过 changelog topic 的 retention.ms 控制数据保留时间。当 changelog 数据过期后,如果状态存储也损坏,可以从源头重放重建。

对于 WindowStore 和 SessionStore,过期窗口的数据会自动清理。清理策略由 window.retention.ms 或 session.retention.ms 控制。

状态存储的大小估算

假设每个 key-value 对占用 100 字节(key 20 + value 50 + 索引开销 30),100 万条记录的存储大小约为 100 MB。如果状态存储增长过快,需要检查:

  1. key 的基数是否过高:如用 UUID 作为 key,无法有效压缩。
  2. value 是否过大:如把整个 JSON 对象存入 value。
  3. changelog topic 的 compact 是否生效:如果 compact 失败,日志会无限增长。

状态丢失的重建策略

当状态存储损坏或丢失时,有三种重建方式:

  1. 从 changelog topic 恢复:自动行为,Streams 启动时自动从 changelog 读取数据重建状态。
  2. 从源头事件重放:删除 state.dir 和 changelog topic,从位移 0 开始重新消费所有事件。
  3. 从快照恢复:如果定期导出了状态存储的快照,从快照开始,只重放增量事件。

方式 1 是最常用的,但前提是 changelog topic 完好无损。方式 2 最慢,但最彻底。方式 3 需要额外维护快照机制。

状态存储的监控指标

指标JMX 路径含义
put-latency-avgstream-state-metrics状态存储写入延迟
get-latency-avgstream-state-metrics状态存储读取延迟
flush-latency-avgstream-state-metrics状态存储 flush 延迟
state-dir-size文件系统监控状态目录总大小

生产环境应配置 file system 监控,当 state.dir 大小超过阈值时告警。告警阈值通常设为磁盘容量的 80%。

状态存储性能调优

RocksDB 的性能可以通过以下参数调优:

  1. block.cache.size:增大 block cache 减少磁盘 I/O。
  2. write.buffer.size:增大写缓冲区,减少 flush 频率。
  3. compaction.style:选择 LEVEL 或 UNIVERSAL 压缩策略。

调优参数通过 StreamsConfig 的 rocksdb.config.setter 传入:

props.put("rocksdb.config.setter", "com.example.CustomRocksDbConfig");

多实例状态迁移

当增加 Streams 应用实例时,Kafka 会触发分区再平衡。新实例被分配的分区需要从 changelog topic 恢复状态。这个过程称为状态迁移。

状态迁移的时间取决于:

  1. changelog topic 的大小:越大恢复时间越长。
  2. 网络带宽:从 Kafka 读取数据的速度。
  3. 磁盘 I/O:写入 RocksDB 的速度。

状态迁移期间,新实例不能处理该分区的数据,会导致短暂的消费停滞。可以通过增大 max.warmup.replicas 来预热副本,减少迁移时间。

本节小结

  • Kafka Streams 的状态存储是本地 RocksDB 实例,访问延迟在微秒级。
  • 每个 task 维护独立的 RocksDB 目录,对应 topic 的一个分区。
  • 三种状态存储类型:KeyValueStore、WindowStore、SessionStore,各有适用场景和清理策略。
  • Changelog topic 自动创建,用于故障恢复和状态迁移。
  • 不同实例不能共享同一个 state.dir,部署时需配置独立目录。
  • 状态存储大小可通过 key 基数和 value 大小估算,过大时需检查 compact 策略。
  • 状态丢失时有三种重建策略:changelog 恢复、源头重放、快照恢复。
  • 性能调优通过 RocksDB 参数实现,状态迁移时间取决于 changelog 大小和网络带宽。

状态存储的缓存策略

RocksDB 使用多层缓存来优化读取性能:

  1. Block Cache:缓存频繁访问的 SSTable 块。默认大小为 8 MB,可通过配置增大。
  2. Index/Filter Block Cache:缓存索引和过滤器,减少磁盘查找。
  3. MemTable:内存中的写缓冲区,写入先进入 MemTable,满后转储为 SSTable。

缓存命中率直接影响状态存储的读取延迟。在监控中,应关注 block.cache.hit.rate 指标,命中率低于 80% 时考虑增大 block cache。

状态存储的 WAL 与持久化

RocksDB 使用预写日志(WAL)保证数据持久化。每次写入先写 WAL,再写 MemTable。WAL 保证了即使进程崩溃,MemTable 中的未刷盘数据也不会丢失。

Streams 的 commit.interval.ms 控制两次 checkpoint 之间的间隔。默认 30 秒,意味着最多丢失 30 秒的状态更新。如果需要更强的持久性保证,可减小 commit.interval.ms,但会增加 I/O 开销。

状态存储的并发访问

Kafka Streams 保证:同一分区的数据只会被一个线程处理。因此,同一 task 内的状态存储访问是单线程的,不需要额外的锁机制。

但 Interactive Queries(IQ)允许从其他实例或外部客户端读取状态存储。IQ 的读取是只读的,不会修改状态,因此与本地写入线程不冲突。但如果需要写操作,必须通过 Kafka Streams 的 processor 进行,不能直接写入 RocksDB。

状态存储与 exactly-once 语义

Kafka Streams 支持 exactly-once 语义(EOS),通过事务保证「消费 → 处理 → 生产」的原子性。EOS 下,状态存储的更新和 offset 提交在同一个事务中:

  1. 开始事务。
  2. 消费一批消息。
  3. 更新状态存储。
  4. 生产输出消息。
  5. 提交 offset(作为事务的一部分)。
  6. 提交事务。

如果步骤 3-5 中任何一步失败,整个事务回滚,状态存储不会留下半完成的数据。但这需要 processing.guarantee=exactly_once_v2,且会增加延迟(事务协调开销)。

阅读导航:上一节 7.1 Streams 的拓扑与处理器 | 下一节 7.3 窗口与聚合

状态存储的压缩与合并

RocksDB 的 SSTable 文件会自动压缩(compaction),将多个小文件合并为大文件。压缩策略有两种:

  1. Level Style:数据按层级组织(L0-L6),每层大小是上一层的 10 倍。新数据写入 L0,压缩时向下一层合并。
  2. Universal Style:所有 SSTable 在同一层级,压缩时选择性地合并相邻文件。

Level Style 适合读多写少场景,Universal Style 适合写多读少场景。Kafka Streams 默认使用 Level Style。

压缩的频率由 level0_file_num_compaction_trigger 控制。当 L0 文件数超过阈值时触发压缩。频繁的压缩会增加 I/O 开销,但减少读取时的文件扫描次数。

状态存储的故障恢复实测

以下实验模拟状态存储损坏后的恢复过程:

  1. 正常启动 Streams 应用,处理一批事件,确认状态存储包含预期数据。
  2. 手动删除 state.dir 目录(模拟磁盘故障)。
  3. 重启 Streams 应用,观察恢复过程。
  4. 验证恢复后的状态存储数据是否与删除前一致。

恢复过程中,Streams 会从 changelog topic 读取所有数据,逐条写入新的 RocksDB 实例。恢复时间与 changelog topic 的大小成正比。

实测中,100 万条事件的 changelog topic 恢复时间约为 30 秒(单次运行,随调度波动,不作基准)。这个时间取决于网络带宽和磁盘 I/O。

状态存储的监控告警

生产环境应为状态存储配置以下告警:

告警条件阈值含义
state.dir 大小 > 80% 磁盘磁盘容量的 80%状态存储即将占满磁盘
put-latency-avg > 100ms100 毫秒状态写入延迟过高
rocksdb.compaction.pending > 1010 个压缩任务堆积,影响性能
changelog lag > 10001000 条changelog 消费滞后,恢复时间延长

这些告警应接入 Prometheus Alertmanager 或类似系统,触发后自动通知运维团队。

本节小结

  • Kafka Streams 的状态存储是本地 RocksDB 实例,访问延迟在微秒级。
  • 每个 task 维护独立的 RocksDB 目录,对应 topic 的一个分区。
  • 三种状态存储类型:KeyValueStore、WindowStore、SessionStore。
  • Changelog topic 自动创建,用于故障恢复和状态迁移。
  • 不同实例不能共享同一个 state.dir,部署时需配置独立目录。
  • 状态存储大小可通过 key 基数和 value 大小估算,过大时需检查 compact 策略。
  • 状态丢失时有三种重建策略:changelog 恢复、源头重放、快照恢复。
  • RocksDB 的压缩策略影响读写性能,需根据实际负载选择 Level 或 Universal Style。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收