← 返回文章

RocksDB

有序 KV 入门:Key、Iterator、Prefix Scan 与数据建模

从默认字节序出发,图解复合 Key、Seek、Iterator、Prefix/Range Scan、exclusive upper bound、Cursor 分页与编码陷阱,理解 Key Schema 为什么就是 RocksDB 的查询设计和持久格式契约。

一排按序排列的纸质标签中,橙色定位标记落在精确位置,两个绿色边界围出一段连续蓝灰范围的编辑风插画
本文目录

把下面三个 Key 写进 RocksDB:

user:1
user:2
user:10

你认为 Iterator 会以什么顺序读出来?

很多人会下意识回答:1、2、10。

但在默认字节比较直觉下,更可能看到:

user:1
user:10
user:2

因为 RocksDB 不知道冒号后面是整数。对存储引擎来说,Key 是字节序列;Comparator 看到前缀 user:1 相同后,较短的 user:1 先结束,而 user:10 的下一字节仍小于 user:2 中对应的 2

这个小例子会把我们带到 RocksDB 数据建模最重要的结论:

Key 不只是记录标识,也是排序规则、查询前缀、范围边界、分页位置与长期磁盘格式。

本文从默认 Bytewise Comparator 建立直觉,随后讲复合 Key、Seek、Iterator、Prefix Scan、Range Scan 和 Cursor。示例不要求使用 Prefix Extractor;它先用明确的起止边界保证语义正确,再说明 Prefix Bloom 等优化位于哪里。

RocksDB 的“有序”究竟是什么意思?

RocksDB 的 Key 与 Value 都是字节序列。Key 由 Comparator 定义一个全序;默认 Bytewise Comparator 可以理解为按无符号字节逐个做字典序比较。

比较两个 Key 时:

  1. 从第一个字节开始;
  2. 第一个不同字节决定大小;
  3. 如果一方是另一方的完整前缀,较短的一方更小;
  4. 所有 Key 必须能得到稳定、传递且一致的顺序。

所以:

user:1 < user:10 < user:2

不是 RocksDB 排错了,而是应用没有把“数值顺序”编码进 Key。

默认字节字典序把 user:1、user:10、user:2 依次排列,固定宽度文本示例才保持预期数值顺序

人类可读的数字字符串不自动拥有数值排序语义。

如果业务明确知道数字最大两位,可以编码成:

user:01
user:02
user:10

但这又引入了新契约:宽度是多少?超过 99 怎么办?负数怎么表示?上线后能不能改成三位?

因此,“左侧补零”只适合范围明确的简单文本例子,不是通用数值编码方案。

Java 的 byte 是有符号,Comparator 直觉却要看无符号字节

Java byte 范围是 -128 到 127。如果应用自己实现编码测试,直接把两个 byte 转成 int 比较,可能与无符号字节顺序不同。测试时应使用 Byte.toUnsignedInt,或使用明确的 unsigned lexicographic comparator。

这一点在 ASCII 示例中不明显,因为常见字符都在正数范围;一旦 Key 包含任意二进制、高位字节或哈希片段,错误比较器就会暴露。

文本还有字符集与规范化问题

字符串必须先通过约定字符集变成字节。UTF-8 是常见选择,但视觉上相同的 Unicode 文本可能有不同规范化形式;大小写折叠与本地化排序也不是 Bytewise Comparator 自动提供的。

如果业务把用户名直接放进 Key,必须决定:是否区分大小写?是否规范化?旧版本用的编码能否继续读取?这些决策一旦写入 SST,就属于数据兼容,而不只是 UI 文本处理。

先从查询出发,再设计复合 Key

假设我们要保存订单,并支持这个查询:

列出 tenant-7 下所有 PAID 订单,按创建时间升序,同一时间用订单 id 稳定排序。

一个概念 Key 可以设计成:

order : tenant-7 : PAID : 20260830103015 : 00000128

复合 Key 由 namespace、tenant、查询维度、排序维度和唯一后缀组成,字段顺序共同决定扫描范围

