【PostgreSQL内核学习:WindowAgg 帧优化与节点去重】
PostgreSQL内核学习:WindowAgg 帧优化与节点去重
- 背景
- 关键词解释
- 本优化主要修改内容描述
- 提交信息
- 提交描述
- 源码解读
- optimize_window_clauses 函数
- 核心逻辑拆解
- 函数时序图
- 新增结构体类型 SupportRequestOptimizeWindowClause
- 优化后的效果
- 帧优化 sql 用例
- 查询计划输出
- 节点去重 sql 用例
- 查询计划输出
- 限制与约束
- 结果不受 `frameOptions` 影响的窗口函数
- 其他窗口函数为何不可优化?
- 问题思考
- 解决方案思考
- 问题描述
- 方案一:支持函数中清空偏移量
- 方案二:在优化阶段集中清空偏移量
- 方案三:修改去重条件以忽略偏移量差异
- 优化后执行结果
声明:本文的部分内容参考了他人的文章。在编写过程中,我们尊重他人的知识产权和学术成果,力求遵循合理使用原则,并在适用的情况下注明引用来源。
本文主要参考了 postgresql-18 beta2 的开源代码和《PostgresSQL数据库内核分析》一书
背景
在 PostgreSQL
中,窗口函数(如 row_number()
、rank()
等)是用于在查询中对行集进行分区和排序计算的强大工具。然而,窗口函数的默认行为可能导致性能问题。具体来说,窗口函数的 WindowClause
默认使用 RANGE
模式,这要求执行器检查“同行
”(peer rows
,即 ORDER BY
字段值相同的行),从而增加计算开销。对于某些窗口函数(如 row_number()
),RANGE
和 ROWS
模式的计算结果相同,但 ROWS
模式的执行效率更高,因为它无需检查同行。
通过对测试查询的分析,发现使用 ROWS
模式替代 RANGE
模式可以带来很大的性能提升。因此,此补丁通过允许窗口函数动态调整其 frameOptions
(框架选项),优化查询执行计划,从而减少不必要的性能开销。
关键词解释
- 窗口函数(Window Function):一种
SQL
函数,用于在查询中对行集进行分区(PARTITION BY
)和排序(ORDER BY
)计算,如row_number()
、rank()
等。- WindowClause:定义窗口函数的分区、排序和框架范围的结构,包含
partitionClause
、orderClause
和frameOptions
等字段。- frameOptions:指定窗口函数的框架范围(
frame specification
),包括模式(ROWS
或RANGE
)和边界(如UNBOUNDED PRECEDING
、CURRENT ROW
)。- ROWS vs. RANGE:
- ROWS:基于行数定义框架范围,效率较高,因为只考虑物理行位置。
- RANGE:基于值范围定义框架范围,需要检查同行(值相同的行),开销较大。
- SupportRequestOptimizeWindowClause:新引入的节点类型,用于支持窗口函数优化其 WindowClause 的框架选项。
- WindowAgg:查询计划中的节点,负责执行窗口函数计算。减少 WindowAgg 节点数量可提升性能。
- 去重(De-duplication):合并重复的
WindowClause
,以减少执行计划中的WindowAgg
节点。
本优化主要修改内容描述
提交信息
下面为本次优化的提交信息,hash值为:ed1a88ddaccfe883e4cf74d30319accfeae6cfe5
提交描述
此补丁为 PostgreSQL
的查询计划器(planner
)增加了对窗口函数框架选项的优化能力,允许窗口函数(如 row_number()
、rank()
、dense_rank()
、percent_rank()
、cume_dist()
、ntile()
)通过支持函数(support function
)调整其 WindowClause
的 frameOptions
,以使用更高效的 ROWS
模式代替默认的 RANGE
模式。主要修改包括:
- 新支持函数节点类型:
- 在
supportnodes.h
中引入了新的节点类型SupportRequestOptimizeWindowClause
,允许窗口函数的支持函数指定优化的frameOptions
。- 该节点为计划器提供了一个机制,用于查询每个窗口函数是否可以调整其框架选项。
- 计划器优化逻辑:
- 在
planner.c
中添加了optimize_window_clauses
函数,遍历所有WindowClause
及其关联的WindowFunc
节点。- 对于每个
WindowClause
,计划器调用每个窗口函数的prosupport
函数,检查是否可以优化frameOptions
(通常是将RANGE
改为ROWS
)。- 仅当同一
WindowClause
下的所有窗口函数一致同意优化后的frameOptions
时,才会应用更改。- 优化后,优化器检查是否存在重复的
WindowClause
,并通过合并减少WindowAgg
节点的生成,从而进一步优化查询计划。- 窗口函数支持函数:
- 在
windowfuncs.c
中为row_number()
、rank()
、dense_rank()
、percent_rank()
、cume_dist()
和ntile()
添加了支持函数(如window_row_number_support
、window_rank_support
等)。- 这些支持函数指定可以将
frameOptions
设置为ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
,因为此模式对这些函数的结果无影响,且避免了同行检查的开销。- 测试与验证:
- 在
window.sql
和window.out
中添加了回归测试,验证优化后的查询计划是否正确减少了WindowAgg
节点数量,并确保结果正确。- 测试用例展示了当所有窗口函数支持优化时,查询计划只包含一个
WindowAgg
节点;当某些窗口函数(如count(*)
)不支持优化时,其frameOptions
保持不变。
以下是一个使用 Mermaid 语法编写的流程图代码,展示 PostgreSQL 补丁 0001-Allow-window-functions-to-adjust-their-frameOptions.patch
优化前后查询计划器处理窗口函数的流程对比。流程图通过一个图中的两个分支(优化前和优化后)来清晰展示差异,重点突出 frameOptions
优化和 WindowClause
去重的过程。
优化前,计划器直接使用默认的 RANGE
模式生成 WindowAgg
节点,导致执行器需检查同行(peer rows
),增加性能开销。优化后,计划器通过 optimize_window_clauses
调用窗口函数的支持函数,检查是否可将 frameOptions
优化为更高效的 ROWS
模式,并在必要时合并重复的 WindowClause
,减少 WindowAgg
节点,从而避免不必要的同行检查,提升查询性能。
注:代码使用
Mermaid
的graph TD
语法,适合在支持 Mermaid 的环境中渲染。
下面给出对应的作图代码,感兴趣的小伙伴可以自取:
graph TDA[开始: 查询计划器处理窗口函数] --> B[识别 WindowClause 和 WindowFunc]B --> C{是否应用优化补丁?}%% 优化前分支C -->|优化前| D[默认 frameOptions 为 RANGE]D --> E[检查 WindowClause 是否重复]E --> F[生成 WindowAgg 节点<br>使用 RANGE 模式]F --> G[执行器处理<br>检查同行(Peer Rows)]G --> H[返回查询结果]%% 优化后分支C -->|优化后| I[调用 optimize_window_clauses]I --> J[遍历 WindowClause]J --> K{每个 WindowFunc<br>有 prosupport 函数?}K -->|是| L[调用 prosupport 函数<br>获取优化 frameOptions]K -->|否| M[保留原有 frameOptions]L --> N{所有 WindowFunc<br>同意优化 frameOptions?}N -->|是| O[更新 frameOptions<br>(如 ROWS 模式)]N -->|否| MO --> P[检查 WindowClause 是否重复]M --> PP -->|存在重复| Q[合并 WindowClause<br>更新 WindowFunc 的 winref]P -->|无重复| R[生成 WindowAgg 节点<br>使用优化后的 frameOptions]Q --> RR --> S[执行器处理<br>避免不必要的同行检查]S --> H[返回查询结果]
源码解读
optimize_window_clauses 函数
optimize_window_clauses
是本次补丁的核心函数,位于 src/backend/optimizer/plan/planner.c
,负责优化 WindowClause
的 frameOptions
并处理去重逻辑。以下是对其源码的详细解读,结合补丁的上下文说明其实现。
/* * optimize_window_clauses* 调用每个 WindowFunc 的 prosupport 函数,检查是否可以调整 WindowClause 的设置,* 以便执行器以更优化的方式执行窗口函数。** 目前仅允许调整 WindowClause 的 frameOptions。未来可能支持更多优化。*/
static void
optimize_window_clauses(PlannerInfo *root, WindowFuncLists *wflists)
{List *windowClause = root->parse->windowClause; // 获取查询解析树中的 WindowClause 列表ListCell *lc; // 声明用于遍历 WindowClause 列表的单元指针foreach(lc, windowClause) // 遍历 WindowClause 列表{WindowClause *wc = lfirst_node(WindowClause, lc); // 获取当前 WindowClause 节点ListCell *lc2; // 声明用于遍历 WindowFunc 列表的单元指针int optimizedFrameOptions = 0; // 初始化优化后的 frameOptions,默认为 0Assert(wc->winref <= wflists->maxWinRef); // 断言:确保 WindowClause 的 winref 不超过最大值/* 跳过没有任何 WindowFunc 的 WindowClause */if (wflists->windowFuncs[wc->winref] == NIL)continue; // 如果当前 WindowClause 无关联 WindowFunc,跳到下一个 WindowClauseforeach(lc2, wflists->windowFuncs[wc->winref]) // 遍历当前 WindowClause 的 WindowFunc 列表{SupportRequestOptimizeWindowClause req; // 声明支持优化请求的结构体SupportRequestOptimizeWindowClause *res; // 声明指向优化结果的指针WindowFunc *wfunc = lfirst_node(WindowFunc, lc2); // 获取当前 WindowFunc 节点Oid prosupport; // 声明支持函数的 OIDprosupport = get_func_support(wfunc->winfnoid); // 获取当前 WindowFunc 的支持函数 OID/* 检查当前 WindowFunc 是否有支持函数 */if (!OidIsValid(prosupport))break; // 如果没有支持函数,退出 WindowFunc 循环,无法优化此 WindowClausereq.type = T_SupportRequestOptimizeWindowClause; // 设置请求类型为优化 WindowClausereq.window_clause = wc; // 设置请求的 WindowClause 为当前 WindowClausereq.window_func = wfunc; // 设置请求的 WindowFunc 为当前 WindowFuncreq.frameOptions = wc->frameOptions; // 设置请求的 frameOptions 为当前 WindowClause 的 frameOptions/* 调用支持函数 */res = (SupportRequestOptimizeWindowClause *)DatumGetPointer(OidFunctionCall1(prosupport,PointerGetDatum(&req))); // 调用支持函数,获取优化结果/** 如果支持函数不支持此请求类型,跳到下一个 WindowClause*/if (res == NULL)break; // 如果支持函数返回 NULL,退出 WindowFunc 循环,跳到下一个 WindowClause/** 对于当前 WindowClause 的第一个 WindowFunc,保存其优化后的 frameOptions*/if (foreach_current_index(lc2) == 0)optimizedFrameOptions = res->frameOptions; // 保存第一个 WindowFunc 的优化 frameOptions/** 对于后续 WindowFunc,如果 frameOptions 与第一个不同,* 则无法优化此 WindowClause 的 frameOptions*/else if (optimizedFrameOptions != res->frameOptions)break; // 如果 frameOptions 不一致,退出循环,跳到下一个 WindowClause}/* 如果所有 WindowFunc 一致同意优化 frameOptions,则调整 frameOptions */if (lc2 == NULL && wc->frameOptions != optimizedFrameOptions){ListCell *lc3; // 声明用于遍历 WindowClause 进行去重的单元指针/* 应用新的 frameOptions */wc->frameOptions = optimizedFrameOptions; // 更新当前 WindowClause 的 frameOptions/** 检查更改 frameOptions 后是否导致此 WindowClause 与其他 WindowClause 重复。* 如果只有 1 个 WindowClause,无需检查去重。*/if (list_length(windowClause) == 1)continue; // 如果只有 1 个 WindowClause,跳过去重检查/** 执行去重检查,如果找到重复的 WindowClause,则重用已有 WindowClause*/foreach(lc3, windowClause) // 遍历所有 WindowClause 进行去重检查{WindowClause *existing_wc = lfirst_node(WindowClause, lc3); // 获取已有 WindowClause/* 跳过当前正在编辑的 WindowClause */if (existing_wc == wc)continue; // 如果是同一个 WindowClause,跳过比较/** 执行与 transformWindowFuncCall 中相同的去重检查*/if (equal(wc->partitionClause, existing_wc->partitionClause) &&equal(wc->orderClause, existing_wc->orderClause) &&wc->frameOptions == existing_wc->frameOptions &&equal(wc->startOffset, existing_wc->startOffset) &&equal(wc->endOffset, existing_wc->endOffset)) // 检查 partitionClause、orderClause 等是否相同{ListCell *lc4; // 声明用于遍历 WindowFunc 的单元指针/** 将 wc 的所有 WindowFunc 移动到 existing_wc。* 需要调整每个 WindowFunc 的 winref,并将 wc 的 WindowFunc 列表* 移动到 existing_wc 的 WindowFunc 列表。*/foreach(lc4, wflists->windowFuncs[wc->winref]) // 遍历当前 WindowClause 的 WindowFunc{WindowFunc *wfunc = lfirst_node(WindowFunc, lc4); // 获取当前 WindowFuncwfunc->winref = existing_wc->winref; // 更新 WindowFunc 的 winref 为 existing_wc 的 winref}/* 移动 WindowFunc 列表 */wflists->windowFuncs[existing_wc->winref] = list_concat(wflists->windowFuncs[existing_wc->winref],wflists->windowFuncs[wc->winref]); // 合并 WindowFunc 列表wflists->windowFuncs[wc->winref] = NIL; // 清空当前 WindowClause 的 WindowFunc 列表/** transformWindowFuncCall 应已确保没有其他重复,* 因此无需继续检查其他 WindowClause*/break; // 找到重复后,退出去重循环}}}}
}
参数:
root
:查询计划器的上下文,包含解析后的查询树(如windowClause
)。wflists
:WindowFuncLists
结构,存储每个WindowClause
关联的WindowFunc
列表,wflists->windowFuncs[winref]
按winref
索引窗口函数。
作用:遍历所有
WindowClause
,为每个WindowClause
检查是否可优化frameOptions
,并在优化后处理重复的WindowClause
。
核心逻辑拆解
函数通过以下步骤实现优化:
- 遍历
WindowClause
:
foreach(lc, windowClause)
{WindowClause *wc = lfirst_node(WindowClause, lc);ListCell *lc2;int optimizedFrameOptions = 0;...
}
- 使用
foreach
宏遍历windowClause
列表,获取每个WindowClause
(wc
)。 - 定义
optimizedFrameOptions
变量,初始化为0
,用于存储支持函数建议的优化框架选项。
- 跳过无
WindowFunc
的WindowClause
:
Assert(wc->winref <= wflists->maxWinRef);
if (wflists->windowFuncs[wc->winref] == NIL)continue;
- 检查
wc->winref
是否有效(不大于wflists->maxWinRef
)。 - 如果
WindowClause
没有关联的WindowFunc
(wflists->windowFuncs[wc->winref] == NIL
),跳过处理。
- 检查每个
WindowFunc
的支持函数:
foreach(lc2, wflists->windowFuncs[wc->winref])
{SupportRequestOptimizeWindowClause req;SupportRequestOptimizeWindowClause *res;WindowFunc *wfunc = lfirst_node(WindowFunc, lc2);Oid prosupport;prosupport = get_func_support(wfunc->winfnoid);if (!OidIsValid(prosupport))break; /* can't optimize this WindowClause */...
}
- 遍历当前
WindowClause
的所有WindowFunc
(通过wflists->windowFuncs[wc->winref]
)。 - 获取每个
WindowFunc
的支持函数OID(prosupport)
,通过get_func_support(wfunc->winfnoid)
。 - 如果
WindowFunc
没有支持函数(prosupport
无效),终止优化,跳到下一个WindowClause
。
- 调用支持函数获取优化
frameOptions
:
req.type = T_SupportRequestOptimizeWindowClause;
req.window_clause = wc;
req.window_func = wfunc;
req.frameOptions = wc->frameOptions;res = (SupportRequestOptimizeWindowClause *)DatumGetPointer(OidFunctionCall1(prosupport, PointerGetDatum(&req)));
if (res == NULL)break; /* skip to next WindowClause */
- 初始化
SupportRequestOptimizeWindowClause
结构(req
),设置类型、当前WindowClause
、窗口函数和当前frameOptions
。 - 调用支持函数(
OidFunctionCall1
),传入req
,获取返回值res
。 - 如果支持函数返回
NULL
(不支持优化),终止优化,跳到下一个WindowClause
。
- 验证所有
WindowFunc
的一致性:
if (foreach_current_index(lc2) == 0)optimizedFrameOptions = res->frameOptions;
else if (optimizedFrameOptions != res->frameOptions)break; /* skip to next WindowClause */
- 对于第一个
WindowFunc
(foreach_current_index(lc2) == 0
),记录其建议的frameOptions
到optimizedFrameOptions
。 - 对于后续
WindowFunc
,检查其建议的frameOptions
是否与第一个一致。如果不一致,终止优化,跳到下一个WindowClause
。
- 应用优化后的
frameOptions
:
if (lc2 == NULL && wc->frameOptions != optimizedFrameOptions)
{wc->frameOptions = optimizedFrameOptions;...
}
- 如果遍历完成(
lc2 == NULL
)且optimizedFrameOptions
与当前frameOptions
不同,更新wc->frameOptions
为优化值(通常为ROWS
模式)。
- 检查和合并重复
WindowClause
:
if (list_length(windowClause) == 1)continue;foreach(lc3, windowClause)
{WindowClause *existing_wc = lfirst_node(WindowClause, lc3);if (existing_wc == wc)continue;if (equal(wc->partitionClause, existing_wc->partitionClause) &&equal(wc->orderClause, existing_wc->orderClause) &&wc->frameOptions == existing_wc->frameOptions &&equal(wc->startOffset, existing_wc->startOffset) &&equal(wc->endOffset, existing_wc->endOffset)){ListCell *lc4;foreach(lc4, wflists->windowFuncs[wc->winref]){WindowFunc *wfunc = lfirst_node(WindowFunc, lc4);wfunc->winref = existing_wc->winref;}wflists->windowFuncs[existing_wc->winref] = list_concat(wflists->windowFuncs[existing_wc->winref],wflists->windowFuncs[wc->winref]);wflists->windowFuncs[wc->winref] = NIL;break;}
}
- 如果
windowClause
列表长度为1
,跳过去重检查。 - 遍历其他
WindowClause
(existing_wc
),检查是否与当前wc
重复(比较partitionClause
、orderClause
、frameOptions
、startOffset
和endOffset
)。 - 如果找到重复:
- 将
wc
的所有WindowFunc
的winref
更新为existing_wc->winref
。 - 合并
wflists->windowFuncs[wc->winref]
到wflists->windowFuncs[existing_wc->winref]
。 - 清空
wflists->windowFuncs[wc->winref]
,标记为NIL
。 - 终止检查(假设
transformWindowFuncCall
已确保无其他重复)。
- 将
函数时序图
新增结构体类型 SupportRequestOptimizeWindowClause
SupportRequestOptimizeWindowClause
是一个新引入的结构体,定义在 src/include/nodes/supportnodes.h
文件中,用于支持 PostgreSQL
查询计划器优化窗口函数的 frameOptions
。该结构体是补丁的核心组件之一,提供了窗口函数支持函数(prosupport
)与计划器之间的接口,允许动态调整 WindowClause
的框架选项(如从 RANGE
切换到更高效的 ROWS
模式)。源码定义如下:
typedef struct SupportRequestOptimizeWindowClause
{NodeTag type; // 节点类型标识,用于 PostgreSQL 的节点系统/* 输入字段: */WindowFunc *window_func; // 指向当前窗口函数的数据结构struct WindowClause *window_clause; // 指向当前窗口子句的数据结构/* 输入/输出字段: */int frameOptions; // 新的 frameOptions 值,若无优化则保持不变
} SupportRequestOptimizeWindowClause;
SupportRequestOptimizeWindowClause
的主要作用是为窗口函数的优化提供一个标准化的请求-响应机制,允许窗口函数的 prosupport
函数(例如 window_row_number_support
)检查并建议优化的 frameOptions
,从而减少执行器的计算开销(如避免同行检查)。
优化后的效果
帧优化 sql 用例
CREATE TABLE t1 (c1 int, c2 int);
INSERT INTO t1 VALUES (1, generate_series(1, 100000));
CREATE INDEX t1_idx ON t1(c1);
EXPLAIN (COSTS OFF, ANALYZE)
SELECT * FROM (SELECT row_number() OVER (ORDER BY c1) rn, * FROM t1
) t WHERE rn < 20;
查询计划输出
QUERY PLAN
-----------------------------------------------------------------------------------WindowAgg (actual time=0.079..0.100 rows=19.00 loops=1)Window: w1 AS (ORDER BY t1.c1 ROWS UNBOUNDED PRECEDING)Run Condition: (row_number() OVER w1 < 20)Storage: Memory Maximum Storage: 17kBBuffers: shared hit=1 read=2-> Index Scan using t1_idx on t1 (actual time=0.065..0.073 rows=20.00 loops=1)Index Searches: 1Buffers: shared hit=1 read=2Planning:Buffers: shared hit=39Planning Time: 0.361 msExecution Time: 0.149 ms
(12 rows)
由执行结果可以看出,通过支持函数将 frameOptions
自动修改为 ROWS
,从而无需判断是否属于同一个同行组,只需按物理行号维护帧边界即可,从而实现了提前终止扫描。底层 Index Scan
仅需读取前 20
行,极大程度的减少了执行时间和资源消耗。
节点去重 sql 用例
CREATE TEMPORARY TABLE empsalary (depname varchar,empno bigint,salary int,enroll_date date
);INSERT INTO empsalary VALUES
('develop', 10, 5200, '2007-08-01'),
('sales', 1, 5000, '2006-10-01'),
('personnel', 5, 3500, '2007-12-10'),
('sales', 4, 4800, '2007-08-08'),
('personnel', 2, 3900, '2006-12-23'),
('develop', 7, 4200, '2008-01-01'),
('develop', 9, 4500, '2008-01-01'),
('sales', 3, 4800, '2007-08-01'),
('develop', 8, 6000, '2006-10-01'),
('develop', 11, 5200, '2007-08-15');-- Ensure WindowClause frameOptions are changed so that only a single
-- WindowAgg exists in the plan.
EXPLAIN (COSTS OFF, VERBOSE)
SELECTempno,depname,row_number() OVER (PARTITION BY depname ORDER BY enroll_date) rn,rank() OVER (PARTITION BY depname ORDER BY enroll_date ROWS BETWEENUNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) rnk,dense_rank() OVER (PARTITION BY depname ORDER BY enroll_date RANGE BETWEENCURRENT ROW AND CURRENT ROW) drnk
FROM empsalary;
查询计划输出
QUERY PLAN
----------------------------------------------------------------------------------------------------------WindowAggOutput: empno, depname, row_number() OVER w1, rank() OVER w1, dense_rank() OVER w1, enroll_dateWindow: w1 AS (PARTITION BY empsalary.depname ORDER BY empsalary.enroll_date ROWS UNBOUNDED PRECEDING)-> SortOutput: depname, enroll_date, empnoSort Key: empsalary.depname, empsalary.enroll_date-> Seq Scan on pg_temp.empsalaryOutput: depname, enroll_date, empno
(8 rows)
由以上执行计划可以发现,PostgreSQL
在进行优化后,可以将多个兼容窗口的帧选项统一重写为等效的 ROWS
模式。此时所有窗口函数共享同一个 WindowClause
,从而合并为一个 WindowAgg
节点。最终执行计划中仅保留一个窗口聚合节点,消除了冗余计算,显著提升了执行效率并简化执行计划结构。
限制与约束
补丁的优化仅适用于特定窗口函数(row_number()
、rank()
、dense_rank()
、percent_rank()
、cume_dist()
、ntile()
),因为这些函数的计算逻辑与 frameOptions
的 RANGE
或 ROWS
模式无关,可以安全切换而不改变结果。以下是详细分析:
结果不受 frameOptions
影响的窗口函数
以下六个窗口函数的语义仅依赖分区和排序顺序,与框架范围无关,因此可安全从 RANGE
优化为 ROWS
模式:
- row_number()
- 逻辑:为分区中的每一行分配递增序号,仅依赖
PARTITION BY
和ORDER BY
定义的顺序。- 补丁说明:在
src/backend/utils/adt/windowfuncs.c
的window_row_number_support
中,明确指出row_number()
“总是逐行递增 1”,无论frameOptions
为RANGE
或ROWS
,结果相同。- 原因:
row_number()
不考虑同行,仅按行序计数,ROWS
模式避免了RANGE
模式检查同行的开销。
- rank()
- 逻辑:为分区中具有相同排序键值的行分配相同的排名,依赖排序顺序。
- 补丁说明:在
window_rank_support
中,rank()
等价于(COUNT(*) OVER (... RANGE UNBOUNDED PRECEDING) - COUNT(*) OVER (... RANGE CURRENT ROW) + 1)
,但结果与框架范围无关,优化为ROWS
模式不影响语义。- 原因:排名基于排序键的分组,
ROWS
模式仍正确处理顺序。
- dense_rank()
- 逻辑:类似
rank()
,但排名无间隙,仅依赖排序键。- 补丁说明:
window_dense_rank_support
指出dense_rank()
不受框架选项影响,优化为ROWS
模式与row_number()
一致。- 原因:与
rank()
类似,ROWS
模式不改变排名逻辑。
- percent_rank()
- 逻辑:计算
(rank - 1) / (total_rows - 1)
,依赖rank()
的逻辑。- 补丁说明:
window_percent_rank_support
确认结果与框架范围无关,优化为ROWS
模式。- 原因:基于
rank()
,仅需排序顺序,ROWS
模式适用。
- cume_dist()
- 逻辑:计算分区中排序键小于或等于当前行的行数比例
(rank / total_rows)
。- 补丁说明:
window_cume_dist_support
指出仅依赖排序顺序,ROWS
模式不影响结果。- 原因:比例计算基于排序键的分组,
ROWS
模式仍有效。
- ntile(n)
- 逻辑:将分区划分为
n
个等份,依赖行数和分区大小。- 补丁说明:
window_ntile_support
确认结果与框架范围无关,优化为ROWS
模式。- 原因:分组基于行序,
ROWS
模式直接适用。
其他窗口函数为何不可优化?
其他窗口函数(如 sum()、avg()、count()、lead()、lag()、first_value()、last_value()
)的结果直接依赖框架范围,切换 RANGE
到 ROWS
可能改变语义:
- sum()、avg()、count()
- 逻辑:这些聚合函数计算框架范围内的值(如
SUM() OVER (... RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) 计算从分区开始到当前行的总和)。- 问题:
RANGE
模式包括所有同行(排序键值相同的行),而ROWS
模式仅包括物理行,可能遗漏同行,导致结果不同。- 示例:若分区有
3
行排序键值相同,RANGE
模式对sum()
包含所有3
行,ROWS
模式可能仅包含当前行,改变总和。
- lead()、lag()
- 逻辑:基于框架内的相对行位置访问前后行。
- 问题:
RANGE
和ROWS
模式定义的框架范围不同,可能导致访问不同行,改变结果。
- first_value()、last_value()
- 逻辑:获取框架范围内的第一行或最后行的值。
- 问题:
RANGE
模式可能包括更多同行,而ROWS
模式仅考虑物理行,改变返回值。
问题思考
观察以下用例
CREATE TABLE sales (sale_id INTEGER,product TEXT,sale_date DATE,amount INTEGER
);INSERT INTO sales VALUES
(1, 'Laptop', '2023-01-01', 1000),
(2, 'Laptop', '2023-02-01', 1200),
(3, 'Laptop', '2023-03-01', 1100),
(4, 'Phone', '2023-01-15', 800),
(5, 'Phone', '2023-02-15', 900),
(6, 'Phone', '2023-03-15', 850);EXPLAIN (COSTS OFF, VERBOSE)
SELECTsale_id,product,sale_date,amount,row_number() OVER w1 AS row_num,rank() OVER w2 AS rank_in_window
FROM sales
WINDOWw1 AS (PARTITION BY product ORDER BY sale_dateROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),W2 AS (PARTITION BY product ORDER BY sale_dateROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING);
执行计划
QUERY PLAN
------------------------------------------------------------------------------------------------------WindowAggOutput: sale_id, product, sale_date, amount, (row_number() OVER w1), rank() OVER w2Window: w2 AS (PARTITION BY sales.product ORDER BY sales.sale_date ROWS UNBOUNDED PRECEDING)-> WindowAggOutput: product, sale_date, sale_id, amount, row_number() OVER w1Window: w1 AS (PARTITION BY sales.product ORDER BY sales.sale_date ROWS UNBOUNDED PRECEDING)-> SortOutput: product, sale_date, sale_id, amountSort Key: sales.product, sales.sale_date-> Seq Scan on public.salesOutput: product, sale_date, sale_id, amount
(11 rows)
从上面的执行计划可以发现,存在的问题是对于语义相同的窗口定义(如示例查询中的 w1
和 w2
,均定义为 PARTITION BY product ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
),由于 optimize_window_clauses
的去重逻辑要求 startOffset
和 endOffset
完全相等(equal(wc->startOffset, existing_wc->startOffset)
),当解析阶段或支持函数导致偏移量不一致时(如一个为 NULL
,另一个为非 NULL
),本应合并为单个 WindowAgg
节点的两个 WindowClause
未能合并,导致查询计划生成了两个独立的 WindowAgg
节点,增加了执行开销。
解决方案思考
问题描述
在 PostgreSQL
中,窗口函数(如 row_number()
、rank()
和 dense_rank()
)的执行依赖 WindowClause
定义的帧框架(frameOptions
)和偏移量(startOffset
和 endOffset
)。对于某些窗口函数(如 row_number()
),其计算结果与帧框架的偏移量值无关,只依赖分区(partitionClause
)和排序(orderClause
)。
然而,在 optimize_window_clauses
函数中,原始去重逻辑要求 startOffset
和 endOffset
严格相等(equal(wc->startOffset, existing_wc->startOffset)
),导致语义相同的窗口定义(如示例查询中的 w1
和 w2
,均定义为 PARTITION BY product ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)因偏移量差异无法合并。
例如,一个窗口的 startOffset
为 NULL
,另一个为非 NULL
(如 Const
节点),会生成两个 WindowAgg
节点,增加执行开销。这种问题在 prosupport
函数未统一设置偏移量或解析阶段(transformWindowFuncCall
)保留非 NULL
偏移量时尤为明显。为解决此问题,我们提出了三种优化方案,逐步改进偏移量管理和去重逻辑。
方案一:支持函数中清空偏移量
方案思考
问题的核心是窗口函数(如 row_number()
)的结果与偏移量无关,应统一为 NULL 以促进去重。一种直接方法是在 prosupport
函数(如 window_row_number_support
)中设置 startOffset
和 endOffset
为 NULL
,并返回优化的 frameOptions
(如 FRAMEOPTION_ROWS | FRAMEOPTION_START_UNBOUNDED_PRECEDING | FRAMEOPTION_END_CURRENT_ROW
)。这可确保偏移量一致,允许合并 w1
和 w2
。修改示例如下:
Datum
window_row_number_support(PG_FUNCTION_ARGS)
{SupportRequestOptimizeWindowClause *req = (SupportRequestOptimizeWindowClause *) PG_GETARG_POINTER(0);if (req->type == T_SupportRequestOptimizeWindowClause){req->frameOptions = FRAMEOPTION_NONDEFAULT |FRAMEOPTION_ROWS |FRAMEOPTION_START_UNBOUNDED_PRECEDING |FRAMEOPTION_END_CURRENT_ROW;req->window_clause->startOffset = NULL;req->window_clause->endOffset = NULL;PG_RETURN_POINTER(req);}PG_RETURN_NULL();
}
但该方法在多个支持函数中重复偏移量逻辑,维护性差。为此,我们探索集中管理方案。
方案二:在优化阶段集中清空偏移量
方案一的分散逻辑降低了代码内聚性,为此,我们考虑将偏移量管理集中到 optimize_window_clauses
,在一致性检查通过(lc2 == NULL
)且 frameOptions
需要更新(wc->frameOptions != optimizedFrameOptions
)时清空偏移量。这减少了支持函数的职责,提高维护性,同时确保偏移量与优化后的 frameOptions
一致。
方案二修改 optimize_window_clauses
,在 if (lc2 == NULL && wc->frameOptions != optimizedFrameOptions)
分支内,更新 wc->frameOptions
后,检查是否包含 FRAMEOPTION_START_OFFSET
或 FRAMEOPTION_END_OFFSET
,若不包含则清空对应偏移量。prosupport
函数仅设置 frameOptions
。这集中管理偏移量,促进示例查询中 w1
和 w2
的合并,生成单个 WindowAgg
节点。但未覆盖 wc->frameOptions == optimizedFrameOptions
的情况,需进一步优化去重逻辑。修改示例如下:
if (lc2 == NULL && wc->frameOptions != optimizedFrameOptions)
{ListCell *lc3;wc->frameOptions = optimizedFrameOptions;if (!(wc->frameOptions & FRAMEOPTION_START_OFFSET)) {wc->startOffset = NULL;}if (!(wc->frameOptions & FRAMEOPTION_END_OFFSET)) {wc->endOffset = NULL;}/* 去重逻辑... */
}
方案三:修改去重条件以忽略偏移量差异
方案三考虑修改 optimize_window_clauses
的去重逻辑,若 frameOptions
不含 FRAMEOPTION_START_OFFSET
或 FRAMEOPTION_END_OFFSET
,忽略对应偏移量的比较。prosupport
函数仅设置 frameOptions
,偏移量由解析阶段管理。这促进示例查询中 w1 和 w2
的合并,减少 WindowAgg
节点。但可能导致语义错误(如 1 PRECEDING
与 UNBOUNDED PRECEDING
合并),需结合方案二清空偏移量以确保一致性。修改示例如下:
foreach(lc3, root->parse->windowClause)
{WindowClause *existing_wc = (WindowClause *) lfirst(lc3);if (wc != existing_wc &&equal(wc->partitionClause, existing_wc->partitionClause) &&equal(wc->orderClause, existing_wc->orderClause) &&wc->frameOptions == existing_wc->frameOptions &&(!(wc->frameOptions & FRAMEOPTION_START_OFFSET) || equal(wc->startOffset, existing_wc->startOffset)) &&(!(wc->frameOptions & FRAMEOPTION_END_OFFSET) || equal(wc->endOffset, existing_wc->endOffset))){/* 合并逻辑 */}
}
优化后执行结果
QUERY PLAN
------------------------------------------------------------------------------------------------WindowAggOutput: sale_id, product, sale_date, amount, row_number() OVER w1, rank() OVER w1Window: w1 AS (PARTITION BY sales.product ORDER BY sales.sale_date ROWS UNBOUNDED PRECEDING)-> SortOutput: product, sale_date, sale_id, amountSort Key: sales.product, sales.sale_date-> Seq Scan on public.salesOutput: product, sale_date, sale_id, amount
(8 rows)
优化后的执行计划将示例查询中的两个 WindowAgg
节点合并为一个,窗口 w1
(定义为 PARTITION BY product ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)同时处理 row_number()
和 rank()
,输出 sale_id
、product
、sale_date
、amount
以及两个窗口函数的结果。
相比原始计划的两个 WindowAgg
节点,新计划通过优化 optimize_window_clauses
的去重逻辑,统一 frameOptions
和偏移量(startOffset
和 endOffset
),成功合并语义相同的 WindowClause
,减少了执行开销,提高了查询性能。