gorm本身不处理缓存雪崩,分页必须与缓存协同设计;limit/offset在缓存失效时会因全表扫描+集体穿透导致数据库被打穿,应改用基于主键或时间戳的游标分页并配合差异化ttl。

直接说结论:GORM 本身不处理缓存雪崩,分页方案必须和缓存层协同设计,否则 OFFSET 越大越慢、缓存集体过期时数据库直接被打穿。
为什么 GORM 的 Limit/Offset 在缓存场景下很危险
默认分页写法如 db.Offset(10000).Limit(20) 会触发全表扫描+跳过前 10000 行,数据库压力随页码线性增长。一旦缓存(比如 Redis)中这批分页 key 集体过期,所有请求瞬间落到 DB,就是典型的缓存雪崩。
- MySQL 的
OFFSET实际执行是“先取 10020 行,再丢掉前 10000 行”,不是跳转到第 10000 行开始读 - Redis 中缓存的分页 key(如
users:page:500)若统一设EXPIRE 30m,整点刷新时容易撞上雪崩窗口 - GORM 不会自动帮你把
Offset转成游标(cursor),得自己改查询逻辑
用游标分页替代 Offset + 随机 TTL 防雪崩
核心是放弃“第几页”概念,改用“从某条记录之后取 N 条”。这样缓存 key 可以基于最后一条数据的主键或时间戳生成,天然支持增量更新,也便于设置差异化过期时间。
- 缓存 key 示例:
users:after_id:12345:limit:20,而不是users:page:500 - 查询语句要重写为:
WHERE id > ? ORDER BY id ASC LIMIT ?,GORM 可用Where+Order+Limit组合实现 - TTL 不固定:对热点游标(如
id=1开始的第一页)设长过期(1h),冷门游标(id=999999)设短过期(5m),打散过期时间 - 避免空游标穿透:如果
id > 999999查不到数据,仍要缓存一个空结果(如"[]")并设60s过期,防止反复穿透
gorm.Model 分页结果缓存需带版本号或时间戳
即使用了游标,缓存内容本身也要防“旧数据长期滞留”。尤其当业务允许后台异步更新(如 CMS 编辑后延迟生效),缓存里的分页列表可能已过时。
- 缓存 value 不要只存原始数据,建议包装成结构体:
{"data": [...], "updated_at": "2026-08-21T13:00:00Z", "version": 123} - 每次写缓存前,先查一次 DB 的最大
updated_at或最新id,作为该分页块的逻辑版本;读缓存时比对,若本地版本落后则主动回源 - 不要依赖 GORM 的
UpdatedAt字段做全局版本——它只反映单条记录,分页结果是聚合视图,需要单独维护聚合版本
警惕 Count 查询在缓存失效时的放大效应
很多分页 UI 需要总条数(Total),GORM 的 Count 方法常被忽略性能代价。缓存失效时,它会和分页主查询一起打到 DB,且无法用游标优化。
- 除非业务强依赖精确总数(如后台管理),否则前端改用“是否还有下一页”代替
Total—— 只查limit + 1条,多出来的那条仅作判断 - 如果必须显示总数,把它单独缓存为
users:total,用定时任务或数据库 trigger 异步更新,过期时间设为10m,允许短暂不准 - 避免在每次分页请求里都调
db.Model(&User{}).Count(),GORM 不会自动复用已有缓存
真正难的不是写对游标 SQL,而是让缓存 key 的生命周期、value 的新鲜度、DB 查询的粒度三者对齐。随便一个环节脱节,雪崩就藏在下次批量过期之后。











