分层干不干净,要等你想换掉一层才见分晓。
DuckDB 2.0 把用了八年的 parser 整层换掉了:默认 parser 从 libpg_query 换成了自研的 PEG parser,libpg_query 移出了内核,老 parser 留在 legacy_parser 扩展里。能换得动,靠的是一条从第一版代码就在的规矩:parser 不认识任何一张表。
这条规矩在平时几乎看不见。下面用 1.5.6 和 2.0.0 两个版本的实测,把这道缝拆开来看:它在哪,凭什么说它干净,换层时它挡住了什么,又有什么没挡住。
parser 只管语法,表存不存在它不关心
先做一个最直接的试探。写一条 SQL,表、列、函数、类型全是不存在的名字,只让它解析,不执行。DuckDB 有个函数 json_serialize_sql,只做解析,把解析树序列化成 JSON,正好拿来当探针。
SELECT json_serialize_sql(
'SELECT ghost_col, ghost_fn(x)::ghost_type, sum(y)
FROM ghost_table GROUP BY ALL'
);
-- 返回的 JSON 里 "error": false,解析成功
同一条 SQL,真执行,或者只是 EXPLAIN,结果就不一样了:
Catalog Error: Table with name ghost_table does not exist!
Did you mean "pg_tables"?
1.5.6 和 2.0.0 在这里表现一致。四个幽灵名字一起出现,报的只有表,因为 binder 先绑 FROM,表都不存在就不往下走了。
把几种情况拆开跑一遍,报错的分界线就很清楚:
表在、列不在: Binder Error: Referenced column "nope"
not found in FROM clause!
表在、函数不在: Catalog Error: Scalar Function with name
ghost_fn does not exist!
聚合规则不满足: Binder Error: column "a" must appear in the
GROUP BY clause or must be part of an
aggregate function.
类型名不存在: Catalog Error: Type with name ghost_type
does not exist!
Parser Error 只在语法写错时出现,不需要任何一张表。Catalog Error 和 Binder Error 都是 binder 在绑定时去查 catalog 抛出来的。EXPLAIN 也躲不过,因为它要先 bind 才出得了计划。
真正的语法错误是另一回事,只解析就失败。这里两个版本有一处不同:
1.5.6 Parser Error: syntax error at or near "SELEC" (position 0)
2.0.0 Parser Error: syntax error at or near "1" (position 6)
同一句 SELEC 1;,报错定位变了,这是换 parser 留在表面上的痕迹。
解析树里只有名字,没有类型
parser 不查 catalog,那它交出去的树里到底有什么?在一个空库上解析这条 SQL:
SELECT sum(bar), lower(bar), baz, 1, CAST(qux AS INTEGER)
FROM no_such_table
把每个节点的关键字段打出来,2.0.0 是这样的:
FROM -> {'type': 'BASE_TABLE', 'table_name': 'no_such_table'}
{"class": "FUNCTION", "function_name": "sum"}
{"class": "FUNCTION", "function_name": "lower"}
{"class": "COLUMN_REF", "column_names": ["baz"]}
{"class": "CONSTANT",
"literal": {"kind": "INTEGER", "text": "1"}}
{"class": "CAST",
"type_expr": {"class": "TYPE", "type_name": "INTEGER"}}
sum 节点上有没有标“这是聚合”的字段: 没有
表只是一个名字字符串,列只是一组名字,没有任何类型。sum 和 lower 在树里是同一种 FUNCTION 节点,节点上没有字段标明谁是聚合。表、列、类型和聚合,全都交给 binder 去查。
1.5.6 的树在常量和 CAST 上不太一样:
{"class": "CONSTANT",
"value": {"type": {"id": "INTEGER", "type_info": null},
"is_null": false, "value": 1}}
{"class": "CAST",
"cast_type": {"id": "INTEGER", "type_info": null}}
1.5.6 的 parser 其实认识内置类型名,常量 1 已经带上了 INTEGER,CAST(... AS INTEGER) 也已经解析成类型 id,只有不认识的类型名才留成 UNBOUND。2.0 退得更彻底,常量只留原文和词法种类,连 INTEGER 也只是一个待解析的类型名。官方 Internals 文档里写着「显式 CAST 的类型例外」,说的是旧行为。「parser 不解析类型」这句话,在 2.0 上比在 1.5 上更准确。
同一个 binder,前面接两种 parser
缝如果真干净,两种 parser 喂给同一个 binder,结果就应该一样。这是整件事最直接的检验。
在同一个 2.0.0 进程里,先用默认的 PEG,再执行 LOAD legacy_parser; SET enable_legacy_parser = true 切回 libpg_query,对 TPC-H 22 条查询分别比较解析树和执行结果。比较解析树时去掉了源码位置字段,类型名统一小写,执行结果在 SF1 库上比。
duckdb 2.0.0,同一进程:默认 PEG vs legacy_parser
TPC-H 22 条:解析树一致 18/22,执行结果一致 22/22
不一致: 7 / 8 / 9 / 22
Q7、Q8、Q9、Q22 的树不一致,原因是同一类语法糖:EXTRACT(YEAR FROM ...) 和 SUBSTRING(x FROM a FOR b)。legacy 把它改写成 main.date_part('year', ...),PEG 改写成 date_part('YEAR', ...),只差一个 schema 前缀和大小写。binder 解析出来是同一个函数,所以执行结果还是 22 条全一致。
前面换了一整套 parser,后面的 binder、优化器、执行器看不出区别。整层换掉在工程上就是这个样子。
还有个小坑,正好顺手演示了「先解析、后绑定执行」这件事。CLI 的 -c 会把整串 SQL 先整体解析完再执行,所以把 SET enable_legacy_parser = true; SELECT ... 写在同一个 -c 里不会生效,后面那句早就被 PEG 解析完了。实验脚本里把 SET 放在 -cmd 里先执行,才切得过去。
源码里看那道墙
行为上的证据有了,再看源码。分别浅克隆 v1.5.6 和 v2.0.0 两个 tag 的 parser 和 planner 目录,数几样东西。
[5a] third_party 里有没有 libpg_query:
v1.5.6 有,v2.0.0 没有
[5b] Postgres 数据结构(duckdb_libpgquery / PGNode)
出现在哪一层(文件数)
v1.5.6 src/parser: 79 src/planner(含 binder): 0
v2.0.0 src/parser: 0 src/planner(含 binder): 0
[5c] 解析路径里查 catalog 的调用
(Catalog::Get / GetCatalog / CatalogEntryRetriever)
v1.5.6 parser.cpp / transformer.cpp / transform/ 0 / 0 / 0
v2.0.0 parser.cpp / peg/ 0 / 0
对照 binder:67 次(v2.0.0 src/planner/binder)
5b 是这道墙最硬的证据。1.5.6 里,Postgres 的节点类型出现在 79 个 parser 文件里,binder 和 planner 里一个都没有。libpg_query 解析完,transformer 马上把结果转成 DuckDB 自己的 C++ 解析树,Postgres 的数据结构出不了 parser 这一层。2019 年 SIGMOD 那篇 demo 论文里的说法是「限制 Postgres 数据结构的影响范围」,这个 79 对 0 就是那句话落到代码上的样子。所以 2.0 删掉 libpg_query 时,binder 里没有一处 PGNode 要改。
这条分工也不是论文发表时才定的。libpg_query 和那层转换在 2018-07-13 的第一个提交里就已经在了,提交信息是 "Working parser + initial draft of interface"。2019 年的论文是第一次把它写出来,「parser 不知道 catalog」这句话出自官方 Internals 文档。从第一个提交到 2.0.0 发布(2026-10-21),八年多。
5c 说明解析路径本身不查 catalog,两个版本都是 0 次,binder 里有 67 次。
这里要说得准一点。如果说成「parser 目录不引用 catalog」,按字面是不成立的:
[5d] v2.0.0 src/parser 里 include catalog 头文件的地方:12 处
11 处在 src/parser/parsed_data/
(CreateTableInfo、CreateViewInfo、AlterInfo 等 DDL 信息结构)
1 处在 peg/transformer/peg_transformer_factory.cpp,
引的是 default_types.hpp
11 处在 parsed_data/,那是 CREATE、ALTER、COMMENT 这类 DDL 语句的信息结构,binder 和 catalog 执行 DDL 时也用它们,算是放在 parser 目录下的共享数据结构,不在解析路径上。剩下 1 处是 PEG 转换器用内置类型表检查「只有 VARCHAR 能带 COLLATE」,查的是写死的内置类型名,不是数据库里的 catalog。准确的说法是:解析路径不查 catalog。
换层不是零成本,binder 也跟着改了
如果只看前面这些,很容易说成「换了 parser,上层一行没动」。实测不支持这个说法。
[5e] v1.5.6 -> v2.0.0 改动量(git diff --shortstat)
src/parser 313 files changed, 50710 insertions(+), 9475 deletions(-)
src/planner/binder 73 files changed, 7019 insertions(+), 3377 deletions(-)
binder 这 73 个文件里混着 2.0 的其他功能,不能都算到换 parser 头上。但上一节的解析树已经说明了问题:函数参数从 children 变成了带名字的 arguments,常量从带类型的值变成 literal 原文,CAST 的类型变成待解析的类型表达式。树的字段变了,binder 就得跟着改。
缝的位置八年没变,parser 产出名字,binder 负责查 catalog、定类型、认聚合。缝两边的接口在 2.0 里重新谈过一次。两件事同时成立,说清楚了才不会被源码打脸。
三处静默语义变化,有一处退不回去
换 parser 的代价里,最要紧的是静默语义变化:同一条 SQL,结果变了,但不报错。迁移指南列了三处,实测如下:
== 1.5.6 默认 (libpg_query)
tilde|fact|hex_type|bit_type
false|720 |VARCHAR |VARCHAR
SELECT -3! → Out of Range Error: factorial of a negative
number is undefined
== 2.0.0 默认 (PEG)
true |12 |BLOB |BIT
SELECT -3! → -6
== 2.0.0 + legacy_parser
false|720 |BLOB |BIT
SELECT -3! → Out of Range Error ...
四列分别是 'abc' ~ 'b'、2 * 3!、X'FF' 的类型、B'101' 的类型。翻成人话:~ 从全匹配改成了部分匹配,所以 'abc' ~ 'b' 从 false 变成 true;! 的优先级变高了,2 * 3! 从 720 变成 12,-3! 也从报错变成了 -6;X'FF' 和 B'101' 从 VARCHAR 变成了 BLOB 和 BIT。
前两处切回 legacy_parser 就能还原,~ 还可以单独用 SET regex_match_operator_semantics='full' 还原,实测结果回到 false(2.0 会提示这个设置已经弃用)。第三处切回 legacy_parser 之后,类型依然是 BLOB 和 BIT,退不回去。
为什么?看两种 parser 在 2.0.0 里产出的解析树:
PEG : "function_name":"factorial"
"function_name":"regexp_matches"
"literal":{"kind":"HEX","text":"FF"}
legacy : "function_name":"!__postfix"
"function_name":"regexp_full_match"
"literal":{"kind":"HEX","text":"FF"}
~ 和 ! 是 parser 自己的决定,在解析树里就能看到 PEG 把 ~ 翻成了 regexp_matches,把 ! 翻成了 factorial,所以换回老 parser 就换回了老行为。X'FF' 在两种 parser 的树里一模一样,都是一个 kind 为 HEX、原文为 FF 的字面量,它是什么类型由 binder 决定。换 parser 管不到 binder 的决定。
一处变化能不能靠回退兜住,看它落在缝的哪一边。迁移指南把三条列在一起,接着说想回到老 parser 就用 legacy_parser,读快了容易以为三条都能退。实测下来,X'…' 和 B'…' 这一条要在升级时单独筛。
还有一个顺带发现:1.5.6 里已经带了 PEG 的实验开关。
== 1.5.6 + CALL enable_peg_parser()
tilde|fact|hex_type|bit_type
false|12 |VARCHAR |VARCHAR
打开之后 2 * 3! 已经是 12,但 ~ 还是全匹配,X'FF' 还是 VARCHAR。PEG 在 1.5 线上就已经能手动打开试,2.0 做的是把它改成默认、把 libpg_query 移出内核。最早是哪个版本带上的这个开关,目前还没核实。
换成 PEG 之后,语法可以在运行时挂上去
最后看一个收益。2.0 的 CLI 在运行时往语法上挂了一个叫 shell 的语法扩展:
== 2.0.0 已挂的语法扩展
shell | Run a bare expression list or table name
as a statement (CLI shorthand)
== 1.5.6 裸表达式 1 + 1 AS two;
Parser Error: syntax error at or near "1"
== 2.0.0 裸表达式 1 + 1 AS two;
two
2
== 2.0.0 SET active_grammar_extensions = []; 再试
Parser Error: syntax error at or near "1"
挂上它,可以直接敲一个表达式当语句;关掉它,同一句话立刻变回语法错误。挂语法的接口在源码 src/parser/peg/grammar_change.cpp 里,有 AddRule、AddChoice、ReplaceRule 这些操作。
最后两行实测很能说明这道缝:
== 2.0.0 裸名字 ghost_table 被解析成:
"class":"STAR" "type":"BASE_TABLE" "table_name":"ghost_table"
== 2.0.0 执行裸名字
Catalog Error: Table with name ghost_table does not exist!
一个裸名字 ghost_table,parser 纯按语法把它当成 SELECT * FROM ghost_table,它是不是一张表,parser 不管。执行时由 binder 去查,查不到才报 Catalog Error。语法扩展能放心地在运行时加规则,是因为加的只是语法,名字该不该存在、是什么类型,始终在 binder 那边判断。
回到开头那句
parser 不认识表,这条规矩从第一版代码就在。平时它看不出值多少,写业务 SQL 的人也碰不到它。等到八年后要把 parser 整层换掉,同一个 binder 接上两套 parser,TPC-H 22 条跑出一样的结果,它的分量才显出来。
它也不是万能的。binder 在 2.0 里跟着改了,有一处语义变化发生在缝的另一边,回退也退不回去。能看清一处变化落在缝的哪一边,升级时就知道哪些靠回退能兜住,哪些得自己筛。