背景
缓存能提升性能,但也最容易制造隐性 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 是否合理
避免缓存雪崩
两个实用点:
- TTL 加随机抖动
- 热点 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
}
总结
缓存一致性最重要的不是某个技巧,而是明确一致性目标:
- 允许多久的短暂不一致
- 哪些字段必须强一致
- 失败时优先保可用还是保最新
这些边界写清楚了,缓存才是加速器,不是故障放大器。
一致性问题的本质是系统语义问题,技术细节只是实现手段。