Skip to content

分页(LIMIT / OFFSET) ​

分页是列表类业务的刚需。本节讲解 LIMIT 与 OFFSET 语法、LIMIT ALL、标准语法 FETCH FIRST ... ROWS ONLY、OFFSET 深分页的性能问题及原理、键集分页(Keyset Pagination) 方案、用 ctid 或唯一键做游标、分页总数统计方案,以及 LIMIT 必须与 ORDER BY 配合的原因。学完本节,你将能设计出在千万级数据上依然稳定的分页方案。

前置知识

阅读本节前,建议先了解:排序(ORDER BY)

基础概念 ​

分页的本质是"在确定的顺序下,取第 N 段数据"。因此它天然依赖排序,而分页方案的选择直接取决于如何定位起始位置。

术语英文说明
分页Pagination把结果集切分为多页返回
偏移量Offset跳过前面多少行
键集分页Keyset Pagination用上一页末尾的键值定位下一页起点
游标Cursor指代"上次读到哪里"的标记
深分页Deep Pagination页码很大、OFFSET 很大的分页
行值比较Row Value Comparison元组比较语法 (a, b) < (x, y)
顺序扫描Seq Scan逐行读取,OFFSET 大时的主要代价来源

OFFSET 的性能问题源于执行方式:它必须先产生前面所有行,再逐行丢弃。因此 OFFSET 1000000 意味着要先扫描并丢弃 100 万行。

分页方式定位方式时间复杂度能否跳页
LIMIT + OFFSET从头数到第 N 行O(offset + limit)可以
键集分页从索引定位到键值位置O(log n + limit)只能顺序翻
游标(DECLARE CURSOR)服务端保持位置摊薄为 O(总行数)只能顺序
ctid 游标用物理位置定位O(log n + limit)只能顺序

语法与用法 ​

text
-- 写法一:MySQL 风格的 LIMIT / OFFSET
SELECT ... FROM ... [ORDER BY ...]
LIMIT 行数 [OFFSET 偏移量];

-- 写法二:SQL 标准语法
SELECT ... FROM ... [ORDER BY ...]
OFFSET 偏移量 ROWS
FETCH { FIRST | NEXT } 行数 { ROW | ROWS } ONLY;

准备本节贯穿使用的示例表:

sql
-- 订单表:用 created_at + id 组合作为稳定的分页排序键
CREATE TABLE orders (
  id         INTEGER       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_no   TEXT          NOT NULL,
  customer   TEXT          NOT NULL,
  amount     NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
  status     TEXT          NOT NULL,
  created_at TIMESTAMPTZ   NOT NULL
);

-- 造一批数据便于观察分页行为
INSERT INTO orders (order_no, customer, amount, status, created_at)
SELECT
  'SO-' || lpad(i::text, 6, '0'),
  'user_' || (i % 100),
  round((random() * 5000)::numeric, 2),
  (ARRAY['pending','paid','shipped','closed'])[1 + (i % 4)],
  TIMESTAMPTZ '2026-01-01' + (i || ' minutes')::interval
FROM generate_series(1, 100000) AS g(i);

-- 分页必备索引(排序键顺序与查询一致)
CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);

三种等价的写法:

sql
-- 1. MySQL 风格(PostgreSQL 支持)
SELECT id, order_no, amount
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;

-- 2. SQL 标准语法(推荐,可移植性更好)
SELECT id, order_no, amount
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;

-- 3. LIMIT ALL:不限制行数,等价于省略 LIMIT
SELECT id, order_no, amount
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT ALL OFFSET 100;

详细说明 ​

LIMIT 与 OFFSET 语法 ​

语法形式含义备注
LIMIT n最多返回 n 行不是"一定返回 n 行"
LIMIT 0不返回数据可用于只取列结构
LIMIT ALL无行数限制等价于省略
OFFSET n跳过前 n 行必须配 ORDER BY
OFFSET n ROWS同上(标准写法)可带 ROWS 关键字
FETCH FIRST n ROWS ONLY标准语法的 LIMIT nFIRST / NEXT 等价
FETCH FIRST ROW ONLY只取一行单数形式
sql
-- 只取一行(存在性检查常用)
SELECT id, order_no FROM orders ORDER BY id LIMIT 1;

-- 只取列结构,不返回数据
SELECT id, order_no, amount FROM orders LIMIT 0;

-- 取前 10 行之后的全部
SELECT id FROM orders ORDER BY id LIMIT ALL OFFSET 10;

