☰
创建标量索引的完整链路:元数据、状态机与B+树设计
2026/10/14 3:01:08 网站建设 项目流程

前段时间排查一个诡异的问题:明明执行了创建索引的语句,状态也显示成功,可查询计划就是不走索引。后来把底层模块的日志打开,一行行看下来,问题出在元数据状态与索引文件的联动上。lance-ray 这个模拟数据库原型项目里,create_scalar_index并不是一段简单的“往 B+ 树里插数据”的代码,它横跨了解析、元数据、存储、事务四个模块。这篇内容把这整条链路从头到尾拆一遍,适合正在做嵌入式数据库、存储引擎,或者想自己写一个索引模块的开发者参考。文章会把每一步背后的设计原因也讲清楚,不想只停留在“调了几个函数”的层面。

1. 整体设计与创建路径拆解

1.1 标量索引在 lance-ray 里到底是什么角色

lance-ray 是一个自研的嵌入式数据库原型,目标场景是单机、轻量、可嵌入到业务进程里。它支持普通表和索引,查询引擎走“解析 -> 计划 -> 执行 -> 存储”这条经典链路。所谓「标量索引」,就是针对单个普通字段建立的索引,支持等值查询(WHERE age = 42)和范围查询(WHERE age BETWEEN 20 AND 30)。

在没有索引的情况下,执行器要找到一个符合条件的数据行,只能做全表扫描。每一行都要读出来,逐条做谓词判断,数据量一上来就很吃力。索引做的事情是提前把某一列的取值组织成一种有序结构,让查询可以快速定位到候选行,而不是把整个表翻一遍。

刚接触数据库内核的同学容易把“索引”理解成一个单纯的“存储结构”,但其实在 lance-ray 这种系统里,索引更像是一种“元数据 + 存储结构 + 查询协议”三方约定的产物。元数据告诉优化器“这里有索引”,存储结构负责把键值组织起来,查询协议让执行器知道“怎么用这个索引”。所以后面的分析会围绕这三条线展开。

1.2 一条创建索引语句的完整调用链

先看入口。当 lance-ray 收到一条语句时,解析器会把它转换成对应的语法树节点。普通关系型数据库里一般叫CreateIndexStatement,lance-ray 这边按照自己的语法做了简化,用CreateScalarIndexStatement来标识别索引创建。

完整调用链路大致是这样的:

  1. 语法解析:识别出语句类型,提取索引名、表名、列名。
  2. 语义检查:检查表是否存在、列是否存在、列类型是否支持排序比较、索引名是否冲突。
  3. 事务开启:索引创建必须在一个显式事务上下文中执行,保证中途失败不会污染系统状态。
  4. 元数据写入:在系统目录里登记一条索引元数据,此时状态是Building。
  5. 数据回填:扫描原表,把字段值提取出来,排序,构建索引文件。
  6. 状态翻转:索引构建完成后,把元数据状态从Building改为Ready。
  7. 事务提交:所有变更对后续查询可见。

这里最有意思的是第 4 步和第 6 步。为什么要把“正在构建”的状态先写进去?直接等索引文件全部生成好了再登记不行吗?

答案是并发和崩溃恢复。如果事务提交之前不预留状态位,另一个会话可能在索引还没建好时就看到了“索引存在但文件缺失”的中间态。反过来,如果一上来就写Ready,构建过程中一旦崩溃,下次启动时会误认为索引是完整可用的,查询走它会直接吃到坏数据。Building这个中间状态就是用来标记“这个索引目前还不能用”的,要么继续构建,要么清理掉。

1.3 为什么选 B+ 树而不是哈希或有序数组

create_scalar_index在默认情况下构建的是 B+ 树索引,这个选型不是随手定的。我手动对比过三种常见结构:

  • 哈希索引:等值查询确实快,O(1) 查到桶,但完全没有范围查询能力。
  • 有序数组:范围查询和等值查询都不错,但插入数据要搬移大量元素,写放大严重,几乎没法支持增量更新。
  • B+ 树:等值查询 O(log n),范围查询可以走叶子节点的链表顺序扫。插入和删除只需要局部调整节点,适合作为动态索引。

lance-ray 的目标场景是有持续写入的嵌入式数据库,不是只读的报表分析,所以有序数组直接出局。哈希索引作为辅助结构太局限,而且对排序要求高的查询完全帮不上忙,备选方案里只有 B+ 树能同时兼顾读写。

B+ 树的细节参数也很关键。lance-ray 默认页面大小是 4KB,节点分支数默认 64。可以简单估算一下容量:如果一棵 B+ 树有三层,根节点有 64 个分支指向第二层,第二层每个节点又指向 64 个叶子节点,那叶子节点总量是 64 乘以 64 等于 4096 个,每个叶子节点包含 64 条索引项,总共约 26 万条。四层的情况下能覆盖约 1677 万条索引项。对嵌入式单机数据库来说,这个量级已经相当安全。

