☰
ParadeDB 基数统计(Cardinality)聚合基准查询解析:从 COUNT(DISTINCT) 到 pdb.agg 的源码级原理
2026/9/25 17:19:38 网站建设 项目流程
  • 数据库
  • 搜索引擎
  • 全文检索
  • 向量数据库
  • 后端

【免费下载链接】paradedb

One Postgres for your application data, full-text search, vector retrieval, and aggregations. Home of the pg_search extension.

项目地址:https://gitcode.com/gh_mirrors/pa/paradedb
点击查看免费下载

在 ParadeDB 的 Stack Overflow 基准测试套件中,cardinality查询目录是专门验证"统计去重后基数"这一最昂贵聚合形态性能的战场。本文以 benchmarks/datasets/stackoverflow/queries/cardinality/README.md 为核心骨架,结合目录下 8 个 SQL 基准脚本、pdb.agg()的公开 API 实现与聚合执行器源码,说明为什么原生 PostgreSQL 在 GIN 索引上做基数聚合不可行、ParadeDB 如何通过列式扫描与 Tantivy 聚合收集器加速这类查询,以及 MVCC 可见性开关(true/false)在字符串字段基数统计上的"惰性可见性检查"快速路径。

读完本文,你将掌握:cardinality基准目录中每个查询脚本的测量意图与参数设置、pdb.agg('{"cardinality": ...}', ...)的完整调用语法与可见性语义、paradedb.enable_aggregate_custom_scan等 GUC 的作用,以及 ParadeDB 在什么条件下能把 MVCC 检查内嵌进基数聚合本身。

为什么基数聚合值得单独开一个基准目录

COUNT(DISTINCT ...)(去重计数)是所有聚合中最"伤"的形态之一:它不仅要遍历全部命中文档,还必须为每个文档的取值建立去重集合才能给出精确结果。在全文检索 + 聚合的混合负载下,这个成本会被放大到不可接受。

cardinality目录的 README 开宗明义地记录了基准设计中的两个关键取舍:

  1. 原生 PostgreSQL 聚合(基于 GIN 索引)被直接排除:对大数据集而言,在 GIN 全文索引之上跑原生聚合"慢到无法工作"(unworkably slow)。原因是标准 PostgreSQL 聚合需要扫描完整 posting list 并回表取堆元组,耗时以数十秒到分钟计。
  2. 冗余的 base scan 变体被裁剪以控制基准运行时长:basescan_group_by.sql和basescan_high_cardinality_group_by.sql被移除,只保留basescan_distinct.sql作为COUNT(DISTINCT ...)的代表性基线。这样基准能在有限时间内跑完,同时仍能对照"关闭自定义聚合扫描后的列式 base scan"性能。

关于第一点,benchmarks/datasets/stackoverflow/README.md 给出了更完整的表述:标准 PostgreSQL 聚合在 GIN 全文索引上需要扫描整条 posting list 与堆元组,而 ParadeDB 即使只用列式 base scan(paradedb.enable_aggregate_custom_scan = off)也比 PostgreSQL 快;因此聚合类查询不与 PostgreSQL 对比,而是专注于测量 ParadeDB 自身的两条执行路径——列式 base scan 与 DataFusion/自定义聚合下推。

基准查询矩阵:8 个脚本各测什么

cardinality目录下的 8 个 SQL 脚本形成了一组精心设计的对照实验。它们统一基于stackoverflow_posts表(结构见 benchmarks/datasets/stackoverflow/create_tables.sql,涉及body TEXT、tags VARCHAR、post_type_id SMALLINT、score INTEGER等字段),统一使用全文谓词body ||| 'javascript'圈定命中文档集。