-- 标准语法:取第 2 页(每页 20)
SELECT id, order_no FROM orders
ORDER BY id
OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;

FETCH FIRST ... ROWS ONLY 标准语法 ​

FETCH FIRST 是 SQL 标准写法,PostgreSQL 同时支持它与 LIMIT。二者在功能上完全等价,选择哪一种取决于可移植性需求。

sql
-- 语义完全等价的三种写法
SELECT id FROM orders ORDER BY id LIMIT 5;                          -- 最常用
SELECT id FROM orders ORDER BY id FETCH FIRST 5 ROWS ONLY;         -- SQL 标准
SELECT id FROM orders ORDER BY id OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY;  -- 显式 OFFSET
特性LIMIT / OFFSETFETCH FIRST / OFFSET
所属标准PostgreSQL / MySQL 扩展SQL:2008 标准
与 OFFSET 顺序必须先 LIMIT 后 OFFSET必须先 OFFSET 后 FETCH
可移植性高(MySQL、SQLite)高(Oracle、DB2、SQL Server 2012+)
支持 LIMIT ALL是无对应写法(用 FETCH FIRST ... 大数)
sql
-- 错误!关键字顺序不能颠倒
SELECT id FROM orders ORDER BY id FETCH NEXT 10 ROWS ONLY OFFSET 20 ROWS;

-- 正确
SELECT id FROM orders ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

OFFSET 的性能问题 ​

OFFSET 的根本代价在于:需要先生成并丢弃前面 N 行。执行计划中会出现 Limit 节点上方的 Offset 逻辑,实际由 Limit 节点内部的 offset 参数完成丢弃。

sql
-- 首页:只读 20 行
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_no, amount FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
text
                                         QUERY PLAN
-----------------------------------------------------------------------------------------------
 Limit  (cost=0.42..1.80 rows=20 width=32) (actual time=0.020..0.031 rows=20 loops=1)
   Buffers: shared hit=24
   ->  Index Scan using idx_orders_created_id on orders
         (cost=0.42..6903.42 rows=100000 width=32) (actual time=0.018..0.028 rows=20 loops=1)
         Index Cond: true
         Buffers: shared hit=24
 Planning Time: 0.156 ms
 Execution Time: 0.052 ms
(7 rows)
sql
-- 深分页:OFFSET 50000,必须读出并丢弃 5 万行
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_no, amount FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 50000;
text
                                          QUERY PLAN
------------------------------------------------------------------------------------------------
 Limit  (cost=1381.09..1382.47 rows=20 width=32) (actual time=34.512..34.539 rows=20 loops=1)
   Buffers: shared hit=1674
   ->  Index Scan using idx_orders_created_id on orders
         (cost=0.42..6903.42 rows=100000 width=32) (actual time=0.019..29.417 rows=50020 loops=1)
         Index Cond: true
         Buffers: shared hit=1674
 Planning Time: 0.168 ms
 Execution Time: 34.622 ms
(7 rows)

关键差异在于 rows=50020:索引扫描实际读出了 50020 行,其中 50000 行被丢弃。Execution Time 从 0.052 ms 增长到 34.622 ms,并且偏移量越大增长越接近线性。

text
-- 偏移量与耗时的近似关系(10 万行表)
 OFFSET   | 实际读取行数 | 执行时间(示意)
----------+--------------+------------------
 0        | 20           | 0.05 ms
 1000     | 1020         | 0.7 ms
 10000    | 10020        | 6.9 ms
 50000    | 50020        | 34.6 ms
 90000    | 90020        | 62.3 ms

即便走了索引,代价仍随 OFFSET 线性增长,因为索引也要逐条读取、逐条丢弃。这就是深分页"越翻越慢"的原因。

键集分页(Keyset Pagination) ​

键集分页的核心思想:不用"跳过多少行",而用"从哪个位置继续"。把上一页最后一行的排序键记下来,下一页用 WHERE 条件直接定位。

sql
-- 第 1 页
SELECT id, order_no, amount, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;
text
 id | order_no | amount  |      created_at
----+----------+---------+-----------------------
  ...
---- 假设最后一行是 (created_at = 2026-03-11 10:39:00+08, id = 99980) ----
sql
-- 第 2 页:用行值比较直接定位,无需扫描丢弃
SELECT id, order_no, amount, created_at
FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2026-03-11 10:39:00+08', 99980)
ORDER BY created_at DESC, id DESC
LIMIT 20;

