分页优化(键集分页)
LIMIT 20 OFFSET 500000 这种写法在数据量上来后必然会拖垮数据库——因为 OFFSET 要求数据库先读出并丢弃前面所有行。本节从 OFFSET 深分页的代价讲起,重点讲清键集分页(Keyset Pagination / Seek Method)的原理、行值比较语法、第一页与后续页的完整 SQL,并给出 OFFSET、键集分页、延迟关联、服务端游标四种方案的选型对照表。
前置知识
阅读本节前,建议先了解:避免全表扫描
基础概念
分页的本质是「在确定的顺序下定位第 N 段数据」,因此定位方式决定了性能。OFFSET 是「数过去」,键集分页是「跳过去」。
| 概念 | 术语 | 说明 |
|---|---|---|
| 分页 | Pagination | 把结果集切分为多页返回 |
| 偏移量 | Offset | 跳过前面多少行,代价随其线性增长 |
| 键集分页 | Keyset Pagination | 用上一页末尾的键值直接定位下一页起点 |
| 定位法 | Seek Method | 键集分页的别称,强调「定位」而非「跳过」 |
| 行值比较 | Row Value Comparison | 元组比较语法 (a, b) < (x, y) |
| 游标 | Cursor | 指代「上次读到哪里」的标记 |
| 深分页 | Deep Pagination | 页码很大、OFFSET 很大的分页 |
| 延迟关联 | Deferred Join | 先在索引上分页,再回表取完整行 |
| 服务端游标 | Server-side Cursor | DECLARE CURSOR,服务端保持读取位置 |
核心差异:OFFSET 的执行方式决定了它必须产生前面所有行再逐行丢弃,代价 O(offset + limit);键集分页用索引条件直接定位,代价 O(log n + limit),与页码无关。
| 分页方式 | 定位方式 | 时间复杂度 | 能否跳页 | 总数信息 |
|---|---|---|---|---|
LIMIT + OFFSET | 从头数到第 N 行 | O(offset + limit) | 可以任意页 | 可算总页数 |
| 键集分页 | 从索引定位到键值位置 | O(log n + limit) | 只能顺序翻 | 无 |
| 延迟关联 | 索引上分页后回表 | O(offset + limit) 但常数小 | 可以任意页 | 可算 |
| 服务端游标 | 服务端保持位置 | 摊薄为 O(总行数) | 只能顺序 | 无 |
语法与用法
标准流程:首页用 OFFSET 0(或直接 LIMIT),后续页用行值比较键集定位。
-- 第一页:无前置条件,取前 N 行
SELECT id, order_no, amount, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 后续页:把上一页最后一行的排序键传回来,用行值比较定位
SELECT id, order_no, amount, created_at
FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;准备本节贯穿使用的示例表(在 demo 库中执行):
-- 订单表:200 万行,配好与排序一致的复合索引
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL,
customer TEXT NOT NULL,
status TEXT NOT NULL, -- pending / paid / shipped / closed
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
created_at TIMESTAMPTZ NOT NULL
);
INSERT INTO orders (order_no, customer, status, amount, created_at)
SELECT
'SO-' || lpad(i::text, 8, '0'),
'user_' || (i % 100000),
(ARRAY['pending', 'paid', 'shipped', 'closed'])[1 + (i % 4)],
round((random() * 5000 + 10)::numeric, 2),
TIMESTAMPTZ '2025-01-01 00:00:00+08' + (i * INTERVAL '30 seconds')
FROM generate_series(1, 2000000) AS i;
-- 关键:复合索引的列顺序与方向必须与 ORDER BY 完全一致
CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);
ANALYZE orders;-- 200 万行写入后的规模参考
SELECT count(*) AS 总行数, pg_size_pretty(pg_total_relation_size('orders')) AS 总大小
FROM orders;
总行数 | 总大小
----------+--------
2000000 | 289 MB
(1 row)最小可运行对照示例(同一「第 25000 页」位置):
-- 写法一:OFFSET 深分页(慢)
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 500000;
-- 写法二:键集分页(快且恒定)
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount
FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2025-06-19 07:20:00+08', 1500000)
ORDER BY created_at DESC, id DESC
LIMIT 20;-- 写法一:500000 行被读出后丢弃
Limit
-> Index Scan using idx_orders_created_id on orders
Index Cond: true
Rows Removed by Filter: 0
Buffers: shared hit=15302 read=14781 -- 读取约 3 万页
Execution Time: 512.884 ms
-- 写法二:只读取 20 行
Limit
-> Index Scan using idx_orders_created_id on orders
Index Cond: (ROW(created_at, id) < ROW('2025-06-19 07:20:00+08'::timestamptz, 1500000))
Buffers: shared hit=25 -- 恒定 25 页
Execution Time: 0.041 ms详细说明
LIMIT/OFFSET 深分页的真实代价
OFFSET 的代价在于读取并丢弃。执行计划里 Index Scan 的 rows= 会明确显示出这一点:OFFSET 500000 时索引实际返回 500020 行。
-- 首页:几乎零成本
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0; Limit
-> Index Scan using idx_orders_created_id on orders
Buffers: shared hit=24
Execution Time: 0.038 ms-- 深分页:OFFSET 500000
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 500000; Limit
-> Index Scan using idx_orders_created_id on orders
Buffers: shared hit=15302 read=14781
Execution Time: 512.884 ms-- OFFSET 与耗时的近似关系(200 万行表,已建索引)
OFFSET | 实际读取行数 | shared read | 执行时间
----------+--------------+-------------+-----------
0 | 20 | 0 | 0.04 ms
1000 | 1020 | 13 | 1.1 ms
100000 | 100020 | 2951 | 102.4 ms
500000 | 500020 | 14781 | 512.9 ms
1500000 | 1500020 | 44402 | 1541.2 ms关键洞察:即使走了索引、没有回表,代价仍随 OFFSET 线性增长——因为索引条目也要逐条读取、逐条丢弃。这就解释了为什么「已经建了索引,翻到后面还是慢」。
键集分页的原理与行值比较语法
键集分页的核心:不用「跳过多少行」,而用「从哪个位置继续」。把上一页最后一行的排序键记下来,下一页用 WHERE 条件直接定位。
行值比较(Row Value Comparison)是 PostgreSQL 支持的元组比较语法:
(a, b) < (x, y) ≡ a < x OR (a = x AND b < y)-- 两种等价写法(后者更易出错)
-- 展开写法
SELECT id FROM orders
WHERE created_at < TIMESTAMPTZ '2025-06-19 07:20:00+08'
OR (created_at = TIMESTAMPTZ '2025-06-19 07:20:00+08' AND id < 1500000)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 推荐:行值比较,语义清晰且优化器能更好利用复合索引
SELECT id FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2025-06-19 07:20:00+08', 1500000)
ORDER BY created_at DESC, id DESC
LIMIT 20;-- 验证:两种写法结果完全一致,用 EXCEPT 双向差集确认
WITH a AS (
SELECT id FROM orders
WHERE created_at < TIMESTAMPTZ '2025-06-19 07:20:00+08'
OR (created_at = TIMESTAMPTZ '2025-06-19 07:20:00+08' AND id < 1500000)
ORDER BY created_at DESC, id DESC LIMIT 20
),
b AS (
SELECT id FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2025-06-19 07:20:00+08', 1500000)
ORDER BY created_at DESC, id DESC LIMIT 20
)
SELECT '仅展开写法' AS 差异, id FROM (SELECT * FROM a EXCEPT SELECT * FROM b) x
UNION ALL
SELECT '仅行值写法', id FROM (SELECT * FROM b EXCEPT SELECT * FROM a) y; 差异 | id
--------------+----
(0 rows) -- 双向差集为空,两种写法等价必须让排序键唯一
键集分页的游标必须能唯一确定位置,否则边界处会重复或遗漏。若排序键不唯一,必须追加一个唯一列(通常是主键 id)。
-- 错误!created_at 可能有重复值,边界处无法唯一定位
SELECT id, created_at FROM orders
WHERE created_at < TIMESTAMPTZ '2025-06-19 07:20:00+08'
ORDER BY created_at DESC
LIMIT 20;
-- 多行共享同一 created_at 时,下一页可能重复或漏掉这些行
-- 正确:追加唯一主键 id,构成稳定排序键
SELECT id, created_at FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2025-06-19 07:20:00+08', 1500000)
ORDER BY created_at DESC, id DESC
LIMIT 20;-- 检查排序键是否唯一:不同值个数应等于总行数
SELECT
count(*) AS 总行数,
count(DISTINCT created_at) AS 时间不同值数
FROM orders; 总行数 | 时间不同值数
----------+-------------
2000000 | 2000000 -- 本例每条时间都不同,但真实业务很少如此,仍应加 id 兜底
(1 row)复合索引必须与排序键完全一致
键集分页能否高效,取决于存在一个列顺序与方向都与 ORDER BY 匹配的索引。少了任何一列、方向反了、多了中间列,索引都无法直接用于定位。
-- 正确:列顺序、方向与 ORDER BY created_at DESC, id DESC 完全一致
CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2025-06-19 07:20:00+08', 1500000)
ORDER BY created_at DESC, id DESC
LIMIT 20; Limit
-> Index Scan using idx_orders_created_id on orders
Index Cond: (ROW(created_at, id) < ROW(...))
Buffers: shared hit=25
Execution Time: 0.041 ms-- 反例:索引只含 created_at,缺少 id,需要额外排序
CREATE INDEX idx_orders_created_only ON orders (created_at DESC);
-- 用这个索引做行值比较时,优化器仍需 Sort 节点确认顺序
-- 计划里会出现 Sort,且无法用 Index Cond 直接定位到 id| 索引定义 | 能否支撑 (created_at, id) < (?, ?) | 说明 |
|---|---|---|
(created_at DESC, id DESC) | 是 | 与 ORDER BY 完全匹配 |
(created_at, id)(默认 ASC) | 是 | 反向扫描即可,代价略高 |
(created_at DESC) | 否 | 缺少 id,无法唯一定位 |
(id DESC, created_at DESC) | 否 | 列顺序与排序键不一致 |
(status, created_at DESC, id DESC) | 部分 | 需同时带 status 等值条件 |
第一页与后续页的完整 SQL
真实接口需要区分「第一页」和「后续页」,因为首页没有游标。下面给出可直接用于接口的两段 SQL。
-- 第一页:没有游标,直接取前 N+1 行(多取 1 行用于判断是否有下一页)
SELECT id, order_no, customer, amount, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 21; -- 页大小 20,多取 1 id | order_no | customer | amount | created_at
--------+------------+-----------+---------+-----------------------
2000000| SO-02000000| user_0 | 4312.10 | 2026-11-20 14:26:40+08
1999999| SO-01999999| user_99999| 872.55 | 2026-11-20 14:26:10+08
...
(21 rows) -- 返回 21 条,说明还有下一页;对外只返回前 20 条-- 后续页:前端把上一页最后一条的 (created_at, id) 传回
-- 参数::cursor_created_at = '2026-11-20 14:14:50+08',:cursor_id = 1999980
SELECT id, order_no, customer, amount, created_at
FROM orders
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 21; id | order_no | customer | amount | created_at
--------+------------+------------+---------+-----------------------
1999979| SO-01999979| user_99979 | 2318.00 | 2026-11-20 14:14:20+08
...
(20 rows) -- 返回 ≤ 20 条,说明已到末页游标传递约定
对外接口返回时,除数据列表外应额外返回 next_cursor = { created_at, id },值为本页最后一行的排序键;前端下一次请求原样带回。末页时 next_cursor 置为 null。
带筛选条件的键集分页
真实列表往往带 WHERE 条件,此时索引要同时覆盖筛选列和排序键。
-- 需求:按状态筛选 + 按时间倒序分页
CREATE INDEX idx_orders_status_created_id
ON orders (status, created_at DESC, id DESC);
-- 第一页
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20; Limit
-> Index Scan using idx_orders_status_created_id on orders
Index Cond: (status = 'paid'::text)
Buffers: shared hit=23
Execution Time: 0.052 ms-- 深分页(OFFSET 方式):代价随偏移线性增长
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 400000; Limit
-> Index Scan using idx_orders_status_created_id on orders
Index Cond: (status = 'paid'::text)
Buffers: shared hit=14623 read=9768
Execution Time: 428.114 ms-- 后续页(键集方式):恒定代价
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount
FROM orders
WHERE status = 'paid'
AND (created_at, id) < (TIMESTAMPTZ '2025-07-01 12:00:00+08', 800000)
ORDER BY created_at DESC, id DESC
LIMIT 20; Limit
-> Index Scan using idx_orders_status_created_id on orders
Index Cond: ((status = 'paid'::text) AND (ROW(created_at, id) < ROW(...)))
Buffers: shared hit=25
Execution Time: 0.048 ms -- 与第几页无关降级方案对比与适用边界
键集分页不是银弹,它有两个硬性局限:不能跳页(无法直接打开第 100 页)、无法得到总页数。因此需要按业务场景选择方案。
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
LIMIT + OFFSET | 后台管理、页数少(< 100 页) | 可实现跳页、可算总数 | 深分页线性劣化 |
| 键集分页 | 信息流、无限滚动、大数据导出 | 恒定代价、稳定 | 不能跳页、无总数 |
| 延迟关联 | 需要 OFFSET 但表很宽 | 先在窄索引上分页,回表少 | 仍随 OFFSET 增长 |
| 服务端游标 | 单次会话内批量处理 | 位置服务端保存、只扫一次 | 占用连接、不能跨请求 |
| 物化游标(前端传游标) | 长列表导出 | 类似键集、可控内存 | 需前端配合 |
延迟关联(Deferred Join)的思路:先在只含排序列的索引上做分页,拿到主键后再回表取完整行,减少回表次数。
-- 原始写法:先读全部列,再丢弃 400000 行(回表 400020 次)
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 400000;
-- 延迟关联:先在索引上分页拿 id,回表只取 20 行
SELECT o.*
FROM orders o
JOIN (
SELECT id FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 400000
) t ON t.id = o.id
ORDER BY o.created_at DESC, o.id DESC;服务端游标(DECLARE CURSOR)适合单次会话内的批量处理,避免一次性把全表读进内存:
-- 必须在事务内使用(除非 WITH HOLD)
BEGIN;
DECLARE cur_orders CURSOR FOR
SELECT id, order_no, amount FROM orders
ORDER BY created_at DESC, id DESC;
-- 每次取一批,反复执行直到取空
FETCH FORWARD 1000 FROM cur_orders;
COMMIT; -- 事务结束游标自动关闭,或显式 CLOSE cur_orders| 游标特性 | 说明 |
|---|---|
| 事务边界 | 默认游标生命周期绑定事务,COMMIT 后失效 |
WITH HOLD | 让游标跨事务存活(代价是结果会被物化) |
SCROLL | 允许 FETCH BACKWARD,但会物化整个结果集 |
| 适用规模 | 大批量导出、批处理,不适合高并发的 Web 请求 |
实战示例
步骤 1:准备一个真实规模的场景
-- 业务需求:订单列表,支持状态筛选,按时间倒序,无限滚动
-- 页大小 20,需要支持翻到很后面的页
CREATE INDEX idx_orders_status_created_id
ON orders (status, created_at DESC, id DESC);
ANALYZE orders;步骤 2:第一页查询
-- 页大小 20,多取 1 行判断是否还有下一页
SELECT id, order_no, customer, amount, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 21; id | order_no | customer | amount | created_at
---------+------------+------------+---------+-----------------------
1999996 | SO-01999996| user_99996 | 3120.00 | 2026-11-20 14:26:10+08
1999992 | SO-01999992| user_99992 | 512.30 | 2026-11-20 14:24:10+08
...
(21 rows) -- 返回 21 条 → 还有下一页;对外返回前 20 条 + next_cursor步骤 3:深分页性能对比(OFFSET 0 与 OFFSET 500000)
-- 起点:OFFSET 0
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0; Limit
-> Index Scan using idx_orders_status_created_id on orders
Index Cond: (status = 'paid'::text)
Buffers: shared hit=23
Execution Time: 0.058 ms-- 深分页:OFFSET 500000(注意过滤条件是 paid,只有 50 万行匹配,已接近末端)
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 400000; Limit
-> Index Scan using idx_orders_status_created_id on orders
Index Cond: (status = 'paid'::text)
Buffers: shared hit=14623 read=9768
Execution Time: 428.114 ms| 指标 | OFFSET 0 | OFFSET 400000 | 倍率 |
|---|---|---|---|
| 执行时间 | 0.058 ms | 428.1 ms | 7381× |
Buffers | 23 页 | 24391 页 | 1060× |
| 实际读取行数 | 20 | 400020 | — |
步骤 4:同一位置的键集分页
-- 用「第 400020 行的位置」作为游标:created_at = '2025-07-01 12:00:00+08', id = 800000
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount FROM orders
WHERE status = 'paid'
AND (created_at, id) < (TIMESTAMPTZ '2025-07-01 12:00:00+08', 800000)
ORDER BY created_at DESC, id DESC
LIMIT 20; Limit
-> Index Scan using idx_orders_status_created_id on orders
Index Cond: ((status = 'paid'::text) AND (ROW(created_at, id) < ROW(...)))
Buffers: shared hit=25
Execution Time: 0.048 ms-- 键集分页在不同「页位置」的代价始终恒定
模拟页位 | Buffers (shared hit) | 执行时间
-----------+----------------------+-----------
第 1 页 | 23 | 0.052 ms
第 500 页 | 25 | 0.048 ms
第 25000 页 | 25 | 0.048 ms
-- 无论翻到第几页,shared read 恒为 25 页左右步骤 5:验证翻页不重不漏
-- 用 CTE 模拟连续翻 3 页,验证页与页之间没有重复行
WITH p1 AS (
SELECT id FROM orders WHERE status = 'paid'
ORDER BY created_at DESC, id DESC LIMIT 20
),
p2 AS (
SELECT id FROM orders WHERE status = 'paid'
AND (created_at, id) < (SELECT created_at FROM orders WHERE id = (SELECT min(id) FROM p1),
(SELECT min(id) FROM p1))
ORDER BY created_at DESC, id DESC LIMIT 20
),
overlap AS (
SELECT id FROM p1 INTERSECT SELECT id FROM p2
)
SELECT count(*) AS 重复行数 FROM overlap; 重复行数
----------
0 -- 无重复
(1 row)-- 更实用的自检:用窗口函数给全量打上行号,检查每页边界
SELECT
row_number() OVER (ORDER BY created_at DESC, id DESC) AS rn,
id, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 25; -- 查看第 20、21 行(即第一页与第二页的边界)注意事项
OFFSET越大越慢是必然的,不要指望「加个索引就不慢了」:sql-- 错误认知:以为有索引 OFFSET 就不慢 SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 1000000;- 排序键必须唯一,否则边界处会重复或遗漏:sql
-- 错误!created_at 可能重复,需追加唯一列 WHERE created_at < :ts ORDER BY created_at DESC LIMIT 20; - 复合索引的列顺序与方向必须与
ORDER BY完全一致,否则仍要走Sort:sql-- 不推荐:索引 (id DESC, created_at DESC) 与 ORDER BY created_at DESC, id DESC 不匹配 - 不要用
ctid作为跨请求的持久游标,行更新或VACUUM FULL会让它失效:sql-- 错误!行更新后 ctid 改变,游标指向的位置不再有效 WHERE ctid > '(0,100)' ORDER BY ctid LIMIT 20; - 键集分页无法跳页、无法得知总页数,若产品必须显示总页数,改用延迟关联或估算行数:sql
-- 估算总数(不精确但极快) SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders'; - 不要把游标的键值拼进 SQL 字符串,必须用参数化查询,否则有注入风险:sql
-- 错误!字符串拼接 -- "... WHERE (created_at, id) < ('" + cursor + "')" LIMIT必须配ORDER BY,否则返回行不确定,翻页会错乱:sql-- 错误!两次执行可能返回不同行 SELECT id FROM orders LIMIT 20 OFFSET 20;- 页大小不要设得过大,
LIMIT 10000会拖慢首屏;信息流一般 20~50 行足够。 - 服务端游标必须在事务内,
COMMIT后默认失效;高并发 Web 场景不要用游标:sql-- 错误!事务外使用游标会报错 -- DECLARE cur CURSOR FOR SELECT ...; - 翻页过程中若有新数据插入,键集分页相对稳定但仍可能出现「新数据看不到」,这是可接受的一致性问题;若要求强一致,需配合快照或时间窗过滤。
最佳实践
- 首页用
LIMIT n+1,后续页用键集分页:多取 1 行判断是否有下一页,避免额外count(*)。 - 游标用「排序键元组」而非单个
OFFSET,并把(created_at, id)一起传给前端。 - 为每个分页排序模式建专用复合索引,列顺序 =
WHERE等值列(在前)+ORDER BY列(在后),方向与ORDER BY一致。 - 始终在排序键末尾追加唯一列(如主键),保证游标唯一确定位置。
- 接口层面同时支持两种模式:前几页用
OFFSET(方便跳页、展示总数),深度浏览用键集分页。 - 对外只暴露「下一页游标」,不暴露数据库主键语义,游标字段建议编码后传输,避免暴露内部结构。
- 表很宽时用延迟关联:先在窄索引上分页拿主键,再回表取完整行,显著减少回表次数。
- 批量导出用
DECLARE CURSOR+ 分批FETCH,避免一次性把大结果集加载到应用内存。 - 用
pg_stat_statements监控分页查询,重点关注shared_blks_read是否随页码异常增长:sqlSELECT calls, mean_exec_time, rows, shared_blks_read, query FROM pg_stat_statements WHERE query ILIKE '%order by%limit%' AND query ILIKE '%offset%' ORDER BY mean_exec_time DESC LIMIT 10; - 压测时按最坏页位(最深的页)验证,首页快不代表分页方案没问题;用
EXPLAIN (ANALYZE, BUFFERS)对比最深页与首页的Buffers。
下一节
继续学习:批量操作优化