OceanBase源码架构总览
官方开源代码:oceanbase · GitHub
如果你是在 2019 到 2021 年之间接触 OceanBase 的,脑子里大概率存着这样一张图:用户建表产生若干分区(Partition),每个分区是一个独立的 Paxos 组,三个副本分布在不同 Zone,Leader 副本对外提供读写,Follower 副本提供只读和容灾。这个模型直观、好讲,几乎所有早期介绍 OceanBase 的文章都这么画。
这张图在 3.x 时代是对的。但当你阅读 5.0.2.0 的源码,在 src/ 目录下找一个叫 partition_service 的模块是找不到的,取而代之的是两个新目录:src/storage/ls/(Log Stream,日志流)和 src/storage/tablet/(Tablet,分片)。分区从一个“执行实体”降级成了一个“逻辑概念”,真正承担复制、存储、迁移职责的,是日志流和分片这两个东西。
这不是一次小改。Paxos 的复制粒度变了,数据均衡的算法变了,事务提交的协调单位变了,连 SQL 层生成执行计划时判断“数据在哪”的方式都变了。用一句话概括这次重构的动机:OceanBase 要把“每台机器上跑 N 个 Paxos 组”这件事,压成“每台机器上跑大约一个”。这篇文章就把这件事从头讲清楚——为什么要压,怎么压,压完之后各层付出了什么代价。
1. 官方八层架构
官方文档 V4.4.2《参考指南--系统原理》第一章把 OceanBase 拆成八层,顺序是多租户层、存储层、复制层、均衡层、事务层、SQL 层、接入层。
这七层各自回答一个独立的问题。多租户层把集群切成多个互相隔离的数据库实例,每个实例叫租户,资源靠资源单元 UNIT 和资源池 Resource Pool 切分;存储层以表或分区为粒度提供存储,每个分区对应一个 Tablet;复制层用日志流在多个副本之间同步状态;均衡层通过日志流的裂、合、迁让 Tablet 和服务能力在机器间重新均衡;事务层负责原子性与隔离性;SQL 层把请求翻译成对一个或多个 Tablet 的访问;接入层也就是 ODP(OBProxy),负责把请求路由到合适的数据节点。把它们按顺序念一遍,你会发现这是一个从“资源怎么分”到“数据怎么存”再到“请求怎么进来”的完整闭环——每一层的输出正好是下一层的输入。
这份分层里有五个字值得单独拎出来看:“复制层”和“存储层”是分开的两层。这不是排版上的并列,而是一次抽象上的解耦。在 3.x 的认知里,分区既是存储单位也是复制单位,两者绑死——你有多少个分区,就有多少个 Paxos 组。到了 4.x,官方文档中的《Logstream》里写得很清楚:“日志流……代表了一批数据的集合,包括若干 Tablet 和有序的 Redo 日志流”,而在《Tablet》里写“Tablet……是数据均衡的最小单位”。存储的最小单位是 Tablet,复制的最小单位是日志流,两个单位由不同的对象承载,中间用“一个日志流装若干 Tablet”这条关系连起来。这个拆分,是理解全部后续设计的总开关。
为什么说它是“总开关”?因为它一次性改变了三件事的粒度。复制粒度从“分区”变成“日志流”,所以 Paxos 组的数量不再跟分区数挂钩;迁移粒度从“分区”落到“Tablet”,所以均衡可以做得非常细;提交粒度从“分区”变成“日志流”,所以多个 Tablet 的修改可以共享一条 Redo 日志,很多事务从此退化成“本地事务”。换句话说,3.x 里“存储、复制、提交”三件事是同一个粒度(分区),4.x 里它们被打散成三个不同粒度,各自可以独立演进。这是架构分层最本质的收益——让不同的关注点有不同的伸缩单位,代价是你必须接受一套更复杂的对象关系和一套动态调度算法,后面的章节会反复看到这个代价。
2. Partition、Tablet 与 Log Stream
网上讲 OceanBase 的文章里,Partition、Tablet、Log Stream 这三个词经常被混着用,最典型的一句就是“每个分区有三副本”——这句话在 3.x 的语境里对,在 4.x 的语境里错,但两种语境一旦混在同一段话里,读者就完全无从判断作者讲的到底是哪一代。混用的后果是读者永远搞不清“复制到底复制的是什么”,也搞不清“均衡到底在搬什么”“提交到底在提交什么”。所以这里先把三个词的边界定死,因为后面每一段论证——尤其是成本推算和承载关系——都建立在这三个词的正确含义上,边界一错,后面的推导会全部失焦。
Partition 是用户创建的逻辑对象,是给用户看的。你执行 CREATE TABLE ... PARTITION BY HASH(k) PARTITIONS 16,得到的是 16 个分区,官方文档中的表述是“分区(Partition)是用户创建的逻辑对象,是划分和管理表数据的一种机制”。用户能对分区做创建、删除、Truncate、分裂、合并、交换这些操作,但分区本身不存储数据,也不参与复制,更不持有任何一个内存对象。Tablet 才是实际的数据存储对象,官方文档中说得很明确:“Tablet 与分区一一对应,单分区表会创建一个 Tablet,多分区表会为每个分区创建一个 Tablet。索引表的每个分区也会对应一个 Tablet,包括局部索引表和全局索引表。特别地,局部索引表的 Tablet 与主表 Tablet 会强制绑定,保证存储在一台机器上”。最后这半句是硬约束,不是优化——局部索引的键里通常不含分区键,如果索引和主表分到两台机器,每次回表都要跨网络,在 OLTP 里这个代价无法接受,所以绑定关系在创建时就写死了。
Log Stream 是 OceanBase 自动创建和管理的实体。官方文档中的定义是“它代表了一批数据的集合,包括若干 Tablet 和有序的 Redo 日志流”,后面紧跟一句更关键的话:“从数据存储的角度来看,日志流可以抽象为 Tablet 容器,支持添加和管理 Tablet 数据,允许 Tablet 在不同日志流之间转移(Transfer)”;再下一句是“从事务的角度来看,日志流是事务的提交单位。事务修改在单个日志流内完成时可以采用一阶段原子提交;事务修改跨多个日志流时,采用 OceanBase 数据库优化的两阶段提交协议完成原子提交,日志流是分布式事务参与者”。到这一步,复制单元是谁就清楚了:Paxos 组的成员是“日志流的副本”,不是“分区的副本”,Log Stream 首先是容器,其次才是复制对象。
容器这个比喻值得再往下压一层,因为它解释了后面很多设计。既然是容器,那么“容器里的数据”和“容器本身”可以分别搬迁:Tablet 可以在日志流之间 Transfer,而日志流可以在机器之间 Migration/Replication。这两级迁移的代价完全不同——Tablet 级的转移只要在日志流内部挪一份数据,不触碰 Paxos 成员;日志流级的迁移要动成员管理和选主,重得多。官方把均衡的原子单位定成“日志流的生灭”,把数据搬运放在日志流内部完成,就是为了让高频的那个动作(搬 Tablet)尽量便宜,让昂贵的那个动作(改成员)尽量少发生。
顺带纠正一个高频误解:日志流和“日志”不是一回事。日志流是一个容器,里面有一条有序的 Redo 日志序列,还挂着一堆 Tablet,在代码里还挂着事务表、锁表这些服务对象。当运维说“这个日志流挂了”,他指的是这个容器的 Paxos 组出了问题;当他说“这个日志流很大”,他指的是容器里的 Tablet 很多、Redo 日志很长;当他说“把这个日志流迁走”,他指的是容器的副本连同它内部的那一堆 Tablet 一起换地方。把“容器”和“容器里的数据”分开想,后面读任何一篇都不会乱,因为 4.x 里绝大多数排查和调优动作,本质上都是在决定“往哪个容器里放、什么时候换容器”。
3. 复制层与存储层的解耦
上一节说清了三个名词,但“为什么要分成这两个单位”这个问题如果只答“为了解耦”,等于没答——解耦本身不是目的,解耦带来的收益和代价才是。这一节补一段完整的工程论证,因为它是整篇总览里最需要讲透的一层:只有先想明白“复制组的固定成本正比于组数”这件事,才能理解后面所有的数量级推算、部署形态约束和隔离性代价。论证的路线是先用一个物理约束把问题逼出来,再看拆开之后两种单位各自遵循什么伸缩规律,最后算清楚这套拆分究竟把复杂度挪到了哪里。
问题的起点是一个不可回避的物理约束:复制是有成本的,而成本几乎只跟“复制组的个数”成正比,跟“每个组里放多少数据”关系不大。一个 Paxos 组,无论它守着 1MB 还是 1TB 数据,都需要维护成员列表、选举状态、ballot/epoch、日志流位置、超时定时器、活跃日志窗口、对其他副本的网络连接、以及一份独立的日志落盘序列。这些开销是“每组的固定成本”,不会因为组里的数据变少而消失。3.x 把复制组等同于分区,等于让“组的个数”直接等于“分区的个数”,而分区个数在真实业务里是可以轻易跑到百万级的——一张亿级流水的表按天分区、一张宽表按用户 ID 分几千片,很快就把固定成本乘出一个天文数字。这就是复制粒度必须与被复制的数据量解耦的根本原因:不是数据量大撑不住,而是“复制组个数”这个计数本身撑不住。
拆开之后,两种单位各自遵循自己的伸缩规律,这正是分层带来的收益。存储的单位 Tablet 需要跟着数据量伸缩——数据多就多切几个 Tablet,均衡要细到 Tablet 这一级,所以 Tablet 的数量应该随数据增长。复制的单位日志流需要跟着机器数伸缩——你只有那么多机器、那么多 Unit,能并行跑的 Paxos 组不该超过机器能高效承载的数量,所以日志流的数量应该随机器数(更准确地说,随 UNIT_NUM 与一级 Primary Zone 数)走。如果两个单位绑死在同一个对象上,你只能满足其中一个规律,另一个必然被牺牲:想让均衡细,就得让 Paxos 组细;想让 Paxos 组少,就得让均衡粗。拆开之后,你可以在同一套系统里同时满足“Tablet 切到上千万个”和“Paxos 组稳定在几十个”这两件看似矛盾的事。
代价落在一层新的中间关系上:“日志流装 Tablet”这条关系本身,成了需要动态维护的状态。3.x 里分区就是一个自洽的实体,它自己管自己的存储、复制、提交,没有任何“归属”问题。4.x 里 Tablet 属于哪个日志流、什么时候从一个日志流转到另一个日志流、日志流什么时候该分裂、分裂出哪些 Tablet、往哪台机器迁——这些全部变成了要由均衡层去推导和执行的动态过程。均衡层的代码量和状态机复杂度因此暴涨,src/rootserver/balance/ 下光是与日志流分组相关的文件就有 ob_ls_balance_group_info.h、ob_tenant_ls_balance_group_info.h、ob_all_balance_group_builder.h 好几个,而 src/rootserver/ob_ls_balance_helper.cpp 里甚至专门定义了 [LS_BALANCE] 的日志前缀方便排查。所以这笔交易的本质是:用一层新的、可管理的动态关系,替换掉了爆炸式的固定成本。换来的是“组数恒定、均衡可细”,付出的是“多了一套动态对象和一套调度算法”。
4. 重构动机:分区粒度 Paxos 的爆炸
把分层的必要性讲完之后,4.0 为什么要做这次重构,就有了具体的推导链,而不是一句“技术更优雅”。我们顺着数量级往下算一遍,把每一项成本都摊开,你就会发现这次重构不是审美选择,而是一道被部署形态逼出来的算术题:组数乘以每组的固定开销,再乘以机器数,最后得到的那个数字会告诉你,为什么分区粒度 Paxos 在规模上来之后必然要退场。为了让论证可核对,下面用一个具体到数字的场景来算。
先设定一个并不极端的场景:一个租户有 1000 张表,每张表 100 个分区,也就是 10 万个分区。在 3.x 架构下,这就是 10 万个 Paxos 组,三副本部署意味着 30 万个 Paxos 副本实体。假设集群有 10 台机器,那么平均每台机器上要跑 1 万个 Paxos 组、3 万个副本身份。第一项成本是内存:每个组要存成员列表、ballot、日志位置、活跃事务窗口、选举定时器状态,即便按每份几百字节到几 KB 的保守口径算,加起来也是几十 GB 级别的常驻开销,而这些内存本来应该用来缓存数据。第二项成本是落盘的顺序性:分区粒度下每个分区各自维护日志位置,文件一多,磁盘的随机写放大、fsync 次数、目录元数据操作全部上来,你以为瓶颈在业务 SQL,实际上一半 IOPS 花在维护 10 万条日志流的元数据上。第三项成本是选主:10 万个组意味着 10 万个独立的选举过程,一台机器故障会同时触发成千上万个组重新选举,选举消息在网络上互相挤占带宽,恢复时间被拉长成雪崩式的长尾。三项成本有一个共同特征——它们都正比于“组数”,而不是正比于“数据量”,这就是为什么数据没涨、机器没变,系统也会被压垮。
OceanBase 的解法是把粒度和数量解耦:日志流的数量不随分区数增长,只随机器数增长。官方文档中给出了同构 Zone 模式下的公式 LS 数量 = UNIT_NUM * first_level_primary_zone_num,并特别注明“此处 LS 数量指的是用户日志流的数量,不包含系统日志流和广播日志流”。假设租户的 UNIT_NUM=3、Primary Zone 一级是 z1,z2 两个 Zone,那么用户日志流就是 3×2=6 个。上面那个 10 万张表、10 万分区的场景,分区数还是 10 万,Tablet 数可能涨到十几万,但 Paxos 组只有 6 个,平均每个日志流里装着一万六千多个 Tablet。这里最关键的一句是文档同时写明的那半句:“每个租户在每台机器上只会有一个日志流的主副本”——也就是说,单机视角下 Paxos 组的数量从“一万个”变成了“大约一个”,误差是三个数量级。
为了把这个量级落差说具体,把两种架构在同一个场景下的成本项并排放在一张表里对照。这张表不是要罗列要点,而是想让“10 万”和“6”这两个数字出现在同一个坐标系里——同样是 10 万分区、10 台机器,左边每一行都在做乘法,右边几乎全是常数。看表的时候注意一件事:左边每一行的数字都会随着业务变大而线性上涨,右边则纹丝不动,这正是“解耦”二字最直白的体现(多字段对照,非要点罗列):
| 成本项 | 3.x:分区粒度 Paxos(10 万分区 / 10 机) | 4.x:日志流(6 个 LS) |
|---|---|---|
| Paxos 组总数 | 100,000 | 6 |
| 平均每机 Paxos 主副本数 | ~10,000 | ~1 |
| 独立选举过程 | 100,000 | 6 |
| 独立日志落盘序列 | 每分区一条 | 每 LS 一条 |
| 均衡的最小搬运单位 | Partition | Tablet |
| 组数随什么增长 | 随数据量(分区数) | 随机器数(UNIT_NUM × 一级 Zone) |
数量级的收缩直接改写了部署形态的可选项,这也是重构最现实的驱动力。OceanBase 早期是“三副本分布式”形态,每个分区三副本没问题;但当它要支持“单机部署”“小规格部署”“云上按需弹性”时,分区级三副本 Paxos 就彻底不适用了——单机上你只有一台机器,难道还能给每个分区凑三个副本吗?日志流架构让“单机只有 1 个日志流、每个日志流可以只有 1 个副本”成为合法配置,这才是重构被逼出来的真相。很多讲架构演进的文章只讲“技术上更先进”,其实工程上的版本往往是被商业和部署场景推着走的。
那么代价是什么?代价是隔离性变差和复杂度上移。同一个日志流里的所有 Tablet 共享一条 Redo 日志,它们的写入要在这条日志上排队——如果某张表写入特别猛,同 LS 里另一张表的写入延迟会被一起拖长;3.x 每个分区独立日志,天然隔离,4.x 里你只能靠调节 LS 的数量和分布去缓解。同时,日志流的动态管理(什么时机分裂、分裂出哪些 Tablet、往哪台机器迁)成了一整套新工程,被塞进均衡层。这笔账的算法是清楚的:把 N 个小组的管理复杂度,换成一个可管理数量的大组,外加一套动态分合算法。这里还有一个尺度问题值得记住:3.x 里分区数直接等于 Paxos 组数,所以分区不能切太细;4.x 里这条约束消失了,你可以放心把表切成几万甚至几百万个 Tablet,代价只剩每个 Tablet 的元数据内存。这是“两级抽象”带来的第二个红利。
5. 一个日志流能装多少 Tablet
第四个问题自然浮出来:既然单个日志流要承载一万多个 Tablet,它凭什么是能工作的?把上万个 Tablet 塞进一个共享 Redo 日志的容器,写路径难道不会打架吗?如果你把“日志流装 Tablet”想象成“把一万个 Tablet 的数据合并压成一份”,那确实会打架,而且会打得完全没法用;但真实的关系根本不是这样,这里有一个必须先扭正的直觉。这一节专门拆开这个关系,因为它是整套架构里最容易被想当然、也最容易被误解的地方,后面几段会依次回答“凭什么装得下”“共享日志到底意味着什么”“并发又是怎么保证的”。
先回答“凭什么装得下”。关键在于日志流承载的是 Tablet 的“日志归属”,而不是数据的物理聚合。日志流并不把上万个 Tablet 的数据揉成一坨,每个 Tablet 依然独立维护自己的 MemTable 和 SSTable,各自的元数据、schema 版本、数据版本都分开管——源码里 ObTablet(src/storage/tablet/ob_tablet.h:217)承载的就是单个分片自己的东西,它的 get_ls_id() 返回的 ls_id_(ob_tablet_meta.h:185)说明 Tablet 只是“知道自己属于哪个日志流”,而不是“数据被并进了日志流”。日志流真正聚合的,是这些 Tablet 产生的 Redo 日志要排进同一条有序日志序列。所以“一个 LS 装一万个 Tablet”在存储上是完全无压力的,压力只出现在日志这一侧的排队上。这也是为什么官方把日志流同时称作“Tablet 容器”和“事务提交单位”——容器是空间概念,提交单位是时间概念,两者是同一对象的两面。
再说“共享 Redo 日志意味着什么”。第一层含义是写路径排队:所有落在这个 LS 上的 Tablet,它们的 Redo 日志按 LSN 顺序排进同一条日志序列,多条针对不同 Tablet 的写入在日志层面是串行的。这条序列本身有很强的顺序写优势(Palf 用 64MB 的大物理块做预分配,就是为了把随机写变成顺序写),但它也意味着一个 LS 内部的写入吞吐是有上限的,且这个上限由所有 Tablet 共享。这里就出现了 4.x 最典型的取舍:组数少了,隔离性也少了。3.x 里 A 表写入再猛也不会拖慢 B 表,因为两人根本不在一条日志上;4.x 里只要 A、B 落在同一个 LS,A 的日志风暴就会把 B 的日志挤到后面排队。官方的应对不是回到分区粒度,而是让 LS 的数量和分布可按 UNIT_NUM、Primary Zone 调节——资源竞争激烈的租户,本质上是在用“多几个 LS”去换回一部分隔离性。这也解释了一个常被忽略的事实:LS 不是越多越好,也不是越少越好,它是隔离性和固定成本之间的一根可调旋钮。
第三层含义更微妙:跨 Tablet 的事务在 LS 内部退化成一阶段提交。前面引过官方文档中的原话——事务修改在单个日志流内完成时可以采用一阶段原子提交。为什么能一阶段?因为这几个 Tablet 的 Redo 日志共享同一条有序序列,日志流本身就是原子的提交单位:只要这条序列上把该事务的全部日志按序写上并确认多数派,事务就原子地成了,根本不需要“准备—提交”两趟协调。于是 src/storage/tx/ob_committer_define.h:53 里的 enum class Ob2PCRole : int8_t { ROOT, INTERNAL, LEAF } 那套两阶段角色,在纯 LS 内事务里一个都用不上。这就是 LS 架构最强的一个红利:传统分库分表里必须上 2PC 或 TCC 的跨表事务,只要这些表在同一个日志流里,就是一条日志的事。反过来也埋着代价——官方明确说“日志流是分布式事务参与者”,一旦事务碰了多个 LS,就要走优化的 2PC。
最后把“LS 内的并发控制”这层补上,否则前面的排队描述会让人误以为整个 LS 是一条单线程。实际情况是日志流内部有大量并发:Palf 用滑动窗口控制“有多少条日志可以在途”,src/logservice/palf/log_define.h:101 定义 PALF_SLIDING_WINDOW_SIZE = 1 << 11(2048),同文件 102 行 PALF_MAX_LEADER_SUBMIT_LOG_COUNT = PALF_SLIDING_WINDOW_SIZE / 2,也就是 Leader 最多可以并发提交 1024 条日志,窗口留一半作为背压缓冲。所以 LS 内部并不是“一条一条同步写”,而是“最多 1024 条在途、按序确认”。真正需要串行的是顺序,不是执行——日志可以并发地提交、并发地落盘,只是在 LSN 这个坐标上排成一条线。把“顺序”和“并发”分开理解,是读 src/logservice/palf/ 时最容易卡住的认知点。
还有一个证据能说明“事务的边界就是日志流”这件事,它藏在 ObLS 的成员里:get_tx_svr()、get_tx_table()、get_lock_table() 三个接口(src/storage/ls/ob_ls.h:276-278)说明事务服务、事务状态表、锁表全都是 per-LS 的,而不是 per-Tablet 或全局的。锁表挂在 LS 上,意味着行锁的分配与冲突检测天然以日志流为边界组织;事务状态表挂在 LS 上,意味着一个事务如果在同一 LS 内碰了多个 Tablet,它的状态是这一份表统一管的,本来就不需要跨对象的协调。反过来说,只有当事务真的跨了 LS,才需要那套 Ob2PCRole 的协调者角色参与。所以“单 LS 事务一阶段提交”不是一句优化口号,而是数据结构层面就把提交单位定在了 LS 上的必然结果——你把锁表、事务表都按 LS 切开,跨 Tablet 的本地事务自然就没有协调开销。这也是为什么在 OceanBase 里做表组设计如此重要:把相关的表放进同一个 LS,等于让它们的锁表、事务表、日志全部收敛到同一份,事务路径会短一大截。
6. 源码里的目录证据
概念讲到这里,该看代码了。判断一个架构有没有真的落地,最直接的方法是看目录结构——目录结构骗不了人,因为它反映的是模块划分,而模块划分是架构决策的直接投影。一个只活在 PPT 里、没有真正改变系统的概念,你在 src/ 下找不到它的栖身之处;反过来说,如果一个抽象真的重塑了系统,它的目录、头文件、类一定会成体系地长出来。下面按日志流、分片、Palf 三个对象依次看它们的代码落点,顺便验证前面讲的每一条结论。
src/storage/ls/ 是日志流的实现,核心类是 ObLS(src/storage/ls/ob_ls.h:190,class ObLS : public common::ObLink)。从职责上看它是个聚合根:get_tablet_svr() 返回 ObLSTabletService*(ob_ls.h:281),管理这个 LS 上所有 Tablet;get_tx_svr() 返回 ObLSTxService*、get_tx_table() 返回 ObTxTable*、get_lock_table() 返回 ObLockTable*(ob_ls.h:276-278),把事务、锁表都挂在 LS 上;再加上 get_freezer()、get_checkpoint_executor()、get_ls_wrs_handler()。也就是说,一个日志流不只装 Tablet,它还装事务服务、锁服务、冻结器和检查点执行器——它是一条完整的“数据+服务”边界。文件里还有 TOTAL_INNER_TABLET_NUM = 4(ob_ls.h:199)和 ObLSInnerTabletIDIter,说明每个 LS 会自带几个内部 Tablet 用于系统用途。这种“什么都不缺、自成一体”的结构,正是“日志流是提交单位”在代码上的体现,也顺带解释了一个乍看奇怪的现象——为什么事务、锁、冻结这些表面上分属不同子系统的东西,最后都挂在 ObLS 这一个聚合根上:因为它们的服务边界本来就是同一条日志流。
src/storage/tablet/ 是 Tablet 的实现。ObTablet(src/storage/tablet/ob_tablet.h:217)承载分片自己的东西,get_ls_id()、get_tablet_id()、get_data_tablet_id() 都在 ob_tablet.h:268-270 附近,全部转发到 tablet_meta_。而 ObTabletMeta(src/storage/tablet/ob_tablet_meta.h:43)的字段很能说明问题:share::ObLSID ls_id_(185 行)、common::ObTabletID tablet_id_(186 行)、data_tablet_id_、ref_tablet_id_,以及 ObTabletHAStatus ha_status_(195 行)、ObTabletTableStoreFlag table_store_flag_(197 行)、ObTabletTransferInfo transfer_info_(211 行)。ls_id_ 的存在让 Tablet 天然知道“我属于哪个日志流”,这是路由的基础;transfer_info_ 里有 get_transfer_src_ls_id() 和 get_transfer_dest_ls_id(),说明 Tablet 跨 LS 迁移这件事被直接写进了元数据结构——迁移不是外挂工具,而是元数据的一等状态。把这一串字段连起来读,你能读出两层意思:一层是 Tablet 知道自己属于谁(ls_id_)以及是谁的索引和主表(data_tablet_id_、ref_tablet_id_),另一层是 Tablet 知道自己正在经历什么(HA 状态、表存储标志、迁移源与目标)。前者是静态身份,后者是动态过程,两者都写在同一个元数据对象里,这正是“Tablet 是动态对象”这句话在数据结构上的证据。
src/logservice/palf/ 是 Palf,官方给的全称是 “Paxos Backed Append Only Log File System”,它在 4.0 里替代了 3.x 的 src/clog/ 和 src/election/。这个命名很有信息量——它把自己定位成文件系统,只不过是分布式、多副本、只追加的。src/logservice/palf/ 下面能看到典型的文件系统抽象:log_storage.h(存储)、log_net_service.h(网络)、log_io_worker.h(IO 工作线程)、log_meta.h(元数据)、palf_handle.h(句柄)。上层拿到一个 PalfHandleImpl,就能调用 append_log 写日志,像写本地文件一样——src/logservice/palf/log_engine.h:163 就是 int append_log(const LSN &lsn, const LogWriteBuf &write_buf, const share::SCN &scn),注意第三个参数 scn:日志和事务版本号是同一次 append 落下去的,这个“原子性”是 MVCC 正确性的基石。再看一个“消失”的证据:3.x 介绍里经常出现的 src/partition_service/、ObPartitionService 这类模块,在 4.x/5.0 源码里已经找不到对应目录了。这不是改名,是那个抽象层级被整体取消了——分区不再是一个运行时的服务实体。
系统日志流的证据同样落在代码里。src/logservice/palf/log_define.h:169 定义 const int64_t SYS_PALF_ID = 1,紧随其后是 inline bool is_sys_palf_id(int64_t palf_id) { return SYS_PALF_ID == palf_id; }。而 src/share/ob_ls_id.h:22-29 进一步说明 1 号 LS 的身份:INVALID_LS_ID = -1、SYS_LS_ID = 1、SSLOG_LS_ID = 1001、METADATA_LS_ID = 1002,紧接着几行是 VT_LS_ID = SYS_LS_ID、IDS_LS_ID = SYS_LS_ID(注释写明 “LS for Trans GTS service”)、LOCK_SERVICE_LS_ID = SYS_LS_ID、GAIS_LS_ID = SYS_LS_ID、DAS_ID_LS_ID = SYS_LS_ID。这几行把系统 LS 的用途写得明明白白:GTS、锁服务、全局自增、DAS ID 服务全都寄生在 1 号日志流上。把系统级服务和用户数据分开,是 4.x 一个很实用的设计——系统元数据的写入量和用户数据完全不在一个量级,如果混在同一条日志里,高频的用户写入会把元数据操作挤到后面去排队,1 号 LS 的特殊身份就是这种隔离的载体。
7. 元数据与缓存一致性
前面反复强调 LS 和 Tablet 是“动态对象”——它们会创建、会迁移、会分裂、会合并、会销毁。动态对象一多,一个绕不开的问题就来了:内存里的元数据和磁盘上的元数据,靠什么保持一致? 这不是一个可以“以后再说”的细节,因为上万个 Tablet 的元数据如果每次都从磁盘读,元数据 IO 会成为整个存储层的瓶颈;可如果全放内存,一台机器又装不下。所以必须有一套“内存里有缓存、缓存会失效、失效后能重载、内存不够能换出”的完整机制。这一节做一个初步讨论,把 src/storage/meta_mem/ 这套机制的四个关键设计轮廓讲清楚。
第一个关键设计是元数据的寻址键。src/storage/meta_mem/ob_tablet_map_key.h 定义了一个 ObTabletMapKey,它的两个字段是 share::ObLSID ls_id_ 和 common::ObTabletID tablet_id_,is_valid() 要求两者都合法,operator== 也要求两者都相等,hash() 也是把两个值一起算进去。这说明在元数据层,Tablet 的身份不是单独一个 tablet_id,而是 (ls_id, tablet_id) 这个复合键——同一个 tablet_id 理论上可以在不同 LS 下有不同的存在形态,归属关系是身份的一部分,而不是一个可以事后补充的标签。这直接呼应了“Tablet 迁移”:迁移改的是归属(ls_id),而不是 tablet_id 本身,所以迁移对上层而言是“同一个 Tablet 换了容器”,对外部引用是透明的。把复合键当成身份,是这套元数据设计里第一个不容易被发现、但影响深远的决定。
第二个关键设计是缓存的组织方式。src/storage/meta_mem/ob_tablet_pointer_map.h:23 里 class ObTabletPointerMap : public ObResourceMap<ObTabletMapKey, ObTabletPointer>,也就是说它是一个以 (ls_id, tablet_id) 为键的哈希表,值是一个 ObTabletPointer。ObTabletPointer 不是 Tablet 本身,而是一个指针壳:它持有 phy_addr(元数据在磁盘上的物理地址)、obj(内存里的 Tablet 对象)、以及 MDS 截断锁等属性(src/storage/meta_mem/ob_tablet_pointer.h 的 set_obj、reset_obj、acquire_obj、release_obj)。ObTenantMetaMemMgr(src/storage/meta_mem/ob_tenant_meta_mem_mgr.h:119)是这套结构的租户级管理者,它按桶(bucket)组织这张大表,DEFAULT_BUCKET_NUM = 10243L(ob_tenant_meta_mem_mgr.h:557)——注意这是个大质数,典型的“用质数做桶数减少冲突”手法,并且有 cal_adaptive_bucket_num()(405 行)可以按租户规模自适应调桶数。有桶就有锁:ObBucketLock bucket_lock_(619 行),而 ob_tablet_pointer.h:120 的 scan_all_tablets_on_chain 上明确注释 “must be called under t3m bucket lock's protection”,ob_tenant_meta_mem_mgr.h:262 也写着 “make sure call within the scope of ls_tablet_svr's bucket lock”。用分桶锁把“全局一把大锁”拆成若干把小锁,是让上万 Tablet 的元数据操作能并发起来的前提,否则元数据层会先于日志层成为瓶颈。
第三个关键设计是内存不足时把元数据“洗”到磁盘。src/storage/meta_mem/ob_tablet_handle.h:18 定义了 enum class WashTabletPriority : int8_t { WTP_HIGH = 0, WTP_LOW = 1, WTP_MAX },ObTenantMetaMemMgr 里有一组带 WashTabletPriority 参数的接口(ob_tenant_meta_mem_mgr.h:225-269),以及 wash_lock_(616 行)。所谓 wash,就是在内存吃紧时把某些 Tablet 的元数据对象序列化回磁盘(写回 phy_addr),留一个 ObTabletPointer 壳在内存里,下次需要时再按 phy_addr 读回来。这套机制解释了“上万个 Tablet”为什么不至于把内存吃光——不是所有 Tablet 的完整元数据都必须常驻内存,热的那部分在,冷的那部分被洗走,用 WTP 优先级区分谁该先被洗。而更底层还有一层 KV 缓存:src/storage/meta_mem/ob_storage_meta_cache.h 基于 common::ObKVCache 实现 ObStorageMetaCacheValue,用 ref_cnt_ 和 cache_handle_ 管理引用与淘汰。于是整个元数据链路是“桶锁保护的指针表 → 指针指向内存对象或磁盘地址 → 磁盘地址经 KV 缓存读取”,三层各司其职。
第四个关键设计是用版本号来判断“内存里的这份元数据是不是最新的”。ObTabletPointer 里维护着 next_meta_version_(src/storage/meta_mem/ob_tablet_pointer.h:136 的 get_next_meta_version()),ObTenantMetaMemMgr 则提供 alloc_tablet_meta_version 和 set_tablet_next_meta_version(ob_tenant_meta_mem_mgr.h:264-265)来分配和推进版本。为什么需要版本号?因为 LS 和 Tablet 都是动态对象,它们的元数据会被并发地修改——迁移、分裂、schema 变更、合并,每一次都可能让内存里的旧副本失效。光靠指针指向谁是不够的,还需要一个单调递增的版本号作为“这份元数据对应哪个时刻”的标记,读到的版本落后了就说明缓存过期、要重新加载。这和 MVCC 用 SCN 判断行版本是否可见是同一个思路,只不过作用在元数据上。所以 src/storage/meta_mem/ 这一层看着琐碎,其实干的是分布式系统里最硬的一件活——在对象不断生灭的同时,保证任何一次读都不会读到一份自相矛盾的元数据。
8. 均衡层:先分家再搬家
重构对均衡层的影响最直观。3.x 里均衡的基本动作是“分区迁移”——把某个分区从机器 A 搬到机器 B。4.x 里,因为复制单元是日志流,均衡被拆成了有明确优先级的两级,官方文档把它写成两个独立的动作:副本均衡(Root Service 通过 Unit 迁移、日志流副本复制或迁移来调整资源占用)和 Leader 均衡(在副本均衡基础上按 Primary Zone 均衡各机器上日志流的 Leader 数目)。日志流均衡的优先级高于分区均衡,道理是逻辑上的——Tablet 只能在日志流内部被分配,你没法直接说“把这个 Tablet 放到 B 机器”,除非 B 机器上有承载它的日志流的副本。所以必须先保证日志流的数量和位置对了,再谈 Tablet 的分布。
日志流层面的均衡由 RootService 驱动,官方文档中给出三种策略,正好对应扩容、缩容、重排三种场景。LS_BALANCE_BY_MIGRATE 处理“有的 LS Group 缺 LS、有的多 LS,但总数符合终态”——把冗余的 LS 迁到缺 LS 的 Group 里;LS_BALANCE_BY_EXPAND 处理扩容,文档给的算法是“假设当前 LS 个数为 M、扩容后为 N(M < N),则每个缺少的 LS 都需要得到 M/N 个 Tablet”;LS_BALANCE_BY_SHRINK 处理缩容,“假设当前为 M、要缩到 N(M > N),则剩下每个 LS 都需要分到 (M-N)/N 个 Tablet”。这三种策略加上 LS Group 变更 和 Leader 均衡,构成了日志流层面的全部基本动作。配合这套策略的还有一个概念叫 Unit Group——官方说“不同 Zone 之间相同编号(UNIT_GROUP_ID)的 Unit 属于同一个 Unit Group。Unit Group 内所有 Unit 上的数据分布相同,具有相同的日志流副本,服务相同的分区数据”,而同构 Zone 模式下“一个 LS Group 唯一对应一个 Unit Group”。这条 Unit Group → LS Group → Tablet 的层级,就是 4.x 数据分布的全貌。
最有意思的是“临时日志流”这个机制。官方在均衡层章节描述:当用户删了一批表后,某些机器上的 Tablet 变少,均衡层会“将 Tablet 多的服务器上的日志流分裂出临时日志流并携带需要移动的 Tablet,临时日志流迁移到目的服务器后再与目的服务器上的日志流进行合并”。读这句话要注意它的真正含义——均衡的原子单位是日志流的“生灭”,而不是 Tablet 的搬运。源机器分裂出一个临时 LS(生),带着要迁的 Tablet 走(迁移),到了目标机器和目标 LS 合并(灭)。这个设计是有代价的:每次均衡都要动日志流的成员管理,比单纯搬 Tablet 重;但它换来了另一个好处——Tablet 的迁移可以完全在 LS 内部完成,不受 Paxos 成员变更的干扰。这和后续的分析结论形成呼应:复杂的、昂贵的操作(改成员)被隔离到低频路径,高频的数据搬运被压进便宜路径,这正是两级抽象在均衡上的具体兑现。
9. 事务层与 SQL 层的连带变化
复制单元变了,往上两层必须跟着变,而且变化的方向是一致的——把“数据在哪”这件事往上层暴露,并且要求上层把它显式表达出来。这句话听着抽象,落到具体机制上其实很清楚:事务层要显式区分“这个事务碰了几个日志流”,因为碰一个和碰多个走的是完全不同的提交路径;SQL 层要显式在计划里标注“这一步的数据在哪个节点、要不要搬过来”,因为优化器必须为分布式执行做决策。换句话说,4.x 把“数据分布”从一个底层的隐式事实,提升成了一个必须在上层代码里被写出来的显式约束,这是两级抽象给上层带来的连锁反应。
事务层最大的变化是提交协调单位从分区变成了日志流。单 LS 事务走一阶段提交(前面已论证),跨 LS 事务则选一个日志流作为协调者,协调者驱动所有参与 LS 写下 Write-Ahead Log、判断是否都持久化,都成了之后事务进入提交态,协调者再驱动所有 LS 写 Commit 日志。这里有个很聪明的容错设计值得单独说:每个参与日志流的 Write-Ahead Log 里都记着这个事务涉及的全部日志流列表,官方原文是“由于每个日志流的 write-ahead log 都包含了事务的所有日志流列表,通过此信息可以重新确定哪个日志流是协调者并恢复协调者的状态,再次推进两阶段提交协议,直到事务达到最终的 Commit 或 Abort 状态”。这意味着如果协调者所在机器在提交过程中宕机,从副本回放日志时能从任意一个参与者的日志里读出完整参与者列表,重新推导出谁是协调者,把 2PC 接着推完。传统 2PC 在协调者宕机时会阻塞,OB 用“日志里冗余存参与者列表 + 事务状态表”把阻塞窗口压到很小——它把一次性的直线流程做成了可循环推进、可自恢复的过程。另外要注意官方对“单机多日志流事务”的判断:官方明确说“由于 OceanBase 数据库日志流的设计,单机多日志流事务本质上也是分布式事务”,只是 OB 对“事务内参与者副本分布相同”的情况做了大量优化,让它比传统 2PC 快得多。这条容易被忽略——单机不等于单日志流,单机也可能是分布式事务。
SQL 层的变化在优化器里看得最清楚,而且很反直觉:优化器多了一个硬性任务——在计划树里显式插入数据交换节点,并把数据分布编译进计划。src/sql/optimizer/ob_optimizer.h:59 的 TraverseOp 枚举里有一项 EXCHANGE_NUMBERING,注释写着 “numbering exchange out for px”,意思是计划树遍历到某一步要开始处理数据交换(Exchange)节点并为并行执行(PX)编号;紧接着 61 行还有 GEN_LOCATION_CONSTRAINT,注释是 “generate plan location constraint, used from plan cache”。两行注释合起来说明了一件事:“数据在哪、要不要搬”不是运行时的临时决定,而是编译期就固化进计划树的结构。GEN_LOCATION_CONSTRAINT 那句 “used from plan cache” 尤其关键——一个计划被缓存后,重放时会带着位置约束重新校验,如果数据分布变了,缓存计划可能失效。这是分布式数据库计划缓存的固有难题,OB 的解法不是“缓存后靠运行时自适应”,而是把位置约束做成计划的一部分,代价是计划的有效性对数据分布变化敏感。
10. 常见误区
在结束这篇总览前,把读者最容易踩的几个坑说清楚。这些坑我在读代码和查资料时都真实遇到过,而且它们往往不是“某个细节记错了”,而是整套坐标系从一开始就理解偏了——正因为如此,它们才会在你后面读每一篇时反复制造困惑,让你在明明看着对的源码面前得出错的结论。下面这几段尽量不用清单,而是把每个误区的来龙去脉讲开,因为“知道它到底错在哪”比“背下正确答案”更有用。
一个最常见的误解是“一个分区对应一个 Paxos 组”。这是 3.x 的模型,4.x 已经变了,正确说法是“一个日志流一个 Paxos 组,一个日志流承载多个分区的 Tablet”。判断方法很简单:看 Paxos 组的数量跟什么相关——跟分区数相关就是 3.x,跟 UNIT_NUM × 一级 Primary Zone 数相关就是 4.x。另一个常被误解的是“分区是物理对象”。分区是逻辑对象,Tablet 才是物理对象,你在 __all_virtual_tablet_to_ls 这类视图里看到的是 Tablet 和日志流的映射;虽然单分区表里分区和 Tablet 经常一一对应,但多分区表里是“每个分区一个 Tablet”、索引表还会再配套索引 Tablet,一一对应只是巧合而非规则。把这两个误解放在一起看,会发现它们的共同病根是把“用户视角的对象”当成了“系统视角的对象”——用户看到的是分区,系统运作的是 Tablet 和 LS,中间隔着一层映射,误区的来源就是漏掉了这层映射。
关于副本数,也有一个反直觉的坑:三副本不等于“能挂两台”,而三副本里有一个 R 副本时,“三副本”甚至不等于三副本级容灾。Paxos 的容灾是“多数派”,官方文档中把副本分成三类——全能型 F“称为 Paxos 副本,对应副本可构成 Paxos 成员组,参与选举投票”,只读型 R 和列存型 C 都是“非 Paxos 副本,不可构成 Paxos 成员组,不参与选举投票”。所以当你配 F@z1, F@z2, R@z3 时,真正的 Paxos 成员只有 z1、z2 两个,多数派是 2,任何一台 F 故障都会导致失去多数派、服务不可用,这个配置给的容灾实际上是“两副本级别”,不是三副本级别。R 副本的价值在于“不增加投票成员”——你加多少 R 副本都不会拉长事务提交延迟,因为多数派计算里没有它们。理解了这一点,才能理解为什么“副本数”和“容灾能力”是两个不同的轴。
关于日志流本身,还有一个操作层面的误解:日志流不能“手动创建/删除”。日志流的数量和位置由 UNIT_NUM、Primary Zone、Locality 三个配置驱动,用户改的永远是这三个配置,RootService 根据它们推导出日志流的终态,再通过分裂、合并、迁移去逼近。官方文档中把触发条件写得很清楚——“当用户对租户执行 UNIT_NUM 变更、修改 PRIMARY_ZONE 的第一优先级、修改 Locality 等操作时,负载均衡模块的后台线程会立即通过 LS 分裂、LS 合并、LS Group 变更等动作,变更 LS 的数量和位置”。理解这一点,才能理解为什么“改个 UNIT_NUM”会触发大规模的均衡动作——它改的是终态,不是某个具体对象,系统要花很长时间才能把现实逼近到新终态。
最后两个坑偏向概念辨析。一是SCN 和 LSN 不是一回事:LSN 是日志流内的物理偏移,只在流内有意义,用于定位和复制日志;SCN 是租户内全局的逻辑时间戳,用于 MVCC 可见性判断。一次事务提交同时产生一个 LSN(日志写到哪了)和一个 SCN(事务版本是多少),两者单调递增且一一对应,但语义完全不同,混用会让你对可见性判断的理解出偏差。二是**“事务”这个词在 OceanBase 里至少有三种粒度**:用户事务(客户端 BEGIN/COMMIT)、日志流内的事务提交(一阶段)、跨日志流的分布式事务(2PC)。它们共享一套事务 ID 和状态机,但走的是不同的提交路径,读 src/storage/tx/ 时如果把这三种混在一起,代码会越读越乱——先分清“我现在读的这段逻辑服务的是哪一种事务”,再进去看细节,效率会高很多。
还有一个值得提前建立的认知:这套架构里没有“全局”这个位置。日志流是分布式的,每个流有自己的 Leader、自己的日志序列、自己的成员组;Tablet 可以在流之间迁移;元数据是每个节点各缓存一份、各自按需刷新。整个系统里不存在一个知道“所有数据在哪”的中心节点——RootService 知道日志流的分布与副本位置,但它不参与具体请求的路由,也不维护分区级的映射。这种去中心化是它能线性扩展的前提,代价是任何一份缓存都可能短暂过期,于是“投错后重试”不是异常处理,而是这套设计的固有组成部分。带着这个认知去读后面的章节,很多看起来像 bug 的行为就有了统一的解释。 另外值得注意的一点是这套架构对运维心智的要求变了。3.x 时代运维关心的是“分区有多少、分布均不均”,4.x 时代关心的是“日志流有多少、Leader 在哪、Unit 怎么摆”。对象粒度变了,观测的入口和调优的抓手也全变了——很多从 3.x 过来的运维习惯(比如按分区数估算开销)在 4.x 上会得出完全错误的结论。这也是为什么理解这次重构不仅是理论问题,它直接决定了你日常看哪些视图、调哪些参数。
顺带纠正一个关于“单机分布式一体化”的流行解读。它常被理解成“OceanBase 既能当单机用也能当分布式用”,这只说对了一半。更准确的说法是:同一套代码路径在小规格和大规格下都成立,因为日志流的数量由 UNIT_NUM 与 Primary Zone 推导,而不是由数据量决定——一个 2C6G 的部署和一台 256 核的机器跑的是同一套逻辑,差别只是同一台机器上放几个 Unit。这与传统分布式数据库“小集群也要按分布式那套来”形成鲜明对比,也是它能在小规格场景替代 MySQL 的底气所在。
小结
把这一篇的内容压缩成一句话,就是:4.0 重构的本质是把“复制”和“存储”这两件事的粒度拆开,让它们各自独立伸缩。3.x 里分区同时承担存储单位、复制单位、调度单位三种角色,于是 Paxos 组的数量被迫跟着数据量增长,单机规格门槛高、分区数上限低、小规格部署不可行。4.x 把复制单位上抬到日志流、把存储与调度单位下放到 Tablet,Paxos 组的数量从此只跟“单位数”有关,跟数据规模无关。
这个拆分带来的三个红利值得记住。Paxos 组数与分区数解耦,所以 2C6G 的机器也能跑起来;同日志流的多个 Tablet 共享一条 Redo 日志,所以跨 Tablet 事务大概率退化成一阶段提交;日志与元数据不再按分区重复维护,所以日志量同比下降三到四成。这三个红利不是三个独立优化,而是同一次抽象解耦的三个投影——理解到这一层,后面所有看起来零散的设计都能串起来。
代价也是真实的。多一层抽象就多一层对象关系和一套动态调度算法:Tablet 可以在日志流之间迁移、日志流可以分裂合并、元数据需要按需加载与租户级回收,这些机制加起来构成了存储层最复杂的部分。所以 4.x 不是“变简单了”,而是把复杂度从“规模相关的开销”转移到了“固定成本的实现复杂度”上——前者会随数据增长而恶化,后者是一次性的工程投入。这是一笔很划算的交易,但只有理解了这个交易,才知道为什么有些地方的代码会写得那么绕。
最后留一个读代码的建议:不要试图从 main() 开始顺着调用链往下读。OceanBase 的对象关系是多对多的,顺着调用链读会在第三层就迷路。正确的读法是先建立对象模型——搞清楚 ObLS、ObTablet、ObSSTable、ObTxCtx 这几个核心对象各自持有什么、生命周期如何、彼此怎么引用,然后任何一段代码都能立刻定位到它在对象模型里的位置。对象模型就是地图,没有地图读代码,每一步都要重新推断上下文。