如果你习惯了 std::unordered_map 或 Redis 这种把数据散列落桶的思维,初次接触工业级嵌入式 KV 引擎(如 RocksDB / LevelDB)时,大概率会被它的“越权”行为打个措手不及:你明明从未指定过任何排序规则,它却固执地重排了你的每一个字节。
写进 1 到 12 的整数字符串,读出来却变成了字典序;如果你直接把 uint64_t 的内存按 Little-Endian reinterpret_cast 扔进 Slice,原本单调递增的数值序列更会在跨字节翻转处被彻底搞碎。
复现这个“不请自来”的排序,只需要几行纯粹的 C++:
C++
std::unique_ptr<DB> db;
Options options;
options.create_if_missing = true;
Status s = DB::Open(options, "/tmp/kv", &db);
for (int i = 1; i <= 12; ++i) {
// 陷阱就藏在 std::to_string(i) 构造出的 Slice 视图与默认三路 Comparator 中
db->Put(WriteOptions(), std::to_string(i), "v");
}
std::unique_ptr<Iterator> it(db->NewIterator(ReadOptions()));
for (it->SeekToFirst(); it->Valid(); it->Next()) {
std::printf("%s ", it->key().ToString().c_str());
}
// 输出:1 10 11 12 2 3 4 5 6 7 8 9
经验丰富的 C++ 工程师一眼就能看穿底层是 memcmp 字节序在作祟——'1' (0x31) 天然排在 '2' (0x32) 前面。但真正值得深思的问题不是现象本身,而是架构哲学:为什么这类引擎哪怕牺牲写入灵活性,也绝不提供任何“无序”选项?
如果你正在或计划自研一个高性能嵌入式存储引擎,搞懂这套接口模型与背后的 C契约设计,就是你必须跨过的第一道分水岭。这篇就顺着上面的代码向下拆解,逐层剥开现代 C 存储引擎在接口与底层模型设计上的硬核取舍。
1. 先把它当哈希表用,直到范围扫描把你拦下来
撞见顺序之前,先把它当最朴素的东西使:一个能落盘的 map。你脑子里那个 std::unordered_map<string, string> 的模型,大半是对的。写一个键:
C++
// db.h:Set the database entry for "key" to "value".
// If "key" already exists, it will be overwritten.
virtual Status Put(const WriteOptions& options,
ColumnFamilyHandle* column_family,
const Slice& key, const Slice& value) = 0;
注释第二行就是 map 的赋值语义:key 已存在就覆盖。所以“更新”在接口层根本没有独立的动词,它就是再 Put 一次同一个 key。这一点先记住,第 5 节我会告诉你它在底下其实没覆盖任何东西——只是压了一层新版本上去,但那是后话。
取值这一侧,注意它的返回约定:
C++
// db.h:Returns OK on success. Returns NotFound and an empty
// value in "*value" if there is no entry for "key". Returns
// some other non-OK status on error.
virtual Status Get(const ReadOptions& options,
ColumnFamilyHandle* column_family, const Slice& key,
PinnableSlice* value, std::string* timestamp) = 0;
“查不到”会给你一个正常的 Status::NotFound() 返回值,从不抛异常。这跟 std::map::find 返回 end() 是一个哲学——找不到是查询的合法结果之一,不是错误。你现在先把这当风格记下,第 7 节我会告诉你,这台引擎从头到尾不用 C++ 异常,是一个刻进整套 API 的硬决定,Get 只是其中最不起眼的一处。
到这里你手里有了 Put/Get/Delete 三个动作,心智模型是“一个落盘的 map”,够用。直到你写下这样一个需求:
把 user:1000 到 user:2000 之间的所有记录拉出来。
std::unordered_map 干不了这件事。哈希表把 key 打散到桶里,user:1000 和 user:1001 大概率隔着半张表,它没有“下一个 key”的概念。可开头那段代码里,it->Next() 明明就能一个接一个走下去,而且走出来是有序的。它凭什么能给你区间? 这个“凭什么”,就是这台引擎和一张哈希表的分水岭,答案在第 3 节。但在回答它之前,我得先带你看写操作——因为顺序这件事,第一次在接口上露头是在删除里。
2. 删除只是记一笔,更新只是压一层
先看最普通的删除:
C++
// db.h:Remove the database entry (if any) for "key".
// It is not an error if "key" did not exist in the database.
virtual Status Delete(const WriteOptions& options,
ColumnFamilyHandle* column_family,
const Slice& key) = 0;
“删一个不存在的 key 不算错”——又一次,接口在告诉你它对“错误”的定义很克制。删除是幂等的,你删十遍和删一遍,结果和返回都一样。这跟你自研引擎时的直觉可能冲突:很多人会让 Delete 在 key 不存在时返回一个“没删到”的信号,然后调用方到处判这个信号。这台引擎的选择是——不给你这个信号,因为在一个 LSM 结构里,“这个 key 现在到底存不存在”本身就是一次查询的成本,Delete 不想替你付这个成本。
真正会咬你的是它旁边那个兄弟,SingleDelete。先看一段会出事的用法,我们让它当场坏掉:
C++
db->Put(WriteOptions(), "k", "v1");
db->Put(WriteOptions(), "k", "v2"); // 覆盖,写了第二遍
db->SingleDelete(WriteOptions(), "k");
std::string val;
Status s = db->Get(ReadOptions(), "k", &val);
// 你以为 s.IsNotFound()。但这里的行为是【未定义】的:
// val 可能是 "v1",可能 NotFound,取决于底层压缩到哪一步了。
我第一次用 SingleDelete 就栽在这上面。当时想当然把它当成“更快的 Delete”,在一条本该幂等的写路径上混着用,灰度上线三天后有一批本该消失的 key 又诈尸回来了,排查到最后是压缩把两条写记录的抵消顺序搞乱了。原因头文件里写得清清楚楚,是我没读:
C++
// db.h:If a key is overwritten (by calling Put() multiple times),
// then the result of calling SingleDelete() on this key is undefined.
// SingleDelete() only behaves correctly if there has been only one
// Put() for this key since the previous call to SingleDelete().
SingleDelete 的契约是:从上一次 SingleDelete 到现在,这个 key 只被 Put 过恰好一次。 违反了就是未定义行为。它为什么这么苛刻?因为它是一个针对特定写负载的性能优化——普通 Delete 会留下一条“墓碑”记录,得等压缩时和被删的那条真值相遇才能双双消掉;SingleDelete 承诺“我保证只有一条真值”,于是墓碑可以用更便宜的方式抵消。你拿走了性能,就得替它守住那个前提。切记,切记:一个 key 的写路径上只要有可能 Put 两次,就永远别对它用 SingleDelete。 这是自研引擎最容易抄错的一类接口——把别人为特定前提设计的快路径,当成通用路径乱铺。
写操作还有一个你迟早要提供的能力:把多个写打成一个原子包。看这段——一次删一个 key、加两个 key,要么全成、要么全不成:
C++
WriteBatch batch;
batch.Put(handles[0], Slice("key2"), Slice("value2"));
batch.Put(handles[1], Slice("key3"), Slice("value3"));
batch.Delete(handles[0], Slice("key"));
Status s = db->Write(WriteOptions(), &batch);
WriteBatch 把若干个 Put/Delete 攒成一批,Write 一次性原子落盘。有意思的是,单个 Put 其实也是走 Write 的——它就是一个只装了一条操作的 batch,接口层根本没有第二条写路径。原子性从哪来?从那条预写日志(WAL):整批操作先作为一条记录顺序追加进日志,日志落盘成功这批才算数;中途崩了,重启时要么整条日志还在、整批重放,要么整条没写全、整批当没发生过。你自研引擎的原子性地基,几乎一定是这条“先顺序写日志、再改内存结构”的路子——而顺序写日志又是一次对磁盘极友好的顺序 IO,和有序结构那股“把随机掰成顺序”的气质一脉相承。
现在看顺序第一次露头的地方,区间删除:
C++
// db.h:Removes the database entries in the range
// ["begin_key", "end_key"), i.e., including "begin_key" and
// excluding "end_key".
// If "end_key" comes before "start_key" according to the user's
// comparator, a `Status::InvalidArgument` is returned.
virtual Status DeleteRange(const WriteOptions& options,
ColumnFamilyHandle* column_family,
const Slice& begin_key, const Slice& end_key);
两个细节值得停一下。一是半开区间 [begin, end),含左不含右——这是所有区间接口的老规矩,和 STL 迭代器一个约定,好处是相邻区间能无缝拼接,[a,b) 加 [b,c) 正好等于 [a,c),不重不漏。二是那句 if "end_key" comes before "start_key" according to the user's comparator——“according to the user's comparator”。到这里,你已经躲不开那个问题了:这台引擎判断谁在前谁在后,靠的是一个叫 comparator 的东西,而且是“user's”,可以由你换。区间删除只是第一个把它逼到台面上的接口,接下来每一个跟“范围”“顺序”“前缀”沾边的功能,底下都站着它。
3. 顺序是谁定的:三路比较函数就是这台引擎的“物理定律”
回到开头那个谜:1 10 11 12 2。现在把定义顺序的那个对象请出来。它在 comparator.h 里,接口小得可怜:
C++
// comparator.h
class CompareInterface {
public:
// Three-way comparison. Returns value:
// < 0 iff "a" < "b",
// == 0 iff "a" == "b",
// > 0 iff "a" > "b"
virtual int Compare(const Slice& a, const Slice& b) const = 0;
};
一个三路比较函数,返回负 / 零 / 正。就这么一个纯虚函数,定义了整台引擎里“顺序”这个词的全部含义。它上面那层 Comparator 类的注释,把话说到了头:
C++
// comparator.h:A Comparator object provides a total order across
// slices that are used as keys in an sstable or a database.
全序(total order)。 任意两个 key,要么相等,要么一个严格在另一个前面,没有“无法比较”这种情况。你自研引擎时如果打算支持范围扫描,这四个字就是你必须先交出的东西:数学意义上的全序,任意两个 key 都能比出先后,否则底层的有序结构会在某两个 key 上直接崩掉。
那默认那套顺序是哪来的?comparator.h 底部:
C++
// comparator.h:Return a builtin comparator that uses lexicographic
// ordering on unsigned bytes, so the empty string is ordered before
// everything else and a sufficiently long string of \\xFF orders
// after anything.
const Comparator* BytewiseComparator();
按无符号字节做字典序。 现在开头那个现象一秒就通了:"10" 和 "2" 是两串字节,逐字节比,'1'(0x31) 撞 '2'(0x32),前者小,所以 "12" < "2"。它压根不知道你心里那是整数,它眼里只有字节。
这引出自研引擎里最经典的一个坑,我们让它当场坏给你看。假设你嫌字符串 key 占地方,改成把原生 int 直接拷进 key:
C++
int k1 = 1, k2 = 256;
db->Put(WriteOptions(),
Slice(reinterpret_cast<char*>(&k1), sizeof(int)), "a");
db->Put(WriteOptions(),
Slice(reinterpret_cast<char*>(&k2), sizeof(int)), "b");
// x86 是小端:
// 1 在内存里是 01 00 00 00
// 256 在内存里是 00 01 00 00
// 按字节比:256 的首字节 0x00 < 1 的首字节 0x01
// 于是范围扫描里,256 排在了 1 前面。
在小端机器上,int 的低字节在前,256 的字节序列 00 01 00 00 首字节是 0,比 1 的 01 00 00 00 还小,于是 256 被排到了 1 前面——你的数值顺序被字节序彻底打乱。修法只有一个:key 里的整数必须按大端编码再存,让高位字节落在前面,字节字典序才和数值顺序对齐。这是每一个用有序 KV 存整数主键的人都必须过的一道坎,过不去,你的范围扫描就是错的,而且错得悄无声息——没有报错,只有顺序不对。面试时如果有人让你用有序 KV 存一张按时间排序的表,第一句就该问“时间戳怎么编码”,答不出大端,这道题基本就废了。
比“顺序错了”更狠的,是“顺序变了却没人告诉你”。看 Comparator 的 Name():
C++
// comparator.h:The name of the comparator. Used to check for
// comparator mismatches (i.e., a DB created with one comparator is
// accessed using a different comparator).
// The client of this package should switch to a new name whenever
// the comparator implementation changes in a way that will cause the
// relative ordering of any two keys to change.
const char* Name() const override = 0;
比较器的名字会被写进库里持久化。下次开库,引擎拿当前比较器的名字和库里存的那个核对,对不上就拒绝打开。为什么要这么防着你?因为顺序不是查询时才用一下的运行期参数,它刻进了磁盘上每一个有序数据块的物理布局。整台引擎的磁盘结构、索引块、二分查找,全都建立在“库里的 key 是按某个固定顺序码好的”这个前提上。你今天用大端顺序码了一百万条数据,明天换个比较器改成小端,那一百万条的物理排列瞬间全错——二分查找会指向错误的块,扫描会漏掉本该在区间里的 key。所以引擎的态度是:换比较器等于换了一个数据库,它宁可拒绝开库也不让你在一个顺序上写、另一个顺序上读。
这就是我说“三路比较函数是这台引擎的物理定律”的意思。它是一条一旦选定、写下第一个 key 就冻结的不变量,运行期动它不得。你自研引擎时对它的唯一正确态度是:开工第一件事就定死比较函数,并且给它一个版本化的名字,往后但凡改动了任意两个 key 的相对顺序,就换名字、就当成一个新库来迁移。 顺序即身份。
比较器还有两个不起眼的方法,顺带点破,它们暴露了顺序渗透得有多深:
C++
// comparator.h:Advanced functions: these are used to reduce the
// space requirements for internal data structures like index blocks.
virtual void FindShortestSeparator(std::string* start,
const Slice& limit) const = 0;
virtual void FindShortSuccessor(std::string* key) const = 0;
引擎会拿比较器去找“两个 key 之间一个尽量短的分隔串”,用它当索引块的边界,省空间。也就是说,比较器不只在你查询时被调用,它还在后台参与塑造索引的物理形态。这条线你现在有个印象就行——它说明的是同一件事:顺序这个东西,从你的 key 编码,一路渗到最底层的磁盘结构里,没有一层能绕开它。
顺序还有一层你现在未必用得上、却决定了“有版本”怎么落地的东西——时间戳。回头看 Compare 的完整注释,它多说了一句:
C++
// comparator.h:Note that Compare(a, b) also compares timestamp if
// timestamp size is non-zero. For the same user key with different
// timestamps, larger (newer) timestamp comes first.
同一个用户 key,带不同时间戳时,更新的那个排在前面。这句话把“顺序”和“版本”焊在了一起:比较器不只裁定 key 与 key 之间谁先谁后,还裁定同一个 key 的多个版本里谁先谁后——而“新版本排在前”正是范围扫描默认读到最新值的底层原因。这也是为什么内置的带时间戳比较器要专门规定“全零字节是最小(最旧)时间戳、全一字节是最大(最新)”:版本之间的先后,也得是一个你能在字节层面算得清的全序。你自研引擎要支持多版本读,这条绕不开——版本号是排序键的一部分,不是挂在 value 上的一块元数据。第 10 节我会带你看这个版本号怎么变成快照、怎么决定一条旧数据能不能被回收。
4. 有序 Map 和无序 Hash,分叉的是整台引擎
现在正面回答第 1 节留下的“它凭什么能给你区间”。把有序结构和哈希表并排放,喂同一个操作,看它们在哪一步分叉。
点查这一关,两者打平,甚至哈希还赢一点。哈希表理论上是均摊 O(1),有序结构是 O(log n) 的二分。差距在范围扫描:
|
点查 user:1500 |
算哈希定桶,O(1) 很快 |
二分定位,O(log n) |
|
扫 [user:1000, user:2000) |
相邻 key 散落各处,只能全表扫一遍 O(n) |
定位起点 O(log n),再顺着走 O(k)(k 为命中条数) |
哈希表做范围扫描要退化成全表扫描加过滤,因为它主动把 key 的邻接关系打散了——这正是它 O(1) 点查的代价来源。有序结构反过来,用 O(log n) 的点查,换来了“key 在物理上就是挨着排的”,于是范围扫描能先二分定位到起点,再顺着往下走。承接这个能力的,就是开头那个 Iterator。看它的游标接口,在 iterator_base.h 里:
C++
// iterator_base.h
// Position at the first key in the source that at or past target.
virtual void Seek(const Slice& target) = 0;
// Moves to the next entry in the source. REQUIRES: Valid()
virtual void Next() = 0;
virtual bool Valid() const = 0;
virtual Slice key() const = 0;
Seek(target) 的语义是“停在第一个大于等于 target 的 key 上”。这个“大于等于”,用的就是第 3 节那个比较器。所以范围扫描 ["user:1000","user:2000") 落到接口上,就是 Seek("user:1000") 定位起点,然后 Next() 一路走,每步用比较器判断有没有越过 "user:2000" 这个右界。有序性在这里兑现成了真金白银的能力:一次二分加一段顺序读,而不是扫全库。
这个分叉不止停在查询接口上,它一路向下决定了整台引擎的形态,这才是我要你记住的重点:
-
磁盘 IO 形态。 有序意味着相邻 key 大概率落在同一个磁盘块、同一次预读里。范围扫描变成一段接近顺序读的 IO,这对机械盘是数量级的差异,对 SSD 也省下大量随机读。哈希布局做区间只能满盘乱跳,随机 IO 打满。你自研嵌入式引擎、又跑在闪存上,这一条几乎单独就能决定你选有序。
-
缓存策略。 有序结构的数据块天然带局部性,缓存一个块,块里一批相邻 key 全跟着热了,范围扫描的缓存命中率很高。哈希表缓存一个桶,桶里的 key 在逻辑上毫无关联,缓存价值低得多。
-
后台整理。 有序结构(典型就是 LSM)把新写入攒在内存,排好序刷成一个个有序文件,再靠后台压缩把多个有序文件归并成更大的有序文件——整个机制的地基就是“归并两个有序序列很便宜”。哈希布局没有这个便利。
把这三条再往下压一层,你会看到有序结构在磁盘上的真实形态,也会看到它为点查付的那份代价其实有救——这一层正是你要设计的磁盘 IO 与缓存策略。
有序数据在盘上的形态,是切成一个个定长数据块(典型 4KB 到 64KB 一块),块内 key 有序,块与块之间也有序,块上面再压一层稀疏索引,只记“每个块的第一个 key”。一次点查是先在稀疏索引上二分定位到块,再把整块读进内存、在块内二分。这个设计对磁盘极其友好:磁盘的最小 IO 单位本就是块,你要的那个 key 和它相邻的几十上百个 key 一次性进了内存;范围扫描接着往下走时,下一个 key 大概率就在这块里,连新的 IO 都省了。这就是第 4 节开头那张表里“顺序读 O(k)”的物理来源——k 个命中里,绝大多数不产生新的磁盘 IO。
缓存策略也顺着块走。读缓存缓存的是数据块,不是单个 key。缓存一个块,块里一批相邻 key 全跟着热了,这对范围扫描是巨大的红利——一次区间查询命中的往往就是同几个块。你自研引擎设计缓存时,块粒度几乎是唯一合理的选择:太细(缓存单 key)元数据开销压垮你,太粗(缓存整个文件)命中率上不去,块粒度正好卡在“磁盘 IO 单位”和“访问局部性”的交点上。
那点查慢一个对数因子这笔开销怎么找补?靠布隆过滤器。有序结构最怕的点查场景是“查一个根本不存在的 key”——它得一路二分到底才能确认没有,白读一堆块。布隆过滤器给每个数据块配一个很小的位图摘要,点查前先问它“这个 key 可能在这块里吗”,答“不可能”就直接跳过整块的磁盘读。它有假阳性(说“可能”其实没有),但没有假阴性(说“没有”就一定没有),于是它把“查不存在的 key”这个最贵场景的磁盘 IO 干到接近零。这就是有序结构追平哈希点查的那张牌,代价是每个块多存一个摘要、多占一点内存。
代价那一头也得算清,这是自研时最容易低估的——写放大。有序结构的后台压缩要反复把小的有序文件归并成大的,同一条数据在它生命周期里可能被搬动好几次,实际写盘量是逻辑写入量的若干倍(做到个位数倍已经算调得不错)。范围扫描那份顺序读红利,是用写入侧这笔额外 IO 换来的。点查为主、极少扫描、写多读少的负载,这笔投入可能划不来,那时哈希布局才是更诚实的选择。
所以有序和无序的选择,从来不是“要不要范围扫描”这一个查询功能的取舍,它是磁盘布局、IO 形态、缓存局部性、后台整理这一整条链路的分叉。嵌入式 KV 引擎绝大多数选有序,根子在磁盘和缓存:一旦你要面对它们,有序能把随机访问尽量掰成顺序访问——而这是磁盘时代最值钱的一件事。代价也得说清楚:有序结构的写入要维护顺序,点查比哈希多一个对数因子,后台压缩会吃额外的 IO 和 CPU(所谓写放大)。纯 KV 缓存、只点查、永不扫描的场景,哈希布局是更诚实的选择。没有免费的有序。
5. “持久化、有版本的有序 Map”这九个字,逐个兑现
第一部分到这里可以收口了。回到那句定义,现在我们把它九个字逐个兑现:
C++
// db.h
// A DB is a persistent, versioned ordered map from keys to values.
// A DB is safe for concurrent access from multiple threads without
// any external synchronization.
class DB {
-
Map from keys to values——第 1 节。就是个 key 到 value 的映射,Put/Get/Delete,你脑子里那个 map 心智是对的。
-
Ordered——第 3、4 节。这个 map 的键空间上有一个由比较器定义的全序,于是它能范围扫描,于是它的磁盘和缓存全为顺序访问服务。这是它和 std::unordered_map 的分水岭。
-
Persistent——跨进程活着,落在磁盘上,进程重启数据还在。这也是它必须对付磁盘 IO 和缓存的根本原因:内存里的 map 不需要考虑这些,一个要落盘的 map 必须考虑。
-
Versioned——这个词第 1、2 节埋了两次伏笔。还记得吗,我说过“更新其实没覆盖任何东西”“删除留下墓碑”。真相是:这个 map 真正存的是“key + 版本号 → value”。每一次 Put、每一次 Delete,都是往这个 key 上叠一个带更新版本号的新记录,旧版本还在底下压着。
最后那句“safe for concurrent access … without any external synchronization”也别放过:这个 map 自带并发安全,多线程直接读写不用你在外面加锁。你自研引擎时,这是一个昂贵的承诺——它意味着内部所有的顺序结构、缓存、后台整理都得自己扛住并发,这笔复杂度是省不掉的。这条承诺还反过来收紧了前面每一层:比较器必须线程安全(comparator.h 明说它会被多个读线程同时调用),缓存得用并发安全的淘汰结构,后台压缩要和前台读写抢同一批数据还不许互相踩。你能在接口上写下“不用外部加锁”这一句,背后是把并发的全部复杂度咽进了实现里、一点没漏给调用方。
“有版本”这个词,是第一部分通往后面两部分的暗门。它一头连着“错误与接口分层”——因为多版本、并发、后台压缩这些复杂度,必须被一层稳定的接口包住,不能漏给你;另一头连着“对象生命周期”——因为有了版本,才会有“这条旧数据还存在,但你已经看不见它了”这种诡异状态,才会有快照,才会有关库时那个能把你拦住的未释放引用。接下来两部分,就是拆这两扇门。
6. 一个纯虚基类,一个巨型实现,接口和实现就这么分了家
第一部分你一直在用 DB 这个类型,Put、Get、NewIterator 都是它的方法。现在把它的真身翻出来——它是个纯虚基类,一行实现都没有。db.h 的类注释把这层关系交代得很干脆:
C++
// db.h
// A DB is an abstract base class with one primary implementation
// (DBImpl) and a number of wrapper implementations.
class DB {
public:
virtual Status Put(const WriteOptions&, ColumnFamilyHandle*,
const Slice& key, const Slice& value) = 0;
virtual Status Get(const ReadOptions&, ColumnFamilyHandle*,
const Slice& key, PinnableSlice* value,
std::string* timestamp) = 0;
// … 一整套 = 0 的纯虚函数
};
= 0 的纯虚函数,从头排到尾。真正干活的实现在另一个目录、另一个类里,db_impl.h:
C++
// db_impl.h
// Since it's a very large class, the definition of the functions is
// divided in several db_impl_*.cc files, besides db_impl.cc.
class DBImpl : public DB {
public:
// —- Implementations of the DB interface —-
Status Put(const WriteOptions& options, ColumnFamilyHandle* column_family,
const Slice& key, const Slice& value) override;
Status Delete(const WriteOptions& options, ColumnFamilyHandle* column_family,
const Slice& key) override;
// … 把 DB 的每个纯虚函数 override 一遍
};
注释说得很实在——“因为这个类实在太大,它的函数定义被拆到好几个 db_impl_*.cc 文件里”。这就是接口和实现分家的意义:DB 是一张薄薄的契约,几百行纯虚声明;DBImpl 是那台真正处理内存表、预写日志、后台压缩、并发控制的庞然大物,散在十几个源文件里。你回头看第 1 节的 DB::Open:
C++
// db.h
static Status Open(const Options& options, const std::string& name,
std::unique_ptr<DB>* dbptr);
它交还给你的是 unique_ptr<DB>——接口类型的智能指针。你从头到尾摸到的都是那张薄契约 DB,DBImpl 这个名字你在自己代码里根本不需要出现。这就是“稳定公共 API”和“内部实现”的物理边界:底下那台庞然大物随版本怎么重构、拆几个文件、换什么数据结构,只要 DB 这张纯虚契约不变,你的代码一行都不用改。你自研引擎时如果只学一条 API 设计原则,就学这条——对外交出一个纯虚接口和一个工厂函数(这里是 Open),把实现类彻底焊死在你的 .cc 文件里,永远不进公共头。
这层分家还顺手买到一样你现在可能没在意、发布库时却会救命的东西——ABI 稳定。DB 是纯虚基类,你的代码通过它的虚表调 Put/Get,编译期你只依赖“这个方法排在虚表第几个”这个偏移,不依赖 DBImpl 里有哪些成员、怎么排布。于是底层实现加个字段、换个数据结构、重排私有成员,你的二进制一行都不用重编,只要虚表的形状不变。这正是以 .so / .dll 发布的库梦寐以求的性质:接口与实现在二进制层面解耦。代价是每次调用多一跳虚表间接寻址,但对一个动辄要读盘的操作,这一跳的开销小到可以忽略。你自研引擎若要给下游发二进制,“纯虚接口 + 工厂函数”几乎是唯一扛得住版本演进的形状——把实现塞进公共头的那一刻,你就把自己焊死在了一个再也改不动的内存布局上。
分层还有更细的一手。看 Get 这一族在 db.h 里的样子,它不止一个:
C++
// db.h:真正要被实现的,只有这一个纯虚版本
virtual Status Get(const ReadOptions&, ColumnFamilyHandle*, const Slice& key,
PinnableSlice* value, std::string* timestamp) = 0;
// 其余全是 final 的便利重载,收敛到上面那个
// NOTE: virtual final => disallow override (was previously allowed)
virtual Status Get(const ReadOptions& options, const Slice& key,
std::string* value) final {
return Get(options, DefaultColumnFamily(), key, value);
}
一个 Get 家族,只有那个带 PinnableSlice* 和 timestamp* 的版本是纯虚的——它是唯一的实现点。其余你天天用的那些“省事版”(省掉列族参数、value 直接给 std::string)全是 final,谁都不许再 override,它们只做一件事:补上默认参数,把调用转发进那个唯一的纯虚 Get。注释里那句 disallow override (was previously allowed) 是有故事的——早年这些重载不是 final,于是每个想定制的子类都得把七八个重载各实现一遍,稍不留神就漏掉一个、行为不一致。改成 final 之后,实现者的义务被收窄到一个方法,便利性留给调用方,一致性由编译期保证。
到 DBImpl 里,这个唯一的纯虚 Get 又转发进一个更内部的实现函数:
C++
// db_impl.h:DBImpl 里,公开的 Get 之下还有一层内部的 GetImpl
Status GetMergeOperands(const ReadOptions& options,
ColumnFamilyHandle* column_family, const Slice& key,
PinnableSlice* merge_operands, /* … */) override {
GetImplOptions get_impl_options;
get_impl_options.column_family = column_family;
get_impl_options.merge_operands = merge_operands;
get_impl_options.get_value = false;
return GetImpl(options, key, get_impl_options);
}
于是读路径是三层套娃:最外层是一堆 final 便利重载(公共 API 的人体工学),中间是唯一的虚函数 Get(多态的实现点、也是稳定的 ABI 边界),最里层是 GetImpl 带一个 GetImplOptions 参数包(内部实现,随便怎么改)。 点查、读合并算子、多键批量读,最后都汇到 GetImpl 这一个洞里。这个结构值得你抄进自研引擎:把“给调用方的花样”和“给实现者的义务”分开,前者尽管多,后者收敛到一个内部函数上,中间用一个稳定的虚接口隔开。
7. 错误是值不是异常:Status 与那个会 abort 的析构函数
第 1、2 节我两次卖过关子,说这台引擎不用异常。现在兑现。它的整个错误通道就是那个到处出现的返回值 Status。先看它承载什么,status.h:
C++
// status.h
enum Code : unsigned char {
kOk = 0,
kNotFound = 1,
kCorruption = 2,
kNotSupported = 3,
kInvalidArgument = 4,
kIOError = 5,
// … 一直到
kColumnFamilyDropped = 15,
};
一个字节的错误码,十六种。kNotFound 只是其中普普通通的一号——查不到、IO 错、数据损坏、参数非法、列族被删,全都是同一个 Status 值的不同取值,顺着函数返回值一层层往上传。没有 throw,没有 catch,没有栈展开。
为什么一台这么复杂的引擎,宁可用返回值也不用异常?比较器那个头文件里有一句最直白的解释:
C++
// comparator.h
// Exceptions MUST NOT propagate out of overridden functions into
// RocksDB, because RocksDB is not exception-safe. This could cause
// undefined behavior including data loss, unreported corruption,
// deadlocks, and more.
“这台引擎不是异常安全的。” 一个持有内部锁、管着一堆多版本数据、后台还有压缩线程在跑的系统,如果让异常在任意一层随意穿过去,栈展开会绕过它精心安排的解锁、回滚、状态复位,轻则死锁,重则丢数据、损坏还不上报。所以它的选择是:在所有权和资源边界上不信任异常,把错误压平成一个必须被逐层检查、逐层传递的值。 你自研引擎、尤其涉及磁盘和并发时,这是一个值得认真掂量的立场——异常在业务代码里很香,但在一个不能容忍“半个操作”的存储引擎核心里,返回值错误码的可预测性往往更值钱。
但返回值有个致命弱点:没人强迫你检查。 throw 出来的异常你不接就崩,返回的 Status 你不看就悄悄溜过去了。这台引擎怎么堵这个洞?我们让它当场崩给你看。写一段“忘了检查返回值”的代码,用打开了状态检查的编译配置跑:
C++
// 灾难演示:拿到 Status 却不看它
db->Put(WriteOptions(), "k", "v"); // 返回的 Status 直接被丢弃
// 程序继续往下……
在开了 ASSERT_STATUS_CHECKED 的构建里,这段跑到那个被丢弃的 Status 析构时,直接 abort:
text
Failed to check Status 0x7ffe1a3c9080
#4 Status::~Status()
#5 main
Aborted (core dumped)
秘密全在 Status 的析构函数里,status.h:
C++
// status.h
~Status() {
#ifdef ROCKSDB_ASSERT_STATUS_CHECKED
if (!checked_) {
fprintf(stderr, "Failed to check Status %p\\n", this);
port::PrintStack();
std::abort();
}
#endif
}
每个 Status 内部藏了一个 checked_ 标志。你调 ok()、code()、IsNotFound() 这些,都会把它标记为“已检查”。要是一个 Status 对象一路活到析构都没被人碰过一次,析构函数就打印地址、打印调用栈、std::abort()——把进程当场干掉,逼你在测试阶段就把这个漏检的地方揪出来。这套检查只在带 ASSERT_STATUS_CHECKED 的调试 / CI 构建里生效,发布版是零开销的(那段代码根本不编进去)。
那要是我确实想丢弃一个错误呢?也有正规出口:
C++
// status.h
// In case of intentionally swallowing an error, user must explicitly
// call this function.
inline void PermitUncheckedError() const { MarkChecked(); }
你得显式写一句 s.PermitUncheckedError(),白纸黑字声明“这个错误我是故意不处理的”。这句话的价值全在代码审查——它把“我漏了”和“我故意的”从视觉上彻底分开,全库搜一下就知道哪些错误是被人有意咽下去的。这是把一个纯约定(请记得检查返回值)升级成了编译期可强制、代码里可搜索的纪律。 你自研引擎时如果也走返回值错误通道,强烈建议抄这一套:光有 Status 不够,得有一个能在测试里把“漏检”变成崩溃的机制,否则返回值错误码迟早会被人这里漏一个那里漏一个,退化成比异常还危险的东西——因为异常至少不会被无声吞掉。
8. 功能怎么一层层叠上去,靠的是装饰
到这里你手里有了两块:一张纯虚契约 DB,一个巨型实现 DBImpl。现在来个真问题:我想给这台引擎加个功能,比如给所有 key 自动带 TTL 过期,或者写之前先加密。往哪加?
新手的第一反应是改 DBImpl。错。DBImpl 是那台谁都不该碰的庞然大物,你往里塞 TTL 逻辑,等于把一个正交的关注点焊死进核心实现。正确答案是装饰,它给你现成的基类,stackable_db.h:
C++
// stackable_db.h
// This class contains APIs to stack rocksdb wrappers.
// Eg. Stack TTL over base db.
class StackableDB : public DB {
public:
explicit StackableDB(DB* db) : db_(db) {} // 独占底层 db
Status Put(const WriteOptions& options, ColumnFamilyHandle* cf,
const Slice& key, const Slice& val) override {
return db_->Put(options, cf, key, val); // 原样转发
}
Status DropColumnFamily(ColumnFamilyHandle* cf) override {
return db_->DropColumnFamily(cf); // 原样转发
}
virtual DB* GetBaseDB() { return db_; } // 露出被包的那层
DB* GetRootDB() override { return db_->GetRootDB(); } // 递归到最底
private:
DB* db_;
};
StackableDB 自己就是一个 DB(public DB),它持有另一个 DB,把每一个方法原封不动转发给里面那个。它自己什么活都不干——它的价值恰恰在于“什么都不干”,因为这样你就有了一个干净的插入点:继承 StackableDB,只重写你关心的那几个方法。TTL 就重写 Put,塞一个过期时间戳再转发;加密就重写 Put/Get,进出各做一次密码学变换;统计就每个方法转发前后记一笔耗时。其余几十个方法,基类替你原样透传,你一行都不用碰。这就是装饰器:功能像套壳一样一层层叠上去,每一层只认它下面是个 DB,不认具体是谁。 TTL 壳可以套在加密壳上,加密壳套在 DBImpl 上,GetRootDB() 一路 db_-> 递归到最底那个真身。
顺便把它的所有权也点破,因为这是自研引擎最容易埋雷的地方。StackableDB 给了三个构造函数,对应三种所有权:
C++
// stackable_db.h
explicit StackableDB(DB* db) : db_(db) {} // 裸指针,独占
explicit StackableDB(std::shared_ptr<DB> db) // 共享
: db_(db.get()), shared_db_ptr_(db) {}
explicit StackableDB(std::unique_ptr<DB>&& db) // 移交独占
: db_(db.get()), shared_db_ptr_(std::move(db)) {}
~StackableDB() override {
if (shared_db_ptr_ == nullptr) {
delete db_; // 独占模式:我析构,我负责删底层
} else {
assert(shared_db_ptr_.get() == db_); // 共享模式:交给 shared_ptr
}
}
它把“我到底拥不拥有被包的那个 DB”这件事,在构造函数签名里就摊开说清楚了——传裸指针或 unique_ptr 就是“这个 db 归我了,我析构时删它”,传 shared_ptr 就是“大家共享,谁都别乱删”。析构函数据此二选一。所有权在类型签名里是显式的,不靠注释、不靠口头约定。 这是 C++ 接口设计的一条硬功底:凡是涉及“谁删这块内存”的地方,让类型自己把答案说出来。
最后收一下第二部分,把这台引擎的 API 面切成三层,你自研时也该这么切:
|
稳定公共 API |
include/ 下的纯虚 DB / Status / Comparator / Iterator |
版本间不破坏,外部代码依赖它 |
|
内部实现 |
DBImpl 及其 GetImpl,散在十几个 .cc 里 |
随时可重构,外人看不见 |
|
测试辅助 API |
DBImpl 里 61 个 TEST_ 方法 |
只给单元测试,靠 TEST_ 前缀隔离 |
前两层第 6 节讲透了。第三层是很多人自研时漏掉的一块——测试需要捅进实现内部去验证状态,但这些捅进去的口子绝不能混进公共 API。这台引擎的做法是给它们统一加 TEST_ 前缀,db_impl.h 里数一下有六十多个:
C++
// db_impl.h:清一色 TEST_ 前缀,只服务单元测试
SequenceNumber TEST_GetLastVisibleSequence() const;
Status TEST_FlushMemTable(bool wait = true, bool allow_write_stall = false, …);
Status TEST_WaitForCompact();
void TEST_LockMutex();
TEST_GetLastVisibleSequence 直接掏出当前最新可见的版本号,TEST_FlushMemTable 手动逼一次内存表落盘,TEST_WaitForCompact 死等后台压缩干完——全是测试用来把不确定的后台时序钉成确定状态的钩子。前缀 TEST_ 是一句一眼可辨的声明:这是实现留给自己人验证内部状态的后门,绝不对外开放。 你自研引擎迟早也要这类后门,早点约定一个前缀把它们和公共 API 隔开,别让某个测试辅助函数哪天不小心被外部用户依赖上,那就再也删不掉了。
9. Open、句柄、关库:谁拥有谁,谁必须先死
第三部分只回答一个问题:这套接口里的对象,什么时候生,什么时候才允许死。这问题对自研引擎是生死攸关的——句柄的所有权切错一刀,要么泄漏,要么二次释放,都是深夜排查的常客。
从库本身开始。Open 交还的是 unique_ptr<DB>,独占所有权,RAII 兜底:
C++
std::unique_ptr<DB> db;
Status s = DB::Open(options, "/tmp/kv", &db);
// … 用 db …
db.reset(); // 或者离开作用域自动析构
db 一析构,库就关了,干净。但库不是孤零零一个对象,它下面挂着一串句柄,这些句柄的死活得你自己管。看列族这个例子的完整生死流程:
C++
// 建一个列族,拿到一个裸指针句柄
ColumnFamilyHandle* cf;
Status s = db->CreateColumnFamily(ColumnFamilyOptions(), "new_cf", &cf);
// … 用 cf 做读写 …
// 关库之前,必须亲手把句柄销毁
s = db->DestroyColumnFamilyHandle(cf);
db.reset();
注意 CreateColumnFamily 给你的是一个裸指针 ColumnFamilyHandle*,不是智能指针。它给裸指针有道理:句柄活多久,由你的业务逻辑说了算,它替你决定不了。于是所有权明确落到你头上:你建的句柄,你负责调 DestroyColumnFamilyHandle 销毁,而且必须在关库之前。db.h 那句话是硬要求:
C++
// db.h:Before destroying the DB, you have to close all column
// families by calling DestroyColumnFamilyHandle() with all the handles.
多列族打开时,这套所有权就是一串:
C++
std::vector<ColumnFamilyHandle*> handles;
s = DB::Open(DBOptions(), "/tmp/kv", column_families, &handles, &db);
// … 用 handles[i] 读写各个列族 …
for (auto handle : handles) {
s = db->DestroyColumnFamilyHandle(handle); // 逐个销毁
}
db.reset(); // 句柄全清完,才关库
这里有个反直觉的例外,得敲一下:默认列族的句柄不要你销毁。db.h 里 DestroyColumnFamilyHandle 的注释专门点了名——except for DefaultColumnFamily()。默认列族的句柄由库自己持有和释放,你去 Destroy 它反而出错。这类“看着一样、生命周期归属却不同”的对象,是自研引擎接口最容易让人踩错的地方:同样是 ColumnFamilyHandle*,你建的那个归你销毁,库送你的那个归库销毁。
迭代器也是同一套规矩,而且它把“谁必须先死”说得更狠。NewIterator 的注释:
C++
// db.h
// Caller should delete the iterator when it is no longer needed.
// The returned iterator should be deleted before this db is destroyed.
virtual Iterator* NewIterator(const ReadOptions& options,
ColumnFamilyHandle* column_family) = 0;
迭代器必须先于库死。 想想也对:迭代器是骑在库的内部数据结构上的一个游标,库都没了,它骑的那匹马就没了,再动它就是访问已释放内存。所以析构顺序有一条铁律——后借的先还:迭代器、列族句柄这些从库借出来的对象,全都得赶在库析构之前销毁掉。你自研引擎时,Open 返回 unique_ptr 很容易让人以为“RAII 全自动了”,但 RAII 只管好了库本身;那些库借给你的裸指针句柄,得靠你自己保证它们死在库前面。把这条顺序写进你的接口注释里,是对下游最起码的交代。
这条顺序错了会怎样,我们让它崩一次:
C++
std::unique_ptr<DB> db;
DB::Open(options, "/tmp/kv", &db);
Iterator* it = db->NewIterator(ReadOptions());
db.reset(); // 库先析构了!
it->SeekToFirst(); // 游标还骑在已经没了的内部结构上
// use-after-free:运气好当场段错误,运气坏读到脏数据接着跑
delete it;
db.reset() 把库连同它的内部结构一起析构了,it 手里那根游标指向的东西已经不存在,SeekToFirst 一动就是 use-after-free。最要命的是它不一定当场崩——内存刚释放、还没被别人复用时,它可能“看起来正常”地读出一批垃圾,你以为查询成功了,其实拿到的是已释放内存里的残渣。这类 bug 在自研引擎里最难查,因为它是概率性的、和内存分配器的即时行为耦合,换台机器、换个负载就不复现。防它只有从接口上把顺序钉死:所有借出的句柄,要么用 RAII 包起来、随作用域自然先于库析构,要么在库的析构里主动查一遍还有没有活着的借出对象、有就当场报错,绝不让它悄悄溜过去。
10. “还存在”和“还看得见”,是两根独立的轴
这是第三部分的核心,也是整篇最反直觉的一节。前面所有铺垫——第 2 节的墓碑、第 5 节的“有版本”——都是为了这一节能站住。
先看列族的“删除”。你以为 DropColumnFamily 会把这个列族的数据抹掉,看它的注释:
C++
// db.h:This call only records a drop record in the manifest and
// prevents the column family from flushing and compacting.
virtual Status DropColumnFamily(ColumnFamilyHandle* column_family);
它只干两件事:往元数据里记一笔“这个列族被删了”,然后禁止它继续刷盘和压缩。数据一个字节都没动。那这个列族什么时候才真正消失?答案在 DestroyColumnFamilyHandle 的注释里,这句话是整篇的题眼:
C++
// db.h:A column family is only removed once it is dropped
// (DropColumnFamily) and all handles have been destroyed
// (DestroyColumnFamilyHandle).
一个列族真正被移除,要同时满足两个条件:被 drop 了,而且所有句柄都销毁了。把这两个条件拆开,你就撞见了整台存储引擎最核心的一组概念——“还存在”和“还看得见”是两根独立的轴:
-
DropColumnFamily 之后,这个列族不可见了:新的读写认为它没了,它不再刷盘不再压缩。
-
但只要还有一个句柄没销毁,它就还存在:数据还躺在盘上,那个句柄还能访问到它。
可见性和存在性,被这两个动作切成了两件事。这不是列族独有的怪癖,它是“有版本”这个词的必然结果。同一根轴,在快照上表现得更纯粹。先认识版本号,snapshot.h:
C++
// snapshot.h
// Abstract handle to particular state of a DB.
class Snapshot {
public:
virtual SequenceNumber GetSequenceNumber() const = 0;
};
一个快照,本质就是一个序列号——第 5 节说的那个“有版本”的版本号。引擎内部每一次写操作都领一个单调递增的序列号,一个快照钉住某个序列号,意思是“我要看这台库在这个序列号那一刻的样子”。db.h 里 GetSnapshot 的约定:
C++
// db.h:Return a handle to the current DB state. Iterators created
// with this handle will all observe a stable snapshot of the current
// DB state. The caller must call ReleaseSnapshot(result) when the
// snapshot is no longer needed.
virtual const Snapshot* GetSnapshot() = 0;
现在“存在与可见”这条线彻底显形了。画一条时间轴:
text
序列号: 100 105 110
│Put k=v1 │Delete k │(此刻)
▼ ▼ ▼
持有一个 seq=100 的快照的读者:
→ 照样读到 k=v1(对他,v1 还【可见】)
不持快照、读最新的读者:
→ 读 k 得到 NotFound(对他,k 已【不可见】)
而 v1 这条数据:
→ 一直【存在】在盘上,因为那个快照还钉着它
Delete k 之后,对读最新状态的人,k 已经不可见了。但那个持有 seq=100 快照的读者,照样能读到 v1——因为对他来说,删除发生在“未来”,还没轮到。于是 v1 这条被删的数据必须继续存在,物理上不能被压缩回收掉,直到那个快照被释放。快照 pin 住的是可见性和可回收性,不是存在性本身——反过来说,一个没释放的快照,会把一大票“逻辑上早该消失”的旧版本数据钉在磁盘上,谁都删不掉。这就是自研 MVCC 引擎绕不开的取舍:读的一致性,是用推迟回收旧版本的空间换来的。
这个代价在后台还有更硬的一面。引擎的后台压缩要回收旧版本——一个 key 被覆盖或删除后,它的旧版本迟早得物理清掉,否则磁盘只涨不落。可压缩不能随便清:只要还有一个快照钉着某个旧序列号,比它新的删除就不能真正生效,被删的数据也不能真正回收。于是最老的那个未释放快照的序列号,就是整台引擎的回收水位线——压缩只能安全清理这条水位线以下、且确实没有快照再关心的版本。一个忘了释放、跑了一整天的快照,会把水位线死死焊在一天前,这一天里所有被删、被覆盖的数据全都清不掉,磁盘占用肉眼可见地往上涨。这就是为什么长命快照在存储引擎里是头号运维事故源,也是为什么关库时那道“有未释放快照就 Abort”的门禁必须存在——它拦下的不只是一次关库,是在提醒你:有一个正悄悄撑爆磁盘的引用还没还。你自研 MVCC 引擎时,把“最老快照序列号”做成一个能监控、能告警的指标,比大多数花哨优化都值。
这个代价会在关库时当面找上你。我们让它崩一次:
C++
const Snapshot* snap = db->GetSnapshot();
// … 用 snap 读了一会儿,然后忘了还 …
Status s = db->Close();
// s.IsAborted() == true —— 关不掉!
Close 直接返回 Aborted,库关不掉。db.h 里 Close 的注释把原因和解法都给了:
C++
// db.h:If the return status is Aborted(), closing fails because
// there is unreleased snapshot in the system. In this case, users
// can release the unreleased snapshots and try again and expect it
// to succeed.
一个没释放的快照,会把关库整个拦下来——因为库一旦关了,那个快照钉着的旧版本内存和文件就都没了,引擎宁可拒绝关库也不制造这种悬空。解法是先还快照:
C++
db->ReleaseSnapshot(snap); // 先把可见性引用还回去
s = db->Close(); // 现在能关了
那能不能干脆 delete snap?不能,编译器不让。看 snapshot.h:
C++
// snapshot.h
class Snapshot {
protected:
virtual ~Snapshot(); // 析构函数是 protected!
};
析构函数是 protected 的,你在外面 delete snap 直接编译不过。这是一道编译期的强制——快照的释放只能走 ReleaseSnapshot 这一个口子,因为释放快照不只是删个对象,还要通知引擎“这个序列号我不钉了,你可以回收对应的旧版本了”。delete 干不了后半件事,所以干脆从类型层面禁掉。想要 RAII 的省心,它给了个包装:
C++
// snapshot.h
// Simple RAII wrapper class for Snapshot.
class ManagedSnapshot {
public:
explicit ManagedSnapshot(DB* db); // 构造即 GetSnapshot
~ManagedSnapshot(); // 析构即 ReleaseSnapshot
};
构造时拿快照,析构时自动 ReleaseSnapshot——把“必须成对调用、否则关不掉库”的手动协议,包成一个离开作用域自动归还的对象。你自研引擎里凡是这种“借了必须还、还错了或忘还会拖住系统”的资源,都该顺手配一个这样的 RAII 包装,别把成对调用的纪律留给调用方的记性。
11. Slice、pin 与 Cache Handle:借出去的内存什么时候失效
生命周期还有最细的一层,细到单个 Slice 指向的那几十个字节。这一层直接连着你要的磁盘 IO 与缓存策略,是接口和性能的交界面。
先崩一个最常见的。你想在遍历时把某个 key 存下来待会儿用:
C++
Slice saved_key = it->key(); // 存下当前 key
it->Next(); // 游标往前走一步
use(saved_key); // 悬空!saved_key 指向的内存已被复用
saved_key 在 Next() 之后就是一个悬空引用。为什么?iterator_base.h 的 key() 注释:
C++
// iterator_base.h:The underlying storage for the returned slice is
// valid only until the next modification of the iterator (i.e. the
// next SeekToFirst/SeekToLast/Seek/SeekForPrev/Next/Prev operation).
virtual Slice key() const = 0;
key() 和 value() 返回的 Slice 只是指向引擎内部缓冲区的一个视图,不拥有内存。游标一动,那块缓冲区就被下一个 key 复用了,你之前存的 Slice 立刻失效。这是零拷贝迭代的代价——它不给你拷贝,换来的是你必须在游标移动前把要留的数据自己拷走(it->key().ToString() 存成 std::string)。这个坑在自研引擎里几乎人人踩一遍,因为 Slice 长得太像 string_view,你会下意识以为它一直有效。千万记住:从游标借出来的 Slice,寿命只到下一次移动游标。
同样的“借出内存”逻辑,在点查这侧就是第 1 节见过的 PinnableSlice。回看那段代码,现在你能读懂它每一行了:
C++
PinnableSlice pinnable_val;
db->Get(ReadOptions(), db->DefaultColumnFamily(), "key2", &pinnable_val);
// pinnable_val 直接指向缓存里那份 value,一次拷贝都没有
assert(pinnable_val == "value");
pinnable_val.Reset();
// The Slice pointed by pinnable_val is not valid after this point
PinnableSlice 是为了省掉 Get 那次值拷贝。普通的 Get(…, std::string* value) 会把值从缓存拷进你的 string;PinnableSlice 不拷,它直接pin 住缓存里那份值——在你 Reset 之前,那份值所在的缓存块不许被淘汰,你手里的 Slice 就一直指着它有效。用完必须 Reset,把 pin 解开。
这里就冒出了你要的“缓存策略”和接口生命周期的交界点。pin 住一份缓存值,底下对应的是 pin 住一个 Cache Handle——缓存里那个数据块的引用。这个引用的代价是这样的:
-
一个块被 pin 住,它就不能被缓存淘汰,哪怕缓存已经满了、这个块是最该被踢出去的冷块,只要还有一个 pin 没解开,它就得在缓存里占着。
-
于是“借出一份零拷贝的值”这个便利,代价是延长了一个缓存块的寿命、占住了一格缓存。你要是拿一堆 PinnableSlice 迟迟不 Reset,等于把一批缓存块钉死,缓存的有效容量被你悄悄吃掉,命中率往下掉。
看到这条线了吗——它和第 10 节的快照是同一个道理,换了个尺度:快照 pin 住的是整个库某个版本的可回收性,Cache Handle pin 住的是一个缓存块的可淘汰性。 都是“你借了一个引用,就延长了某个东西该死而未死的时间”。存储引擎的生命周期管理,从库、到快照、到列族句柄、到一个 PinnableSlice,从头到尾是同一套哲学在不同粒度上的重复:每一个句柄,管的都是可见性 / 可回收性,不是存在性;你多攥一个引用,就多钉住一分本该被回收的资源。
把这条线在缓存这头再钉实一点,因为它直接就是你要设计的缓存策略。读缓存通常是个 LRU:容量满了,就把最久没碰的块踢出去。但“踢出去”有个前提——这个块没被任何人 pin 住。一个块只要还有一个 Cache Handle 没释放,它的引用计数就不为零,LRU 再想淘汰它也只能跳过。于是缓存的真实可用容量,是“总容量减去当前被 pin 住的那部分”。正常情况下 pin 是瞬时的——Get 拿到值、拷走或用完、Reset,几微秒的事,对缓存毫无压力。可只要你把一批 PinnableSlice 攥在手里迟迟不 Reset(比如把一堆查询结果对象缓存起来,每个里面躺着一个没释放的 PinnableSlice),你就等于在缓存里凿了一批永不融化的冰块,可用容量被悄悄吃掉,命中率往下掉——而且这种下跌在监控上极难定位:缓存明明没满,命中率却在崩。你自研引擎设计缓存时,“被 pin 住的块不计入可淘汰容量”这条必须一开始就想清楚,并且给“长期被 pin 的块数”配一个指标,否则零拷贝那点便利,迟早变成一次说不清根因的性能事故。
最后补一个遍历里必踩的收尾坑,把生命周期和错误处理这两条线在这里系上。遍历结束别只看 Valid():
C++
for (it->SeekToFirst(); it->Valid(); it->Next()) {
// 处理 it->key(), it->value()
}
if (!it->status().ok()) {
// Valid() 变 false 有两种可能:走到头了,或者中途 IO 出错了!
// 只有查 status() 才分得清是正常结束还是出错中断
}
Valid() 返回 false 有两种含义:正常走到了区间尽头,或者中途读盘出了错。iterator_base.h 说得很直白——Always returns false if !status().ok()。要是你只用 Valid() 当循环条件、不在循环外查一次 status(),一次读盘错误就会被你当成“正常遍历完了”,悄悄漏掉半个区间的数据还浑然不觉。这正好呼应第 7 节:错误是一个必须主动去查的返回值,连迭代器这种地方都不例外。
12. 三道判断题,把这套模型搬到你自己的引擎上
回到开头那九行代码。那时你看到 1 10 11 12 2 会愣一下,现在你该能一口气说清:它按无符号字节做字典序(第 3 节),字符串 "12" 的首字节比 "2" 小;要让它按数值排,key 里的整数得大端编码。同一段代码,你手里的信息已经完全不同了。
不做总结,给你三道判断题,每道都对着你自研引擎时真会下的一个决定,随判随答:
第一题:你的 key 编码,在字节字典序下顺序还对吗? 但凡你的引擎要支持范围扫描,这就是第一道坎。整数主键?必须大端。多字段复合 key?每个字段的边界和顺序都得在字节层面对齐。定长还是变长,直接决定前缀扫描成不成立。答不利索这一题,你的有序性是假的——不报错,只是扫描结果悄悄不对。
第二题:你这套接口里,哪些对象 pin 的是“可见性”,哪些是“存在性”? 快照、列族句柄、PinnableSlice、迭代器,全是引用型对象,它们钉住的都是“可回收性”这根轴,不是“存在性”。想清楚每一个的释放会放开什么、忘了释放会钉住什么——关不掉的库、淘汰不掉的缓存块、回收不了的旧版本,根子都在这里。给每个这样的引用配一个 RAII 包装,把成对调用的纪律从调用方的记性里拿走。
第三题:你的错误通道,是值还是异常? 如果你的引擎核心也碰磁盘、也带并发、也容不下“半个操作”,认真考虑走返回值 Status。但光有返回值不够——配一套能在测试构建里把“漏检返回值”变成当场崩溃的机制,否则它迟早退化成比异常更危险的东西:被无声吞掉的错误。
三道题背后是同一句判断,也是这篇唯一想焊进你脑子的那一句:有序 KV 存储的每一个接口决定,最终都收敛到一个问题——顺序由谁定义、状态对谁可见、资源归谁回收。 顺序即身份,可见即版本,引用即账单。你把自研引擎的接口按这三条捋一遍,磁盘 IO、缓存策略、性能优化那些下游的活,才有一个立得住的地基。
这套模型有它的保质期。它成立,建立在三件事上:key 空间存在一个你定得死的全序、写入是多版本叠加而非原地覆盖、资源靠引用计数而非垃圾回收来收。哪天你的引擎改用了别的存储范式——比如换成纯内存、或者上了带 GC 的运行时——这三条里但凡塌一条,上面整套关于“存在与可见”“顺序即身份”的推论就得重画。到那时,别忘了回来把这三条前提重新对一遍。
网硕互联帮助中心






评论前必须登录!
注册