改一个配置,200MB 读成了另一个文件

主流存储引擎大多用 64 位地址(一个 long),TC.Tier 用的是 128 位。

这个选择不是从”地址空间够不够大”出发的——64 位空间(16 EB)本身远远够用。它是从一次真实的数据事故里长出来的。先说事故。

事故:改一个配置,历史数据全错

用户场景:WAL 数据集,第一次配置 SegmentSize = 128MB,觉得太小改成 256MB 重启,历史数据全部错乱

这不是偶发,是结构性缺陷。当时在三个子系统(Ring / Blob / Log)逐一实证,全是同一个模式。

根因是两个条件同时成立:

条件 A:使用层只存绝对字节偏移,从不存段号。 三个子系统的元数据头里,全部用一个 long 存”全局第几个字节”,没有任何字段记录”在第几个段文件”。

条件 B:底层在读写时用”当前”的段大小重新拆分这个偏移。 读路径长这样:

int segId = (int)(offset / (ulong)_owner.SegmentSize);   // 用当前配置重算

机制用具体数字看最清楚。存了偏移 200MB

  • 按 128M 配置解释:段号 1、段内偏移 72MB → 读 log.1@72MB
  • 按 256M 重启后解释:段号 0、段内偏移 200MB → 读 log.0@200MB文件都错了

同一个存储值,因为解释口径变了,被路由到完全不同的物理文件。

这里的关键认知是:段大小不是常量,它是配置。把地址压成一个数、再用”当前配置”去解释它,等于把段的几何烙进了解释规则——几何一改,所有历史地址全部指向错误的位置。

一次失败的修复

第一次修复尝试是引入”自定位地址”:固定 18+46 位编码,高位当段号、低位当偏移,试图消除对段大小的依赖。

它犯了两个错误:

  1. 没改使用层的存储——三处元数据头仍然存单 long 绝对偏移;
  2. 保留了隐式转换——调用方继续传绝对偏移,高 18 位直接变成垃圾段号。

它把”可变的配置旋钮”换成了”固定的魔法常数”,但”使用层不存段号”这个根本问题一点没动。2^46 只是把 bug 从”配置相关”退化成”始终存在但更隐蔽”——更糟。

正确的形状:地址是两个值

修正后的设计核心只有一句话:

段切换是”上层知道”的事件,不是隐藏在下层的除法。

地址重新分层:就是 (segmentId, offset) 两个独立的值,贯穿上层与底层;上层水位线、记录定位、元数据落盘,全部存这两个值;不编码成单 ulong、不打包、不拆包

这样一来,“改段大小不影响历史数据”成了可以验证的目标——测试写得很直白:按 128M 写入、改成 256M 重启、读回数据必须正确。

那为什么是 16 字节,不是 12

问题来了:(int segId, long offset) 只有 12 字节,为什么最终的地址是 16 字节?

因为原子性。存储引擎的尾水位线(“写到哪儿了”)必须能被多个写者并发推进,做法是 CAS。CAS 有个硬前提:目标必须落在一条原子指令能覆盖的宽度里。

12 字节(96 位)恰好是尴尬位置——早期源码注释写得很直白:

不打包成单 ulong:(int 32bit + long 64bit) = 96bit 放不进 64bit。

那个阶段,水位线只能各子系统自己想办法:单写者用 volatile、多写者加锁。

转折点出现在 128 位 CAS 落地之后——x86-64 的 lock cmpxchg16b、ARM64 的 ldaxp/stlxp。实测封装层 CAS 16.9ns,比 lock 的 17.4ns 还快;4 线程争用下快 30%。有了它,那句历史约束才被解除:整个地址(段号 + 版本 + 偏移)可以用一条 CAS 原子更新

布局由此定型:

[StructLayout(LayoutKind.Explicit, Size = 16)]
public readonly struct LogicalAddress
{
    [FieldOffset(0)] public readonly int SegId;      // 段号(文件序号 data.{segId})
    [FieldOffset(4)] public readonly int Extension;  // 扩展字段(当前承载 ABA 版本号)
    [FieldOffset(8)] public readonly long Offset;    // 段内字节偏移
}

多出来的 4 字节不是 padding——它是让整个地址能被一条 CAS 原子更新的那块拼图。

那 4 个字节:ABA 版本号

ABA 问题是这样:写者读到”尾部 = (段 5, 版本 10, 100MB)“,算好新值准备 CAS;这中间另一个线程把尾部推到 110MB、又被一个收缩操作回退到 100MB——位置一样,写者察觉不到中间发生过变化,CAS 就会按陈旧的前提成功。