Key Schema 既是存储格式,也是查询和排序设计。

把它拆成五段:

字段 示例 主要职责
namespace order 区分记录类型或索引空间
tenant tenant-7 把租户数据聚在连续范围
query dimension PAID 支持按状态构造前缀
sort dimension 20260830103015 决定范围内部时间顺序
unique suffix 00000128 处理同时间记录并保证 Key 唯一

对应查询可以构造:

prefix = order : tenant-7 : PAID :

Seek 到这个前缀后,连续记录天然按时间与 id 排列。

但“用冒号拼字符串”只是为了可读。真正实现还要回答:字段值本身能否包含冒号?时间使用什么时区?id 上限是多少?每段是否定长?编码版本放在哪里?

字段顺序怎样改变查询能力?

比较两个 Schema:

A: order : status : time : id
B: order : id : status : time

它们包含相同字段,但物理顺序完全不同。

order状态时间id把同状态订单聚成连续范围,order id状态时间适合已知id点查却让状态分散

复合 Key 字段相同,排列顺序不同,可用查询就不同。

Schema A 把所有 PAID 订单聚在一起,适合:

  • status = PAID 的前缀扫描;
  • PAID 内按时间的范围扫描;
  • 从某个时间 Cursor 继续分页。

但如果只知道订单 id,不知道 status,就不能直接构造完整 Key。应用可能要扫描多个状态,或维护 order-by-id 的额外索引。

Schema B 把 id 放在前面,已知 id 时点查直接;但 PAID 分散在每个 id 下面,无法用一个连续范围得到全部 PAID 订单。

这里没有“永远正确”的字段顺序。应该列出真实访问模式,再判断:

  • 哪些查询必须点查;
  • 哪些查询允许范围扫描;
  • 范围内部按什么排序;
  • 哪些维度需要额外索引;
  • 索引更新能否与主记录原子维护。

Key Schema 本质上是在选择一组被物理顺序优先支持的查询。

Key 越长,成本为什么会被重复放大?

Key 不只保存在一份业务记录旁边。它还会参与 MemTable 排序、SST Data Block、Index 分隔、Bloom 构建、Cache 与 Compaction 比较。同一个长前缀可能在大量记录中重复出现,虽然 Block 前缀压缩能减少一部分空间,CPU 比较、索引边界与内存仍要处理这些字节。

因此,复合 Key 应该包含“查询与唯一性真正需要”的字段,而不是把整个业务对象序列化进去。冗长的租户名称、可变 JSON 与展示文本通常不适合直接成为排序字段,可以使用稳定 id,并在 Value 中保存展示信息。

但也不能为了省几个字节把 Key 变成无法调试、无法迁移的隐式位域。合理做法是先估算记录数、平均 Key 长度、重复前缀和索引数量,再用实际数据测量空间与查询;在可解释性和资源之间做有证据的取舍。

Key 分布还会影响写入热点。把单调递增时间放在最左侧,可能让新写入集中到同一 Key 尾部;把哈希打散到最左侧又会破坏时间范围连续性。是否分桶、分片或加盐,必须与所需范围查询一起设计,而不是只追求“均匀”。

三类编码陷阱

可读字符串非常适合文章,却容易隐藏上线后的兼容问题。

分隔符冲突、变长数字和错误字节序会破坏无歧义解码或业务排序,不同方案解决不同问题

编码方案必须同时满足排序、解码和长期兼容三项要求。

陷阱一:分隔符也可能出现在字段值中

假设 Key 是:

tenant:team:a:user:42

team:a 是一个 tenant 字段,还是 teama 两段?简单 split(":") 无法判断。

常见解决方向包括:

  • 对分隔符进行无歧义转义;
  • 每段使用长度前缀;
  • 字段使用固定宽度;
  • 使用结构化二进制编码,并为格式保留版本。

选择长度前缀时还要考虑长度本身怎样编码、是否保持期望顺序。能解码不等于保序,保序也不等于容易演进。

