行深膳食-Redis做分布式缓存

// 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), &sections) == 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), &sections) == 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), &sections) == 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 没被打爆。