背景

缓存能提升性能,但也最容易制造隐性 bug。

线上常见问题:

  • 数据库已更新,缓存还是旧值
  • 热点 key 失效瞬间把数据库打穿
  • 多服务写入同一份数据,更新顺序错乱

在 Go 服务里,缓存一致性通常不是“技术选型”问题,而是“写路径设计”问题。

常见策略

Cache Aside

最常见模型:读先查缓存,未命中再查库并回填;写时先写库,再删缓存。

func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)

    if val, ok := s.cache.Get(ctx, key); ok {
        return decodeUser(val)
    }

    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, err
    }

    _ = s.cache.Set(ctx, key, encodeUser(user), 5*time.Minute)
    return user, nil
}

func (s *UserService) UpdateUser(ctx context.Context, user *User) error {
    if err := s.repo.Update(ctx, user); err != nil {
        return err
    }

    key := fmt.Sprintf("user:%d", user.ID)
    _ = s.cache.Delete(ctx, key)
    return nil
}

这个模型简单、可靠,适合大多数业务系统。

双删策略的取舍

有些场景会用“先删缓存,再写库,延迟再删一次”。

它能降低极端并发下的脏读概率,但不是银弹。更关键的还是:

  • 写操作是否集中在一条服务链路
  • 有没有事件通知机制统一刷新
  • key 的 TTL 是否合理

避免缓存雪崩

两个实用点:

  1. TTL 加随机抖动
  2. 热点 key 做单飞保护
var g singleflight.Group

func (s *UserService) GetUserWithSingleflight(ctx context.Context, id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)
    v, err, _ := g.Do(key, func() (interface{}, error) {
        return s.GetUser(ctx, id)
    })
    if err != nil {
        return nil, err
    }
    return v.(*User), nil
}

总结

缓存一致性最重要的不是某个技巧,而是明确一致性目标:

  • 允许多久的短暂不一致
  • 哪些字段必须强一致
  • 失败时优先保可用还是保最新

这些边界写清楚了,缓存才是加速器,不是故障放大器。


一致性问题的本质是系统语义问题,技术细节只是实现手段。