行值比较(Row Value Comparison)是 PostgreSQL 支持的元组比较语法,(a, b) < (x, y) 等价于 a < x OR (a = x AND b < y),但能直接用索引定位:

sql
-- 两者逻辑等价,但行值比较写法更简洁、更不易出错
SELECT id FROM orders WHERE created_at < '2026-03-11 10:39:00+08'
   OR (created_at = '2026-03-11 10:39:00+08' AND id < 99980)
ORDER BY created_at DESC, id DESC LIMIT 20;

-- 推荐:行值比较
SELECT id FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2026-03-11 10:39:00+08', 99980)
ORDER BY created_at DESC, id DESC LIMIT 20;

性能差异是数量级的:

sql
-- 键集分页的执行计划:只读 20 行
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_no, amount FROM orders
WHERE (created_at, id) < (TIMESTAMPTZ '2026-03-11 10:39:00+08', 99980)
ORDER BY created_at DESC, id DESC
LIMIT 20;
text
                                          QUERY PLAN
------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..1.80 rows=20 width=32) (actual time=0.019..0.028 rows=20 loops=1)
   Buffers: shared hit=25
   ->  Index Scan using idx_orders_created_id on orders
         (cost=0.42..1911.02 rows=27778 width=32) (actual time=0.017..0.026 rows=20 loops=1)
         Index Cond: (ROW(created_at, id) < ROW('2026-03-11 10:39:00+08'::timestamptz, 99980))
         Buffers: shared hit=25
 Planning Time: 0.142 ms
 Execution Time: 0.048 ms
(7 rows)

无论翻到第几页,实际读取行数都稳定在 20 行附近,执行时间基本恒定。两种分页方案的完整对比:

对比项LIMIT + OFFSET键集分页(Keyset)
定位方式从头数 N 行从键值直接定位
大偏移性能差,线性劣化好,恒定
执行代价O(offset + limit)O(log n + limit)
能否跳页可以任意页只能顺序翻
总数信息可算总页数无法直接得知
数据变动影响可能重复或遗漏相对稳定
实现复杂度低中(需传递游标)
适用场景后台管理少量页信息流、无限滚动、导出

用唯一键或 ctid 做游标 ​

游标必须有唯一性,否则边界处会重复或遗漏。几种常见的游标选择:

游标类型示例优点缺点
自增主键id > 1000简单、高效、稳定仅适用于按 id 排序
时间 + 主键(created_at, id) < (?, ?)支持时间序、稳定需要复合索引
业务唯一键(status, order_no) > (?, ?)语义直观需保证唯一
ctidctid > '(0,100)'无需额外列不稳定(更新会变)、跨表不能比
sql
-- 单列游标:最简单
SELECT id, order_no FROM orders
WHERE id > 1000
ORDER BY id
LIMIT 20;

-- 时间 + 主键游标:支持时间序
SELECT id, order_no, created_at FROM orders
WHERE (created_at, id) > (TIMESTAMPTZ '2026-01-02 10:00:00+08', 1500)
ORDER BY created_at, id
LIMIT 20;

ctid 是行的物理位置,可作为快速游标,但有明显局限:

sql
-- ctid 游标:无需额外排序键
SELECT ctid, id, order_no
FROM orders
ORDER BY ctid
LIMIT 5;
text
  ctid   |  id   | order_no
---------+-------+-----------
 (0,1)   |     1 | SO-000001
 (0,2)   |     2 | SO-000002
 (0,3)   |     3 | SO-000003
 (0,4)   |     4 | SO-000004
 (0,5)   |     5 | SO-000005
(5 rows)
sql
-- 用 ctid 做游标翻页
SELECT ctid, id, order_no FROM orders
WHERE ctid > '(0,5)'
ORDER BY ctid
LIMIT 5;
sql
-- 不推荐:ctid 作为对外游标暴露给前端
-- 原因 1:行更新后 ctid 会变化(MVCC 会写入新版本)
UPDATE orders SET amount = amount + 1 WHERE id = 3;
-- 该行的 ctid 已改变,之前记录的 ctid 指向旧版本甚至无效位置

-- 原因 2:VACUUM FULL / 分区/集群操作会重排物理位置,游标失效
VACUUM FULL orders;

结论:ctid 只适合单次会话内的批量处理(如后台批处理脚本),不适合作为跨请求的持久游标。对外 API 一律用业务唯一键。

LIMIT 必须配合 ORDER BY ​

