GORM分页性能崩塌根源在MySQL执行OFFSET时必须逐行扫描并丢弃前N行,而非跳过;主键索引仍需B+树遍历节点,I/O与CPU成本线性增长,实测千万级表OFFSET 50万常超800ms。

单表千万级数据下,GORM 分页性能崩塌的根源不在 Go 代码,而在 MySQL 执行 OFFSET 时必须逐行扫描并丢弃前 N 行——索引设计不到位,再好的 GORM 写法也救不了。真正起效的,是让数据库能用索引直接定位“第 N 条”,而不是靠跳过。
为什么主键索引单独存在也不够用
很多人以为只要 id 是主键、有聚簇索引,ORDER BY id ASC LIMIT 20 OFFSET 100000 就快。错。MySQL 仍需在 B+ 树里从根节点一路向下遍历,跳过 10 万条记录对应的索引节点,I/O 和 CPU 成本线性增长。实测千万级表,OFFSET 500000 常超 800ms,且波动大。
- 复合查询(如带
WHERE status = ?)时,若没覆盖索引,会先走status索引查出 ID 集合,再回表排序,OFFSET在回表后执行,更慢 -
ORDER BY created_at DESC单独建索引,但created_at有大量重复值,MySQL 无法稳定利用索引做范围跳转 - 使用
SELECT *时,即使id走索引,后续字段仍要回表,深分页放大回表开销
复合索引必须严格匹配查询模式
GORM 分页不是只写 Order("id ASC") 就完事。索引字段顺序、是否包含查询条件、是否覆盖 SELECT 字段,三者缺一不可。否则优化器大概率弃用索引,退化为全索引扫描。
- 典型错误:
WHERE deleted_at IS NULL AND status = 'active' ORDER BY created_at DESC, id DESC,却只建了INDEX idx_created_id (created_at, id)—— 缺少status字段,索引无法用于 WHERE 过滤 - 正确做法:建联合索引
INDEX idx_status_created_id (status, deleted_at, created_at, id),把 WHERE 条件字段放最左,排序字段紧随其后 - 如果查询还选了
name、email,且不想回表,就把它们加到索引末尾(INCLUDE语义),例如INDEX idx_covering (status, created_at, id) INCLUDE (name, email)(MySQL 8.0.13+ 支持函数索引或前缀包含,但更稳的是显式列出)
游标分页必须搭配「单调+索引」排序字段
游标分页(WHERE id > ? ORDER BY id ASC LIMIT 20)能绕过 OFFSET,但前提是排序字段可精确锚定下一页起点。一旦字段不单调、无索引、或类型不一致,就会漏数据或死循环。
-
created_at在高并发插入时可能相同,仅靠它排序 + 游标会导致同时间戳的多条记录顺序不确定,游标值取哪一条?必须加二级排序字段,如id DESC - 索引必须包含游标字段和排序字段,例如游标基于
created_at DESC, id DESC,索引就得是INDEX idx_cursor (created_at, id),且方向严格匹配(MySQL 不支持混合 ASC/DESC 索引,除非 8.0+) - 前端传来的游标值(如
last_id=123456)必须原样用于WHERE id > ?,不能做任何字符串拼接或隐式转换,否则索引失效;id是INT就别传"123456"字符串
Count 查询的索引陷阱比 Find 更隐蔽
db.Model(&User{}).Where("status = ?", "active").Count(&total) 看似简单,但 MySQL 为算总数,常被迫走全表扫描或全索引扫描。即使你为 status 建了索引,COUNT(*) 仍可能无法利用它快速统计,尤其当该索引不是覆盖索引时。
- 优化方向不是“让 COUNT 快”,而是“让 COUNT 不必快”:UI 不显示总页数?那就别查
Count,改用查size + 1条判断has_next - 真要查总数,确保 WHERE 字段和主键都在同一索引中,例如
INDEX idx_status_id (status, id),这样COUNT(*)可走索引统计行数,避免回表 - 不要在事务里先
Count再Find:两次查询之间若有写入,总数和当前页数据必然不一致,业务上接受就留着,不接受就合并逻辑(比如用窗口函数或子查询一次拿结果+总数)
索引不是建了就生效,而是每一条 WHERE、ORDER BY、SELECT 都在悄悄决定它能不能被选中。千万级数据下,一个字段顺序错、一个字段漏掉、一个类型不匹配,就足以让分页从毫秒变秒级——这跟 GORM 版本无关,是 SQL 引擎的物理现实。











