MariaDB 的 OFFSET 越大越慢,因其需真实扫描并丢弃前 N 行,I/O 与 CPU 开销随 offset 线性增长;>50000 时响应常超 1.2 秒,易拖垮连接池。

为什么 MariaDB 的 OFFSET 越大越慢
MariaDB 执行 SELECT * FROM orders LIMIT 20 OFFSET 100000 时,不会跳过前 10 万行,而是真实扫描、排序(若无覆盖索引)、丢弃——哪怕 id 已建主键索引。B+ 树仍需从根节点逐层跳转到第 100001 条物理位置,I/O 和 CPU 开销随 offset 线性增长。实测中,offset > 5000 就可能触发慢查询日志;> 50000 时响应常超 1.2 秒,且并发稍高就拖垮连接池。
常见错误现象:db.Offset(99999).Limit(20).Find(&orders) 在压测中引发 MariaDB Threads_running 持续 > 50,Handler_read_next 暴涨;同一页刷新两次,created_at 相同的两条记录顺序颠倒或漏掉一条。
- 必须显式写
Order("id ASC")或Order("created_at DESC, id DESC"),无序分页在 MariaDB 中结果不可重现 -
Order("status ASC")不可靠:status 值重复率高,MariaDB 内部排序不稳定 - 避免
Order("RAND()")—— 无法分页续查,且全表扫描 - 前端传的
page和page_size必须校验:page ≤ 0 强制设为 1;page_size 超过 100 则截断为 100,防止恶意构造OFFSET 1000000
GORM 游标分页在 MariaDB 中怎么写才不丢数据
游标分页本质是把“第 N 页”换成“从某条记录之后开始取”,绕开 OFFSET 的物理扫描缺陷。MariaDB 对 WHERE id > ? ORDER BY id LIMIT 20 能走主键索引范围扫描,性能稳定在 5–20ms,与总数据量无关。
关键约束:
- 首请求不带游标:
db.Order("id ASC").Limit(21).Find(&orders)(多查 1 条判断是否有下一页) - 下一页游标必须基于当前页最后一条记录的
id值生成,不是第一条 - 排序字段必须有索引,且复合排序时索引顺序严格匹配,例如
Order("status, created_at DESC, id DESC")需对应INDEX idx_status_created_id (status, created_at, id) - 前端透传
last_id(非页码),后端不做转换;若last_id为负数或非整数,直接返回 400,不静默 fallback - 时间字段用
created_at作游标时,MariaDB 默认精度是秒,高并发下易重复,建议改用BIGINT时间戳或加id作为第二排序字段兜底
GORM 的 Count 查询在 MariaDB 中为什么不准
db.Where("status = ?", "paid").Limit(20).Offset(40).Count(&total) 查出的 total 永远 ≤ 20,因为 GORM v2 的 Count() 方法会复用链式调用中的 Limit 和 Offset,这不是 bug,是设计行为。
更隐蔽的问题是关联查询:你写了 db.Joins("JOIN payments ON orders.id = payments.order_id").Where("payments.state = ?", "success") 查订单,却用 db.Model(&Order{}).Where(...).Count() 算总数——它没走 JOIN,结果虚高。
- 简单场景:用独立会话隔离条件:
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 payments p ON o.id = p.order_id WHERE p.state = ?) AS t", "success").Scan(&total) - UI 不强制显示“共 XX 条”时,别查总数——省一次全表扫描,QPS 可翻倍;改用查
LIMIT 21后判断 len(orders) == 21 来返回has_next: true - 缓存 total 只适用于低频变更数据(如配置项),用户订单类动态数据缓存反而引入一致性风险
MariaDB 特定优化点:索引与配置联动
MariaDB 的 optimizer_switch 和索引策略对分页影响极大。即使写了 Order("id ASC"),若没有覆盖索引,SELECT * 仍可能回表,offset 大时延迟飙升。
- 确保分页字段有单列索引或复合索引前导列,例如
id主键天然满足,但created_at必须手动建INDEX idx_created_at (created_at) - 避免
SELECT *:用Select("id, status, created_at")减少网络传输和内存拷贝,尤其字段含TEXT或BLOB时 - 检查 MariaDB 的
sort_buffer_size:默认 2MB,若分页排序字段未走索引,该值过小会导致磁盘临时文件,大幅拖慢;线上建议调至 4–8MB - 开启
PrepareStmt: true:GORM 预编译语句缓存对重复分页查询(相同 limit/offset 组合)性能提升明显,实测降低 30%+ CPU - 连接池参数必须调优:
SetMaxOpenConns(100)+SetMaxIdleConns(20),否则高并发分页请求会卡在连接获取阶段
游标分页不是“更高级的选项”,而是 MariaDB 上百万级订单表的生存底线。最容易被忽略的是:游标值必须来自上一页的最后一条,且排序字段索引必须严格匹配查询顺序——错一个字符,就可能返回空 slice 或重复数据。