不带 ORDER BY 的 LIMIT 返回行是任意的,取决于扫描路径。这会导致极其隐蔽的 bug:同一查询两次执行可能返回不同结果,分页时大量重复或遗漏。

sql
-- 错误!没有 ORDER BY,返回哪些行不确定
SELECT id, order_no FROM orders LIMIT 20 OFFSET 0;
SELECT id, order_no FROM orders LIMIT 20 OFFSET 20;   -- 可能与前页重复

-- 正确
SELECT id, order_no FROM orders ORDER BY id LIMIT 20 OFFSET 0;
SELECT id, order_no FROM orders ORDER BY id LIMIT 20 OFFSET 20;

即使加了 ORDER BY,如果排序键不唯一,分页仍然不稳定:

sql
-- 不稳定:status 只有 4 个取值,大量行顺序不确定
SELECT id, status FROM orders ORDER BY status LIMIT 20 OFFSET 20;

-- 稳定:追加唯一键
SELECT id, status FROM orders ORDER BY status, id LIMIT 20 OFFSET 20;

分页总数统计方案 ​

分页界面常需要"共 N 条 / 第 x 页"。精确统计代价可能很高。

方案语句精确性代价
精确 count(*)SELECT count(*) FROM t WHERE ...精确高,需扫描全部匹配行
估算(优化器)见下方 EXPLAIN 技巧估算极低
系统统计表pg_class.reltuples粗估极低
增量计数维护计数表 / 触发器精确写放大
只算页数上限LIMIT N+1 探测部分低
sql
-- 方案一:精确统计(配合索引可走 Index Only Scan)
SELECT count(*) FROM orders WHERE status = 'paid';

-- 方案二:用优化器估算行数(无需真正执行)
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
-- 输出中的 rows=25000 即为估算值,精度通常足够展示"约 2.5 万条"
text
                                   QUERY PLAN
--------------------------------------------------------------------------------
 Seq Scan on orders  (cost=0.00..1791.00 rows=25000 width=52)
   Filter: (status = 'paid'::text)
(2 rows)
sql
-- 方案三:用 EXPLAIN (FORMAT JSON) 在程序里取出估算值
EXPLAIN (FORMAT JSON) SELECT * FROM orders WHERE status = 'paid';
sql
-- 方案四:N+1 探测法,判断是否还有下一页
SELECT id, order_no FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 21;      -- 期望取 20 条,多取 1 条用于判断
-- 若返回 21 条 → 还有下一页;若 ≤ 20 条 → 已到末页
sql
-- 方案五:估算全表行数(VACUUM/ANALYZE 后较准)
SELECT reltuples::bigint AS estimated_rows
FROM pg_class
WHERE relname = 'orders';

方案四(N+1 探测)是无限滚动场景的最佳选择:不需要总数,只需知道"有没有下一页",代价仅比正常查询多读一行。

实战示例 ​

下面用"订单列表的完整分页接口"串联本节要点。

sql
-- 步骤 1:准备场景——按状态筛选、按时间倒序分页
-- 页大小 20;排序键 created_at DESC, id DESC
CREATE INDEX idx_orders_status_created_id ON orders (status, created_at DESC, id DESC);
sql
-- 步骤 2:第一页(OFFSET 方式,适合页数少的后台)
SELECT id, order_no, customer, amount, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
text
  id   | order_no | customer | amount  | status |      created_at
-------+----------+----------+---------+--------+-----------------------
 99997 | SO-099997| user_97  | 3421.50 | paid   | 2026-03-11 10:39:00+08
 99993 | SO-099993| user_93  |  890.00 | paid   | 2026-03-11 10:38:00+08
  ...
(20 rows)
sql
-- 步骤 3:深分页对比 —— OFFSET 10000(慢)
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_no, amount FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;
text
                                          QUERY PLAN
------------------------------------------------------------------------------------------------
 Limit  (cost=345.60..347.00 rows=20 width=28) (actual time=6.812..6.828 rows=20 loops=1)
   Buffers: shared hit=418
   ->  Index Scan using idx_orders_status_created_id on orders
         (cost=0.42..3455.42 rows=50000 width=28) (actual time=0.024..5.731 rows=10020 loops=1)
         Index Cond: (status = 'paid'::text)
         Buffers: shared hit=418
 Planning Time: 0.170 ms
 Execution Time: 6.876 ms
(7 rows)
sql
-- 步骤 4:同样深度改用键集分页(快且恒定)
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_no, amount FROM orders
WHERE status = 'paid'
  AND (created_at, id) < (TIMESTAMPTZ '2026-03-08 00:00:00+08', 40000)
