异步批量写:AsyncWriter 如何把吞吐和可控性放在一起

从提交、编码、pending 队列、flush 到 ack 语义,梳理 seastar-log-engine 里 AsyncWriter 这条异步批量写路径的真实工作方式。

为什么日志写入一定会走向批量

单条消息逐条落盘,逻辑上最直观,但工程上很快就会撞墙。每条日志都单独触发一次完整的写入路径,意味着更高的调度成本、更碎的 I/O,以及更明显的尾延迟波动。

seastar-log-engine 选择的是更贴近实际系统的一条路:先把消息收进 shard 本地的 writer,编码成稳定的字节缓冲,再以批量为单位推进到底层 append 路径。这样做的目标并不神秘,就是在吞吐、延迟和持久化语义之间找到一个运行上足够稳的平衡点。

从整体上看,这条路径可以概括成:

submit -> encode -> pending queue -> batch trigger -> flush -> append_batch
Rendering diagram...

真正有意思的部分,不是这条链路本身,而是每一步为什么要这么组织。

AsyncWriter 先解决的是“把写入留在本地”

在这个项目里,批量写并不是一个单独的优化插件,而是 AsyncWriter 的核心职责之一。每个 shard 都有自己的 writer,写入先在本地完成聚合,然后再决定何时刷盘。

这意味着 AsyncWriter 内部需要同时持有几类状态:

  • _pendingdeque<seastar::temporary_buffer<char>>
  • _pending_bytes
  • _flush_timer
  • _backpressure
  • _gate
  • _append_writer
  • _log_manager

这几项组合在一起,体现的是一种很典型的 Seastar 风格:尽量在 shard 内部把状态和动作收拢起来,把复杂性局部化,而不是把所有请求推给一个全局协调层。

Rendering diagram...

提交阶段先做编码,不把高层对象拖进 flush 路径

submit()submit_many() 的第一件事,并不是直接去碰底层文件,而是把日志对象转成最终可写的 record buffer。进入 _pending 的不是原始 LogMessage,而是已经格式化好的 temporary_buffer<char>

这个取舍很关键。它意味着:

  • flush 阶段不需要再次编码
  • pending 队列里保存的是稳定字节,而不是业务对象
  • 上下游边界更清楚,后续 AppendWriter 只处理“怎么写”

如果把编码动作拖到 flush 阶段,写路径上的很多代价都会被推迟到更靠后的位置,最后很容易让 I/O 和格式化互相干扰。现在这种做法虽然看起来朴素,但更符合一条长期运行写路径的需求。

批量触发不是按字节,而是按条数

当前实现里,一个很容易被忽略的事实是:批量阈值使用的是 batch_size,它控制的是 entry count,而不是所谓的 batch_size_bytes

这会影响我们对整条路径的理解。按条数触发的好处是行为更直接,也更容易在不同 payload 分布下保持可预测性。项目当然还会跟踪 _pending_bytes,但那主要用于 backpressure,而不是批次形成的主语义。

从实践角度看,这意味着调参时不能把“batch 越大越好”理解成单向结论。批量过小会让 flush 太碎,批量过大又会推高积压和尾延迟。真正要观察的,是写入曲线在当前 payload 和 inflight 下是否稳定,而不是只盯着单个阈值。

定时 flush 的意义,是控制尾延迟而不是追求绝对吞吐

如果只有 batch_size 这一个触发条件,那么低流量阶段的日志可能会在 pending 里停很久。因此 AsyncWriter 还会通过 flush timer 提供另一条触发路径。

定时 flush 的意义,不是和批量竞争,而是补齐低压或波动流量下的响应性。批量触发负责把吞吐做上去,时间触发负责把等待时间控制住。两者并存,才是一条更像真实服务写路径的设计。

这也解释了为什么 flush_background(...) 会成为这层实现的关键节点。无论是条数触发、时间触发,还是停机收尾,最后都要收敛到统一的 flush 路径上。

write_acksync_ack 是两种不同承诺

在日志系统里,很多问题最后都会落到一句话:调用返回时,到底承诺了什么。

当前实现通过 ack_mode 明确区分了两种语义:

  • write_ack
  • sync_ack

它们最终都会触发 flush,但等待深度不同。write_ack 更偏向把数据尽快推进写路径,而 sync_ack 会进一步等待更强的落盘确认。

这个区分的重要性在于,它把“已提交”和“已持久化”拆成了两个层次。对于吞吐导向的场景,write_ack 往往更现实;对于恢复边界更敏感的场景,sync_ack 则能提供更强保证。两者没有谁天然更高级,关键是系统要把语义说清楚。

backpressure 只在必要时介入

回压设计同样很克制。只有当 max_pending_bytes > 0 时,提交路径才会真的进入 backpressure 逻辑;否则系统更像一个纯聚合加 flush 的 writer。

一旦 _pending_bytes 超过阈值,提交者会在 condition_variable 上等待,直到:

  • writer 进入 stopping,或者
  • pending 数据下降到恢复阈值以下

恢复阈值优先使用 pending_bytes_low_watermark,没有配置时则回落到 max_pending_bytes / 2

这里最值得关注的,不是某个变量名,而是它背后的思路:回压不是默认姿态,而是系统在积压开始明显失控时,才把压力往上游显式传回去。这比一开始就让所有提交都背着额外同步点,更适合高吞吐写路径。

flush 的真正价值,是把批次边界切干净

flush_once() 这类函数真正做的事,远不止“把队列写到文件里”。它首先会把 _pending 和当前批次做切分,再把这一批交给 AppendWriter::append_batch()

这种先切边界、再做 I/O 的方式非常重要。它保证了:

  • 前台提交不会长期被一次 flush 卡住
  • 后台 flush 有自己清晰的批次边界
  • rotate、checkpoint、stop 都能挂接到统一的写入收口点上

因此 flush 在这里其实是一种调度动作。I/O 当然是结果,但更上层的意义,是把 writer 内部状态从“持续堆积”切到“安全推进”。

这条写路径为什么值得单独看

AsyncWriter 不是简单把消息塞给 AppendWriter 的薄封装。它真正承担的是一整条异步写入路径的组织工作:什么时候编码、什么时候累积、什么时候刷盘、什么时候施加回压、什么时候与 rotate 和 checkpoint 衔接。

对技术博客来说,这篇文章真正想说明的也不是某个具体函数,而是这条路径背后的工程判断:高吞吐日志写入不是把所有消息尽快丢给文件系统就结束了,而是要先把 shard 局部性、批次边界、ack 语义和异常收尾这些问题一起收拢到一个可控的 writer 里。

这也正是 AsyncWriter 在整套系统里最有价值的地方。