存储引擎上:Log Stream 与 Tablet 的元数据世界

0 阅读51分钟

存储引擎上:Log Stream 与 Tablet 的元数据世界

在任意一个 OceanBase 租户里查 CDB_OB_TABLET_TO_LS,你会看到一种很不对称的分布:左边 TABLET_ID 是十亿量级的大整数,右边 LS_ID 却常常只有 1001、1002、1003 这种小数字。一张十万分区的表,十万个分片被塞进个位数的日志流里。上一篇讲完了这套架构的动机——把 Paxos 组的数量从“数据量级”压到“机器量级”——但动机只是故事的一半。真正决定这套架构能不能跑起来的,不是 Paxos,而是元数据。

十万个 Tablet 意味着十万份“我是谁、我住在哪个日志流、我的 schema 是哪个版本、我的数据文件在磁盘的哪个位置”的记录。这些记录如果全躺在磁盘上,每读一行数据都要先做一次随机 IO 把元数据捞回来,OLTP 的延迟会当场崩掉。反过来,如果全部常驻内存,十万份记录哪怕每份只有几百字节,一台机器也是几十 MB,乘上几百台机器和多个租户就是 GB 级的纯元数据开销;更麻烦的是,你还得额外写一套机制,保证内存里的那份和磁盘上的那份永远一致——迁移、分裂、合并、重启、回放,任何一环出岔子,两边就对不上。

所以这一篇要回答的问题,本质上是三个:一个日志流在内存里到底是一坨什么东西,为什么它必须被组织成一个叫 ObLS 的聚合根;一个分片的元数据到底存了哪些字段,其中 ls_id 这个字段凭什么成为整条路由链的原点;以及,OceanBase 究竟用什么结构,让几十万个分片的元数据既能大部分常驻、又不会把租户内存吃穿。至于 LSM-Tree 的四层结构、MemTable 怎么落盘、转储合并怎么调度,那是下一篇的事——本篇只讲到“每个 Tablet 手上有一棵自己的树”为止,树里面长什么样留给下一篇。

ls-tablet.png

1. 路由链的物理落点

官方文档在《系统原理》中(对应 V4.4.2 参考指南)把“数据在哪”这件事拆成了三段,分别落到三张系统视图上。原文是这样写的:oceanbase.CDB_OBJECTS 指定 OBJECT_TYPE 为 TABLE、TABLE PARTITION 或 TABLE SUBPARTITION,可以获取对应分区的 DATA_OBJECT_ID,即分区对应的分片 TABLET_ID;oceanbase.CDB_OB_TABLET_TO_LS 可以获取分片所属的日志流,日志流在租户下通过 LS_ID 唯一标识;oceanbase.CDB_OB_LS_LOCATIONS 则获取日志流的副本类型及位置。官方紧接着给出了结论式的表述:“如果需要获取分区数据的 Location 信息,就需要知道分区对应的分片、分片与日志流的映射关系,以及日志流副本的位置信息。”

把这三句话翻译成一条执行路径,就是 4.x 的完整路由链路:SQL 层解析出一个分区,先拿到它的 tablet_id(也就是视图里的 DATA_OBJECT_ID),再用 tablet_id 查出它属于哪个 ls_id,最后用 ls_id 查出这个日志流的 Leader 副本落在哪台机器,把请求发过去。和老架构相比,“用分区号直接查副本位置”的一步被拆成了四跳。多出来的两跳不是设计者闲得慌,它换来的能力是:分片可以在机器之间自由搬家,而完全不需要动 Paxos 组。因为 Paxos 组的边界是日志流,分片搬走只是换了一个宿主,复制成员一个都没变。

这条链路上有一个容易被忽略的工程细节:中间那两跳并不是每次都去查系统表。CDB_OB_TABLET_TO_LS 是给人和工具看的字典视图,真正的读路径走的是每个 observer 进程内的一份 Location Cache(负责“刷新及缓存本机需要的分区 Location 信息”)。也就是说,路由的实时性靠进程内缓存保证,字典视图只是同一份事实的对外投影。这套设计的代价在讲分布式篇里会详细展开——缓存就有失效问题,而 tablet → ls 这段映射在 Tablet 迁移时会变,缓存必须能感知。

2. ObLS 这个聚合根

打开 src/storage/ls/ob_ls.h,ObLS 的类声明第一行就暴露了它的地位。之所以先看类声明而不是先看方法实现,是因为这个类的“重量”完全体现在基类、友元和静态常量上——它决定了后面的几十个成员为什么必须被组织在一起:

class ObLS : public common::ObLink
{
  friend ObLSLockGuard;
  friend class ObFreezer;                        // 冻结
  friend class checkpoint::ObDataCheckpoint;     // 数据检查点
  friend class ObLSSwitchChecker;                // 主从切换校验
public:
  static constexpr int64_t TOTAL_INNER_TABLET_NUM = 4;
  static const uint64_t INNER_TABLET_ID_LIST[TOTAL_INNER_TABLET_NUM];
  static const share::SCN LS_INNER_TABLET_FROZEN_SCN;

它继承 common::ObLink,说明它要被串进某个链表里被统一管理——租户级的 LS 管理器持有这台机器上该租户的所有 LS。friend 列表只放了三个组件(ob_ls.h:194-197),都是和 LS 生命周期强耦合、需要直接触碰私有状态的协作方:ObFreezer 要在冻结时读写 LS 内部状态,ObDataCheckpoint 要推进检查点,ObLSSwitchChecker 要在切主时校验数据是否落盘到位。而 TOTAL_INNER_TABLET_NUM = 4 这个常量是第一眼就该记住的信号:每个日志流自带 4 个内置 Tablet,第五节展开。

真正说明问题的是私有段里那张成员表(ob_ls.h:1115-1224)。源码注释把每一块的用途都写清楚了:ls_tablet_svr_ 上面写着 “table manager: create, remove and guard get”,是这一篇的第二个主角;log_handler_ 上面写着 “log service for ls”,是日志流对外部世界(Palf)的唯一接口;ls_tx_svr_ 是 “trans service for ls”;再往下依次是 replay_handler_(回放)、restore_handler_(恢复)、checkpoint_executor_(检查点执行器)、ls_freezer_(注释 “for ls freeze”)、gc_handler_(“for GC”)、ls_restore_handler_、tx_table_、data_checkpoint_、lock_table_(注释 “for lock table”)、ls_sync_tablet_seq_handler_、ls_ddl_log_handler_、keep_alive_ls_handler_、dup_table_ls_handler_、ls_wrs_handler_(弱读)、ls_migration_handler_、ls_remove_member_handler_、ls_rebuild_cb_impl_、tablet_gc_handler_、tablet_empty_shell_handler_、reserved_snapshot_mgr_、ls_recovery_stat_handler_、member_list_service_、block_tx_service_、tablet_ttl_mgr_,最后是 transfer_handler_ 和 reorg_info_table_。几十个成员,每一个都对应一件“这个日志流必须负责的事”。

把这些成员按职责归类,能看出四个层次:存储与分片服务(ls_tablet_svr_)、日志与复制(log_handler_、role_change_handler_)、事务基础设施(ls_tx_svr_、tx_table_、lock_table_、block_tx_service_)、以及一整套“容灾与生命周期”处理器(冻结、检查点、GC、迁移、重建、成员变更、TTL)。这份清单本身回答了一个问题:为什么 ObLS 这么“重”。因为它不是数据容器,它是一致性域——凡是要和这个日志流的数据变更保持同一个提交顺序、同一份日志、同一个 Paxos 组的东西,都必须挂在这里。

真正承担“一个 LS 内所有分片操作”的,是挂在 ObLS 上的 ObLSTabletService(src/storage/ls/ob_ls_tablet_service.h)。看它的类声明会发现一个有意思的地方(ob_ls_tablet_service.h:102-104):

class ObLSTabletService : public logservice::ObIReplaySubHandler,
                          public logservice::ObIRoleChangeSubHandler,
                          public logservice::ObICheckpointSubHandler

它同时继承了三个日志订阅接口,对应三件大事:回放(replay,从 clog 恢复 Tablet 状态)、主从切换(switch_to_leader / switch_to_follower_forcedly / resume_leader)、检查点(flush / get_rec_scn,决定哪些日志可以回收)。这意味着 Tablet 的生命周期事件不是由某个独立模块推动的,而是作为日志流的订阅者被驱动——LS 收到一条日志、发生一次切主、推进一次检查点,回调就落到这里。上游要拿一个 Tablet,路径也很直白(ob_ls_tablet_service.cpp:587-594):