把版本号嵌进地址之后,机制就干净了:

  • CAS 是位精确比较——16 个字节全比,含版本号。回退操作会把版本号 +1,陈旧 CAS 的期望值对不上,失败重试;
  • 判等忽略版本号——地址的 Equals / CompareTo / GetHashCode 只比段号和偏移。对上层来说,“同一个位置”就是同一个地址。

一个字段,两种视角:CAS 看到”位置 + 版本”,业务看到”位置”。

诚实的边界:版本号是 int,理论上 40 亿次回退后会回绕、构成一个漏洞。按实际使用(每次回退对应一次尾部截断)这个量级不可达——评审把它记为”实际不可达”,同时提醒代码注释不该暗示比这更强的保证。

地址即版本

append-only 存储的地址序天然单调、全序、唯一——这正好是版本语义需要的全部。所以底层不设版本记录

版本语义实现
单调 / 全序 / 唯一append-only 地址序天然具备
同 key 历史记录头的 PreviousAddress 版本链
版本续传地址游标
版本回收回收线前移(与 compaction 同构)

地址不只是”位置”,它就是版本本身。拿到地址直达数据,点查不必过索引——上层缓存地址后,取值永久跳过哈希,哈希只是”发现地址”的一次性成本,此后地址即句柄。这条直达路径实测到 74ns。

附记:8 字节的诱惑

后来还提过一次”8 字节紧凑地址编码”(把段号折进高位)的优化,被正式作废:

8B 永远无法正确解决无限地址空间——打包把几何烙进地址:段号占 N 位 ⇒ 段数 ≤ 2^N 且段大小 ≤ 2^(64−N)。

FASTER 就是这么做的:它的哈希桶条目是 8 字节压缩位 [1b tentative | 15b tag | 48b addr],地址被截断到 48 位(256TB 上限)。跑得很快,但架构上把几何焊死了——正是上面那个事故的坑,不能踩第二次。

代价:16 字节对齐的四场仗

128 位带来能力,也带来纪律。16 字节对齐不是”慢一点”,是”直接死”:

一、非对齐 = 段错误。 实测 lock cmpxchg16b 在非对齐地址直接 SIGSEGV。我们一度以为它像 8 字节的 lock cmpxchg 一样容忍非对齐(只是慢),删掉了对齐检查——结果 AccessViolation。对齐检查是正确性必需,不可删。

二、托管堆不保证 16 字节对齐。 托管数组只保证 8 字节对齐,实测约 50% 的字段落在非 16B 边界上。早期兜底是一把进程级自旋锁——所有非对齐 CAS 被串行化,多核下就是灾难。后来改成按地址哈希分片的多桶锁,再后来干脆用 64 字节对齐的专用分配做背板,从根上消除”碰巧 GC 分配对齐”的定时炸弹。

三、对齐够了,缓存行还得管。 两个尾水位线试过合并成单个 128B 块(看起来更紧凑),实测反而更慢:2 线程吞吐 2.19M vs 1.35M ops/s。原因是 Intel 的空间预取器会绑定相邻缓存行,CAS 一个时另一个被踢。最终回退成两个独立的 64B 块。

四、最阴险的:ABI 的 1 个字节。 C 侧 CAS 函数返回 C99 bool(1 字节),托管侧声明成了 UnmanagedType.Bool(4 字节的 Win32 BOOL)。返回值寄存器只有低 8 位被设置,高 24 位残留上一次计算的值——当偏移第 8 位以上非零时,CAS 失败被读成成功。现象是多线程下地址租借区间重叠、数据被覆盖。它一直藏着:偏移 < 256 时不触发;同步 IO 串行化了竞争,失败路径几乎不执行;异步化之后才高频命中。最后靠反汇编定位,修复是把声明改成 UnmanagedType.U1——一行。

小结

128 位地址买的是三样东西:

  • 语义:无限地址空间的正交表达,编码与几何解耦(事故的根因不能再犯);
  • 原子性:地址(含版本)一条 CAS 整体更新,乐观并发;
  • 版本:地址序即版本序,底层不设版本记录。

代价是 16 字节对齐的纪律、一套跨平台原子原语、以及上面四场仗。相对于”地址格式将来改不动”的架构债,这笔账划算。


本文事实来自 TC.Tier 的地址空间设计评审、性能基准与提交历史。

评论

← 全部文章