陷阱二:变长数值不保数值序

文本 1、10、2 的字典序与数值序不同。固定宽度文本可解决有限正整数,但必须先定义最大值与溢出策略。

对于无符号整数,固定宽度 Big Endian 编码可以保持数值顺序,因为高位字节先参与比较。例如两字节概念值:

00 02 < 00 10

如果使用 Little Endian,低位字节先出现,字典序通常不再对应数值序。

陷阱三:有符号数不能直接套无符号结论

把 Java long 的二进制直接按 Big Endian 写入时,负数最高位为 1,会在无符号字节比较中排到非负数之后。若想保持有符号数自然顺序,常见方法是在编码前翻转符号位,例如对值与 Long.MIN_VALUE 做异或,再用 Big Endian 写出;解码时执行逆变换。

浮点数还涉及负数、-0、NaN 与位表示;倒序时间常通过补数或逐字节取反构造。它们都应该使用经过证明和测试的编码,不要由一个正整数例子随意推广。

编码测试至少覆盖什么?

一套 Key Codec 至少要有:

  • encode → decode 往返测试;
  • 业务比较结果与字节比较结果一致的属性测试;
  • 最小值、最大值、零、空字段和非法输入;
  • 分隔符、高位字节与 Unicode 规范化;
  • 旧版本数据在新代码下的兼容测试;
  • 上界、下界和 successor 的边缘测试。

Key 已写入生产以后再发现排序错误,修复往往意味着全量重编码与重建索引。

Seek 的准确语义:第一个 Key 大于等于 target

假设数据库按序包含:

order:9
user:41
user:42
user:43
version:1

执行 Seek("user:42") 后,Iterator 定位到第一个满足:

key >= "user:42"

的位置。

Seek user:42 在有序 Key 中定位第一个大于等于目标的位置,随后可以用 Next 继续遍历

Seek 是定位起点,不是自动定义扫描终点。

如果 user:42 存在,当前 Key 是 user:42。如果它不存在,Seek 可能直接落到 user:43。所以 Seek 不是“Get 加 Iterator”,调用方必须检查实际 Key。

另外,Seek 后不保证 Iterator 一定有效。目标可能大于数据库最后一个 Key,或者 ReadOptions 的上界让结果不可见。每次读取 key()value() 前都要先检查 isValid()

SeekToFirst 与 SeekForPrev

seekToFirst() 从当前迭代范围的第一条开始。反向定位可以使用 seekForPrev(target) 寻找小于等于目标的位置,但 Prefix Mode 与反向迭代有额外限制,不能把正向 Prefix Scan 的结论直接镜像过去。

基础篇以正向 Seek + Next 为主。需要反向时间线时,应先确认 Comparator、Prefix Extractor、ReadOptions 上下界与具体 RocksDB 版本支持的组合。

一个安全的 RocksJava Prefix Scan

先写最容易验证语义的版本:Seek 到前缀,然后每一轮同时检查 isValid 与实际前缀。

private static boolean startsWith(byte[] key, byte[] prefix) {
    if (key.length < prefix.length) {
        return false;
    }
    for (int i = 0; i < prefix.length; i++) {
        if (key[i] != prefix[i]) {
            return false;
        }
    }
    return true;
}

byte[] prefix = "user:".getBytes(StandardCharsets.UTF_8);

try (ReadOptions readOptions = new ReadOptions();
     RocksIterator iterator = db.newIterator(readOptions)) {
    iterator.seek(prefix);

    while (iterator.isValid()) {
        byte[] key = iterator.key();
        if (!startsWith(key, prefix)) {
            break;
        }
        byte[] value = iterator.value();
        // 处理当前 Key-Value;不要把 native iterator 泄漏到循环外。
        iterator.next();
    }
}

这个循环有四个不可省略的边界:

  1. seek(prefix) 只定位起点;
  2. isValid() 必须在读取 Key/Value 前检查;
  3. startsWith 在前缀变化时停止;
  4. RocksIteratorReadOptions 都要关闭。

