亿级表不能用OFFSET,因其必须真实扫描并丢弃前N行,B+树无法跳指针;应改用游标分页,基于上一页末尾值(如id>100500或复合条件)范围查询,配合联合索引与cursor透传,彻底规避线性扫描。

亿级表不能用 Offset,这不是 GORM 的限制,是 MySQL/PostgreSQL 底层扫描机制决定的——OFFSET 1000000 意味着数据库必须真实读取并丢弃前一百万行,无论有没有索引。
为什么 OFFSET 在亿级表上必然失败
不是慢,是不可控的慢。当 OFFSET 超过 10 万,MySQL 常见表现是:查询耗时从毫秒级跳到秒级甚至超时;CPU 和 I/O 突增;同一语句多次执行响应时间波动极大。根本原因在于 B+ 树索引无法“跳指针”,它只能顺序遍历叶子节点。即使 id 有主键索引,ORDER BY id LIMIT 20 OFFSET 1000000 仍要定位到第 1000001 条记录的物理位置。
- 没有 WHERE 条件的全表分页,哪怕加了
Order("id ASC"),也逃不开扫描开销 -
Count(&total)在亿级表上本身就会卡住,别指望返回总页数 - 并发写入下,
OFFSET分页天然漏数据或重复——新插入的记录可能挤进上一页范围,而你查不到
游标分页必须用 WHERE + 排序字段,不是语法糖
游标分页不是换了个写法,是彻底抛弃“第几页”的思维。核心是:每次只查“比上一页最后一条更大的下一批”。例如上一页最后一条是 id = 100500, created_at = '2026-08-20 14:22:33',下一页就该:
db.Where("id > ? OR (id = ? AND created_at > ?)", 100500, 100500, "2026-08-20 14:22:33").
Order("id ASC, created_at ASC").
Limit(20).
Find(&users)
- 必须用复合条件(
id > ?或id = ? AND created_at > ?),否则时间相同、id 不同的记录会漏掉 -
Order字段必须和WHERE条件完全一致,且顺序严格匹配联合索引定义(如INDEX idx_id_created (id, created_at)) - 前端不能传
page=100,只能传上一页返回的cursor(比如 base64 编码的"100500:2026-08-20T14:22:33Z") - 首次请求没有 cursor,用
WHERE id > 0或最小已知值起步,避免全表扫
GORM 中游标分页的三个硬性前提
不满足任意一条,游标分页就退化成假分页,照样慢或错。
- 排序字段必须有覆盖索引:单字段如
id主键索引够用;复合排序如created_at DESC, id DESC,必须建联合索引INDEX idx_created_id (created_at, id),且方向一致 - 禁止在游标查询中用
Preload:一对多关联会导致 N+1 查询,或一次性加载远超 20 条的关联数据;应拆成两步——先查主表 20 条,再用IN批量查关联 -
cursor值必须来自上一页最后一条的原始字段值,不能加工(如转时间戳、拼字符串后哈希);否则索引失效,退回到全表扫描
Count 查询在亿级表上应该被放弃
用户不需要知道“总共多少页”,需要的是“还能不能再往下翻”。与其执行 SELECT COUNT(*) FROM huge_table WHERE ... 卡死数据库,不如查 size + 1 条:
db.Limit(21).Find(&users)
hasNext := len(users) > 20
if hasNext { users = users[:20] }
- 这个逻辑对前端透明:返回
data和has_next: true/false即可 - 避免了 COUNT 的全表扫描,延迟稳定在毫秒级
- 如果业务真需要总数(比如后台导出),走异步任务或近似统计(如
EXPLAIN的rows估算)
真正难的不是写对那几行 Where 和 Order,而是让整个链路接受“无总页数”“不可随机跳页”“cursor 必须透传”这些约束——这是亿级数据的物理规律,GORM 再怎么封装也绕不开。


















