把下面三个 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 时:
- 从第一个字节开始;
- 第一个不同字节决定大小;
- 如果一方是另一方的完整前缀,较短的一方更小;
- 所有 Key 必须能得到稳定、传递且一致的顺序。
所以:
user:1 < user:10 < user:2
不是 RocksDB 排错了,而是应用没有把“数值顺序”编码进 Key。
人类可读的数字字符串不自动拥有数值排序语义。
如果业务明确知道数字最大两位,可以编码成:
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 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
它们包含相同字段,但物理顺序完全不同。
复合 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 字段,还是 team 与 a 两段?简单 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 是 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();
}
}
这个循环有四个不可省略的边界:
seek(prefix)只定位起点;isValid()必须在读取 Key/Value 前检查;startsWith在前缀变化时停止;RocksIterator与ReadOptions都要关闭。
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 循环不是前缀扫描,而是继续扫向数据库尾部。
对 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?
不能对所有前缀都简单拼接一个字符。通用算法是:
- 从前缀最后一个字节向前找;
- 找到第一个不等于
0xFF的字节; - 将它加一;
- 丢弃它后面的所有字节;
- 得到最小的 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。
应按这个顺序思考:
- 先用 Comparator、start、end 或
startsWith定义正确范围; - 再确认 Prefix Extractor 与 Key Schema 一致;
- 再通过统计验证 Prefix Bloom 是否减少无效读取;
- 最后评估内存与构建成本。
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
Key Cursor 避免从头跳过大量记录,但不会自动冻结并发变化。
由于 seek(lastKey) 的语义是 >=,恢复时可以:
- Seek 到 lastKey;
- 如果当前 Key 与 lastKey 相等,先 Next;
- 再读取下一页;
- 同时保留原始范围上界与页大小。
如果 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 写入磁盘前做一次查询反推评审,比上线后重编码整库便宜得多。
一问:点查怎样构造完整 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 或更高层协议。
把整篇压缩成八条规则
- Key 是字节序列,顺序由 Comparator 决定;
- 从真实点查、前缀和范围查询反推字段顺序;
- 文本、数字、时间和复合字段都要使用可证明的保序编码;
- Seek 只定位第一个
>= target的起点; - Iterator 每次访问前检查
isValid(),结束后关闭 native 资源; - Prefix/Range Scan 同时定义 start 与 exclusive end;
- Cursor 保存位置,Snapshot 才固定视图;
- Comparator 与 Key Codec 一旦写盘,就是长期兼容契约。
一句话总结:
RocksDB 的有序性不会自动变成业务查询能力;只有把业务字段编码成正确、连续、可演进的字节顺序,Seek 与 Iterator 才能安全工作。
下一篇 B06《Update 与 Delete:为什么旧数据不会立即消失》,我们会回到 Alice、Bob 与 Tombstone:普通 Get 为什么只看见最新结果,旧 Snapshot 为什么还能读旧值,Compaction 满足哪些条件后才可以安全回收版本和空间。
