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 引擎存在一些关键的能力缺口——子查询处理就是其中最典型的例子。
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;
通过 EXPLAIN 和 OPT_TRACE 分析可以发现,Tianmu 对此类 EXISTS/IN 子查询默认走 Nested Loop 执行路径——对外表的每一行,都要完整扫描内表来判断是否存在匹配。这在列存引擎中代价极高:列存的优势在于批量顺序扫描,而 Nested Loop 的逐行驱动模式完全破坏了这一优势。
问题的根源在于:原版 Tianmu 缺乏子查询展开(Subquery Flattening)与半连接(Semi-Join)执行路径。MySQL 5.7 优化器虽然内置了 Semi-Join 改写能力,但 Tianmu 引擎在查询编译阶段并未对接这套机制,导致 EXISTS 子查询始终以独立子查询的方式被执行。
解决这个问题的第一步是在 MySQL 优化器层面打通 Semi-Join 的改写通道。
SELECT_LEX::resolve_subquery() 中的 Semi-Join 候选判定:
MySQL 5.7 的子查询解析流程中,resolve_subquery() 是决定子查询命运的关键函数。在该函数中,需要增强对 Semi-Join 候选条件的判定逻辑:当子查询满足以下条件时,将其标记为 Semi-Join 候选——
WHERE 子句中,且由 EXISTS 或 IN 引导SELECT(非 UNION),不包含 GROUP BY、HAVING、聚合函数、LIMIT 等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_nest 的 sj_cond 上。同时,子查询内部的非关联条件(如 l_commitdate < l_receiptdate)会合并到 ON 条件中。这样,优化器在后续的 JOIN 优化阶段可以统一处理所有的连接条件。
(3)维护 sj_outer_exprs / sj_inner_exprs 表达式结构
为了让优化器和执行器能够正确地识别 Semi-Join 中的外表表达式与内表表达式的对应关系,转换过程还需要维护 sj_outer_exprs 和 sj_inner_exprs 两个列表。以 l_orderkey = o_orderkey 为例,o_orderkey 进入 sj_outer_exprs,l_orderkey 进入 sj_inner_exprs。这一对应关系是后续选择具体 Semi-Join 执行策略的基础。
完成以上转换后,优化器与执行器可以复用同一套 JOIN 图表示来处理 Semi-Join,不再需要为子查询维护独立的执行上下文。
打通改写链路只是第一步。更关键的是要在 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。
列式存储的天然优势在于读取——分析查询只需扫描相关列,压缩率高,I/O 效率好。然而,写入是列存引擎的”阿喀琉斯之踵”。一次 INSERT 操作在列存中意味着:
这个过程的写放大非常显著:一行数据的写入被放大为 N 列的独立写操作(N 为列数),每列还涉及编码和压缩的计算开销。对于 UPDATE 和 DELETE,情况更加复杂——列存中没有原地更新的概念,通常需要标记删除 + 重写。
对于 HTAP 场景,实时写入性能是不可回避的需求。Delta Store 行存层的设计正是为了解决这一矛盾。
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
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)。
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 任务的执行分为四个阶段:
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 的统一分配
在 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 操作时:
这一设计的关键优势在于:前台写入只需要写入 RocksDB(行格式),不需要执行列存的编码/压缩操作,写入延迟大幅降低。
后台事务
后台 Merge 任务使用 StoneDB 自有的事务封装。每个 Merge 任务作为一个独立事务执行:
后台 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.cpp | MySQL ↔ Tianmu 交互入口 |
| 引擎核心 | storage/tianmu/core/engine.cpp | Engine 类,管理表/事务/查询 |
| 查询编译 | storage/tianmu/core/query_compile.cpp | SQL → CompiledQuery 转换 |
| 查询执行 | storage/tianmu/core/engine_execute.cpp | 查询路由与执行调度 |
| 编译查询 | storage/tianmu/optimizer/compile/compiled_query.cpp | CompiledQuery 步骤定义 |
| 临时表 | storage/tianmu/core/temp_table.cpp | TempTable 中间结果存储 |
| 列属性 | storage/tianmu/vc/tianmu_attr.cpp | 列数据访问与 Knowledge Grid |
| 列共享 | storage/tianmu/vc/column_share.cpp | 列物理结构与 DPN 管理 |
| 表管理 | storage/tianmu/core/tianmu_table.cpp | 表操作与 Delta 存储 |
| Data Pack | storage/tianmu/data/pack_int.cpp | 整型 Pack 压缩/解压 |
| Data Pack | storage/tianmu/data/pack_str.cpp | 字符串 Pack 压缩/解压 |
| MySQL 优化器 | sql/sql_optimizer.cc | Semi-Join 改写入口 |