Skip to content

分页优化(键集分页) ​

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 CursorDECLARE 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),后续页用行值比较键集定位。

sql
-- 第一页:无前置条件,取前 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 库中执行):

sql
-- 订单表: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;
text
-- 200 万行写入后的规模参考
SELECT count(*) AS 总行数, pg_size_pretty(pg_total_relation_size('orders')) AS 总大小
FROM orders;
  总行数  | 总大小
----------+--------
  2000000 | 289 MB
(1 row)

最小可运行对照示例(同一「第 25000 页」位置):

sql
-- 写法一: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;
text
-- 写法一: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 行。

sql
-- 首页:几乎零成本
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, order_no, amount FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
text
 Limit
   ->  Index Scan using idx_orders_created_id on orders
         Buffers: shared hit=24
 Execution Time: 0.038 ms
sql
-- 深分页: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;
text
 Limit
   ->  Index Scan using idx_orders_created_id on orders
         Buffers: shared hit=15302 read=14781
 Execution Time: 512.884 ms
text
-- 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 支持的元组比较语法:

text
(a, b) < (x, y)  ≡  a < x OR (a = x AND b < y)
sql
-- 两种等价写法(后者更易出错)
-- 展开写法
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;
sql
-- 验证:两种写法结果完全一致,用 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;
text
     差异     | id
--------------+----
(0 rows)      -- 双向差集为空,两种写法等价

必须让排序键唯一 ​

键集分页的游标必须能唯一确定位置,否则边界处会重复或遗漏。若排序键不唯一,必须追加一个唯一列(通常是主键 id)。

sql
-- 错误!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;
sql
-- 检查排序键是否唯一:不同值个数应等于总行数
SELECT
  count(*)                          AS 总行数,
  count(DISTINCT created_at)        AS 时间不同值数
FROM orders;
text
  总行数  | 时间不同值数
----------+-------------
  2000000 |     2000000    -- 本例每条时间都不同,但真实业务很少如此,仍应加 id 兜底
(1 row)

复合索引必须与排序键完全一致 ​

键集分页能否高效,取决于存在一个列顺序与方向都与 ORDER BY 匹配的索引。少了任何一列、方向反了、多了中间列,索引都无法直接用于定位。

sql
-- 正确:列顺序、方向与 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;
text
 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
sql
-- 反例:索引只含 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。

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
text
  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 条
sql
-- 后续页:前端把上一页最后一条的 (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;
text
  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 条件,此时索引要同时覆盖筛选列和排序键。

sql
-- 需求:按状态筛选 + 按时间倒序分页
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;
text
 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
sql
-- 深分页(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;
text
 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
sql
-- 后续页(键集方式):恒定代价
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;
text
 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)的思路:先在只含排序列的索引上做分页,拿到主键后再回表取完整行,减少回表次数。

sql
-- 原始写法:先读全部列,再丢弃 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)适合单次会话内的批量处理,避免一次性把全表读进内存:

sql
-- 必须在事务内使用(除非 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:准备一个真实规模的场景 ​

sql
-- 业务需求:订单列表,支持状态筛选,按时间倒序,无限滚动
-- 页大小 20,需要支持翻到很后面的页
CREATE INDEX idx_orders_status_created_id
  ON orders (status, created_at DESC, id DESC);
ANALYZE orders;

步骤 2:第一页查询 ​

sql
-- 页大小 20,多取 1 行判断是否还有下一页
SELECT id, order_no, customer, amount, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 21;
text
   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) ​

sql
-- 起点: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;
text
 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
sql
-- 深分页: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;
text
 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 0OFFSET 400000倍率
执行时间0.058 ms428.1 ms7381×
Buffers23 页24391 页1060×
实际读取行数20400020—

步骤 4:同一位置的键集分页 ​

sql
-- 用「第 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;
text
 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
text
-- 键集分页在不同「页位置」的代价始终恒定
 模拟页位  | Buffers (shared hit) | 执行时间