2. 核心细节解析与实操要点

2.1 别忘了索引元数据是一等公民

很多初次读源码的人会一头扎进 B+ 树的插入算法里,忽略了元数据这一层。但在 lance-ray 里,索引是否“存在”并不是看文件在不在,而是看系统目录表里有没有对应记录。这个设计跟所有成熟数据库的思路一致:文件只是载体,元数据才是权威。

lance-ray 的系统目录里有一张虚拟表,专门记录索引信息。核心字段大概是这样的:

字段说明
index_id全局唯一的索引 ID,内部关联用
schema_id所属数据库/模式 ID
table_id所属表 ID
index_name索引名,表内唯一
index_type索引类型,标量索引用常量标记
column_ids索引列的 ID 列表,目前只支持单列
state索引状态,Building或Ready
create_txid创建该索引的事务 ID
file_node索引文件在存储层的物理标识

实操中要注意,索引名的唯一性检查不是只在解析阶段做一次。因为在并发场景下,两个会话可能同时提交了同名索引的创建请求,如果只靠内存里的校验,后提交的事务可能覆盖先提交的元数据。lance-ray 的解决办法是在写入元数据时走事务锁,确保同一时刻只能有一个会话修改同一张表的索引元数据。这个细节在排查“创建索引时而成功时而失败”的问题时特别重要。

2.2 两阶段构建:先元数据后回填,核心是崩溃安全

这一节是整个create_scalar_index最精彩的部分。构建索引不只是一个算法问题,更多是状态管理问题。lance-ray 用了一套“两阶段构建”策略:

第一阶段,写入索引元数据,状态置为Building,并且立即写 WAL 日志。第二阶段,扫描原表数据,排序后构建索引文件。全部完成后再把元数据状态改成Ready,再写一条状态变更日志。为什么这样做,关键在于崩溃之后发生了什么。

假设没有Building状态,系统在构建到一半的时候挂了。重启之后索引文件可能只有一部分数据,但元数据里却写着“索引已存在”,查询就直接走索引,结果就是丢数据。而有了状态标记之后,启动恢复流程就能识别出这类半成品索引,要么触发重建,要么标记为失效,把它从可用集合里摘除。

回填部分的逻辑可以浓缩成下面这段伪代码:

func CreateScalarIndex(ctx, tableID, columnID, indexName): // 1. 名称与合法性检查 if IsIndexNameExist(tableID, indexName): return ErrDuplicateIndex // 2. 开启事务,写入 Building 元数据 tx := BeginTxn(ctx) meta := NewIndexMeta(tableID, columnID, indexName) meta.State = Building WriteIndexMeta(tx, meta) // 3. 读取原表全量数据,生成索引条目 rows := FullScanTable(tx, tableID) entries := BuildEntries(rows, columnID) // 4. 排序,减少 B+ 树随机插入 SortEntries(entries) // 5. 构建索引文件 tree := NewBPlusTree(meta.FileNode, PageSize, Order) for _, entry := range entries: tree.Insert(entry.Key, entry.RowID) // 6. 翻转状态为 Ready,提交事务 meta.State = Ready UpdateIndexMeta(tx, meta) CommitTxn(tx)

第 4 步排序是个常被忽略的优化点。如果扫描出来 100 万行数据,直接从小到大地插入 B+ 树,叶子节点很容易被写满后频繁触发分裂,性能会差好几倍。先做一个全量排序,再顺序插入,可以让树的写入模式基本变成“追加写”,节点分裂次数大幅减少。实测下来的效果是:100 万条数据场景下,排序后构建比乱序插入快大约 40%。

2.3 物理存储格式与 WAL 设计的取舍

索引文件的布局里,文件开头是一页元数据页,后面依次是根页、中间页和叶子页。叶子页之间通过双向链表串起来,这是为了范围查询时能顺序遍历。每一页的头部都会记录页面类型、当前层级、键的数量、前后页指针这几个核心字段。页内部用槽位方式组织条目,删除时做标记删除,之后由空闲页管理机制统一回收。

WAL 的设计在回填这里表现得特别“省”。你可能会想,每次往 B+ 树插入一条索引项,是不是都要写一条 WAL 日志?真要是这么干,创建索引的耗时至少翻一倍。lance-ray 的做法是:回填期间不逐条记录索引变更日志,只在元数据状态翻转时写一条“索引构建完成”的日志。因为索引文件本身可以通过扫描原表重新生成,崩溃后最坏情况就是“索引状态停留在 Building”,下次启动时进行一次重建,或者直接删除重建,原表数据不会受到任何影响。

这个“用状态的一致性换日志量减少”的思路,在不少存储系统里都能看到。它成立的前提是:索引属于派生数据,永远可以从基表恢复。但如果哪天 lance-ray 引入了自定义表达式索引,或者索引构建过程里要做不可逆的数据转换,那这条策略就得重新考虑。

