缓存对gorm分页效果有限,因limit+offset查询无法复用、count未隔离导致总数错误、offset越大mysql扫描越慢;应缓存总数、高频页和游标锚点,而非每页结果。

分页查得慢,光加缓存没用——GORM 分页本身不缓存、COUNT 不隔离、OFFSET 越大越卡,缓存只能盖住表层,压不住 SQL 层的病根。
为什么缓存对 GORM 分页效果有限
很多人一看到分页慢就加 Redis,但实际发现首页快了,翻到第 50 页还是卡。根本原因是:GORM 的 Limit + Offset 查询本身无法被简单缓存复用——每页的 OFFSET 值不同,SQL 就不同,缓存 key 难统一;而总数查询 Count 若没隔离会污染主链,导致缓存命中的其实是错总数;更关键的是,MySQL 在 OFFSET > 100000 时必须扫描前 N 行,这部分开销 Redis 根本挡不住。
- 前端传
page=50&size=20→ 后端算出OFFSET=980→ 生成新 SQL → 缓存 key 变成users:page:50:size:20:order:id_asc,和 page=49 完全不共享 -
db.Where("status = ?", 1).Count(&total)如果复用了带Limit(20)的 db 实例,total永远 ≤ 20 - 缓存能存下
SELECT * FROM users ORDER BY id ASC LIMIT 20 OFFSET 980的结果,但下一次OFFSET=1000还得重新执行慢查询
缓存该缓什么、怎么缓才真有效
与其缓每一页的完整结果,不如缓三类高价值、低变更的数据:
-
总数(Total):用户刚进列表页时最需要,且变动频率远低于明细。用
cache.Set("users:total:status_1", total, time.Hour),配合写操作时cache.Del("users:total:status_1") -
高频页(如前 5 页):用固定 key 缓存,比如
users:list:page_1:size_20,设置较短 TTL(如 5 分钟),避免 stale 数据影响太大 -
游标锚点(Cursor-based anchor):如果已切游标分页,缓存上一页最后一条的
id和created_at,比如users:cursor:page_5:last_id,减少重复计算
别缓 Find(&users) 返回的整个 slice —— Go struct 序列化/反序列化开销大,且内存占用随 size 线性涨;直接缓 JSON 字节流更轻量,用 json.Marshal 后存 []byte。
GORM 分页 + 缓存的典型错误组合
这几个搭配看似省事,实则埋雷:
- 用
Preload("Orders")分页再塞进缓存:缓存体积爆炸,且订单数据更新频繁,缓存失效策略难设计 - 总数查完不校验是否为 0 就直接缓存:
total=0可能是条件写错(如WHERE deleted_at IS NOT NULL),缓存后用户永远看不到数据 - 缓存 key 拼接漏掉排序字段:
users:list:page_3vsusers:list:page_3:order:created_at_desc,导致升序页内容被降序页覆盖 - 用
db.Session(&gorm.Session{NewDB: true})做 Count 隔离,却忘了在缓存逻辑里也用同一 session —— 缓存的总数和实际查的页数据可能来自不同事务快照
真正降低延迟的关键不在缓存,而在绕过 OFFSET
当单页响应超过 300ms,优先检查是否还在用 Offset((page-1)*size)。这时候缓存只是止痛药,游标分页才是手术刀:
- 把
page=3&size=20改成传cursor=12345(即上一页最后一条的id),SQL 变成WHERE id > 12345 ORDER BY id ASC LIMIT 20 - 确保
id或(created_at, id)有联合索引,让数据库不用扫全表 - 游标值本身可缓存(如 Redis 的
zset存最近 1000 个活跃 cursor),但重点是它让每次查询都走索引范围扫描,和总数据量无关
缓存解决不了 OFFSET 的线性扫描问题,就像给跑车装消音器治不了发动机爆缸——得先换掉那台老引擎。











