对 StoneDB 列式优化

1. StoneDB 列存引擎概述

StoneDB 是一款基于 MySQL 内核构建的开源 HTAP 数据库,其核心分析能力由 Tianmu 列式存储引擎提供。与传统的 InnoDB 行存引擎不同,Tianmu 采用列式存储模型,将每列数据独立组织为固定大小的 Data Pack(默认 65536 行/Pack),配合 Knowledge Grid 元数据索引实现高效的数据过滤与查询优化。

从架构上看,StoneDB 遵循三层结构:应用层负责连接管理与认证,服务层提供 SQL 解析与查询缓存,存储引擎层则由 Tianmu 承担列存数据的存储与检索。Tianmu 通过 ha_tianmu 实现 MySQL 标准的 handler 接口,在 Engine::HandleSelect() 中判断查询是否可由 Tianmu 处理——若所有表均为 Tianmu 表且语法受支持,则进入 Tianmu 自有的编译-优化-执行链路;否则回退至 MySQL 原生路径。

SQL Query
  └─ MySQL Parser
       └─ ha_tianmu::HandleSelect()
            └─ IsTIANMURoute()  →  Tianmu Path / MySQL Fallback
                 └─ Query::Compile()
                      └─ CompiledQuery (steps)
                           └─ Query::Preexecute()
                                └─ TempTable::Materialize()
                                     └─ ResultSender → Client

这个架构设计使 StoneDB 在享受 MySQL 生态兼容性的同时,能够针对分析型负载做深度优化。然而,正是在”查询优化”这个环节,Tianmu 引擎存在一些关键的能力缺口——子查询处理就是其中最典型的例子。


子查询优化:EXISTS/IN Flattening & Semi-Join

TPCH Q4 的性能瓶颈

TPC-H Q4 是一个经典的分析型查询,其核心模式是外层 orders 表通过 EXISTS 子查询关联内层 lineitem 表:

SELECT o_orderpriority, COUNT(*) AS order_count
FROM orders
WHERE o_orderdate >= '1993-07-01'
  AND o_orderdate < '1993-10-01'
  AND EXISTS (
    SELECT * FROM lineitem
    WHERE l_orderkey = o_orderkey
      AND l_commitdate < l_receiptdate
  )
GROUP BY o_orderpriority
ORDER BY o_orderpriority;

通过 EXPLAINOPT_TRACE 分析可以发现,Tianmu 对此类 EXISTS/IN 子查询默认走 Nested Loop 执行路径——对外表的每一行,都要完整扫描内表来判断是否存在匹配。这在列存引擎中代价极高:列存的优势在于批量顺序扫描,而 Nested Loop 的逐行驱动模式完全破坏了这一优势。

问题的根源在于:原版 Tianmu 缺乏子查询展开(Subquery Flattening)与半连接(Semi-Join)执行路径。MySQL 5.7 优化器虽然内置了 Semi-Join 改写能力,但 Tianmu 引擎在查询编译阶段并未对接这套机制,导致 EXISTS 子查询始终以独立子查询的方式被执行。

改造 MySQL 5.7 子查询改写链路

解决这个问题的第一步是在 MySQL 优化器层面打通 Semi-Join 的改写通道。

SELECT_LEX::resolve_subquery() 中的 Semi-Join 候选判定:

MySQL 5.7 的子查询解析流程中,resolve_subquery() 是决定子查询命运的关键函数。在该函数中,需要增强对 Semi-Join 候选条件的判定逻辑:当子查询满足以下条件时,将其标记为 Semi-Join 候选——

resolve_subquery() 判定流程:

  子查询类型 = EXISTS / IN ?
    ├─ YES → 检查子查询结构是否满足展开条件
    │         ├─ 无 UNION / GROUP BY / HAVING / 聚合 / LIMIT ?
    │         │    ├─ YES → 标记为 semi-join 候选
    │         │    └─ NO  → 保持原子查询执行
    │         └─ 关联谓词可提取?
    │              ├─ YES → 进入 convert_subquery_to_semijoin()
    │              └─ NO  → 保持原子查询执行
    └─ NO  → 其他子查询处理路径

SELECT_LEX::convert_subquery_to_semijoin() 完成转换:

