53.2. 可扩展性 #
传统上,实现一种新的索引访问方法意味着大量艰难的工作。必须理解数据库的内部机制,例如锁管理器和预写式日志。GiST接口具有很高的抽象层次,只要求访问方法实现者实现被访问数据类型的语义。GiST层本身会处理并发、日志记录以及树结构的搜索。
这种可扩展性不应与其他标准搜索树在可处理数据方面的可扩展性相混淆。例如,PostgreSQL支持可扩展的 B-树和 hash 索引。这意味着你可以用PostgreSQL在任意数据类型上构建 B-树或 hash 索引。但 B-树只支持范围谓词(<、=、>),而 hash 索引只支持等值查询。
因此,如果你用PostgreSQL的 B-树为一个图像集合建立索引,你只能发出诸如“imagex 是否等于 imagey”、“imagex 是否小于 imagey”以及“imagex 是否大于 imagey”之类的查询。取决于你如何在这种上下文中定义“等于”、“小于”和“大于”,这可能仍然有用。不过,使用基于GiST的索引,你就可以构造出能够提出特定领域问题的查询方式,例如“找出所有马的图片”或者“找出所有曝光过度的图片”。
要让一个GiST访问方法运行起来,只需实现几个用户定义的方法,这些方法定义了树中键的行为。当然,要支持复杂查询,这些方法本身也必须足够巧妙;但对于所有标准查询(B-树、R 树等),它们都相对直接。简而言之,GiST把可扩展性与通用性、代码复用以及清晰的接口结合了起来。
一个GiST索引操作符类必须提供七个方法,另外还有一个可选方法。通过正确实现same、consistent和union方法可以保证索引的正确性,而索引的效率(大小与速度)则取决于penalty和picksplit方法。另外两个基本方法是compress和decompress,它们允许索引的内部树数据使用与其所索引数据不同的类型。叶子必须是被索引数据类型,而其他树节点可以是任意 C 结构体(但这里仍必须遵守PostgreSQL的数据类型规则,关于变长数据可参见varlena)。如果树的内部数据类型在 SQL 层存在,可以使用CREATE OPERATOR CLASS命令的STORAGE选项。可选的第八个方法是distance,若操作符类希望支持有序扫描(最近邻搜索),则需要它。
consistent给定一个索引项
p和一个查询值q,该函数判断该索引项是否与该查询“一致”;也就是说,该索引项所代表的某一行是否可能使谓词“indexed_columnindexable_operatorq”为真。对于叶子索引项,这等同于测试该可索引条件;而对于内部树节点,这决定是否有必要扫描该树节点所表示的索引子树。当结果为true时,还必须返回一个recheck标志。它表示该谓词是确定为真,还是仅可能为真。如果recheck=false,则该索引已经精确测试了谓词条件;如果recheck=true,则该行只是候选匹配。在这种情况下,系统会自动针对实际行值计算indexable_operator,以判断它是否真的匹配。这种约定使GiST能够同时支持无损和有损的索引结构。该函数的SQL声明必须如下所示:
CREATE OR REPLACE FUNCTION my_consistent(internal, data_type, smallint, oid, internal) RETURNS bool AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
而 C 模块中的对应代码则可以遵循如下框架:
Datum my_consistent(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_consistent); Datum my_consistent(PG_FUNCTION_ARGS) { GISTENTRY *entry = (GISTENTRY *) PG_GETARG_POINTER(0); data_type *query = PG_GETARG_DATA_TYPE_P(1); StrategyNumber strategy = (StrategyNumber) PG_GETARG_UINT16(2); /* Oid subtype = PG_GETARG_OID(3); */ bool *recheck = (bool *) PG_GETARG_POINTER(4); data_type *key = DatumGetDataType(entry->key); bool retval; /* * 根据 strategy、key 和 query 确定返回值。 * * 使用 GIST_LEAF(entry) 判断当前调用位于索引树的哪个位置。 * 例如,支持 = 操作符时这很有用(可以在非叶节点检查 * union() 是否非空,在叶节点检查是否相等)。 */ *recheck = true; /* 如果检查是精确的,则为 false */ PG_RETURN_BOOL(retval); }这里,
key是索引中的一个元素,而query是在该索引中查找的值。StrategyNumber参数指示应用的是操作符类中的哪个操作符,它对应于CREATE OPERATOR CLASS命令中的某个操作符编号。取决于你在该类中包含了哪些操作符,query的数据类型可能会随操作符而变化,但上面的框架假设它不会变化。union该方法用于汇总树中的信息。给定一组项,该函数生成一个新的索引项,用来表示所有给定项。
该函数的SQL声明必须如下所示:
CREATE OR REPLACE FUNCTION my_union(internal, internal) RETURNS internal AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
而 C 模块中的对应代码则可以遵循如下框架:
Datum my_union(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_union); Datum my_union(PG_FUNCTION_ARGS) { GistEntryVector *entryvec = (GistEntryVector *) PG_GETARG_POINTER(0); GISTENTRY *ent = entryvec->vector; data_type *out, *tmp, *old; int numranges, i = 0; numranges = entryvec->n; tmp = DatumGetDataType(ent[0].key); out = tmp; if (numranges == 1) { out = data_type_deep_copy(tmp); PG_RETURN_DATA_TYPE_P(out); } for (i = 1; i < numranges; i++) { old = out; tmp = DatumGetDataType(ent[i].key); out = my_union_implementation(out, tmp); } PG_RETURN_DATA_TYPE_P(out); }如你所见,在这个框架里,我们处理的是一种满足
union(X, Y, Z) = union(union(X, Y), Z)的数据类型。对于不满足这一性质的数据类型,只需在这个GiST支持方法中实现正确的 union 算法即可。union实现函数应返回一个指向新近通过palloc()分配的内存的指针。不能原样返回输入值。compress将一个数据项转换成适合在索引页中物理存储的格式。
该函数的SQL声明必须如下所示:
CREATE OR REPLACE FUNCTION my_compress(internal) RETURNS internal AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
而 C 模块中的对应代码则可以遵循如下框架:
Datum my_compress(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_compress); Datum my_compress(PG_FUNCTION_ARGS) { GISTENTRY *entry = (GISTENTRY *) PG_GETARG_POINTER(0); GISTENTRY *retval; if (entry->leafkey) { /* 将 entry->key 替换为压缩后的形式 */ compressed_data_type *compressed_data = palloc(sizeof(compressed_data_type)); /* 根据 entry->key 填充 *compressed_data ... */ retval = palloc(sizeof(GISTENTRY)); gistentryinit(*retval, PointerGetDatum(compressed_data), entry->rel, entry->page, entry->offset, FALSE); } else { /* 通常无需对非叶项做任何处理 */ retval = entry; } PG_RETURN_POINTER(retval); }当然,为了压缩叶子节点,你必须把
compressed_data_type改成要转换成的具体类型。根据你的需要,你可能还需要注意在其中压缩
NULL值,例如像gist_circle_compress那样存储(Datum) 0。decompresscompress方法的逆操作。将数据项的索引表示转换成数据库能够操作的格式。该SQL声明必须如下所示:
CREATE OR REPLACE FUNCTION my_decompress(internal) RETURNS internal AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
C 模块中相应的代码可以采用以下框架:
Datum my_decompress(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_decompress); Datum my_decompress(PG_FUNCTION_ARGS) { PG_RETURN_POINTER(PG_GETARG_POINTER(0)); }上述框架适用于不需要解压的情况。
penalty返回一个值,指示把新项插入树中特定分支的“代价”。项会沿着树中
penalty最小的路径插入。penalty返回的值应为非负;如果返回负值,它将被按零处理。该函数的SQL声明必须如下所示:
CREATE OR REPLACE FUNCTION my_penalty(internal, internal, internal) RETURNS internal AS 'MODULE_PATHNAME' LANGUAGE C STRICT; -- 某些情况下 penalty 函数不必是严格函数
而 C 模块中的对应代码则可以遵循如下框架:
Datum my_penalty(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_penalty); Datum my_penalty(PG_FUNCTION_ARGS) { GISTENTRY *origentry = (GISTENTRY *) PG_GETARG_POINTER(0); GISTENTRY *newentry = (GISTENTRY *) PG_GETARG_POINTER(1); float *penalty = (float *) PG_GETARG_POINTER(2); data_type *orig = DatumGetDataType(origentry->key); data_type *new = DatumGetDataType(newentry->key); *penalty = my_penalty_implementation(orig, new); PG_RETURN_POINTER(penalty); }penalty函数对于索引的良好性能至关重要。它会在插入时用于决定在树中应沿着哪个分支向下,以便选择把新项加到哪里。在查询时,索引越平衡,查找就越快。picksplit当索引页必须分裂时,该函数决定页面上的哪些项留在旧页中,哪些移到新页中。
该函数的 SQL 声明必须类似如下形式:
CREATE OR REPLACE FUNCTION my_picksplit(internal, internal) RETURNS internal AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
而 C 模块中匹配的代码可以遵循如下框架:
Datum my_picksplit(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_picksplit); Datum my_picksplit(PG_FUNCTION_ARGS) { GistEntryVector *entryvec = (GistEntryVector *) PG_GETARG_POINTER(0); OffsetNumber maxoff = entryvec->n - 1; GISTENTRY *ent = entryvec->vector; GIST_SPLITVEC *v = (GIST_SPLITVEC *) PG_GETARG_POINTER(1); int i, nbytes; OffsetNumber *left, *right; data_type *tmp_union; data_type *unionL; data_type *unionR; GISTENTRY **raw_entryvec; /* 这里的代码将项划分到 left 和 right 两个数组中 */ }和
penalty一样,picksplit函数对于索引的良好性能至关重要。设计合适的penalty和picksplit实现,正是实现高性能GiST索引的难点所在。same如果两个索引项相同则返回真,否则返回假。
该函数的SQL声明必须如下所示:
CREATE OR REPLACE FUNCTION my_same(internal, internal, internal) RETURNS internal AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
而 C 模块中的对应代码则可以遵循如下框架:
Datum my_same(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_same); Datum my_same(PG_FUNCTION_ARGS) { prefix_range *v1 = PG_GETARG_PREFIX_RANGE_P(0); prefix_range *v2 = PG_GETARG_PREFIX_RANGE_P(1); bool *result = (bool *) PG_GETARG_POINTER(2); *result = my_eq(v1, v2); PG_RETURN_POINTER(result); }出于历史原因,
same函数并不是直接返回一个布尔结果;相反,它必须把该标志存储到第三个参数指示的位置。distance给定一个索引项
p和一个查询值q,该函数确定索引项与查询值之间的“距离”。如果操作符类包含任何排序操作符,就必须提供此函数。使用排序操作符的查询会优先返回“距离”值最小的索引项,因此结果必须与该操作符的语义一致。对于叶子索引项,结果仅表示到该索引项的距离;对于内部树节点,结果必须是其任意子项可能具有的最小距离。该函数的SQL声明必须如下所示:
CREATE OR REPLACE FUNCTION my_distance(internal, data_type, smallint, oid) RETURNS float8 AS 'MODULE_PATHNAME' LANGUAGE C STRICT;
而 C 模块中的对应代码则可以遵循如下框架:
Datum my_distance(PG_FUNCTION_ARGS); PG_FUNCTION_INFO_V1(my_distance); Datum my_distance(PG_FUNCTION_ARGS) { GISTENTRY *entry = (GISTENTRY *) PG_GETARG_POINTER(0); data_type *query = PG_GETARG_DATA_TYPE_P(1); StrategyNumber strategy = (StrategyNumber) PG_GETARG_UINT16(2); /* Oid subtype = PG_GETARG_OID(3); */ data_type *key = DatumGetDataType(entry->key); double retval; /* * 根据 strategy、key 和 query 确定返回值。 */ PG_RETURN_FLOAT8(retval); }distance函数的参数与consistent函数的参数相同,只是不使用 recheck 标志。到叶子索引项的距离必须始终精确确定,因为元组一旦返回就无法再重新排序。在确定到内部树节点的距离时允许有一定近似,只要结果永不大于任一子节点的实际距离即可。因此,例如在几何应用中,到包围盒的距离通常就足够了。结果值可以是任意有限的float8值。(无穷大和负无穷在内部用于处理空值等情况,因此不建议distance函数返回这些值。)
所有 GiST 支持方法通常都在短生命周期的内存上下文中被调用;也就是说,每处理完一个元组,CurrentMemoryContext都会被重置。因此通常无需过分担心释放所有通过 palloc 分配的内容。不过,在某些情况下,让支持方法在重复调用之间缓存数据是有用的。要做到这一点,可将寿命更长的数据分配在fcinfo->flinfo->fn_mcxt中,并在fcinfo->flinfo->fn_extra中保存指向它的指针。这类数据会在一次索引操作期间存活(例如一次 GiST 索引扫描、索引构建或索引元组插入)。在替换fn_extra值时要注意 pfree 旧值,否则泄漏会在整个操作期间不断累积。