假设相关列的数据类型为text
。
CREATE TABLE prefix (code text, name text, price int);
CREATE TABLE num (number text, time int);
“简单”解决方案
SELECT DISTINCT ON (n.number)
n.number, p.code
FROM num n
JOIN prefix p ON right(n.number, -1) LIKE (p.code || '%')
ORDER BY n.number, p.code DESC;
关键要素:
DISTINCT ON
是SQL标准DISTINCT
的Postgres扩展。参见:
ORDER BY p.code DESC
选择最长匹配,因为'1234'
在升序排序中位于'123'
之后。
db<>fiddle 这里
旧版sqlfiddle
没有索引,这个查询运行时间非常长(没等到它完成就放弃了)。我们需要索引支持。三元组索引(由附加模块pg_trgm
提供)是一个很好的选择。您必须在GIN和GiST索引之间进行选择。数字的第一个字符只是噪音,可以从索引中排除,使其成为一个功能性索引。
在我的测试中,一个功能性的三元组GIN索引胜过了一个三元组GiST索引(正如预期的那样):
CREATE INDEX num_trgm_gin_idx ON num USING gin (right(number, -1) gin_trgm_ops);
高级dbfiddle 这里。
所有的测试结果都来自一个本地的Postgres 9.1测试安装,其中设置较少:17k个数字和2k个代码:
- 总运行时间:1719.552毫秒(trigram GiST)
- 总运行时间:912.329毫秒(trigram GIN)
更快速
使用text_pattern_ops
失败的尝试
忽略首个干扰字符后,问题可以简化为基本的左锚定模式匹配。因此我尝试了使用操作符类 text_pattern_ops
(假设列类型为text
)的函数B-tree索引。
CREATE INDEX num_text_pattern_idx ON num(right(number, -1) text_pattern_ops);
这对于单个搜索词的直接查询非常有效,与三元索引相比,使其看起来不太好。
SELECT * FROM num WHERE right(number, -1) LIKE '2345%'
- 总运行时间:3.816毫秒(trgm_gin_idx)
- 总运行时间:0.147毫秒(text_pattern_idx)
然而,查询规划器在连接两个表时不会考虑这个索引(在LATERAL
连接存在之前)。
部分/功能B树索引
另一种方法是使用部分字符串的等值检查和部分索引。这可以在JOIN
中使用。
由于我们通常只有有限数量的不同长度
的前缀,我们可以构建一个类似于这里介绍的部分索引的解决方案:
假设我们的前缀长度从1到5个字符不等。为每个不同的前缀长度创建一组部分功能索引:
CREATE INDEX prefix_code_idx5 ON prefix(code) WHERE length(code) = 5;
CREATE INDEX prefix_code_idx4 ON prefix(code) WHERE length(code) = 4;
CREATE INDEX prefix_code_idx3 ON prefix(code) WHERE length(code) = 3;
CREATE INDEX prefix_code_idx2 ON prefix(code) WHERE length(code) = 2;
CREATE INDEX prefix_code_idx1 ON prefix(code) WHERE length(code) = 1;
由于这些是部分索引,它们所有在一起的大小几乎只比一个完整的索引大一点。
为数字添加匹配的索引(考虑前导噪音字符):
CREATE INDEX num_number_idx5 ON num(substring(number, 2, 5)) WHERE length(number) >= 6;
CREATE INDEX num_number_idx4 ON num(substring(number, 2, 4)) WHERE length(number) >= 5;
CREATE INDEX num_number_idx3 ON num(substring(number, 2, 3)) WHERE length(number) >= 4;
CREATE INDEX num_number_idx2 ON num(substring(number, 2, 2)) WHERE length(number) >= 3;
CREATE INDEX num_number_idx1 ON num(substring(number, 2, 1)) WHERE length(number) >= 2;
尽管这些索引只包含一个子字符串且是部分的,但每个索引覆盖了大部分或全部表格。因此它们在一起比单个总索引要大得多 - 除了长数字外。它们对写操作施加了更多的工作量。这就是惊人速度的代价。
如果这个代价对您来说太高(写入性能重要/太多的写操作/磁盘空间有问题),您可以跳过这些索引。其余部分仍然很快,尽管不像可能的那样快...
如果数字从不少于n
个字符的长度开始,可以从某些或全部中删除冗余的WHERE
子句,并且同时也删除后续查询中相应的WHERE
子句。
递归CTE
到目前为止,通过所有的设置,我希望得到一个非常优雅的解决方案,使用递归CTE:
WITH RECURSIVE cte AS (
SELECT n.number, p.code, 4 AS len
FROM num n
LEFT JOIN prefix p
ON substring(number, 2, 5) = p.code
AND length(n.number) >= 6 -- incl. noise character
AND length(p.code) = 5
UNION ALL
SELECT c.number, p.code, len - 1
FROM cte c
LEFT JOIN prefix p
ON substring(number, 2, c.len) = p.code
AND length(c.number) >= c.len+1 -- incl. noise character
AND length(p.code) = c.len
WHERE c.len > 0
AND c.code IS NULL
)
SELECT number, code
FROM cte
WHERE code IS NOT NULL;
虽然这个查询并不差 - 和带有三元组GIN索引的简单版本一样好 - 但它没有达到我想要的效果。递归项只计划执行一次,所以它不能使用最佳索引。只有非递归项可以。
UNION ALL
由于我们处理的是少量递归,我们可以迭代地将它们列出来。这样可以为每个递归项优化计划。(尽管我们失去了对已成功数字的递归排除,但对于更广泛的前缀长度仍然有改进的空间):
SELECT DISTINCT ON (number)
number, code
FROM (
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 5) = p.code
AND length(n.number) >= 6 -- incl. noise character
AND length(p.code) = 5
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 4) = p.code
AND length(n.number) >= 5
AND length(p.code) = 4
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 3) = p.code
AND length(n.number) >= 4
AND length(p.code) = 3
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 2) = p.code
AND length(n.number) >= 3
AND length(p.code) = 2
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 1) = p.code
AND length(n.number) >= 2
AND length(p.code) = 1
) x
ORDER BY number, code DESC;
终于有了突破!
SQL 函数
将其封装成一个 SQL 函数可以消除重复使用时的查询计划开销:
CREATE OR REPLACE FUNCTION f_longest_prefix()
RETURNS TABLE (number text, code text)
LANGUAGE sql AS
$func$
SELECT DISTINCT ON (number)
number, code
FROM (
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 5) = p.code
AND length(n.number) >= 6 -- incl. noise character
AND length(p.code) = 5
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 4) = p.code
AND length(n.number) >= 5
AND length(p.code) = 4
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 3) = p.code
AND length(n.number) >= 4
AND length(p.code) = 3
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 2) = p.code
AND length(n.number) >= 3
AND length(p.code) = 2
UNION ALL
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(number, 2, 1) = p.code
AND length(n.number) >= 2
AND length(p.code) = 1
) x
ORDER BY number, code DESC
$func$;
电话:
SELECT * FROM f_longest_prefix_sql();
带有动态 SQL 的 PL/pgSQL 函数
这个 PL/pgSQL 函数与上面的递归 CTE 非常相似,但是使用 EXECUTE
的动态 SQL 强制查询在每次迭代时重新计划。现在它利用了所有定制的索引。
此外,它适用于任何范围的前缀长度。该函数接受两个参数作为范围,但我已经准备好了带有DEFAULT
值的版本,所以它也可以在没有显式参数的情况下工作:
CREATE OR REPLACE FUNCTION f_longest_prefix2(_min int = 1, _max int = 5)
RETURNS TABLE (number text, code text)
LANGUAGE plpgsql AS
$func$
BEGIN
FOR i IN REVERSE _max .. _min LOOP -- longer matches first
RETURN QUERY EXECUTE '
SELECT n.number, p.code
FROM num n
JOIN prefix p
ON substring(n.number, 2, $1) = p.code
AND length(n.number) >= $1+1 -- incl. noise character
AND length(p.code) = $1'
USING i;
END LOOP;
END
$func$;
最后一步不能轻易地包装成一个函数。
要么就像这样调用它:
SELECT DISTINCT ON (number)
number, code
FROM f_longest_prefix_prefix2() x
ORDER BY number, code DESC;
或者使用另一个 SQL 函数作为包装器:
CREATE OR REPLACE FUNCTION f_longest_prefix3(_min int = 1, _max int = 5)
RETURNS TABLE (number text, code text)
LANGUAGE sql AS
$func$
SELECT DISTINCT ON (number)
number, code
FROM f_longest_prefix_prefix2($1, $2) x
ORDER BY number, code DESC
$func$;
电话:
SELECT * FROM f_longest_prefix3();
由于所需的规划开销,稍微慢一些。但比 SQL 更灵活,对于较长的前缀更短。