alitrack

DuckDB 查询跑一半,一条 SELECT 看读了多少字节

一条 SQL 跑了二十分钟,你盯着进度条不知道它在等磁盘还是在等内存——这种时候以前只有两个选择:等它跑完看 profile,或者杀掉重来。DuckDB 主仓上周和今天连着合了两个 PR(#26311、#27856),把这些数字变成了查询运行中就能读的表。新指标今天刚合并,我把能实测的部分全在本机跑了一遍。

核心一句话:查询不用停、profiling 不用开,一条 SELECT 就能读到当前连接已经读了多少字节、写了多少字节、内存占了多少。

● ● ●

以前这些数字只在事后出现

DuckDB 一直在查询运行时更新几个计数器:读了多少字节、写了多少字节、buffer manager 分配了多少内存。CLI 进度条就是靠它们画的。问题是它们只存在于引擎内部,查询结束写进最终 profile 之后外面才看得见。

想搞清楚"这条查询是不是在疯狂换页",要么开着 profiling 跑完等报告,要么去盯 temp 目录的大小变化。监控一个正在跑的查询,基本靠猜。

● ● ●

一条 SQL 的事

上周四合并的 #26311 加了 duckdb_live_query_metrics() 表函数,按连接返回实时指标,一行一个指标,风格和 duckdb_settings() 一致。C++ 侧对应 ClientContext::GetLiveQueryMetrics(),可以从另一个线程调,不用开 profiling。

先造一张 2000 万行的表,CHECKPOINT 之后重新打开数据库,让数据真的从磁盘读:

WITH scan AS MATERIALIZED (SELECT sum(l_extendedprice) FROM lineitem)
SELECT m.metric_name, m.metric_value
FROM scan, duckdb_live_query_metrics() m
WHERE m.connection_id = current_connection_id();

MATERIALIZED 强制扫描先跑完,快照看到的就是这条查询自己已经读到的字节。本机实测输出:

io.total_bytes_read           136577024
io.total_bytes_written        0
system.total_memory_allocated 262144

1.3 亿字节,和 137MB 的库文件对得上。写入是 0,纯读查询没碰磁盘写。这套写法有个前提要说明:它是同连接自读,真正的外部监控走 C++ API 或另开连接查全表——表函数列出所有连接的指标,不只自己的。

● ● ●

换个会溢出的查询,看它写盘

读指标之外更让人心痒的是写入。把内存限到 100MB、单线程,排序 2000 万行必然溢出到 temp 目录:

SET memory_limit = '100MB';
SET threads = 1;

WITH sorted AS MATERIALIZED (
    SELECT first(i) FROM (SELECT i FROM range(20000000) t(i) ORDER BY i DESC)
)
SELECT m.metric_name, m.metric_value
FROM sorted, duckdb_live_query_metrics() m
WHERE m.connection_id = current_connection_id();

本机实测(main 分支构建,含 #26311):

io.total_bytes_read           151453696
io.total_bytes_written        68354048
system.total_memory_allocated 320602112

读 151MB、写 68MB、内存顶在 320MB。写出的 68MB 就是溢出到 temp 目录的排序中间结果——这条查询是不是在换页、换了多少页,跑着跑着就能答。

这里要交代诚实边界:今天下午合并的 #27856 把 live 指标从 3 个扩到 8 个,新增了 io.total_read_operations、io.total_read_time、io.total_write_operations、io.total_write_time 和 system.total_bytes_spilled。PR 作者给的参考输出里 system.total_bytes_spilled 直接是 77758464。但官方 nightly 构建停在今早 6 点的 commit,还没包含这个 PR,所以这 5 个新指标我没能实测,以上面两个实测输出为准。

● ● ●

官方文档没写的一页

这两个 PR 的实际意义不在 SQL 自读这个姿势——那更多是演示。真正有用的是引擎侧的口径变化:这些计数器本来就是原子变量,查询跑着就在更新,PR 做的只是把它们标记成 live 并暴露出来,执行路径上零新增开销。

对做工具的人,这意味着几个以前做不到的事:给长查询画一条随时间变化的 I/O 曲线;读的字节超过阈值就主动打断查询(官方 PR 描述里明确提了这个用法,语义类似 max_execution_time 但按 I/O 量算);判断一条慢查询到底是 I/O 瓶颈还是 CPU 瓶颈,不用等它跑完。

8 个 live 指标全景:

duckdb_live_query_metrics 的 8 个实时指标

duckdb_live_query_metrics 的 8 个实时指标

指标
单位
来源
io.total_bytes_read
bytes
#26311,实测 ✅
io.total_bytes_written
bytes
#26311,实测 ✅
system.total_memory_allocated
bytes
#26311,实测 ✅
io.total_read_operations
次
#27856,PR 输出 🔶
io.total_read_time
秒
#27856,PR 输出 🔶
io.total_write_operations
次
#27856,PR 输出 🔶
io.total_write_time
秒
#27856,PR 输出 🔶
system.total_bytes_spilled
bytes
#27856,PR 输出 🔶

一个容易误读的细节:read_time 和 write_time 是按线程累加的,多线程查询的加总可能超过墙钟时间,别拿它直接除出吞吐率。

回到开头那个盯进度条的场景:等下一个 nightly 出来,这条查询是不是在换页、换了多少,中途一条 SELECT 就有答案,不用再杀掉重来。做监控面板的人,这个月值得盯一下 2.0.1 的 changelog。

你最想在面板上加哪个:读字节、spill 量,还是按 I/O 阈值自动杀查询?评论区聊聊。

参考来源

  1. 01
    https://github.com/duckdb/duckdb/pull/26311
  2. 02
    https://github.com/duckdb/duckdb/pull/27856
  3. 03
    https://artifacts.duckdb.org/v2.0-cyanoptera/duckdb-cli-linux-amd64.tar.gz

本机实测环境:DuckDB main 分支构建 v2.0.1-alpha50398(c4d7b8bd7e,2026-10-26 06:27Z),WSL。标注 ✅ 的数字为实际运行输出,标注 🔶 的引自 PR #27856 作者输出。