  const ObTabletMapKey key(ls_->get_ls_id(), tablet_id);

一次调用就把当前 LS 的 ls_id 和传入的 tablet_id 拼成二元 key 去查索引——ls_id 在这里不是装饰,它是 key 的一半。同文件私有段里是 ObTabletIDSet tablet_id_set_(ob_ls_tablet_service.h:1353)和 get_tablet_count() 直接返回 tablet_id_set_.size()(ob_ls_tablet_service.h:1360-1363),所以“这个 LS 有多少分片”是 O(1) 拿到的,因为 LS 里维护着一份明确的全集,而不是靠遍历索引去数。

那么为什么事务表、锁表这些不能做成全局的?因为它们保护的数据就在这个 LS 里。行锁的对象是本 LS 的某一行,事务的提交顺序由本 LS 的日志决定;如果锁表全局共享,一次 LS 级切主的影响会扩散成全租户的锁表重建,而且锁状态与数据状态的提交顺序无法对齐。同理,ObFreezer 必须以 LS 为单位推进冻结(ob_freezer.h 里同时有 logstream_freeze 和 tablet_freeze 两条接口),因为一次冻结要在同一个日志流内产生一个所有 Tablet 都能共享的 SCN 边界。log_handler_ 挂在 LS 上更是必然:ob_ls.h 里有大段 DELEGATE_WITH_RET(log_handler_, append, int)、remove_member、change_replica_num、add_arbitration_member 这样的透传宏(ob_ls.h:806-837),上层拿一个 ObLS 就能写日志、改成员、加仲裁副本,不需要知道 Palf 的存在。

3. 运行态与持久态

ObLS 上挂着两个状态成员,很容易看漏:ObLSRunningState running_state_;(ob_ls.h:1197)和藏在 ls_meta_ 里的 ObLSPersistentState。它们在 src/storage/ls/ob_ls_state.h 里各自是一个独立类,各自一套状态和迁移动作。这个双状态机设计值得单独看,因为它解释了“为什么一个日志流的状态不能用一个枚举表示”。

运行态只有 5 个值(ob_ls_state.h:52-56):LS_INIT = 0、LS_RUNNING = 1、LS_OFFLINING = 2、LS_OFFLINED = 3、LS_STOPPED = 4,对应动作是 CREATE_FINISH / ONLINE / PRE_OFFLINE / POST_OFFLINE / STOP。注意它把下线拆成了 LS_OFFLINING 和 LS_OFFLINED 两拍——中间那段时间用来做正事:ObLS::offline_() 会依次把合并、事务、epoch 逐个停掉,而不是一次布尔翻转就宣告下线。持久态是另一套(ob_ls_state.h:186-190):LS_INIT = 0、LS_NORMAL = 1、LS_CREATE_ABORTED = 2、LS_ZOMBIE = 3、LS_HA = 4,并且带一个 is_need_gc() 判定(ob_ls_state.h:158-163),把 LS_INIT、LS_ZOMBIE、LS_CREATE_ABORTED 圈成“可回收残骸”。

多出来的 LS_CREATE_ABORTED(创建中途流产)和 LS_ZOMBIE(僵尸)这两个值,服务的是崩溃恢复:一个 LS 可能“创建到一半崩了”,重启后磁盘上留着一个半成品,必须先识别出来回收掉才能重新创建。运行态不需要这两个值,因为活着的进程里不存在“半成品 LS”——要么在跑,要么没在跑。两套状态机分开的根本原因,是它们的语义域不同:运行态描述“现在能不能对外服务”,纯内存、易失、重启归零;持久态描述“磁盘上的持久身份是什么”,必须随 SLOG 落盘、能被回放重放。合成一个状态,就会出现“磁盘上写的是 LS_OFFLINED、内存里想置成 LS_RUNNING”这种无法自洽的情形。

ls_meta_ 旁边还有一行值钱的注释,它把“状态”和“版本号”这两个本来要分开存的语义合进了一个十六进制变量里(ob_ls.h:1200):

  uint64_t switch_epoch_;// started from 0, odd means online, even means offline
  ObLSMeta ls_meta_;

用奇偶表示在线/下线,而不是“一个 bool 加一个计数器”。这不是为了省那 7 个字节,而是为了把“状态”和“版本号”压进同一个变量:每切换一次 epoch 就 +1,上层拿一个 epoch 值做完事再回来校验,发现变了就重试。一次 CAS 同时完成了状态判断和乐观并发校验。

状态的迁移顺序也被接口顺序固定下来。ob_ls.h:246-264 里依次是 init、start、stop、wait、prepare_for_safe_destroy、safe_to_destroy、destroy,再往后才是 offline 和 online。其中 start() 上面留了一句源码作者的口吻——“I am ready to work now.”——它标出的是“初始化完成”与“开始对外服务”的分界。值得注意的是 prepare_for_safe_destroy() 和 safe_to_destroy() 这一对,它们对应的是两阶段析构:先把 LS 从服务里摘出去、停止接受新请求,再确认所有在途引用都还回来了,才真正 destroy()。确认引用归零靠的是 ObLS 上的 common::ObMultiModRefMgr<ObLSGetMod> ref_mgr_(ob_ls.h:1204),一个按访问意图分模的引用管理器。为什么不能直接 delete?因为 ObLS 被大量上层结构以裸指针加引用计数的方式持有(ObLSHandle),一个 SQL 线程完全可能正拿着 handle 读某个 Tablet,此时 delete 就是 use-after-free。两阶段析构是“对象生命周期跨越多个并发上下文”时的标准答案,代价是必须保证每一个持有者都真的会把引用还回来,而这份信任需要泄漏检查机制在旁边兜底。

4. ObLSMeta:磁盘上的身份

ObLSMeta(src/storage/ls/ob_ls_meta.h)是“日志流在磁盘上的样子”,用 OB_UNIS_VERSION_V(1) 声明了序列化版本(ob_ls_meta.h:49)。它的字段可以分成四组:身份(tenant_id_、ls_id_、replica_type_、ls_persistent_state_)、日志回收点(clog_checkpoint_scn_ 与 clog_base_lsn_,两者都写成 union,共享存储模式下的 ss_checkpoint_scn_ / ss_checkpoint_lsn_ 复用了同一块存储)、迁移与恢复(migration_status_、restore_status_、replayable_point_、rebuild_seq_、transfer_meta_info_、transfer_scn_)、以及 GC 与离线(gc_state_、offline_scn_)。此外还有一组和 ID 分配、物化视图相关的字段,比如 transaction::ObAllIDMeta all_id_meta_(ob_ls_meta.h:243)和 ls_epoch_(ob_ls_meta.h:250)。

其中最值得逐字读的,是那对回收点坐标上的注释——它用四行英文把“什么日志可以删”“从哪条日志开始回放”这两件事一次讲完了,是整个日志回收机制唯一的权威口径:

  // clog_checkpoint_scn_, meaning:
  // 1. dump points of all modules have exceeded clog_checkpoint_scn_
  // 2. all clog entries which log_scn are smaller than clog_checkpoint_scn_ can be recycled
  union { share::SCN clog_checkpoint_scn_; share::SCN ss_checkpoint_scn_; };
  // clog_base_lsn_, meaning:
  // 1. all clog entries which lsn are smaller than clog_base_lsn_ have been recycled
  // 3. clog starts to replay log entries from clog_base_lsn_ on crash recovery
  union { palf::LSN clog_base_lsn_; palf::LSN ss_checkpoint_lsn_; };

第一,clog_checkpoint_scn_ 是所有模块落盘点的最小值。一个 LS 里有事务表、锁表、tx data 等好几个模块,每个模块各自维护“我已经把小于某个 SCN 的数据落盘了”的进度;LS 层的回收点必须取全体模块的最小值,否则就会把某个还没落盘的模块对应的日志删掉。第二,clog_base_lsn_ 是日志的物理回收边界,小于它的日志物理上已经被删。第三,崩溃恢复从这个 LSN 开始回放——所以这两个值必须成对更新、成对落盘,中途任何一个先动,都会导致“日志删了但 checkpoint 没推进”或者“checkpoint 推进了但日志被误删”的数据丢失。这里体现的正是第 00 篇讲过的“双坐标”:SCN 是逻辑时间,LSN 是物理位置,缺一不可。

ObLSMeta 上挂的锁是两把而不是一把,这个“多出来的一把”很能说明设计者对读路径延迟的在意程度,注释也把分野写得很干脆(ob_ls_meta.h:205-206):