ORDER BY created_at DESC, id DESC
LIMIT 20;
text
                                          QUERY PLAN
------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..6.02 rows=20 width=28) (actual time=0.022..0.038 rows=20 loops=1)
   Buffers: shared hit=25
   ->  Index Scan using idx_orders_status_created_id on orders
         (cost=0.42..1709.02 rows=6625 width=28) (actual time=0.020..0.036 rows=20 loops=1)
         Index Cond: ((status = 'paid'::text) AND
                      (ROW(created_at, id) < ROW('2026-03-08 00:00:00+08'::timestamptz, 40000)))
         Buffers: shared hit=25
 Planning Time: 0.155 ms
 Execution Time: 0.061 ms
(7 rows)

rows=20 与 rows=10020 的对比清晰展示了两种方案的差别。

sql
-- 步骤 5:带总数的分页响应(估算总数,避免全表 count)
SELECT
  (SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders') AS estimated_total,
  count(*) FILTER (WHERE status = 'paid')                           AS paid_total
FROM orders;
text
 estimated_total | paid_total
-----------------+------------
          100000 |      25000
(1 row)
sql
-- 步骤 6:N+1 探测判断是否有下一页(无限滚动首选)
-- 前端传 cursor = (created_at, id);不传则从第一页开始
SELECT id, order_no, created_at
FROM orders
WHERE status = 'paid'
  AND (created_at, id) < (TIMESTAMPTZ '2026-03-08 00:00:00+08', 40000)
ORDER BY created_at DESC, id DESC
LIMIT 21;      -- 多取一行用于判断
text
-- 应用层处理逻辑
-- rows.length > 20 → 有下一页,返回前 20 条,cursor 取第 20 条的 (created_at, id)
-- rows.length <= 20 → 已到末页
sql
-- 步骤 7:导出场景——用 DECLARE CURSOR 流式读取,避免一次性载入内存
BEGIN;
DECLARE order_cur NO SCROLL CURSOR FOR
  SELECT id, order_no, amount FROM orders ORDER BY id;

-- 每次取 1000 行,循环直到取不到数据
FETCH FORWARD 1000 FROM order_cur;
FETCH FORWARD 1000 FROM order_cur;

CLOSE order_cur;
COMMIT;
sql
-- 步骤 8:验证分页结果无重复无遗漏(对账查询)
-- 分别取第 1、2 页的 id 集合,检查交集为空
WITH page1 AS (
  SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 0
),
page2 AS (
  SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 20
)
SELECT
  (SELECT count(*) FROM page1)                          AS p1_cnt,
  (SELECT count(*) FROM page2)                          AS p2_cnt,
  (SELECT count(*) FROM page1 JOIN page2 USING (id))    AS overlap;
text
 p1_cnt | p2_cnt | overlap
--------+--------+---------
     20 |     20 |       0
(1 row)

注意事项 ​

  • LIMIT 不带 ORDER BY 时结果不确定。分页查询必须同时写 ORDER BY,且排序键必须唯一(或用唯一键兜底)。
sql
-- 错误!
SELECT id FROM orders LIMIT 20 OFFSET 20;

-- 正确
SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 20;
  • OFFSET 代价随偏移量线性增长。OFFSET 100000 需要扫描并丢弃 10 万行,即使走了索引也一样慢。深分页改用键集分页。
sql
-- 慢:OFFSET 深分页
SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 100000;

-- 快:键集分页
SELECT id FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;
  • 排序键不唯一会导致分页重复或遗漏。这类 bug 在数据量小时难以复现,上线后才暴露。
sql
-- 不稳定:status 有大量重复
SELECT id, status FROM orders ORDER BY status LIMIT 20 OFFSET 20;

-- 稳定:追加唯一键
SELECT id, status FROM orders ORDER BY status, id LIMIT 20 OFFSET 20;
  • ctid 不适合作为对外游标。行更新后 ctid 会变化,VACUUM FULL 会重排物理位置。
sql
-- 不推荐:把 ctid 暴露给前端做游标
SELECT ctid, id FROM orders ORDER BY ctid LIMIT 20;
  • LIMIT 与 OFFSET 的顺序是固定的。LIMIT 在前、OFFSET 在后;标准语法则相反,OFFSET 在前、FETCH 在后。
sql
-- 错误!语法顺序颠倒
SELECT id FROM orders ORDER BY id OFFSET 10 LIMIT 5;

-- 正确(LIMIT 风格)
SELECT id FROM orders ORDER BY id LIMIT 5 OFFSET 10;

-- 正确(标准风格)
SELECT id FROM orders ORDER BY id OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY;
  • 精确 count(*) 在宽条件或大表上代价高。它需要扫描所有匹配行。分页界面可改用估算值或 N+1 探测。
sql
-- 昂贵:大表上带复杂条件的精确计数
SELECT count(*) FROM orders WHERE amount > 100 AND status IN ('paid', 'shipped');

-- 便宜:只判断是否存在下一页
SELECT id FROM orders WHERE amount > 100 ORDER BY id LIMIT 21;
  • OFFSET 分页在数据变动时结果不稳定。翻页过程中若有新数据插入到前面,会导致重复行出现;有删除则可能遗漏。键集分页同样受影响较小但仍需注意有序键不可变。
sql
-- 场景:用户翻页时新人下单,created_at 更大(排在最前)
-- 第 2 页用 OFFSET 20 读到的实际是"原第 19 行",导致第 1 页末行重复出现
  • LIMIT 不是"保证返回 n 行"。匹配行数不足时返回更少行,应用层必须容忍。
sql
-- 表中只有 5 行时,以下返回 5 行而非 100 行
SELECT id FROM orders LIMIT 100;
  • FETCH FIRST ... ROWS ONLY 与 LIMIT 在 OFFSET 位置上有区别。混用风格时容易写错顺序。
sql
-- 混用风格:PostgreSQL 允许,但可读性差,不推荐
SELECT id FROM orders ORDER BY id OFFSET 10 LIMIT 5;

最佳实践 ​

  1. 分页排序键 = 业务排序键 + 唯一键。推荐 created_at DESC, id DESC,并建立对应复合索引。
sql
-- 索引列顺序与方向都要与 ORDER BY 一致
CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);
  1. 默认使用键集分页。前台列表、信息流、导出等场景一律用游标,只有后台管理的小页码场景才用 OFFSET。
