PostgreSQL 分页性能优化:FETCH WITH TIES 与传统 LIMIT/OFFSET 的对比
一、传统 LIMIT/OFFSET 的问题
经典分页写法
sql
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
三大致命问题
问题 1:深度分页性能爆炸
OFFSET 100000 的实际执行过程:
扫描 100020 行 → 丢弃前 100000 行 → 返回 20 行
↑ 浪费 99.98% 的工作
实测对比(1 亿行数据,索引扫描):
| OFFSET | 耗时 |
|---|---|
| 0 | 1 ms |
| 1,000 | 5 ms |
| 100,000 | 200 ms |
| 1,000,000 | 2 秒+ |
| 10,000,000 | 20 秒+ |
问题 2:数据漂移(Gap 问题)⭐
sql
-- 用户翻第 1 页(按 created_at 倒序)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- 返回订单 1001~1020
-- 此时新插入了一条订单 1021
-- 用户翻第 2 页
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 20;
-- 返回 1001~1020,因为新增的订单挤进了第 1 页位置
-- 用户看到第 2 页和第 1 页内容重复!
问题 3:相同排序值导致结果不稳定
sql
-- created_at 相同的多条记录
ORDER BY created_at LIMIT 10 OFFSET 0;
-- 第 1 页和第 2 页可能出现重复或遗漏
-- 因为 created_at 相同时,排序顺序不确定
二、FETCH WITH TIES 是什么?
标准语法(SQL:2008)
sql
SELECT *
FROM orders
ORDER BY created_at DESC
FETCH FIRST 20 ROWS WITH TIES;
核心特性
WITH TIES 表示:返回排序键值"并列"的所有行 ,即使总行数超过 FETCH FIRST N。
FETCH FIRST 5 ROWS ONLY → 严格 5 行(即使第 5 行有并列也截断)
FETCH FIRST 5 ROWS WITH TIES → 至少 5 行,并列的全部包含
三、WITH TIES 直观示例
测试数据
sql
CREATE TABLE scores (
id INT,
name VARCHAR(20),
score INT
);
INSERT INTO scores VALUES
(1, 'Alice', 95),
(2, 'Bob', 95),
(3, 'Carol', 95),
(4, 'David', 90),
(5, 'Eve', 88),
(6, 'Frank', 85);
普通 LIMIT vs WITH TIES
sql
-- 写法 1: LIMIT 2 OFFSET 0
SELECT * FROM scores ORDER BY score DESC LIMIT 2;
id | name | score
---+-------+------
1 | Alice | 95
2 | Bob | 95 ← Carol 的 95 分被遗漏!
sql
-- 写法 2: FETCH FIRST 2 ROWS ONLY (等价上面)
SELECT * FROM scores ORDER BY score DESC
FETCH FIRST 2 ROWS ONLY;
-- 同样只返回 Alice、Bob
sql
-- 写法 3: FETCH FIRST 2 ROWS WITH TIES ⭐
SELECT * FROM scores ORDER BY score DESC
FETCH FIRST 2 ROWS WITH TIES;
id | name | score
---+-------+------
1 | Alice | 95
2 | Bob | 95
3 | Carol | 95 ← 自动包含并列的 95 分
经典应用:取并列前 N 名
sql
-- 月度销售并列前 3 的销售员(含并列名次)
SELECT salesman, total_sales
FROM monthly_sales
ORDER BY total_sales DESC
FETCH FIRST 3 ROWS WITH TIES;
四、关键澄清:WITH TIES 不能直接解决深度分页
重要事实 :FETCH FIRST N ROWS WITH TIES 本身不解决 OFFSET 性能问题,它解决的是排序值并列时结果完整性问题。
sql
-- ❌ 这种用法仍然有 OFFSET 性能问题
SELECT * FROM orders
ORDER BY created_at DESC
OFFSET 100000
FETCH FIRST 20 ROWS WITH TIES;
-- OFFSET 100000 依然要跳过 10 万行
真正解决分页性能问题的方案是 键集分页(Keyset Pagination / Seek Method),下面详细介绍。
五、真正的高性能分页:键集分页(Keyset Pagination)
核心思想
不用 OFFSET,而是记住"上一页最后一条的位置",从那个位置继续往后取。
单一排序键分页
第一页
sql
SELECT id, name, created_at
FROM orders
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
第二页(带上一页最后一行的 created_at 和 id)
sql
-- 假设上一页最后一行: created_at='2026-04-01 10:00:00', id=10000
SELECT id, name, created_at
FROM orders
WHERE (created_at, id) < ('2026-04-01 10:00:00', 10000) -- 关键!
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
利用了行值比较(Row Value Comparison),PG 原生支持。
性能对比
| 方案 | 第 1 页 | 第 1000 页 | 第 100000 页 |
|---|---|---|---|
| LIMIT/OFFSET | 1 ms | 200 ms | 2000 ms+ |
| 键集分页 | 1 ms | 1 ms | 1 ms |
无论翻到多深,键集分页永远是 O(log N) 复杂度(B-Tree 索引定位)。
六、WITH TIES 与键集分页结合(解决 Gap 问题)
完整解决方案
sql
-- 创建支持索引
CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);
-- 第一页
SELECT id, name, created_at
FROM orders
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
-- 第二页(携带上一页最后一行)
SELECT id, name, created_at
FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
为什么这样能解决 Gap 问题?
传统 OFFSET 分页:
[第1页] OFFSET 0 → 取最新 20 条
↓ (新增 1 条记录)
[第2页] OFFSET 20 → 跳过最新 20 条 → 旧的第 1 页内容跑到第 2 页
用户看到重复!
键集分页:
[第1页] 从最新开始取 20 条,记住最后一条 = X
↓ (新增 1 条记录,但与 X 比较已知)
[第2页] WHERE (created_at, id) < X → 严格从 X 之后开始
不受新插入影响,无 Gap
七、应用场景的语法选择
场景 1:单一排序字段分页(推荐键集分页)
sql
-- 第 N 页(API 传入 cursor)
SELECT *
FROM products
WHERE id > :last_id -- 上次最后一行的 id
ORDER BY id
FETCH FIRST 20 ROWS ONLY;
场景 2:复合排序字段分页
sql
SELECT *
FROM articles
WHERE (publish_date, id) < (:last_date, :last_id)
ORDER BY publish_date DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
场景 3:取并列 Top N(用 WITH TIES)
sql
-- 销售额前 10 名(含并列)
SELECT salesman, total_sales
FROM sales_summary
ORDER BY total_sales DESC
FETCH FIRST 10 ROWS WITH TIES;
场景 4:每个分类的 Top N(用 LATERAL)
sql
-- 每个商家最热销的 3 个商品(含并列)
SELECT m.id, t.product_name, t.sales
FROM merchants m
CROSS JOIN LATERAL (
SELECT product_name, sales
FROM products
WHERE merchant_id = m.id
ORDER BY sales DESC
FETCH FIRST 3 ROWS WITH TIES
) t;
八、键集分页 + 总数显示的实现
键集分页有个常见问题:没法显示总页数(因为不用 OFFSET)。
方案 1:估算总数(推荐)
sql
-- 用统计信息快速估算(精度足够)
SELECT reltuples::BIGINT AS estimate_count
FROM pg_class
WHERE relname = 'orders';
方案 2:精确总数(首次查询缓存)
sql
-- 仅在用户首次进入列表时执行,缓存到 session
SELECT COUNT(*) FROM orders WHERE status = 'paid';
方案 3:放弃总数,改用"加载更多"模式
现代 App(微信、Twitter 等)都是这种模式,无总数概念,体验流畅。
九、完整案例:电商订单分页 API
接口设计
GET /api/orders?cursor=<last_created_at>_<last_id>&size=20
后端 SQL
sql
-- 首次查询(无 cursor)
SELECT id, order_no, customer_id, amount, created_at
FROM orders
WHERE customer_id = :customer_id
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
-- 翻页(传入 cursor)
SELECT id, order_no, customer_id, amount, created_at
FROM orders
WHERE customer_id = :customer_id
AND (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
索引设计
sql
-- 按客户分页时需要的复合索引
CREATE INDEX idx_orders_cust_created
ON orders (customer_id, created_at DESC, id DESC);
返回结构
json
{
"data": [...],
"next_cursor": "2026-04-01T10:00:00_10000",
"has_more": true
}
十、性能验证
创建测试表
sql
CREATE TABLE bench_orders (
id BIGSERIAL PRIMARY KEY,
customer_id INT,
amount NUMERIC(10,2),
created_at TIMESTAMP DEFAULT NOW()
);
-- 插入 1000 万行
INSERT INTO bench_orders (customer_id, amount)
SELECT (random()*10000)::INT, (random()*1000)::NUMERIC(10,2)
FROM generate_series(1, 10000000);
CREATE INDEX idx_bench_created ON bench_orders (created_at DESC, id DESC);
ANALYZE bench_orders;
对比测试
sql
-- A: 传统 OFFSET 分页(深度分页)
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM bench_orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 5000000;
-- 结果: Execution Time ≈ 3000+ ms
-- Seq Scan + Sort 或 Index Scan 5M 行
sql
-- B: 键集分页
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM bench_orders
WHERE (created_at, id) < ('2026-03-15 10:00:00', 5000000)
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
-- 结果: Execution Time ≈ 0.5 ms
-- Index Scan,只读取 20 行
性能差距:约 6000 倍。
十一、不同写法的等价对照
| 用途 | 推荐写法 |
|---|---|
| 严格取 N 行 | FETCH FIRST N ROWS ONLY 或 LIMIT N |
| 取 N 行 + 并列项 | FETCH FIRST N ROWS WITH TIES |
| 高效深度分页 | WHERE (col1, col2) < (...) ORDER BY col1, col2 LIMIT N |
| 每组 Top N | LATERAL + FETCH FIRST N ROWS [WITH TIES] |
sql
-- 三种 LIMIT 等价写法
SELECT * FROM t LIMIT 10;
SELECT * FROM t FETCH FIRST 10 ROWS ONLY;
SELECT * FROM t FETCH NEXT 10 ROWS ONLY; -- FIRST 和 NEXT 等价
-- WITH TIES 必须配合 ORDER BY
SELECT * FROM t ORDER BY x FETCH FIRST 10 ROWS WITH TIES;
十二、注意事项与陷阱
1. WITH TIES 必须有 ORDER BY
sql
-- ❌ 报错: WITH TIES cannot be specified without ORDER BY clause
SELECT * FROM t FETCH FIRST 10 ROWS WITH TIES;
-- ✅ 正确
SELECT * FROM t ORDER BY x FETCH FIRST 10 ROWS WITH TIES;
2. 键集分页的排序键必须"严格唯一"
sql
-- ❌ 仅按 created_at 排序,相同时间会跳过
WHERE created_at < :last_created_at
ORDER BY created_at DESC
-- ✅ 加上唯一键 id 作为补充
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
3. 索引必须匹配排序方向
sql
-- ORDER BY DESC 需要对应方向的索引
CREATE INDEX ON orders (created_at DESC, id DESC);
-- 或者保证索引和查询方向一致
4. 不能跳页(只能上一页/下一页)
键集分页是线性翻页,不像 OFFSET 能直接跳到第 N 页。
解决方案:
- 移动端 / 现代 Web 用"无限滚动"
- 必须跳页的场景,用 OFFSET 但限制最大页码(例如最多 100 页)
十三、快速决策指南
分页需求出现
↓
是否需要"取并列前 N 名"?
│
├─ 是 → 用 FETCH FIRST N ROWS WITH TIES
│
└─ 否
↓
数据量大吗?(>10万)
│
├─ 否 → 用 LIMIT/OFFSET 即可
│
└─ 是
↓
需要跳页吗?
│
├─ 否(只需上下页) → ⭐ 键集分页 (推荐)
│
└─ 是 → 限制最大页码 + LIMIT/OFFSET
或键集分页 + 估算总数
一句话总结
FETCH FIRST N ROWS WITH TIES解决的是 "排序并列时结果完整性" 问题(如取并列前 3 名),它本身不解决深度分页性能。真正的高性能分页方案是"键集分页"(Keyset Pagination) :用
WHERE (排序列, 唯一键) < (上一页最后值)+ORDER BY ... LIMIT N替代OFFSET,不仅性能从秒级降到毫秒级 ,还顺带解决了数据漂移(Gap)问题。配合复合索引 + 必要时用
WITH TIES处理边界并列项,就是 PostgreSQL 分页的最优解。
如果你有具体的分页场景(比如带筛选、多种排序、跨表 JOIN),可以告诉我表结构,我帮你写出最优查询。