一旦子查询被判定为 Semi-Join 候选,convert_subquery_to_semijoin() 函数负责执行实际的结构转换。这个过程涉及三个核心操作:

(1)表上提到 sj_nest

将子查询中的表从子查询的 SELECT_LEX 中”上提”到外层查询的 FROM 子句中,创建一个特殊的 TABLE_LIST 节点(即 sj_nest)。这个节点在优化器看来就是一个普通的 JOIN 参与者,但带有 SEMI JOIN 的语义标记。

-- 改写前的逻辑结构
SELECT ... FROM orders
WHERE EXISTS (SELECT * FROM lineitem WHERE ...)

-- 改写后的逻辑结构
SELECT ... FROM orders SEMI JOIN lineitem ON (关联条件)

(2)谓词上提到 sj_cond

子查询内部的关联条件(如 l_orderkey = o_orderkey)被提取出来,挂载到 sj_nestsj_cond 上。同时,子查询内部的非关联条件(如 l_commitdate < l_receiptdate)会合并到 ON 条件中。这样,优化器在后续的 JOIN 优化阶段可以统一处理所有的连接条件。

(3)维护 sj_outer_exprs / sj_inner_exprs 表达式结构

为了让优化器和执行器能够正确地识别 Semi-Join 中的外表表达式与内表表达式的对应关系,转换过程还需要维护 sj_outer_exprssj_inner_exprs 两个列表。以 l_orderkey = o_orderkey 为例,o_orderkey 进入 sj_outer_exprsl_orderkey 进入 sj_inner_exprs。这一对应关系是后续选择具体 Semi-Join 执行策略的基础。

完成以上转换后,优化器与执行器可以复用同一套 JOIN 图表示来处理 Semi-Join,不再需要为子查询维护独立的执行上下文。

补齐 Tianmu 半连接执行与去重能力

打通改写链路只是第一步。更关键的是要在 Tianmu 引擎侧实现半连接的物理执行策略。MySQL 5.7 定义了多种 Semi-Join 策略,需要在 Tianmu 中逐一对齐:

DuplicateWeedout(重复消除)

这是最通用的 Semi-Join 策略。其核心思想是:将 Semi-Join 当作普通 INNER JOIN 执行,但在结果集中通过临时表对外表行进行去重。

执行流程:
  1. 按普通 INNER JOIN 执行 orders ⋈ lineitem
  2. 对每一行结果,取外表的 rowid 插入临时表
  3. 若 rowid 已存在(唯一键冲突),丢弃该行
  4. 若 rowid 不存在,保留该行并输出

这里有一个 Tianmu 特有的挑战:Tianmu 列存引擎没有像 InnoDB 那样的默认隐藏主键(即 DB_ROW_ID)。DuplicateWeedout 策略依赖 rowid 来标识外表的唯一行。因此,在实现时需要确保 Tianmu 能够提供一个等价的行标识机制——可以是列存中的 (pack_no, row_in_pack) 组合,也可以是显式主键列。

FirstMatch(首次匹配即跳转)

FirstMatch 策略针对的是”只需要知道是否存在匹配”这一语义。当内表扫描到第一条匹配行时,立即跳转到外表的下一行,跳过内表剩余的扫描。

执行流程:
  for each row in orders:
    for each row in lineitem:
      if l_orderkey == o_orderkey AND l_commitdate < l_receiptdate:
        OUTPUT(orders row)
        BREAK  // 命中即跳转,不再继续扫描 lineitem

这一策略的优势在于:对于匹配率较高的场景,能够大幅减少内表扫描量。在 Tianmu 的 Pack 扫描模型中,FirstMatch 可以在 Pack 级别实现”命中即跳转”,进一步利用 Knowledge Grid 的元数据跳过不可能包含匹配行的 Pack。

Materialize(物化)

Materialize 策略先将内表子查询的结果物化到一个临时表中,然后外表与该临时表做 JOIN。对于子查询结果集较小且可被多次复用的场景,这一策略可以避免重复执行子查询。

执行流程:
  1. 执行 SELECT DISTINCT l_orderkey FROM lineitem
     WHERE l_commitdate < l_receiptdate
     → 物化为临时表 tmp
  2. orders ⋈ tmp ON o_orderkey = tmp.l_orderkey