Iterator 为什么不应该无期限持有?

Iterator 是 native 资源,并且需要在一个一致的数据库版本上工作。长时间不关闭的 Iterator 可能让旧文件继续被引用,推迟文件删除;如果还绑定了 Snapshot,旧版本保留时间会更长。

因此,扫描 API 应限定页大小、最长执行时间与取消路径。业务代码不应把 RocksIterator 塞进全局缓存或跨线程随意复用,也不应在网络客户端停顿时一直占有它。更安全的边界是:在一次本地扫描或一页结果内创建、使用并关闭,再用稳定 Cursor 继续。

iterator.key()iterator.value() 取得的字节也应及时转换或复制到业务拥有的对象,不能假设它们在 Iterator 移动、关闭或数据库关闭后仍由 native 结构保证有效。具体 RocksJava 版本的复制语义应查对应 Javadoc,但应用生命周期不应依赖一个正在变化的 Iterator 位置。

扫描中发生错误与“到达尾部”也不应在业务层被随意合并。isValid() 为 false 可能表示没有当前位置;若 API 版本提供状态或抛出异常路径,调用方还要正确传播 I/O 与数据损坏错误,而不是把部分扫描结果当作完整成功。

如果只写:

iterator.seek(prefix);
while (iterator.isValid()) {
    iterator.next();
}

那么扫描会继续经过 version: 等后续 Key,直到数据库尾部。这不是 Prefix Scan,只是从某个位置开始的全尾扫描。

用 exclusive upper bound 定义半开区间

RocksDB 的 iterate_upper_bound 可以为 Iterator 设置一个不包含的上界。扫描范围可表达为:

[start, end)

前缀扫描从 Seek 起点开始,在前缀变化或达到 exclusive upper bound 时停止,形成明确半开区间

没有停止条件的 Seek 循环不是前缀扫描,而是继续扫向数据库尾部。

对 ASCII/UTF-8 文本前缀 user:,冒号字节是 0x3A,下一个字节值是分号 0x3B,于是概念范围可以写成:

["user:", "user;")

RocksJava 示例:

byte[] start = "user:".getBytes(StandardCharsets.UTF_8);
byte[] end = "user;".getBytes(StandardCharsets.UTF_8);

try (Slice upperBound = new Slice(end);
     ReadOptions readOptions = new ReadOptions()
         .setIterateUpperBound(upperBound);
     RocksIterator iterator = db.newIterator(readOptions)) {
    iterator.seek(start);

    while (iterator.isValid()) {
        byte[] key = iterator.key();
        byte[] value = iterator.value();
        // 处理 [start, end) 内的记录。
        iterator.next();
    }
}

资源声明顺序很重要:Iterator 最先关闭,随后 ReadOptions,最后 Slice。只要 ReadOptions 仍使用上界,承载上界字节的 Slice 就必须保持有效。

通用二进制前缀怎样求 successor?

不能对所有前缀都简单拼接一个字符。通用算法是:

  1. 从前缀最后一个字节向前找;
  2. 找到第一个不等于 0xFF 的字节;
  3. 将它加一;
  4. 丢弃它后面的所有字节;
  5. 得到最小的 exclusive upper bound。

例如:

12 34 7F FF  →  12 34 80

如果整个前缀都是 0xFF,就不存在有限的字节 successor。此时应使用 startsWith 检查、数据库尾部,或由更高层 Key Schema 提供可表示的命名空间上界。

即使设置 upper bound,循环中保留业务前缀检查也能作为防御性校验,尤其在自定义 Comparator 或编码正在演进时。但边界计算必须与数据库 Comparator 的顺序一致,不能拿 Bytewise 的 successor 套到任意自定义比较器。

Prefix Extractor 与 Prefix Bloom 是优化,不是扫描语义

RocksDB 可以配置 Prefix Extractor,让系统知道怎样从 Key 提取固定或可变前缀,并使用 Prefix Bloom 等机制加速某些查询。

