PostgreSQL码农集散地

PostgreSQL Top K简单查询性能噩梦的救命稻草:block wand剪枝

本期播客

PostgreSQL Top K简单查询性能噩梦的救命稻草:block wand剪枝

本文深入剖析了PostgreSQL在处理Top K查询时的性能瓶颈。虽然基于B-Tree的索引在简单场景下表现优异,但一旦加入多个过滤条件或全文搜索,查询性能急剧下降,甚至需要数十秒。根本原因在于Postgres的索引结构无法同时高效处理过滤、排序和文本匹配。相比之下,以ParadeDB为代表的搜索数据库采用单一复合索引(结合倒排索引和列式存储),通过文档ID映射、列式随机访问和Block WAND剪枝等优化,使任意组合的Top K查询都能保持亚秒级响应。文章通过实测数据对比,揭示了两种架构在设计哲学上的根本差异。

参考: https://www.paradedb.com/blog/optimizing-top-k

索引不是万能的,组合查询才是真正的考验

作为一个DBA,你肯定遇到过这样的场景:用户抱怨查询慢,你一看,不过是个简单的TOP 10查询,加了索引应该秒出。可现实往往是,这个查询在最简单的情况下确实很快,但一旦加上几个过滤条件,性能就直线跳水,从5ms直接飙到15秒以上。

今天,我们就来深入剖析PostgreSQL中Top K查询的性能真相,以及为什么像ParadeDB这样的搜索数据库采用了完全不同的方法。

PostgreSQL 2026 年度大戏来了, 扫海报中的二维码报名, 选择早鸟或通票(都含午餐和周边礼品), 可私信我要优惠码, 数量有限先到先得!

图片

什么是Top K查询?

在数据库中,Top K意味着“给我按某列或某值排序的最佳K行”。通常这是指“最近的行”、“最高分”或“最大值”。

听起来像是一个Postgres应该轻松解决的问题,毕竟我们可以创建索引,不是吗?然而在许多生产环境的Postgres部署中,Top K查询的难度被严重低估了。

Postgres的方案:排序的B-Tree

让我们从一个包含1亿行的单表开始:

CREATETABLE benchmark_logs (  
idSERIAL PRIMARY KEY,  
    message TEXT,  
    country VARCHAR(255),  
    severity INTEGER,  
timestampTIMESTAMP,  
    metadata JSONB  
);  

我们想返回按时间戳排序的最新10行:

SELECT *  
FROM benchmark_logs  
ORDERBYtimestampDESC
LIMIT10;  

没有索引时,这个查询需要15秒。为了加速,我们可以在时间戳上创建B-Tree索引:

CREATEINDEXON benchmark_logs (timestamp);  

B-Tree对于Top K查询是理想的选择,因为它们是排序的结构,检索Top K结果的时间复杂度是O(K)。创建索引后,我们的查询降至惊人的5ms。

但是,当我们加入过滤条件后...

上面的例子有一个隐藏的限制:它仅当索引完全匹配查询模式时才有效。一旦你添加了索引中没有的过滤条件,情况就复杂了。

考虑一个更现实的查询,加上过滤条件 WHERE severity < 3:

SELECT *  
FROM benchmark_logs  
WHERE severity < 3
ORDERBYtimestampDESC
LIMIT10;  

现在Postgres面临一个两难选择。它可以使用时间戳上的B-Tree索引按正确顺序获取行,但无法直接跳转到符合 severity < 3 的行。它必须逐个条目遍历索引,检查每行的严重性,丢弃大多数行。最坏情况下,这意味着遍历整个索引。

或者,它可以先扫描符合 severity < 3 的行,但这样会失去排序,之后必须对结果进行排序。

无论哪种方式,Postgres最终扫描的行数远超K行,需要额外工作来过滤或排序,突然之间我们回到了最坏情况下查询需要15秒的窘境。

排序与过滤的组合爆炸

自然的反应是添加一个包含严重性和时间戳的复合B-Tree索引:

CREATEINDEXON benchmark_logs (severity, timestamp);  

复合B-Tree仍然是排序树,但现在条目首先按严重性排序,然后在每个严重性值内按时间戳排序。这对于这个查询模式效果很好:Postgres可以直接跳转到树中匹配 severity < 3 的部分,然后按降序遍历时间戳获取Top K行。

