6.0 KiB
6.0 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | order | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| backend.redis.cache-patterns | 缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性 | lesson | backend |
|
|
intermediate |
|
3 |
|
|
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 合并同一进程内的并发回源):
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 面试题:穿透 / 击穿 / 雪崩、缓存和数据库一致性、为什么删缓存而不是更新缓存。