Featured image of post DuckDB 表采样深度解析:USING SAMPLE 与 TABLESAMPLE(以及采样为什么会更慢)

DuckDB 表采样深度解析:USING SAMPLE 与 TABLESAMPLE(以及采样为什么会更慢)

实测 DuckDB 的 USING SAMPLE 与 TABLESAMPLE:SYSTEM / BERNOULLI / RESERVOIR 三种方法的差异与隐藏默认值、五个语法陷阱,以及采样何时真正快于全表扫描的基准测试。

引言:“直接采样就行了"只对了一半

“直接采样就行了”——这是面对大表时最常见的建议。听起来很划算:只读 10% 的数据,就该得到快 10 倍的结果。但在 DuckDB 里,这个直觉只对了一半,而错的那一半正是最容易踩坑的地方。

DuckDB 支持两种采样语法:原生的 USING SAMPLE 和 SQL 标准的 TABLESAMPLE;背后则有三种算法:SYSTEM、BERNOULLI、RESERVOIR。它们的行为差别很大,默认值不同,而且其中一种无论你要百分之几,都会悄悄扫完整张表。

下面所有数字都来自 DuckDB 1.5.4,那些反直觉的结论(包括一次采样让查询慢 16 倍)都可以复现。


两种语法,一个容易混淆的重叠

DuckDB 同时接受这两种写法:

-- DuckDB 原生
SELECT * FROM events USING SAMPLE 10%;

-- SQL 标准
SELECT * FROM events TABLESAMPLE 10 PERCENT;

看起来可以互换,其实不能。USING SAMPLE 既接受百分比,也接受行数;而 TABLESAMPLE 把裸数字当百分比,把括号里的数字当成完全不同的东西 —— 这就是第一个陷阱的来源。

先建一张表:

CREATE TABLE events AS
SELECT i AS id,
       (i % 7) AS category,
       random() * 100 AS value
FROM range(100000) t(i);
SELECT count(*) FROM events;
┌──────────────┐
│ count_star() │
│    int64     │
├──────────────┤
│       100000 │
└──────────────┘

三种采样方法,与它们的隐藏默认值

DuckDB 采样架构:SYSTEM 下推进扫描,BERNOULLI 在扫描之上加算子

两种语法背后是三种算法。选哪一种,同时决定了结果和代价:

方法机制粒度是否读全表
RESERVOIR边扫描边维护蓄水池,输出精确大小的均匀样本行是
BERNOULLI每行独立抛硬币行是
SYSTEM随机挑选整块 / row group块否(可下推)

两种写法的默认方法并不相同,这一点很容易被忽略:

-- 百分比形式:默认是 SYSTEM
EXPLAIN SELECT count(*) FROM events USING SAMPLE 10% (system);

-- 行数形式:默认是 RESERVOIR
SELECT count(*) FROM events USING SAMPLE 5000 ROWS;

在 10 万行数据上实测,三种方法对同一个"10%“给出的样本量明显不同:

USING SAMPLE 10% (system)     ->  8192 行
USING SAMPLE 10% (bernoulli)  -> 10017 行
USING SAMPLE 10% (reservoir)  -> 10000 行

RESERVOIR 精确;BERNOULLI 统计上准确但有噪声;SYSTEM 按块量化,可能偏离标称比例几个百分点 —— 这不是 bug,而是"能跳过 I/O"必须付出的代价。


五个语法陷阱与真实报错

陷阱一:USING SAMPLE 必须是 FROM 里最后一项

不能直接接 WHERE 或 GROUP BY:

SELECT category, count(*)
FROM events USING SAMPLE 20%
GROUP BY category;
Parser Error: syntax error at or near "GROUP"

把采样包进子查询即可:

SELECT category, count(*)
FROM (SELECT * FROM events USING SAMPLE 20% (bernoulli)) s
GROUP BY category
ORDER BY category;

陷阱二:TABLESAMPLE SYSTEM (n) 非法

括号形式要求的是百分比,小整数会被当成行数并报错:

SELECT count(*) FROM events TABLESAMPLE SYSTEM (5);
Error: Sample method System cannot be used with a discrete sample count,
either switch to reservoir sampling or use a percentage.

应写成 TABLESAMPLE SYSTEM (5%) 或裸写的 TABLESAMPLE 5 PERCENT。BERNOULLI 同理。

