Per-shard Writer:AsyncWriter 如何组织写入路径

拆解 seastar-log-engine 中每个 shard 上的 AsyncWriter,理解它如何把批量写、backpressure、flush、rotate 与 stop 串成一条可控的写入路径。

为什么每个 shard 都要有自己的 writer

Seastar 的优势,本来就建立在 shard 局部性之上。如果日志写入还要先汇总到某个全局队列,再由中心线程统一刷盘,那前面通过 route key 做的 shard 定位几乎就白费了。

seastar-log-engine 选择的路线正好相反:LogEngine 内部持有 seastar::sharded<AsyncWriter>,每个 shard 都有自己的 writer、自己的 pending 队列、自己的 flush 节奏。这样做不是为了结构好看,而是为了让绝大多数写入都能在目标 shard 本地完成。

这意味着写入路径上的很多问题,都要在 AsyncWriter 这一层解决:什么时候批量、什么时候刷盘、什么时候施加回压、什么时候触发 rotate,以及停机时怎么把尾巴收干净。

AsyncWriter 持有哪些状态

从职责上看,AsyncWriter 更像一个“写入调度器”,而不是单纯的缓存容器。

它内部最关键的状态包括:

  • _pendingdeque<seastar::temporary_buffer<char>>,保存已经格式化好的待刷盘字节。
  • _pending_bytes:当前 pending 数据量。
  • _append_writer:真正负责 DMA append 的底层 writer。
  • _log_manager:负责 rotate、archive、checkpoint 等外围流程。
  • _flush_timer:控制定时 flush。
  • _backpressureseastar::condition_variable,用于回压等待。
  • _gate:保护异步 flush 生命周期,避免 stop 过程和后台任务交错失控。

这组状态很能说明当前实现的思路:高层日志对象不会长期滞留在 writer 内部,真正进入缓冲区的是已经成形的字节数据;而围绕这些字节的 flush、回压和停机收口,则由 AsyncWriter 自己协调。

Rendering diagram...

一条日志进入 writer 之后会发生什么

主写入流程并不花哨,但层次很清楚。

  1. submit(LogMessage) 先检查 writer 是否已经进入 stopping 状态。
  2. submit_record() 会把记录格式化成 temporary_buffer<char>
  3. 这段 buffer 被追加进 _pending,同时更新 _pending_bytes
  4. 如果 pending_entries() >= config.batch_size,就触发后台 flush。
  5. 如果启用了回压,还要经过 maybe_wait_for_backpressure()

这里有两个实现取舍值得注意。

第一,批量触发是按“条数”算的,也就是 batch_size 控制 entry count,而不是按字节阈值做 batch_size_bytes。这样做的好处是行为更可预测,参数语义也更直接。

第二,writer 里缓冲的是已经编码好的字节数据,而不是 LogMessage 原对象。这样 flush 路径不需要二次编码,也避免了对象生命周期拖进更深的异步链路。

write_acksync_ack 到底差在哪

当前实现里,ack_mode 会直接影响 flush 行为。

  • write_ack 对应 flush_background(false, true),重点是把数据尽快推进到写路径里。
  • sync_ack 对应 flush_background(true, true),会进一步等待底层 flush 完成。
Rendering diagram...

回压不是默认开启的

AsyncWriter 的回压设计也很克制。只有当 max_pending_bytes > 0 时,这条机制才真正启用;否则 writer 只负责积累和 flush,不主动阻塞提交方。

_pending_bytes 超过 max_pending_bytes 后,提交路径会在 _backpressure 上等待,直到:

  • writer 开始 stopping,或者
  • pending 数据回落到恢复阈值以下

恢复阈值优先使用 pending_bytes_low_watermark,如果没有显式配置,则退回到 max_pending_bytes / 2

这一点很像工程上常见的“只在必要时引入复杂性”。对很多部署来说,系统本身就可能足够平稳,没有必要让每次提交都背着额外的等待逻辑;但当积压开始失控时,condition_variable 形式的回压又能及时把压力传回上游。

flush 为什么先 swap 再写

flush_once() 的关键动作,是先把 _pending 和一个局部 batch 做交换,再把这批数据交给 AppendWriter::append_batch()

这样做的好处是,真正的 I/O 路径不会长期占着主 pending 队列。flush 在后台慢慢写的时候,前台提交仍然可以继续往新的 _pending 里塞数据。对持续写入场景来说,这种“把批次边界先切干净再去落盘”的方式,比一边持锁一边写更适合 Seastar 的执行模型。

flush 完成后,writer 还会更新统计信息、处理错误计数,并根据需要唤醒被回压挡住的提交者。也就是说,flush 在这里不只是一次 I/O,它同时是系统节奏控制点。

rotate、checkpoint 和 stop 怎么收口

运行中的 writer 不是只负责“不断写”。文件大小达到 rotate_size_bytes,或者运行时间超过 rotate_interval_seconds 时,writer 还要进入 rotate 流程。

与此同时,checkpoint 并不是一个独立的周期性后台线程,它更多是作为写入生命周期里的边界动作出现,典型触发点包括:

  • flush 后的合适时机
  • rotate 前后
  • stop() 收尾阶段

stop() 本身的处理顺序也很有代表性:

  1. 标记 _stopping = true
  2. 取消 _flush_timer
  3. 唤醒回压等待者
  4. 等待 gate 中的异步任务收敛
  5. 把剩余数据做最终 flush
  6. 在启用 checkpoint 时写出最终 checkpoint
  7. 关闭 AppendWriter

这说明 AsyncWriter 不是一个只会“把消息推给底层”的薄封装,它本身就是写入生命周期的编排中心。

指标的意义不只是监控面板

writer 这一层暴露了不少指标,例如:

  • submitted_messages
  • submitted_bytes
  • flushed_batches
  • flushed_bytes
  • flush_errors
  • backpressure_waits
  • pending_entries
  • pending_bytes
  • waiting_submitters
  • logical_size_bytes

这些指标的价值,不只是给 Prometheus 多几条曲线。对于 per-shard writer 来说,它们直接反映了系统是不是已经偏离正常节奏:batch 是否刷得太碎、pending 是否持续堆高、回压是否频繁发生、flush 错误是否开始抬头。

如果说 AppendWriter 解决的是“怎么写”,那 AsyncWriter 暴露的这些指标,解决的就是“现在这条写路径是不是还处在健康区间”。

小结

AsyncWriterseastar-log-engine 里非常典型的一层设计。它既不直接承担所有底层 I/O 细节,也没有把自己退化成一个纯转发器,而是稳稳卡在中间,把 shard 本地写入、批量 flush、回压、rotate 和停机收尾都组织起来。

从技术博客的角度看,per-shard writer 最值得关注的不是某个单独函数,而是这条思路本身:把写入路径上的复杂性收敛到 shard 局部范围内,再通过清晰的状态边界把异步 I/O 驯服住。后面再看 DMA append、checkpoint 和恢复流程时,这个结构会一直出现。