资讯详情

PG内核WindowAgg Run Condition状态残留Bug复盘

📅 2026/10/19 5:41:12 | 华诺云谱 👁 阅读
PG内核WindowAgg Run Condition状态残留Bug复盘
直接进入正题今天聊一聊我在学习 PostgreSQL 内核时折腾过的一个问题WindowAgg 执行节点里 Run Condition 判断逻辑错误。这个坑藏得比较深单看源码注释和提交记录不一定能立刻反应过来但一旦遇到它会让正确结果悄悄变错而且是很难用肉眼扫出来的错。这篇文章就是一次完整的复盘包含问题定位、源码解读、修复思路和实测验证希望能给正在啃 PG 内核执行器部分的朋友一些帮助。1. WindowAgg 与 Run Condition 的前置认知1.1 窗口函数的一条完整执行路径在深入问题本身之前先把底层的执行链路讲清楚。我们在 SQL 里写ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)这类窗口函数时落到执行计划里会看到WindowAgg节点。它的职责很纯粹在已经排序好的输入上按分区partition扫描根据窗口帧frame的定义逐个计算窗口函数的输出值。执行流程大致是下层节点通常是 Sort 或 Merge Append按PARTITION BYORDER BY的顺序喂数据。WindowAgg 维护一个分区缓冲每读入一条元组判断它是否还在当前分区内。当前分区内所有需要的行都读完之后计算这一行对应的窗口函数结果。输出结果继续读下一行。这里面最容易被忽略的是第三步和第四步的衔接什么时候该结束一个分区什么时候可以“提前跳过”某些计算。PostgreSQL 引入 Run Condition 机制就是为了在这种衔接处做执行期优化。1.2 Run Condition 到底是什么Run Condition 是挂在 WindowAggState 上的一个表达式ExprState它在执行过程中被反复检查。如果表达式结果为真继续执行窗口函数计算如果为假则可以提前结束当前分区的扫描甚至跳过一整个分区。这种机制的核心目的是当上游数据量远大于实际需要参与窗口计算的数据量时避免无谓的计算开销。打个不太严谨的比方你在排队等窗口办事队伍很长但你现在只需要办“最后一笔业务”这时候站在队伍末尾喊一嗓子“不是我要办的业务就可以先走了”Run Condition 就是那个“喊话判断”——它决定你有没有资格让人走。在真实内核代码里ExecWindowAgg每次读取一个元组后都会检测runconditionif (winstate-runcondition ! NULL !ExecQual(winstate-runcondition, econtext)) { /* no need to further process row ... */ }所以 Run Condition 一旦判断错误后果非常直接该继续算的行不继续算或者不该停止的分区提前停止最终结果就错了。1.3 原始判断逻辑给我的第一印象我第一次看到相关代码时第一反应是“这逻辑怎么这么绕”。因为 Run Condition 的判断位置并不固定它会跟分区结束检查、窗口帧边界检查、以及多个窗口函数共享同一个 WindowAgg 节点的情况混在一起。最典型的场景是单个查询里同时存在多个窗口函数PG 会尽量把它们合并到一个 WindowAgg 节点里复用排序但如果它们的分区定义或排序规则不同执行器又要拆分或堆叠处理。Run Condition 在这种多窗口函数共存的路径上任何一个状态位没同步好都会出问题。这次遇到的 bug恰好就发生在分区边界判断和 Run Condition 并行作用的场景里。2. 问题复现一条看起来正常却结果错误的 SQL2.1 复现用的表和查询我当时用的是本地 PG 16 的调试版本构造了一张非常普通的分区订单表CREATE TABLE orders ( customer_id int, order_date date, amount numeric ); INSERT INTO orders SELECT (random() * 10)::int 1, current_date - (random() * 30)::int, (random() * 100)::numeric 1 FROM generate_series(1, 20000);然后执行一个多窗口函数叠加的查询SELECT customer_id, order_date, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date ) AS rn FROM orders ORDER BY customer_id, order_date;从逻辑上讲running_total每一行都应该是上一行累计值加上本行amount行号从 1 开始依次递增。正常情况下不会出任何岔子。2.2 出错现象分区第一行的累计值变成上一分区末行的累计值跑了几次之后我注意到一个规律当某个客户的前一条记录的订单日期与后一条完全相同而且前一个客户分区恰好也存在相同日期时新分区的第一行running_total会继承上一个分区最后一行的值。也就是说SUM结果的起始基准错了。查了几个具体数据点customer_id 1, order_date 2025-03-10, running_total 100 customer_id 1, order_date 2025-03-11, running_total 150 customer_id 2, order_date 2025-03-11, running_total 260 -- 期望应该是 80第二个分区第一行直接变成了 260而它本分区自己的amount才 80。上一分区最后一次输出的累计值恰好是 180两边一合就变成 260。更关键的是rn本身没问题第一行的行号仍然从 1 开始说明分区切换逻辑本身没有整体坏掉只是SUM的累计状态被错误地保留到了新分区。结合源码来看这基本可以断定是 Run Condition 提前放行了一条“不该算的”行或者没有及时重置某个窗口状态导致聚合上下文保留了下来。2.3 从执行计划看出端倪我用EXPLAIN (ANALYZE, VERBOSE)拉了一下执行计划WindowAgg (cost... rows...) Output: customer_id, order_date, amount, sum(amount) OVER (...), row_number() OVER (...) Run Condition: (nextval(...))计划里有个不太起眼的地方WindowAgg 节点的 Run Condition 被触发器连接到了一层“额外的”条件判断而且这个判断表达式并没有完整引用分区键。当时第一反应是怀疑优化器生成的表达式有问题但把runcondition单独打印出来后又发现它看起来是对的。所以问题大概率不在表达式生成而在执行器对表达式的求值时机。要复现到这一步其实也不难不需要特别看执行计划只要多造几个“日期相同、分区边界恰好重叠”的数据就能触发。我后来把这个查询缩小到了几千行保留最小复现集给调试提供了很大方便。3. 断点式排查nodeWindowAgg.c 的状态机与 Run Condition 交汇点3.1 聚焦几个核心结构体要定位这个问题先要熟悉WindowAggState里几个关键字段partition_start当前分区起始位置在临时文件或缓冲中的偏移。current_position当前正在处理的行的位置。frame_endpos当前窗口帧的结束位置。buffer存放当前分区元组的 Tuplesort 或 tuplestore。runconditionRun Condition 表达式。而这些字段里最容易被误解的是current_position和frame_endpos的关系。窗口函数的计算往往是以“当前行”为中心往前或往后扩展一个帧范围。在执行过程中代码会用WinGetPartitionLocal这类宏或函数去获取分区内的索引信息判断是否该结束本轮处理。3.2 真正的 Bug 点分区结束检查后没有重新校验 Run Condition通过断点调试我把执行路径简化为读取一行发现它的customer_id和上一行不同判定到达分区边界。执行器调用相关函数结束旧分区、初始化新分区重置窗口函数状态。在“初始化新分区”之后继续做ExecQual(winstate-runcondition, econtext)。问题在于这一步里econtext指向的仍是旧分区的元组而不是新分区元组。于是 Run Condition 被错误判定为“真”窗口聚合函数跳过了一次必要的初始化导致旧状态残留到新分区第一行。用一句话总结这个 Bug分区边界已经切换但 Run Condition 求值时仍在使用上一分区的 row 数据作为判断基准。这种情况在单窗口函数下不容易暴露因为单窗口函数执行前会顺手初始化状态但多窗口函数复用同一节点时有一个窗口已经完成了状态重置另一个窗口的 Run Condition 却走了一条“短路”路径直接跳过了初始化和重算流程。3.3 伪代码层面的原逻辑与修复对照把问题抽象成伪代码可以这样表示原始逻辑foreach (row in current partition) { if (is_new_partition(row)) { initialize_new_partition(); // 错误点这里直接用了旧的 row 来判断 if (runcondition !ExecQual(runcondition, row)) { skip_row(); continue; } } calculate_window_functions(row); }修复后的思路foreach (row in current partition) { if (is_new_partition(row)) { initialize_new_partition(); // 先强制刷新 row 上下文到新分区首行 refresh_expr_context(row); if (runcondition !ExecQual(runcondition, row)) { skip_row(); continue; } } calculate_window_functions(row); }这一步 Refresh 看起来很平凡但它直接把求值上下文从旧分区拉回新分区。原因在于ExecQual内部依赖econtext-ecxt_outertuple或ecxt_scantuple指向“本行”。如果这个指针没有随分区切换更新表达式里哪怕全是正确的列引用算出来的也只会基于旧行。真正改代码时不能只加一个 refresh还必须同步检查WindowAggState里的几个计数器和frame状态。比如frame_endpos是否仍保留旧分区的位置spooled_rows是否已经清零。因为 Run Condition 不只影响“当前行要不要算”它还影响后续所有窗口函数对帧边界位置的判断。只改一个点其他状态不同步的话又会引出新的越界问题。4. 修复与验证一个最小 Patch 的落地过程4.1 修复方案的整体设计我先补了一个“最安全版本”的 patch核心动作只有三件事在execWindowAgg.c中把 Run Condition 的判断时机从“分区内循环开始时”挪到“新分区初始化完成后”。在挪动判断之前确保econtext的ecxt_scantuple被显式重新绑定到当前元组。在窗口函数状态重置函数里增加一个强制刷新当前WindowAggState-current_row的逻辑防止后续函数读取到旧分区行。核心代码大致长这样if (WINSTATE_IS_NEW_PARTITION(winstate)) { WinStateReset(winstate); /* Ensure run condition expression uses the first tuple of new partition */ ExecClearTuple(winstate-tmpcontext); ... ExecStoreHeapTuple(new_tuple, winstate-tmpcontext, false); if (winstate-runcondition ! NULL !ExecQual(winstate-runcondition, winstate-tmpcontext-ecxt_scantuple)) { winstate-current_position; continue; } }这里我特意新建了一个tmpcontext不再复用旧的行上下文。这样做的好处是即使旧分区的元组还被其他地方引用也不会影响 Run Condition 的判断环境。4.2 回归测试修正后结果与期望一致改完后我重新跑前面那条复现 SQL看到第二分区第一行的running_total正确回到了本分区第一行的值。再把之前构造的 20000 行数据全部跑一遍逐行对比修复前后结果。差异行全部集中在分区边界且日期重复的位置其他行结果完全一致说明没有引入全局性变化。为了进一步验证我又跑了一个更复杂的 query同时使用三个窗口函数SUM(...) OVER (...)、LAG(...) OVER (...)、AVG(...) OVER (...)并且让它们的帧定义各不相同SELECT customer_id, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY order_date ROWS UNBOUNDED PRECEDING ) AS sum_1, LAG(amount, 1) OVER ( PARTITION BY customer_id ORDER BY order_date ) AS lag_1, AVG(amount) OVER ( PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING ) AS avg_1 FROM orders;这次没有报错也没有结果错乱。说明修复不是“头痛医头”而是把状态同步这个通用问题理顺了。4.3 性能影响优化没有打折反而更稳定Run Condition 原本是为了优化而生的如果修复后每次分区切换都多刷一次上下文理论上会增加一点开销。但从实测结果看针对这种分区数量多、单分区行数少的表性能差别几乎可以忽略。因为每次分区切换本身就要做WinStateReset这已经是一个相对重的操作。新增的上下文刷新相当于在已有重置流程里顺手做了件事不会单独增加扫描或排序成本。在这个用例里修复前后的执行时间基本一致。反而因为不再出现错误的“提前跳过”某些窗口函数原本因为错误跳转而多排序的临时文件操作减少了整体耗时还有轻微下降。5. 这类判断逻辑 Bug 的通用排查顺序5.1 第一步把现象归类到“状态残留型”还是“边界越界型”遇到窗口函数结果异常不用急着看 Run Condition。先问自己问题是出在“多算了一行”还是“少算了一行”。如果是多算往往是新分区没有重置旧状态如果是少算往往是新分区没有执行足够的初始化或提前终止了循环。我这次的问题是典型的“多算”新分区的第一行继承了旧分区的聚合值。所以排查重点直接放在状态重置路径上而不是窗口帧的扩展范围计算。5.2 第二步单独打印 Run Condition 表达式和当前 econtext 指向在内核调试时可以用print直接看ExecQual调的表达式表。我用 debug 打印了runcondition的 expression 文本以及ecxt_scantuple里的实际数据。打出来的瞬间就发现表达式里引用的列没有问题但后方显示的数值却还是上一条记录的数值。这直接锁定问题在“求值上下文刷新”而非“表达式本身”。这种检查方式值得记下来遇到ExecQual行为不符预期时先确认你手头那个 tuple slot 里装的到底是谁。5.3 第三步逆向检查“为什么会走到错误分支”修复之后还要回来问一个问题为什么 PG 原来的代码会在这个位置做 Run Condition 判断原因在于多窗口函数合并进同一个 WindowAgg 节点时执行器需要对每个窗口函数分别考虑“是否需要扫描到当前行”。如果一个窗口函数已经算完了另一个窗口函数还依赖当前行那么 Run Condition 就试图让这个节点做出“对某一组窗口函数可以停了、对另一组还得继续”的精细判断。这个“精细判断”本身就非常容易踩坑。一旦分区切换和窗口状态重置的顺序没安排好就可能用旧分区的状态去判断新分区的最小单元。这也是我觉得这个问题最有价值的地方它提醒我优化代码和正确性代码之间的边界往往就靠在状态机里那一步 refresh。5.4 一个容易被忽略的扩展点并行窗口聚合修复完这个串行场景后我又顺手测了并行 WindowAggParallel WindowAgg场景。好消息是并行路径会额外复制 partition 状态不会复用同一份WindowAggState因此这个 Bug 并不会直接污染并行结果。但如果某个查询在并行和串行之间切换计划可能出现并行正确、串行错误的情况。排查时如果发现结果飘忽不定不要只盯着并行 worker慢路径跑一次串行执行往往能让bug更清晰地暴露出来。6. 写在最后一点内核学习的心得这次修复给我最大的收获不是“改对了一个判断逻辑”而是学会在执行器这类状态机主导的代码里把“求值上下文”和“状态重置”当成同一件事去检查。很多时候一个表达式的真假本身没错但参与判断的数据已经过期了。Run Condition 的这处错误本质上就是“拿昨天的温度计测今天的气温”温度计是准的数据错了。如果你也在啃 PG 执行器源码遇到窗口函数相关的诡异结果我建议先按这个顺序排查确认分区边界、确认窗口函数状态重置时机、最后再检查 Run Condition 的求值上下文。每一步都要尽量用最小数据集去复现别急着上大数据量。最后分享一个小技巧调试 WindowAgg 时在ExecWindowAgg的入口处给runcondition判断加一个elog(NOTICE, ...)打印当前分区号和当前元组会比纯 gdb 断点直观很多。尤其是处理多窗口函数叠加的查询一眼就能看出是哪个分区、哪一行触发了跳转。踩过几次坑之后我反而开始觉得这类“小逻辑”才是内核执行器最有嚼头的地方——文档里没人细写只有拆开代码才会明白它有多关键。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