sql
-- 键集分页模板
SELECT id, order_no, created_at
FROM orders
WHERE (:last_created_at IS NULL OR (created_at, id) < (:last_created_at, :last_id))
ORDER BY created_at DESC, id DESC
LIMIT :page_size + 1;     -- 多取一行用于判断下一页
  1. 无限滚动用 N+1 探测判断下一页,避免精确 count(*)。
sql
-- 期望 20 条 → LIMIT 21
SELECT id, order_no FROM orders
WHERE (:cursor_created_at IS NULL OR (created_at, id) < (:cursor_created_at, :cursor_id))
ORDER BY created_at DESC, id DESC
LIMIT 21;
  1. 必须展示总数时用估算值,并明确标注"约"。
sql
-- 从 pg_class 取估算行数(VACUUM/ANALYZE 后精度足够展示用途)
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';

-- 带条件的估算:用 EXPLAIN (FORMAT JSON) 在应用层解析
EXPLAIN (FORMAT JSON) SELECT * FROM orders WHERE status = 'paid';
  1. 为分页查询建立"覆盖"条件的索引,让过滤与排序在同一个索引内完成。
sql
-- 支撑 WHERE status = ? ORDER BY created_at DESC, id DESC
CREATE INDEX idx_orders_status_created_id ON orders (status, created_at DESC, id DESC);
  1. 导出大数据用服务端游标(DECLARE CURSOR),避免一次性把结果集载入内存。
sql
BEGIN;
DECLARE c NO SCROLL CURSOR FOR SELECT id, order_no FROM orders ORDER BY id;
FETCH FORWARD 1000 FROM c;    -- 循环调用
CLOSE c;
COMMIT;
  1. 分页参数做上限校验。LIMIT 必须设最大页大小(如 200),防止客户端传 LIMIT 1000000 拖垮数据库。
sql
-- 应用层约束示例(伪代码)
-- page_size = min(requested_size, 200)
SELECT id FROM orders ORDER BY id LIMIT :page_size;
  1. 用 EXPLAIN (ANALYZE, BUFFERS) 验证分页计划。关注实际读取行数(rows)是否远大于 LIMIT 值,若是则说明发生了扫描丢弃。
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_no FROM orders ORDER BY id LIMIT 20 OFFSET 10000;
-- 若 rows 表示约 10020,说明丢弃了 1 万行,应改用键集分页

下一节

继续学习:去重(DISTINCT)

参考链接 ​