这条 Append 再也没回来

给 TierLog 写消费面的第一版时,AppendAsync 的耐久语义我们想得很”优雅”:waitCommit: true 就在返回前等一下提交水位,等谁推?等底座那个组提交后台循环——它本来就在那儿跑着,循环往复地推进 CommittedOffset。复用现成机制,一行代码都不用加。

然后最简单的测试就挂了:追加一条记录,永远不返回。

现场:不是死锁的”死锁”

dotnet-stack 截下来的栈很干净:调用方停在 WaitForCommitAsync 里,Monitor.Wait(_commitLock, 10) 原地打转。没有锁竞争,没有循环等待——锁随时拿得到,10 毫秒超时也一直在醒。它不是等不到信号,是等的那个条件永远不会成立。

while (CommittedOffset < untilAddress)
{
    lock (_commitLock)
    {
        if (_committedOffset >= untilAddress) return ValueTask.CompletedTask;
        Monitor.Wait(_commitLock, 10);   // 10ms 兜底超时(防信号丢失)
    }
}

这个 10ms 兜底超时是防丢唤醒用的。但丢唤醒的前提是”有人该唤醒而没唤醒”——这次的情况更根本:条件本身的 生产者不存在。超时唤醒一百次,条件检查一百次,结果一致。

要看懂为什么,得先看这条日志里有几个水位。

三个水位

EntryLog 里同时活着三个地址:TailAddress(写尾,含未提交)、FlushedTail(已落盘的帧尾)、CommittedOffset(提交水位,读面的右界)。不变量只有一句话:

CommittedOffset ≤ FlushedTail——已 commit 的必已落盘。

反方向不成立:落盘不等于提交。一个 Append 的数据落在内存页缓冲 _pageBuf 里,页满时写线程做 FlushPage 落盘、FlushedTail 前进;而提交水位的推进有两条路:

  1. 页契约:每次页 flush 落盘后,同步 commit 到 flushedUntil——“前一页换出即持久化”,这个保证不依赖任何策略;
  2. 显式提交链:先 FlushUntil(数据落盘,含 fsync),再 CommitCore(写提交记录 + meta fsync)——顺序严格 data 先于 meta,断电时提交记录绝不会标记一个数据还没落盘的点。

末页没满的时候,数据就留在 _pageBuf 里,既不在 FlushedTail 内,也没人走提交链。

后台循环的边界

那组提交循环呢?它按三个维度(时间 10ms / 字节 64KB / 条数 1000 条)判定”该提交了”,然后——只推进到 FlushedTail,不碰页缓冲:

// 后台循环只推进 CommittedOffset 到 Log 自管真实水位 FlushedTail(已落盘 frame 尾),
// 不调 FlushPage——FlushPage 操作共享页缓冲 _pageBuf/_pageUsed,只能由写线程
// (Append/Flush/CommitAsync) 串行调用,后台并发会损坏页缓冲状态。
await CommitCoreAsync(FlushedTail);

这是单写者契约:页缓冲只有写线程能碰。后台循环要是自作主张去 flush 末页,两边的 _pageBuf/_pageUsed 状态就开始互相践踏。

于是事故的完整链条是:那条记录落在了末页的内存里 → 页没满,没人 flush → FlushedTail 不动 → 后台循环守着自己的边界,CommittedOffset 也不动 → WaitForCommitAsync 等一个没有任何人负责生产的事件。

“等后台赶上”不是一种提交实现——它把耐久性押在了一个没有生产者的事件上。

我们试过让后台去 flush——更糟

顺手 就想:让后台循环走完整提交链(含末页 flush)不就行了?其实试过。V0.1 时期(2026-09-14 取证留档)改过一版,全量套件形态下出现了挂死——显式提交 × 自动提交双驱动下的丢唤醒窗口。两个驱动源在同一条提交线上交错,等待方和推进方之间就出现了谁也说不清的窗口期。

那次之后立的规矩:提交热路径的改动必须按冻结裁定专项进行——不是不能改,是不能顺手改。

正解:耐久回执由写线程驱动

最后的形态反而更简单。waitCommit: true 不等任何人——写线程自己把这条记录推过耐久线:

private async ValueTask CommitWithFlushAsync(LogicalAddress commitTarget, ...)
{
    await FlushUntilAsync(commitTarget, ct);   // ① 数据落盘(fsync)
    await CommitCoreAsync(commitTarget);       // ② 提交记录落盘 + 推水位 + PulseAll
}

写线程有页缓冲的独占权,所以它可以 flush 末页;flush 完走提交链,水位推进、PulseAll,等待者醒来。后台循环退回它该在的位置——fire-and-forget 吞吐的摊薄器,永远不是耐久路径。两条线各自成立,互不越界。

修完之后的实测(mem 介质,128B 记录):

口径结果
waitCommit: true p50110.4 µs
waitCommit: true p99359.2 µs
waitCommit: false(组提交摊薄)758,979 rec/s

有个数字值得多看一眼:耐久回执的 p50 是 110µs,比组提交间隔(10ms)快两个数量级——因为显式提交链根本不等组提交,写线程当场 flush 当场提交。想要耐久回执,你就得接受”这一次调用自己付 flush 的钱”;想要吞吐,就 waitCommit: false 让 75 万条记录摊薄同一次 flush。两个数字都很体面,前提是它们走的是各自该走的路。

收尾:一条可迁移的判据

如果只能留一句给未来的自己:

你在等的一个条件,必须有明确归属的生产者。“后台会处理的”不是生产者——除非后台真的有权处理它。

这次事故里没有丢唤醒、没有锁竞争、没有内存问题,每个部件都在按设计工作,组合起来却是一个永久悬挂。10ms 兜底超时防得住信号丢失,防不住条件无人生产。回过头看,WaitForCommitAsync 从一开始就不该出现在那条调用链上——耐久回执只有一种合法形态:由持有写入权的线程亲自给出来。


本文事实来自 KernLab.Tier 的 EntryLog 实现(含 V0.1 判例留档与页契约注释)、TierLog 契约测试与性能基线材料(probes/TierLogProbe)。

评论

← 全部文章