OceanBase源码解读:列存表存储结构 ob_column_oriented_sstable

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 &param,
    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)和列组扫描器实现就有了抓手。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注