扩展程序可以注册自己的索引结构,并让服务器调用它。服务器拥有 SQL 语法、元数据和事务。扩展程序拥有索引的磁盘格式以及在其中的搜索过程。
有两个预览功能承载此能力。vsql::preview::index_type 注册索引结构本身。vsql::preview::index_profile 将该结构绑定到某个自定义类型,并指定索引进行计算所用的函数。两者都位于开发 ABI 头文件中的 <villagesql/preview/index_builder.h>,因此使用它们的扩展程序需要使用 -DVSQL_USE_DEV_ABI=ON 构建。
目前支持:创建、修改和删除自定义索引的 DDL;INSERT 时的索引维护;以及一种查询形态,即由索引提供服务的最近邻排序。不支持:DELETE、修改被索引列的 UPDATE,以及最近邻搜索之外的所有访问模式。十二个必需钩子中的四个——mark_delete、purge、save 和 restore——必须存在,构建器才能编译,但服务器从不调用它们。除非 vsql_allow_preview_extensions 为 ON,否则指定自定义索引的语句会被拒绝;安装预览扩展程序本来就需要同一设置。
三个组成部分
索引类型是指结构:条目如何存储、插入和扫描。每个扩展程序的每种结构注册一次,使用类似 hnsw 的名称。
索引函数是绑定到某个配置文件的确定性函数,例如距离函数。配置文件可绑定两类函数。使用 with_function() 绑定的函数是优化器可以为其规划索引扫描的函数。使用 with_helper() 绑定的函数仅由索引实现调用。
将函数绑定到配置文件并不会创建 SQL 函数。优化器按名称将 ORDER BY 中的调用与配置文件的绑定进行匹配,因此使用 with_function() 绑定的函数还必须用 .func() 以同一名称注册,否则任何语句都无法指定它,索引也永远不会被选中。辅助函数不需要 .func(),从 SQL 调用辅助函数会得到 ERROR 1305 (42000): FUNCTION <database>.<name> does not exist。
索引配置文件将自定义类型与索引类型关联起来,并为每个绑定的函数指定一个数字 id,索引通过该 id 调用它。一个索引类型可服务于多个配置文件:vsql_vector 注册了单个 hnsw 索引类型,并在其上注册了四个配置文件——hnsw_l1、hnsw_l2、hnsw_cosine 和 hnsw_inner_product——每种距离度量对应一个。
注册索引类型
make_index_type<Name, Context>() 接受索引名称和一个按索引划分的上下文结构体,该结构体保存扩展程序所需的任何运行时状态。全部十二个钩子都是必需的,分为三组,并按此顺序排列。如果缺少某个钩子,构建器将无法编译——失败的断言会指出该钩子的名称。
生命周期钩子创建索引存储、在打开表时加载索引,以及删除索引:
DML 钩子在行发生变化时维护索引。服务器会调用 insert。mark_delete 和 purge 必须以这些签名存在,构建器才能编译;请为它们提供桩实现:
扫描钩子负责响应查询。服务器会调用 begin、position、fetch 和 end,并且只向 position 传递 Index::CursorOp::Next。save 和 restore 必须存在且是桩实现,与上面的两个 DML 钩子一样:
每个可能失败的钩子都以消息缓冲区及其长度作为最后两个参数,并在将消息写入其中后返回 true 表示失败。
游标归扩展程序所有。请在 begin 中分配游标并将其赋给 *cursor,并让它已定位到第一条记录——服务器会先调用 fetch,再调用 position。在 end 中释放游标并将 *cursor 设置为 nullptr。C++ SDK 不封装游标状态。
begin、position 和 restore 的 bool * 参数是由钩子写入的扫描结束标志。成功时,即使 begin 设置了该标志,服务器也会调用 end;失败时,begin 必须让 *cursor 保持为空,并且不会调用 end。在服务器目前驱动的路径上,begin 收到的 MtrCtx::Ref 为空。
上下文不归扩展程序所有。C++ SDK 会在调用 create 或 load 之前,从 InnoDB 内存池中分配 Index::StorageCtx<T> 和您的 T,并在 drop 返回后销毁该内存池——这会运行 ~T。请通过 ctx->user() 访问您自己的状态,通过 ctx->arena() 访问该内存池。切勿删除上下文、用户对象或内存池。
声明索引的能力
.capabilities() 声明索引支持哪些访问模式,.storage_props() 声明每个索引条目如何引用它所索引的数据。两者都是必需的。
构建器接受五个能力值,而服务器只对其中一个采取行动:Index::Support::KNN,即用于 ORDER BY <distance>(<column>, <reference>) LIMIT K 的 k 最近邻扫描。POINT_LOOKUP、RANGE_SCAN、REVERSE_SCAN 和 ORDER_BY 可以编译和注册,但优化器不会构建其中任何一种路径。无论索引声明了什么,被索引列上的 = 条件仍然可能在自定义索引上规划出 Index lookup(例如在 FORCE INDEX 下),而该查询会以 ERROR 1031 (HY000): Table storage engine for '<table>' doesn't have this option 失败。
为最近邻扫描提供服务的存储声明是:在扩展程序自行存储的列上的单列索引中声明 Index::Storage::HAS_COLUMN_REF | Index::Storage::HAS_ROW_REF。HAS_COLUMN_REF 表示每个条目存储服务器提供的稳定列引用,而不是完整的主键,正是它使 get_key_data() 和 get_key_ref() 可用。REF_LOOKUP 表示索引可以根据该引用重新定位某个条目,并且它带来一项义务:每个条目都必须具有唯一的引用令牌。
Index::Storage::HAS_ROW_REF 表示每个条目存储它所索引的行的主键:服务器在 pkeys 数组中将其传递给 insert(),fetch() 必须在 pkeys 中返回它,服务器通过主键查找来解析每个命中结果。未声明它的索引永远不会被选中用于最近邻扫描。请将它与 HAS_COLUMN_REF 一起声明。
读取和写入列引用
get_key_data() 将 KeyPartRef——DML 钩子和扫描钩子交给索引的那种引用——解析为键列的实际值,其大小由 get_max_col_len(key_pos) 决定。get_key_ref() 则反其道而行:它为已经位于列存储中的键列值(例如 insert() 收到的 keys 之一)派生引用。get_key_data() 在自己的迷你事务下读取,在扩展程序持有页面闩锁期间不得调用它。两者都接受 key_pos,即该值所属的从零开始计数的键列,并在失败时返回 true,消息位于 index.get_error() 中:
除非索引声明了 HAS_COLUMN_REF,否则服务器会将两者都保留为空函数指针,而 C++ SDK 在调用它们时不做防护,因此在未声明的情况下调用任一函数会导致崩溃,而不是返回错误。
索引选项
DDL 中的 WITH (M = 8, ef_construction = 64) 会作为索引选项传递给扩展程序。服务器会先将每个键和值转为小写,因此即使 DDL 写的是 M,params[i].key 读取到的也是 m。请声明一个带默认值和 parse 函数的结构体,并使用 .options<T, &T::parse>() 注册它:
请与转为小写后的键进行比较:
当 DDL 不带 WITH 子句时,params 为空,因此应先检查 count 的值。返回失败的 parse 会中止 DDL,而它写入的消息会进入服务器错误日志——客户端只会看到 ERROR 1030 (HY000): Got error 168 - 'Unknown (generic) error from engine' from storage engine,因此请在消息中指明索引和出问题的键。
index.options<HNSWOptions>() 返回指向已解析结构体的指针。每个接收 Index 的钩子都可以访问它——生命周期钩子、DML 钩子和 begin。其余的扫描钩子只接收游标,因此在扫描过程中需要使用这些选项的索引类型必须在 begin 中将它们复制到游标中。
索引函数
索引函数必须是确定性的,并且必须返回 REAL。请构建一个索引函数并将其保存在 static const 中——配置文件存储的是对它的引用,因此其地址必须比注册过程存活得更久:
一个函数最多可声明八个参数,即 VEF_INDEX_PROFILE_FN_MAX_ARGS 的值。服务器会在 INSTALL EXTENSION 时检查返回类型和参数个数,而不是在首次使用时检查。
索引配置文件
配置文件指定自定义类型、索引类型,以及带有各自 id 的函数。for_type() 接受类型的名称字符串——即传给 make_type 的同一个常量——而不是类型描述符。with_function() 和 with_helper() 接受已构建的索引函数,而不是名称:
id 在每个列表内必须唯一,且这两个列表独立编号——with_function(1, ...) 和 with_helper(1, ...) 不会冲突。同一个列表内重复的 id 会在 INSTALL EXTENSION 时被拒绝。
.default_for_type(true) 将此配置文件标记为语句未指定配置文件时所使用的配置文件。它按类型和索引类型的组合生效,默认值为 false。vsql_vector 标记了 hnsw_l2,这就是为什么创建索引时未指定配置文件会记录为 hnsw_l2。
.ordering() 记录配置文件支持的扫描方向,取值为 NONE、ASC、DESC 或 ASC | DESC。服务器会存储该值,但没有任何代码读取它。无论配置文件声明什么,都只会规划升序排序:仅声明 DESC 的配置文件仍然为升序查询提供服务,而降序的 ORDER BY 会以表扫描方式运行。
钩子通过 index.profile() 调用绑定的函数,或通过 index.helper() 调用绑定的辅助函数。两者都接受键列位置、配置文件赋予该函数的 id、指向结果的指针,以及以 vef_storage_col_data_t 参数形式给出的列值。
index.helper_fn_name() 报告绑定的辅助函数的注册名称,而不是调用它,这使得内层循环可以一次性解析出自己的原生实现,而不必每次调用都进行分派:
完整示例
一个骨架,省略了钩子主体、选项结构体和上下文:
两个功能对象都必须是 static——C++ SDK 会持有指向它们的指针——并且每个对象都必须精确地传递给 .with() 一次。一个功能对象可以承载多个结构:为每个结构链式调用 .index_type() 或 .index_profile()。
SQL 语法
自定义索引通过 USING EXTENDED 选择,并且列可以指定要使用的配置文件:
被索引列必须是某个已安装扩展程序注册的自定义类型,并且当 DDL 指定了配置文件时,该配置文件必须是为该列的类型声明的。普通列会被拒绝,错误为 ERROR 3219 (HY000): No default index profile found: column '<name>' is not a custom type。分区表会被拒绝,错误为 ERROR 3219 (HY000): InnoDB: Custom index is not supported on partitioned tables。
该子句可以内联用于 CREATE TABLE,也可以用于 CREATE INDEX 和 ALTER TABLE ADD INDEX。配置文件和索引类型都可以用扩展程序名称限定,您可以通过这种方式区分提供相同名称的两个扩展程序。如果未指定配置文件,服务器将使用针对该类型和索引类型声明了 default_for_type(true) 的配置文件。
本节中的每条语句都需要将 vsql_allow_preview_extensions 设置为 ON,可在启动时设置或使用 SET PERSIST 设置。没有它,语句会被拒绝:
以 vsql_vector 为例,请先从 vsql-vector 仓库构建它,将 vsql_vector.veb 复制到 veb_dir 中,打开该设置,然后安装该扩展程序:
删除自定义索引与普通索引相同:使用 DROP INDEX、ALTER TABLE ... DROP INDEX,或者 DROP TABLE(会随表一起移除索引元数据)。自定义索引及其所保存的行在服务器重启后仍然存在。
通过自定义索引读取和写入
行通过 INSERT 写入,索引也随之得到维护。在已经包含行的表上创建索引同样可行,并且会基于这些行构建索引。该构建会阻塞对表的写入,而不是作为在线 DDL 运行;读取则继续进行。使用 ALGORITHM=INPLACE、ALGORITHM=COPY 或不带子句时,自定义索引都走离线构建路径,因为目前还没有任何机制能把并发写入重放到自定义索引中。ALGORITHM=INSTANT 和 LOCK=NONE 会被拒绝。
自定义索引只为一种查询形态提供服务:以 ORDER BY <distance>(<column>, <reference>) LIMIT K 作为唯一的排序项、升序,且 <reference> 为常量。索引必须是单列索引,其类型必须声明 Index::Support::KNN 和 Index::Storage::HAS_ROW_REF,并且其绑定的配置文件必须提供该距离函数。列和参考值可以按任一顺序给出。不满足其中任何一个条件的查询会以表扫描加排序的方式运行,且没有警告。EXPLAIN 会指明访问方式:
服务器不会按读取事务的视图过滤索引自身的命中结果。它将每个命中解析为对应的行,跳过该视图看不到的行,然后取下一个候选——因此 begin 应当返回其完整的搜索池,而不是恰好 limit 个条目,否则并发写入会使查询返回的行少于 K 行。
修改被索引列的 UPDATE 会被拒绝:
修改主键的 UPDATE 会得到同样的错误;命中已有行的 REPLACE,以及为被索引列或主键赋值的 INSERT ... ON DUPLICATE KEY UPDATE 也是如此。只为未被索引的列赋值的 UPDATE 或 INSERT ... ON DUPLICATE KEY UPDATE 会成功。
对带有自定义索引的表修改主键的 ALTER TABLE 会被拒绝,无论 ALGORITHM 为何:
添加列,或修改主键以外的列,会使表现有的自定义索引保持原样。使用 ALGORITHM=COPY 或 ALGORITHM=INPLACE 强制重建表同样会保留该索引,并且重建后的索引在重启后仍然存在。
服务器报告的内容
SHOW CREATE TABLE 会呈现自定义索引绑定的配置文件、索引类型以及任何 WITH 参数:
配置文件和索引类型始终是以反引号引起的 extension.name 标识符,而绝不是字符串。WITH 参数的键和值都会转为小写,并按键的字母顺序排序——与上文“索引选项”中描述的规范化相同——因此写作 M 的键会呈现为 m。配置文件始终会显示,即使 DDL 从未指定过配置文件而是由服务器将其解析为该类型的默认值:持久化的元数据只记录解析后的名称。该呈现可以往返还原,因此重放捕获到的 SHOW CREATE TABLE 字符串会重现一个呈现完全相同的索引。
INFORMATION_SCHEMA.STATISTICS.INDEX_TYPE 和 SHOW INDEX 报告传递给 USING EXTENDED(...) 的索引类型名称(转为大写),以取代存储引擎自身的算法名称。该值不带扩展程序限定符,因此提供相同索引类型名称的两个扩展程序在此列中无法区分。同一个表上的普通 B 树索引仍然报告 BTREE:
在 DROP INDEX idx_a ON t2 之后,它对应的行也会从此查询中消失。
服务器将每个自定义索引记录在 villagesql.custom_indexes 中,并将其键列——以及绑定到每个键列的配置文件——记录在 villagesql.custom_index_columns 中。两者在重启后均保持有效。
即使以 root 身份,也无法从 SQL 读取这两个表:
请将它们视为服务器内部内容。SHOW CREATE TABLE 和 INFORMATION_SCHEMA.STATISTICS 是从客户端检查自定义索引的受支持方式。