bigywater.tech
ClickHouse 内部原理 · 43

WITH、子查询和 CTE 会不会真的只执行一次

真实用sleepEachRow这种有明显耗时的表达式验证WITH子句和手写重复子查询是否真的只执行一次,找到真相是公共子表达式消除而非WITH语法本身。

507ms≈503ms
真实手写重复子查询跟WITH的耗时几乎相同
看文本不看语法
真实决定因素是子查询文本是否完全相同

做的事:用真实耗时的sleepEachRow验证子查询到底算了几次

代码-- 用WITH,引用3次 WITH (SELECT sleepEachRow(0.5) FROM numbers(1)) AS side SELECT side, side, side FROM numbers(1); -- 不用WITH,同样的子查询原样抄写3遍 SELECT (SELECT sleepEachRow(0.5) FROM numbers(1)), (SELECT sleepEachRow(0.5) FROM numbers(1)), (SELECT sleepEachRow(0.5) FROM numbers(1));

验证:真实两种写法耗时都只有约500ms,不是预期的1500ms

实测 · 本机执行输出
WITH引用3次(相同sleepEachRow(0.5)):       真实耗时 503ms
不用WITH,原样抄写3遍(相同的0.5):          真实耗时 507ms   ← 意外:也只有约500ms,不是1500ms!

如果子查询真的每次出现都被真实执行一次,"抄写3遍0.5秒的sleep"应该真实耗时接近1.5秒,但实测只有507毫秒——跟用WITH的503毫秒几乎没有差别。这说明决定子查询是否真的重复执行的,不是"有没有用WITH关键字"。

验证:真相是公共子表达式消除,只认"文本是否完全相同"

实测 · 本机执行输出(真实的对照实验)
3个字面不同的sleep(0.5)/(0.6)/(0.7),不用WITH:
→ 真实耗时 1823ms ≈ 0.5+0.6+0.7=1.8秒   ← 真实按3次独立执行处理

2个字面完全相同的sleep(0.5)/(0.5),出现在WHERE子句里:
→ 真实耗时 516ms ≈ 只有1次的耗时,不是2次的1秒
真相真实是:ClickHouse的查询分析器会对文本上完全相同的表达式(包括子查询)做公共子表达式消除(Common Subexpression Elimination),只要两处出现的子查询逐字节相同,不管你是用WITH命名之后多次引用,还是笨拙地把同一段SQL原样抄写好几遍,真实都只会被求值一次、结果被复用;一旦子查询内容哪怕有一点点不同(本文用不同的sleep秒数模拟),就会被当成真实不同的表达式,各自独立执行。这跟很多人对WITH的直觉理解("用WITH才能保证只执行一次,不用WITH写普通子查询会重复执行")不完全一致——WITH真实的价值更多在于给一段逻辑命名、复用、让SQL更好读,而不是保证求值次数这件事本身;决定重复求值与否的,本质上是查询分析器能不能识别出"这几处子查询其实是同一个东西",这件事对手写重复的子查询同样成立,只要文本真实相同。不过这个结论有一个真实的前提——本文测的是确定性的(同样输入同样输出的)子查询,如果子查询内部用了真正的非确定性函数(比如依赖执行时刻的now()),公共子表达式消除是否依然会把它们当成"相同"处理,本文没有进一步验证,读者如果依赖这类非确定性场景,值得自己再测一次。

分步演示:从耗时数字反推子查询真实执行了几次

每一步都是本文上面用真实sleepEachRow跑出来的原始耗时。

真相不是WITH,是公共子表达式消除
这一步新验证的写法

    本文用到的工具

    • 本机 ClickHouse 26.9.4(单节点),真实用 sleepEachRow() 的耗时反推子查询求值次数
    • 诚实说明:本文未验证非确定性函数(如now())是否同样被公共子表达式消除
    京ICP备2026067502号-1京公网安备11010502063487号