在 Tianmu 中实现 Materialize 策略时,可以利用列存的高压缩比优势:物化后的临时表数据量通常远小于原始内表,JOIN 操作的数据量大幅减少。

增强可执行性约束

在将子查询转换为 Semi-Join 的过程中,并非所有的转换都能在 Tianmu 引擎上高效执行。因此需要在转换阶段引入可执行性约束,避免产生不可执行或明显退化的 Semi-Join 计划。

关键约束包括:

通过这些约束,优化器能够为每个具体场景选择最合适的执行路径,而不是盲目地将所有 EXISTS/IN 子查询都展开为 Semi-Join。


2. Delta Store 行存层:实时写入 + 后台合并

列存写入的痛点

列式存储的天然优势在于读取——分析查询只需扫描相关列,压缩率高,I/O 效率好。然而,写入是列存引擎的”阿喀琉斯之踵”。一次 INSERT 操作在列存中意味着:

  1. 将行数据拆分为各列的值
  2. 将每个值按照对应列的编码方式进行编码
  3. 将编码后的数据追加到 Data Pack 中
  4. 当 Pack 满时,执行压缩并持久化

这个过程的写放大非常显著:一行数据的写入被放大为 N 列的独立写操作(N 为列数),每列还涉及编码和压缩的计算开销。对于 UPDATE 和 DELETE,情况更加复杂——列存中没有原地更新的概念,通常需要标记删除 + 重写。

对于 HTAP 场景,实时写入性能是不可回避的需求。Delta Store 行存层的设计正是为了解决这一矛盾。

架构设计:RocksDB 作为行存增量层

Delta Store 的核心思想是引入一个行存增量层,将实时写入操作先以行格式落入该增量层,再由后台任务异步地将行数据转换为列格式并合并到主列存中。

          ┌─────────────────────────────────────────┐
          │            MySQL Server Layer            │
          │        (INSERT / UPDATE / DELETE)         │
          └──────────────┬──────────────────────────┘
                         │
                         ▼
          ┌──────────────────────────────────────────┐
          │          Delta Store (RocksDB)            │
          │  ┌────────────────────────────────────┐  │
          │  │  Row Format:                       │  │
          │  │  Key = table_prefix + row_id        │  │
          │  │  Value = RecordType + encoded fields │  │
          │  │                                     │  │
          │  │  Per-table Column Family             │  │
          │  │  Merge Operator for same-key ops     │  │
          │  └────────────────────────────────────┘  │
          └──────────────┬──────────────────────────┘
                         │  后台 Merge 线程池
                         ▼
          ┌──────────────────────────────────────────┐
          │        Tianmu Column Store (Base)         │
          │  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐   │
          │  │Col 0 │ │Col 1 │ │Col 2 │ │ ...  │   │
          │  │ Pack │ │ Pack │ │ Pack │ │      │   │
          │  └──────┘ └──────┘ └──────┘ └──────┘   │
          └──────────────────────────────────────────┘

选择 RocksDB 作为行存增量层有几个关键原因:RocksDB 基于 LSM-Tree,天然适合写密集型负载;它的 Merge Operator 机制可以在不读取旧值的情况下合并同一 key 的多次写入;此外,RocksDB 本身已经是 StoneDB 持久化存储的底层组件,复用它可以减少系统复杂度。

行数据编码协议与合并语义

编码协议

Delta Store 定义了一套行数据编码协议,将 MySQL 的 Field 对象序列化为字节流。编码格式如下:

┌──────────┬──────────┬──────────┬─────────────────────┐
│RecordType│ null_mask│ field_1  │ field_2  │ ...      │
│ (1 byte) │(variable)│(variable)│(variable)│          │
└──────────┴──────────┴──────────┴─────────────────────┘

RecordType:
  INSERT = 0x01  — 新增一行
  UPDATE = 0x02  — 更新已有行
  DELETE = 0x03  — 删除已有行

每个 Field 根据其 MySQL 类型进行紧凑编码:定长类型(INT、BIGINT 等)直接写入固定字节;变长类型(VARCHAR、TEXT 等)先写长度前缀再写数据。null_mask 是一个位图,标记哪些列为 NULL。

Merge Operator 合并语义

