题目重点:从锁加 fsync 的正确性基线出发,解释记录 framing、部分写、崩溃恢复以及多线程 group commit 提升吞吐。
题意与假设
这道练习题保留了面经中值得训练的核心约束,并将需要澄清的条件单独列出。现场拿到变体时,先和面试官确认这些条件。
- 单机本地文件,多个线程共享一个 writer;每条 data≤1024 bytes 且内容不解析。示例自定义 4-byte big-endian 长度前缀只是训练 record framing,题目没有固定二进制协议。
- push 成功表示该 record 的文件内容已 fsync;若文件是首次创建,目录项持久化还需考虑 parent directory fsync 或预创建文件。磁盘/控制器是否兑现 flush 由运行环境保证,不能仅凭语言调用作绝对承诺。
解题路线
- 正确性基线:一把锁覆盖长度前缀+payload 的完整 write 循环与 fsync,防线程间记录字节交错,并让成功返回点落在 fsync 之后。write 可能部分完成,必须循环直到写满。
- 每条都 fsync 吞吐低。高并发版让 push 将 record 入有界队列并等待自己的 completion;单个 flusher 按大小/时间阈值聚合多个 record,以一个连续写入和一次 fsync 提交整个 batch,成功后同时唤醒该 batch 的所有调用者。不能在持久化前提前 ack。
- 队列满时阻塞或显式限流,按进队顺序给每条 record 序号;多个 producer 的真实先后仅能由锁/队列线性化点定义。批量 fsync 失败要使整批等待者得到错误,并停止继续写入直到恢复。
- 崩溃后按 framing 从文件头扫描,遇尾部不完整 header/payload 则截断到最后完整 record。注意完整字节不代表已经 fsync 成功,若需要精确区分已 ack 的边界,可给 batch commit marker/checksum 并持久化。
- 示例代码实现每条 fsync 的基线并在失败时 poison,便于看清不变量;group commit 是待选优化而非该段代码已实现的特性。
手动走一遍
线程 A/B 各 push 一条 1KB 内记录。基线锁使 A 的长度与 payload 连续写入并 fsync 后才返回,B 随后写;group commit 可把二者同批写入,单次 fsync 成功后 A/B 才各自返回。若 flush 失败,两者均收到失败,不可宣称任一已持久。
Python 参考实现
下面的代码按上文假设实现,适合本地运行并追加自己的测试用例。
代码区可横向滚动;使用键盘时,先聚焦代码区再按方向键查看。
"""Single-server durable writer correctness baseline.
Opaque payloads <=1 KiB are framed by a 4-byte big-endian length, then
written under one lock. push returns only after fsync. This code favors
correctness over throughput; group commit is discussed in the article.
"""
import os
import struct
import threading
class DataWriter:
def __init__(self, file_path_on_disk: str):
self.fd = os.open(file_path_on_disk, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
self.lock = threading.Lock()
self.closed = False
self.poisoned = False
@staticmethod
def _write_all(fd: int, record: bytes) -> None:
view = memoryview(record)
while view:
written = os.write(fd, view)
if written <= 0:
raise OSError('write made no progress')
view = view[written:]
def push(self, data: bytes) -> None:
if not isinstance(data, bytes) or len(data) > 1024:
raise ValueError('data must be bytes of at most 1 KiB')
record = struct.pack('>I', len(data)) + data
with self.lock:
if self.closed or self.poisoned:
raise ValueError('writer closed or failed')
try:
self._write_all(self.fd, record)
os.fsync(self.fd)
except OSError:
# A partial frame may have reached disk. Stop appending until
# recovery scans/truncates the tail and the writer is reopened.
self.poisoned = True
raise
def close(self) -> None:
with self.lock:
if not self.closed:
os.close(self.fd)
self.closed = True
复杂度
每条记录长度 L≤1024,内存复制与写入 O(L);基线每 push 至少一次 fsync,延迟受存储设备主导,单锁令吞吐串行。Group commit 若 B 条一批,摊还一次 fsync/B,但增加排队延迟、队列 O(B·L) 内存和故障传播复杂度。
易错点与追问
容易失分的边界
- 只给 os.write/flush 便返回,未定义真实持久点。
- 假设单次 write 一定写完整 record。
- 多线程各写一段造成 header/payload 交错。
- 批量模式在 fsync 前 ack。
- 崩溃后不处理半条尾记录和重复上游重试。
- 新建文件只 fsync 文件却忽视目录项。
继续深挖
- 如何同时兼顾吞吐与每调用 durable-before-return?
- 多线程 push 的顺序如何定义?
- partial write/ENOSPC 时等待者与后续写入怎么办?
- 日志回放如何识别损坏尾部与幂等重试?
- group commit 的 batch 大小与最大等待怎样选择?
