趁还没有一个用户,把盘上格式定死

十月初,Tier 的变长键(byte[] 键)其实已经能跑了:产品层的 TierBytesKv 作为一次 spike 的产物已经就位,功能齐全,测试全绿。但这个”能跑”里藏着两个不对:键的概念倒挂了——“这条记录的键是什么”,只有产品层的编解码器能回答,存储层自己的扫描和恢复路径看不到键;而且那个编解码器是在产品层手写的二进制偏移,按我们自己的红线,spike 可以,安家不行。

于是在同一个晚上,我们围着它连改四笔设计,从”它能跑”改到”它该是什么”。这篇讲那次重构:一个基座怎么拆出两种键形态,以及为什么说,那是”零消费方窗口”里最后一次便宜的机会。

先看两个不对

变长键形态第一版的思路参考的是 FASTER 的双 allocator——一个日志基座、两种可替换记录几何(定长内联 / 变长分离)。但落地时,为了绕开”基座只认定长键”这个前提,我们让 ring 用一个 64 位假键(键的哈希)定位,把真正的键字节塞进记录载荷;判等、编码、重建全都由产品层的 BytesRecordCodec 与 BytesRecordResolver 编排——判等链跨层四段:ring 哈希 → 索引槽 tag → 槽全哈希 → 记录内键字节。

能跑。但按层级摊开看:记录的盘上几何由产品层代码决定,存储层只看见一个 8 字节假键。一个存储运行时,自己的记录里有没有键、键在哪、多长,居然答不出来。

还有一条过期的老裁定需要顺带更正:设计文档里写着”变长 TKey 不做——结构层无此功能项”(那一天是 9 月 9 日),到十月它已经被事实推翻——spike 都做出来了,只是文档还没改。裁定登记会过期,事实不会。

解键:把”键”从基座里拆出去

重构的第一步叫”解键”(de-keying)。原来的 RingBase 是泛型定长键基类——键类型渗在记录几何和几十个方法签名里。我们把 23 个 partial 文件里的键耦合全部盘点了一遍,数出一个干净的切口:

键类型出现在公开/保护 API 签名或记录几何计算的成员 → 形态层;其余全部 → 基座。

数下来,键无关的代码约占七成:地址空间、页池、epoch、flush、恢复、CRC、meta、溢出分离(大值走 WiscKey)、快照管道、事务参与——这些是 Ring 的硬资产,全部留在基座(非泛型化的 RingBase)。类型化写/扫/批/快照 API、IKeyResolver 实现、键长锚点,下放给实现类。拆分基本是机械的:同名 partial 平移。

真正新增的是两个记录面原语,只让形态层调用:

// 键以「字节面」进记录——定长形态零拷贝,变长形态直接是用户字节
LogicalAddress AppendRecord(ReadOnlySpan<byte> keyBytes, ulong probeHash,
    ReadOnlySpan<byte> payload, ushort flags);
bool TryReadKeyBytesAt(long phys, out ReadOnlySpan<byte> keyBytes);

定长形态里,keyBytes 就是 TKey 的 span-cast——零拷贝,哈希不落盘;变长形态里,键字节真的写进记录,哈希随之落盘。同一个基座,两种键几何,从这一刻起是兄弟,不是父子。

变长形态:记录里到底放了什么

VarKeyRing 的记录几何:

[Header 40B : "VRHD" | Version | Flags | PayloadLength | ... | CRC32C]
[KeyHash  8B: XxHash64(key)——索引探测槽]
[KeyLen   2B: 键字节数]
[KeyBytes   : 键本体(上限 4096,etcd 同位)]
[Payload    : 值(墓碑记录无此段)]

几个决策值得展开:

独立 magic。 两种形态用不同魔法数(VRHD / BRHD):错误形态打开错误卷,读头部那一刻就拒绝——沿用 v1→v2 改版时”旧盘不认”的纪律。meta 里同时锚了键长上限,低配实例开高配卷同样拒绝启动。

哈希落盘,8 字节一条。 键的 XxHash64 存进记录本体,索引探测时免回读键字节——拿空间换路径,买得清楚。

判等闭环。 哈希只负责”往哪找”,不负责”是不是”——最终判定永远是键字节精确比对:索引 tag/全哈希 → 记录内哈希 → 键字节终判。测试里专门做了一族碰撞注入:人为构造哈希碰撞的记录,正确性依然成立。正确性永不依赖哈希单独判定。

索引族的对照很有意思。 探测族索引这次只是把泛型约束从 unmanaged 放宽到 notnull 就完事了——因为索引槽位本来就只存 tag、不物化键;而 Ring 的定长键是记录几何本身(内联、零拷贝读、meta 锚点)——约束放宽解决不了几何问题,只能出第二个形态。

还有个 v1 的幽灵:Ring header 最初本来就有一个 KeyLength 字段,v2 泛型改版时退役了。这次的变长形态,本质上是那个槽位以”平级第二形态”的身份正式回归——不是新发明。

副作用也有:键一宽,BTree 的大节点会被撑大,原来 stackalloc 的节点缓冲在递归路径上爆栈——修法是超 4KB 栈预算改去堆上租借。变长键的涟漪,一直淌到排序索引的栈上。

九分钟里的四次落笔