RocksDB 的 Merge Operator 是处理同一 key 多次写入的关键机制。当同一 row_id 上发生多次操作时,Merge Operator 按照以下规则合并:

这一合并语义确保了:无论中间经历了多少次修改,最终在 RocksDB 中只保留该行的”净效果”,从而减少后续 Merge 到列存时的数据量。

按表 Column Family + Prefix Seek 优化访问

独立 Column Family

Delta Store 为每张表分配独立的 RocksDB Column Family。这一设计的好处是:

Prefix Seek 高效扫描

Key 的组织格式为 table_prefix + row_id,其中 table_prefix 是表的唯一标识。利用 RocksDB 的 Prefix Seek 特性,可以高效地扫描某张表的所有增量数据:

// 伪代码:扫描表 T 的所有 delta 数据
ReadOptions opts;
opts.prefix_same_as_start = true;
auto iter = db->NewIterator(opts, table_cf);
iter->Seek(table_prefix);
while (iter->Valid()) {
    // 解析 row_id 和行数据
    auto row_id = DecodeRowId(iter->key());
    auto record = DecodeRecord(iter->value());
    ProcessDeltaRecord(row_id, record);
    iter->Next();
}

对于点查场景(如根据 row_id 查找单行的 delta 状态),直接使用 Get() 操作即可,时间复杂度接近 O(1)。

后台 Merge:Row → Column 落盘链路

Delta Store 中的数据不能无限增长,需要定期将增量数据合并到主列存中。这一过程由后台 Merge 线程池调度执行。

任务调度

Merge 任务按表和 Segment(Pack 的逻辑分组)切分。调度器会监控每张表在 Delta Store 中的增量数据量,当超过阈值时触发 Merge 任务。

Merge 调度器
  │
  ├─ Table A, Segment 0: delta_rows = 12000 → 触发 Merge
  ├─ Table A, Segment 1: delta_rows = 3000  → 暂不触发
  ├─ Table B, Segment 0: delta_rows = 65536 → 触发 Merge
  └─ ...

Merge 执行流程

每个 Merge 任务的执行分为四个阶段:

  1. 拉取(Pull):从 RocksDB 中读取指定表和 Segment 范围内的所有增量行数据
  2. 解析(Parse):将字节流解码为结构化的行记录,识别 RecordType
  3. 转换(Transform):将行记录拆分为各列的值,按列组织并编码为列存格式
  4. 写入(Flush):将转换后的列数据追加到对应列的 Data Pack 中,更新 DPN 元数据
RocksDB (行格式)          Tianmu Column Store (列格式)
┌────────────────┐        ┌───────┐ ┌───────┐ ┌───────┐
│ row_id=1: I    │   ──→  │ Col 0 │ │ Col 1 │ │ Col 2 │
│ row_id=2: I    │   ──→  │ val   │ │ val   │ │ val   │
│ row_id=3: U    │   ──→  │ val   │ │ val   │ │ val   │
│ row_id=5: D    │   ──→  │(mark) │ │(mark) │ │(mark) │
└────────────────┘        └───────┘ └───────┘ └───────┘

Merge 完成后,对应的 RocksDB 数据会被清理(通过 DeleteRange 或逐 key 删除),释放 Delta Store 的空间。

统一 row_id 生成与查询一致性

row_id 的统一分配

在 Delta Store 中写入新行时,需要预先分配与列存一致的 row_id。这一步至关重要——如果 Delta 层和 Base 层使用不同的行标识方案,后续的合并和查询都会变得极其复杂。

具体实现上,Tianmu 维护一个全局的 row_id 计数器。每次 INSERT 操作在 Delta 层执行时,先从计数器获取下一个可用的 row_id,然后以此 row_id 作为 key 写入 RocksDB。后台 Merge 时,这些行按照已分配的 row_id 写入列存的对应 Pack 位置,保证 Base 层和 Delta 层的定位方式完全一致。

CombinedIterator:Base + Delta 组合迭代

查询时,需要同时读取 Base 列存中的数据和 Delta Store 中尚未合并的增量数据,并提供一个一致的视图。这由 CombinedIterator 实现:

