NULL BITMAP by Justin Jaffray logo

NULL BITMAP by Justin Jaffray

Subscribe Archives March 24, 2025

停止构建 KV 数据库的时候到了

NULL BITMAP.png × Close dialog NULL BITMAP.pngNULL BITMAP.png 我真的厌倦了 Key-Value。 这种最缺乏想法,最没个性的数据模型,在任何场景下使用起来都很痛苦。

它们非常受欢迎!尤其是在数据库供应商中。或者更准确地说,是 存储引擎 供应商。 他们喜欢为你提供一个字节数组到字节数组的映射,如果他们特别慷慨,它甚至可能是有序的,以便你可以进行范围扫描。

问题在于,KV 接口唯一擅长的是作为构建合理数据模型的基础。这种原始性意味着每个使用 KV 数据库的人都必须从头开始做这件事。 而且他们(其实是我)通常做得一般,因为它实际上不是他们打算解决的问题。 是的:我们可以有一个第三方库,建立在 RocksDB 之上。 这是自然的“关注点分离”的答案。 但是,说实话:你 真的 会使用某个家伙用 Rust 编写的库吗? 这个库在它的 Cargo.toml 文件中有 300 个依赖项,并且还有一个动漫 logo? 反正我不会。 我想尽可能地使用一个单一的数据库,我不想让这个数据库把更多依赖的责任推给我。

对我来说,“数据独立性”是关系模型的关键卖点。 数据独立性包括 Codd 的关系数据库十二条规则中的第 8 条和第 9 条。 来自 Wikipedia:

规则 8:物理数据独立性: 无论存储表示或访问方法发生任何变化,应用程序和终端活动在逻辑上都保持不受影响。 规则 9:逻辑数据独立性: 当对基本表进行任何在理论上允许不受损害的信息保持性更改时,应用程序和终端活动在逻辑上都保持不受影响。

这区分了 物理模式 (physical schema),它包括数据编码和索引结构之类的内容,以及 逻辑模式 (logical schema),它表示给定表中有哪些列,以及它们的名称、类型和约束是什么。 对世界的柏拉图式的关系视图是,查询应该始终针对 逻辑 模式编写,而不是针对物理模式。

数据库中实现数据独立性的部分是 查询规划器 (query planner)。 它可以获取用户希望实现的声明性语句,并将其转换为命令式计划。 正是这种转换层保护了应用程序代码,使其无需了解数据存储方式的详细信息。

我曾多次从数据库用户那里听到,他们喜欢 KV 胜过 SQL,因为他们 不喜欢 为了使查询生效而费力研究查询规划器。 这是完全合理的! 对于许多用例来说,以声明方式指定计算是不合适的。 你需要 知道 你正在做一个约束范围扫描,而不是“如果我这样编写 SQL,数据库很可能会将其作为约束范围扫描来执行。” 我认为这是一个错误的二分法:我们可以实现比“字节到字节数组”更好的东西,而无需一直达到“关系乌托邦数据独立性”。

我首选的基础数据模型是记录 (records)。 你可以在关系数据库中找到类型化的记录,它由描述“这是我想要访问的列”的逻辑级别和描述“这是我想要能够查询的列集”的物理级别组成。 也就是说,逻辑模式和物理模式的解耦。

更简单地说:

让我们更具体一些。 我们可能会像这样在 SQL 中创建一个表:

-- 逻辑模式
CREATETABLEdata(
tsTIMESTAMPNOTNULL,
idINTNOTNULL,
user_idINTNOTNULL,
reasonTEXTNOTNULL
);
-- 物理模式
CREATEUNIQUEINDEXONdataprimary_idx(ts,id);

这很好,也很正常,并且可以清晰地映射到像 RocksDB 这样的 KV 数据库:你只需将 key 设为 (id, ts) 的某种编码,它保留了字典序比较(对于固定大小的类型很容易,对于可变大小的类型有点棘手),然后将 value 设为 user_idreason 的某种连接。

如果你有其他索引,实际使用 SQL 数据库可能会出现以下问题:

CREATEINDEXONdatasecondary_user_id_idx(user_id,ts);
CREATEINDEXONdatasecondary_reason_idx(reason,ts);

然后我编写一个 SQL 查询,如下所示:

SELECT*FROMdataWHEREts>100ANDts<200ANDuser_id=4ANDreason='expired';

对于这个查询,有两种自然的计划:

  1. 扫描索引 secondary_user_id_idx,限制 user_idts,并在 reason 列上进行剩余过滤; 或者
  2. 扫描索引 secondary_reason_idx,限制 reasonts,并在 user_id 列上进行剩余过滤。

在这两者之间进行选择归结于了解 reason_iduser_id 是否更具选择性。 我们想要选择迫使我们使用剩余过滤器处理更少行数的选项。 这是查询规划器的工作。

通常,反对查询规划器的原因是不希望在查询的编写和执行之间存在这个“智能”层。 这里存在一个风险,即查询规划器会做出 错误 的选择,而我们没有一个简单的方法来预测或纠正它。 对于一个非常有限的用例,在大多数情况下,我们事先知道我们的访问模式是什么,并且有一小部分查询,那么这种风险(和麻烦)可能不值得数据独立性的好处。 这就是为什么人们有时会支持 KV 数据库,这意味着这种事情不会发生。

但是,我们不必完全采用这种方式。 我们可以获得记录的很多好处,并通过简单地……不使用查询规划器来避免这些麻烦。 如果你不介意显式编写查询计划(并且编写代码来处理 KV 数据比编写查询计划更容易),那么这完全没问题。 你只需让你的查询语言要求你指定要读取哪个索引以及希望操作以什么顺序执行。

你不会获得真正的查询规划器的好处,因为你无法动态响应数据分布的变化,也无法根据其参数专门化查询,并且你需要同时理解数据库的物理和逻辑模式,但是所有这些对于 KV 数据库 来说仍然是正确的。

如果你的数据库了解你的模式,那么它可以为你做以下一些事情:

当然,确实 有充分的理由提供最不具主观性的接口。 如果你想提供,比如说,范围扫描,那么你现在就在你的数据上发布一个类型系统和语义。 你必须做出的每一个决定都是一个做出错误决定的机会。

必须做出的最大决定也许是“你的记录渗透到你的数据模型有多深”,意思是,你是否有写入磁盘然后知道如何比较的类型? 还是你有你知道如何转换为字节切片的类型,然后将按字典顺序进行比较。 这两个选项都有权衡。 我不知道这里是否有完美的解决方案。

尽管如此,我认为嵌入式数据库有一个位置,它对数据的存储方式有更主观的看法。 SQLite 基本上就是这样! SQLite 并不经常被认为是 RocksDB 的替代品,但在很多方面它实际上就是。 我认为它可能受到 SQL 事实的阻碍,因此对于人们想要使用 RocksDB 的那种工作负载,它有很多不好的含义,这就是为什么我认为现在是时候推出一些新东西了。

总而言之,我想要一个具有以下功能的嵌入式数据库:

诸如此类的东西已经尝试过了,但我认为它们的时代再次来临。 不再需要 KV。 Don't miss what's next. Subscribe to NULL BITMAP by Justin Jaffray: Your email (you@example.com) Subscribe Continue the conversation:

— Start the conversation: Comment and Subscribe GitHub Website Bluesky Twitter Find NULL BITMAP by Justin Jaffray elsewhere: GitHub Website Bluesky Twitter This email brought to you by Buttondown, the easiest way to start and grow your newsletter.