脚本注释中的测量意图关键参数
aggregate_scan_count_col.sqlpdb.agg(底层)无GROUP BY的计数work_mem='4GB',enable_aggregate_custom_scan=on
aggregate_scan_group_by.sqlaggregate custom scan 下的GROUP BY计数同上,COUNT(*) FROM (SELECT ... GROUP BY post_type_id)
aggregate_scan_high_cardinality_group_by.sql高基数GROUP BY多指标聚合GROUP BY tags LIMIT 65000,聚合COUNT/MIN/MAX/SUM(score)
basescan_distinct.sql列式 base scan 的COUNT(DISTINCT ...)基线enable_aggregate_custom_scan=off(数值 fast field)
pdb_agg_high_cardinality_group_by_no_mvcc.sql高基数GROUP BY+pdb.agg,关闭 MVCCpdb.agg(..., false),GROUP BY tags LIMIT 65000
pdb_agg_value_count_no_mvcc.sql无GROUP BY的pdb.aggvalue_count,关闭 MVCCpdb.agg('{"value_count": ...}', false)
tantivy_cardinality_mvcc.sql字符串字段 cardinality,开启 MVCC → 惰性可见性检查pdb.agg('{"cardinality": {"field": "tags"}}', true)
tantivy_cardinality_no_mvcc.sql字符串字段 cardinality,关闭 MVCC 的基线pdb.agg('{"cardinality": {"field": "tags"}}', false)

可以看到目录内同时覆盖了两个正交维度:

  • 执行路径维度:aggregate_scan_*.sql走自定义聚合扫描(enable_aggregate_custom_scan = on);basescan_distinct.sql关闭下推,走列式 base scan;pdb_agg_*.sql与tantivy_cardinality_*.sql也是off,但通过显式pdb.agg()调用驱动聚合。
  • MVCC 维度:同一查询分别用true/false(新版语义对应'transaction'/'raw')测量开启与关闭事务可见性检查的开销,并借此验证字符串字段 cardinality 的快速路径。

三条自定义聚合扫描基准

-- aggregate_scan_count_col.sql:pdb.agg(底层)在无 GROUP BY 下的计数 SET work_mem TO '4GB'; SET paradedb.enable_aggregate_custom_scan TO on; SELECT COUNT(post_type_id) FROM stackoverflow_posts WHERE body ||| 'javascript';
-- aggregate_scan_group_by.sql:aggregate custom scan 下按 post_type_id 分组计数 SET work_mem TO '4GB'; SET paradedb.enable_aggregate_custom_scan TO on; SELECT COUNT(*) FROM (SELECT post_type_id FROM stackoverflow_posts WHERE body ||| 'javascript' GROUP BY post_type_id);
-- aggregate_scan_high_cardinality_group_by.sql:高基数标签分组 + 多指标 SET paradedb.enable_aggregate_custom_scan TO on; SET work_mem = '4GB'; SELECT tags, COUNT(tags), MIN(score), MAX(score), SUM(score) FROM stackoverflow_posts WHERE body ||| 'javascript' GROUP BY tags LIMIT 65000;

这三条脚本共同覆盖了"无分组计数 → 低基数分组 → 高基数分组 + 多指标"三种典型负载形态。tags是 Stack Overflow 帖子上的高基数字符串字段(每个帖子通常挂多个标签),GROUP BY tags LIMIT 65000强制聚合器在高基数桶下工作,用于压测分布式聚合收集器的桶管理与内存上限。

COUNT(DISTINCT) 的列式基线

-- basescan_distinct.sql:数值 fast field 上的 COUNT(DISTINCT),关闭自定义聚合扫描 SET work_mem TO '4GB'; SET paradedb.enable_aggregate_custom_scan TO off; SELECT COUNT(DISTINCT post_type_id) FROM stackoverflow_posts WHERE body ||| 'javascript';

该脚本是目录中唯一保留的 base scan 变体。post_type_id是 SMALLINT 数值字段,走列式 fast field;enable_aggregate_custom_scan = off迫使执行器退回 base scan 路径,从而为COUNT(DISTINCT ...)提供一个不与任何自定义路径共享实现细节的对照基线。

pdb.agg():聚合下推的公开入口

pdb_agg_*.sql与tantivy_cardinality_*.sql展示了另一条 API 形态——显式调用pdb.agg()向 Tantivy 聚合收集器提交 JSON 规格:

