GORM与Redis分页缓存需在游标设计、缓存键结构、空值兜底和并发控制四方面对齐。键应含user_id、sort_field、cursor_value(URL转义)、page_size及服务前缀;缓存未命中时用singleflight防击穿,查DB前校验游标并LIMIT+1;序列化须显式json.Marshal且字段导出带tag;通过用户版本号实现缓存一致性。

GORM 和 Redis 结合做分页缓存,不是简单地“查完塞进 Redis”,而是必须在游标设计、缓存键结构、空值兜底和并发控制四点上对齐,否则高并发下分页错乱、数据重复、缓存击穿会集中爆发。
游标分页的 Redis 键怎么设计才不冲突
用 OFFSET 分页没法缓存,必须切到游标(cursor)模式。键不能只拼 user_id 和 page_size,否则不同排序方式(比如按发布时间 vs 按点赞数)会互相覆盖。
推荐格式:"article:list:uid_{user_id}:sort_{sort_field}:cursor_{cursor_value}:size_{page_size}"
-
sort_field必须显式传入,比如"publish_time"或"like_num",避免业务侧默认值不一致 -
cursor_value要做 URL 安全转义(url.PathEscape),防止用户传入2026-08-21 14:00:00这类含空格/冒号的值导致键解析失败 - 不要用时间戳整数直接拼接——PostgreSQL 的
TIMESTAMP默认带微秒,Go 的time.Time.UnixMilli()和数据库实际存储可能差几个毫秒,造成游标跳页或重复 - 生产环境建议加服务前缀,如
"svc-article:list:...",多服务共用 Redis 实例时防 key 冲突
缓存未命中时如何避免 DB 打穿
分页请求缓存没命中,直接查 DB 是最危险的时刻。尤其当多个请求同时打中同一个游标(比如首页刷新高峰),就会触发缓存击穿。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 必须用
singleflight.Group包一层:同一key的并发请求只放行一个去查GORM,其余等待结果返回后直接写缓存 - 查 DB 前先校验游标有效性——比如
cursor是publish_time,就先db.Where("publish_time > ?", cursor).First(&dummy)看是否存在后续数据,避免无效游标反复穿透 - DB 查询必须带
LIMIT page_size + 1:多查一条用于判断是否为最后一页,但只返回page_size条给前端,避免漏掉边界逻辑 - 如果 DB 查不到任何数据,仍要写空缓存:
rdb.Set(ctx, key, "null", 60*time.Second),否则下次请求还会穿透
从 GORM 查询结果写入 Redis 的序列化陷阱
把 GORM 查出的 []Article 直接 json.Marshal 存 Redis 看似没问题,但两个细节常被忽略:
- 结构体字段必须全部导出(首字母大写),且每个字段带
json:"field_name"tag,否则json.Marshal返回空对象{} - 时间字段(
publish_time)默认序列化为 RFC3339 字符串,但前端可能期望 Unix 时间戳;要么统一转成int64存,要么在结构体里加自定义MarshalJSON方法,别依赖默认行为 - 别用
rdb.Set(ctx, key, articles, ttl)直接传 slice——go-redis会尝试调用其String()方法,结果是"[{...} {..}]"这种非法 JSON;必须显式data, _ := json.Marshal(articles)后再rdb.Set(ctx, key, data, ttl) - 反序列化时,先判断
err == redis.Nil,再判断返回值是否等于"null"字符串,两者都跳过 DB 查询
分页游标更新与缓存一致性怎么协同
用户发布新文章后,首页分页游标应自动失效——但你不可能删掉所有以该用户为作者的分页缓存键(数量不可控)。更务实的做法是引入“版本戳”:
- 为每个用户维护一个递增版本号:
"user:version:123",每次发文章就INCR一次 - 分页缓存键里嵌入这个版本号:
"article:list:uid_123:ver_5:sort_publish_time:cursor_..." - 查缓存前先
rdb.Get(ctx, "user:version:123").Val(),如果本地缓存键里的ver_5小于当前值,直接丢弃缓存、走 DB - 这个版本号 TTL 可设长些(比如 24 小时),避免频繁读取,且不用强一致——允许最多 1 分钟延迟
真正的难点不在代码长度,而在于游标语义、缓存键粒度、空值处理这三者必须咬合。少一个环节,分页就可能跨页重复、漏数据,或者缓存永远不更新。

