但 Prefix Extractor 不会自动替应用定义“哪些 Key 属于业务前缀”。如果循环没有停止条件,打开 Prefix Bloom 也不会把它变成正确的 Prefix Scan。

应按这个顺序思考:

  1. 先用 Comparator、start、end 或 startsWith 定义正确范围;
  2. 再确认 Prefix Extractor 与 Key Schema 一致;
  3. 再通过统计验证 Prefix Bloom 是否减少无效读取;
  4. 最后评估内存与构建成本。

Prefix Mode 对 Seek 目标、反向迭代和前缀范围有使用限制。基础篇不把它作为所有 Iterator 的默认开关;使用前应阅读当前版本的 Prefix Seek 文档并编写边界测试。

Range Scan 不只是 Prefix Scan 换个名字

Prefix Scan 的范围由共同前缀定义,例如所有 user:。Range Scan 可以使用任意业务下界与上界,例如:

[order:PAID:20260901000000,
 order:PAID:20261001000000)

这表示扫描九月份的 PAID 订单,前提是时间编码保持顺序,且上界精确表示十月起点。

范围设计应明确:

  • 上下界是否包含;
  • 时区和时间精度;
  • 相同时间如何用 id 排序;
  • 空范围与跨天、跨月边界;
  • 大范围如何限页和取消;
  • 是否需要 Snapshot 固定视图。

Iterator 天然按 Key 顺序输出,不代表大范围扫描成本低。它仍可能读取大量 Data Block、占用 Cache、触发解压并与后台 I/O 竞争。查询接口必须有页大小、最大范围、超时或取消机制。

Cursor 分页为什么优于大 Offset?

如果第 10,000 页仍从范围开头遍历并丢弃前 99,990 条记录,Offset 越大,重复工作越多。

Key Cursor 可以保存上一页最后一个 Key:

Page 1: K1, K2, K3  → lastKey = K3
Page 2: 从 K3 之后继续 → K4, K5, K6

Cursor 分页保存上一页最后一个 Key,下一页从其后继续,而并发写入下稳定性仍取决于 Snapshot

Key Cursor 避免从头跳过大量记录,但不会自动冻结并发变化。

由于 seek(lastKey) 的语义是 >=,恢复时可以:

  1. Seek 到 lastKey;
  2. 如果当前 Key 与 lastKey 相等,先 Next;
  3. 再读取下一页;
  4. 同时保留原始范围上界与页大小。

如果 lastKey 已被删除,Seek 可能直接落到后续 Key,不需要额外 Next。比较“是否相等”必须使用与 Key Codec/Comparator 一致的逻辑。

Cursor 为什么不自动提供稳定分页?

Page 1 与 Page 2 之间可能发生:

  • 在 K2 与 K3 之间插入新 Key;
  • 删除 K4;
  • 更新记录使二级索引 Key 改变;
  • Compaction 重写物理文件。

Cursor 只告诉系统从什么 Key 继续,不固定整个结果集合。若业务要求分页期间看到同一个一致视图,需要使用合适 Snapshot,并控制 Snapshot 生命周期;长 Snapshot 会保留旧版本,增加空间与 Compaction 压力。

Cursor 自身也应包含 Schema 版本、查询维度与上界校验,不能把裸 lastKey 无期限复用于已经升级的 Key 格式。

一个可公开的 Cursor 至少包含什么?

如果 Cursor 要返回给 HTTP 客户端,它通常不应只是 Base64(lastKey)。至少要绑定:

  • Key Schema / API 版本;
  • 原查询的 tenant、status 与范围上界;
  • 排序方向和页大小上限;
  • 可选的 Snapshot 或一致性标识;
  • 过期时间;
  • 防篡改签名或服务端状态 id。

否则,客户端可能把 A 租户 Cursor 用到 B 租户查询,修改 lastKey 越过授权边界,或在 Schema 升级后得到不可解释的排序结果。解码 Cursor 后仍要重新做权限与范围校验,不能因为它由服务端生成过,就永远信任其中字节。

