应改用游标分页替代limit+offset分页,通过传递上一页末尾排序字段值(如after_id)实现高效分页,避免大offset导致的性能断崖;paginationq中data必须为指针切片,total统计需按数据量分级优化或直接省略。

直接用 Limit + Offset 做分页,在小数据量下没问题,但一旦表行数过百万、Offset 值很大(比如 Offset=100000),MySQL 就会扫描并丢弃前 10 万行,性能断崖式下跌。这不是 GORM 的锅,是 SQL 分页的通病——必须换思路。
为什么不能只靠 Limit 和 Offset
本质是数据库执行计划退化:MySQL 无法跳过大量行而不扫描。即使加了索引,只要 ORDER BY id + OFFSET 100000,它仍得定位到第 100001 行的物理位置。
- 真实线上查
/api/logs?page=10001&size=20,响应可能从 20ms 涨到 2s+ -
Count(*)统计总条数在大表上也极慢,尤其没覆盖索引时 - GORM 的
db.Count(&total)默认走全表扫描,不是优化过的EXPLAIN友好型
用游标分页(cursor-based)替代页码分页
适合日志、消息流、审计记录等天然有序、追加写多的场景。核心是不传 page,改传上一页最后一条的排序字段值(如 id 或 created_at)。
- 请求变成:
/api/ssh-log?after_id=12345&size=10,服务端查WHERE id > 12345 ORDER BY id ASC LIMIT 11 - 返回结果剔除第一条(用于下一页游标),剩余 10 条给前端;同时把新末尾的
id作为next_cursor字段返回 - 完全规避
OFFSET,索引能高效定位,响应稳定在 sub-50ms - 缺点:不支持跳转任意页,不能按“第 87 页”跳,只能逐页或前后翻
PaginationQ 结构体里 Data 字段必须是指针切片
这是 GORM 查询最常踩的坑——如果 Data 定义成 interface{} 但传入的是非指针切片(如 []SshLog 而非 *[]SshLog),db.Find() 会静默失败,Data 保持 nil,且不报错。
- 正确绑定方式:
c.ShouldBindQuery(&q); var list []SshLog; err := db.Find(&list).Error,然后手动赋值q.Data = &list - 更安全的做法:结构体里直接定义为
Data *[]SshLog,并在 handler 中做非空检查:if q.Data == nil { q.Data = &[]SshLog{} } - 别依赖
json:"data" comment:"must be a pointer of slice gorm.Model"这种注释——GORM 不读注释,只认 Go 类型系统
总条数 Total 不要无脑 Count(*)
对千万级表,SELECT COUNT(*) FROM ssh_log WHERE client_ip = ? 可能锁表或拖垮查询队列。生产环境应分级处理:
- 数据量 ≤ 10 万:用
db.Where(...).Count(&total),快且准 - 10 万 ~ 100 万:加覆盖索引(如
INDEX(client_ip, id)),避免回表 - >100 万:改用估算值,例如查
SHOW TABLE STATUS LIKE 'ssh_log'的Rows字段(误差 ±20%,但毫秒级);或业务接受“仅显示前 N 页”,Total = min(estimated, page_limit * max_page)
游标分页本身就不需要总页数,所以如果你选了游标方案,Total 字段可以直接去掉——少一个慢查询,少一个故障点。
大量免费API接口:立即使用
涵盖生活服务API、金融科技API、企业工商API、等相关的API接口服务。免费API接口可安全、合规地连接上下游,为数据API应用能力赋能!











