Files

177 lines
6.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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):穿透 / 击穿 / 雪崩、缓存和数据库一致性、为什么删缓存而不是更新缓存。