-- pdb_agg_value_count_no_mvcc.sql:value_count 指标,MVCC 关闭 SET work_mem TO '4GB'; SET paradedb.enable_aggregate_custom_scan TO off; SELECT pdb.agg('{"value_count": {"field": "post_type_id"}}', false) FROM stackoverflow_posts WHERE body ||| 'javascript';
-- pdb_agg_high_cardinality_group_by_no_mvcc.sql:高基数分组 + 多指标,MVCC 关闭 SET paradedb.enable_aggregate_custom_scan TO off; SET work_mem = '4GB'; SELECT tags, pdb.agg('{"value_count": {"field": "tags"}}', false) as count, pdb.agg('{"min": {"field": "score"}}', false) as min, pdb.agg('{"max": {"field": "score"}}', false) as max, pdb.agg('{"sum": {"field": "score"}}', false) as sum FROM stackoverflow_posts WHERE body ||| 'javascript' GROUP BY tags LIMIT 65000;

在 pg_search/src/api/aggregate.rs 的模块注释中可以看到pdb.agg()的完整语义:

  • 函数形态:pdb.agg(jsonb)、pdb.agg(jsonb, text)和pdb.agg(jsonb, bool)三个重载。JSONB 参数是 Tantivy 聚合请求规格,例如{"cardinality": {"field": "tags"}}。
  • 可见性参数(text 重载):第二个参数是visibility模式——
    • 'transaction'(默认):检查事务可见性,与 PostgreSQL 语义一致;
    • 'raw':跳过检查,直接聚合原始索引数据,更快但近似;
    • 'threshold':仅当预估匹配行数低于paradedb.visibility_threshold时才检查。
  • bool 重载(已弃用):这是旧的solve_mvcc拼写,true等价'transaction',false等价'raw',保留是为了兼容存量查询。基准脚本中的true/false正是这一历史形态。
  • GROUP BY 场景:窗口函数形态(pdb.agg(...) OVER ())是主推用法,会在规划期被替换为内部占位函数window_agg();与GROUP BY组合时,非窗口形态可能直接报错或走独立 API 路径执行。基准中的pdb_agg_high_cardinality_group_by_no_mvcc.sql通过多次pdb.agg(...)调用与 SQL 原生GROUP BY tags结合来实现多指标高基数聚合。

cardinality 指标:HyperLogLog++ 近似基数

目录名cardinality对应的正是pdb.agg的cardinality指标。docs/reference/aggregates/metrics/cardinality.mdx 对该指标的语义做了权威说明:

  • 它估计(estimate)字段中去重值的数量,返回形如{"value": 5.0}的结果;
  • 与 SQL 的DISTINCT给出精确值但计算代价极高不同,cardinality聚合使用HyperLogLog++ 算法以极低的内存开销给出高精度近似;
  • 支持对字符串字段与数值字段使用(tantivy_cardinality_*.sql针对字符串tags字段)。

这也是基准选择"基数"作为压测对象的原因:它是"近似但极快"的聚合语义代表,与"精确但昂贵"的COUNT(DISTINCT ...)(basescan_distinct.sql)形成互补对照。

MVCC 快速路径:字符串字段基数聚合的惰性可见性检查

tantivy_cardinality_mvcc.sql的注释点出了本目录最值得玩味的实现细节:

-- tantivy_cardinality_mvcc.sql:字符串字段 cardinality,MVCC 开启 → 惰性可见性检查 SET work_mem TO '4GB'; SET paradedb.enable_aggregate_custom_scan TO off; SELECT pdb.agg('{"cardinality": {"field": "tags"}}', true) FROM stackoverflow_posts WHERE body ||| 'javascript';

"惰性可见性检查"(lazy vischeck)的实现在 pg_search/src/aggregate/exec.rs 的模块注释中有直接描述:

When every aggregation in a request is a cardinality over a string field, MVCC is solved inside the aggregation: tantivy consults the visibility filter only for docs whose value is not yet confirmed visible, instead of vischecking every matched doc up front.