设计稿不是一次想清楚的,是四笔改出来的——提交时间戳记着这个过程:

  • 20:39,初稿落地:解键 + 变长形态 + 双形态性能测算矩阵,D1–D6 全部挂着”待拍板”;
  • 20:44,改了一处前提:我们发现自己写错了一个基准——变长键根本没有既有基线(第一版 spike 没做过性能测试),“不劣于现状”是无基线伪命题。对比口径改成”定长形态同负载”,同时明确:这一波交付的产出之一,就是变长键的首个基线;
  • 20:47,D1–D6 全部拍板:哈希落盘、键长 4096、类名 VarKeyRing、基座保留 RingBase、排序族挂独立设计、产品更名 TierVarKv;
  • 20:48,补了一段”设计立场”,把这件事的性质说破:

底层完备性是设计属性,不是需求属性。定长/变长键形态都是记录存储原语的基本能力面,按完备性一次做对,不等消费方出现再补——“先跑起来、有使用方再改”是应用层逻辑,不适用于底层仓。

敢这么定,是因为当时的背景本身:变长形态零消费方,盘上格式和命名还没被任何人依赖——这是改动成本最低的窗口,也是把话说死的最后机会。等第一个消费方出现,格式和名字就从”设计”变成”契约”,价格反转。

换底与更名:手写偏移全部退役

拍板之后,从夜里到第二天下午连做了几步:

  • 引擎换底:TierBytesKv 内部从”假键 ring + 产品层编解码”换到 VarKeyRing 原生记录面。手写偏移的 BytesRecordCodec、适配层 BytesRecordResolver、假键的 RingImpl<long> 全部退役——红线件清零,判等闭环由 ring 原生承接;
  • 测试族 9 项:往返保真、边界键长(1 字节 / 恰上限 / 超限拒绝)、墓碑、恢复重放、magic 判别(错形态开卷即拒)、meta 锚点、碰撞注入、大值溢出、全量重放;
  • 更名:TierBytesKv → TierVarKv。理由值得抄一遍:键形态是两产品的分界维度,产品名应姓分界面而非共同面——值面本来就都是字节(类型化是生成器给的 overlay),所以”Bytes”名错了维度。改名后与底层构成命名同构:VarKeyRing → TierVarKv、BlittableRing → TierKv;同时把”值面 = 原始字节”显式写进文档,防”Var”被误读成”类型化的值”。

然后发生了第二次推翻。P2/P3 阶段我们先做了一版”薄壳 + 新引擎类”的产品形态——一层薄门面包着新引擎,改动最小。裁定把它推翻了:变长 KV 不是薄壳,是与 TierKv 平级的完整第二套 KV——部件一一对应(产品类 / 恢复 / Builder / 装配 / 备份 / 订阅流),“不是薄壳抄老代码”。落地是产品类正典化:旧引擎类取消(三个文件净删 700 余行),产品类内聚全部能力面(重写 +700 行),命名空间从 Kv/Bytes/ 的从属位置搬到产品级平级。判定规则也成文了:

TierVarKv 的每个部件都必须能回答”TierKv 里对应物是什么”——回答不出的部件 = 多余或错位。能力差异只允许来自键形态本身,不允许来自实现取舍。

两天里形状变了三次(spike → 薄壳 → 正典)。前两版不是白写的:第一版证明需求是真的,第二版证明”薄壳”是错的。

数字,连同不好看的那个

首个变长基线(同机、内存介质、一万条预载、128B 值):

基准定长 TierKv变长 TierVarKv差距
点查命中4.42 µs2.73 µs变长快 38%
前缀扫 100 条9.01 ms0.373 ms变长快 24×
覆盖写11.4 µs17.1 µs慢 50%

写慢一半,归因清楚:变长形态的写路径要同时维护哈希点索引和内存有序索引,一记两笔。读面则全面占优——点查走哈希索引直读,扫描不再逐条回表。换底相对第一版形态是零回归(写 17.1 vs 18.3–18.9 µs、点查 2.73 vs 2.9–3.0 µs、扫描同带或更优)——也就是说”慢 50%“不是重构弄丢的,是形态本身的取舍:拿写的一半,买读的 24 倍。

选型结论顺势成文:键可映射定长的(long / Guid / int)用 TierKv,写路径省那 50%;真变长、前缀扫和点查重的用 TierVarKv。两形态并列一等公民,无主次、无迁移方向。

还有一个数字需要坦白。解键重构的回归门禁里,Ring 追加有一个 +16% 的表观退化。按纪律,表观退化要做对照实验再定论——把解键前的旧代码在同一个时间段、同一台机器上复测,旧代码同样是 2,908ns(它的基线记录是 2,314–2,513ns)。退化是机器状态漂移,不是代码回归,新旧同带,门禁通过。要是当时对着两个不同时段的数字下结论,就会给一个不存在的回归立块碑。

收尾:一条可迁移的判据

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

在应用层,“先跑起来、有需求再改”叫敏捷;在底层,盘上格式一出生就是契约——“等有消费方再补”的那扇窗,会在第一个消费者出现的瞬间关上。

底层完备性是设计属性,不是需求属性——而它还附赠一条时间约束:完备性最便宜的时刻,永远是零消费方的那个窗口。


本文事实来自 KernLab.Tier 的 ring-varkey 设计稿与 D1–D6 拍板记录、RingBase 解键与 VarKeyRing 实现及其测试族、双形态同负载性能基线(定长 / 变长 / 换底前三方对照),与产品层正典化重构(含 TierVarKv 更名与”薄壳形态”被推翻的记录)。

评论

← 全部文章