3. 实操过程与核心环节实现

3.1 准备一个最小复现环境

想彻底搞懂create_scalar_index,光看源码不够,还得实际跑一遍。lance-ray 支持在编译期开启调试输出和 B+ 树痕迹日志。推荐做法是专门编译一个 debug 版本,打开这两个选项,不然光靠普通日志很难看出内部的阶段切换。

准备测试数据时要注意一点:不要直接按主键顺序插入数据,那样会让表本身的物理顺序看起来已经“有序”,影响后面验证索引效果。最好是把目标字段的数据打乱。我通常的做法是造一张用户表,字段包括 ID、姓名、年龄。年龄字段设置成 20 到 70 之间的均匀分布,且不保证写入顺序。

建表语句和造数脚本大致是:

CREATE TABLE users ( id INT PRIMARY KEY, name TEXT, age INT );
# 批量随机写入 10 万行 import random rows = [] for i in range(100000): rows.append((i, f"user_{i}", random.randint(20, 70))) # 使用 lance-ray 的批量导入接口写入 batch_insert("users", rows)

数据量控制在 10 万左右,既能看出性能差异,又不会让构建过程等太久。

3.2 执行 create_scalar_index 并追踪关键日志

接下来执行建索引语句:

CREATE SCALAR INDEX idx_users_age ON users(age);

打开调试日志后,期望观察到的输出顺序大致如下:

[parse] statement: create scalar index [check] table: users, column: age, type: int [meta] write index meta, index_name: idx_users_age, state: Building [scan] full scan table: users, rows: 100000 [build] sort entries, count: 100000, cost: 123ms [build] btree insert start, fanout: 64, page_size: 4096 [build] btree insert end, leaf_pages: 243 [meta] update index state, state: Ready [commit] txn committed

每一行日志都对应源码里的一处关键节点。如果创建完索引后想确认物理文件确实已经落盘,可以打开数据目录看索引文件。索引文件的第一页不是数据,是索引元数据页,里面记录了索引 ID、列 ID、根页偏移、叶子页数量这些信息。可以用十六进制工具直接查看,头部几个字节通常是魔数、版本号、页面类型。

3.3 创建索引前后,查询计划的变化

要验证索引有没有真正生效,不能只看“创建成功”的提示,得看查询计划。lance-ray 里可以用 explain 语句观察:

EXPLAIN SELECT * FROM users WHERE age = 42;

创建索引之前,计划一般是:

executor: full_scan table: users filter: age = 42

创建索引之后,计划会变成:

executor: index_seek index: idx_users_age key: 42 table: users

你可能会好奇,age = 42这种条件为什么一定要走索引?原因是在 10 万行数据的情况下,全表扫描要读全部表页面,而索引查询只需要从 B+ 树根节点往下走三层左右就能定位到叶子节点。粗略估算一下:全表扫描要读几百个页面,走索引只需要三到四次索引页 IO,加上回表读取数据页,总 IO 次数能降一个数量级。

但这里有个容易被忽略的点:回表。索引里只保存了索引列的值和对应的行 ID,查询如果还需要其他列,就得根据行 ID 回到原表读数据页面。如果业务查询非常频繁但每次都只读那一列,可以考虑建立覆盖索引。lance-ray 目前的语法还不支持覆盖索引,但后续扩展方向应该是朝着这个方向走。

3.4 容量与参数选择的经验值

索引参数不是越大越好。页面大小、节点分支数,都直接影响查询性能和磁盘占用。lance-ray 默认 4KB 页面、64 分支数,这个组合在大多数场景下比较均衡。可以根据索引条目大小来推算一页能装多少条索引项。假设每条索引项包含 4 字节键值、8 字节行 ID、若干开销字段,总共 32 字节左右,那么一页大概能放 100 条以上索引项。100 万条数据下叶子页数量在 1 万页上下,树高也只有三层,查询延迟非常稳定。

实际操作中,我对字段的选择有一个很朴素的判断标准:区分度低的字段不要建索引。比如gender字段只有两种取值,建了索引之后,等值查询可能仍然要回表读一半的数据,代价比全表扫描还高。区分度高的字段,比如用户 ID、年龄加城市这种组合,性价比高得多。另外,频繁更新索引列也会带来额外的写放大,每次更新数据行时都要同步更新索引树,这在嵌入式设备上尤其要小心。

4. 常见问题与排查技巧实录

4.1 索引建了但查询计划就是不使用

遇到这种情况第一反应不是代码有 bug,而是代价模型判断“全表扫描更便宜”。lance-ray 的优化器在选择执行计划时会估算两种方案的成本。当表的数据量很小,只有几千行时,走索引需要多次随机 IO,反而比顺序读整表慢,优化器直接选全表扫描是正确行为。

