存储引擎下:MemTable、SSTable 与编码
假设有一张 100 列的表,你执行一条 UPDATE t SET c_50 = 1 WHERE pk = 1。这条语句只改了一个列的值。请你猜一猜,OceanBase 在这个 Tablet 的内存 MemTable 里,究竟写进了多少数据------是整行 100 列的新版本,还是只有 c_50 这一列的 1 个字节?
答案是后者。源码里那句注释写得不留余地:
ObMvccTransNode is the multi-version data used for mvcc and stored on memtable. It only saves updated columns for write and write by aggregating tx nodes to data contains all columns.
出处是 src/storage/memtable/mvcc/ob_mvcc_row.h:61。翻译过来就是:MemTable 里的每个写操作节点只保存被更新的列,读一行的时候要把同一行上的多个节点"聚合"起来,才能拼出完整行。
这句话几乎决定了 OceanBase 单机存储引擎的全部性格。它意味着 OB 的 MemTable 和 RocksDB 的跳表不是一类东西,和 InnoDB 的 buffer pool 页也不是一类东西,它是"稀疏的、列粒度的、多版本链式"的增量集合。你今天读到的所有关于"准内存数据库""写放大低""内存占用小"的说法,根子都在这十几个字的注释上。而且这个设计比你想的更彻底:一次只加行锁的写(SELECT ... FOR UPDATE)在 MemTable 里留下的节点,连被改的列都没有,只剩一串主键字节------ObMemtable::lock_ 调用的 row_writer.write_lock_rowkey()(src/storage/memtable/ob_memtable.cpp:2716)就是这么干的。所以"只存被改的列"这句话,严格说应该是"只存这次操作真正改变的那部分行语义,可能是几列,也可能是零列"。
这篇文章要把这条线索拉到底:MemTable 用什么数据结构装这些增量、它在 5.0 里为什么多了一个可以关掉的开关、稀疏节点读回来怎么拼成整行、宏块与微块在文件里怎么排、十种列内编码与两种列间编码各自在什么数据分布下被选中、编码之后再叠一层通用压缩为什么还能赚,以及 MemTable 满了之后冻结、转储、合并这条因果链怎么接起来。

1. 四层结构与两个相似的名字
社区里流传的讲法通常是"OceanBase 的存储是 MemTable + SSTable 两层"或者"MemTable + Minor SSTable + Major SSTable 三层"。这两种说法在 4.x 之后都不准确。官方文档《参考指南--系统原理》存储层那一节写得很清楚:Tablet 的内部是分层存储的结构,总共有四层------MemTable、L0 层 Mini SSTable、L1 层 Minor SSTable,以及 Major SSTable。紧接着的四句话描述了这个状态机怎么流动:DML 先写 MemTable;MemTable 达到一定大小转储成 L0 层 Mini SSTable;L0 层 Mini SSTable 个数达到阈值后合并成一个 L1 层 Minor SSTable;每天业务低峰期,所有的 MemTable、L0 Mini SSTable、L1 Minor SSTable 一起合并成一个 Major SSTable。这四层的名字和顺序都值得记牢,因为后面所有关于"写放大""读放大""合并代价"的讨论,本质上都是在争论数据在这四层之间搬动的次数和搬动的粒度。
但这段官方描述里藏着一句措辞含糊的话,同一份文档中写道:"多个 Mini SSTable 达到一定的阈值时,会触发 Minor Compaction 成为 Mini SSTable 或 Minor SSTable"。这里把目标和结果并列写成了二选一,读者很难判断到底产出哪一层,也容易被误读成"Minor Compaction 有时产 Mini、有时产 Minor";更糟的是,若按这个读法去理解,就会以为 L0 和 L1 之间没有清晰边界,从而看不出 OB 的分层策略。源码给了确定的答案,src/storage/compaction/ob_compaction_util.h:13 的合并类型枚举里,这两件事是两个不同的值、走两条不同的代码路径:
cpp
MINOR_MERGE, // compaction several mini sstable into one larger mini sstable
MINI_MERGE, // mini merge, only flush memtable
注释把语义钉死了:MINI_MERGE 只刷 MemTable,产出的就是 L0 层 Mini SSTable;MINOR_MERGE 把多个 Mini 合成一个更大的,产出的就是 L1 层 Minor SSTable。Mini 是"从 MemTable 直接转储出来、只包含增量、尚未和其他 SSTable 合并过"的产物;Minor 是"多个 Mini 合并出来的、位于 L1 的中间层",已经是一段相对稳定的归并结果。这两个名字只差一个字母,却是两个层级、两种策略,读文档时被绕进去过的人应该不少。建议在读任何一篇讲 OB 合并的文章时,先看它用的是 Mini 还是 Minor:用 Mini 就是讲"内存到 L0 的转储",用 Minor 就是讲"L0 到 L1 的归并",混用这两个词的论述基本都是错的。
四层结构里最容易被误读的是 L0 和 L1 的存在必要性。为什么不是"MemTable → Minor → Major"三层,硬要在中间插一层?因为这一层不是冗余,而是两种 LSM 合并策略的分界线。L0 承接的是"刚写完、数量在快速堆积"的增量,用 Tiered(堆叠)策略,只做归并不做跨层重写,写放大最低,代价是同一段 key 区间可能散落在多个文件里、范围扫描时要同时打开它们做归并,读放大高。L1 承接的是"已经稳定下来的增量",用 Leveled(分层归并)策略,把多个文件归并成一个有序序列,读放大低,代价是每次归并要重写整层。OB 把这两种方向相反的诉求切给了两个层级,因此它本质上是一个 Tiered + Leveled 的混合 LSM。代价也随之而来:多一层状态就多一种合并类型、多一套调度逻辑,ObMergeType 枚举里那十几个值就是这笔复杂度的账单。
2. MemTable 的双索引