  mutable common::ObLatch rw_lock_;     // only for atomic read/write in memory.
  mutable common::ObLatch update_lock_; // only one process can update ls meta. both for write slog and memory

rw_lock_ 管的是“内存里的原子读写”,读多写少、要求低延迟;update_lock_ 管的是“更新 ls meta 这件大事”,它要保证写 SLOG 和改内存是同一次串行操作,防止两个线程各自写一半 SLOG。一个数据挂两把锁,是为了把高频读和低频写从同一片锁竞争里彻底剥开——如果只有一个写锁,每次读 ls_id 都要抢它,读路径会被低频的元数据更新拖死。这个取舍在别处也反复出现:ObLSTabletService 里的 common::ObBucketLock bucket_lock_; // for tablet update, not for dml(ob_ls_tablet_service.h:1354)把它推到了更细的粒度——桶锁只保护元数据更新(建/改/删 Tablet),不保护 DML。DML 的并发由行锁和 MVCC 负责,两者绝不能共用一把锁,否则一个写热点的行锁竞争会把建表、合并这些元数据操作一起拖住。

5. tablet_id 的号段

tablet_id 不是随意的自增整数,它的取值空间被切成几段,每段带明确语义,定义在 deps/oblib/src/common/ob_tablet_id.h,具体数值来自 deps/oblib/src/lib/ob_define.h:1114-1131:

  static const uint64_t MIN_USER_TABLET_ID = OB_MAX_INNER_TABLE_ID;              // 200000
  static const uint64_t MIN_LS_INNER_TABLET_ID = OB_MIN_LS_INNER_TABLE_ID;       // 49400
  static const uint64_t LS_TX_CTX_TABLET_ID     = MIN_LS_INNER_TABLET_ID + 1;    // 49401
  static const uint64_t LS_TX_DATA_TABLET_ID    = MIN_LS_INNER_TABLET_ID + 2;    // 49402
  static const uint64_t LS_LOCK_TABLET_ID       = MIN_LS_INNER_TABLET_ID + 3;    // 49403
  static const uint64_t LS_REORG_INFO_TABLET_ID = MIN_LS_INNER_TABLET_ID + 4;    // 49404
  static const uint64_t MAX_LS_INNER_TABLET_ID = OB_MAX_LS_INNER_TABLE_ID;       // 49500

配套的判定全靠一次整数比较(ob_tablet_id.h:60-76):is_ls_inner_tablet() 就是 id_ > 49400 && id_ < 49500,is_ls_tx_ctx_tablet()、is_ls_tx_data_tablet()、is_ls_lock_tablet()、is_ls_reorg_info_tablet() 各自就是一次相等判断。这里有个容易忽略的细节:内置 Tablet 只占了 49400~49500 这 100 个号,用户 Tablet 从 200000 起步,中间 49500 到 200000 是一段被刻意留空的区间,留给系统表和内部对象的其他号段。留空的好处是“判断一个 tablet 属于哪一类”永远不会出现区间重叠,判定退化成一次算术,不需要查任何表。

这种“把分类信息压进标识符本身”的做法,和 InnoDB 里硬编码 DICTIONARY_ 系列表 ID、Linux 里用主次设备号区分设备类型,是同一路思路。它的代价也一致:号段一旦对外发布就不能随便改,因为它已经写进了持久化数据和 rowid 的编码里。证据就在同一个文件(ob_tablet_id.h:28-30):MIN_USER_NORMAL_ROWID_TABLE_TABLET_ID = MIN_USER_TABLET_ID,MAX_USER_NORMAL_ROWID_TABLE_TABLET_ID = ((uint64_t)1 << 37) - 1,而扩展 rowid 表被限定在 1 << 60 到 1 << 61 之间。也就是说,rowid 里要嵌分区信息,就必须知道分区用的什么号段,否则算不出边界——rowid 的物理布局和 tablet_id 的取值是耦合设计的,这是“标识符承担语义”这条思路在更细一层的延续。

6. 每个日志流的四个内置 Tablet

ObLS 用一张常量表和一套迭代器把 4 个内置 Tablet 暴露出来,这张表的顺序就是内置 Tablet 的“出生顺序”,遍历 LS 内部 Tablet 时也按这个顺序走(ob_ls.cpp:76-82):

const share::SCN ObLS::LS_INNER_TABLET_FROZEN_SCN = share::SCN::base_scn();
const uint64_t ObLS::INNER_TABLET_ID_LIST[TOTAL_INNER_TABLET_NUM] = {
    common::ObTabletID::LS_TX_CTX_TABLET_ID,
    common::ObTabletID::LS_TX_DATA_TABLET_ID,
    common::ObTabletID::LS_LOCK_TABLET_ID,
    common::ObTabletID::LS_REORG_INFO_TABLET_ID,
};

这 4 个 ID 是硬编码常量,任何一个 LS 一旦创建就必须把它们建出来(ObLS::create_ls_inner_tablet,ob_ls.cpp:330),删掉 LS 时再一起回收(ObLS::remove_ls_inner_tablet,ob_ls.cpp:354)。它们分别承载什么,对照 ObLSTabletService 私有段里那四个 memtable manager 就能一一对上(ob_ls_tablet_service.h:1349-1352):

内置 Tablettablet_id对应的服务与数据
LS_TX_CTX_TABLET49401活跃事务上下文,对应 ObTxCtxMemtableMgr tx_ctx_memtable_mgr_ 与 ObLSTxService
LS_TX_DATA_TABLET49402事务多版本数据/undo,对应 ObTxDataMemtableMgr tx_data_memtable_mgr_ 与 tx_table_
LS_LOCK_TABLET49403锁表,对应 ObLockMemtableMgr lock_memtable_mgr_ 与 ObLockTable lock_table_
LS_REORG_INFO_TABLET49404重组信息(transfer / split 相关),对应 ObTabletReorgInfoTable reorg_info_table_

这是“日志流自洽”最关键的一步:一个日志流要支持事务,就需要事务表、锁表、活跃事务表这些基础设施,而它们本身也是“数据”,也需要被存储、被复制、被合并、被回收。OceanBase 没有给它们另造一套存储系统,而是让它们照样以 Tablet 的形态存在于这个 LS 内部,共享同一条 Redo 日志流。于是事务表和用户表用的是同一套 Tablet 生命周期、同一套日志复制、同一套合并调度——实现上是复用,语义上是“事务元数据随日志流一起复制”。代价很直接:这 4 个 Tablet 会在每个 LS 里各占一份,日志流一多,内置 Tablet 的总量就是 4 × LS 数。这也顺带解释了一个常见疑问:为什么一个空租户查 CDB_OB_TABLET_TO_LS 也有几十上百行——那些大多是每个 LS 的标配。

内置 Tablet 复用了 Tablet 的“壳”,但在多条数据面路径上被特殊化甚至直接跳过,这一点在源码里到处都是:备份时它们被单独归入 sys_tablet_id_list 而与用户数据分开(src/storage/backup/ob_backup_ctx.cpp:1633-1642);宏块生成布隆过滤时明确跳过(src/storage/blocksstable/ob_macro_block.cpp:168);行缓存、融合行缓存、布隆过滤缓存对内置 Tablet 一律不生效(src/storage/access/ob_table_access_context.h:151-163);Tablet 状态变更回放时直接 “do nothing”(ob_ls.cpp:2368);冻结也不走常规的 tablet_freeze 批量路径,而是转给 ls_freezer_.ls_inner_tablet_freeze(tablet_id) 单独处理(ob_ls.cpp:2501-2502)。所以更准确的表述是:内置 Tablet 与用户 Tablet 共用同一套形态与生命周期,但它们不进入用户数据的迁移、均衡、备份、缓存与合并常规数据面,是 LS 的“私有内脏”而非“可搬运的资产”。

合并策略也被编码进了判定函数,而不是写在一张配置表里。ob_tablet_id.h:66-76 定义了 is_only_mini_merge_tablet()(tx_ctx 与 lock)和 is_mini_and_minor_merge_tablet()(tx_data 与 reorg_info):事务上下文和锁表的数据基本“用完就扔”,只做 Mini Merge(把 MemTable 刷成 L0)就够;事务数据需要多版本,得做 Minor Merge。调度器扫到一个 Tablet,一次算术就知道该派哪种合并任务,不需要查表也不需要解析 schema。

7. ObTablet 的内存布局

ObTablet(src/storage/tablet/ob_tablet.h:217)是一个 final 类,不允许被继承。它的私有成员表把每个字段的大小和对齐都标了出来(ob_tablet.h:1213-1260),这是估算内存时最有用的一段:

