Seastar Log Engine 项目背景与整体设计

从 Seastar 的 shard 模型出发,梳理 seastar-log-engine 的写入、路由、查询、归档与恢复路径,说明这套日志引擎为什么会长成现在的样子。

为什么要单独做一套日志引擎

如果只是把日志写到文件里,很多问题都可以先不考虑。但一旦目标变成"持续高吞吐写入、按 key 稳定落到固定 shard、支持在线查询、还能在异常退出后尽快恢复",普通的文件追加就不够了。

seastar-log-engine 的设计出发点很明确:它不是一个通用数据库,也不是一个带复杂索引的消息系统,而是一套围绕 Seastar 线程模型实现的 append-only 日志引擎。它把最重要的事情集中在几条路径上:

  • 写入要顺着 shard 本地执行,尽量少跨核。
  • 路由要稳定,不能因为进程重启就把同一个 key 打到不同 shard。
  • 查询要足够直接,能同时覆盖 active log 和 archive。
  • rotate、archive、checkpoint、recovery 这些后台动作不能破坏主写入路径。

这也决定了它的整体风格更像"围绕写路径优化的工程化日志组件",而不是追求功能面面俱到的平台。

整体结构怎么拆

从代码结构上看,这个项目并不复杂,但边界划分很清楚。

  • LogEngine 负责对外暴露统一入口,并持有 seastar::sharded<AsyncWriter>ShardRouter
  • AsyncWriter 是每个 shard 上的写入执行单元,负责接收记录、做批量聚合、触发 flush,并和 backpressure、rotate、checkpoint 协调。
  • AppendWriter 负责更底层的 DMA append,包括对齐写、tail buffer 管理、flush 和 close。
  • LogManager 负责文件生命周期相关的后台逻辑,例如 rotate、archive、checkpoint 与恢复辅助。
  • ShardRouter 负责把一条日志稳定路由到目标 shard。
  • log_readerquery_server 负责读路径和查询接口。

这个拆分的关键在于:上层关心"日志该去哪里、什么时候刷盘、什么时候切文件",下层关心"字节最终怎么以 DMA 方式写进文件"。职责不混在一起,后面的优化空间才会比较清晰。

Rendering diagram...

路由为什么是核心问题

在 Seastar 里,最怕的是让一个本来能本地完成的操作,频繁变成跨 shard 协调。日志写入尤其如此。

所以 seastar-log-engine 从一开始就把"先路由、后写入"作为基本前提。ShardRouter 目前提供两类策略:

  • hash_modulo
  • consistent_hashing

两种策略都基于稳定哈希,而不是依赖进程内实现细节不稳定的 std::hash<std::string>。在 consistent hashing 路径里,hash ring 的实现也不是关联容器,而是排序后的 vector<pair<token, shard>>。这样做一方面更贴合当前场景,另一方面也更容易控制 token 布局与查找成本。

如果请求里没有 route key,系统再按照 empty_route_policy 决定是本地处理还是轮转分发。换句话说,route key 不是一个附属参数,而是整个写入模型能否保持 shard 局部性的前提。

写入路径真正做了什么

从调用链看,一条日志进入系统后,大致会经历下面几个阶段:

Rendering diagram...
  1. LogEngine 先通过 ShardRouter 选出目标 shard。
  2. 对应 shard 上的 AsyncWriterLogMessage 格式化成 temporary_buffer<char>
  3. 缓冲后的记录进入 _pending 队列。
  4. 当累计条数达到 batch_size,或者定时器触发时,writer 会把这批数据交给 AppendWriter 执行真正的 flush。
  5. 在 rotate 或 stop 等边界时,再与 checkpoint、archive 等后台逻辑衔接。

这里最重要的一点是:AsyncWriter 累积的是已经格式化好的字节缓冲,而不是继续保留高层对象。这样做的好处是,真正落盘前的数据形态已经稳定,flush 路径只需要关心批量写和对齐问题,不必再重复处理日志对象本身。

配置为什么保持扁平

这个项目的配置格式是简单的 key=value,而不是更复杂的 TOML 或 YAML。原因并不神秘:当前需要表达的参数集中在写入、路由、rotate、archive 和恢复上,结构并不深,扁平格式已经够用。

目前比较关键的配置项包括:

  • batch_size
  • flush_interval_ms
  • routing_strategy
  • routing_virtual_nodes
  • max_pending_bytes
  • pending_bytes_low_watermark
  • rotate_size_bytes
  • rotate_interval_seconds
  • archive_retention_seconds
  • max_archived_files_per_shard
  • checkpoint_enabled

配置保持简单的直接收益,是部署和排障都更轻。对这类偏底层的日志组件来说,减少"配置系统本身"的复杂度,通常比引入花哨语法更有价值。

查询接口刻意做得很窄

query_server 对外同时提供 HTTP 和 gRPC,但接口范围收得很紧。

HTTP 侧目前暴露:

  • /healthz
  • /v1/status
  • /v1/route
  • /v1/records

gRPC 侧目前对应的是:

  • GetStatus
  • Route
  • QueryRecords

它们都是 unary 接口,没有做 streaming records,也没有额外的 server push 机制。读路径本身会同时覆盖 active log 和 archive,但接口层依然保持克制。这种取舍很符合项目定位:重点是把写路径、恢复路径和可观测性做扎实,而不是把查询层扩成一个通用数据访问网关。

健康状态与可观测性

项目当前使用的健康状态值是:

  • ok
  • degraded
  • unhealthy

这个状态既会出现在 /v1/status,也会在 gRPC GetStatus 中体现。它不是一个花哨的运维面板,而是一种很实用的信号汇总方式:写入失败、checkpoint 异常、恢复降级、archive 读取问题,最终都可以收敛成对外能消费的状态结果。

再加上 Prometheus 指标,系统的基本运行面就比较完整了。对于日志引擎这种基础组件来说,能不能快速判断"它还在不在按预期工作",往往比单次 benchmark 数字更重要。

这套设计适合什么,不适合什么

seastar-log-engine 最适合的场景,是需要稳定 shard 路由、持续追加写、有限查询能力和明确恢复策略的服务内日志或事件落盘组件。它不是为了替代数据库,也没有朝着复杂分析引擎去设计。

从这个角度看,它的架构并不追求"大而全",而是在 Seastar 的执行模型下把几件关键事情做到位:

  • 每个 shard 各自承担本地写入责任。
  • append-only active log 负责主数据路径。
  • rotate、archive、checkpoint、recovery 负责把长期运行问题收拢起来。
  • query 和 metrics 负责把系统状态暴露给外部。

这也是理解后续各篇文章的最好入口。后面无论是 per-shard writer、DMA append、checkpoint/recovery,还是 soak testing,本质上都在回答同一个问题:一套围绕 shard 局部性构建的日志引擎,怎样在吞吐、持久化和可恢复性之间找到可运行的平衡点。