// GetSections 查询小节列表
func (s *SectionService) GetSections(c *gin.Context) *utils.Response {
// 1. 提取并校验请求参数
chapterNum := c.Query("chapter_num")
guidelineIDStr := c.Query("guideline_id")
guidelineID := utils.StrToInt(guidelineIDStr)
// 参数严格校验:guideline_id必须正整数 且 chapter_num不能为空
if guidelineID <= 0 && chapterNum == "" {
utils.Logger.Warn("小节查询参数校验失败", zap.String("chapterNum", chapterNum), zap.String("guidelineIDStr", guidelineIDStr))
return &utils.Response{
Code: 400,
Msg: "Chapter num is required and Guideline ID must be a positive integer",
Data: nil,
}
}
// ===== 查 Redis 缓存 =====
// cacheKey 带两个查询条件:guideline_id + chapter_num
cacheKey := consts.RedisKeySectionList + strconv.Itoa(guidelineID) + ":" + chapterNum
ctx := context.Background()
if val, err := s.redisUtil.GetCache(ctx, cacheKey); err == nil {
var sections []guidelines.FoodDGSection // ← 注意:按你 model 里的实际类型改
if json.Unmarshal([]byte(val), §ions) == nil {
utils.Logger.Info("缓存命中", zap.String("cacheKey", cacheKey), zap.Int("条数", len(sections)))
return &utils.Response{
Code: 200,
Msg: "success",
Data: sections,
}
}
} else {
utils.Logger.Info("缓存未命中,查DB", zap.String("cacheKey", cacheKey), zap.Error(err))
}
// ===== 缓存未命中,加互斥锁防击穿 =====
lockKey := cacheKey + ":lock"
gotLock := s.redisUtil.Lock(ctx, lockKey, 10) // 锁 10 秒(Lock 方法签名:Lock(ctx, key, expire秒))
if gotLock {
// 抢到锁的请求:再查一次缓存(可能别的请求刚重建完),没有再查 DB
if val, err := s.redisUtil.GetCache(ctx, cacheKey); err == nil {
var sections []guidelines.FoodDGSection
if json.Unmarshal([]byte(val), §ions) == nil {
utils.Logger.Info("锁内缓存命中(已被重建)", zap.String("cacheKey", cacheKey))
s.redisUtil.UnLock(ctx, lockKey) // 释放锁
return &utils.Response{
Code: 200,
Msg: "success",
Data: sections,
}
}
}
// 2. 调用仓储层查询数据
sections, err := s.sectionRepo.GetSections(chapterNum, guidelineID)
if err != nil {
// 区分参数错误和系统错误
if errors.Is(err, errors.New("invalid_param")) {
return &utils.Response{
Code: 400,
Msg: "invalid_param",
Data: nil,
}
}
utils.Logger.Error("查询小节列表业务处理失败", zap.String("chapterNum", chapterNum), zap.Int("guidelineID", guidelineID), zap.Error(err))
return &utils.Response{
Code: 500,
Msg: "查询小节列表失败",
Data: nil,
}
}
// ===== 写回 Redis 缓存 =====
if data, err := json.Marshal(sections); err == nil {
ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second
if err := s.redisUtil.SetCache(ctx, cacheKey, data, ttl); err != nil {
utils.Logger.Warn("写缓存失败", zap.String("cacheKey", cacheKey), zap.Error(err))
} else {
utils.Logger.Info("写缓存成功", zap.String("cacheKey", cacheKey), zap.Duration("ttl", ttl))
}
}
s.redisUtil.UnLock(ctx, lockKey) // 释放锁
return &utils.Response{
Code: 200,
Msg: "success",
Data: sections,
}
}
// ===== 没抢到锁,说明别人正在重建,短暂等待后重读缓存 =====
time.Sleep(100 * time.Millisecond)
if val, err := s.redisUtil.GetCache(ctx, cacheKey); err == nil {
var sections []guidelines.FoodDGSection
if json.Unmarshal([]byte(val), §ions) == nil {
utils.Logger.Info("等锁后缓存命中", zap.String("cacheKey", cacheKey))
return &utils.Response{
Code: 200,
Msg: "success",
Data: sections,
}
}
}
// 重读还是没有(极少数:DB 慢/锁过期),降级直接查 DB,不阻塞用户
utils.Logger.Warn("等锁后仍未命中,降级直接查DB", zap.String("cacheKey", cacheKey))
sections, err := s.sectionRepo.GetSections(chapterNum, guidelineID)
if err != nil {
utils.Logger.Error("降级查询小节失败", zap.String("chapterNum", chapterNum), zap.Int("guidelineID", guidelineID), zap.Error(err))
return &utils.Response{
Code: 500,
Msg: "查询小节列表失败",
Data: nil,
}
}
// 组装成功响应
return &utils.Response{
Code: 200,
Msg: "success",
Data: sections,
}
}
TTL 过期
ttl := 5*time.Minute + ...
这就是缓存淘汰策略(TTL 到期自动淘汰)
Cache Aside 读路径
// 查缓存
if val, err := s.redisUtil.GetCache(ctx, cacheKey); err == nil {
...
return ...
}
// 没命中,查 DB
sections, err := s.sectionRepo.GetSections(...)
// 查完回填
s.redisUtil.SetCache(ctx, cacheKey, data, ttl)
以上是标准读穿
防雪崩
time.Duration(rand.Intn(60))*time.Second
随机偏移,避免 key 同时过期,已体现。解决高峰期同一波请求创建的一批 key
很多 key 是在同一时刻被创建的,这时候它们天然会同秒过期。晚上 8 点,100 个新用户同时首次打开 App;这 100 个用户同时查 “饮食记录”,这种按用户维度、key 数量巨大、且可能同时创建的场景,随机偏移就有用。缓存都没命中;同一时刻创建了 100 个 key,都设 TTL 5 分钟;8 点 05 分,这 100 个 key 同时过期;又有一批新用户同时查 → 100 个请求同时打 DB。
防击穿
所谓击穿:一个热点 key 刚好过期了,一瞬间成千上万个请求同时发现缓存没命中,全部涌进 MySQL,把 DB 打挂。
使用互斥锁,缓存失效时,只有第一个请求去查 DB 重建缓存,其他请求等一下再读缓存。
gotLock := s.redisUtil.Lock(ctx, lockKey, 10)
...
s.redisUtil.UnLock(ctx, lockKey)
Lock 是 SETNX 实现,10 秒自动过期(防止请求崩了锁不释放)
第一道:查缓存 → 命中直接返回
第二道:没命中 → 抢锁 → 抢到的查 DB 重建
第三道:没抢到锁 → 等 100ms → 重读缓存 → 还没有就降级查 DB
锁内再查一次缓存:防止 “抢到锁的瞬间,别人已经重建好了”,避免重复查 DB;出错也要释放锁:如果查 DB 失败不释放锁,锁要卡 10 秒,期间所有请求都走 “等锁” 分支,变慢;等锁后降级兜底:万一 DB 很慢、锁 10 秒过期了还没重建好,直接查 DB,不让用户无限等待。
流程演示(3 个请求同时来)假设缓存刚好过期,3 个请求 A、B、C 同时到:
请求A → 缓存未命中 → 抢到锁 → 查 DB → 回填缓存 → 释放锁
请求B → 缓存未命中 → 没抢到锁 → 等100ms → 重读缓存 → 命中返回
请求C → 缓存未命中 → 没抢到锁 → 等100ms → 重读缓存 → 命中返回
只有 A 查了 DB,B、C 都在等锁后直接读缓存,MySQL 没被打爆。