摘要
4000 条记录、8 个发送线程、泊松到达 2500/s、真实文件加 os.fsync,3 轮实测:逐条写 fsync=4000,p50 0.57~1.01ms;leader 攒批窗口 W=0 只 drain 队列已有,fsync 降到 1947~2083,p50 0.39~0.70ms;W=20ms 时 fsync 只剩 79~82 次(省 98%),但 p50 涨到 10.88~12.09ms、p95 20.47~21.63ms。窗口越大 fsync 越少、延迟越高,低负载下 W=0 是免费午餐。
一、fsync 为什么值得攒批
写盘要持久化就得 fsync,而 fsync 是磁盘操作里最贵的一类:它要把文件数据真正落到存储介质,单次耗时与吞吐无关。逐条写意味着每条记录摊一次 fsync;group commit 的思路是让一个 leader 线程把一段时间内到达的记录攒成一批,一次 write 加一次 fsync,批内所有记录共享这次落盘。数据库的 WAL、消息队列的刷盘本质上都是这笔账。
窗口设多大是核心参数:等得越久批越大、fsync 越省,但每条记录要多等一会儿。这篇用真实文件加 os.fsync 把 0/1/5/10/20ms 五档窗口各跑 3 轮,量 fsync 次数和单条延迟,看权衡曲线长什么样。
攒批的思路在存储系统里很常见:数据库让多个事务的日志共享一次刷盘,消息队列把一批消息一起落盘,都是同一笔账。本文不依赖任何具体引擎,直接用 Python 写一个最小的 leader 攒批,把曲线量出来——这样每个数字都能回到代码,而不是引用别人引擎的文档。
二、实验设置
| 参数 | 取值 | 说明 |
|---|---|---|
| 记录数 | 4000 | 每条 153 字节 |
| 到达过程 | 泊松,总速率 2500/s | 8 个发送线程平分 |
| 发送方式 | 开环 | 只记录入队时刻,不等 ack |
| 写盘 | os.write + os.fsync | 真实文件,Windows 加 O_BINARY |
| 窗口 W | 0 / 1 / 5 / 10 / 20 ms | 从 leader 取出第一条起算 |
| 批上限 | 256 | 达到即提前写 |
| 轮次 | 1 / 2 / 3 | 到达种子 9001~9003,偶数轮倒序跑 |
对照模式 per_record 是 8 个线程各自加锁、每条 write 加 fsync。延迟定义为"入队到 ack",ack 时刻在 fsync 完成之后统一打给批内所有记录。每轮都断言写盘字节数等于 4000×153=612000,防止丢写或重复写。
到达用泊松过程(间隔取指数分布)而不是匀速,因为真实流量有突发,匀速到达会让队列几乎常空,低估窗口的作用。发送线程只记录入队时刻、不等 ack,这是开环:系统变慢时到达不会跟着变少,积压和延迟会如实暴露出来。闭环压测的坑见避坑指南第一条。
三、结果:fsync 单调降,延迟单调升
三轮到达跨度 1649.3~1689.8ms,各模式都跟上了到达速率,没有持续积压。核心读数:
| 模式 | fsync 次数(3 轮) | p50/ms | p95/ms | 平均批 |
|---|---|---|---|---|
| per_record | 4000 / 4000 / 4000 | 0.57~1.01 | 3.05~9.06 | 1.00 |
| W=0 | 2083 / 1947 / 2027 | 0.39~0.70 | 1.07~1.24 | 1.92~2.05 |
| W=1 | 955 / 953 / 934 | 2.27~2.34 | 2.68~2.81 | 4.19~4.28 |
| W=5 | 281 / 284 / 278 | 4.00~4.21 | 6.56~6.83 | 14.08~14.39 |
| W=10 | 151 / 157 / 153 | 6.26~6.81 | 10.99~11.66 | 25.48~26.49 |
| W=20 | 79 / 82 / 79 | 10.88~12.09 | 20.47~21.63 | 48.78~50.63 |
三个观察:
- W=0 是免费午餐。 不等待、只 drain 队列里已有的记录,fsync 就从 4000 降到 1947~2083,约省一半;p50 反而从 0.57~1.01ms 降到 0.39~0.70ms——单 leader 串行写,省掉了 8 线程抢锁的开销。
- 窗口越大越省 fsync,但延迟是纯代价。 W=20 时 fsync 只剩 79~82 次,比逐条写省 98%(79/4000 换算),平均批 48.78~50.63 条,与"2500/s × 20ms = 50 条"的理论值吻合;代价是 p50 涨到 10.88~12.09ms、p95 到 20.47~21.63ms,基本就是窗口长度本身。
- 吞吐在这个负载下拉不开。 到达 2500/s 低于各模式的处理能力,wall 都在 1650.0~1718.4ms 之间(第 2 轮 per_record 除外,见下节)。攒批的收益体现在 fsync 次数——也就是磁盘 IO 余量——而不是当前负载下的吞吐。
补充一句第 1 个观察的机理:W=0 的 p50 更低,不是"攒批变快了",而是"争抢消失了"。per_record 是 8 个线程抢一把锁,锁交接本身有开销;单 leader 天然串行,没有竞争。所以对照时要分清收益来自 batching 还是来自去锁。
第 2 轮 per_record 出现一次 max=374.78ms 的尖峰,wall 被拉到 2436.1ms、rps 掉到 1642.0;另两轮 max 只有 11.21 / 18.54ms。这是逐条写路径在锁竞争下的偶发抖动,照实记录,不剔除。
四、窗口是负载的函数
窗口的收益取决于负载。低负载时队列里没几条记录,等了也攒不满,窗口退化成纯等待——W=1 档平均批只有 4.19~4.28 条,p50 却涨到 2.27~2.34ms,就是这个状态。负载高时队列常满,W=0 的机会主义 drain 就能攒出大批,固定窗口的必要性反而下降。由此推断:最优窗口不是常数,而是随负载升高可以调小;本文只测了 2500/s 一档,其它负载要重扫,别把这条曲线当普适结论。
如果你在用数据库或消息队列,这条曲线对应手里的刷盘批延迟参数:调大省 IO、抬写延迟。差别在于引擎的 fsync 成本、批上限和并发模型都与本参考实现不同,数值不能照搬,但权衡的方向一致。
五、leader 攒批的核心代码
W=0 的机会主义 drain:
if window_ms == 0:
while True:
try:
batch.append(q.get_nowait())
except queue.Empty:
break
W>0 的窗口等待,窗口从 leader 取出第一条起算:
deadline = time.perf_counter() + window_ms / 1000.0
while len(batch) < MAX_BATCH:
remain = deadline - time.perf_counter()
if remain <= 0:
break
批写完后一次 fsync,再给批内所有记录打 ack:
blob = b"".join(PAYLOAD for _ in batch)
written[0] += os.write(fd, blob)
os.fsync(fd)
六、避坑指南
- 闭环压测会把批钉死在并发数。 第一版用"提交→等 ack→再提交"的闭环负载,8 个生产者永远只有 8 条在途,W≥1ms 的批恒等于 8 条,窗口退化成纯等待,吞吐反而低于逐条写。换开环泊松到达才看到真实权衡。
- 窗口起点别用 enqueue 时刻。 从入队起算时,leader 一旦落后,所有批的窗口都已超时,全部退化成单条写,积压自我强化:实测 W=1 那版队列持续滚大、fsync 次数回到逐条写水平,吞吐反而更低。改成从 leader 取出第一条起算即稳定。
- Windows 上 os.open 必须加 O_BINARY。 否则文本模式把每条记录的 \n 转成 \r\n,每条多写 1 字节,写盘字节数断言当场抓住。
- 亚毫秒等待别用 queue.get(timeout)。 Windows 定时器粒度约 1ms,remain 不足 1ms 时会被向上取整,W=1 档每个批多等近 1ms,直接拖垮吞吐;不足 1ms 改用 get_nowait 忙等。
- fsync 耗时依介质而变。 本机逐条写在两个正常轮跟上了 2500/s 到达(wall 1650.0 / 1691.0ms 与到达跨度相当),说明单次 fsync 明显低于 0.4ms(按 1/2500s 换算的上界);网络盘或云盘可能差几个量级,窗口档位要按目标介质重测,别照搬。
七、结论
低负载下先拿 W=0 的免费收益:fsync 约省一半,p50 还更低。要压 fsync 次数换 IO 余量,再按延迟预算选固定窗口——W=20 省 98% 的 fsync,代价是 p50 约 11~12ms。窗口不是越大越好,是"延迟预算换磁盘余量"的显式定价。所有数字来自本机 Python 参考实现、真实文件与 os.fsync、三轮实测,代码与 transcript 已留档。
574



被折叠的 条评论
为什么被折叠?