  int32_t version_;                                          // 4B
  int32_t length_;                                           // 4B
  volatile int64_t wash_score_;                              // 8B
  ObTabletMdsData *mds_data_;                                // 8B
  volatile int64_t ref_cnt_;                                 // 8B
  ObTabletMeta tablet_meta_;                                 // 288B
  // in memory or disk
  ObTabletComplexAddr<ObTabletTableStore> table_store_addr_; // 56B
  // always in disk
  ObTabletComplexAddr<ObStorageSchema> storage_schema_addr_; // 56B
  ObTabletComplexAddr<ObTabletMacroInfo> macro_info_addr_;   // 56B
  ObTabletPointerHandle pointer_hdl_;                        // 24B
  ObMetaDiskAddr tablet_addr_;                               // 48B
  storage::ObIMemtable *memtables_[MAX_MEMSTORE_CNT];        // 128B

第一个关键信息在注释里:table_store_addr_ 标着 “in memory or disk”,storage_schema_addr_ 标着 “always in disk”。一个 Tablet 的元数据被明确划分成“必须常驻”和“可以只在磁盘”两类。288 字节的 tablet_meta_ 常驻,因为路由、版本比较、可见性判断高频地读它;而 Table Store(那一堆 SSTable 句柄)和 Storage Schema(列定义,可能几百字节到几 KB)被 ObTabletComplexAddr<T> 包裹,这个模板的语义就是“地址恒定,但指向的对象可能在内存、也可能在磁盘”。看它的三个判定函数就明白了(src/storage/tablet/ob_tablet_complex_addr.h:76-91):is_memory_object() 是 nullptr != ptr_,is_disk_object() 是 ptr_ == nullptr && (addr_.is_block() || addr_.is_sslog()),is_none_object() 是两者皆空。内存紧张时,大量“冷” Tablets 的 Table Store 和 Schema 就不进内存,只有在被访问到时才按地址加载回来——用一次 IO 换一份常驻内存,这是“十万级 Tablet 能塞进一台机器”的根本原因。

第二个关键信息是源码作者主动承认的一处抽象妥协,这段注释值得完整读一遍,因为它把“为什么 Tablet 上会出现一个本该由外部提供的指针”交代得很清楚(ob_tablet.h:1230-1240):

  // NOTICE: these two pointers: memtable_mgr_ and log_handler_,
  // are considered as cache for tablet.
  // we keep it on tablet because we cannot get them in ObTablet::deserialize
  // through ObTabletPointerHandle.
  // may be some day will fix this issue, then the pointers have no need to exist.
  // won't persist
  storage::ObIMemtable *memtables_[MAX_MEMSTORE_CNT];
  logservice::ObLogHandler *log_handler_;

log_handler_ 被当成“缓存”挂在 Tablet 上,原因是反序列化时拿不到它;ObTablet::get_log_handler() 的注释也留了一句 TODO(gaishun.gs): get log handler from tablet pointer handle(ob_tablet.h:968)。这意味着 ObTablet 不是一个纯粹的数据对象,它被迫背了一个只有反序列化上下文才能补上的指针。读源码时,“作者自己写下 TODO 的地方”是金矿——它告诉你这里的抽象是妥协出来的,未来某个版本会变。第三个信息是 memtables_ 用的是定长数组 ObIMemtable *memtables_[MAX_MEMSTORE_CNT] 而不是 ObSEArray:MemTable 的数量在编译期就封顶,内存布局确定、零分配开销,这和“每个 Tablet 一棵 LSM 树、树高有限”的模型吻合。

8. ObTabletMeta 的字段

ObTabletMeta(src/storage/tablet/ob_tablet_meta.h)只有 288 字节,却是整个存储层被读得最频繁的结构。它的字段在 TO_STRING_KV 里一次列全,按语义分几组,而最前面的四个字段就是重点(ob_tablet_meta.h:185-189):

