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 更像一个“写入调度器”,而不是单纯的缓存容器。
它内部最关键的状态包括:
_pending:deque<seastar::temporary_buffer<char>>,保存已经格式化好的待刷盘字节。_pending_bytes:当前 pending 数据量。_append_writer:真正负责 DMA append 的底层 writer。_log_manager:负责 rotate、archive、checkpoint 等外围流程。_flush_timer:控制定时 flush。_backpressure:seastar::condition_variable,用于回压等待。_gate:保护异步 flush 生命周期,避免 stop 过程和后台任务交错失控。
这组状态很能说明当前实现的思路:高层日志对象不会长期滞留在 writer 内部,真正进入缓冲区的是已经成形的字节数据;而围绕这些字节的 flush、回压和停机收口,则由 AsyncWriter 自己协调。
一条日志进入 writer 之后会发生什么
主写入流程并不花哨,但层次很清楚。
submit(LogMessage)先检查 writer 是否已经进入 stopping 状态。submit_record()会把记录格式化成temporary_buffer<char>。- 这段 buffer 被追加进
_pending,同时更新_pending_bytes。 - 如果
pending_entries() >= config.batch_size,就触发后台 flush。 - 如果启用了回压,还要经过
maybe_wait_for_backpressure()。
这里有两个实现取舍值得注意。
第一,批量触发是按“条数”算的,也就是 batch_size 控制 entry count,而不是按字节阈值做 batch_size_bytes。这样做的好处是行为更可预测,参数语义也更直接。
第二,writer 里缓冲的是已经编码好的字节数据,而不是 LogMessage 原对象。这样 flush 路径不需要二次编码,也避免了对象生命周期拖进更深的异步链路。
write_ack 和 sync_ack 到底差在哪
当前实现里,ack_mode 会直接影响 flush 行为。
write_ack对应flush_background(false, true),重点是把数据尽快推进到写路径里。sync_ack对应flush_background(true, true),会进一步等待底层 flush 完成。
回压不是默认开启的
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() 本身的处理顺序也很有代表性:
- 标记
_stopping = true - 取消
_flush_timer - 唤醒回压等待者
- 等待 gate 中的异步任务收敛
- 把剩余数据做最终 flush
- 在启用 checkpoint 时写出最终 checkpoint
- 关闭
AppendWriter
这说明 AsyncWriter 不是一个只会“把消息推给底层”的薄封装,它本身就是写入生命周期的编排中心。
指标的意义不只是监控面板
writer 这一层暴露了不少指标,例如:
submitted_messagessubmitted_bytesflushed_batchesflushed_bytesflush_errorsbackpressure_waitspending_entriespending_byteswaiting_submitterslogical_size_bytes
这些指标的价值,不只是给 Prometheus 多几条曲线。对于 per-shard writer 来说,它们直接反映了系统是不是已经偏离正常节奏:batch 是否刷得太碎、pending 是否持续堆高、回压是否频繁发生、flush 错误是否开始抬头。
如果说 AppendWriter 解决的是“怎么写”,那 AsyncWriter 暴露的这些指标,解决的就是“现在这条写路径是不是还处在健康区间”。
小结
AsyncWriter 是 seastar-log-engine 里非常典型的一层设计。它既不直接承担所有底层 I/O 细节,也没有把自己退化成一个纯转发器,而是稳稳卡在中间,把 shard 本地写入、批量 flush、回压、rotate 和停机收尾都组织起来。
从技术博客的角度看,per-shard writer 最值得关注的不是某个单独函数,而是这条思路本身:把写入路径上的复杂性收敛到 shard 局部范围内,再通过清晰的状态边界把异步 I/O 驯服住。后面再看 DMA append、checkpoint 和恢复流程时,这个结构会一直出现。