如果使用服务端持有 Snapshot 的 Cursor,还要处理客户端不再翻页时的资源释放。设置过期时间、最大活跃 Cursor 数和清理机制,比单纯延长 Snapshot 生命周期更安全。

Comparator 与编码为什么是持久格式契约?

SST、MemTable、Index 和 Compaction 都依赖同一个 Key 顺序。如果已有数据使用 Comparator A 排序,却用 Comparator B 重新打开并解释,文件内部顺序与查询假设会冲突。

因此:

  • 自定义 Comparator 应有稳定名称与稳定行为;
  • 升级不能悄悄改变比较结果;
  • 编码修改应采用新命名空间、版本字段或全量迁移;
  • Rollback 必须知道新旧二进制格式怎样互读;
  • 备份恢复环境必须使用兼容 Comparator 与 Codec。

Value Schema 变更通常还能在读取后按版本解码;Key Schema 变更更危险,因为它会改变文件排序、Seek 位置与范围边界。

如果确实要迁移,常见安全路线是建立新 Column Family 或新数据库格式,双写或离线重编码,校验数量与顺序后再切换,而不是在原数据目录上直接更换 Comparator。

一个查询需要第二种顺序时怎么办?

假设主记录使用:

order-by-id : tenant : orderId

它适合按 id 点查,却不支持“按 status + time 扫描”。可以额外维护:

order-by-status-time : tenant : status : time : orderId

二级索引的 Value 可以为空、保存主 Key,或保存少量覆盖字段。选择取决于读取是否允许再做一次主记录 Get,以及怎样处理主记录更新。

真正困难的不是写出第二个 Key,而是保持一致:创建订单时两份记录都要出现;状态变化时旧索引 Key 要删除、新索引 Key 要插入;删除主记录时索引也要清理;恢复和重试不能留下半完成状态。

在同一 RocksDB 实例与合适边界内,可以使用 WriteBatch 把主记录与索引变更放进一次原子写批次。但这仍要求应用准确知道旧 status/time,正确构造旧索引 Key,并处理幂等与批次大小。跨数据库或跨外部系统时,WriteBatch 不能自动提供分布式原子性。

二级索引还会增加:

  • 每次业务写入的操作数与 WAL 字节;
  • MemTable 和 SST 空间;
  • Flush 与 Compaction 工作;
  • 迁移、校验与恢复复杂度;
  • 读取主记录时的一次间接跳转。

所以,遇到新查询时不要本能地把所有字段都塞进主 Key,也不要无成本地增加索引。先确认查询频率、延迟目标、更新比例与一致性要求,再选择主顺序和辅助顺序。

索引校验怎样做?

可以定期或离线做双向校验:从主记录推导期望索引并确认存在,再从索引解析主 Key 并确认主记录及索引字段仍匹配。校验任务也必须使用明确读视图,否则并发更新会制造短暂假差异。

发现孤儿索引时,修复流程要可幂等、可限速并有审计,不应直接在生产全库无界扫描。Seata 实践篇会把这套主记录与索引原子维护模型落到具体 Column Family 和 Key Schema。

用访问模式表演练一次 Schema

在写代码前,可以先列一张表:

查询 已知字段 结果顺序 最大结果量 一致视图
按订单 id 点查 tenant、id 1 当前
扫描 PAID 订单 tenant、status time、id 每页 100 可选 Snapshot
查询时间区间 tenant、status、start/end time、id 有上限 当前或 Snapshot
删除租户数据 tenant Key 顺序 大范围 运维任务

然后为每个候选 Key Schema 手工构造十几条包含边界的数据:最短与最长 id、相同时间、跨月、空状态、分隔符、高位字节、非法输入。把编码后的十六进制字节真正排序,确认连续范围与预期一致。