  share::ObLSID ls_id_;                // alignment: 8B, size: 8B
  common::ObTabletID tablet_id_;       // alignment: 8B, size: 8B
  common::ObTabletID data_tablet_id_;  // alignment: 8B, size: 8B
  common::ObTabletID ref_tablet_id_;   // alignment: 8B, size: 8B
  share::SCN create_scn_;              // create_tablet_scn, not create_tablet_version_scn

ls_id_ 是本篇点名的那个字段。Tablet 自己知道“我住在哪个日志流里”,这件事看起来天经地义,但在 4.x 之前它不成立——那时分区和 Paxos 组是一回事,根本没有独立的“所属日志流”这个概念。现在 ls_id_ 写死在 ObTabletMeta 里、随 Tablet 一起持久化,带来两个后果。第一,tablet_id 单独不足以定位一个 Tablet,(ls_id, tablet_id) 才是完整坐标,证据就在内存索引的 key 类型上(src/storage/meta_mem/ob_tablet_map_key.h:33):

class ObTabletMapKey final
{
public:
  ObTabletMapKey(const share::ObLSID &ls_id, const common::ObTabletID &tablet_id);
public:
  share::ObLSID ls_id_;
  common::ObTabletID tablet_id_;
};

为什么不能只用 tablet_id 当 key?因为迁移:一个 Tablet 从 LS-A 搬到 LS-B 的过程中,两边可能短暂各有一个同 ID 的实体(源端待删、目的端待生效)。用二元组做 key,两个实体天然区分开,源端删完、目的端生效,索引里就是两次“不同 key 的增删”。如果只用 tablet_id,迁移中就会出现“同 ID 覆盖”的歧义,而覆盖一个正在被读的 Tablet 是很危险的事。第二,路由链是反向建立的:Location Cache 回答“这个 tablet 在哪台机器”,路径是 tablet_id → ls_id → ls 副本位置,中间这一跳的信息,物理上就存在 ObTabletMeta::ls_id_ 里,并通过 CDB_OB_TABLET_TO_LS 对外暴露。data_tablet_id_ 和 ref_tablet_id_ 则说明 Tablet 之间还有“归属”和“引用”两种关系(比如 LOB 的分片 Tablet 会指向它服务的数据 Tablet),Tablet 不是孤立的。

create_scn_ 那行注释同样值得停留:“create_tablet_scn, not create_tablet_version_scn”。创建时刻有两套时间——一个日志提交的 SCN,一个事务提交的版本号,源码在这里专门标出用的是前者,就是为了防止使用者在可见性判断里拿错值。这种“注释专门防误用”的地方,是读元数据代码时最容易踩坑也最该停下来看的地方。

再往下是一层“化石层”:max_sync_storage_schema_version_ 标着 “will be inaccurate after 4.2”(ob_tablet_meta.h:204),create_schema_version_ 标着 “add after 4.2 ... NEED COMPAT”(ob_tablet_meta.h:216),has_next_tablet_ 干脆写着 “(abandoned, don't use this field)”(ob_tablet_meta.h:242)。这些字段被弃用了却还留着,是因为元数据结构一旦落盘就不能随便删字段,否则旧数据读不出来。你在 ObTabletMeta 里看到的其实是一条“字段增生史”,TABLET_META_VERSION = 1 加上 VERSION_V1..V4 几套版本号(ob_tablet_meta.h:269)就是对付多版本兼容的工具。同一文件里还有一行所有改这个结构的人都会先看到的警告(ob_tablet_meta.h:238):

  //ATTENTION : Add a new variable need consider ObMigrationTabletParam
  // and tablet meta init interface for migration.

ObMigrationTabletParam 是一个和 ObTabletMeta 平行的结构,专门用于迁移,带 MAGIC_NUM = -20230111(注释解释:旧格式没有版本和长度头,而第一个成员 ls_id_ 是 8 字节且不可能为负数,所以用负数当魔数来识别新格式,ob_tablet_meta.h:356)。为什么要拆成两个结构而不是共用?因为迁移需要的信息和在线运行需要的信息不一样:迁移要把 Storage Schema、Medium Compaction 信息、Major Checksum 这些“重”内容一起带上,同时不需要某些运行期字段。拆开以后,在线路径的 288 字节保持精简,迁移路径按需带全。

9. TabletGroup 绑定

官方文档中给 Tablet 下定义时,顺手埋了一句很重的话:“索引表的每个分区也会对应一个 Tablet,包括局部索引表和全局索引表。特别地,局部索引表的 Tablet 与主表 Tablet 会强制绑定,保证存储在一台机器上。”这句“强制绑定”在源码里是一个独立的概念,由 MDS(Multi Data Source)框架承载,落在 ObTabletBindingMdsUserData(src/storage/tablet/ob_tablet_binding_mds_user_data.h)上,里面有 hidden_tablet_id_、lob_meta_tablet_id_、lob_piece_tablet_id_ 三个字段(ob_tablet_binding_mds_user_data.h:65-67)。

操作绑定关系的接口在 ObTabletBindingHelper(src/storage/tablet/ob_tablet_binding_helper.h):bind_hidden_tablet_to_orig_tablet(第 112 行)负责在创建时把局部索引 Tablet 绑到主表 Tablet,bind_lob_tablet_to_data_tablet(第 113 行)负责把 LOB 分片绑到数据 Tablet;反向的 unbind_hidden_tablets_from_orig_tablets 和 ObBatchUnbindTabletArg 负责 DDL 时的解绑重建。注意 ObBatchUnbindTabletArg 里那两个平行数组 orig_tablet_ids_ 和 hidden_tablet_ids_(ob_tablet_binding_helper.h:76-77):“orig(原始)”对应主表分区,“hidden(隐藏)”对应局部索引分区,绑定关系是双向写进各自 Tablet 的 MDS user data 里的,不是存在一张中心映射表里。ObBatchUnbindTabletArg::is_redefined()(判据是 schema_version_ != OB_INVALID_VERSION)和 ObSetRwDefensiveOp 这类接口则说明解绑和重新定义 schema 之间还需要一个“防写窗口”,防止旧定义下的写入落到正在改绑的 Tablet 上。

局部索引为什么必须和主表死死绑在一起?回到第 00 篇讲过的:局部索引的键里不含分区键,所以回表一定会落到同一个分区。如果索引 Tablet 和主表 Tablet 被分到两台机器,每次回表都是一次跨机 RPC,延迟放大几十倍。这不是“优化选项”,是“不这么做就不成立”的物理需求,所以 OceanBase 干脆把它写进元数据的绑定关系。代价是均衡自由度下降:主表 Tablet 和它的索引 Tablet 必须同迁同留,均衡器不能把它们拆到不同机器上去凑均匀。把“违背就会出致命性能问题”的约束写进元数据,比写在文档里可靠得多——文档会被忽略,绑定数据不会被忽略。这里的容量规划也要跟着变:集群里真实的 Tablet 数量是“用户分区数 × (1 + 局部索引局部数 + LOB 相关分片)”,而不是分区数本身。

TableGroup 是在更上层做“尽量让相关 Tablet 落进同一个 LS”的聚拢,官方给它定义了 NONE、PARTITION、ADAPTIVE、SCOPE 几种 Sharding 属性。它和绑定的关系是正交的:绑定是硬约束(局部索引 vs 主表,必须在一起),TableGroup 是软偏好(相关表的分区,希望在一起)。前者由 MDS 的 binding 数据强约束,后者由创建时的 LS 分配策略(聚拢 + 轮询)尽力实现,做不到也不会报错——这个“硬/软分层”是设计者刻意保留的弹性空间:如果把 TableGroup 也做成强约束,一张表的创建就会依赖一大票别的表的当前分布,DDL 的复杂度和失败率都会失控。

10. Tablet 的动态性

Tablet 的“生老病死”被建模成一组可提交、可回放的状态,枚举在 src/storage/tablet/ob_tablet_create_delete_mds_user_data.h:18-38:CREATE_TABLET = 1、REMOVE_TABLET = 2、START_TRANSFER_OUT = 3、START_TRANSFER_IN = 4、FINISH_TRANSFER_OUT = 5、FINISH_TRANSFER_IN = 6、START_TRANSFER_OUT_PREPARE = 7,以及分裂的 START_SPLIT_SRC = 8、START_SPLIT_DST = 9、FINISH_SPLIT_SRC = 10、FINISH_SPLIT_DST = 11。迁移和分裂都被拆成 start / finish 两拍,而且源端和目的端各有独立状态。

为什么不能一步到位?因为多副本加多 LS 的操作天生不是瞬时的:源 LS 上把 Tablet 标成 START_TRANSFER_OUT,目的 LS 上标成 START_TRANSFER_IN,数据传完两边再分别 FINISH_*。中间任何一步崩溃,重启后靠 SCN 回放这些 MDS 记录,就能判断该继续还是该回滚。ObTabletCreateDeleteMdsUserData 里同时存着一串 SCN 就是给回放当判据用的,其中 create_commit_scn_ 的注释写着 “remain unchanged throughout the entire tablet lifecycle”(第 87 行附近)——也就是说,无论这个 Tablet 后来怎么迁移、怎么分裂,它的“出生时刻”是永恒的,这正是“身份不变、归属可变”的形式化表达。这套“把多步骤操作显式建模成状态机、每一步留日志、崩溃恢复从中断处继续”的方法论,和 05 篇事务引擎里的 2PC 状态表是同一套。

接下来是那个最容易被误解的点:删一个 Tablet 不等于立刻释放内存。ObLSTabletService::remove_tablets 只是把它从 LS 的 tablet_id_set_ 里摘掉、推进 GC 队列;只要还有读线程持着 ObTabletHandle,对应的 ObTablet 对象就会一直漂着。管理这件事的是两样东西:一是那条挂在每个 Tablet 指针上的旧版本链 old_version_chain_,二是“飞行中”集合。ObTabletPointer::add_tablet_to_old_version_chain(src/storage/meta_mem/ob_tablet_pointer.cpp:650-675)把被替换下来的旧 ObTablet 头插进链表,并在插入前做一次“是否已经在链上”的防御性检查;而判定一个指针是否属于“飞行中”的逻辑只有两行(ob_tablet_pointer.cpp:143-152):

bool ObTabletPointer::need_push_to_flying_() const
{
  return (is_in_memory() && obj_.ptr_->get_ref() > 1) || OB_NOT_NULL(old_version_chain_);
}
bool ObTabletPointer::need_remove_from_flying() const
{
  return is_flying() && is_old_version_chain_empty();
}

也就是说,一个 Tablet 指针被认定为“飞行中”,当且仅当“它还在内存里且引用计数大于 1(说明有人正拿着)”或者“它挂着旧版本链”。等引用都释放、旧版本链清空,才允许从飞行集合里摘出去。ObTablet 对象自己还带 next_tablet_,注释明确说 “used in old_version_chain and tablet_gc_queue”(ob_tablet.h:1245)——用同一个 next 指针同时串旧版本链和 GC 队列,省下 8 字节,代价是这两条链不能同时持有同一个对象,逻辑上必须保证互斥。

这套“不可变对象 + 版本链 + 引用计数”的并发模型,和 LSM 里“旧 SSTable 要等没有读者引用后才删”完全同源,只是被搬到了元数据层。它带来的直接后果是:响应一个 DDL 的“删除成功”,和内存真正还回去,中间隔着一整段引用释放的过程。运维持上看到 Tablet 数量已经降下来了、内存却没降,多半就是这个原因。反过来说,这也是它必须存在的理由:如果删除时直接 free,任何持有旧 handle 的读线程都会撞上 use-after-free。

11. 元数据内存管理

前面看到,每个 ObTablet 有 288 字节常驻的 ObTabletMeta,外加一堆指针和两个原子计数。十万个 Tablet 如果每个对象都完整常驻,光对象本身加堆上的 MemTable 管理器就是几十上百 MB,乘上多租户,单机内存扛不住。src/storage/meta_mem/ 这一整套目录就是答案,入口是 ObTenantMetaMemMgr,社区里简称 t3m。它按租户隔离(mtl_new / mtl_destroy),管的是“这个租户所有 Tablet 的内存实体和索引”。几个常量直接暴露了约束(ob_tenant_meta_mem_mgr.h:123-130,177):

  static const int64_t NORMAL_TABLET_POOL_SIZE = (ABLOCK_SIZE - ABLOCK_HEADER_SIZE) / 2 - ... ; // 3824B
  static const int64_t LARGE_TABLET_POOL_SIZE = 64 * 1024L - THE_SIZE_OF_HEADERS;              // 65,480B
  static const int64_t MIN_MODE_MAX_TABLET_CNT_IN_OBJ_POOL = 10000;
  static const int64_t MAX_TABLET_CNT_IN_OBJ_POOL = 50000;
  static const int64_t DEFAULT_TABLET_CNT_PER_GB = 20000;

第一,Tablet 对象被分成两档池:普通池 3824 字节一个,大池 65480 字节一个,get_tablet_pool_type()(ob_tenant_meta_mem_mgr.h:157-170)按 tablet_size 选,超过大池就 OB_NOT_SUPPORTED。分两档是为了减少碎片——全部按最大尺寸分配,小 Tablet 浪费巨大;全部按最小尺寸,大 Tablet 装不下。默认给普通池 96%(get_default_normal_tablet_pool_count() 里那个 * 0.96,ob_tenant_meta_mem_mgr.h:142-148),这个比例是经验值。第二,容量有上限:小规格模式(mini mode 或租户内存不超过 2GB)上限 10000 个,普通模式 50000 个,全局内存上限 TOTAL_LIMIT = 15GB。第三,DEFAULT_TABLET_CNT_PER_GB = 20000 把“内存”翻译成了“Tablet 数量”——每 GB 租户内存按 2 万个 Tablet 规划,cal_adaptive_bucket_num()(ob_tenant_meta_mem_mgr.cpp:2389-2395)则按每 GB 内存 5 万这个更宽的尺度算哈希桶数,再取下一个素数。

对象池不只管 Tablet,还管 MemTable 和各种附属结构,而它们的配额直接暴露了“内置 Tablet 存在”这件事对内存预算的影响(ob_tenant_meta_mem_mgr.h:126-134):

  static const int64_t MIN_MODE_MAX_MEMTABLE_CNT_IN_OBJ_POOL = 2 * MIN_MODE_MAX_TABLET_CNT_IN_OBJ_POOL;
  static const int64_t MAX_MEMTABLE_CNT_IN_OBJ_POOL = 2 * MAX_TABLET_CNT_IN_OBJ_POOL;
  static const int64_t MAX_TX_DATA_MEMTABLE_CNT_IN_OBJ_POOL = MAX_MEMSTORE_CNT * OB_MAX_LS_NUM_PER_TENANT_PER_SERVER_FOR_SMALL_TENANT;
  static const int64_t MAX_TX_CTX_MEMTABLE_CNT_IN_OBJ_POOL  = OB_MAX_LS_NUM_PER_TENANT_PER_SERVER_FOR_SMALL_TENANT;
  static const int64_t MAX_LOCK_MEMTABLE_CNT_IN_OBJ_POOL    = OB_MAX_LS_NUM_PER_TENANT_PER_SERVER_FOR_SMALL_TENANT;
  static const int64_t MAX_DDL_KV_IN_OBJ_POOL = 5000;

MemTable 池的容量是 Tablet 池的两倍——因为一个 Tablet 可能同时持有活跃的与冻结的 MemTable,MAX_MEMSTORE_CNT 就对应这个“每 Tablet 最多几份 MemTable”的编译期上限。而事务上下文与锁表的内存池容量,恰好等于“每服务器每租户最大 LS 数”乘以 1,这个“乘 1”正是内置 Tablet 的数学痕迹:每个 LS 只有一个 LS_TX_CTX_TABLET 和一个 LS_LOCK_TABLET,所以池对象数与 LS 数严格一一对应;事务数据之所以要多乘一个 MAX_MEMSTORE_CNT,因为它自己是一棵要多版本、要多层 MemTable 的树。“每个 LS 自带四个内置 Tablet”这个设计决定,最终变成了内存池配额里的一个乘法因子——这是概念层设计如何一路落到资源数字上的一个很干净的例子。

选桶这一步比看上去讲究。choose_tablet_pool_type(ob_tenant_meta_mem_mgr.cpp:223-243)不是只用大小做判据,它引入了两个概念:must_cache_size 和 try_cache_size。如果一个 Tablet 的必缓存部分就已经超过普通池,直接给大池;只有当“必缓存 + 可选缓存”超过普通池、并且当前用户 Tablet 总数还没到默认大池配额时,才升级到大池,否则退回普通池。也就是说,“能不能进大池”是全局配额约束下的动态决策,不是单个对象的大小决策。这带来的代价是:同一个表在不同负载下可能落进不同的池,Tablet 的内存占用不再是“看 schema 就能算出来”的常数。

最反直觉的一处机制在对象池的 acquire() 里(src/storage/meta_mem/ob_tenant_meta_obj_pool.h 中 ObTenantMetaObjPool<T>::acquire)。当空闲对象列表已经用完、且不允许超额分配时,它不会立刻返回 OB_ALLOCATE_MEMORY_FAILED,而是带着 3 秒超时进入一个循环:每睡 1 微秒就调用一次洗出回调(wash_func_),试着把别的 Tablet 挤出去,腾出一个对象来。这是“分配触发回收”而不是“定时器触发回收”——内存压力是分配者自己感知并现场解决的,不指望一个后台线程及时响应。代价是分配路径在最坏情况下要等 3 秒,而且这条路径上会出现“分配一个 Tablet 对象”与“回收另一个 Tablet 对象”的重入关系,任何一个环节死锁都会把整个元数据子系统卡住。

淘汰与回收在 t3m 里是几条独立节奏的流水线,节奏由常量写死(ob_tenant_meta_mem_mgr.h:557-570):TABLE_GC_INTERVAL_US = 20000(20ms)驱动 TableGCTask 回收 MemTable / SSTable 句柄,REFRESH_CONFIG_INTERVAL_US = 10000000(10s)刷新租户配置,SESSION_TABLET_GC_INTERVAL_US = 60000000(60s)回收临时会话表,每一轮都有数量阈值(ONE_ROUND_RECYCLE_COUNT_THRESHOLD = 20000、ONE_ROUND_TABLET_GC_COUNT_THRESHOLD = 200)防止 GC 自己把正常请求饿死。飞行集合的上限是 FLYING_TABLET_THRESHOLD = 100000,超了就告警——“飞行中的对象数量”本身就是一个可观测的健康指标,反映引用归还是否及时。淘汰的受害者怎么挑?t3m 用 ObTabletPointer::wash_score_ / ObTablet::wash_score_ 打分(ob_tablet.h:1215),用一个 16 元素的候选堆挑最该被赶出去的那个,把它的内存对象释放掉、只留磁盘地址,下次访问再加载。

索引结构这一层,命名很能说明问题:ObTabletPointerMap tablet_map_ 加 ObFlyingTabletPointerMap flying_tablet_map_(ob_tenant_meta_mem_mgr.h:621-622),前者是 ObResourceMap<ObTabletMapKey, ObTabletPointer>(ob_tablet_pointer_map.h:23),值的类型是 ObResourceValueStore<ObTabletPointer>——不是 ObTablet,而是“指针”。ObTabletPointer 才是理解整套元数据管理的钥匙,它是一个 432 字节的小对象(ob_tablet_pointer.h:215-228):

  ObLSHandle ls_handle_;                             // 24B
  ObDDLKvMgrHandle ddl_kv_mgr_handle_;               // 48B
  ObProtectedMemtableMgrHandle protected_memtable_mgr_handle_; // 32B
  mds::ObMdsTableHandler mds_table_handler_;         // 48B
  ObTablet *old_version_chain_;                      // 8B
  ObTabletAttr attr_;                                // 120B
  int64_t next_meta_version_;                        // 8B
  int64_t last_gc_version_;                          // 8B

“指针记录”和“对象”分开的意义在于:ObTabletPointer 永远在,ObTablet 可能在也可能不在。指针记录里还带着 META_VERSION_ABORT_INTERNVAL = 500(ob_tablet_pointer.h:103)用于版本号分配与“分配了但没提交”的版本回滚。ObLSHandle ls_handle_ 更是一个信号:每个 Tablet 的指针记录都反向持有它所属 LS 的句柄,从 Tablet 指针可以直接 get_ls()——这再次印证了 ls_id 是路由基石,连内存索引里的最小记录都不肯省掉这层归属。t3m 用 ObBucketLock bucket_lock_ 保护创建/更新/删除,源码注释反复强调 “need call within the scope of ls_tablet_svr's bucket lock”;分桶而不是全局锁,是因为 Tablet 操作高频且大多无关,全局锁会成为瓶颈。此外还有 ObTabletLeakChecker(src/storage/meta_mem/ob_tablet_leak_checker.h:89)专门抓“拿了 ObTabletHandle 忘了归还”的泄漏——引用计数换来了安全,代价就是必须防漏,而防漏本身要额外一套埋点。

最后是磁盘侧。src/storage/meta_mem/ob_storage_meta_cache.h 里有 ObStorageMetaKey : public ObIKVCacheKey(第 41 行)和 ObStorageMetaValue : public ObIKVCacheValue(第 62 行),后者的 MetaType 枚举只有三种:SSTABLE = 0、CO_SSTABLE = 1、TABLE_STORE = 2(第 65-71 行)——正好对应前面 ObTabletComplexAddr 指向的那类“可能落在磁盘上的元数据块”。于是整个元数据体系是三层:t3m 内存对象(ObTablet 实例)→ KVCache(磁盘元数据块)→ 磁盘(宏块里的序列化数据)。访问一个 Tablet 的元数据,先看 t3m 里有没有内存对象,没有就走 KVCache,再没有才落磁盘 IO,每一层都在用空间换时间。顺带纠正一处版本差异:_research/SOURCE_MAP.md 里提到的 ObMetaObjCache 在 5.0.2.0 的源码里已经完全不存在了——全仓库搜不到这个符号,它的职责被 ObTenantMetaObjPool(ob_tenant_meta_obj_pool.h:153)和 ObMetaObjBuffer(头部带 MAGIC_NUM = 0xa12f,ob_tenant_meta_obj_pool.h:26-31)承接。读源码时,手上的笔记和文档都可能滞后,一切以当前代码为准。

值得一提的还有元数据内存参与资源校验这件事。t3m 里有一个 ObT3MResourceLimitCalculatorHandler,它把两种约束分别算出来(ob_tenant_meta_mem_mgr.cpp:2873-2898):配置约束是 tenant_mem / 1GB × _max_tablet_cnt_per_gb,内存约束是 tenant_mem × _storage_meta_memory_limit_percentage / 200MB × 20000;反过来它还能做逆运算(cal_min_phy_resource_needed,ob_tenant_meta_mem_mgr.cpp:2912-2918),由“我想放 N 个 Tablet”倒推出“至少需要多少内存和多少配置额度”,并且取两者最大值。这意味着元数据内存不是事后统计出来的观测值,而是参与租户资源准入的前置约束——RootService 在建表或分裂时会拿这个结论判断目标机装不装得下。这个设计的代价是:_max_tablet_cnt_per_gb 和 _storage_meta_memory_limit_percentage 这两个配置一旦调小,原本合法的表结构可能突然变成“资源不足”。

12. 横向对比

把 OceanBase 这套东西和邻居摆在一起,能看到很有代表性的分歧。先说 vs TiDB 的 Region 与 Placement Driver。TiDB 里一个 Region(默认约 96MB)就是一个 Raft 组,Region 的元数据(起止 key、peer 列表、epoch)集中在 PD 里,由 PD 统一调度;TiKV 本地的 Region 元数据相对轻,路由靠 TiDB 从 PD 拉回来的全量 Region 路由表。OceanBase 把这个枢纽反过来放:路由信息不在中心化的 RootService 里,而是分散在“每个 Tablet 自己带着 ls_id“加上”每个 LS 自己带着成员列表”这两处;RootService 只负责决定终态,读路径上的路由靠 Location Cache 缓存 (tablet → ls) 和 (ls → leader) 两段映射,不需要每次问中心。差异的代价很清楚:PD 是路由表这处信息的单点(虽然有高可用,但事实只有一份),OceanBase 把路由信息冗余到每个 Tablet 的元数据里,避免了中心热点,代价是元数据要随 Tablet 一起迁移、一起复制——ls_id_ 写进 ObTabletMeta 正是为此。另一层差异在粒度:Region 数量随数据量增长,分片粒度下限被 Region 大小绑死;Tablet 粒度不受这个约束,可以做到很小,因为它不承担 Paxos 组的角色。这正是第 00 篇那条主线在元数据层的落地。

再看 vs RocksDB 的 Column Family。RocksDB 里一个 CF 是一棵独立的 LSM 树,有自己的 MemTable 和 SSTable 集合,CF 之间共享一个 WAL 但各自独立 flush / compact。OceanBase 的 Tablet 在“一棵独立的 LSM 树”这一点上和 CF 很像——ObTablet 持有自己的 memtables_ 数组,以及 ObTabletTableStore 里那七组 SSTable 数组(major_tables_、inc_major_tables_、minor_tables_、ddl_sstables_、inc_major_ddl_sstables_、mds_sstables_、meta_major_tables_,src/storage/tablet/ob_tablet_table_store.h:341-347),各自独立合并。但差异有两层。第一层,Tablet 是复制的,CF 不是:CF 是单机概念,Tablet 是分布式存储单元,它的元数据要带着 ls_id 参与路由和迁移,CF 完全没有这回事。第二层,CF 的元数据是“配置”,Tablet 的元数据是“数据”:CF 定义写在 options 里、启动时固定;Tablet 元数据是运行时动态增删、要持久化、要能被回放重放。所以在 RocksDB 里你很难想象“运行时把某个 CF 搬到另一台机器上”,而这正是 Tablet 的核心能力(transfer)——因为它从设计上就是分布式存储单元,而不是本地存储引擎的内部概念。这个差异还决定了两者的元数据量级:RocksDB 的 CF 数量通常是个位数,而 OceanBase 单机要面对的是五万到几十万个 Tablet 对象。

第三组对比是 vs 传统数据库的表空间 / 段 / 区。Oracle、PostgreSQL 这类单机数据库有清晰的三层物理组织:表空间(tablespace)→ 段(segment)→ 区 / 块(extent / block),这套结构管的是“空间怎么分配”,是分配器视角的层级。OceanBase 的 LS → Tablet →(MemTable + SSTable)是另一个视角:LS 是复制和日志边界,Tablet 是均衡和存储边界,SSTable / MemTable 是存储引擎内部层次。差异的根源在于进程模型:传统数据库的一个实例就是一台机器,不需要在“空间分配”之上再叠一层“跨机器的数据单元”;OceanBase 需要一个既能被均衡器搬动、又能被 Paxos 复制的中间对象,这个对象就是 Tablet。换句话说,传统数据库的“段”是给分配器看的,OceanBase 的“Tablet”是给均衡器和复制层看的——服务对象不同,层级设计自然不同。这个视角也能解释为什么 OceanBase 的 Tablet 元数据里充满“迁移状态”“重建状态”“副本类型”这类字段,而传统数据库的段头里全是“空闲区链表”“高水位标记”。

13. 代价与权衡

第一处权衡,是元数据常驻内存换路由性能,代价是内存上限加一整套淘汰协议。ObTabletMeta 288 字节常驻、ObTablet 对象入池,让读路径几乎不用碰磁盘就能拿到“我在哪个 LS、数据版本多少”。但内存有硬上限(MAX_TABLET_CNT_IN_OBJ_POOL = 50000、TOTAL_LIMIT = 15GB、每 GB 内存 2 万个 Tablet),超了就要洗出;而洗出这条链路——旧版本链、引用计数、飞行集合、GC 队列、泄漏检查、分配时现场回收——恰恰是整套元数据管理里最复杂、最容易出并发 bug 的部分。用空间换来的速度,是用“必须把一整套缓存淘汰协议写对”换来的。

第二处权衡,是 (ls_id, tablet_id) 做 key 换来迁移期间无歧义,代价是索引变大、遍历变复杂。单个 tablet_id 是 8 字节,加上 ls_id 也是 8 字节,key 直接翻倍;更深的影响是“同一个 tablet_id 可能在不同 LS 下并存”这件事会渗透进所有相关的遍历、比较、GC 逻辑。如果用纯 tablet_id 做 key 会简单很多,但迁移期间的覆盖歧义无法安全处理,而覆盖一个正在被读的 Metadata 对象是不允许的。OceanBase 在这里选择了“多一点复杂、少一点歧义”。

第三处权衡,是 ObTabletMeta 和 ObMigrationTabletParam 拆成两个结构,代价是双份维护。那行 ATTENTION : Add a new variable need consider ObMigrationTabletParam 就是代价的直接证据:每加一个字段,要同步考虑迁移参数和迁移初始化接口,忘了就会在迁移路径上丢数据。收益是在线路径的元数据保持精简(迁移才需要的 Storage Schema、Major Checksum 不进那 288 字节),迁移路径可以按需携带重内容。这是“运行时效率”和“开发者心智负担”之间的交换。

第四处权衡,是双状态机换正确性,代价是状态同步逻辑。运行态和持久态分开,让“内存易失状态”和“磁盘持久身份”各自语义清晰,崩溃恢复和正常下线走完全不同的判定路径(is_need_gc() 只认持久态,运行态根本没有 ZOMBIE 这个值)。代价是每次状态变更(online / offline / create_finish)都要同时维护两边,漏写一边就会出现“内存说在线、磁盘说下线”的不一致。这种“两个坐标必须一起动”的成本,是分布式系统里所有双份状态都要付的。

第五处权衡,是 ObTabletComplexAddr 惰性加载换内存,代价是访问延迟不可预测。Table Store、Storage Schema、Macro Info 都被包成“可能只在磁盘”,冷 Tablet 不占常驻内存。代价是访问一个冷 Tablet 的 Table Store 可能触发一次磁盘 IO,响应时间不再稳定在内存访问量级——“访问 Tablet A 很快、访问 Tablet B 很慢”是正常现象,延迟分布天然带长尾。第 03 篇讲合并调度、第 08 篇讲路由缓存时都会撞上这个事实:元数据也不一定在内存里。

第六处权衡,是把内置 Tablet 也造成 Tablet 换来通用性,代价是每个日志流一笔固定开销。事务表和锁表复用 Tablet 的形态,就不需要为它们单独写一套存储、复制、合并、回收逻辑,这是巨大的工程量节约。但成本是固定的:每个 LS 无论装不装用户数据,都必然持有 4 个内置 Tablet 的元数据对象与对应的 MemTable 池对象。当一个租户为高并发拆出大量小 LS、而每个 LS 实际只管很少几个用户 Tablet 时,这 4 个内置 Tablet 就成了纯粹的分母——MAX_TX_CTX_MEMTABLE_CNT_IN_OBJ_POOL 那一类“乘以 LS 数”的配额,此时几乎全是内置对象的开销。这也是为什么 LS 数量不能无限拆:拆 LS 确实提升并发与均衡自由度,但每一刀都附带一份恒定的元数据成本。

14. 反直觉点

反直觉点之一:一个日志流里还藏着 4 个“系统分片”。大多数人对“LS 装着用户的 Tablet”有直觉,但打开 ObLS::INNER_TABLET_ID_LIST 会发现每个 LS 自带 4 个固定 ID 的内置 Tablet(49401~49404),ID 是硬编码常量,永远存在,承载事务上下文、多版本事务数据、锁表、重组信息。这些“不是用户数据但又是数据”的东西被统一建模成 Tablet,复用了整套存储与复制能力。这既解释了“一个空租户为什么也有几百个 Tablet”,也解释了为什么元数据内存的下限是随 LS 数量线性增长的。

反直觉点之二:句柄不是指针,而是“引用计数 + 可能触发加载的代理”。ObTabletHandle 不是一个裸 ObTablet *。你拿到它的时候,它可能指向内存里现成的 Tablet,也可能触发一次 ObTabletPointer::acquire_obj → read_from_disk 的加载路径,去磁盘或 KVCache 把序列化数据反序列化成 ObTablet。所以“访问一个 Tablet”这件事本身可能包含一次磁盘 IO 和一次对象池分配。“读元数据是无成本的”这个直觉是错的——成本只是被藏进了句柄的惰性加载里。

反直觉点之三:Tablet 的“归属”会变,但“身份”不变。tablet_id 在整个生命周期里不变(迁移、分裂都不变),变的是 ls_id(迁移)和绑定关系(DDL)。所以做路由缓存时要特别小心:缓存的应该主要是 (tablet → ls) 这段关系,而且必须能感知它的失效,否则一个 Tablet 迁移到别的 LS 之后,旧缓存会把请求发到错误的位置。这也是第 08 篇“分布式路由”要专门处理的问题。更细一层,ObTabletCreateDeleteMdsUserData 里那个“整个生命周期都不变”的 create_commit_scn_ 就是这个不变性的形式化表达。

反直觉点之四:承载路由的那个二元 key,它的比较运算符并不是字典序。ObTabletMapKey 提供了 operator <,但实现只有一行(src/storage/meta_mem/ob_tablet_map_key.h:55-58):return ls_id_ < other.ls_id_ && tablet_id_ < other.tablet_id_;。这不是 (ls_id, tablet_id) 的字典序,也不满足严格弱序应有的性质——两个不同的 key 完全可能互相都不小于对方,而按字典序本应能区分。也就是说,Tablet 索引查询靠的是哈希加相等判定,不是排序;这里的 operator < 更接近“两种身份都更小”的偏序语义,而不是给有序容器或 std::sort 用的。读到这里就该意识到:(ls_id, tablet_id) 服务的是哈希索引与相等比较,不是有序遍历,所以在语义上不能把它当“复合排序键”来依赖。这种“接口齐全但语义比名字更窄”的地方,是读元数据结构时最需要留神的一类。

反直觉点之五:官方那句“Tablet 与分区一一对应”要当成“逻辑对应”来读。这句话至少有三层限定。其一,局部索引、全局索引、LOB 都会各自产生 Tablet,所以“一个分区”对应的实际是一组 Tablet,不是一个。其二,Tablet 可以在日志流之间迁移,迁移完 ls_id_ 就变了,但它对应的“分区”没变——所以“对应”是逻辑层的稳定绑定,不是物理位置的不变。其三,绑定关系本身可变(bind / unbind),MDS 把“绑定”做成了一条能注册、能提交、能回放的元数据操作。把这句话当成“物理一一对应”,读到时就会一头雾水:明明“一一对应”,为什么还要 bind_hidden_tablet_to_orig_tablet?

反直觉点之六:删一个 Tablet 不等于立刻释放内存。remove_tablets 只是把它从 tablet_id_set_ 里摘掉并推进 GC 队列;只要还有读线程持着 ObTabletHandle,对应的对象就会漂在 flying_tablet_map_ 里,等引用计数归零、旧版本链清空才真正回收(判据就是 need_remove_from_flying())。响应一个 DDL 的“删除成功”,和内存真正还回去,中间隔着一整段引用释放过程。运维上看到的“Tablet 数降了、内存没降”,多半就是这个窗口期。

小结

这一篇的核心,是看清 OceanBase 用“日志流 + 分片”两级抽象替换掉 3.x“分区即 Paxos 组”之后,代价被转移到了哪里。转移的地方就是元数据:路由从一跳变成四跳,多出来的两跳(分区到分片、分片到日志流)必须有物理落点,而这两个落点分别是 CDB_OBJECTS.DATA_OBJECT_ID 背后的 tablet_id 和 ObTabletMeta::ls_id_。ObLS 因此被组织成一个聚合根,把 Tablet 集合、Palf 日志句柄、事务表与锁表、冻结器、检查点、GC 与迁移处理器全部收拢进来——凡是要与这个日志流的数据变更共享同一提交顺序、同一份日志、同一个 Paxos 组的东西,都必须挂在这里,因为它是这个租户里的“一致性域”,不是单纯的“数据容器”。ObTabletMeta 只有 288 字节,却把身份(ls_id_ / tablet_id_ / data_tablet_id_ / ref_tablet_id_)、版本与检查点、以及一层为了持久化兼容而无法删除的废弃字段全部装了进去;ls_id_ 写死在这里,才使得 (ls_id, tablet_id) 成为完整坐标、使得迁移期间同 ID 实体可以无歧义共存、使得 Tablet 能随日志流一起复制。

真正让这套结构能落地的,是 src/storage/meta_mem/ 那一整套内存管理。它把“一个 Tablet”拆成两个东西:永远在的 ObTabletPointer(432 字节的指针记录,带着 LS 句柄、旧版本链、版本号)和可能在也可能不在的 ObTablet(利用 ObTabletComplexAddr 把 Table Store 与 Storage Schema 做成可离场)。对象被塞进两档定长池,容量按每 GB 内存 2 万个 Tablet 折算并有硬上限,超限就靠“分配时现场洗出”和几条定频 GC 流水线来腾空间;最底层还有一层 KVCache 缓存磁盘元数据块,构成“内存对象 → KVCache → 磁盘”的三层结构。这套设计换来了路由性能与五万级单机 Tablet 的常驻能力,代价是必须把引用计数、旧版本链、飞行集合、泄漏检查这一整套并发回收协议写对——这也是整个存储层最容易被低估的复杂度来源。

最后留一个贯穿全篇的观察:表结构层面的“官方描述”和代码层面的实现之间,总有一层需要你自己补上的映射。文档说“Tablet 与分区一一对应”,代码里却要为局部索引建绑定、为 LOB 建关联、为迁移建 start / finish 双向状态;文档说“日志流是 Tablet 的容器”,代码里每个 LS 还自带四个不属于任何用户分区的内置 Tablet;SOURCE_MAP.md 里写着 ObMetaObjCache,5.0.2.0 的代码里这个符号已经彻底消失,换成了 ObTenantMetaObjPool。这些差异不是文档错了,而是文档在描述意图、代码在描述约束。下一篇进入 LSM-Tree 的内部:MemTable 怎么用 MVCC 版本链支持并发读写、SSTable 的四层结构(L0 Mini / L1 Minor / Major)和那十一种编码器怎么把数据压小,以及 ObMergeType 里那些看起来很像的合并类型(MINI_MERGE / MINOR_MERGE / MAJOR_MERGE / MEDIUM_MERGE / INC_MAJOR_MERGE)到底差在哪。