先纠正一个流传很广的说法:OceanBase 的 MemTable 不是 B+ 树,也不是跳表。打开 src/storage/memtable/ob_memtable.h,ObMemtable 的成员里没有 ObBPlusTree 这种东西,取而代之的是一个 ObQueryEngine(ob_memtable.h:586),它的自述注释(src/storage/memtable/mvcc/ob_query_engine.h:54)写得很明白:ObQueryEngine 同时维护一个哈希表和一个 B-Tree,用它们分别完成高效的点查和范围查询。你在这份头文件里能同时看到 keyhash_ 和 keybtree_ 两个成员(ob_query_engine.h:248、250),get() 走哈希、scan() 走 B-Tree,这种双索引结构不是 B+ 树的形态。更准确的说法应该是:MemTable 内部是一个"哈希表 + B-Tree"的双索引,用哈希把点查压到近似常数级,用 B-Tree 支撑范围扫描。文档里的"B+ 树"是一个工程近似说法,别当成精确描述。
双索引带来的第一个直接代价是内存放大:同一个 key 在哈希表里有一份槽位、在 B-Tree 里有一个叶子节点,元数据存了两遍。源码把这两笔账分开统计,ob_memtable.h:372 和 374 分别是 get_hash_alloc_memory() 与 get_btree_alloc_memory(),正是为了让使用者把这笔开销看清楚。第二个代价是并发控制:同一时刻可能有多个线程往同一个 key 写,怎么保证 ObMvccRow 只被创建一次?ObQueryEngine 的接口注释(ob_query_engine.h:157)给了三步顺序------先用原子哈希表保证只有一个线程能创建 ObMvccRow 并支持高效点查(走 set()),再按操作语义在这个行对象上写入或加锁,最后把行对象原子地插入 B-Tree 以支持范围查询(走 ensure())。注意顺序是先哈希、后 B-Tree。如果反过来,两个线程可能各自在 B-Tree 里插了同一个 key 的节点,范围扫描时同一行就出现两次。用哈希表当"创建闸门"、B-Tree 只负责"可见性发布",是这套双索引能并发正确的前提。这也意味着两棵结构之间不是事务性的:存在"哈希里已有、B-Tree 里还没插"的短暂窗口,这个窗口由 ensure() 语义兜住,范围扫描遇到空洞时不是报错,而是尝试把它补上。用最终一致换写入路径的锁开销,是这里没有写进文档、但读代码才能看出来的设计。
哈希表本身的实现也不是教科书那套。ObMtHash(src/storage/memtable/ob_mt_hash.h)把桶索引的二进制位反转之后当哈希值用(bitrev(),第 25 行),节点 ObHashNode 用最低两位做标志位:最低位区分桶节点与数据节点,次低位表示"是否可见/已填充"(ob_mt_hash.h:51)。初始桶数只有 128(INIT_HASH_SIZE = 128,ob_mt_hash.h:712),增长是概率式的------当随机哈希的中段位满足一个 1/1024 的条件时,桶数才增长 1024(ob_mt_hash.h:639)。注释还专门解释了为什么桶的填充是惰性的:为了兼容"历史库数据很多但很少修改"的场景,避免一个空 MemTable 一建出来就吃掉 dir/seg 的内存(ob_mt_hash.h:366)。这条注释和 OB_EMPTY_MEMSTORE_MAX_SIZE = 10L << 20(ob_memtable.h:474,即 10MB)是同一件事的两面:几万个 Tablet 每个都豪迈预分配内存,租户内存会瞬间被吃穿,所以初始给得很小、真正写入时再按需增长。这是 OB 能支撑百万级 Tablet 的内存前提之一。
真正的变数出现在 5.0:这个双索引现在可以被关掉,双索引从"设计定论"退化成"可配置选项"。这意味着如果你只读了 3.x/4.0 时代的资料,你脑子里关于 MemTable 的模型可能已经过期了------同一个租户、同一个 Server 上,两张表的 MemTable 完全可能跑在两套不同的索引结构上。租户级参数 _enable_memtable_hash_index(src/share/parameter/ob_parameter_seed.ipp:578,默认 True,且是 DYNAMIC_EFFECTIVE 可动态改)决定 MemTable 实例化哪一套引擎,也就是说这个开关影响的是"整块内存表的索引形态",而不是某个查询的临时优化。ob_memtable.cpp:164 的分支读起来一目了然:
cpp
} else if (use_hash_index && FALSE_IT(mvcc_engine_ = new(mvcc_engine_buffer_) ObMvccEngine())) {
} else if (!use_hash_index && FALSE_IT(mvcc_engine_ = new(mvcc_engine_buffer_) ObMvccEngineWithoutHashIndex())) {
ObMvccEngineWithoutHashIndex(src/storage/memtable/mvcc/ob_mvcc_engine.h:163)是对 ObMvccEngine 的派生覆盖,它把"创建 key"直接走 B-Tree(ob_mvcc_engine.cpp:603 的 create_btree_kv_),把点查也改成从 B-Tree 查(query_engine_get_ 直接调 query_engine_->get_from_btree,ob_mvcc_engine.cpp:596),并且把 ensure_kv() 变成空函数------因为 key 在 create 阶段就已经进了 B-Tree,不需要事后再补(ob_mvcc_engine.cpp:590)。这个改动的动机很实际:哈希索引省的是点查的访问时间,花掉的是每个 key 一份额外的桶槽位内存,外加写路径上"哈希 + B-Tree"两次插入。对于写入热点集中、或者内存极度紧张的租户,关掉哈希索引、只留一棵 B-Tree,可以把内存占用降下来,代价是点查从近似 O(1) 退化到 O(log N) 的指针追逐,cache 命中率变差。这是一个典型的"空间换时间"开关,也是判断一个引擎是否成熟的标志------它愿意把早期为了极致性能而写死的设计,重新暴露成一个可调的取舍点。
3. 一次 UPDATE 写进内存的是什么
官方文档对这一点的描述很直接:在 MemTable 中更新写入的数据只包含更新列的新值以及对应的主键列,也就是说更新行并不一定包含表全部列的数据。落到结构上,每个写操作对应一个 ObMvccTransNode,节点尾部是一个柔性数组 char buf_[0](ob_mvcc_row.h:162),真正写进去的内容由 ObMemtableDataHeader 承载(src/storage/memtable/ob_memtable_data.h:19),它只记录三样东西:dml_flag_、buf_len_,以及紧跟在头后面的变长负载。这个负载是按列描述符序列化出来的一小段字节------如果这次 UPDATE 只改 c_50,负载里就只有主键列加 c_50;如果是插入,才是整行。dml_flag_ 的类型定义在 src/storage/blocksstable/ob_datum_row.h:38,取值是 DF_NOT_EXIST / DF_LOCK / DF_UPDATE / DF_INSERT / DF_DELETE,读路径正是靠这个标志判断"这个节点到底改了什么、还有没有效"。所以 MemTable 里根本不是"一行的若干版本",而是"一串描述行状态变化的操作记录",每一条记录只覆盖它真正触及的列。
同一行的这些记录靠一条双向链表串起来,链表头就是这一行的"最新状态"。ObMvccRow 的自述注释(ob_mvcc_row.h:267)写道:ObMvccRow 包含指定 key 的所有多版本事务节点,所有事务节点双向链接、按从新到旧排序。排序方向这个细节很关键:新节点总是插在头部,所以"找当前读快照可见的版本"就是从头部往后走、跳过还没有填上提交版本的节点,直到遇到第一个 trans_version_ 小于等于快照的已提交节点。节点结构上一眼能看到两个指针和一笔校验和(ob_mvcc_row.h:150):
cpp
ObMvccTransNode *prev_;
ObMvccTransNode *next_;
uint32_t modify_count_;
uint32_t acc_checksum_;
prev_/next_ 是链表连接,维护版本序的意图很明确:读路径要从最新节点往回走,找到第一个对当前快照可见的版本,每个可见版本的负载再按列覆盖到更老的版本上。modify_count_ 是这条记录被改过多少次(影响判断是否该压紧),而 acc_checksum_ 是这套设计里最值得单独讲的一个字段。它不是"这个节点自己的校验和",而是"从链表头累加到当前节点为止的校验和":m_cal_acc_checksum(last_acc_checksum) 的第一件事就是把上游传入的累加值填进校验器(src/storage/memtable/mvcc/ob_mvcc_row.cpp:31,注意第 35 行 bc.fill(&last_acc_checksum, ...) 在填数据之前),结果再存回本节点的 acc_checksum_。为什么要用累加而不是每节点独立?
因为内存里的 MVCC 链是被并发修改的。你没法保证"遍历一遍链、再整体算一次校验和"这个动作是原子的------遍历到一半,另一个线程可能刚在头部插了一个新节点、或者给某个节点打上了提交标记。用累加和的话,遍历者只要从头走一遍,把每个节点存的 acc_checksum_ 和"上游累加值重新算出来的期望值"比对,就能发现自己这次遍历是否被并发改动打断过(verify_acc_checksum,ob_mvcc_row.cpp:49,不一致就报 OB_CHECKSUM_ERROR)。这是一套"给并发数据结构做一致性自检"的手法,代价是每个节点多 4 字节,收益是读取路径可以在不加锁的前提下判断自己读到的是不是一个自洽的链快照。顺带解释了一个小细节:m_cal_acc_checksum 里 (bc.calc() ?: 1) 把 0 换成 1,因为 0 被用作"这是第一个节点、还没有上游值"的哨兵。
节点还带着一个位标志 TransNodeFlag(ob_mvcc_row.h:66),把事务状态压进 1 个字节,而且每个状态是独立的一个 bit:F_COMMITTED = (1 << 2)、F_ELR = (1 << 3)、F_ABORTED = (1 << 4)、F_DELAYED_CLEANOUT = (1 << 6)、F_INCOMPLETE_STATE = (1 << 7)。独立 bit 的意义在于可以用 CAS 单独置位或清位,所以"提交"和"标 ELR(早期锁释放)"可以并发进行而互不覆盖;add_flag_ / clear_flag_ 就是两个 CAS 循环(ob_mvcc_row.h:106)。F_DELAYED_CLEANOUT 这个"延迟清理"位说明另一件事:被回滚或被覆盖的旧版本不是马上从链上摘掉,而是先打个标记等后台清理。原因是摘链涉及指针改写,必须持锁做完整的链表操作,高并发下频繁摘链会争锁。把"逻辑上不可见"和"物理上移除"解耦,读路径只要跳过带标记的节点就行,代价是内存里会短时间滞留一些"垃圾节点"。
链表的物化行对象 ObMvccRow 本身有一个硬约束,任何人想往里加成员之前都得先看看这个编译期断言,加一个 8 字节指针就可能让它编不过;在微秒级操作里这也是一笔真实开销,因为每个 Tablet 的行头都是常驻内存的,缩一个字段就省一片内存;反过来,任何"顺手加个指针方便调试"的改动都会被这个断言当场拦下。这条断言的存在本身也说明,OB 把"内存里每一行的元数据体积"当成了和正确性同等级别的设计约束(ob_mvcc_row.h:327):
cpp
STATIC_ASSERT(sizeof(ObMvccRow) <= 120, "Size of ObMvccRow Overflow.");
为什么死盯 120 字节?因为每一行的多版本头都常驻内存,几亿行就是几亿份这个头。120 字节乘 1 亿就是 12GB,这个数字必须被摁住。所以 ObMvccRow 里塞的全是紧凑标量(max_trans_version_、min_modify_scn_、first_dml_flag_ / last_dml_flag_、total_trans_node_cnt_ 等),指针只留了链表头、最近一次压紧节点和可选的索引三根。行内数据的定位还有一层懒加速:INDEX_TRIGGER_COUNT = 500(ob_mvcc_row.h:303),注释说得很直白------当"找到正确插入位置之前需要跳过的节点数"超过 500 时才构造索引。绝大多数行的版本链很短,没必要为每一行都维护索引结构;只有写热点行(被反复更新)才值得建。这也解释了 ob_memtable.h:387 那个 contain_hotspot_row_ 标志的用途------它标记这个 MemTable 里出现了热点行,提示后续处理(比如行压紧 row_compact)要留意。
稀疏存储的收益与代价,以及一个容易看漏的深层动机
表层动机是省内存和省写量。OLTP 大量是"改某行的一两列",如果每次 UPDATE 都复制整行 100 列,内存占用和写量会放大两个数量级;只存被改的列,两者都降到最低。而且这个收益是双向的:写入时少写字节,落盘时也少写字节,因为转储只是把这些节点顺序搬到 SSTable 里,中间没有"补全整行"的步骤。更细一点说,MemTable 的内存占用直接决定冻结频率,冻结频率又决定 L0 层堆积速度和合并压力,所以"少存几个字节"这件事会沿着整条链路一路放大成"少合并几次"。
深层动机更容易被忽略:稀疏存储是为列式转置预埋的结构 。OB 想把同一套数据同时喂给行存和列存,如果 MemTable 里存的是"整行快照",那每一份快照都是行的形态,无法被列编码复用;而存"列粒度的增量",天然就是按列切开的,将来在 Major SSTable 阶段转成列存(CONVERT_CO_MAJOR_MERGE)时,这些增量可以按列聚合处理。稀疏不是省内存的附带效果,它是为了让行存与列存共享同一份写入路径。源码里 ObMemtable 还保留着 original_merge_engine_type_(ob_memtable.h:580)这样一个字段,它的取值来自 ObMergeEngineType(deps/oblib/src/common/ob_store_format.h:64)里的 OB_MERGE_ENGINE_PARTIAL_UPDATE(只写增量列)与 OB_MERGE_ENGINE_DELETE_INSERT(删旧插新)。建表时选的是哪种合并引擎,决定了这条 UPDATE 到底以"增量列"还是"整行替换"的形式落到 MemTable 里,而默认是前者。
代价则落在读路径上。读一行的完整数据,不能像 InnoDB 那样从聚簇索引里一次读出整行,你必须顺着版本链从新到旧遍历,把每一段负载按列 concat 起来,才能拼出对当前读快照可见的完整行。行越长、版本链越长,聚合成本越高。OB 用 ObMvccRow::row_compact(ob_mvcc_row.h:378)在链过长时把多个节点压成一个完整行节点,正是为了给读路径回血------这也是开头那句注释里"aggregating tx nodes to data contains all columns"的另一半含义:写的时候只存增量,攒到一定程度再聚成整行。读放大和写放大在这里不是二选一,而是被时间轴分开了:写在前面便宜,读在后面贵,中间靠压紧和缓存把差距抹平。
4. 冻结的那一刻
理解 MemTable 的"活跃态"与"冻结态",得先看清它到底有几个状态。src/storage/ob_i_tablet_memtable.h:96 定义了 ObMemtableState,取值依次是 INVALID = -1、ACTIVE = 0、MAJOR_FROZEN = 1、MINOR_FROZEN = 2、MAJOR_MERGING = 3、MINOR_MERGING = 4。注意它不是"活跃/冻结"二元,而是一路走到 MERGING 的六态机:ACTIVE 时可写;MAJOR_FROZEN / MINOR_FROZEN 表示已经冻结、等待被某一次大合并或小合并消费;MAJOR_MERGING / MINOR_MERGING 表示合并已经把它读进去了。另有一个更细的冻结进度枚举 TabletMemtableFreezeState(同文件第 109 行):INVALID / ACTIVE / FREEZING / READY_FOR_FLUSH / FLUSHED / RELEASED / FORCE_RELEASED。这两个枚举一个描述"这块内存归谁管",一个描述"这次冻结走到哪一步",读代码时要分清------把 MEMTABLE_FROZEN 和 READY_FOR_FLUSH 混起来看,就会误以为"冻结完成"等于"可以刷盘了",而这两件事之间其实隔着"等在途写线程退场"这一段。
冻结动作本身落在哪里,是个值得琢磨的细节------它没有落在状态枚举上,也没有落在一个 is_frozen_ 布尔位上,而是落到了分配器里。这个反直觉的选择是整段并发安全的地基:状态位只能表达"我打算冻结了",而分配器能表达"从现在起新的分配被拦下、在途的引用仍然合法",后者才是冻结真正需要保证的语义。换句话说,冻结的原子性不在"状态"上,而在"内存所有权"上,这也是为什么回收内存的时机不由冻结者决定、而由最老的那个在途引用决定。ObMemtable 的实现是(ob_memtable.h:253):
cpp
virtual int set_frozen() override { local_allocator_.set_frozen(); return OB_SUCCESS; }
也就是说,冻结不是一个布尔位被翻转,而是下沉到 local_allocator_(类型别名 ObSingleMemstoreAllocator,即 share::ObMemstoreAllocator::AllocHandle,ob_memtable.h:232)上。为什么要落到分配器?因为冻结 MemTable 时不能简单"停止写入"就完事------你已经发出去的写线程还持有内存引用,它们得能安全地继续用完、然后释放;同时新的分配必须被拦住。ObMemstoreAllocator 用一组时钟来管这件事:AllocHandle::get_protection_clock() 就是它自己的 handle 时钟(src/share/allocator/ob_memstore_allocator.h:87),get_retire_clock() 则取全局分配器的 arena_.retired()(ob_memstore_allocator.h:88、161)。分配器顺着一条 handle 链维护 hazard(最老的在用位置),于是 get_active_memstore_used() 拿 arena_.allocated() - hazard,get_freezable_active_memstore_used() 拿 arena_.retired() - hazard(ob_memstore_allocator.h:142、146)。ObMemtable 因此能对外报出两个数:get_protection_clock() 和 get_retire_clock()(ob_memtable.h:381)。这套机制的收益是"冻结瞬间的并发写不丢也不乱",代价是内存回收不再由单点决定,而要看最老那个 handle 什么时候退场------所以冻结后的内存释放有一个滞后窗口,ObFreezer 的状态机里"等 MemTable 满足可刷盘条件"那一档,等的就是这个窗口收敛。
可刷盘的判定条件写在 ready_for_flush_() 里(src/storage/memtable/ob_memtable.cpp:1692),三个条件同时成立才行:is_frozen_memtable() 为真、get_write_ref() 归零(没有写线程还挂在上面)、get_unsubmitted_cnt() 归零(没有未提交的事务还指望它),再加上右边界 SCN 已经确定。对应到 ObFreezer 的状态机(src/storage/ls/ob_freezer.h:49),冻结过程被拆成四步:NOT_SET_FREEZE_FLAG(置冻结标志,本地生效)、NOT_SUBMIT_LOG(提交冻结日志,让所有副本对"这一批数据到哪为止"达成一致)、WAIT_READY_FOR_FLUSH(等 ready_for_flush_ 由假转真)、FINISH。中间"提交冻结日志"这一步是分布式共识的要求:冻结不只是单机内存状态切换,它是一个需要在所有副本上达成一致的事件,否则各副本会各自认为自己的 MemTable 边界在别处。这也解释了为什么一个看似纯内存的动作,却要先走一趟日志同步。
冻结的触发阈值和调度粒度是另一组变量。租户参数 freeze_trigger_percentage(src/share/parameter/ob_parameter_seed.ipp:547)默认值是 20,含义是 MemTable 用到租户 MemStore 上限的 20% 就触发冻结。这个值偏保守,是为"冻结期间还有新写入"留余量:冻结不是瞬时的,等事务、切分配器、开新 MemTable 都需要时间,期间新数据要继续写进新 MemTable,不能把内存写爆。再往上还有 writing_throttling_trigger_percentage 默认 60(同文件第 550 行),到 60% 就开始限流,最后一道是 data_disk_write_limit_percentage 这类磁盘水位线。也就是说"内存不够"这件事在 OB 里不是单一阈值,而是一条从 20% 到 60% 再到磁盘水位的阶梯:越往后手段越重,先冻结(让数据落盘)、再限流(让写入变慢)、最后拒写。这套阶梯的存在说明,冻结本身是主动的、抢在内存被吃穿之前发起的。
5. 宏块与微块的物理排布

MemTable 满了,数据要落盘。这一层的物理单位是宏块和微块。官方口径是:"每个 SSTable 由若干个大小为 2MB 的定长宏块组成,每个宏块内部由多个不定长微块组成。"同一份文档在第 15252 行补充了动机:"2Mb 定长的宏块方便对存储空间进行管理,宏块内部变长的微块则方便我们对数据进行压缩。"这两种粒度承担的任务是分开的------IO 需要定长才好预分配、才好做 IO 合并与回收;压缩需要变长才能自适应数据特征、把冗余真的压掉。定长管 IO、变长管压缩,这个分层在多数现代存储引擎里都能看到影子,但 OB 做得比较彻底:宏块是"SSTable 的基本组成部分"和"IO 写入的基本单位",微块概念则被官方拿来和传统数据库的 page/block 作类比,区别只是它"借助 LSM-Tree 的特性做过压缩、是变长的"。
这里有个源码里的坑,找常量的时候容易栽。你在 src/storage/blocksstable/ 下翻很久也找不到"2MB"这个数,因为宏块 2MB、微块默认 16KB 这两个数字被放在了 SQL 优化器目录里,理由和"存储引擎里为什么会有优化器的默认统计"完全是同一件事------它们同时是优化器的估算输入,改一个数就可能改变某些表走不走索引,所以不能藏在存储模块内部。找到它之后你会意识到一件更大的事:物理布局参数在 OB 里是跨模块的公共约定(src/sql/optimizer/ob_opt_default_stat.h:25):
cpp
const int64_t DEFAULT_MICRO_BLOCK_SIZE = 16L * 1024;
const int64_t DEFAULT_MACRO_BLOCK_SIZE_MB = 2L;
const int64_t DEFAULT_MACRO_BLOCK_SIZE = DEFAULT_MACRO_BLOCK_SIZE_MB * 1024 * 1024;
它们被放在优化器的默认统计头文件里,是因为优化器需要用这两个值做代价估算------估算一张表有多少宏块、扫多少微块、IO 成本多大,从而决定全表扫描是不是比走索引更划算。存储引擎的物理常量同时是优化器的估算参数,于是被放在一个两边都能引用的头文件里,代价是这个"存储定义"离存储代码很远,不看代价模型的人是找不到它的。这也侧面说明一件事:在 OB 里,物理布局不只是存储模块的内部实现,它是会反向影响执行计划选择的公开参数。微块的下限 4KB 则确实在存储侧:src/storage/blocksstable/ob_data_store_desc.h:349 的 MIN_MICRO_BLOCK_SIZE = 4 * 1024;建表指定 block_size 时会用 MAX(merge_schema.get_block_size(), MIN_MICRO_BLOCK_SIZE) 钳一次(ob_data_store_desc.cpp:844),防止用户配出过小的微块导致压缩和随机访问都不划算。
宏块头是 ObMacroBlockCommonHeader(src/storage/blocksstable/ob_macro_block_common_header.h:21),带 MACRO_BLOCK_COMMON_HEADER_MAGIC = 1001,并且有一条很硬的约定------它不使用虚函数,设计上让 sizeof(ObMacroBlockCommonHeader) 等于所有数据成员的序列化长度,同时要求数据成员 64 位对齐(第 97 行的注释)。它最关键的字段是 MacroBlockType 枚举(第 27 行):None = 0、SSTableData = 1、LinkedBlock = 2、TmpFileData = 3、SSTableMacroID = 4、BloomFilterData = 5、SSTableIndex = 6、SSTableMacroMeta = 7、SharedSSTableData = 8、SharedMetaData = 9。这个类型字段的作用是让读取方在拿到一个 2MB 块之后,一眼知道它是什么、该走哪条解析路径:是数据块就交给微块迭代器,是 SSTableIndex 就交给索引树,是 BloomFilterData 就走布隆过滤器解码。其中 BloomFilterData 单独占一类,说明布隆过滤器是独立的宏块,不是塞在数据微块里。这是为空查优化服务的:把布隆过滤器独立成宏块,可以被单独缓存、单独加载,点查时先过布隆过滤器就能一次性排除掉大部分不存在的 key,避免去读真正的数据微块。宏块类型值还被一条 static_assert 限制在 4 bit 以内(第 41 行),说明这个字段在序列化时是省着用位宽的------2MB 的块,头部几字节也很宝贵。
微块的头是 ObMicroBlockHeader(src/storage/blocksstable/ob_micro_block_header.h:17),魔法值是 MICRO_BLOCK_HEADER_MAGIC = 1005(ob_block_sstable_struct.h:41)。这个头里的字段密度很高,几个是理解编码的关键:column_count_ 与 rowkey_column_count_ 说明微块自己知道有几列、其中几列是主键列(第 90、91 行),is_trans_version_column_idx()(第 82 行)会用 rowkey_column_count_ - ObMultiVersionRowkeyHelpper::get_extra_rowkey_col_cnt() 定位隐藏的多版本列;max_merged_trans_version_ 记录这块数据里最大的合并事务版本;data_length_ / data_zlength_ / data_checksum_ 分别是编码后长度、压缩后长度、数据校验和;而 column_checksums_ 是一组按列的校验和指针(第 142 行),支持做列级数据校验。ObMicroBlockHeader 的填充位也很有信息量:contain_uncommitted_rows_、single_version_rows_、is_last_row_last_flag_ 这几个位把"这块里有没有未提交行、是不是单版本、是不是区间边界"压进了 1 个字节(第 106 行的 union)。换句话说,微块不只是一袋数据,它还携带了后续解码、可见性判断和区间扫描所需的全部元信息。
微块有两种存储格式,官方说得清楚:"微块有两种存储格式,flat 微块(未编码)和 encoding 微块(编码)。"官方这句只说了一半,源码里实际的行存类型有五种而不是两种,多出来的几种分别服务于不同的读写权衡------有的为 AP 的列扫描让路,有的为 OLTP 的点查让路,有的专门给列存副本用,所以"微块是 flat 还是 encoding"并不是一个二元开关,而是一条从"完全不解码"到"完全按列存"的连续谱。更完整的行存类型枚举在 deps/oblib/src/common/ob_store_format.h:24:
cpp
enum ObRowStoreType : uint8_t {
FLAT_ROW_STORE = 0,
ENCODING_ROW_STORE = 1,
SELECTIVE_ENCODING_ROW_STORE = 2,
CS_ENCODING_ROW_STORE = 3,
FLAT_OPT_ROW_STORE = 4,
};
flat 就是把行逐条平铺序列化,解码极快但不压缩;encoding 是行列混存、按列编码,压缩率极高但解码要花钱;SELECTIVE_ENCODING_ROW_STORE 是折中档,它在 ObMicroBlockEncoderOpt::set_store_type(ob_block_sstable_struct.h:318)里被处理成一件事------关掉 bit-packing、改成存"排序后的变长数字字典"(第 276 行的注释),用来换更快的解码;CS_ENCODING_ROW_STORE 是纯列存副本用的格式,走的是 ObMicroBlockCSEncoder 而不是行存的 ObMicroBlockEncoder(src/storage/blocksstable/ob_macro_block_writer.cpp:2903、2906)。一个 encoding 微块的构建流程,官方写得也很完整:"填充行 > 编码 > 通用压缩(可选)> 加密(可选)"。这个顺序本身就是一条信息------编码在前、压缩在后,两者是独立的两个阶段,后面第七节专门讲这个"两次压缩"。
最后补一个边角但重要的物理事实:行不是原子的。官方明确说:"当表中有大对象类型时,其所占用的存储空间会超过宏块大小,此时会将大对象类型溢出存储到其他的宏块中。"也就是说一个 LOB 列的值可以单独占用若干宏块,和它所属的行物理上分离,读一行可能要跨宏块拼装。为什么非这么设计?因为宏块是定长的,如果强制"一行所有列必须在一个宏块里",一个 10MB 的 BLOB 就会把整个 2MB 宏块撑爆,定长这条铁律会被破坏。OB 的选择是"允许大对象独立成块",用读路径的额外一跳换"定长宏块"不被破坏。ObMicroBlockHeader 里的 has_string_out_row_ 与 all_lob_in_row_ 两个位(ob_micro_block_header.h:95、96)正是为这件事留的标记------解码器读到这些位,就知道要不要去行外把大对象捞回来。
6. 编码器是怎么被选出来的
现在进入这套编码体系的核心。上一节的物理布局回答的是"数据放在哪、放多大",这一节回答的是"同一段数据怎么用更少的字节表示、以及系统凭什么判断该用哪种表示",两件事共同决定了 SSTable 最终能压到多小、读回来又要花多少 CPU。编码类型由 ObColumnHeader::Type 枚举定义(src/storage/blocksstable/ob_block_sstable_struct.h:196),依次是 RAW、DICT、RLE、CONST、INTEGER_BASE_DIFF、STRING_DIFF、HEX_PACKING、STRING_PREFIX、COLUMN_EQUAL、COLUMN_SUBSTR、MAX_TYPE。前 8 个是列内编码,对单列自己动手;后 2 个 COLUMN_EQUAL 和 COLUMN_SUBSTR 是列间编码,跨列找冗余。ob_block_sstable_struct.h:252 用一个函数把这个划分写死了:is_inter_column_encoder(type) 返回 type == COLUMN_EQUAL || type == COLUMN_SUBSTR,同一个文件第 248 行还有个 is_span_column() 做同样判断,供另一条路径使用。
与其把十种编码逐个罗列,不如先讲"选择逻辑"本身。这套逻辑的输入端只有三类信号:这一列的数据分布形态(值域、基数、是否定长、有没有公共字节)、这一列的类型存储类(整型、字符串、数值、时间戳等,由 get_store_class_map() 映射),以及这一次编码的上下文预算(微块大小、压缩器、编码粒度)。第一条原则是先看值域。官方说得很具体:当存储的列是定长数值(如 timestamp、bigint),并且这个微块中的数据都分布在一个值域内时,整形差值编码会带来比较好的压缩效果------做法是只存每一行的值与微块中最小值的差值,再做 bit-packing 来减少实际存储的数据量。这就是 INTEGER_BASE_DIFF:把绝对值变成相对值,值域越窄、bit-packing 后每个值占的位越少,压缩比越高;反过来如果这一列的值在 int64 全量程上乱跳,差值不比原值小,编码就没有收益,会被 RAW 顶掉。
第二条原则是看基数。官方(第 15289 行)写道:"当微块内的数据的基数(Cardinality)比较小时,字典编码和 RLE 编码能通过在微块内部构建字典,存储每行的引用来进行压缩。"注意这里的字典是"微块内部的字典",不是全表字典------每个微块各自建一份。DICT 适合低基数的枚举值列(状态、类型、城市),它把每个不同的值放进字典一次,然后每行只存一个字典下标,下标位宽由 ObDictMetaHeader 里的 row_ref_size_ 和 index_byte_ 决定(src/storage/blocksstable/encoding/ob_dict_encoder.h:33、38)。RLE 适合"连续重复"的分布------注意它和 DICT 的差别:DICT 关心"有多少种不同的值",RLE 关心"相同值是否挨在一起"。一列全是随机的 0/1,基数极低但完全不相邻,RLE 会把每一行都编成一个游程,反而亏;一列是按时间排序的状态码,几十行才变一次,RLE 就能压到极致。所以官方那句"基数小时字典和 RLE 有效"是一句粗略概括,真实判定看的是"基数低 + 连续性",这也是为什么源码里试 RLE 之前先要求基数不超过行数一半(distinct_cnt() <= datum_rows_.count() / 2,后文详述)。在这两者之上还有最极端的一档:官方(第 15291 行)说"一个微块内的一列可能基本都是相同的数据,这时 OceanBase 会通过常量编码(Const)只存储常量和微块内不等于常量的值"。这就是 CONST,它把整列压成一个常量加一张"例外行"清单,清单用 HAS_EXTEND_VALUE 属性位标出(ob_block_sstable_struct.h:214)。
第三条原则是字符串列单独有一套,按"冗余长在哪个维度上"分工。官方(第 15293 行起)给了三种:当一列数据有着相似的前缀时用前缀编码,存储前缀和每行的尾缀;当一列是定长字符串、其中几个字节相同时用定长字符串差值编码,存储模式串和每行的差值数据;当一列字符串的字符基数小于 16 时,可以用一个十六进制数表示这个字符,做十六进制编码。对应到源码就是 STRING_PREFIX、STRING_DIFF、HEX_PACKING。前缀编码的前提是"公共前缀真的存在",它的实现靠一棵多路前缀树 ObMultiPrefixTree(src/storage/blocksstable/encoding/ob_string_prefix_encoder.cpp:76),并且有两个硬上限:前缀种类数不能超过 UINT8_MAX(第 81 行),否则下标一字节装不下,直接判为不合适;前缀总长超过 UINT16_MAX 也判不合适(第 87 行)。字符串差值编码的前提是"定长且字节位上有稳定模式",ob_string_diff_encoder.cpp:108 里还要求"非 null 非 nop 的行至少要有两行以上差值变化"(min_diff_rows = 2)才值得编。十六进制编码的前提最窄------字符集必须落在 16 个符号以内,收益是把每个字符从 8 bit 压到 4 bit。第四档是 RAW,它什么都不做,直接存。
列间编码是 OB 特有的一张牌,它的命中条件和列内编码完全不同。官方的说法是:"当两列数据大部分相同时,使用列间等值编码,这样一整列都是另外一列的引用;当一列数据是另外一列数据的前缀时,也可以使用列间子串编码,只存储完整的一列和一列的后缀。"落到源码,COLUMN_EQUAL 的编码器是 ObColumnEqualEncoder,它的元数据 ObColumnEqualMetaHeader 里除了版本号只有一个 uint16_t ref_col_idx_(src/storage/blocksstable/encoding/ob_column_equal_encoder.h:28)------被编码的这一整列,除了少数例外行之外,一个字节的实际数据都不存,全是"引用第 ref_col_idx_ 列"。COLUMN_SUBSTR 由 ObInterColSubStrEncoder 实现,存的是"完整的那一列 + 这一列的后缀"。命中它需要满足一串前提,而且这些前提比列内编码苛刻得多:被引用的列必须和被编码列类型完全相同 (ob_column_equal_encoder.cpp:86 用 column_type_ != ref_col_idx 类型的直接比较判不合适);引用方向和大小关系有约束,源码在 try_span_column_encoder 里跳过 i < column_index && is_inter_column_encoder(encoders_.at(i)->get_type()) 的情况(ob_micro_block_encoder.cpp:1513),也就是"已经被别人引用过的列不能再引用别人",这正是在防官方提到的那种"不同列之间级联引用的情况";例外行数量还要受控,ObColumnEqualEncoder::traverse 里算出的上限是 min(MAX_EXC_CNT, rows * EXC_THRESHOLD_PCT / 100 + 1)(ob_column_equal_encoder.cpp:92),而这两个常量在 ob_icolumn_encoder.cpp:87 定义成 MAX_EXC_CNT = 100、EXC_THRESHOLD_PCT = 10------也就是说例外行不能超过 100 行,也不能超过总行数的 10% 加 1。超了就直接判"不合适"。一层层前提叠下来,你能看出列间编码不是"发现两列像就编",而是"在能证明整列几乎完全冗余时才敢编"。
那么编码器到底是怎么被选出来的?答案在 ObMicroBlockEncoder 的三段式流程里,而不是一句 choose_encoder 包打天下。第一段是 prescan(src/storage/blocksstable/encoding/ob_micro_block_encoder.cpp:1188),它对每一列扫描一遍,建一张哈希表做基数统计,桶数是 datum_rows_.count() * 2 向上取 2 的幂(第 1208 行),同时统计 null 数、变长数据量、最大整数值等。第二段是 fast_encoder_detect(第 1311 行),它先处理三种能一步定案的情况:如果用户显式指定了这一列的编码(ctx_.column_encodings_[column_idx])就用指定的;如果这一列被判为"只能用 RAW"(cc.only_raw_encoding_)就强制 RAW;如果哈希表统计出 distinct_cnt() <= 1,直接上 CONST,连擂台都不用打(第 1342 行)。第三段才是 choose_encoder(第 1596 行)的完整擂台赛,而它只在前面没有定案时才被调用(第 1285 行)。这个三段式设计本身就是一条重要的性能结论:真正的"全编码探测"只发生在数据分布有悬念的列上,能一眼看穿的列会被前两段直接放行。
擂台赛的规则读起来像一场淘汰赛:raw 先上场并永远"合适"(ob_micro_block_encoder.cpp:1607,后面还跟着一句断言------raw 不能被禁用,返回空就是 OB_ERR_UNEXPECTED,第 1611 行),它就是兜底,保证编码器选择永不失败;接着试 DICT(第 1617 行),谁小留谁;再试"上一微块用过的编码"(第 1631 行);再试列间等值(第 1639 行)和列间子串(第 1661 行);然后如果基数够低(distinct_cnt() <= datum_rows_.count() / 2,第 1679 行)才试 RLE 和 CONST;最后按类型分别试 INTEGER_BASE_DIFF(仅整型,第 1714 行)、STRING_DIFF(仅定长字符串,第 1734 行)、STRING_PREFIX(第 1755 行)、HEX_PACKING(第 1774 行,而且必须在 string diff 和 prefix 都没命中时才试)。每一轮的判据都是"算大小、和当前最优比、小的胜出",calc_size() 是统一接口。这里有个关键的提前退出阈值:acceptable_size = choose->calc_size() / 4(第 1616 行),一旦某个编码把大小压到 raw 的四分之一以下,就把 try_more 置假,后面的编码器不用再试了(第 1646、1667、1708、1723 行多处设置)。按类型逐个试的逻辑很能说明问题:INTEGER_BASE_DIFF 只对整型列试,STRING_DIFF 还额外要求 cc.fix_data_size_ > 0(即这一列真的是定长),HEX_PACKING 被排在字符串编码的最后------这些都是"静态适用性前提 + 动态收益比较"两层筛子叠加的结果。
为什么要设提前退出阈值?因为探测是有成本的。官方很坦白地承认这一点:"对 n 列数据 m 种编码进行探测理论上需要 m*n 次编码才能发现对每一列最优的编码方式,引入列间编码后又会变得更复杂,因此在编码选择算法上,OceanBase 也进行了一些优化。"真让每列都跑满所有编码器,合并的 CPU 会被探测本身吃光。1/4 阈值换掉的是"理论最优编码",得到的是"探测时间可控",这是一笔明确的取舍。官方在第 15318 行紧接着说"OceanBase 在编码选择算法上也进行了一些优化",指的应该还包括另一处机制:跨微块复用上次的编码选择 。try_previous_encoder(ob_micro_block_encoder.cpp:1438)会把前几个微块用过的编码记在 previous_encodings_ 里(每个列最多记 MAX_PREV_ENCODING_COUNT = 2 个,ob_block_sstable_struct.h:438),并按微块序号采样重试------微块数超过 32 个时每 16 个微块才重新算一次,超过 16 个时每 8 个算一次,否则每 4 个算一次(第 1452 行)。这背后的假设是"相邻微块的数据分布相似",用空间换时间:绝大多数微块直接沿用邻居的结论,只有隔一段时间才校验一次是否仍然最优。
还有两个细节值得单独拎出来。一是编码可以叠加 。官方说"支持对一列数据采用多种编码方式进行压缩,比如 hex 编码可以与其他的字符串编码叠加"。这条在源码里有直接证据:ObStringDiffEncoder::traverse 里先做字符串差值,然后判断 hex_string_map_.can_packing(),如果成立,就把"差值区"再按十六进制压一半(src/storage/blocksstable/encoding/ob_string_diff_encoder.cpp:95、131),row_store_size_ = (row_store_size_ + 1) / 2。也就是说"定长字符串差值 + 十六进制"是真实存在的两级流水。代价官方也说了------"相应地也会让编码解码变得更复杂"。二是字典要不要排序 。ObColumnEncodingCtx::try_set_need_sort(ob_block_sstable_struct.h:511)只对 DICT 返回真,并且排除"可能含 lob locator 的类型":
cpp
const bool encoding_type_need_sort = type == ObColumnHeader::DICT;
need_sort_ = encoding_type_need_sort && !store_class_might_contain_lob_locator(col_sc);
排好序的字典可以二分查找、可以做更紧凑的变长下标编码,ObDictMetaHeader 里专门有一个 IS_SORTED = 0x2 属性位记这件事(ob_dict_encoder.h:30);而 ObMicroBlockEncoderOpt 里的 store_sorted_var_len_numbers_dict_(ob_block_sstable_struct.h:277)注释说得很明白------在 SELECTIVE_ROW_STORE 模式下禁用 bit-packing、改为存排好序的变长数字字典。所以"字典排序与否"和"是否位压缩"之间是有取舍的:排序字典利于查找但压缩率可能略低,位压缩字典更省空间但查找要线性。一个微块里几千行数据,这个取舍直接影响解码的常数因子。至于是不是所有列都能选到"最优"编码,源码留了一个接口但当前没接上------encoding_ctx.column_encodings_ 在 ob_macro_block_writer.cpp:2890 被显式写成 nullptr,意味着"用户指定列编码"这条路目前是留白。这与官方那句判断一致:编码格式"比较难以通过 DBA 设计表数据模型时指定列编码来实现最好的压缩效果",所以 OB 选择了合并期自适应探测。这条路留白,说明将来若要开放给用户,钩子已经在了。
7. 为什么两次压缩还有收益
官方把两级压缩的区别讲得很准:"通用压缩指的是压缩算法对数据内部的结构没有了解的情况下,直接对数据块进行压缩......压缩后的数据不能随机访问,压缩和解压都要以整个数据块为单位进行。"而在第 15270 行讲 encoding 时又说:"和通用压缩不同,encoding 的基础建立在压缩算法感知数据块内部数据的格式和语义的基础上。"一个是"感知语义"的(知道这是列、这是时间戳、值域在哪),一个是"不感知语义"的(直接按二进制特征压),两者作用在不同维度上:编码先吃掉"列内/列间的结构性冗余",通用压缩再吃掉"编码之后残留的字节级冗余"。为什么编码之后还有东西可压?因为编码器的产物本身仍然有规律------bit-packing 之后高位往往是大片 0,定长区里相邻行的字典下标分布集中,前缀编码留下的尾缀块有重复字节。这些规律编码器没有进一步利用(它的职责是把数据变成"好解压的定长结构"),但通用压缩的字典匹配和熵编码正好吃这一口。这就是"两次压缩还有收益"的机理:不是重复劳动,而是两个视角错开的冗余。
这两个阶段的产物长度被物理记录在微块头里,对照着看最清楚:data_length_ 是编码之后、压缩之前的长度,data_zlength_ 是压缩之后的长度,data_encoding_length_ 是解码所需的元数据长度(ob_micro_block_header.h:138、139、137)。ObRecordHeaderV3 里也有一份同样的三元组(src/storage/blocksstable/ob_block_sstable_struct.h:797),并且提供了一个很说明问题的判定函数:is_compressed_data() { return data_length_ != data_zlength_; }(第 767 行)。换句话说,系统不需要额外存一个"是否压缩"的布尔位------两个长度不相等就说明压过了。这个细节透露了整条链路的形态:解压得到的是"encoding 数据",还要再解码才是原始行;反过来写入时先编码、再压缩,两次变换各自留下自己的长度刻度。顺带一提,通用压缩支持 zlib、zstd、lz4、snappy 四选一,官方给出的经验是:在默认 16KB 微块上,snappy 和 lz4 速度快但压缩率低,zlib 和 zstd 压缩率高但慢,lz4 比 snappy 更快、zstd 比 zlib 又快又省。选哪个,是在"合并期 CPU 预算"和"磁盘空间"之间做一个连续可调的选择。
但"压缩后数据不能随机访问"这条通用压缩的固有性质,在 OB 这里并没有变成整体缺陷,因为 encoding 微块自己就支持随机访问。官方(第 15282 行)说:"在 encoding 微块中,数据是可以随机访问的,当需要读微块中的一行数据时,可以只对这一行数据进行解码,避免了部分解压算法读一部分数据要解压整个数据块的计算放大;在向量化执行的过程中也可以对指定的列进行解码,降低了投影的开销。"实现的机理是 encoding 微块内部被切成两个区:编码后的定长数据存放在列存区 ,一部分变长数据仍然按行存在变长区 (第 15281 行)。列存区里的每一列有自己的 ObColumnHeader,里面记着 offset_ 和 length_(ob_block_sstable_struct.h:230、231),所以定位"第 3 列第 500 行"只需要算一次偏移就能直接跳到。这个设计让"压缩"和"随机访问"这对通常互斥的性质在微块这一层和解了:压缩整体仍在(省 IO),随机访问靠编码内部结构找回来(省 CPU)。代价是变长数据被单独拎出去按行存,行与列两种布局混在一个块里,解码器要同时理解两套偏移规则------这也是为什么 ObMicroBlockHeader 里要同时有 row_index_offset_(flat 用)、row_data_offset_(encoding 用)和 var_column_count_(pax 用)这一堆看似重复的字段(ob_micro_block_header.h:131、第 122 行)。
这里有个反直觉的推论:通用压缩的"不可随机访问"其实是被 encoding 提前化解的,所以 OB 敢在微块上叠一层强压缩算法。如果 OB 只有 flat 微块 + zstd,那么"读一行要先解压整个 16KB 微块"这件事会直接毁掉点查延迟,那时它只能被迫选 lz4 这种低压缩率高速度的算法。正因为 encoding 微块把随机访问能力补了回来,它才有底气在通用压缩层选 zstd / zlib 这类高压缩比的算法。这是两级设计之间的相互成全:不是简单地"压两次",而是"第一层负责让第二层可以放心地激进"。代价同样实在------官方承认(第 15326 行)"encoding 也会带来一些问题,其中最直接的是编码解码带来的额外开销,包括合并过程中带来的 CPU 计算压力,查询逐行迭代时复杂的解码带来的 CPU 开销和解码器本身的开销等",OB 的应对是"将解码器和对应的数据一起 cache 在内存中",但对复杂编码,"解码本身带来的额外性能开销也是无法避免的"。所以这套设计的账不是"零成本高收益",而是把成本从"写入路径"和"IO 路径"挪到了"合并期的 CPU"和"缓存命中率"上。
8. 从写满到基线的触发链
前面讲的是静态格式------数据和它落盘后的样子。现在把动态过程接起来:一块 MemTable 从"能写"到"变成基线的一部分",中间到底经过了谁的手、按什么顺序。这条链路是一串严密的因果:MemTable 写入量涨到租户 MemStore 上限的 freeze_trigger_percentage(默认 20,ob_parameter_seed.ipp:547)比例,触发冻结;冻结的驱动是每个日志流上的 ObFreezer(src/storage/ls/ob_freezer.h);冻结完成后由合并任务做 MINI_MERGE 把 MemTable 刷成 L0 层 Mini SSTable;L0 层 Mini 数量到阈值后由 MINOR_MERGE 合成 L1 层 Minor SSTable;每天业务低峰期,MemTable、L0、L1 与旧基线一起做 MAJOR_MERGE(或 MEDIUM_MERGE、INC_MAJOR_MERGE)重写成新基线。这一段因果里每一步都有明确的源码或文档依据,但有两个地方最容易讲混,得掰开。
第一个容易混的是 MINI_MERGE 与 MINOR_MERGE 的区别,前面第一节已经用枚举注释钉死:前者只刷 MemTable,后者把多个 Mini 合成一个更大的(ob_compaction_util.h:16、19)。要注意的是,"转储"和"合并"在 OB 里不是一件事,也不共享同一种调度语义。转储(Mini Merge)是单个 Tablet 的局部、频繁动作,它不改变基线,只是把易失的增量搬到磁盘上;合并(Major Merge)是租户级的全局动作------官方说"每个租户的每次合并都会选取一个全局的快照点,租户内所有的分区都会用这个快照点的数据做一次 Major Compaction",昂贵、低频,而且顺带提供了一个天然的数据校验点:所有副本和主表/索引表都基于同一个快照版本重写,可以据此做多维度的物理数据比对。ObMergeType 的完整家族(ob_compaction_util.h:13)远不止这三种,还有 MDS_MINI_MERGE / MDS_MINOR_MERGE(Meta 租户数据专用)、BACKFILL_TX_MERGE(事务回填)、DDL_KV_MERGE(DDL 专用)、CONVERT_CO_MAJOR_MERGE(行转列)、INC_MAJOR_MERGE(增量合并)。这些值一个都不能删,因为每一个都对应一条走不同代码路径、带不同准入条件的合并任务。合并还有第二个维度:ObMergeLevel(ob_compaction_util.h:138)只有 MACRO_BLOCK_MERGE_LEVEL 和 MICRO_BLOCK_MERGE_LEVEL 两个值,正好对应下面要讲的增量合并的两个层次。
第二个容易混的是冻结的两个粒度,也就是"一次到底冻多少"。很多讲 OB 的文章只提"冻结",好像冻结天然就是全局动作,但源码里它是一对语义完全不同的入口,选错了粒度会让紧急场景被日常转储拖住,也会让一次本该全局一致的动作被拆成很多个互不相干的小动作。区分这两个粒度不只是理解名词,它决定了你排查"为什么这个 Tablet 卡在冻结状态"时该去看哪条路径。ObFreezer 为此提供了两个语义不同的入口(ob_freezer.h:197、198):
cpp
int logstream_freeze(const int64_t trace_id);
int tablet_freeze(const int64_t trace_id,
const ObIArray<ObTabletID> &tablet_ids,
const bool need_rewrite_meta,
ObIArray<ObTableHandleV2> &frozen_memtable_handles,
ObIArray<ObTabletID> &freeze_failed_tablets);
logstream_freeze 冻结整个日志流下的所有 Tablet,tablet_freeze 只冻结调用方点名的那个 Tablet 集合(还带一个"是否需要重写元数据"的开关)。粒度不同、代价不同、紧急程度也不同:日志流级冻结要停下整条流,影响所有 Tablet,通常只在更急的场景才发起;Tablet 级冻结只涉及点名的几个分区,开销小、频次高,是日常转储的主力,一条流上一个 Tablet 写热了就能触发。正因为两者会互相抢资源、也都可能在同一时刻排队,优先级才在源码里被明确写死(ob_freezer.h:389):
cpp
// make sure ls freeze has higher priority than tablet freeze
int64_t high_priority_freeze_cnt_; // waiting and freeze cnt
int64_t low_priority_freeze_cnt_; // freeze tablet cnt
日志流级冻结走 high_priority_freeze_cnt_,Tablet 级走 low_priority_freeze_cnt_,前者优先。为什么日志流冻结优先级更高?因为它通常出现在更紧急的场景(日志流迁移、租户内存吃紧时的全流冻结),而 Tablet 级冻结多是单表触发的日常转储,可以等。用一个高低优先级计数器而不是锁来排优先级,是为了避免高优先级任务被低优先级任务堵在锁上。冻结状态本身也被压成一个 32 位整数 freeze_flag_(ob_freezer.h:373):最高位是"是否在冻结",低 31 位是冻结时钟,每冻结一次加一。把"状态"和"代数"压进一个原子变量,好处是判断"某次冻结是不是当前这次"只需要一次原子读,不用额外加锁。get_freeze_clock() 就是低 31 位(ob_freezer.h:223)。
真正让 OB 区别于传统 LSM 的,是合并时的增量复用 。官方说得很到位:"宏块是 OceanBase 数据库基本的 IO 写入单位,在很多情况下,并不是所有的宏块都会被修改,当一个宏块没有增量修改时,合并可以直接重用这个数据宏块,OceanBase 中将这种合并方式称之为增量合并......更进一步地,OceanBase 会在宏块内部将数据拆分为更小的微块,很多情况下,也并不是所有的微块都会被修改,可以重用微块而不是重写微块。"这段话解释了一个很实际的问题:为什么 OB 不需要每次全量重写。因为大部分数据在两次合并之间根本没被碰过,而那些没被碰过的宏块在物理上仍然有效,直接在新基线里引用它就行,不用读出来再写回去。第 14917 行补了一句定性:"没有更新的数据宏块不会被重新打开读取,这样能够尽可能减少合并期间的写放大,相较于传统的 LSM-Tree 架构数据库显著降低合并代价。"注意这里的表述精度------它省掉的不只是写,还有"打开读取",也就是连 IO 读都免了。这就是 MACRO_BLOCK_MERGE_LEVEL 和 MICRO_BLOCK_MERGE_LEVEL 两级复用的意义:宏块级复用解决"整块没变",微块级复用解决"块变了但里面的这个小块没变",两级叠加,增量合并的代价才能压到"只和真正被改的数据量成比例"。作为代价,合并器必须维护一份"哪些宏块/微块被增量碰到了"的映射,这份映射本身要占内存和 CPU,而且判断逻辑一旦出错就会写出错误的基线------所以增量合并对正确性的要求比全量合并高得多。
渐进合并 解决的是另一类问题:DDL。官方把这套逻辑讲成了完整的因果链:加列、减列、建索引这类 DDL 需要把所有数据重写一遍,如果在一次每日合并里做完,"对存储空间和合并时间都会是一个比较大的考验";所以 OB 把重写分散到多次合并里,轮次由 progressive_merge_num 控制,"假设渐进轮次设置为 60,那么一次合并就只会重写 60 分之一的数据,在 60 轮合并过后,数据就被整体重写了一遍"。租户级默认值 default_progressive_merge_num 在 ob_parameter_seed.ipp:590,默认 0。代价是"DDL 变更的最终生效时间被拉长了"------不做满 60 次合并,数据就不会全部转成新格式;收益是每次合并不至于把 IO 打满,DDL 对在线业务几乎无感。第 15560 行还提到,加减列的 DDL 变更是实时生效的,对存储数据的变更被延后到每日合并时才做,这也是为什么 ALTER TABLE ADD COLUMN 能在 OB 上瞬间返回:它改的是元数据,不碰数据。
这条链路的最后一环是长事务。纯 LSM 有一个老毛病:MemTable 在内存里、易失,一个事务改了 1GB 数据却迟迟不提交,这 1GB 就得一直待在内存里,很快把内存撑爆。官方把 OB 的解法写得很明确:"不同于其他 LSM-Tree 数据库,OceanBase 通过支持活跃事务的落盘保证用户的大事务/长事务的正常运行或回滚。"源码里的痕迹在 ObFreezer:try_freeze_tx_data_()(ob_freezer.h:319)配合 LS 上的事务服务与 data checkpoint 工作,含义是"一个未提交的长事务的中间数据,可以被转储到磁盘上、脱离 MemTable 的内存约束,但事务语义(未提交、可回滚)依然保持"。这是 LSM 架构里相当罕见的动作------通常数据一旦离开内存落盘,就假定它已经"完成写入",而 OB 让"未完成的事务"也能落盘,靠事务状态表和回滚段保证它事后还能被撤销或补全。代价是事务状态管理复杂了一个量级,收益是大事务终于能在 LSM 上跑起来。把这一环补上,前面"MemTable 容量是租户内存规划约束"的说法也要修正:约束是真实的,但被长事务落盘和限流阶梯共同松绑了一部分。
9. 横向对比
把 OB 的这套设计和两个经典邻居放一起,差异才显形。vs RocksDB 的 LSM 分层 :RocksDB 的经典配置是纯 leveled compaction,L0 满了压到 L1,L1 满了挑一个文件压到 L2,逐层往下,每层容量按倍数增长。这个模型的问题是 L0 → L1 的合并极其频繁,写放大严重;RocksDB 后来引入 universal compaction(也就是 tiered)来降低写放大,但不降读放大。OB 的做法是把两者按层级切分:最近数据(L0 Mini)用 tiered 语义,稳定一点的增量(L1 Minor)用 leveled 语义。这是同一个"写放大 vs 读放大"权衡的第三种解法------不是二选一,而是分层各取所需。它比 RocksDB 走得更远的地方在于,OB 把 tiered 限制在最小的一层,让最高频的写入路径免于跨层重写;代价是 OB 需要维护两个层级、两套合并策略,代码和调度复杂度都上去了(ObMergeType 里那十几个枚举就是账单)。另外 OB 的增量合并比 RocksDB 的 compaction 更"抠":RocksDB 的 compaction 会重写文件里所有涉及的 key,OB 是按宏块/微块判断"这块有没有被增量碰到",没碰到的直接复用物理块,连读都省。
vs InnoDB 的 B+Tree 与页/区/段:InnoDB 的根本假设是"数据原地更新"------一个页读进 buffer pool,改完再写回,主键唯一性靠 B+Tree 的聚簇索引直接保证;它的空间单位是 16KB 的页,64 个连续页是 1MB 的区,段是区组成的逻辑空间。有意思的是,16KB 这个数字在 OB 里也出现,但含义完全不同:InnoDB 的 16KB 页是"读写的基本单位",读一行要把它所在的整页读进来;OB 的 16KB 微块是"编码和压缩的单位",读一行可以只解码微块里对应的那一小段,不需要把整个微块解压。OB 确实借用了 InnoDB 的部分分层思想------官方说"传统数据库把数据分成很多页面,OceanBase 也借鉴了传统数据库的思想,把数据文件按照 2MB 为基本粒度切分为一个个宏块"------但读路径的关键差异在于:InnoDB 读一行,从聚簇索引一次定位、一次读出完整行;OB 读一行,往往要先在 MemTable 的版本链上找一遍、再从 SSTable 里解码一遍,最后归并两级结果(官方:"需要分别对 SSTable 和 MemTable 进行查询,并将查询结果进行归并")。所以 InnoDB 的读延迟更确定,OB 的读延迟更依赖多级 cache(Block Cache / Row Cache / Fuse Row Cache / Bloom Filter)来抹平"基线 + 增量"带来的额外跳。这是 append-only 换写性能、再用缓存换读性能的典型路径;它的隐含前提是工作集能进内存,一旦工作集远超内存、cache 频繁 miss,"基线 + 增量"的归并开销就会暴露------这也是为什么 OB 强调"准内存"而非"纯内存"。
vs ClickHouse 的列存编码 放在编码这一节对比更贴切。ClickHouse 是纯列存,编码器有 LZ4、ZSTD、Delta、DoubleDelta、Gorilla、T64、LowCardinality(字典)等,逻辑上和 OB 的列内编码高度重合:Delta 对应 INTEGER_BASE_DIFF,LowCardinality 对应 DICT,DoubleDelta 专门给单调递增的时间戳。差异有两点。其一,OB 是行列混存:encoding 微块内部按列编码,但"逻辑上仍然是一组行的数据存在微块里",所以它能同时服务两种负载------给 AP 做列向量化、给 OLTP 做点查;ClickHouse 是纯列式,点查单行要碰很多列文件,代价高。其二,OB 有列间编码------COLUMN_EQUAL / COLUMN_SUBSTR 这种"整列引用另一列"的编码,在 ClickHouse 里没有对应物(ClickHouse 的编码都是列内的)。列间编码能吃到"业务表设计产生的跨列冗余"(比如两个几乎相同的冗余字段、复合列的前缀关系),这是 OB 特有的一张牌。但这张牌有明确代价,官方(第 15304 行)自己就承认:"这种列间编码在编码和解码时都会更复杂,编码时需要对不同列数据是否符合编码规则进行探测,解码时需要根据引用访问被引用的列的数据,进行处理后解码出数据,相对于其他的编码对 cpu 不友好一些。"读一列 COLUMN_EQUAL,你实际上要跳到另一列的内存里去取,多一次指针解引用,对 CPU cache 和分支预测都不友好;再叠加官方提到的"级联引用"(A 引用 B、B 引用 C),解码要递归处理。所以列间编码不是免费红利,它用查询时的 CPU 换存储时的空间。
10. 代价与反直觉点
这套设计不是白拿的,有三笔账必须付。第一笔账是列间编码的收益 vs 解码开销 ,上一节已经算过:它用解码时的指针跳转和多一层的递归处理,换存储空间的压缩率,官方承认"对 CPU 不友好一些",并说明应对手段只是"将解码器和对应的数据一起 cache 在内存中","对于一些复杂的编码格式,解码本身带来的额外性能开销也是无法避免的"。这是一个没有完美解的取舍,OB 的选择是"默认敢用,但把代价标在明面上"。第二笔账是多一层 L0/L1 的复杂度 vs 单层的诱惑 。你可能会想,既然 L0 和 L1 都是"非基线增量",为什么不合并成一层、简化逻辑?因为两层的读放大和写放大诉求相反:只有一层 tiered,L1 上会堆积大量小 SSTable,范围扫描要把它们全部打开归并,读放大失控;只有一层 leveled,每次 MemTable 转储都要触发和 L1 的归并,写放大上去。分两层,L0 承接高频写(tiered,容忍读放大),L1 承接相对稳定的增量(leveled,控制读放大),是用结构复杂度换两种放大的同时可控。第三笔账是压缩只在合并期发生的两面性。官方说得很透:"在传统的 B 树存储结构下的数据库中,数据压缩可能会给数据写入带来 CPU 的计算压力,影响写入性能。但 OceanBase 的 LSM-Tree 架构使数据的压缩只发生在 Compaction 阶段,不会影响数据的写入。"反过来读就是代价:你新写入的数据在转储或合并之前,是以未编码(或简单编码)的形态待在 MemTable 里的,MemTable 不享受压缩红利,它对内存的占用接近原始大小。所以 MemTable 的容量成了租户内存规划的核心约束,也解释了为什么 OB 的 LSM 要设计得这么激进地压缩------压缩只发生在后台、前台写入零成本,那当然要把后台的压缩做到极致。收益与代价被 LSM 的"读写分离"结构恰好分到了两个时间窗口上。代价还有一面:编码带来的 CPU 开销在合并期是真实存在的(第 15327 行:"合并过程中带来的 CPU 计算压力"),所以合并不能太频繁,否则 CPU 会被编码探测吃满。
最后是那个反直觉的坑:很多人(包括写这篇旧版时的我)默认"内存表 = 有序结构 = B+ 树",而且官方文档中确实写着"MemTable 使用的是 B+ 树结构"。但源码是实然,文档是应然,这里对不上,判断依据很硬:ObQueryEngine 里明明有 keyhash_(ob_query_engine.h:250)和 keybtree_(第 248 行)两个成员,get() 走哈希、scan() 走 B-Tree,这种双索引不是 B+ 树的形态。更反直觉的是,5.0 里连这个双索引都不是必然------_enable_memtable_hash_index 默认开着,但可以关,关掉之后 ObMvccEngineWithoutHashIndex 会把点查也改成走 B-Tree。所以最准确的说法是:OB 的 MemTable 内部是一个"哈希表 + B-Tree"的双索引结构,哈希优化点查、B-Tree 支持范围扫描,代价是同一份 key 在两种结构里各存一份;而这个结构在 5.0 已经降级成一个可调选项,文档里的"B+ 树"只是其中一个特例的近似说法。第二个坑同样值得记:一次 SELECT ... FOR UPDATE 在 MemTable 里留下的节点只包含主键,不包含任何列数据 (ob_memtable.cpp:2716 的 write_lock_rowkey),它的 dml_flag_ 是 DF_LOCK。如果你按"每个节点至少存一列"的直觉去推算内存占用,会高估;如果你按"锁行也要存整行"去推算,会高估得更多。稀疏存储的稀疏程度,比那句注释字面上说的还要极端。
小结
这篇文章的主线是"稀疏"两个字,但它展开成了三个互相咬合的层次。最底层是 MemTable 的存储形态:它用哈希表加 B-Tree 的双索引装下所有增量(ob_query_engine.h:26),每个 key 在索引里只出现一次、无论被改过多少版本,而行的内容挂在 ObMvccRow 的双向版本链上(ob_mvcc_row.h:267),链上每个 ObMvccTransNode 只保存这次操作真正改变的列(ob_mvcc_row.h:61),靠 dml_flag_ 区分操作类型(ob_datum_row.h:38)、靠 acc_checksum_ 在无锁遍历时自检一致性(ob_mvcc_row.cpp:31)。这个设计让写的路径极省,代价全部押在读路径的"聚合"上,靠 row_compact 和懒索引 INDEX_TRIGGER_COUNT = 500 回血;它的深层动机不只是省内存,更是为将来把行存转成列存(CONVERT_CO_MAJOR_MERGE)预埋一份按列切开的增量。
中间层是落盘的物理与编码。宏块 2MB 定长管 IO、微块 16KB 变长管压缩(ob_opt_default_stat.h:25、ob_data_store_desc.h:349),宏块头上用 MacroBlockType 标明这块是什么(ob_macro_block_common_header.h:27),微块头上带着列元信息与三级长度刻度(ob_micro_block_header.h:17)。编码从十种列内加两种列间里按"数据分布 + 类型适用性 + 收益比较"三层筛子选出来(ob_block_sstable_struct.h:196、ob_micro_block_encoder.cpp:1311、1596),初筛在 prescan 里用一张按行数两倍建桶的哈希表做基数统计(ob_micro_block_encoder.cpp:1188),相邻微块之间复用上次的编码选择(ob_micro_block_encoder.cpp:1438),并用 acceptable_size = calc_size() / 4 这个提前退出阈值把探测成本控在可接受范围内。编完之后再叠一层通用压缩,两层各吃一类冗余;而正是 encoding 微块内部的列存区/变长区结构,让"压缩过"的块仍然支持随机访问,OB 才敢在通用压缩层选 zstd/zlib 这类高压缩比的算法。
最上层是动态的触发链路:写入量到 freeze_trigger_percentage(默认 20)→ ObFreezer 冻结(先置标志、再提交冻结日志、再等 ready_for_flush_ 三个条件归零)→ MINI_MERGE 刷成 L0 Mini → MINOR_MERGE 合成 L1 Minor → MAJOR_MERGE / INC_MAJOR_MERGE 重写基线。冻结动作被下沉到 local_allocator_ 上(ob_memtable.h:253),用 protection clock 与 retire clock 这对时钟换"冻结瞬间的并发写不丢不乱";合并不必每次全量重写,靠宏块级与微块级两级增量复用(ob_compaction_util.h:138),DDL 的重写则被 progressive_merge_num 摊到多轮里。这一整套的代价是明确的:列间编码用查询 CPU 换空间,双层 L0/L1 用结构复杂度换读写放大的同时可控,压缩只在合并期做换来前台写入零 CPU,代价是 MemTable 不压缩、容量成为内存规划的硬约束,而这一切能被接受,靠的是多级缓存、长事务落盘和限流阶梯这三层补偿。读源码时如果只记住"OB 是 LSM",你会漏掉它真正的性格------它是一个用稀疏增量做写、用两级编码做存、用分层合并做收敛的引擎,每一处取舍都能在某个常量、某个枚举、某句注释里找到出处。