--- id: backend.redis.cache-patterns title: 缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性 type: lesson domain: backend stack: [redis] tags: [旁路缓存, 缓存穿透, 缓存击穿, 缓存雪崩, 一致性, singleflight] level: intermediate prerequisites: [backend.redis.data-structures] est_hours: 3 related: [backend.redis.lock-ratelimit, backend.redis.interview] week: [3, 6] order: 2 --- # 缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性 ## 解释说明 **旁路缓存(Cache Aside)**——最常用: - 读:先读缓存,命中直接返回;未命中读数据库,写入缓存(带过期时间)再返回。 - 写:**先更新数据库,再删除缓存**(不是更新缓存)。下一次读自然会加载新值。 为什么删除而不是更新缓存:并发写时两个请求更新缓存的顺序可能和更新数据库的顺序不一致,导致缓存里留下旧值;删除更简单,也避免为不常读的数据计算缓存。 **三个经典问题** | 问题 | 现象 | 解决 | | --- | --- | --- | | 穿透 | 查询**不存在**的数据,每次都打到数据库(常见于恶意请求) | 缓存空值(短 TTL);参数校验;布隆过滤器 | | 击穿 | 一个**热点 key** 过期瞬间,大量请求同时查库 | 同一个 key 只让一个请求回源(singleflight / 互斥锁);热点 key 逻辑过期 | | 雪崩 | **大量 key 同时过期**或 Redis 宕机,请求全部压到数据库 | 过期时间加随机值;Redis 高可用;限流降级 | **一致性**:“先更新库再删缓存”仍有极小概率不一致(读请求在删除前读到旧值并在删除后写回)。常见补充:延迟双删(更新后过一会儿再删一次)、订阅 binlog 异步删除缓存、给缓存设较短 TTL 兜底。缓存只能做到**最终一致**,强一致需求(如余额)不要读缓存。 ## 案例 商品详情页 QPS 高,数据库扛不住: - 详情缓存 10 分钟 + 0~60 秒随机值,防止同时过期。 - 不存在的商品 ID 缓存空值 1 分钟,防穿透。 - 热门商品过期时用 singleflight 合并回源请求,防击穿。 - 后台修改商品后删除缓存。 ## 代码示例 旁路缓存 + 空值缓存 + singleflight(`golang.org/x/sync/singleflight` 合并同一进程内的并发回源): ```go package main import ( "context" "encoding/json" "errors" "fmt" "math/rand/v2" "os" "sync" "sync/atomic" "time" "github.com/redis/go-redis/v9" "golang.org/x/sync/singleflight" ) type Product struct { ID int64 `json:"id"` Name string `json:"name"` Price int64 `json:"price"` } var ErrNotFound = errors.New("商品不存在") const nullValue = "null" // 空值标记,防穿透 type ProductCache struct { rdb *redis.Client group singleflight.Group load func(ctx context.Context, id int64) (*Product, error) // 查数据库 } func (c *ProductCache) Get(ctx context.Context, id int64) (*Product, error) { key := fmt.Sprintf("product:%d", id) s, err := c.rdb.Get(ctx, key).Result() if err == nil { if s == nullValue { return nil, ErrNotFound } var p Product if err := json.Unmarshal([]byte(s), &p); err == nil { return &p, nil } } else if !errors.Is(err, redis.Nil) { // Redis 出错时降级直接查库(也可以选择返回错误,看业务) return c.load(ctx, id) } // 未命中:同一个 key 的并发请求只有一个去查库 v, err, _ := c.group.Do(key, func() (any, error) { p, err := c.load(ctx, id) if errors.Is(err, ErrNotFound) { c.rdb.Set(ctx, key, nullValue, time.Minute) return nil, err } if err != nil { return nil, err } b, _ := json.Marshal(p) ttl := 10*time.Minute + time.Duration(rand.IntN(60))*time.Second // 随机过期,防雪崩 c.rdb.Set(ctx, key, b, ttl) return p, nil }) if err != nil { return nil, err } return v.(*Product), nil } // Update:先更新数据库,再删除缓存 func (c *ProductCache) Update(ctx context.Context, p *Product, save func(*Product) error) error { if err := save(p); err != nil { return err } return c.rdb.Del(ctx, fmt.Sprintf("product:%d", p.ID)).Err() } func main() { url := os.Getenv("REDIS_URL") if url == "" { fmt.Println("未设置 REDIS_URL,跳过") return } opt, _ := redis.ParseURL(url) rdb := redis.NewClient(opt) defer rdb.Close() ctx := context.Background() rdb.Del(ctx, "product:1", "product:404") var dbHits atomic.Int32 c := &ProductCache{rdb: rdb, load: func(ctx context.Context, id int64) (*Product, error) { dbHits.Add(1) time.Sleep(50 * time.Millisecond) // 模拟慢查询 if id == 404 { return nil, ErrNotFound } return &Product{ID: id, Name: "键盘", Price: 19900}, nil }} var wg sync.WaitGroup for range 100 { // 100 个并发请求同一个未缓存的商品 wg.Add(1) go func() { defer wg.Done(); _, _ = c.Get(ctx, 1) }() } wg.Wait() fmt.Println("100 个并发请求,查库次数:", dbHits.Load()) // 1 _, err := c.Get(ctx, 404) _, err2 := c.Get(ctx, 404) fmt.Println(err, err2, "查库次数:", dbHits.Load()) // 第二次命中空值缓存,仍是 2 rdb.Del(ctx, "product:1", "product:404") } ``` ## 易错点 - **先删缓存再更新数据库**:删除后、更新前有读请求进来,会把旧值重新写进缓存,不一致窗口更大。 - **更新数据库后更新缓存**:并发写时可能写入旧值;且很多数据写多读少,白白计算。 - **空值缓存 TTL 太长**:数据后来被创建了,用户仍然看到“不存在”。 - **singleflight 只在单个进程内生效**:多实例部署时每个实例仍会各回源一次,一般可以接受;需要全局只回源一次时用分布式锁。 - **缓存序列化格式变了**:新版本读不了旧缓存,key 带版本号(如 `product:v2:1`)。 - **所有接口都上缓存**:缓存带来一致性问题,只给读多写少、能接受短暂旧数据的接口加。 ## 面试常问 见 [Redis 面试题](interview.md):穿透 / 击穿 / 雪崩、缓存和数据库一致性、为什么删缓存而不是更新缓存。