PostgreSQL 分页性能优化 FETCH WITH TIES 与传统 LIMIT/OFFSET 的对比

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 ONLYLIMIT 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),可以告诉我表结构,我帮你写出最优查询。

相关推荐
m0_741481781 小时前
mysql如何设置定时自动备份脚本_编写shell脚本与cron任务
jvm·数据库·python
m0_631529821 小时前
如何用 cache 参数控制 Fetch 是否读取浏览器自带的缓存
jvm·数据库·python
剑神一笑1 小时前
Linux find 命令深度解析:从递归遍历到性能优化的完整实现
linux·运维·性能优化
HalvmånEver1 小时前
MySQL事务(二)
数据库·mysql
m0_470857641 小时前
CSS如何实现表单元素的统一样式_使用CSS变量控制输入框状态
jvm·数据库·python
会编程的土豆1 小时前
mysql数据类型
数据库·mysql
wang3zc2 小时前
如何正确管理浮层提示(Tooltip)显示时的页面焦点顺序
jvm·数据库·python
2401_824222692 小时前
如何导出Laravel特定时间段的订单数据 基于created_at过滤导出
jvm·数据库·python
2501_901200532 小时前
进阶设计指南之如何打印分页与自适应ER图_支持高级扩展类型
jvm·数据库·python