DMA 对齐写:AppendWriter 如何处理真正的落盘细节
围绕 AppendWriter 的 logical size、write offset 与 tail buffer 设计,理解 seastar-log-engine 怎样在 DMA 约束下维持 append-only 写入。
为什么 DMA 写不是"直接 append 就行"
从上层看,日志写入像是在不断追加一段又一段记录;但到了 DMA 文件接口这一层,事情就没这么简单了。对齐要求、尾部不足一个对齐块的数据、rotate 或 close 时的最终收口,都会让"顺手追加"变成一个需要精细处理的问题。
AppendWriter 的价值就在这里。它不负责路由,也不决定 batch 什么时候形成;它关心的是另一件更底层的事:在 append-only 语义不变的前提下,怎样把这些字节安全而高效地写进 DMA 文件。
AppendWriter 要同时维护两个长度概念
理解这层实现,最重要的是先分清两个量:
_logical_size_write_offset
_logical_size 表示当前逻辑上已经存在的有效日志长度,也就是读路径、checkpoint 和恢复语义真正关心的边界。
_write_offset 则更接近底层 DMA 写入实际推进到哪里。由于对齐和 padding 的存在,这两个值并不总是相等。也正因为如此,系统才能一边满足 DMA 约束,一边保持上层看到的仍然是一条连续的 append-only 日志。
AppendWriter 内部状态
除此之外,writer 还维护:
_tail_chunks_tail_bytes_alignment_file_opened_at
这些状态共同决定了最后一次不满对齐块的数据如何暂存,以及什么时候真正冲进文件。
打开文件时就已经确定了 I/O 约束
AppendWriter 通过 seastar::open_file_dma() 打开 active log,并根据设备对齐要求初始化内部状态。对齐值通常来自 disk_write_dma_alignment() 之类的查询,而不是硬编码某个魔数。
这一步的意义在于,后续所有 append、flush、tail 合并和 truncate 都是在同一套对齐规则下执行。DMA 不是额外优化选项,而是整个底层写路径的前提。
批量 append 时真正难的是尾部
append_batch() 接收的是:
std::deque<seastar::temporary_buffer<char>>& batch
也就是说,上层已经把一批记录编码成字节缓冲并交下来了。AppendWriter 的工作,是把这些 buffer 接到当前尾部状态之后,尽量形成可以直接 DMA 写的对齐块,同时保留必要的 tail。
这里的难点不在"怎么把数据写出去",而在"怎么让最后那一点没凑满对齐块的数据既不丢,又不破坏 append 语义"。所以实现里会同时维护 _tail_bytes 和 _tail_chunks,把暂时不能安全落盘的尾部收在内存里,等后续批次到来时再一起处理。
为什么需要 flush_chunks_prefix()
对这类 writer 来说,真正适合直接落盘的,往往是"已经形成完整对齐前缀"的那一部分数据,而不是整个 batch 的原始拼接结果。
flush_chunks_prefix() 的作用,就是把当前可写前缀切出来,组织成 aligned buffer,然后交给 DMA 写路径推进。剩下不足对齐要求的尾部,则继续留在 tail 结构里。
这一步体现的其实是一种很重要的边界意识:文件写入层不会为了图省事,把未对齐尾部也当成普通数据强行推进。宁可多维护一层 tail buffer,也要把"逻辑可见数据"和"物理写入对齐"这两件事拆清楚。
write_aligned_buffer() 处理的是稳定 I/O,而不是业务语义
当一段 aligned buffer 真正准备好之后,底层会通过 _file.dma_write(...) 把它写出。这个阶段关心的是 I/O 行为本身,例如:
write_behindwrite_retry_countwrite_retry_backoff_ms
如果启用了需要更强持久化保证的路径,还会在写后继续执行 _file.flush()。
这说明 AppendWriter 的职责边界非常清楚。它不再关心这批日志属于哪个 route key,也不关心上层选择的是 write_ack 还是 sync_ack;它只负责把已经决定好的字节内容按 DMA 规则稳定推进。
tail buffer 为什么是整个设计的关键
很多看似"追加写"的实现,真正复杂的地方都不在大块数据,而在最后一点零头。对 AppendWriter 来说,这部分零头就是 tail buffer 的职责范围。
在 rotate、close 或显式收尾时,系统会通过 flush_tail(true) 把这部分尾部真正落盘,并在必要时:
- 处理尾部缓冲中的剩余字节
- 调整最终文件边界
- 通过
_file.truncate(_logical_size)收回 padding 带来的物理多写部分 - 执行
_file.flush()
尤其是 truncate(_logical_size) 这一步,很能体现当前设计的意图。DMA 写路径为了满足对齐,物理上可能临时写出比逻辑数据更多的 padding;但对外可见的 active log 仍然应该停在真正的逻辑边界上。truncate 正是在最后把这两个世界重新对齐。
这套实现解决的不是"能写",而是"写完之后还能恢复"
如果只看一次正常写入,tail buffer、logical size 和 truncate 这些细节可能显得有点啰嗦。但一旦把 recovery 放进来,就会发现它们都是必要的。
恢复流程最终依赖的是一个可信的逻辑边界,而不是"上次 DMA 大概写到了哪里"。AppendWriter 之所以要严格区分 _logical_size 和 _write_offset,并且在收尾阶段显式整理 tail,就是为了让后续 checkpoint、verified scan 和 truncate 恢复路径有可靠基础。
从这个意义上说,DMA append 不是单纯的性能优化,它同时也是一致性设计的一部分。
小结
AppendWriter 这一层最值得看的地方,不是某次 dma_write 调用了哪个参数,而是它如何把两个看似冲突的目标放到一起:
- 底层遵守 DMA 对齐约束
- 上层继续保持自然的 append-only 日志语义
它通过 logical size、write offset、tail buffer 和最终 truncate,把这件事做成了一个可恢复、可持续运行的写入基础。前面的 AsyncWriter 负责决定"何时刷",而 AppendWriter 负责保证"刷下去之后文件状态仍然讲得通"。这正是日志引擎里最不显眼、但也最不能偷懒的一层。