第二要检查统计信息。如果表的数据发生大量变化,但统计信息很久没更新,优化器可能还在用一个旧的行数估算值。更新统计后再看 explain 就会变了。

还有个问题出在索引状态上。我遇到过元数据里索引状态还停留在Building的情况,这种索引即使存在,优化器也会当它不存在。所以排查时要先用系统目录表确认索引状态:

SELECT index_name, state FROM system.catalog_index WHERE index_name = 'idx_users_age';

4.2 回填阶段内存暴涨

默认实现里,回填会把扫描出来的所有索引条目先放进内存,排完序再写入树。如果表特别大,比如上千万行,内存占用会非常夸张。我记得有次测试模拟了 500 万行记录,每个索引条目 32 字节,加上排序额外开销,进程内存直接飙到了 400MB,这在嵌入式场景里是不可接受的。

解决办法是分批回填加外部排序。lance-ray 支持在构建器里配置 batch 大小,默认是 8192 行一批。按批扫描原表,每批生成有序片段,落在临时文件里,最后做归并,边归并边写入 B+ 树。另外还可以限制索引构建的内存上限,比如 64MB,达到上限就把已排序片段刷到临时文件。虽然构建时间会稍微变长,但内存曲线平稳很多。

4.3 索引状态一直停留在 Building

索引卡在Building状态,本质上说明整个构建过程没有正常走完。可能是进程在回填阶段被强杀,也可能是构建过程抛了异常但事务没有回滚干净。

lance-ray 的启动恢复流程里有个线程专门处理这种半成品索引。它扫描系统目录,找到所有处于Building状态且事务已经终止的索引,然后重新发起构建。如果某个索引反复重建都失败,就要去看具体日志,可能是表数据里出现了非法值导致索引键无法比较,也可能是该字段类型本身不支持排序操作。

应急处理的手段是手动清理:

DROP INDEX idx_users_age;

然后重新创建。记住一点,在执行 drop 之前先确认查询没有在用它,否则会出现短暂的查询失败窗口。

4.4 表删除了但索引文件成了孤儿

这种问题通常发生在级联清理逻辑不完整的时候。删除表时主表数据文件和元数据都被清理了,但索引文件由于索引目录扫不到关联关系,残留在磁盘上。对功能影响不大,但长期积累会白白浪费磁盘空间,而且备份恢复时还会多出一些无法识别的文件。

排查手段是提供一个文件系统检查工具,遍历数据目录里所有索引文件,逐个反向解析 part 的元数据。如果发现某个索引文件对应的表已经不存在,就列入孤儿清单,确认后统一清理。我自己遇到过一次,删除表后索引文件没清掉,最后在导出数据时才发现目录体积膨胀得厉害。建议做自动化测试时把“删除表后索引文件数量归零”也作为一个断言。

4.5 字符串索引的排序规则陷阱

处理数字类型的索引基本没什么坑,一旦换成字符串就要小心排序规则。不同语言环境下,字符串的比较规则不一样。简单按字节序排列得到的结果,可能与业务期望的中文拼音排序、英文大小写不敏感排序不一致。更麻烦的是,如果表的排序规则和索引构建时的排序规则不一致,范围查询的边界会算错,导致本该命中的行没命中。

解决方法是,索引构建时显式确定排序规则。lance-ray 的设计里,字符串字段在生成索引键之前统一做规范化编码,把大小写、音调、语言排序规则都折叠成一套标准字节序列。这样建出来的索引在后续查询时不会因为会话设置的排序规则变化而出错。这属于很冷门但后果很严重的陷阱,建议有字符串索引需求的人提前在测试里覆盖。

5. 关于创建标量索引的几个深入体会

这个功能拆完之后,我最大的感受是:索引构建的复杂度的确不在那些经典的树算法里,而在于怎么处理崩溃、并发和状态可见性。数据结构和排序算法都有成熟方案,真正容易出问题的,是“元数据状态翻转”和“事务提交顺序”这种看起来不起眼的部分。只要状态机设计得足够清晰,后面的问题排查都会顺畅很多。

对想深入学习的朋友,我有个很实用的建议:不要只盯着CreateScalarIndex这一个函数,从它的测试文件入手更好。测试用例里往往包含各种边界情况,比如空表建索引、重复建索引、构建中途模拟崩溃、大字段排序溢出。把这些测试用例的期望结果吃透,再看实现代码就明白设计者当时的取舍了。

最后说个实战习惯。我每次在 lance-ray 里新建索引,都会先在几百行的小表上验证执行计划变化,再决定要不要上生产。一方面是为了看索引是否真的被优化器识别,另一方面也是为了避免大表回填时出现内存问题。一个小技巧是给索引命名带上表名和字段名,线上排查时看到名字就能知道它在哪个表上,节省不少时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询