问题是这个方案不具备通用性。例如,想象我们现在还想按国家过滤:

SELECT *  
FROM benchmark_logs  
WHERE country = 'United States'
AND severity < 3
ORDERBYtimestampDESC
LIMIT10;  

或者,如果我们改变排序列:

SELECT *  
FROM benchmark_logs  
WHERE country = 'United States'
ORDERBY severity ASC, timestampDESC
LIMIT10;  

支持所有实际的过滤器和排序顺序组合需要越来越多的索引。这些索引导致存储膨胀、写入变慢,以及难以推理的查询计划。

当全文搜索加入时,Postgres的Top K彻底崩溃

到目前为止,我们假设过滤器是可以在B-Tree内表达为等式或范围条件的简单谓词。全文搜索打破了这一假设。

考虑一个查询,它结合了Postgres的原生文本搜索(执行标记匹配而不是完整字符串匹配)和范围过滤器,然后按相关性(即类似TF-IDF的分数)排序结果:

SELECT *,  
       ts_rank(to_tsvector('english', message), q) ASrank
FROM benchmark_logs,  
     plainto_tsquery('english', 'research team') AS q  
WHERE to_tsvector('english', message) @@ q  
AND severity < 3
ORDERBYrankDESC
LIMIT10;  

这个查询可能看起来与之前的例子相似:过滤行,然后按分数返回前10名。但在内部,Postgres没有单一结构能同时满足所有这些约束。

Postgres可以对tsvector文本搜索谓词使用GIN索引,对排序或数字过滤器使用B-Tree。但由于GIN不保留顺序,Postgres无法跨不同索引组合顺序保证,规划器必须将查询分解为多个阶段:

  1. 使用GIN索引产生一个(可能很大的)匹配行ID集合
  2. 从“堆”(即底层表存储)中获取这些行
  3. 应用额外的过滤器如 severity < 3
  4. 对幸存的行进行排序
  5. 返回前10个结果

如果GIN产生的结果集很大,第2步中重复的堆获取变得非常昂贵。

让我们看看如果尽可能优化这个查询,它能表现得多好。首先,我们预计算 to_tsvector 表达式,这避免了一些查询时的开销,并帮助Postgres规划器收集更好的统计信息:

ALTERTABLE benchmark_logs  
ADDCOLUMN message_fts tsvector  
GENERATEDALWAYSAS (to_tsvector('english', coalesce(message,''))) STORED;  

接下来,我们在生成的列上创建GIN索引:

CREATEINDEXON benchmark_logs USING gin (message_fts);  

最后,我们用生成的列替换查询中的 to_tsvector 表达式:

SELECT *,  
       ts_rank(message_fts, q) ASrank
FROM benchmark_logs,  
     plainto_tsquery('english', 'research team') AS q  
WHERE message_fts @@ q  
AND severity < 3
ORDERBYrankDESC
LIMIT10;  

有了GIN索引,这个查询仍然需要37秒才能执行,主要是因为查询“research team”返回了数百万个匹配项,必须检查过滤条件并排序。

作为额外的优化,我们可以在谓词 severity < 3 上创建一个部分GIN索引:

CREATEINDEXON benchmark_logs USING gin (message_fts) WHERE severity < 3;  

不幸的是,查询仍然需要大致相同的时间(33秒),因为即使有了 severity < 3 谓词,仍然返回了大量的候选集。实现进一步改进需要修改查询本身,使其更具选择性——这在现实场景中可能可行,也可能不可行。

搜索数据库对Top K的不同思考

这些例子突出了Postgres中Top K的两个问题:

  1. B-Tree强制你预先承诺查询模式,这与即席Top K查询的理念相悖
  2. B-Tree与文本搜索查询/GIN索引配合不佳,特别是当Top K候选集很大时

像ParadeDB这样的搜索数据库采取了根本不同的方法。我们不是需要许多定制的索引,而是使用一个包含Top K过滤和排序所需所有字段的单一复合索引。与B-Tree不同,这个索引不一定是排序的。这意味着目标不是击败针对特定查询优化的B-Tree,而是让所有形状的Top K查询,包括那些包含文本搜索和评分的查询,都能合理快速地执行,且在不同形状间方差低。