陷阱三:TABLESAMPLE RESERVOIR (5) 是 5 行

SELECT count(*) FROM events TABLESAMPLE RESERVOIR (5);
┌──────────────┐
│       5      │
└──────────────┘

是 5 行,不是 5%。RESERVOIR 是唯一在括号形式里接受离散行数的算法。

陷阱四:SYSTEM 可能返回零行

因为 SYSTEM 按整块采样,如果整张表只有一个 row group,就没有块可挑。你要 5%,结果可能一行都没有:

CREATE TABLE small AS SELECT i FROM range(1000) t(i);
SELECT count(*) FROM small USING SAMPLE 5% (system);
┌──────────────┐
│       0      │
└──────────────┘

需要小表也能拿到样本,请用 RESERVOIR。

陷阱五:SYSTEM 与 TABLESAMPLE SYSTEM 底层不同

USING SAMPLE … (system) 会把采样下推进扫描;而 TABLESAMPLE SYSTEM (x%) 可能表现为一个独立算子(见下一节),这会直接影响代价。


EXPLAIN 揭示了什么:下推 vs 事后过滤

这部分解释了后面所有基准结果。对比两个执行计划:

EXPLAIN SELECT count(*) FROM events USING SAMPLE 10% (system);
┌───────────────────────────┐
│    UNGROUPED_AGGREGATE    │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│          SEQ_SCAN         │
│       Sample Method:      │
│       System: 10.0%       │   <-- 采样发生在扫描算子内部
└───────────────────────────┘
EXPLAIN SELECT count(*) FROM events USING SAMPLE 10% (bernoulli);
┌───────────────────────────┐
│    UNGROUPED_AGGREGATE    │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│      STREAMING_SAMPLE     │   <-- 扫描之上多了一个算子
│   Bernoulli: 10.000000%   │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│          SEQ_SCAN         │   <-- 仍然读取每一行
└───────────────────────────┘

两个世界:

  • SYSTEM 把采样下推进扫描算子,扫描知道可以跳过整块,因此能省 I/O。
  • BERNOULLI(和 RESERVOIR)在扫描之上加一个算子,扫描仍然碰每一行,算子再把大部分行丢掉。你付了全部 I/O 和物化成本,最后扔掉绝大部分。

这一个事实解释了为什么 BERNOULLI 常常比"不采样"还慢。


让样本可复现

随机采样每次结果都不同。要固定下来 —— 这是可复现实验的前提 —— 传入种子:

SELECT count(*) FROM events USING SAMPLE 5% (reservoir, 7);
┌──────────────┐
│    5000      │
└──────────────┘

同一个种子跑两次得到完全相同的 5000 行;换种子则不同。这是让"采样结果"变成可引用产物的唯一办法。


基准测试:采样什么时候真的更快

以下均为 best-of-3,DuckDB 1.5.4,4 线程,本地 SSD,Parquet + ZSTD。

廉价谓词(500 万行)

数据:5,000,000 行,约 45 MB Parquet。查询:count(*) WHERE value > 900。

计划耗时相对全表扫描
全表扫描62.9 ms1.00×
USING SAMPLE 10% (system)55.4 ms1.14× 更快
USING SAMPLE 10% (bernoulli)160.9 ms0.39×(慢 2.6 倍)
USING SAMPLE 500000 ROWS (reservoir)1008.9 ms0.06×(慢 16 倍)

采样几乎没帮上忙,而且三种方法里有两种明显更慢。RESERVOIR 不仅要扫全表,还要维护一个 50 万行的蓄水池。

昂贵的逐行谓词

同样数据,但过滤条件换成逐行正则(对 email 列做 regexp_matches):

计划耗时相对全表扫描
全表扫描1086.0 ms1.00×
USING SAMPLE 10% (system)624.5 ms1.7× 更快
USING SAMPLE 1% (system)390.3 ms2.8× 更快
USING SAMPLE 10% (bernoulli)1170.7 ms0.93×(更慢)

结论反转了。当每行处理很贵时,在昂贵操作之前丢掉行就有收益 —— 而功劳属于 SYSTEM,因为它在扫描阶段剪枝,而不是事后过滤。

