postgresql的offset越大越慢,是因为必须扫描并丢弃前n行,无法跳过;应改用游标分页(如where id > last_id order by id limit 20),配合单调排序字段和匹配索引,性能恒定。

PostgreSQL 下 GORM 的分页不能靠 Limit + Offset 硬扛,超过 5000 行偏移后响应时间会从毫秒级跳到秒级——这不是 GORM 写得不好,是 PostgreSQL 执行器必须扫描并丢弃前 N 行,索引也救不了。
为什么 PostgreSQL 的 OFFSET 越大越慢
执行 SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000 时,PostgreSQL 不会“跳”过前 10 万行,而是:先按 created_at 排序(走索引),再逐行计数、丢弃,直到第 100001 行才开始取数据。即使 created_at 有 B-tree 索引,OFFSET 仍需在索引树中线性遍历节点,无法直接定位。
- OFFSET 5000 已明显变慢;OFFSET 50000 常见响应 >1.2s,CPU 和 shared buffer 压力陡增
- 并发写入下,同一
OFFSET查询两次可能返回不同结果(MVCC 版本差异 + 排序不稳定) - 只加
ORDER BY created_at DESC不够稳:高并发插入可能产生毫秒级重复时间戳,导致排序顺序不可重现 - 用
ORDER BY RANDOM()分页?别试——全表扫描 + 排序,百万行直接卡死
GORM 游标分页在 PostgreSQL 中的正确写法
游标分页本质是把 “第 N 页” 换成 “从某条记录之后开始取”,绕开物理扫描缺陷。PostgreSQL 对 WHERE + ORDER BY + LIMIT 组合优化极好,只要游标字段有索引,性能几乎恒定。
- 首排序字段必须单调、非空、有索引:优先用
id(SERIAL 或 BIGSERIAL),次选用created_at+id复合排序 - 复合排序必须严格匹配索引顺序:如
ORDER BY status, created_at DESC, id DESC,索引就得建为CREATE INDEX idx_status_created_id ON orders (status, created_at DESC, id DESC) - 首次请求不带游标:
db.Order("id ASC").Limit(21).Find(&orders)(多查 1 条判断是否有下一页) - 下一页游标必须基于当前页**最后一条**记录生成,不是第一条;前端传
last_id=12345,后端写db.Where("id > ?", lastID).Order("id ASC").Limit(20) - 若用时间戳,注意 PostgreSQL 默认
timestamp without time zone精度是微秒,但应用层常只传毫秒,建议统一用BIGINT存纳秒时间戳,避免精度丢失
GORM Count 查询在 PostgreSQL 中怎么才准又快
db.Where("status = ?", "paid").Limit(20).Offset(40).Count(&total) 这种链式调用在 GORM v2 中会复用 Limit 和 Offset,Count 返回的永远 ≤20 —— 这是高频误用点,不是 bug,是设计行为。
- 简单场景:用
db.Session(&gorm.Session{NewDB: true}).Model(&Order{}).Where("status = ?", "paid").Count(&total)隔离上下文 - 含
Joins或Preload的复杂查询:手写子查询更可靠,例如db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM orders o JOIN users u ON o.user_id = u.id WHERE u.active = ?) AS t", true).Scan(&total) - 别在事务里反复调
Count+Find:两次查询之间若订单状态变更,总数和当前页数据就对不上 - UI 不强制显示“共 XX 条”?那就别查总数。改用查
Limit(21),返回has_next: len(orders) == 21,省掉一次全表扫描,QPS 可翻倍
PostgreSQL 特有的坑与绕过方式
PostgreSQL 对 NULL 值排序默认排在最前(NULLS FIRST),而大多数业务希望最新数据在前、NULL 排最后。GORM 默认不控制这个,容易导致游标断裂或漏数据。
- 显式写
ORDER BY created_at DESC NULLS LAST, id DESC,并在索引中声明:CREATE INDEX idx_created_id_nulls_last ON orders (created_at DESC NULLS LAST, id DESC) - 用
jsonb字段做条件分页时,别指望普通索引生效:必须建GIN索引,且WHERE data @> '{"type": "refund"}'才能走索引 - 分区表(如按月分区的
orders_2026_07)上分页,确保WHERE条件能触发分区裁剪,否则会扫所有子表 - 别信
EXPLAIN ANALYZE里 “Index Scan using …” 就代表快:如果Rows Removed by Filter数值巨大,说明索引没覆盖查询条件,得补联合索引
真正卡住性能的,从来不是 GORM 怎么写,而是 PostgreSQL 怎么执行那条 SQL。游标分页不是“高级技巧”,是面对真实数据规模时的生存策略;而索引定义是否匹配查询模式,往往比换 ORM 更影响结果。