-----------+----------------------+-----------
 第 1 页   | 23                   | 0.052 ms
 第 500 页 | 25                   | 0.048 ms
 第 25000 页 | 25                 | 0.048 ms
-- 无论翻到第几页,shared read 恒为 25 页左右

步骤 5:验证翻页不重不漏 ​

sql
-- 用 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;
text
 重复行数
----------
        0   -- 无重复
(1 row)
sql
-- 更实用的自检:用窗口函数给全量打上行号,检查每页边界
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 行(即第一页与第二页的边界)

注意事项 ​

  1. OFFSET 越大越慢是必然的,不要指望「加个索引就不慢了」:
    sql
    -- 错误认知:以为有索引 OFFSET 就不慢
    SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 1000000;
  2. 排序键必须唯一,否则边界处会重复或遗漏:
    sql
    -- 错误!created_at 可能重复,需追加唯一列
    WHERE created_at < :ts ORDER BY created_at DESC LIMIT 20;
  3. 复合索引的列顺序与方向必须与 ORDER BY 完全一致,否则仍要走 Sort:
    sql
    -- 不推荐:索引 (id DESC, created_at DESC) 与 ORDER BY created_at DESC, id DESC 不匹配
  4. 不要用 ctid 作为跨请求的持久游标,行更新或 VACUUM FULL 会让它失效:
    sql
    -- 错误!行更新后 ctid 改变,游标指向的位置不再有效
    WHERE ctid > '(0,100)' ORDER BY ctid LIMIT 20;
  5. 键集分页无法跳页、无法得知总页数,若产品必须显示总页数,改用延迟关联或估算行数:
    sql
    -- 估算总数(不精确但极快)
    SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';
  6. 不要把游标的键值拼进 SQL 字符串,必须用参数化查询,否则有注入风险:
    sql
    -- 错误!字符串拼接
    -- "... WHERE (created_at, id) < ('" + cursor + "')"
  7. LIMIT 必须配 ORDER BY,否则返回行不确定,翻页会错乱:
    sql
    -- 错误!两次执行可能返回不同行
    SELECT id FROM orders LIMIT 20 OFFSET 20;
  8. 页大小不要设得过大,LIMIT 10000 会拖慢首屏;信息流一般 20~50 行足够。
  9. 服务端游标必须在事务内,COMMIT 后默认失效;高并发 Web 场景不要用游标:
    sql
    -- 错误!事务外使用游标会报错
    -- DECLARE cur CURSOR FOR SELECT ...;
  10. 翻页过程中若有新数据插入,键集分页相对稳定但仍可能出现「新数据看不到」,这是可接受的一致性问题;若要求强一致,需配合快照或时间窗过滤。

最佳实践 ​

  1. 首页用 LIMIT n+1,后续页用键集分页:多取 1 行判断是否有下一页,避免额外 count(*)。
  2. 游标用「排序键元组」而非单个 OFFSET,并把 (created_at, id) 一起传给前端。
  3. 为每个分页排序模式建专用复合索引,列顺序 = WHERE 等值列(在前)+ ORDER BY 列(在后),方向与 ORDER BY 一致。
  4. 始终在排序键末尾追加唯一列(如主键),保证游标唯一确定位置。
  5. 接口层面同时支持两种模式:前几页用 OFFSET(方便跳页、展示总数),深度浏览用键集分页。
  6. 对外只暴露「下一页游标」,不暴露数据库主键语义,游标字段建议编码后传输,避免暴露内部结构。
  7. 表很宽时用延迟关联:先在窄索引上分页拿主键,再回表取完整行,显著减少回表次数。
  8. 批量导出用 DECLARE CURSOR + 分批 FETCH,避免一次性把大结果集加载到应用内存。
  9. 用 pg_stat_statements 监控分页查询,重点关注 shared_blks_read 是否随页码异常增长:
    sql
    SELECT 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;
  10. 压测时按最坏页位(最深的页)验证,首页快不代表分页方案没问题;用 EXPLAIN (ANALYZE, BUFFERS) 对比最深页与首页的 Buffers。

下一节

继续学习:批量操作优化

参考链接 ​