177 lines
6.0 KiB
Markdown
177 lines
6.0 KiB
Markdown
---
|
||
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):穿透 / 击穿 / 雪崩、缓存和数据库一致性、为什么删缓存而不是更新缓存。
|