Redis-14 缓存设计与经典问题
1. 缓存的基本模式
在讲问题之前,先明确缓存的三种经典读写模式。
1.1 Cache Aside(旁路缓存,最常用)
读:
1. 先读缓存
2. 命中 → 直接返回
3. 未命中 → 读数据库 → 写入缓存 → 返回
写:
1. 更新数据库
2. 【删除】缓存(不是更新缓存)
func GetUser(ctx context.Context, id int64) (*User, error) {
key := fmt.Sprintf("user:%d", id)
// 1. 读缓存
val, err := rdb.Get(ctx, key).Result()
if err == nil {
var u User
json.Unmarshal([]byte(val), &u)
return &u, nil
}
if err != redis.Nil {
return nil, err // Redis 故障,可以选择降级直接读 DB
}
// 2. 缓存未命中,读数据库
u, err := db.GetUser(ctx, id)
if err != nil {
return nil, err
}
// 3. 回写缓存(带随机 TTL 抖动)
data, _ := json.Marshal(u)
ttl := time.Hour + time.Duration(rand.Intn(300))*time.Second
rdb.Set(ctx, key, data, ttl)
return u, nil
}
func UpdateUser(ctx context.Context, u *User) error {
// 1. 先更新数据库
if err := db.UpdateUser(ctx, u); err != nil {
return err
}
// 2. 再删除缓存
rdb.Del(ctx, fmt.Sprintf("user:%d", u.ID))
return nil
}
特点:应用代码同时管理缓存和数据库,逻辑清晰,是绝大多数业务的选择。
1.2 Read/Write Through(读写穿透)
应用只跟缓存打交道,由缓存服务负责同步数据库:
读:应用 → 缓存服务 →(未命中时)缓存服务自己去读 DB 并回填 → 返回应用
写:应用 → 缓存服务 → 缓存服务【同步】写 DB 和缓存
特点:应用逻辑最简单,但需要一个"缓存服务"层(如 Amazon DAX、某些 ORM 的二级缓存、Ehcache)。Redis 本身不提供这个能力,需要自己封装或用中间件。
1.3 Write Behind(异步写回)
写:应用 → 只写缓存(立即返回)→ 缓存服务【异步批量】刷到数据库
特点:写性能最好(合并多次写、批量落库),但有丢数据风险(缓存挂了未刷盘的数据就没了)。
适用场景:能容忍丢失的高频写入,如阅读量、点赞数:
// 高频写只落 Redis
rdb.HIncrBy(ctx, "article:views", articleID, 1)
// 定时任务(如每分钟)批量刷到数据库
func FlushViews() {
views, _ := rdb.HGetAll(ctx, "article:views").Result()
// 批量 UPDATE 到数据库
db.BatchUpdateViews(views)
// 注意:这里有并发问题,正确做法是用 HGETALL + DEL 的 Lua 或者按时间分 key
}
1.4 三种模式对比
| 模式 | 读性能 | 写性能 | 一致性 | 实现复杂度 | 适用 |
|---|---|---|---|---|---|
| Cache Aside | 高 | 中 | 最终一致 | 低 | 绝大多数业务 |
| Read/Write Through | 高 | 中低 | 较强 | 高(需缓存服务) | 有成熟中间件时 |
| Write Behind | 高 | 最高 | 弱(可能丢) | 中 | 计数器等可容忍丢失的高频写 |
2. 缓存穿透
2.1 什么是缓存穿透
查询一个数据库里也不存在的数据。因为数据库返回空,通常代码不会写缓存,导致每次请求都穿过缓存打到数据库。
请求 GET /user/-1
→ 查 Redis:不存在
→ 查 MySQL:不存在
→ 【不写缓存】(因为没数据可写)
→ 返回空
下一次同样的请求,重复整个流程 → 数据库被反复查询
危害:如果被恶意攻击(用随机 ID 疯狂请求不存在的数据),数据库会被瞬间打爆。这是一种典型的攻击手法。
正常业务里也会发生:比如爬虫遍历不存在的 ID、前端 bug 传了错误参数、业务逻辑允许查询任意 ID。
2.2 解法一:缓存空值(最简单实用)
查不到数据时也写一个"空值标记"到缓存,并设一个较短的 TTL。
func GetUser(ctx context.Context, id int64) (*User, error) {
key := fmt.Sprintf("user:%d", id)
val, err := rdb.Get(ctx, key).Result()
if err == nil {
if val == "" || val == "NULL" {
return nil, ErrNotFound // 【命中空值缓存】,直接返回
}
var u User
json.Unmarshal([]byte(val), &u)
return &u, nil
}
u, err := db.GetUser(ctx, id)
if err == sql.ErrNoRows {
// 【关键】数据不存在也写缓存,但 TTL 要短(如 60 秒)
rdb.Set(ctx, key, "NULL", 60*time.Second)
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
data, _ := json.Marshal(u)
rdb.Set(ctx, key, data, time.Hour+randomJitter())
return u, nil
}
注意点:
- TTL 必须短(30~300 秒)。因为这个数据将来可能真的被创建,TTL 太长会导致"数据已创建但查不到";
- 空值要能和"正常的空字符串"区分开。用一个特殊标记(如
"NULL"、"\x00")或者用一个独立的 key 前缀; - 数据创建时要主动删除空值缓存:
func CreateUser(ctx context.Context, u *User) error {
if err := db.CreateUser(ctx, u); err != nil {
return err
}
rdb.Del(ctx, fmt.Sprintf("user:%d", u.ID)) // 删掉可能存在的空值缓存
return nil
}
- 缺点:如果攻击者用大量随机 ID,会产生海量空值缓存占用内存。所以要配合限流和布隆过滤器。
2.3 解法二:布隆过滤器
布隆过滤器(Bloom Filter) 是一个概率型数据结构,能高效判断"某个元素一定不存在"。
原理:
1. 一个长度为 m 的位数组(初始全 0)+ k 个独立的哈希函数
2. 【添加元素】:用 k 个哈希函数算出 k 个位置,把这些位都置 1
3. 【查询元素】:算出 k 个位置
├─ 【任意一位是 0】→ 【一定不存在】(100% 确定)
└─ 【全部是 1】→ 【可能存在】(有误判可能,因为这些位可能是别的元素置的)
核心特性:
- 没有假阴性(false negative):说不存在就一定不存在;
- 有假阳性(false positive):说存在可能实际不存在(误判率可控);
- 不支持删除(删一个元素可能把别的元素共用的位也清掉了)。计数布隆过滤器(Counting Bloom Filter)可以删,但内存翻几倍。
误判率公式:
误判率 p ≈ (1 - e^(-kn/m))^k
其中 n = 元素个数,m = 位数组长度,k = 哈希函数个数
最优 k = (m/n) * ln2
最优 m = -n * ln(p) / (ln2)^2
实用估算:1 亿个元素、1% 误判率,约需 114MB;误判率降到 0.1% 需要 171MB。
用 RedisBloom 模块(推荐)
# 安装模块后
BF.RESERVE users 0.001 100000000 # 误判率 0.1%,预期 1 亿元素
BF.ADD users 1001 # 添加
BF.MADD users 1002 1003 1004 # 批量添加
BF.EXISTS users 1001 # 1 = 可能存在,0 = 一定不存在
BF.MEXISTS users 1001 9999 # 批量查询
BF.INFO users # 查看容量、已插入数、误判率等
# 可扩展布隆过滤器(超出容量自动扩容,但误判率会上升)
BF.RESERVE users 0.001 1000000 EXPANSION 2
# NONSCALING 表示不扩容,满了报错
业务代码集成
func GetUser(ctx context.Context, id int64) (*User, error) {
// 【第一道防线】布隆过滤器
exists, err := rdb.Do(ctx, "BF.EXISTS", "bf:users", id).Bool()
if err == nil && !exists {
return nil, ErrNotFound // 【一定不存在,直接返回,不查 DB】
}
// 【第二道防线】空值缓存 + 正常缓存
// ...(同 2.2)
}
// 新增数据时要加入布隆过滤器
func CreateUser(ctx context.Context, u *User) error {
if err := db.CreateUser(ctx, u); err != nil {
return err
}
rdb.Do(ctx, "BF.ADD", "bf:users", u.ID)
rdb.Del(ctx, fmt.Sprintf("user:%d", u.ID))
return nil
}
布隆过滤器的实践难点:
- 需要预热:上线时要把已有的全部 ID 灌进去(几亿条数据的初始化可能要跑很久,用
BF.MADD批量 + pipeline); - 不能删除:数据被删除后布隆过滤器里还在,会有误判(但只是多查一次 DB + 命中空值缓存,可接受)。要清理只能定期重建;
- 误判率与内存的权衡:误判率每降 10 倍,内存增加约 1.44 倍;
- 容量要预估准确:实际元素超过预设容量后误判率会急剧上升(
BF.RESERVE时留 2~3 倍余量,或用EXPANSION自动扩容); - 需要 RedisBloom 模块:自建 Redis 要额外装模块(云厂商的 Redis 通常已内置,如阿里云 Tair)。
自己用 Bitmap 实现(不推荐但要知道)
-- 添加:用多个哈希函数把 element 映射到 bitmap 的多个位
-- 查询:GETBIT 全为 1 才"可能存在"
不推荐自己实现的原因:哈希函数的质量、扩容、误判率控制都很难做好,而 RedisBloom 是经过验证的实现。
2.4 解法三:参数校验与限流
最基础但最容易被忽略的一层:
// 1. 参数合法性校验(挡住明显非法的请求)
if id <= 0 || id > maxValidID {
return nil, ErrInvalidParam
}
// 2. 对"缓存未命中"的请求做限流
// 正常业务的缓存命中率应该 > 95%,如果某个 IP/用户的未命中率异常高,
// 说明它在扫描不存在的数据 → 限流或拉黑
if missRate(clientIP) > 0.5 && missCount(clientIP) > 100 {
return nil, ErrTooManyRequests
}
2.5 三种解法对比
| 方案 | 内存开销 | 实现复杂度 | 效果 | 适用 |
|---|---|---|---|---|
| 缓存空值 | 中(恶意攻击时会很大) | 低 | 好 | 首选,几乎所有场景 |
| 布隆过滤器 | 低(1 亿 ID 约 114MB) | 中高(要预热、不能删) | 最好 | ID 空间大、有恶意攻击风险 |
| 参数校验 + 限流 | 无 | 低 | 一般(挡不住合法范围内的随机 ID) | 必备的基础防线 |
生产建议:三者结合。参数校验做第一道,布隆过滤器做第二道(数据量大时),空值缓存做第三道。
3. 缓存击穿
3.1 什么是缓存击穿
一个热点 key 突然过期,导致大量并发请求同时发现缓存未命中,全部涌向数据库。
某个热点商品的缓存 TTL 到了,缓存失效的那一瞬间:
1 万个并发请求同时发现缓存未命中
→ 1 万个请求同时查数据库(查的是同一条数据!)
→ 数据库瞬间过载
→ 1 万个请求又同时写缓存(重复的无效写入)
和穿透的区别:
- 穿透:数据在数据库里也不存在,问题是"无效查询反复打到 DB";
- 击穿:数据存在,只是缓存刚好失效,问题是"瞬间的并发全部打到 DB"。
3.2 解法一:互斥锁(最常用)
只让一个请求去查数据库,其他请求等待或降级。
func GetHotData(ctx context.Context, id int64) (*Data, error) {
key := fmt.Sprintf("data:%d", id)
// 1. 先读缓存
if val, err := rdb.Get(ctx, key).Result(); err == nil {
return unmarshal(val), nil
}
// 2. 缓存未命中,尝试获取"重建锁"
lockKey := "lock:rebuild:" + key
token := uuid.NewString()
ok, _ := rdb.SetNX(ctx, lockKey, token, 10*time.Second).Result()
if ok {
// 【拿到锁】:负责查库并回填缓存
defer unlockScript.Run(ctx, rdb, []string{lockKey}, token)
// 【重要】拿到锁后要再查一次缓存(Double Check)
// 因为可能在等锁的过程中别人已经填好了
if val, err := rdb.Get(ctx, key).Result(); err == nil {
return unmarshal(val), nil
}
data, err := db.Get(ctx, id)
if err != nil {
return nil, err
}
rdb.Set(ctx, key, marshal(data), time.Hour+randomJitter())
return data, nil
}
// 【没拿到锁】:三种策略选一种
// 策略 A:短暂等待后重试读缓存(推荐)
for i := 0; i < 5; i++ {
time.Sleep(50 * time.Millisecond)
if val, err := rdb.Get(ctx, key).Result(); err == nil {
return unmarshal(val), nil
}
}
return nil, ErrTimeout
// 策略 B:直接返回旧值/默认值(降级,可用性最好)
// 策略 C:直接查数据库(放弃保护,只在 DB 能承受时用)
}
关键点:
- 拿到锁后必须再查一次缓存(Double Check)——避免重复查库;
- 锁的 TTL 要足够长(覆盖查库 + 回填的时间),但也不能太长(持锁者崩溃时其他请求要等太久);
- 没拿到锁的请求不要无限等待,要有超时和降级。
更进一步:用 Go 的 singleflight 在进程内先合并一次
import "golang.org/x/sync/singleflight"
var g singleflight.Group
func GetHotData(ctx context.Context, id int64) (*Data, error) {
key := fmt.Sprintf("data:%d", id)
if val, err := rdb.Get(ctx, key).Result(); err == nil {
return unmarshal(val), nil
}
// 【进程内合并】:同一进程内的 N 个并发请求只有 1 个真正执行
v, err, _ := g.Do(key, func() (interface{}, error) {
// 这里再用分布式锁做跨进程的合并
return getWithDistributedLock(ctx, key, id)
})
if err != nil {
return nil, err
}
return v.(*Data), nil
}
两层合并的效果:假设 100 台机器、每台 100 个并发 = 1 万并发请求:
singleflight把每台机器的 100 个请求合并成 1 个 → 变成 100 个请求;- 分布式锁把 100 个请求合并成 1 个 → 最终只有 1 个请求到数据库。
这是生产环境的标准做法。
3.3 解法二:逻辑过期(永不过期 + 后台刷新)
思路:缓存不设物理 TTL(或设一个很长的),而是在 value 里存一个逻辑过期时间。读取时如果发现逻辑过期,返回旧数据的同时异步触发更新。
type CacheValue struct {
Data json.RawMessage `json:"data"`
ExpireAt int64 `json:"expire_at"` // 逻辑过期时间戳
}
func GetHotData(ctx context.Context, id int64) (*Data, error) {
key := fmt.Sprintf("data:%d", id)
val, err := rdb.Get(ctx, key).Result()
if err == redis.Nil {
// 缓存完全没有(首次访问或被淘汰)→ 同步查库(走 3.2 的互斥锁逻辑)
return getWithLock(ctx, key, id)
}
if err != nil {
return nil, err
}
var cv CacheValue
json.Unmarshal([]byte(val), &cv)
if time.Now().Unix() < cv.ExpireAt {
// 【未逻辑过期】,直接返回
return unmarshal(cv.Data), nil
}
// 【已逻辑过期】:尝试获取锁,异步重建
lockKey := "lock:rebuild:" + key
if ok, _ := rdb.SetNX(ctx, lockKey, 1, 10*time.Second).Result(); ok {
go func() {
defer rdb.Del(context.Background(), lockKey)
data, err := db.Get(context.Background(), id)
if err != nil {
return
}
newCV := CacheValue{
Data: marshal(data),
ExpireAt: time.Now().Add(time.Hour).Unix(),
}
// 【注意】物理上不设 TTL 或设很长的 TTL
rdb.Set(context.Background(), key, marshal(newCV), 0)
}()
}
// 【关键】无论是否拿到锁,都【立即返回旧数据】
return unmarshal(cv.Data), nil
}
优点:
- 永远不会有请求阻塞等待(可用性最好);
- 数据库压力最小(只有一个后台请求)。
缺点:
- 会返回一段时间的旧数据(不适合对实时性要求高的场景);
- 实现更复杂(要处理 value 结构、后台任务、异常情况);
- 不设 TTL 意味着 key 永久占用内存(要靠
maxmemory淘汰或定期清理,否则冷数据永久堆积)。
3.4 解法三:热点 key 提前预热与永不过期
对于已知的热点(如秒杀商品、首页推荐位):
// 1. 活动开始前预热缓存
func WarmUp(ctx context.Context, hotIDs []int64) {
for _, id := range hotIDs {
data, _ := db.Get(ctx, id)
rdb.Set(ctx, fmt.Sprintf("data:%d", id), marshal(data), 0) // 不设 TTL
}
}
// 2. 后台定时任务主动刷新(而不是等它过期)
func RefreshHotKeys(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
for _, id := range getHotIDs() {
data, err := db.Get(ctx, id)
if err == nil {
rdb.Set(ctx, fmt.Sprintf("data:%d", id), marshal(data), 0)
}
}
}
}
3.5 三种解法对比
| 方案 | 数据库压力 | 请求延迟 | 数据实时性 | 复杂度 |
|---|---|---|---|---|
| 互斥锁 | 低(1 个请求) | 部分请求要等待 | 好 | 中 |
| 逻辑过期 | 最低 | 无等待 | 差(返回旧数据) | 高 |
| 预热 + 主动刷新 | 最低 | 无等待 | 中(取决于刷新间隔) | 低(但要维护热点列表) |
生产选择:
- 一般热点:
singleflight+ 分布式锁(互斥锁方案); - 超级热点且能容忍旧数据:逻辑过期;
- 已知的、数量有限的热点:预热 + 主动刷新(最稳)。
4. 缓存雪崩
4.1 什么是缓存雪崩
大量 key 同时失效,或者缓存服务整体不可用,导致所有请求打到数据库,数据库被压垮,进而整个系统崩溃。
两种成因:
成因一:大量 key 同时过期
凌晨 2 点批量导入了 100 万条数据,都设了 TTL = 3600 秒
→ 凌晨 3 点这 100 万个 key 【同时失效】
→ 所有请求穿透到数据库
→ 数据库被打爆
成因二:Redis 实例宕机
Redis 主节点宕机 → 缓存完全不可用
→ 所有请求打到数据库(原本 95% 的请求由缓存承担)
→ 数据库瞬间承受 20 倍流量 → 崩溃
→ 服务全面不可用
4.2 解法一:TTL 加随机抖动(针对成因一)
最简单也最有效:
// ❌ 所有 key 的 TTL 完全相同
rdb.Set(ctx, key, val, time.Hour)
// ✅ 加随机抖动,把过期时间打散
func randomTTL(base time.Duration) time.Duration {
// 基础 1 小时 + 0~10 分钟随机
return base + time.Duration(rand.Intn(600))*time.Second
}
rdb.Set(ctx, key, val, randomTTL(time.Hour))
双重好处(第 6 篇提过):
- 避免缓存雪崩;
- 均摊 Redis 自身的过期删除压力(避免某个 100ms 周期内有几百万 key 要删,把 25ms 的时间预算撑爆)。
抖动幅度建议:基础 TTL 的 10%~30%。
4.3 解法二:多级缓存(针对成因二)
在应用进程内加一层本地缓存,Redis 挂了还有本地缓存兜底。
请求 → 【本地缓存(进程内)】→ 【Redis】→ 【数据库】
L1: 微秒级 L2: 毫秒级 L3: 十毫秒级
import "github.com/dgraph-io/ristretto" // 或 bigcache / freecache / go-cache
var localCache *ristretto.Cache
func GetData(ctx context.Context, id int64) (*Data, error) {
key := fmt.Sprintf("data:%d", id)
// L1:本地缓存
if v, found := localCache.Get(key); found {
return v.(*Data), nil
}
// L2:Redis
val, err := rdb.Get(ctx, key).Result()
if err == nil {
data := unmarshal(val)
// 回填本地缓存,TTL 要【短】(因为无法及时感知失效)
localCache.SetWithTTL(key, data, 1, 30*time.Second)
return data, nil
}
// L3:数据库(用 singleflight + 分布式锁保护)
data, err := getFromDBWithProtection(ctx, id)
if err != nil {
return nil, err
}
rdb.Set(ctx, key, marshal(data), randomTTL(time.Hour))
localCache.SetWithTTL(key, data, 1, 30*time.Second)
return data, nil
}
本地缓存的关键取舍:
| 维度 | 说明 |
|---|---|
| TTL 要短(10~60 秒) | 因为无法及时感知数据变更,只能靠过期收敛 |
| 只缓存热点 | 本地内存有限,缓存全部数据会 OOM |
| 一致性最弱 | N 台机器有 N 份副本,更新时无法保证全部同步 |
| 失效通知 | 可以用 Redis pub/sub 广播失效(但会丢消息,仍需 TTL 兜底,见第 9 篇) |
**Redis 6.0 的客户端缓存(Client-side Caching)**是这个模式的官方实现:
CLIENT TRACKING ON # 开启键追踪(需要 RESP3)
# 客户端本地缓存 GET 到的数据
# 服务端在 key 被修改时【主动推送 invalidate 消息】给缓存了它的客户端
它比自己用 pub/sub 广播更精准(服务端记录了每个客户端缓存了哪些 key,只推给相关客户端)。
4.4 解法三:熔断降级与限流
核心思想:保护数据库,宁可部分请求失败也不能让 DB 崩溃。
// 1. 限流:限制到数据库的并发量
var dbSemaphore = make(chan struct{}, 100) // 最多 100 个并发查库
func getFromDB(ctx context.Context, id int64) (*Data, error) {
select {
case dbSemaphore <- struct{}{}:
defer func() { <-dbSemaphore }()
return db.Get(ctx, id)
case <-time.After(50 * time.Millisecond):
return nil, ErrSystemBusy // 【快速失败,保护数据库】
}
}
// 2. 熔断:数据库错误率过高时直接返回降级数据
// 用 sony/gobreaker 或 alibaba/sentinel-golang
var cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "db-query",
MaxRequests: 10,
Interval: 10 * time.Second,
Timeout: 30 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.Requests >= 20 &&
float64(counts.TotalFailures)/float64(counts.Requests) > 0.5
},
})
result, err := cb.Execute(func() (interface{}, error) {
return db.Get(ctx, id)
})
if err != nil {
return getDefaultData(), nil // 【降级:返回兜底数据】
}
4.5 解法四:Redis 高可用
从架构上减少"Redis 整体不可用"的概率:
- 主从 + 哨兵(第 11 篇):主节点挂了自动切换,10~35 秒恢复;
- Cluster(第 12 篇):一个分片挂了只影响 1/N 的数据(配
cluster-require-full-coverage no); - 跨机房部署:避免机房级故障;
- 持久化(第 7 篇):重启后能快速恢复数据,避免"空缓存重启"引发雪崩。
4.6 解法五:缓存预热
Redis 重启后如果是空的,所有请求都会穿透到数据库——这本身就是一次雪崩。
// 服务启动时预热核心数据
func WarmUpCache(ctx context.Context) error {
hotIDs, err := db.GetHotDataIDs(ctx, 10000) // 取最热的 1 万条
if err != nil {
return err
}
// 分批预热,避免瞬间压垮数据库
for i := 0; i < len(hotIDs); i += 100 {
end := min(i+100, len(hotIDs))
batch := hotIDs[i:end]
items, _ := db.BatchGet(ctx, batch)
pipe := rdb.Pipeline()
for _, item := range items {
pipe.Set(ctx, fmt.Sprintf("data:%d", item.ID),
marshal(item), randomTTL(time.Hour))
}
pipe.Exec(ctx)
time.Sleep(50 * time.Millisecond) // 限速
}
return nil
}
更彻底的做法:如果 Redis 开了持久化(AOF/RDB),重启会自动恢复数据,就不需要预热。这是开启持久化的一个重要理由——即使 Redis 只做缓存,持久化也能避免"重启即雪崩"。
4.7 三大问题总结对比
| 缓存穿透 | 缓存击穿 | 缓存雪崩 | |
|---|---|---|---|
| 数据是否存在于 DB | 不存在 | 存在 | 存在 |
| 失效的 key 数量 | 不涉及(本来就没有) | 一个热点 key | 大量 key 或整个 Redis |
| 成因 | 查询不存在的数据 | 热点 key 突然过期 | 批量 key 同时过期 / Redis 宕机 |
| 核心解法 | 空值缓存 + 布隆过滤器 | 互斥锁 / 逻辑过期 | TTL 随机抖动 + 多级缓存 + 熔断限流 + 高可用 |
| 辅助手段 | 参数校验、限流 | 预热、主动刷新 | 预热、持久化 |
记忆口诀:
- 穿透:查不存在的 → 用空值/布隆挡住;
- 击穿:一个热点过期 → 用锁只放一个进去;
- 雪崩:大量同时失效 → 打散 TTL + 多级缓存 + 限流熔断。
5. 缓存与数据库一致性
这是缓存领域最核心也最容易答错的问题。
5.1 先明确一个前提
只要缓存和数据库是两个独立的系统,就不可能有"完美的强一致"。
因为"更新数据库"和"更新/删除缓存"是两个独立的操作,无法原子化(除非引入分布式事务,但那个代价高到不可接受,且 Redis 不支持 XA)。
所以问题不是"怎么做到强一致",而是"怎么让不一致的窗口尽可能小、发生概率尽可能低、并且能自动收敛"。
5.2 四种组合方案
写操作有两个维度的选择:更新缓存 vs 删除缓存、先操作 DB vs 先操作缓存。四种组合:
方案一:先更新数据库,再更新缓存(❌ 不推荐)
问题一:并发写导致脏数据
时刻 T1:线程 A 更新数据库为 v1
时刻 T2:线程 B 更新数据库为 v2
时刻 T3:线程 B 更新缓存为 v2
时刻 T4:线程 A 更新缓存为 v1 ← 【A 的缓存写入晚到了】
结果:数据库 = v2,缓存 = v1 【永久不一致】
因为两个线程的"更新 DB"和"更新缓存"之间的执行顺序不可控(网络延迟、GC、调度),这个不一致是永久的(除非 TTL 到了)。
问题二:浪费
如果这个数据写多读少,每次写都更新缓存,但缓存可能根本没被读过就又被更新了——做了大量无用功。
问题三:更新逻辑可能很复杂
如果缓存的值不是数据库字段的直接映射(比如是多表 join + 计算的结果),“更新缓存"就要重新执行整套计算逻辑,容易出错。
方案二:先删除缓存,再更新数据库(❌ 不推荐)
问题:并发读写导致脏数据
时刻 T1:线程 A(写)删除缓存
时刻 T2:线程 B(读)缓存未命中 → 读数据库,拿到【旧值】
时刻 T3:线程 A(写)更新数据库为新值
时刻 T4:线程 B(读)把【旧值】写入缓存
结果:数据库 = 新值,缓存 = 旧值 【不一致,且会持续到 TTL 过期】
这个问题发生概率很高,因为"读数据库 + 写缓存”(T2→T4)通常比"更新数据库"(T3)快,两者很容易交错。
方案三:先更新数据库,再删除缓存(✅ 推荐,Cache Aside)
func Update(ctx context.Context, u *User) error {
if err := db.Update(ctx, u); err != nil { // 1. 先更新 DB
return err
}
rdb.Del(ctx, key(u.ID)) // 2. 再删缓存
return nil
}
这是业界标准方案(Facebook 的论文《Scaling Memcache at Facebook》也是这个思路)。
它也有理论上的不一致场景,但概率极低:
时刻 T1:缓存刚好【失效】(TTL 到期)
时刻 T2:线程 B(读)缓存未命中 → 开始读数据库,拿到旧值 v0
时刻 T3:线程 A(写)更新数据库为 v1
时刻 T4:线程 A(写)删除缓存(此时缓存本来就是空的,删了也没用)
时刻 T5:线程 B(读)把旧值 v0 写入缓存
结果:缓存 = v0(旧),数据库 = v1 【不一致】
为什么概率极低:需要同时满足三个条件:
- 缓存刚好在这个时刻失效;
- 有并发的读和写;
- 读操作的"查库 + 写缓存"(T2→T5)耗时要长于写操作的"更新库 + 删缓存"(T3→T4)。
而实际上读操作(一次 SELECT + 一次 SET)几乎总是比写操作(一次 UPDATE,可能涉及锁、日志、索引维护)快,所以第三个条件很难满足。
为什么"删除"优于"更新":
| 删除缓存 | 更新缓存 | |
|---|---|---|
| 并发写的脏数据风险 | 低 | 高(方案一的问题) |
| 写多读少时的开销 | 低(懒加载,只在被读时才重建) | 高(每次写都算一遍) |
| 复杂计算值的正确性 | 好(重建时走统一的读路径) | 差(要复制一遍计算逻辑) |
方案四:先删除缓存,再更新数据库,然后延迟再删一次(延迟双删)
func Update(ctx context.Context, u *User) error {
rdb.Del(ctx, key(u.ID)) // 1. 先删缓存
if err := db.Update(ctx, u); err != nil { // 2. 更新数据库
return err
}
// 3. 【延迟】再删一次,清掉可能在步骤 1、2 之间被写入的旧值
time.AfterFunc(500*time.Millisecond, func() {
rdb.Del(context.Background(), key(u.ID))
})
return nil
}
目的:解决方案二的问题——第二次删除会清掉那个"在删除和更新之间被并发读写入的旧值"。
问题:
- 延迟多久无法确定:要覆盖"并发读的查库 + 写缓存"耗时,还要考虑主从延迟(如果读的是 MySQL 从库,可能还要等主从同步完成)。设短了没用,设长了不一致窗口反而更大;
- 实现别扭:
time.AfterFunc在进程崩溃时会丢失;用消息队列做延迟又增加了复杂度; - 仍然不能保证 100% 一致(如果延迟期间又有并发读写)。
实践中的定位:延迟双删不是标准方案,它是"方案三 + 额外保险"的一种加强,主要用在读写分离且主从延迟明显的场景。大多数情况直接用方案三就够了。
5.3 更可靠的方案:订阅 binlog
思路:不在业务代码里删缓存,而是订阅数据库的 binlog,由一个独立的服务来删缓存。
业务代码 → 只更新数据库(不管缓存)
↓
MySQL binlog
↓
Canal / Debezium / Maxwell(伪装成 MySQL 从库)
↓
消息队列(Kafka/RocketMQ)
↓
缓存更新服务 → 删除/更新 Redis
优点:
- 业务代码完全解耦——业务只管写数据库,不用关心缓存(大幅降低出错概率,也避免了"某处代码忘了删缓存");
- 可靠——binlog 是数据库的事实记录,配合 MQ 的重试机制,保证最终一定会删缓存(不会因为业务进程崩溃而漏删);
- 顺序性——binlog 天然有序,按表/主键分区后能保证同一条记录的更新顺序;
- 能感知所有变更——包括直接在数据库上执行的 SQL(运维改数据、定时脚本),业务代码里删缓存是覆盖不到这些的。
缺点:
- 架构复杂——多了 Canal + MQ + 消费服务三个组件,都要做高可用;
- 有延迟——从 binlog 产生到缓存被删通常 几十毫秒到几百毫秒;
- 运维成本——Canal 要处理 binlog 位点管理、主从切换、表结构变更。
适用:数据一致性要求较高、且已有 binlog 订阅基础设施(很多公司为了数据同步到数仓/ES 已经有了)的场景。
实践中的常见组合:
业务代码删缓存(快速路径,覆盖 99% 的情况)
+
binlog 订阅删缓存(兜底路径,修正遗漏和异常)
这样既有低延迟,又有可靠兜底。
5.4 兜底手段
无论用哪种方案,都应该有这些兜底:
(1)给所有缓存设置合理的 TTL(最重要)
rdb.Set(ctx, key, val, randomTTL(time.Hour))
这是所有一致性方案的最后一道保险。即使所有删除机制都失效了,TTL 保证不一致最多持续一个 TTL 周期,数据最终会收敛。
永远不要设置"永不过期"的业务缓存(除了明确需要逻辑过期方案的超级热点)。
(2)删除缓存失败的重试
func Update(ctx context.Context, u *User) error {
if err := db.Update(ctx, u); err != nil {
return err
}
if err := rdb.Del(ctx, key(u.ID)).Err(); err != nil {
// 删除失败 → 丢进消息队列异步重试
mq.Publish("cache-invalidate", key(u.ID))
log.Errorf("删除缓存失败,已投递重试: %v", err)
}
return nil
}
(3)定期对账
// 低峰期跑,抽样比对缓存和数据库
func Reconcile(ctx context.Context) {
for _, id := range sampleIDs(1000) {
cached, _ := rdb.Get(ctx, key(id)).Result()
actual, _ := db.Get(ctx, id)
if cached != "" && !equal(cached, actual) {
metrics.InconsistentCounter.Inc()
rdb.Del(ctx, key(id)) // 修复
log.Warnf("发现不一致: id=%d", id)
}
}
}
对账不只是修复数据,更重要的是发现"某个代码路径忘了删缓存"这类系统性问题。
5.5 读写分离带来的额外问题
如果数据库用了主从读写分离,会引入新的不一致来源:
时刻 T1:写请求更新【主库】
时刻 T2:删除缓存
时刻 T3:读请求缓存未命中 → 读【从库】
→ 【主从复制还没到达,读到旧值】
时刻 T4:把旧值写入缓存 【不一致】
解法:
- 缓存重建时强制读主库(最简单可靠):
func rebuildCache(ctx context.Context, id int64) {
data, _ := db.Master().Get(ctx, id) // 【强制读主库】
rdb.Set(ctx, key(id), marshal(data), randomTTL(time.Hour))
}
- 延迟双删(延迟时间要大于主从延迟);
- 写后一段时间内该 key 的读走主库(会话粘性,见第 10 篇)。
5.6 一致性方案总结
| 方案 | 一致性 | 复杂度 | 推荐度 |
|---|---|---|---|
| 先更 DB,再更新缓存 | 差(并发写永久脏) | 低 | ❌ |
| 先删缓存,再更 DB | 差(并发读写概率高) | 低 | ❌ |
| 先更 DB,再删缓存(Cache Aside) | 好(不一致概率极低) | 低 | ✅ 首选 |
| 延迟双删 | 较好 | 中 | ⚠️ 主从延迟明显时用 |
| binlog 订阅 | 最好 | 高 | ✅ 高要求场景 / 已有基础设施 |
| + TTL 兜底 | — | — | ✅ 必备(所有方案都要有) |
标准答案:Cache Aside(先更新数据库,再删除缓存)+ 合理的 TTL + 删除失败重试,一致性要求高时再叠加 binlog 订阅兜底。
6. 大 key 与热 key 治理
6.1 大 key
定义(业界通用标准):
| 类型 | 大 key 标准 |
|---|---|
| string | value > 10KB(超过 100KB 算严重) |
| hash / list / set / zset | 元素数 > 5000,或总内存 > 1MB |
| 单个元素 | > 10KB |
六大危害:
- 网络阻塞:一个 1MB 的 value,1000 QPS 就是 1GB/s 的流量,直接打满网卡;
- 操作阻塞主线程:
HGETALL/SMEMBERS一个大集合是 O(N),可能阻塞几百毫秒; - 删除阻塞:
DEL大 key 要同步释放大量内存(要用UNLINK+lazyfree-*); - 集群迁移阻塞:
MIGRATE是同步的,迁一个 1GB 的 key 可能阻塞几十秒(第 12 篇); - 内存分布不均:Cluster 中某个节点内存暴涨,无法通过扩容解决(一个 key 不能拆分到两个节点);
- 持久化影响:AOF 重写和 RDB 生成时处理大 key 耗时长,加剧 fork 和 COW 压力。
排查:
# 1. 采样扫描(走 SCAN,线上可用)
redis-cli --bigkeys # 每种类型的 Top 1
redis-cli --memkeys # 按实际内存排序(更准但更慢)
redis-cli --memkeys --memkeys-samples 0 # 0 = 扫全部(谨慎)
# 2. 单个 key 精确查看
MEMORY USAGE mykey
DEBUG OBJECT mykey # serializedlength 是序列化后的长度
STRLEN / HLEN / LLEN / SCARD / ZCARD mykey
# 3. 离线分析 RDB(最全面,不影响线上)
# rdb_bigkeys / redis-rdb-tools
rdb --command memory dump.rdb --bytes 10240 -f memory.csv
治理:
# ---------- 方案一:拆分(最根本)----------
# 一个 100 万字段的 hash → 按 hash(field) % 100 拆成 100 个 hash
HSET user:bucket:0 f1 v1
HSET user:bucket:1 f2 v2
# 好处:每个小 hash 保持 listpack 编码,内存还能省 5~10 倍(第 4 篇)
# 一个 100 万元素的 list → 按业务维度拆
LPUSH msg:user:1001 ... # 而不是 LPUSH msg:all ...
# ---------- 方案二:只取需要的部分 ----------
HMGET bigHash f1 f2 f3 # 而不是 HGETALL
HSCAN bigHash 0 COUNT 100 # 分批遍历
LRANGE bigList 0 99 # 分页而不是 0 -1
ZRANGE bigZset 0 9 # Top N 而不是全量
# ---------- 方案三:压缩 value ----------
# 应用层用 gzip/snappy/zstd 压缩后再存(对 JSON 通常能压到 20%~30%)
# 代价:CPU 开销 + 无法在服务端做部分操作
# ---------- 方案四:不该放 Redis 的别放 ----------
# 大文件、图片、大文档 → 对象存储(OSS/S3)+ Redis 只存 URL
# ---------- 删除大 key ----------
UNLINK bigkey # 异步删除(4.0+)
# 或者分批删(更安全)
HSCAN + HDEL 批量
LTRIM 逐步截断
SPOP 批量弹出
ZREMRANGEBYRANK 批量删
6.2 热 key
定义:QPS 远高于其他 key(比如单 key 达到 1 万 QPS 以上,或占实例总 QPS 的显著比例)。
危害:
- 单节点被打满:Cluster 中这个 key 只在一个节点上,那个节点的 CPU 和网卡成为瓶颈,无法通过扩容解决;
- 拖累同节点的其他 key:单线程模型下,热 key 的请求排队会影响同节点所有请求;
- 热 key 失效引发击穿(第 3 节)。
排查:
# 1. Redis 自带(需要 maxmemory-policy 为 lfu 系列)
redis-cli --hotkeys
# 2. MONITOR 抓样本(线上慎用,有明显性能开销)
redis-cli monitor | head -10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head
# 3. 从命令统计推断
INFO commandstats # 看哪类命令调用最多
OBJECT FREQ mykey # LFU 模式下的访问频率
# 4. 【推荐】客户端埋点统计
# 在应用层统计每个 key 的访问次数,上报到监控系统
# 这是最准确且无侵入的方式
# 5. 云厂商的热 key 分析
# 阿里云 Tair、腾讯云 Redis 都提供内置的热 key 探测(基于抽样)
治理:
方案一:【本地缓存】(最有效)
├─ 把热 key 缓存在应用进程内(TTL 几秒到几十秒)
├─ 请求根本不到 Redis → 彻底解决单节点瓶颈
└─ 代价:一致性变弱(要配短 TTL + pub/sub 失效通知兜底)
方案二:【拆分成多个副本 key】
├─ 把 hotkey 复制成 hotkey:0 ~ hotkey:N(N 个副本,分散到不同 slot)
├─ 读时随机选一个:GET hotkey:{random(0,N)}
├─ 写时要更新所有 N 个副本
└─ 代价:写放大 N 倍、一致性更难保证
方案三:【读写分离】
├─ 热 key 的读请求分散到多个从节点
└─ 代价:主从延迟(第 10 篇)
方案四:【Redis 6.0 客户端缓存】
└─ CLIENT TRACKING ON,服务端主动推送失效通知,比自己做本地缓存更精准
方案五:【业务层削峰】
├─ 合并请求(singleflight)
├─ 限流
└─ 如果是计数类的热 key,先在本地累加再定期批量提交
推荐组合:本地缓存(主要手段)+ 短 TTL + pub/sub 失效通知 + 监控告警。
7. 秒杀场景实战
把前面所有知识串起来的综合案例。
7.1 核心挑战
- 瞬时高并发:几万到几十万 QPS 集中在几秒内;
- 绝对不能超卖:库存是硬约束;
- 热点集中:所有请求都打到同一个商品的库存 key;
- 数据库承受不了:不能让每个请求都查库/写库。
7.2 完整分层设计
【第 1 层:前端】
├─ 按钮点击后立即置灰(防重复提交)
├─ 静态资源 CDN 化
└─ 答题/验证码/滑块(削峰 + 拦机器人)
【第 2 层:网关/接入层】
├─ 限流(令牌桶,把 10 万 QPS 削到 1 万)
├─ 黑名单(IP/用户维度,拦异常流量)
└─ 请求合并/排队
【第 3 层:应用层】
├─ 本地缓存"活动信息""已售完标记"
│ → 售完后直接本地返回,请求根本不到 Redis
├─ 用户资格预校验(是否已购买过,先查本地/Redis)
└─ singleflight 合并同 key 请求
【第 4 层:Redis(核心)】
└─ 【Lua 脚本原子完成:库存校验 + 扣减 + 一人一单】
【第 5 层:消息队列】
└─ Redis 扣减成功后发消息,【异步】创建订单(削峰填谷)
【第 6 层:数据库】
└─ 最终扣减,用【乐观锁/CHECK 约束】做最终兜底
UPDATE stock SET count=count-1 WHERE id=? AND count>=1
7.3 关键代码
(1)活动开始前预热库存
func WarmUpSeckill(ctx context.Context, activityID, skuID int64, stock int) error {
// 预热库存
if err := rdb.Set(ctx, fmt.Sprintf("seckill:stock:%d", skuID),
stock, 24*time.Hour).Err(); err != nil {
return err
}
// 清理上次活动的用户集合
rdb.Del(ctx, fmt.Sprintf("seckill:users:%d", skuID))
return nil
}
(2)核心的 Lua 脚本
-- KEYS[1] = 库存 key KEYS[2] = 已购用户集合 key
-- ARGV[1] = 用户 ID ARGV[2] = 购买数量
-- 返回:1 成功 -1 已购买过 -2 库存不足
-- 【所有校验在前】(Lua 不能回滚,见第 8 篇)
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -1
end
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock < tonumber(ARGV[2]) then
return -2
end
-- 【所有写入在后】
redis.call('DECRBY', KEYS[1], ARGV[2])
redis.call('SADD', KEYS[2], ARGV[1])
return 1
注意 KEYS[1] 和 KEYS[2] 必须在同一个 slot(Cluster 模式),所以要用 hash tag:
stockKey := fmt.Sprintf("seckill:{%d}:stock", skuID)
usersKey := fmt.Sprintf("seckill:{%d}:users", skuID)
// 都用 skuID 做 hash tag,保证同 slot;且粒度足够细,不会倾斜(第 12 篇)
(3)业务代码
var seckillScript = redis.NewScript(`...上面的 Lua...`)
// 进程内的"已售完"标记,避免售完后还去打 Redis
var soldOut sync.Map
func Seckill(ctx context.Context, skuID, userID int64) error {
// 1. 【本地缓存快速失败】:已售完直接返回
if v, ok := soldOut.Load(skuID); ok && v.(bool) {
return ErrSoldOut
}
stockKey := fmt.Sprintf("seckill:{%d}:stock", skuID)
usersKey := fmt.Sprintf("seckill:{%d}:users", skuID)
// 2. Redis 原子扣减
result, err := seckillScript.Run(ctx, rdb,
[]string{stockKey, usersKey}, userID, 1).Int()
if err != nil {
return fmt.Errorf("秒杀失败: %w", err)
}
switch result {
case -1:
return ErrAlreadyBought
case -2:
soldOut.Store(skuID, true) // 【标记售完,后续请求本地拦截】
return ErrSoldOut
}
// 3. 扣减成功 → 【异步】创建订单
msg := OrderMessage{SkuID: skuID, UserID: userID, Qty: 1}
if err := mq.Publish(ctx, "seckill-order", msg); err != nil {
// 【关键】发消息失败必须【回滚 Redis 扣减】,否则库存白扣了
rollbackScript.Run(ctx, rdb, []string{stockKey, usersKey}, userID, 1)
return fmt.Errorf("下单失败: %w", err)
}
return nil // 立即返回"抢购成功,订单处理中"
}
(4)回滚脚本
-- 消息投递失败时回滚 Redis 的扣减
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
redis.call('SREM', KEYS[2], ARGV[1])
redis.call('INCRBY', KEYS[1], ARGV[2])
return 1
end
return 0
(5)消费者创建订单(幂等 + 最终兜底)
func ConsumeOrder(ctx context.Context, msg OrderMessage) error {
// 【幂等】:用消息 ID 或 (skuID, userID) 唯一索引防重复
return db.Transaction(func(tx *gorm.DB) error {
// 【最终兜底】:数据库层面的库存扣减,即使 Redis 出错也不会超卖
result := tx.Exec(
"UPDATE stock SET count = count - ? WHERE sku_id = ? AND count >= ?",
msg.Qty, msg.SkuID, msg.Qty)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// 数据库库存不足(说明 Redis 和 DB 不一致,要告警)
log.Errorf("DB 库存不足但 Redis 扣减成功: sku=%d", msg.SkuID)
return ErrOutOfStock
}
// 创建订单(唯一索引 (sku_id, user_id) 保证一人一单)
order := &Order{SkuID: msg.SkuID, UserID: msg.UserID, Qty: msg.Qty}
if err := tx.Create(order).Error; err != nil {
if isDuplicateKey(err) {
return nil // 【重复消息,幂等返回成功】
}
return err
}
return nil
})
}
7.4 关键设计点复盘
| 设计 | 解决的问题 | 对应的知识点 |
|---|---|---|
| Lua 脚本扣减 | 原子性(防超卖)+ 性能(比分布式锁快一个数量级) | 第 8 篇、第 13 篇 |
| hash tag | 让两个 key 同 slot,Lua 才能操作 | 第 12 篇 |
| 本地"售完"标记 | 售完后请求不到 Redis,保护热点节点 | 第 6 节热 key |
| 异步下单(MQ) | 削峰填谷,把瞬时 10 万 QPS 平滑成数据库能承受的速率 | — |
| 发消息失败要回滚 Redis | 避免"库存扣了但订单没创建" | — |
| 消费者幂等 | MQ 至少一次投递会有重复消息 | 第 9 篇 |
| 数据库唯一索引 + 乐观锁 | 最终正确性兜底(Redis 失效也不超卖) | 第 13 篇的"双层设计" |
| 预热库存 | 避免缓存未命中导致的击穿 | 第 3 节 |
| 限流 + 验证码 | 削峰,把无效流量挡在外面 | — |
核心思想:Redis 负责性能(挡住 99.9% 的流量),数据库负责正确性(最终兜底)。 这与第 13 篇分布式锁的结论完全一致。
8. 高频面试题
Q1:什么是缓存穿透?怎么解决?
缓存穿透:查询一个数据库里也不存在的数据。因为数据库返回空,代码通常不写缓存,导致每次请求都穿过缓存打到数据库。被恶意攻击(随机 ID 疯狂请求)时会打爆数据库。
三种解法(生产要结合使用):
(1)缓存空值(首选,最简单):查不到也写一个特殊标记(如 "NULL")到缓存,TTL 要短(30~300 秒,因为数据将来可能真的被创建)。注意:数据创建时要主动删除空值缓存;缺点是恶意攻击会产生海量空值占内存。
(2)布隆过滤器:能判断"一定不存在"(无假阴性,有假阳性)。1 亿 ID、1% 误判率约需 114MB。用 RedisBloom 的 BF.RESERVE/BF.ADD/BF.EXISTS。难点是要预热全量数据、不支持删除(只能定期重建)、容量要预估准(超出后误判率急剧上升)。
(3)参数校验 + 限流:挡住明显非法的 ID;对"缓存未命中率异常高"的 IP/用户限流或拉黑。
Q2:什么是缓存击穿?和穿透有什么区别?怎么解决?
缓存击穿:一个热点 key 突然过期,大量并发请求同时发现未命中,全部涌向数据库查同一条数据。
与穿透的区别:
- 穿透:数据在数据库里也不存在,问题是"无效查询反复打到 DB";
- 击穿:数据存在,只是缓存刚好失效,问题是"瞬间并发全部打到 DB"。
三种解法:
(1)互斥锁(最常用):只让一个请求去查库重建,其他请求等待重试或降级。关键是拿到锁后要 Double Check 再查一次缓存(可能等锁期间别人已填好)。生产标准做法是两层合并:进程内用 singleflight(把每台机器的 N 个并发合并成 1 个)+ 跨进程用分布式锁(把 M 台机器合并成 1 个)→ 1 万并发最终只有 1 个请求到数据库。
(2)逻辑过期:缓存不设物理 TTL,value 里存逻辑过期时间。读到已逻辑过期的数据时,立即返回旧值,同时异步(加锁)重建。优点是永不阻塞、DB 压力最小;缺点是会返回旧数据且 key 永久占内存。
(3)预热 + 主动刷新:对已知的、数量有限的热点(秒杀商品、首页推荐位),提前预热并用后台任务定时刷新,不让它过期。
Q3:什么是缓存雪崩?怎么解决?
缓存雪崩:大量 key 同时失效或Redis 整体不可用,导致所有请求打到数据库,DB 被压垮,整个系统崩溃。
两种成因 + 对应解法:
成因一:大量 key 同时过期(如批量导入的数据用了相同 TTL)
→ TTL 加随机抖动:TTL = base + rand(base * 10%~30%)。双重好处:避免雪崩 + 均摊 Redis 自身的过期删除压力(第 6 篇)。
成因二:Redis 实例宕机(原本 95% 的请求由缓存承担,突然全打到 DB = 20 倍流量)
→ 多级缓存:进程内本地缓存(L1,TTL 10~60 秒)+ Redis(L2)+ DB(L3)。Redis 挂了还有本地缓存兜底;
→ 熔断降级 + 限流:限制到数据库的并发量(信号量),错误率高时熔断返回兜底数据。宁可部分请求失败也不能让 DB 崩溃;
→ Redis 高可用:主从+哨兵 / Cluster(配 cluster-require-full-coverage no 让一个分片挂了只影响 1/N);
→ 缓存预热:服务启动时分批预热核心数据(或者干脆开启持久化,重启自动恢复数据,这是"即使纯缓存也该开持久化"的重要理由)。
Q4:缓存穿透、击穿、雪崩的区别?(对比题)
| 穿透 | 击穿 | 雪崩 | |
|---|---|---|---|
| 数据在 DB 里存在吗 | 不存在 | 存在 | 存在 |
| 涉及的 key 数量 | 不涉及(本来没有) | 一个热点 key | 大量 key 或整个 Redis |
| 成因 | 查不存在的数据(常被恶意利用) | 热点 key 突然过期 | 批量 key 同时过期 / Redis 宕机 |
| 核心解法 | 空值缓存 + 布隆过滤器 | 互斥锁 / 逻辑过期 | TTL 抖动 + 多级缓存 + 熔断限流 + 高可用 |
口诀:穿透查不存在的 → 空值/布隆挡住;击穿一个热点过期 → 用锁只放一个进去;雪崩大量同时失效 → 打散 TTL + 多级缓存 + 限流熔断。
Q5:更新数据时,应该更新缓存还是删除缓存?
应该删除缓存,三个理由:
(1)更新缓存有并发写的脏数据问题(最致命)
T1: 线程 A 更新 DB 为 v1
T2: 线程 B 更新 DB 为 v2
T3: 线程 B 更新缓存为 v2
T4: 线程 A 更新缓存为 v1 ← A 的缓存写入晚到
结果:DB = v2,缓存 = v1,【永久不一致】(直到 TTL 过期)
删除缓存不存在这个问题(删两次和删一次效果一样,是幂等的)。
(2)写多读少时更新缓存是浪费:缓存可能根本没被读过就又被更新了,做了大量无用功。删除是懒加载,只在真正被读时才重建。
(3)复杂计算值的正确性:如果缓存值是多表 join + 计算的结果,“更新缓存"要复制一遍计算逻辑(容易出错、难维护);“删除 + 重建"走的是统一的读路径,天然正确。
Q6:应该先更新数据库还是先删除缓存?
先更新数据库,再删除缓存(Cache Aside),这是业界标准方案(Facebook 的 Memcache 论文也是这个思路)。
先删缓存再更新 DB 的问题(概率很高):
T1: 线程 A(写)删除缓存
T2: 线程 B(读)未命中 → 读 DB,拿到【旧值】
T3: 线程 A(写)更新 DB 为新值
T4: 线程 B(读)把【旧值】写入缓存
结果:不一致,且持续到 TTL 过期
这个概率高,因为"读 DB + 写缓存”(T2→T4)通常比"更新 DB”(T3)快。
先更 DB 再删缓存也有理论上的不一致,但概率极低:
T1: 缓存刚好【失效】
T2: 线程 B(读)未命中 → 读 DB 拿到旧值 v0
T3: 线程 A(写)更新 DB 为 v1
T4: 线程 A(写)删除缓存(此时缓存本来就空,删了没用)
T5: 线程 B(读)把 v0 写入缓存 → 不一致
需要同时满足三个条件:① 缓存刚好在这一刻失效;② 有并发读写;③ 读操作的"查库+写缓存"耗时长于写操作的"更新库+删缓存"。而实际上读(一次 SELECT + 一次 SET)几乎总是比写(UPDATE,涉及锁、日志、索引维护)快,所以第三条很难满足。
Q7:什么是延迟双删?真的有必要吗?
延迟双删:先删缓存 → 更新数据库 → 延迟一段时间(如 500ms)再删一次缓存。
目的:第二次删除清掉那个"在第一次删除和更新数据库之间被并发读写入的旧值"。
问题:
- 延迟时间无法确定:要覆盖"并发读的查库 + 写缓存"耗时,如果读的是 MySQL 从库还要覆盖主从延迟。设短了没用,设长了不一致窗口反而更大;
- 实现别扭:
time.AfterFunc在进程崩溃时丢失;用 MQ 做延迟又增加复杂度; - 仍不能保证 100% 一致(延迟期间又有并发读写就白做了)。
结论:延迟双删不是标准方案,它是"先更 DB 再删缓存"之外的额外保险,主要用在读写分离且主从延迟明显的场景。大多数情况直接用 Cache Aside + TTL 兜底就够了。
Q8:怎么保证缓存和数据库的强一致性?
前提认知:只要缓存和数据库是两个独立系统,就不可能有完美的强一致(“更新 DB"和"删缓存"无法原子化,除非引入分布式事务,代价高到不可接受且 Redis 不支持 XA)。
所以问题应该是”怎么让不一致的窗口尽可能小、概率尽可能低、且能自动收敛"。
方案(按可靠性递增):
- Cache Aside(先更 DB 再删缓存) —— 基础,覆盖 99% 场景;
- + 合理的 TTL(必备) —— 这是所有方案的最后一道保险,保证不一致最多持续一个 TTL 周期。永远不要给业务缓存设"永不过期";
- + 删除失败重试(投递到 MQ 异步重试);
- + binlog 订阅(Canal/Debezium) —— 最可靠:业务只管写 DB,独立服务订阅 binlog 删缓存。优点是业务解耦(不会有"某处代码忘了删缓存")、可靠(配合 MQ 重试保证最终一定删)、能感知直接在 DB 上执行的 SQL(运维改数据、定时脚本)。缺点是架构复杂 + 有几十到几百毫秒延迟;
- + 定期对账:抽样比对并修复,更重要的是发现系统性问题。
真正需要强一致的场景(如金额):不要依赖缓存,直接读数据库,或者用数据库的乐观锁/唯一约束保证正确性。
Q9:什么是大 key?有什么危害?怎么治理?
标准:string > 10KB;集合类元素数 > 5000 或总内存 > 1MB。
六大危害:
- 网络阻塞:1MB 的 value × 1000 QPS = 1GB/s,直接打满网卡;
- 操作阻塞主线程:
HGETALL/SMEMBERS是 O(N),可能阻塞几百毫秒(单线程模型下拖累所有请求); - 删除阻塞:
DEL要同步释放大量内存(要用UNLINK+lazyfree-*); - 集群迁移阻塞:
MIGRATE是同步的,迁 1GB 的 key 可能阻塞几十秒,还可能误触发故障转移(第 12 篇); - 内存分布不均:Cluster 中某节点暴涨,无法通过扩容解决(一个 key 不能拆分到两个节点);
- 加剧持久化压力:AOF 重写/RDB 生成时处理大 key 耗时长。
排查:redis-cli --bigkeys(采样,走 SCAN 安全)、--memkeys(按内存排序)、MEMORY USAGE key、离线分析 RDB(redis-rdb-tools)。
治理:
- 拆分(最根本):大 hash 按
hash(field) % N分桶——顺带还能保持 listpack 编码省 5~10 倍内存(第 4 篇); - 只取需要的部分:
HMGET/HSCAN代替HGETALL,分页LRANGE; - 压缩 value(应用层 gzip/zstd);
- 不该放 Redis 的别放(大文件放对象存储,Redis 只存 URL);
- 删除用
UNLINK或分批删(HSCAN+HDEL、LTRIM、ZREMRANGEBYRANK)。
Q10:什么是热 key?怎么发现和治理?
热 key:QPS 远高于其他 key(如单 key > 1 万 QPS)。
危害:① Cluster 中这个 key 只在一个节点上,该节点 CPU/网卡成为瓶颈且无法通过扩容解决;② 单线程模型下拖累同节点所有 key;③ 它失效时会引发缓存击穿。
发现:
redis-cli --hotkeys(需要maxmemory-policy为 LFU,基于OBJECT FREQ);MONITOR抓样本统计(线上慎用,有明显性能开销);- 客户端埋点统计(最准确且无侵入,推荐);
- 云厂商内置的热 key 探测(阿里云 Tair、腾讯云 Redis)。
治理(按效果排序):
- 本地缓存(最有效):把热 key 缓存在进程内(TTL 几秒到几十秒),请求根本不到 Redis。代价是一致性变弱,要配短 TTL + pub/sub 失效通知兜底;
- Redis 6.0 客户端缓存(
CLIENT TRACKING ON):服务端主动推送 invalidate,比自己做本地缓存更精准; - 拆成多个副本 key:
hotkey:0~hotkey:N分散到不同 slot,读时随机选一个。代价是写放大 N 倍; - 读写分离:热 key 的读分散到从节点(有主从延迟);
- 业务层削峰:
singleflight合并、限流、计数类先本地累加再批量提交。
Q11:秒杀系统怎么设计?
核心思想:Redis 负责性能(挡住 99.9% 流量),数据库负责正确性(最终兜底)。
六层设计:
- 前端:按钮置灰防重复提交、CDN 静态化、答题/验证码削峰;
- 网关:限流(令牌桶把 10 万 QPS 削到 1 万)、黑名单;
- 应用层:本地缓存"已售完"标记(售完后请求根本不到 Redis)、
singleflight合并; - Redis(核心):Lua 脚本原子完成"库存校验 + 扣减 + 一人一单"。注意两个 key 要用 hash tag 保证同 slot(
seckill:{skuID}:stock/seckill:{skuID}:users); - 消息队列:Redis 扣减成功后发消息,异步创建订单(削峰填谷)。发消息失败必须回滚 Redis 扣减;
- 数据库:
UPDATE stock SET count=count-1 WHERE id=? AND count>=1(乐观锁)+(sku_id, user_id)唯一索引(一人一单),作为最终正确性兜底——即使 Redis 出问题也不会超卖。
为什么用 Lua 而不是分布式锁:Lua 一次网络往返、服务端原子执行,性能比分布式锁高一个数量级(分布式锁要加锁+业务+解锁至少 3 次往返,且热点场景下所有请求串行等一把锁)。
Lua 脚本的写法要点:所有校验在前,所有写入在后(因为 Lua 不能回滚,第 8 篇)。
Q12:Redis 挂了怎么办?
分三个层面回答:
(1)预防(架构层面)
- 主从 + 哨兵:自动故障转移,10~35 秒恢复(第 11 篇);
- Cluster:一个分片挂了只影响 1/N(配
cluster-require-full-coverage no,第 12 篇); - 跨机房/可用区部署;
- 开启持久化:重启能快速恢复数据,避免"空缓存重启"引发雪崩。
(2)降级(应用层面)
val, err := rdb.Get(ctx, key).Result()
if err != nil && err != redis.Nil {
// Redis 故障 → 降级
// 选项 A:直接查数据库(但要限流保护 DB!)
// 选项 B:返回本地缓存的旧数据
// 选项 C:返回默认值/兜底数据
// 选项 D:直接返回错误(非核心功能)
}
关键:降级到数据库时必须限流,否则原本 95% 由缓存承担的流量全打到 DB,会引发第二次雪崩(这就是缓存雪崩的成因二)。
(3)兜底(多级缓存)
进程内本地缓存作为 L1,Redis 挂了还能扛住热点数据的请求。配合熔断器(错误率高时直接走本地缓存/默认值,不再尝试连 Redis)。
Q13:布隆过滤器的原理是什么?有什么局限?
原理:一个长度 m 的位数组 + k 个哈希函数。
- 添加:用 k 个哈希函数算出 k 个位置,都置 1;
- 查询:算出 k 个位置,任意一位是 0 → 一定不存在(100% 确定);全是 1 → 可能存在(有误判,因为这些位可能是别的元素置的)。
核心特性:无假阴性(说不存在就一定不存在),有假阳性(说存在可能实际不存在)。这正好匹配缓存穿透的需求——我们只需要可靠地判断"不存在"。
误判率:p ≈ (1 - e^(-kn/m))^k,最优 k = (m/n)·ln2。实用估算:1 亿元素、1% 误判率约 114MB,降到 0.1% 需要 171MB(误判率每降 10 倍,内存约增 1.44 倍)。
四个局限:
- 不支持删除(删一个元素会把别的元素共用的位清掉)。计数布隆过滤器可以删但内存翻几倍。实践中只能定期重建;
- 有误判(虽然只是多查一次 DB,可接受);
- 需要预热:上线时要把全量已有 ID 灌进去(几亿条数据要用
BF.MADD+ pipeline 批量跑很久); - 容量要预估准:实际元素超过预设容量后误判率急剧上升。要留 2~3 倍余量或用
EXPANSION自动扩容(但扩容后误判率也会上升)。
生产用 RedisBloom 模块(BF.RESERVE/BF.ADD/BF.EXISTS),不要自己用 Bitmap 实现(哈希质量、扩容、误判率控制都很难做好)。
小结
- 三种缓存模式:Cache Aside(旁路,应用管缓存,绝大多数场景)、Read/Write Through(缓存服务代管)、Write Behind(异步回写,性能最好但会丢数据,适合计数器)。
- 缓存穿透(查 DB 里也不存在的数据):空值缓存(TTL 要短 + 创建数据时删空值)+ 布隆过滤器(1 亿 ID 约 114MB,无假阴性、有假阳性、不能删、要预热)+ 参数校验/限流。
- 缓存击穿(一个热点 key 突然过期):互斥锁 + Double Check(生产标准是
singleflight(进程内合并)+ 分布式锁(跨进程合并) 两层,1 万并发最终只有 1 个到 DB)、逻辑过期(返回旧值 + 异步重建,永不阻塞但数据不实时)、预热 + 主动刷新。 - 缓存雪崩(大量 key 同时失效或 Redis 宕机):TTL 加随机抖动(10%~30%,还能均摊 Redis 的过期删除压力)、多级缓存(本地 L1 + Redis L2)、熔断降级 + 限流(宁可部分失败也要保 DB)、Redis 高可用、预热/持久化(避免"空缓存重启即雪崩")。
- 写策略要"删除缓存"而不是"更新缓存":更新缓存有并发写永久脏数据问题、写多读少时浪费、复杂计算值容易算错;删除是幂等的、懒加载的。
- 顺序要"先更新数据库,再删除缓存"(Cache Aside):先删缓存的方案里"并发读写入旧值"概率很高;先更 DB 的方案理论上也有不一致,但需要"缓存刚好失效 + 并发读写 + 读比写慢"三条同时满足,而读几乎总比写快,所以概率极低。
- 强一致做不到(两个独立系统无法原子操作)。可靠性递增:Cache Aside → + 合理 TTL(必备的最后保险)→ + 删除失败重试 → + binlog 订阅(业务解耦、可靠、能感知直接改库的 SQL)→ + 定期对账。延迟双删不是标准方案,主要用于主从延迟明显的场景。
- 读写分离会引入新的不一致(读从库拿到旧值回填缓存),解法是缓存重建时强制读主库。
- 大 key(string > 10KB、集合 > 5000 元素或 1MB)六大危害:网络阻塞、O(N) 阻塞主线程、删除阻塞、集群迁移阻塞(MIGRATE 同步)、内存分布不均且无法扩容解决、加剧持久化压力。治理靠拆分/分桶(顺带省 5~10 倍内存)、部分读取、压缩、
UNLINK。 - 热 key(单 key 万级 QPS)无法通过扩容解决(key 只在一个节点上)。治理首选本地缓存 + 短 TTL + pub/sub 失效通知,或 Redis 6.0 的
CLIENT TRACKING;其次是多副本 key(写放大)、读写分离、singleflight合并。发现靠客户端埋点(最准)或--hotkeys。 - 秒杀的核心是分层削峰 + “Redis 保性能、数据库保正确”:前端防重 → 网关限流 → 应用层本地"售完"标记 → Redis Lua 原子扣减(hash tag 保证同 slot、校验在前写入在后) → MQ 异步下单(发送失败要回滚 Redis) → 数据库乐观锁 + 唯一索引最终兜底。热点场景用 Lua 而不是分布式锁(快一个数量级)。
xingliuhua