即:当一个聚合请求中全部聚合都是字符串字段上的cardinality时,执行器走use_cardinality_fast_path(exec.rs):

  1. 判定条件为solve_mvcc为真、请求非空,且每个聚合都是AggregationVariants::Cardinality且目标字段在索引 schema 中是文本字段(field.is_text());
  2. 命中快速路径后,不再为每个命中文档预先做可见性检查(否则包装器会返回Some(VisibilityChecker),见 exec.rs),而是构造一个DocVisibilityFilterFactory——只有在聚合器内部遇到"尚未确认可见"的取值时,才按文档的 CTID(通过FFType::new_ctid读取)查询堆做一次检查(exec.rs)。

这意味着开启 MVCC 的字符串字段基数查询,其开销近似于"HLL++ 计数 + 按需检查",比逐文档回表验证廉价得多。tantivy_cardinality_no_mvcc.sql则提供false基线,用于量化该快速路径相比完全跳过可见性检查的额外成本。

关键 GUC 与运行环境

基准脚本中反复出现的三个设置项:

设置取值作用
SET work_mem TO '4GB'会话级为聚合/排序操作分配内存,保证大结果集不被过早 spill 到磁盘而污染耗时测量
SET paradedb.enable_aggregate_custom_scan TO on/off布尔,用户级(Userset)开关 ParadeDB 的自定义聚合扫描。on时以列式/DataFusion 方式替代行式聚合;off时退回 base scan,用作基线
pdb.agg(..., true/false)的可见性参数'transaction'/'raw'/'threshold'或旧 bool 拼写控制是否执行 MVCC 可见性检查,threshold模式配合paradedb.visibility_threshold使用

paradedb.enable_aggregate_custom_scan的官方定义见 pg_search/src/gucs.rs:一个用户级布尔 GUC,"用基于列的自定义聚合扫描在有利时替代基于行的聚合"。docs/reference/aggregates/limitations.mdx同时提醒:聚合下推要求查询中存在 ParadeDB 谓词(如body ||| 'javascript'或id @@@ pdb.all()),否则查询不会走下推路径。

关于数据集环境:benchmarks/datasets/stackoverflow/config.toml 记录了根表stackoverflow_posts(主键id)、采样种子(sampling_seed = 723)及badges/comments/users三个关联表,数据由s3_base_path = "s3://paradedb-benchmarks/datasets/stackoverflow"提供;基准运行流程可参考 benchmarks/README.md 与 scripts/pg_search_run.sh。

从基准到生产:可迁移的结论

综合 README 说明、8 个脚本与源码实现,可以沉淀出三条可迁移到生产环境的结论:

  1. 不要在高基数大表上对 GIN 索引做原生精确去重聚合:README 明确记载其在大数据集上慢到不可用。基数类聚合应优先交给 ParadeDB 的自定义聚合扫描(列式 + Tantivy 收集器)。
  2. COUNT(DISTINCT ...)用 base scan 兜底,cardinality指标用pdb.agg加速:前者保精确,后者用 HyperLogLog++ 换数量级的速度收益;对字符串字段,开启 MVCC 时执行器会自动走惰性可见性检查快速路径,不必担心可见性语义丢失。
  3. enable_aggregate_custom_scan与可见性参数是调优旋钮而非开关:关闭自定义扫描可获得纯 base scan 基线(对照basescan_distinct.sql);false/'raw'模式适用于"允许近似结果"的场景,'threshold'模式则适合在一致性要求高但与速度有折中需求的生产负载中精细控制。

围绕本目录展开的更多聚合形态(terms、sum、min/max、跨 join 下推、回退条件等)可继续阅读 docs/reference/aggregates/overview.mdx 与 docs/reference/aggregates/limitations.mdx,以及聚合执行器的完整实现 pg_search/src/aggregate/exec.rs。

  • 数据库
  • 搜索引擎
  • 全文检索
  • 向量数据库
  • 后端

【免费下载链接】paradedb

One Postgres for your application data, full-text search, vector retrieval, and aggregations. Home of the pg_search extension.

项目地址:https://gitcode.com/gh_mirrors/pa/paradedb
点击查看免费下载

相关推荐

上一篇:从源码编译 Bazel:基于 Bazel 的自举构建与零依赖 Bootstrap 完整实战指南
下一篇:Gas Town 工作分派指南:用 Kilo 创建任务与车队(Convoy)实现多智能体协作

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询