此外,由于排序键不是预先知道的,我们必须接受大型候选集是不可避免的。优化目标变为使扫描和过滤的实际工作极其便宜,并在选择Top K时积极剪枝以避免额外工作。

基础数据结构:倒排索引和列式数组

像大多数搜索引擎一样,ParadeDB的索引建立在两个核心结构上。第一个是倒排索引,它将每个词条(例如“research”)映射到一个“发布列表”——包含该词条的文档ID的排序列表。

第二个是列式布局,它将单个字段存储在连续、紧凑的数组中。列式数组以其加速分析查询的能力而闻名,但它们也是Top K查询的自然选择,因为它们允许廉价、缓存友好的查找。

复合索引消除了昂贵的行查找

在Postgres中,每个索引行包含一个指向该行在表中存储位置的指针。如果Top K过滤器不能完全由索引回答,Postgres必须跟随该指针并从底层表存储中物化整行。当需要对数百万候选行执行此操作时,这个操作成本高昂且容易发生缓存未命中。

ParadeDB通过将所有可搜索、可过滤和可排序的字段存储在一个索引内解决了这个问题。每个索引行被分配一个紧凑的内部u32标识符,称为文档ID,索引中的每个数据结构都引用相同的ID。

这种文档ID设计对布尔查询(即多个WHERE子句)非常高效。例如,考虑布尔 WHERE country = 'United States' AND severity < 3。文本条件从倒排索引中产生一个文档ID流,而范围过滤器成为在同一ID处对列式数组的直接查找。评估AND条件简化为交互相交的u32流,无需物化任何中间行。

列式数组使过滤成本低廉

由于文本搜索索引产生的候选行可能不按连续顺序排列,列式数组必须具有真正的O(1)随机访问能力。在Tantivy的列式格式中,这是通过将列值的行ID设置为其在数组中的位置来实现的。因此,从列中访问值以评估过滤器只需:

value = column[row_id]  

此外,列带有最小值和最大值元数据注释。这允许像 severity < 3 这样的范围过滤器跳过整个不能满足过滤器的列。对于确实与范围重叠的列,值是批处理而不是逐个处理的。通过将比较应用于值向量而不是标量,引擎可以在单个CPU操作中使用SIMD指令评估多个值。

Block WAND实现早期剪枝

对于按相关性分数排序的查询,Tantivy通过一种称为Block WAND的优化更进一步。从概念上讲,这意味着跳过评估整个文档块,除非它们在数学上有进入Top K的可能性。

Block WAND的工作原理是维护一个块内任何文档可能达到的最高分数上限。当引擎填充其Top K堆时,它会建立一个阈值:堆中当前的最低分数。在评估一个块之前,引擎检查该块的最大分数。如果该最大值低于阈值,则跳过整个块,而不对单个文档评分。

性能对比:ParadeDB vs Postgres GIN

在ParadeDB中,与上面按相关性排序的查询类似的查询如下:

SELECT *, pdb.score(id) FROM benchmark_logs  
WHERE severity < 3AND message ||| 'research team'
ORDERBY pdb.score(id) DESC
LIMIT10;  

这个查询现在降至非常合理的300ms,而Postgres GIN需要33秒!

结论

Postgres的Top K方法有点像“全有或全无” —— 快乐路径几乎是瞬间的,但最坏情况可能需要秒甚至分钟。而ParadeDB的设计目标是只要所有过滤器和排序键都存在于索引中,就让任何Top K查询都保持合理快速。

未来,我们仍然看到通过在执行流水线中更早地剪枝工作来优化Top K性能的重要空间。一个方向是索引分区和段级排序,其中数据按经常查询的维度(如时间范围或粗略分数桶)进行物理分组或排序。通过这种布局,可以跳过整个段,如果其可能的最大分数或排序值无法超过当前Top K阈值。

我们也正在优化Top K连接——即“在多个表上搜索和过滤,连接结果,并返回Top K”。如果你对这种工作感兴趣,我们正在招聘喜欢解决这类数据库内核问题的工程师。

最后,不要犹豫,给我们的开源项目打个星标吧!

https://github.com/paradedb/paradedb

思考:你的生产环境中是否遇到过Top K查询性能问题?你是如何解决的?欢迎在评论区分享你的经验!