由此得到一条经验法则: 采样带来的收益,与查询其余部分的昂贵程度成正比。如果查询只是对本地热数据做一个廉价聚合,全表扫描本来就够快,采样只会增加开销。


样本有多准?

只有当估计值可信时,采样才有用。对约 499,441 / 5,000,000 行(约 10%)的谓词,把样本放大后的误差如下:

方法样本比例估计总数误差
SYSTEM1%565,40013.21%
SYSTEM5%511,4602.41%
SYSTEM10%479,7503.94%
SYSTEM25%511,5482.42%
BERNOULLI1%516,8003.48%
BERNOULLI10%499,2800.03%

两点:

  1. 1% 太粗,不足以引用;SYSTEM 的误差达到 13%。
  2. 采样误差并不单调。这里 SYSTEM 10% 反而不如 5% —— 按块量化意味着"比例更大"不保证更接近真值。需要给出数字时,BERNOULLI 是更稳定的估计器。

采样 vs 近似聚合函数

到底该用采样,还是用 approx_* 聚合函数?在 500 万行上:

方案耗时结果误差
count(DISTINCT id)(精确)312.6 ms5,000,000—
approx_count_distinct(id)139.6 ms4,889,4662.21%
USING SAMPLE 5% (system) 放大81.2 ms4,751,3604.97%

这里采样反而更快,但误差大约翻倍。两者并非谁绝对更优:

  • approx_* 聚合函数单趟扫描、原地替换精确算子,误差有界、可预期。去重计数、分位数、top-k 优先用它。
  • 采样适合"想要一个真实的行的子集、并在这个子集上跑任意后续查询"的场景 —— 比如测试夹具,或可复现的探索样本。

分位数上差距缩小:同样数据下 quantile_cont 428.6 ms,approx_quantile 308.1 ms —— 1.4× 的收益,而去重计数是 2.2×。


什么时候该采样,什么时候不该

该采样:

  • 数据在远端(S3/HTTP)或处于冷缓存,跳过 I/O 本身就是目的。
  • 你在探索一张陌生的表,只想要个大概形状,不要精确数字。
  • 过滤之后的逐行处理很重(正则、JSON 解析、字符串操作)。
  • 你需要一个可复现的实验 —— 用固定种子。

不该采样:

  • 全表扫描本来就快(本地热 Parquet、廉价聚合),经常会亏。
  • 你需要精确答案,采样给不了。
  • 你想用大行数的 RESERVOIR —— 它是最贵的方法。
  • 小表需要保证非空样本(SYSTEM 可能返回零行)。

实用配方

可复现的 5% 报告样本:

SELECT category, avg(value)
FROM (SELECT * FROM read_parquet('events.parquet')
      USING SAMPLE 5% (system, 42)) s
GROUP BY category;

精确 N 行的测试夹具:

COPY (SELECT * FROM big_table USING SAMPLE 1000 ROWS (reservoir, 1))
TO 'fixture.parquet' (FORMAT PARQUET);

快速估个分布,再验证:

-- 先快速看一眼
SELECT category, count(*) FROM (SELECT * FROM events USING SAMPLE 5% (bernoulli, 7)) GROUP BY category ORDER BY 2 DESC;
-- 数字重要时,用不带采样的精确答案
SELECT category, count(*) FROM events GROUP BY category ORDER BY 2 DESC;

总结

DuckDB 提供两种语法(USING SAMPLE、TABLESAMPLE)和三种方法(SYSTEM、BERNOULLI、RESERVOIR),默认值与代价各不相同。只有 SYSTEM 能下推进扫描;BERNOULLI 与 RESERVOIR 都会先读完每一行,这正是它们常常比全表扫描更慢的原因。采样不是通用的加速手段 —— 它在昂贵的逐行谓词上取胜(最高 2.8×),在廉价查询上则可能惨败(低至 0.06×)。

下一步建议:

  • 对任何采样查询跑一次 EXPLAIN,确认采样位于扫描内部,还是位于其上的 STREAMING_SAMPLE 算子里
  • 只想要近似数字时优先用 approx_* 聚合函数;想要"可复跑的行子集"时才用采样
  • 打算引用某个样本,就一定要设种子

先测量,再采样。一行 USING SAMPLE 10% 既可能让查询快 3 倍,也可能慢 16 倍 —— 而 EXPLAIN 会在你运行之前就告诉你答案。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials