在 PostgreSQL 里声明一个游标(cursor)然后逐行 fetch,看起来是再正常不过的操作。但很多人没意识到:一旦查询走游标路径,优化器会默认假设你只打算读取结果集的 10%,并据此选择"快速返回前几行"的执行计划。如果你实际上会把游标拉到底,这个假设会让整条查询跑得比普通 SELECT 还慢。
游标路径下的优化器逻辑
PostgreSQL 对普通 SELECT 和游标查询的规划策略截然不同:
- 普通 SELECT:优化器目标是最小化总执行时间,倾向于选择全表扫描或更高效的批量访问路径。
- 游标查询(
DECLARE CURSOR ... SELECT或FETCH风格):优化器认为用户只关心前几行尽快出来,于是偏好索引扫描等"启动快、总成本高"的计划。
这个行为由 GUC 参数 cursor_tuple_fraction 控制,默认值是 0.1,即优化器假设你只会取结果集的 10%。值越小,优化器越偏向"快速起步";值越大,越接近普通 SELECT 的总成本最小化策略。
一个能直观感受差异的例子
下面用 pgbench 数据库做演示。如果你没有现成的 pgbench 数据库,可以先初始化一个:
# 初始化 pgbench 数据库,scale=10 大约产生 100 万行 accounts 表
pgbench -i -s 10 pgbench
然后在 psql 中对比同一查询在游标模式和普通模式下的执行计划:
-- 查看普通 SELECT 的计划(总成本最小化)
EXPLAIN (COSTS ON) SELECT * FROM pgbench_accounts WHERE bid BETWEEN 1 AND 100;
-- 查看游标模式下的计划(默认 cursor_tuple_fraction=0.1)
BEGIN;
DECLARE c1 CURSOR FOR SELECT * FROM pgbench_accounts WHERE bid BETWEEN 1 AND 100;
EXPLAIN (COSTS ON) FETCH 1000 FROM c1;
CLOSE c1;
COMMIT;
你会看到游标版本更可能走索引扫描,启动成本低但每行访问成本高。当你要 fetch 全部结果时,总耗时反而比全表扫描更长。
调整 cursor_tuple_fraction 让计划回归理性
如果你确定游标会读取大部分甚至全部结果,可以在会话级别调高这个参数:
-- 假设你会读取游标结果的 80%,把 fraction 设为 0.8
SET cursor_tuple_fraction = 0.8;
BEGIN;
DECLARE c2 CURSOR FOR SELECT * FROM pgbench_accounts WHERE bid BETWEEN 1 AND 100;
-- 现在优化器会更倾向选择总成本低的计划
EXPLAIN (COSTS ON) FETCH ALL FROM c2;
CLOSE c2;
COMMIT;
也可以在事务块内局部设置,不影响其他会话:
BEGIN;
SET LOCAL cursor_tuple_fraction = 1.0; -- 等同于普通 SELECT 的规划策略
DECLARE c3 CURSOR FOR SELECT * FROM pgbench_accounts WHERE bid BETWEEN 1 AND 100;
FETCH ALL FROM c3;
CLOSE c3;
COMMIT; -- SET LOCAL 在 COMMIT 后自动恢复默认值
用 SET LOCAL 的好处是事务结束后参数自动回退,不会污染后续操作。
什么时候该动这个参数
几个典型场景值得考虑调整:
| 场景 | 建议值 | 原因 |
|---|---|---|
| 游标只取前 N 行做分页预览 | 0.01–0.1(默认即可) | 快速返回前几行正是你想要的 |
| 游标逐行处理全量结果做 ETL | 0.8–1.0 | 总吞吐比首行延迟重要得多 |
| 游标批量导出报表数据 | 1.0 | 你一定会读到底,用总成本最优计划 |
| 不确定会读多少行 | 先用默认值,遇到慢查询再调 | 盲目调高可能让首屏响应变差 |
一个实用的排查思路:如果你发现某条游标查询整体比去掉游标后直接跑 SELECT 慢很多,先对比两者的 EXPLAIN 输出。如果游标版本走了不合理的索引扫描,大概率就是 cursor_tuple_fraction 在作怪。
代价与边界
调高 cursor_tuple_fraction 不是免费午餐:
- 首行延迟会增大:优化器不再优先让前几行快速返回,用户感知的"响应速度"可能变差。
- 它只影响游标路径:普通
SELECT、CTE、子查询都不受这个参数影响,不要指望靠它优化非游标查询。 - 应用层游标模拟不受影响:有些应用用
LIMIT/OFFSET或SELECT ... OFFSET n模拟游标分页,这些走的是普通 SELECT 路径,cursor_tuple_fraction对它们无效。
检查清单
下次遇到游标查询性能问题,可以按这个顺序排查:
- 用
EXPLAIN对比游标和普通 SELECT 的计划差异,确认是否走了不同路径。 - 评估业务上游标实际会读取多少比例的行——是只看前几条,还是会拉到底?
- 如果全量读取,用
SET LOCAL cursor_tuple_fraction = 1.0在事务内调整,观察总耗时变化。 - 如果只是分页预览,保持默认值,但考虑是否真的需要游标——
LIMIT/OFFSET可能更简单。
cursor_tuple_fraction 是一个容易被忽略的参数,但它直接决定了游标查询的执行计划走向。搞清楚你的游标到底会读多少行,再决定让优化器站在哪一边。