CombinedIterator 逻辑:

  for each row_id in [0, max_row_id]:
    if delta.contains(row_id):
      record = delta.get(row_id)
      if record.type == DELETE:
        SKIP  // 该行已被删除
      else if record.type == UPDATE:
        YIELD merge(base.get(row_id), record)  // 覆盖更新的字段
      else:  // INSERT
        YIELD record  // 纯增量行,Base 中无对应数据
    else:
      YIELD base.get(row_id)  // 无 delta,直接读 Base

这一设计保证了无论数据处于 Delta 还是 Base 中,查询都能看到最新的一致状态。对于 UPDATE 操作,CombinedIterator 执行字段级别的覆盖:Delta 中有更新值的字段取 Delta 的值,其余字段仍从 Base 读取。对于 DELETE 操作,CombinedIterator 直接跳过该行。

事务分离与一致性保障

Delta Store 的事务模型采用前台/后台分离的设计:

前台事务

前台写入操作(INSERT/UPDATE/DELETE)复用 MySQL 的事务接口。当用户执行 DML 操作时:

  1. MySQL 事务框架管理事务的 BEGIN/COMMIT/ROLLBACK 语义
  2. Delta Store 的 RocksDB 写入和索引更新在同一个事务上下文中完成
  3. COMMIT 时,RocksDB 的 WriteBatch 原子提交,保证 delta 数据和索引的一致性

这一设计的关键优势在于:前台写入只需要写入 RocksDB(行格式),不需要执行列存的编码/压缩操作,写入延迟大幅降低。

后台事务

后台 Merge 任务使用 StoneDB 自有的事务封装。每个 Merge 任务作为一个独立事务执行:

  1. 从 RocksDB 读取增量数据(读操作)
  2. 执行行到列的转换
  3. 将列格式数据写入列存
  4. 清理已合并的 RocksDB 数据
  5. 整个过程原子提交

后台 Merge 事务与前台写入事务相互隔离,保证了两个关键特性:


两大特性的协同效应

子查询优化与 Delta Store 看似是两个独立的方向,但它们共同服务于 StoneDB 的 HTAP 定位。

从查询侧来看,Semi-Join 优化使 Tianmu 能够高效处理 EXISTS/IN 这类在 OLAP 中极为常见的关联子查询模式,不再退化为逐行驱动的 Nested Loop。配合 Knowledge Grid 的 Pack 级过滤,Semi-Join 的 FirstMatch 策略可以在 Pack 粒度实现跳跃式扫描,充分发挥列存的批量 I/O 优势。

从写入侧来看,Delta Store 解耦了实时写入与列存落盘两个过程。前台写入以行格式快速进入 RocksDB,后台 Merge 以列格式异步归并到 Pack 中。对于 Semi-Join 查询而言,CombinedIterator 透明地合并了 Base 和 Delta 的数据,保证了即使部分数据尚未 Merge,查询结果仍然完整且一致。

这种”读优化 + 写缓冲”的组合模式,是 HTAP 系统的经典范式。StoneDB 通过在 MySQL 生态内实现这一范式,为用户提供了一个无需复杂 ETL 流程、即可在同一系统中同时支持实时写入和分析查询的解决方案。


关键源码文件索引

模块关键文件说明
Handler 接口storage/tianmu/handler/ha_tianmu.cppMySQL ↔ Tianmu 交互入口
引擎核心storage/tianmu/core/engine.cppEngine 类,管理表/事务/查询
查询编译storage/tianmu/core/query_compile.cppSQL → CompiledQuery 转换
查询执行storage/tianmu/core/engine_execute.cpp查询路由与执行调度
编译查询storage/tianmu/optimizer/compile/compiled_query.cppCompiledQuery 步骤定义
临时表storage/tianmu/core/temp_table.cppTempTable 中间结果存储
列属性storage/tianmu/vc/tianmu_attr.cpp列数据访问与 Knowledge Grid
列共享storage/tianmu/vc/column_share.cpp列物理结构与 DPN 管理
表管理storage/tianmu/core/tianmu_table.cpp表操作与 Delta 存储
Data Packstorage/tianmu/data/pack_int.cpp整型 Pack 压缩/解压
Data Packstorage/tianmu/data/pack_str.cpp字符串 Pack 压缩/解压
MySQL 优化器sql/sql_optimizer.ccSemi-Join 改写入口
我的姐姐
MySQL 源码阅读笔记