OceanBase 4.3 最重要的新特性之一是列存(行存转列存)。开启列存后,一份逻辑表在磁盘上不再是单个行存文件,而是拆成「1 个列存主 SSTable(ObCOSSTableV2)+ N-1 个列组子表(CG SSTable)」:每个列组(Column Group)按列聚簇存储,默认包含 rowkey CG(行存形态的全列兜底)和若干普通 CG。ObCOSSTableV2 继承自行存基类 ObSSTable,对外仍然是统一的 SSTable 门面——上层读接口完全不用改,对内则负责聚合各列组的统计信息、按需加载列组数据。本文基于 ob_column_oriented_sstable.cpp(约 1000 行)梳理这套结构的核心数据模型与读路径分派逻辑。
一、为什么要拆成多个物理文件
行存格式把一行的所有列放在一起,点查快但分析查询要扫大量无关列;列存把同一列的数据连续存放,聚合、投影、过滤都只碰需要的列。OceanBase 的折中方案是列组:把经常一起访问的列划进一个 CG,每个 CG 独立成文件。围绕这个模型,文件里定义了三层结构:
- ObSSTableWrapper:裸指针 + 元数据句柄的组合。sstable_ 可直接使用,meta_handle_ 兜底生命周期,防止底层 SSTable 在使用中被元数据缓存淘汰。
- ObCOSSTableMeta:全表列存统计聚合体(宏块数、微块数、占用与原始大小、数据校验和、列组数、全列数)。它把 N 个 CG 的 meta 累加成一份汇总数字,让租户容量统计像看待普通行存表一样看待列存表。
- ObCOMajorSSTableStatus:行转列的渐进合并进度状态。COL_WITH_ALL 表示列组与全列行存并存,PURE_COL 表示已是纯列存,带 WITH_ALL 后缀表示仍保留一份全列行存镜像。每次 major 合并推进一格,保证格式切换全程始终有可用形态可读。
ObCOSSTableV2 本身继承 ObSSTable,再叠加三个关键成员:base_type_(底层形态)、is_cgs_empty_co_(无 CG 子表的空壳列存表)、valid_for_cs_reading_(列存读就绪标志)。
二、init:合法性校验与空壳分支
初始化入口有两道硬约束:列组数必须大于 1(至少 rowkey CG 加一个普通 CG),底层形态必须落在枚举区间内。值得注意的是空壳分支——空表或普通 CG 冗余时,co 主表自己是唯一的全列 CG,无需挂 CG 子表:
int ObCOSSTableV2::init(
const ObTabletCreateSSTableParam ¶m,
common::ObArenaAllocator *allocator)
{
int ret = OB_SUCCESS;
if (OB_UNLIKELY(!param.is_valid() ||
ObCOSSTableBaseType::INVALID_TYPE >= param.co_base_type() ||
ObCOSSTableBaseType::MAX_TYPE <= param.co_base_type() ||
1 >= param.column_group_cnt() ||
NULL == allocator)) {
ret = OB_INVALID_ARGUMENT;
LOG_WARN("get invalid arguments", K(ret), K(param), K(allocator));
} else if (OB_FAIL(ObSSTable::init(param, allocator))) {
LOG_WARN("failed to init basic ObSSTable", K(ret), K(param));
} else if (param.is_co_table_without_cgs()) {
// 空表或普通 CG 冗余:co 表自己是唯一全列 CG,无需挂 CG 子表
cs_meta_.column_group_cnt_ = param.column_group_cnt();
is_cgs_empty_co_ = true;
if (OB_FAIL(build_cs_meta_without_cgs())) {
LOG_WARN("failed to build cs meta without cgs", K(ret), K(param), KPC(this));
}
}
// ...随后设置 base_type_ / valid_for_cs_reading_ / full_column_cnt_
}
三、build_cs_meta:跨列组一致性校验
各 CG 是同一份数据的不同列切面,它们的统计必须能对上。build_cs_meta 把 base CG(即 this 自己)和所有普通 CG 的统计逐项累加,累加前做三道校验:
const ObSSTableArray &cg_sstables = meta_->get_cg_sstables();
const int64_t cg_table_cnt = cg_sstables.count() + 1/*base_cg_table*/;
cs_meta_.column_group_cnt_ = cg_table_cnt;
for (int64_t idx = 0; OB_SUCC(ret) && idx < cg_table_cnt; ++idx) {
// 最后一个位置是 base CG,即 co 主表自己
ObSSTable *cg_sstable = (cg_table_cnt - 1 == idx) ? this : cg_sstables[idx];
// ...取各 CG meta 后依次校验:
// ① end_scn 必须相同 —— 各 CG 必须来自同一快照版本
// ② schema 版本必须相同 —— 列定义一致
// ③ major 表行数必须相同 —— 列切面行数对得上
// 校验通过后逐项累加:
cs_meta_.data_macro_block_cnt_ += cg_meta_handle.get_sstable_meta().get_basic_meta().data_macro_block_count_;
cs_meta_.data_micro_block_cnt_ += cg_meta_handle.get_sstable_meta().get_basic_meta().data_micro_block_count_;
cs_meta_.occupy_size_ += cg_meta_handle.get_sstable_meta().get_basic_meta().occupy_size_;
cs_meta_.data_checksum_ += cg_meta_handle.get_sstable_meta().get_basic_meta().data_checksum_;
}
任何不一致直接报错——宁可失败也不允许脏读。这套校验是列存正确性的根基。
四、惰性加载与一处精妙的 off-by-one
CG 子表并不随 co 主表常驻内存,而是首次访问时才通过 ObTabletTableStore::load_sstable 现场加载(惰性加载)。取某个 CG 时有个容易踩坑的细节:在线加列组后,rowkey CG 可能被插到 CG 数组中间,此时列组号与数组下标出现错位:
} else if (cg_idx == key_.get_column_group_id()) {
// 目标就是 co 主表自己
cg_wrapper.sstable_ = const_cast<ObCOSSTableV2 *>(this);
} else if (OB_UNLIKELY(is_cgs_empty_co_)) {
ret = OB_STATE_NOT_MATCH;
} else {
const ObSSTableArray &cg_sstables = co_meta_handle.get_sstable_meta().get_cg_sstables();
cg_wrapper.sstable_ = cg_idx < key_.column_group_idx_
? cg_sstables[cg_idx]
: cg_sstables[cg_idx - 1]; // rowkey CG 插到中间后,下标整体后移
}
同文件里 get_all_tables 的注释也值得玩味:返回裸 ObITable* 已经不再安全,因为 CG 子表可能还没加载,必须改返回带生命周期句柄的 Wrapper 列表。
五、cg_scan:八种迭代器的选择逻辑
cg_scan 是列存读路径的总分派器,也是本文件含金量最高的函数。它按查询特征选择不同的 CG 迭代器:
if (is_virtual_cg(param.cg_idx_)) {
// 虚拟列组:不落盘的动态列组
ALLOCATE_CG_ITER(context, param.cg_idx_, ObVirtualCGScanner, cg_scanner);
} else if (OB_FAIL(fetch_cg_sstable(param.cg_idx_, table_wrapper))) {
// 先惰性加载目标 CG
} else if (project_single_row) {
// 投影单行
ALLOCATE_CG_ITER(context, param.cg_idx_, ObCGSingleRowScanner, cg_scanner);
} else if (param.cg_idx_ >= cs_meta_.column_group_cnt_) {
// 超出列组范围 → 默认 CG(兜底全列),再按聚合/投影/过滤三档细分
if (param.enable_pd_group_by() && is_projector) {
ALLOCATE_CG_ITER(context, param.cg_idx_, ObDefaultCGGroupByScanner, cg_scanner);
} else if (is_projector) {
ALLOCATE_CG_ITER(context, param.cg_idx_, ObDefaultCGScanner, cg_scanner);
} else {
ALLOCATE_CG_ITER(context, param.cg_idx_, ObDefaultCGFilterScanner, cg_scanner);
}
} else if (param.enable_pd_group_by() && is_projector) {
// Parallel Dataframe group by 下推
ALLOCATE_CG_ITER(context, param.cg_idx_, ObCGGroupByScanner, cg_scanner);
} else if (param.enable_pd_aggregate()) {
// 聚合下推:sum/count 等在存储层直接算
ALLOCATE_CG_ITER(context, param.cg_idx_, ObCGAggregatedScanner, cg_scanner);
} else if (is_projector) {
ALLOCATE_CG_ITER(context, param.cg_idx_, ObCGRowScanner, cg_scanner);
} else {
ALLOCATE_CG_ITER(context, param.cg_idx_, ObCGScanner, cg_scanner);
}
聚合与分组下推到存储层直接计算,正是列存加速 OLAP 查询的落点。此外文件还实现了 fill_column_ckm_array:从各 CG 的 meta 取出列校验和,按全表列号摊平成数组,同一列若出现在多个 CG,两边校验和必须一致,否则判定数据损坏。
六、scan/get 的行存回退
列存并不是万能的。scan 里有两个回退条件:纯行存形态的 co 表,或 all-cg 基表且本次访问未启用列存时,直接走行存老路 ObSSTable::scan:
} else if (is_row_store_only_co_table() ||
(is_all_cg_base() && !param.is_use_column_store())) {
// 行存回退:调用方无感知
if (OB_FAIL(ObSSTable::scan(param, context, key_range, row_iter))) {
LOG_WARN("Fail to scan in row store sstable", K(ret));
}
} else {
ObStoreRowIterator *row_scanner = nullptr;
ALLOCATE_TABLE_STORE_ROW_IETRATOR(context, ObCOSSTableRowScanner, row_scanner);
// ...跨列组拼行后按行输出
}
get(点查)的回退条件更宽:只要只访问 rowkey 列或是 all-cg 基表就走行存——点查天然享受不到列裁剪收益,行存格式反而最快。反序列化则采用追加式设计:在行存 ObSSTable 字段之后顺序追加 base_type_、is_cgs_empty_co_、cs_meta_,老版本代码读完行存字段即可工作。
七、小结
ob_column_oriented_sstable.cpp 展示了 OceanBase 4.3 列存的骨架:一份逻辑表拆成 co 主表加 N 个列组子表,主表作为门面聚合统计、惰性加载、分派读路径。三个设计点最值得学习:其一,「接口不变、实现切换」——上层仍通过 scan/get/cg_scan 访问,行存与列存在函数内部透明回退;其二,跨 CG 的一致性校验(快照版本、schema 版本、行数三对齐)从物理层面保证了列存正确性;其三,cg_scan 的迭代器动物园把聚合/分组/过滤下推到存储层,这是列存加速分析查询的真正落点。理解了这层结构,再去看列存合并(ob_co_merge_dag)和列组扫描器实现就有了抓手。