接着模拟 Seek:目标存在、目标缺失、目标小于第一条、大于最后一条;模拟 Prefix Scan:空结果、一条结果、恰好到上界、全 0xFF 前缀;模拟 Cursor:lastKey 仍在、已删除、两页之间插入新记录。

最后再用 RocksDB 小数据目录做往返实验,重启数据库后重复 Get、Seek 与 Iterator,并用旧版本 Codec 读取新旧混合样本。这个过程比只写几个字符串单元测试更接近真实持久格式。

Schema 评审的交付物不应只有一段 Key 示例,还应包括字段表、编码规范、比较性质、查询范围、迁移版本、Cursor 格式和测试向量。未来任何语言重写或数据恢复工具都能据此实现同一排序契约。

上线前做一次 Key Schema 六问

Key Schema 从点查、前缀、范围、字节序、可逆编码和版本兼容六方面评审,不满足时应重做设计

Key 写入磁盘前做一次查询反推评审,比上线后重编码整库便宜得多。

一问:点查怎样构造完整 Key?

调用方是否拥有所有必需字段?如果查订单只知道 id,而 Key 还要求 status,就需要另一个索引。

二问:前缀查询是否连续?

所有目标记录是否拥有同一左侧前缀?前缀结束后会不会混入其他租户或状态?

三问:范围上下界怎样计算?

是否使用半开区间?最大值、空值、全 0xFF 前缀、时区与倒序如何处理?

四问:字节序是否等于业务序?

数字、时间、有符号值、Unicode 与自定义 Comparator 是否有属性测试证明?

五问:编码能否无歧义反解?

分隔符、长度、转义、非法数据和版本字段是否覆盖?错误输入会拒绝还是产生碰撞?

六问:怎样升级、回滚与迁移?

新代码能否读旧 Key?旧代码是否会误解新 Key?索引重建和校验怎样完成?

六问通过后,还要评估额外索引的写放大、空间与原子维护成本。“查询快”不等于“更新时永远一致”。

六个常见误区

误区一:人类可读的 Key 自然按业务顺序排列

默认比较的是字节。变长数字、Unicode 与二进制字段都可能产生意外顺序。

误区二:Seek 等于精确命中

Seek 定位第一个大于等于 target 的 Key。目标不存在时可能落到下一个 Key,必须检查实际结果。

误区三:Seek(prefix) 后循环到 Iterator 失效就是 Prefix Scan

没有前缀变化或 upper bound 停止条件,它会一直扫到数据库尾部。

误区四:Prefix Extractor 会自动保证业务扫描边界

它是优化配置,语义仍由 Key Schema 与循环边界定义。

误区五:Big Endian 能直接解决所有数值类型

固定宽度无符号整数可以用它保序;有符号数、浮点数和倒序需要额外可逆变换。

误区六:Cursor 分页天然稳定

Cursor 只保存继续位置。并发变化下的一致结果需要 Snapshot 或更高层协议。

把整篇压缩成八条规则

  1. Key 是字节序列,顺序由 Comparator 决定;
  2. 从真实点查、前缀和范围查询反推字段顺序;
  3. 文本、数字、时间和复合字段都要使用可证明的保序编码;
  4. Seek 只定位第一个 >= target 的起点;
  5. Iterator 每次访问前检查 isValid(),结束后关闭 native 资源;
  6. Prefix/Range Scan 同时定义 start 与 exclusive end;
  7. Cursor 保存位置,Snapshot 才固定视图;
  8. Comparator 与 Key Codec 一旦写盘,就是长期兼容契约。

一句话总结:

RocksDB 的有序性不会自动变成业务查询能力;只有把业务字段编码成正确、连续、可演进的字节顺序,Seek 与 Iterator 才能安全工作。

下一篇 B06《Update 与 Delete:为什么旧数据不会立即消失》,我们会回到 Alice、Bob 与 Tombstone:普通 Get 为什么只看见最新结果,旧 Snapshot 为什么还能读旧值,Compaction 满足哪些条件后才可以安全回收版本和空间。

参考资料