亿级数据表不可用offset分页,因其必须逐行扫描并丢弃前n行,导致io、cpu和响应时间崩溃;唯一可行方案是游标分页,且需满足三条件:确定性排序、联合索引覆盖、游标值严格来自上一页末条记录的真实字段值。

亿级数据表用 Offset 分页必然不可行——不是 GORM 的问题,是 MySQL/PostgreSQL 底层执行机制决定的:OFFSET 1000000 时,数据库必须真实扫描并丢弃前一百万行,IO、CPU、响应时间全崩。真正能落地的方案只有游标分页(Cursor-based Pagination),且必须配合索引和前端协作。
为什么不能用 Limit + Offset 做亿级分页
OFFSET 不是“跳转”,是“逐行扫描后丢弃”。当偏移量超 50 万,MySQL 即使有主键索引,也可能因回表或统计信息不准导致执行计划退化;PostgreSQL 在大 OFFSET 下常触发 Seq Scan。更致命的是语义缺陷:
- 并发写入时,同一页可能重复或漏掉记录(比如第 100 万条刚被删,下一页就跳过一条)
-
Count(&total)查询在亿级表上本身就会慢甚至超时,而多数前端根本不需要精确总数 - 前端传
page=50000这种参数,后端不做校验就直接算Offset(49999 * 20),等于主动发起 DoS 攻击
游标分页必须满足的三个硬条件
游标分页不是换个写法就行,它依赖确定性排序和可比较的边界值。缺一不可:
- 排序字段必须有**联合索引覆盖**,例如你按
created_at DESC, id DESC排序,索引就得是INDEX idx_created_id (created_at, id);只建created_at单列索引,ORDER BY 仍可能失效 - 游标值(
last_id或last_created_at)必须来自**上一页最后一条记录的真实字段值**,不能前端伪造、不能服务端生成、不能取平均值 - WHERE 条件必须严格使用
>或(取决于排序方向),例如 <code>WHERE created_at —— 这是为了处理时间相同的情况,避免漏数据
如何安全地写 GORM 游标查询
GORM 没有内置游标支持,但你可以用原生链式调用精准控制。关键不是“怎么写”,而是“怎么不写错”:
- 首次请求不带游标:
db.Order("created_at DESC, id DESC").Limit(20).Find(&users) - 后续请求必须带两个参数:
last_created_at和last_id,然后拼 WHERE:db.Where("created_at - 别用
Preload加载一对多关联——游标分页里 Preload 会破坏边界语义,改用 JOIN + 子查询或分两步查 - 前端必须透传游标值(如 URL 中
?cursor=1724219180123_1000456),后端不做page → cursor转换,转换逻辑放在前端或网关层
Count 总数在亿级场景下该不该查
该不该查,取决于业务。但技术上:查了大概率慢、不准、还拖垮连接池。更务实的做法是:
- 用
has_next替代total:查Limit(21),如果返回 21 条,就设has_next = true,第 21 条的字段值作为下一页游标;否则设has_next = false - 如果真要总数,别用
db.Model(&User{}).Count()—— 它在亿级表上是全表扫描。改用近似值:SELECT table_rows FROM information_schema.tables WHERE table_name = 'users'(MySQL)或pg_class.reltuples(PostgreSQL) - 复杂 WHERE 条件(含 JOIN、子查询)时,Count 必须手写子查询,且子查询里不能带 LIMIT/OFFSET,例如:
db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE p.status = ?) AS t", "active").Scan(&count)
游标分页真正的坑不在代码怎么写,而在于前端是否理解游标语义、DBA 是否建对了复合索引、以及产品是否接受“无总页数”的交互设计——这三个点没对齐,再漂亮的 GORM 链式调用也救不了。











