游标分页替代offset可避免全表扫描:用created_at和id双字段游标,配合(created_at, status, id)复合索引,并按排序方向调整索引顺序,辅以时间切片与物化视图优化宽范围查询。

直接用 WHERE created_at BETWEEN ? AND ? 加 Limit/Offset 做分页,在日期范围大、数据量高时,MySQL/PostgreSQL 很容易走全表扫描或索引失效——这不是写法错,是索引覆盖和查询模式不匹配导致的。
为什么日期范围 + OFFSET 分页会变慢
当 created_at 上有索引,但查询条件是宽泛区间(比如“近一年”),数据库优化器可能放弃使用该索引做排序+跳过,转而用主键索引扫描再过滤。尤其在 OFFSET 值很大时(如第1000页),它仍要定位前 9990 条记录,哪怕只取10条。
- PostgreSQL 中,
EXPLAIN ANALYZE显示Bitmap Heap Scan后跟大量Recheck Cond,说明索引没完全覆盖过滤逻辑 - MySQL 8.0+ 的
index merge在多条件组合下不稳定,BETWEEN+ORDER BY id容易触发Using filesort - 即使加了
created_at单列索引,若查询还带status = 'active'等其他字段,复合索引顺序没对齐,照样失效
用游标分页替代 OFFSET(推荐)
把“第 N 页”改成“从某时间点之后取下一批”,避免跳过历史数据。前提是结果必须有严格单调字段(如 created_at 或 id),且前端能传回上一页最后一条的值。
- 后端接收上一页最后一条的
created_at和id(双字段防时间重复):last_time=2025-06-01T08:30:00Z&last_id=12345 - 查询改写为:
db.Where("created_at > ? OR (created_at = ? AND id > ?)", lastTime, lastTime, lastID).Order("created_at ASC, id ASC").Limit(p.PageSize) - 必须确保
ORDER BY字段与WHERE条件中使用的字段一致,否则游标逻辑断裂 - 首次请求无游标值时,用
created_at >= ?起始,不要留空条件
复合索引怎么建才真正生效
单列 created_at 索引对分页帮助有限;关键是要让索引同时支撑过滤 + 排序 + 游标比较。
- 如果按时间正序分页且常查
status,建复合索引:CREATE INDEX idx_created_status_id ON users (created_at, status, id) - 若排序是
created_at DESC,索引字段顺序也要倒过来:(created_at DESC, status, id DESC),否则无法索引下推 - PostgreSQL 支持包括式索引(
INCLUDE),可把常用 SELECT 字段塞进去减少回表:CREATE INDEX idx_created_inc ON users (created_at) INCLUDE (name, email) - GORM 迁移中声明:
CreatedAt time.Time `gorm:"index:idx_created_status_id,priority:1"`,注意priority控制复合索引内字段顺序
时间范围太宽时,先缩小再分页
用户搜“2020–2026年所有订单”,直接查会崩。得在分页前主动切片,而不是依赖数据库硬扛。
- 服务层预判:若时间跨度 > 90 天,强制按月分桶,先查出有哪些月份有数据(
SELECT DISTINCT DATE_TRUNC('month', created_at) FROM orders WHERE ...) - 每个有效月份单独分页,前端合并渲染;避免单次 SQL 扫描数千万行
- 对高频时间维度(如“本周”“本月”)加物化视图或汇总表,GORM 查询时用
Raw直接查预计算结果 - 别在 GORM 链式调用里拼
Where("created_at >= ? AND created_at 就完事——得评估这个 <code>end - start是否在索引选择性容忍范围内
最易被忽略的一点:游标分页不是加个 WHERE 就完事,它要求每次查询的 ORDER BY 必须稳定、可复现。如果业务允许时间微秒级重复,又没主键兜底,游标就断了。这时候得在模型里加唯一序列字段,或者用 id 强制补位。











