ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OceanBase源码架构总览

OceanBase源码架构总览 OceanBase源码架构总览官方开源代码oceanbase · GitHub如果你是在 2019 到 2021 年之间接触 OceanBase 的脑子里大概率存着这样一张图用户建表产生若干分区Partition每个分区是一个独立的Paxos 组三个副本分布在不同 ZoneLeader 副本对外提供读写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 的访问接入层也就是 ODPOBProxy负责把请求路由到合适的数据节点。把它们按顺序念一遍你会发现这是一个从“资源怎么分”到“数据怎么存”再到“请求怎么进来”的完整闭环——每一层的输出正好是下一层的输入。这份分层里有五个字值得单独拎出来看“复制层”和“存储层”是分开的两层。这不是排版上的并列而是一次抽象上的解耦。在 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_NUM3、Primary Zone 一级是z1,z2两个 Zone那么用户日志流就是 3×26 个。上面那个 10 万张表、10 万分区的场景分区数还是 10 万Tablet 数可能涨到十几万但 Paxos 组只有 6 个平均每个日志流里装着一万六千多个 Tablet。这里最关键的一句是文档同时写明的那半句“每个租户在每台机器上只会有一个日志流的主副本”——也就是说单机视角下 Paxos 组的数量从“一万个”变成了“大约一个”误差是三个数量级。为了把这个量级落差说具体把两种架构在同一个场景下的成本项并排放在一张表里对照。这张表不是要罗列要点而是想让“10 万”和“6”这两个数字出现在同一个坐标系里——同样是 10 万分区、10 台机器左边每一行都在做乘法右边几乎全是常数。看表的时候注意一件事左边每一行的数字都会随着业务变大而线性上涨右边则纹丝不动这正是“解耦”二字最直白的体现多字段对照非要点罗列成本项3.x分区粒度 Paxos10 万分区 / 10 机4.x日志流6 个 LSPaxos 组总数100,0006平均每机 Paxos 主副本数~10,000~1独立选举过程100,0006独立日志落盘序列每分区一条每 LS 一条均衡的最小搬运单位PartitionTablet组数随什么增长随数据量分区数随机器数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 版本、数据版本都分开管——源码里ObTabletsrc/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 落在同一个 LSA 的日志风暴就会把 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 112048同文件 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/是日志流的实现核心类是ObLSsrc/storage/ls/ob_ls.h:190class ObLS : public common::ObLink。从职责上看它是个聚合根get_tablet_svr()返回ObLSTabletService*ob_ls.h:281管理这个 LS 上所有 Tabletget_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 4ob_ls.h:199和ObLSInnerTabletIDIter说明每个 LS 会自带几个内部 Tablet 用于系统用途。这种“什么都不缺、自成一体”的结构正是“日志流是提交单位”在代码上的体现也顺带解释了一个乍看奇怪的现象——为什么事务、锁、冻结这些表面上分属不同子系统的东西最后都挂在ObLS这一个聚合根上因为它们的服务边界本来就是同一条日志流。src/storage/tablet/是 Tablet 的实现。ObTabletsrc/storage/tablet/ob_tablet.h:217承载分片自己的东西get_ls_id()、get_tablet_id()、get_data_tablet_id()都在ob_tablet.h:268-270附近全部转发到tablet_meta_。而ObTabletMetasrc/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.hIO 工作线程、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 ObResourceMapObTabletMapKey, 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。ObTenantMetaMemMgrsrc/storage/meta_mem/ob_tenant_meta_mem_mgr.h:119是这套结构的租户级管理者它按桶bucket组织这张大表DEFAULT_BUCKET_NUM 10243Lob_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_versionob_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、扩容后为 NM N则每个缺少的 LS 都需要得到 M/N 个 Tablet”LS_BALANCE_BY_SHRINK处理缩容“假设当前为 M、要缩到 NM 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 成员组不参与选举投票”。所以当你配Fz1, Fz2, Rz3时真正的 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 把复制单位上抬到日志流、把存储与调度单位下放到 TabletPaxos 组的数量从此只跟“单位数”有关跟数据规模无关。这个拆分带来的三个红利值得记住。Paxos 组数与分区数解耦所以 2C6G 的机器也能跑起来同日志流的多个 Tablet 共享一条 Redo 日志所以跨 Tablet 事务大概率退化成一阶段提交日志与元数据不再按分区重复维护所以日志量同比下降三到四成。这三个红利不是三个独立优化而是同一次抽象解耦的三个投影——理解到这一层后面所有看起来零散的设计都能串起来。代价也是真实的。多一层抽象就多一层对象关系和一套动态调度算法Tablet 可以在日志流之间迁移、日志流可以分裂合并、元数据需要按需加载与租户级回收这些机制加起来构成了存储层最复杂的部分。所以 4.x 不是“变简单了”而是把复杂度从“规模相关的开销”转移到了“固定成本的实现复杂度”上——前者会随数据增长而恶化后者是一次性的工程投入。这是一笔很划算的交易但只有理解了这个交易才知道为什么有些地方的代码会写得那么绕。最后留一个读代码的建议不要试图从main()开始顺着调用链往下读。OceanBase 的对象关系是多对多的顺着调用链读会在第三层就迷路。正确的读法是先建立对象模型——搞清楚ObLS、ObTablet、ObSSTable、ObTxCtx这几个核心对象各自持有什么、生命周期如何、彼此怎么引用然后任何一段代码都能立刻定位到它在对象模型里的位置。对象模型就是地图没有地图读代码每一步都要重新推断上下文。
返回列表