目录

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
}

注意点

  1. TTL 必须短(30~300 秒)。因为这个数据将来可能真的被创建,TTL 太长会导致"数据已创建但查不到";
  2. 空值要能和"正常的空字符串"区分开。用一个特殊标记(如 "NULL""\x00")或者用一个独立的 key 前缀;
  3. 数据创建时要主动删除空值缓存
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
}
  1. 缺点:如果攻击者用大量随机 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
}

布隆过滤器的实践难点

  1. 需要预热:上线时要把已有的全部 ID 灌进去(几亿条数据的初始化可能要跑很久,用 BF.MADD 批量 + pipeline);
  2. 不能删除:数据被删除后布隆过滤器里还在,会有误判(但只是多查一次 DB + 命中空值缓存,可接受)。要清理只能定期重建
  3. 误判率与内存的权衡:误判率每降 10 倍,内存增加约 1.44 倍;
  4. 容量要预估准确:实际元素超过预设容量后误判率会急剧上升BF.RESERVE 时留 2~3 倍余量,或用 EXPANSION 自动扩容);
  5. 需要 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 能承受时用)
}

关键点

  1. 拿到锁后必须再查一次缓存(Double Check)——避免重复查库;
  2. 锁的 TTL 要足够长(覆盖查库 + 回填的时间),但也不能太长(持锁者崩溃时其他请求要等太久);
  3. 没拿到锁的请求不要无限等待,要有超时和降级。

更进一步:用 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 篇提过):

  1. 避免缓存雪崩;
  2. 均摊 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 【不一致】

为什么概率极低:需要同时满足三个条件:

  1. 缓存刚好在这个时刻失效
  2. 有并发的读和写
  3. 读操作的"查库 + 写缓存"(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
}

目的:解决方案二的问题——第二次删除会清掉那个"在删除和更新之间被并发读写入的旧值"。

问题

  1. 延迟多久无法确定:要覆盖"并发读的查库 + 写缓存"耗时,还要考虑主从延迟(如果读的是 MySQL 从库,可能还要等主从同步完成)。设短了没用,设长了不一致窗口反而更大;
  2. 实现别扭time.AfterFunc 在进程崩溃时会丢失;用消息队列做延迟又增加了复杂度;
  3. 仍然不能保证 100% 一致(如果延迟期间又有并发读写)。

实践中的定位延迟双删不是标准方案,它是"方案三 + 额外保险"的一种加强,主要用在读写分离且主从延迟明显的场景。大多数情况直接用方案三就够了。

5.3 更可靠的方案:订阅 binlog

思路:不在业务代码里删缓存,而是订阅数据库的 binlog,由一个独立的服务来删缓存。

业务代码 → 只更新数据库(不管缓存)
              ↓
         MySQL binlog
              ↓
     Canal / Debezium / Maxwell(伪装成 MySQL 从库)
              ↓
         消息队列(Kafka/RocketMQ)
              ↓
       缓存更新服务 → 删除/更新 Redis

优点

  1. 业务代码完全解耦——业务只管写数据库,不用关心缓存(大幅降低出错概率,也避免了"某处代码忘了删缓存");
  2. 可靠——binlog 是数据库的事实记录,配合 MQ 的重试机制,保证最终一定会删缓存(不会因为业务进程崩溃而漏删);
  3. 顺序性——binlog 天然有序,按表/主键分区后能保证同一条记录的更新顺序;
  4. 能感知所有变更——包括直接在数据库上执行的 SQL(运维改数据、定时脚本),业务代码里删缓存是覆盖不到这些的。

缺点

  1. 架构复杂——多了 Canal + MQ + 消费服务三个组件,都要做高可用;
  2. 有延迟——从 binlog 产生到缓存被删通常 几十毫秒到几百毫秒
  3. 运维成本——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:把旧值写入缓存 【不一致】

解法

  1. 缓存重建时强制读主库(最简单可靠):
func rebuildCache(ctx context.Context, id int64) {
    data, _ := db.Master().Get(ctx, id)     // 【强制读主库】
    rdb.Set(ctx, key(id), marshal(data), randomTTL(time.Hour))
}
  1. 延迟双删(延迟时间要大于主从延迟);
  2. 写后一段时间内该 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

六大危害

  1. 网络阻塞:一个 1MB 的 value,1000 QPS 就是 1GB/s 的流量,直接打满网卡;
  2. 操作阻塞主线程HGETALL/SMEMBERS 一个大集合是 O(N),可能阻塞几百毫秒;
  3. 删除阻塞DEL 大 key 要同步释放大量内存(要用 UNLINK + lazyfree-*);
  4. 集群迁移阻塞MIGRATE 是同步的,迁一个 1GB 的 key 可能阻塞几十秒(第 12 篇);
  5. 内存分布不均:Cluster 中某个节点内存暴涨,无法通过扩容解决(一个 key 不能拆分到两个节点);
  6. 持久化影响: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 的显著比例)。

危害

  1. 单节点被打满:Cluster 中这个 key 只在一个节点上,那个节点的 CPU 和网卡成为瓶颈,无法通过扩容解决
  2. 拖累同节点的其他 key:单线程模型下,热 key 的请求排队会影响同节点所有请求;
  3. 热 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 核心挑战

  1. 瞬时高并发:几万到几十万 QPS 集中在几秒内;
  2. 绝对不能超卖:库存是硬约束;
  3. 热点集中:所有请求都打到同一个商品的库存 key;
  4. 数据库承受不了:不能让每个请求都查库/写库。

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)再删一次缓存

目的:第二次删除清掉那个"在第一次删除和更新数据库之间被并发读写入的旧值"。

问题

  1. 延迟时间无法确定:要覆盖"并发读的查库 + 写缓存"耗时,如果读的是 MySQL 从库还要覆盖主从延迟。设短了没用,设长了不一致窗口反而更大;
  2. 实现别扭time.AfterFunc 在进程崩溃时丢失;用 MQ 做延迟又增加复杂度;
  3. 仍不能保证 100% 一致(延迟期间又有并发读写就白做了)。

结论:延迟双删不是标准方案,它是"先更 DB 再删缓存"之外的额外保险,主要用在读写分离且主从延迟明显的场景。大多数情况直接用 Cache Aside + TTL 兜底就够了。

Q8:怎么保证缓存和数据库的强一致性?

前提认知:只要缓存和数据库是两个独立系统,就不可能有完美的强一致(“更新 DB"和"删缓存"无法原子化,除非引入分布式事务,代价高到不可接受且 Redis 不支持 XA)。

所以问题应该是”怎么让不一致的窗口尽可能小、概率尽可能低、且能自动收敛"。

方案(按可靠性递增)

  1. Cache Aside(先更 DB 再删缓存) —— 基础,覆盖 99% 场景;
  2. + 合理的 TTL(必备) —— 这是所有方案的最后一道保险,保证不一致最多持续一个 TTL 周期。永远不要给业务缓存设"永不过期";
  3. + 删除失败重试(投递到 MQ 异步重试);
  4. + binlog 订阅(Canal/Debezium) —— 最可靠:业务只管写 DB,独立服务订阅 binlog 删缓存。优点是业务解耦(不会有"某处代码忘了删缓存")、可靠(配合 MQ 重试保证最终一定删)、能感知直接在 DB 上执行的 SQL(运维改数据、定时脚本)。缺点是架构复杂 + 有几十到几百毫秒延迟;
  5. + 定期对账:抽样比对并修复,更重要的是发现系统性问题

真正需要强一致的场景(如金额)不要依赖缓存,直接读数据库,或者用数据库的乐观锁/唯一约束保证正确性。

Q9:什么是大 key?有什么危害?怎么治理?

标准:string > 10KB;集合类元素数 > 5000 或总内存 > 1MB

六大危害

  1. 网络阻塞:1MB 的 value × 1000 QPS = 1GB/s,直接打满网卡;
  2. 操作阻塞主线程HGETALL/SMEMBERS 是 O(N),可能阻塞几百毫秒(单线程模型下拖累所有请求);
  3. 删除阻塞DEL 要同步释放大量内存(要用 UNLINK + lazyfree-*);
  4. 集群迁移阻塞MIGRATE同步的,迁 1GB 的 key 可能阻塞几十秒,还可能误触发故障转移(第 12 篇);
  5. 内存分布不均:Cluster 中某节点暴涨,无法通过扩容解决(一个 key 不能拆分到两个节点);
  6. 加剧持久化压力:AOF 重写/RDB 生成时处理大 key 耗时长。

排查redis-cli --bigkeys(采样,走 SCAN 安全)、--memkeys(按内存排序)、MEMORY USAGE key、离线分析 RDB(redis-rdb-tools)。

治理

  1. 拆分(最根本):大 hash 按 hash(field) % N 分桶——顺带还能保持 listpack 编码省 5~10 倍内存(第 4 篇);
  2. 只取需要的部分HMGET/HSCAN 代替 HGETALL,分页 LRANGE
  3. 压缩 value(应用层 gzip/zstd);
  4. 不该放 Redis 的别放(大文件放对象存储,Redis 只存 URL);
  5. 删除用 UNLINK 或分批删(HSCAN+HDELLTRIMZREMRANGEBYRANK)。

Q10:什么是热 key?怎么发现和治理?

热 key:QPS 远高于其他 key(如单 key > 1 万 QPS)。

危害:① Cluster 中这个 key 只在一个节点上,该节点 CPU/网卡成为瓶颈且无法通过扩容解决;② 单线程模型下拖累同节点所有 key;③ 它失效时会引发缓存击穿。

发现

  1. redis-cli --hotkeys(需要 maxmemory-policy 为 LFU,基于 OBJECT FREQ);
  2. MONITOR 抓样本统计(线上慎用,有明显性能开销);
  3. 客户端埋点统计(最准确且无侵入,推荐);
  4. 云厂商内置的热 key 探测(阿里云 Tair、腾讯云 Redis)。

治理(按效果排序)

  1. 本地缓存(最有效):把热 key 缓存在进程内(TTL 几秒到几十秒),请求根本不到 Redis。代价是一致性变弱,要配短 TTL + pub/sub 失效通知兜底;
  2. Redis 6.0 客户端缓存CLIENT TRACKING ON):服务端主动推送 invalidate,比自己做本地缓存更精准;
  3. 拆成多个副本 keyhotkey:0 ~ hotkey:N 分散到不同 slot,读时随机选一个。代价是写放大 N 倍
  4. 读写分离:热 key 的读分散到从节点(有主从延迟);
  5. 业务层削峰singleflight 合并、限流、计数类先本地累加再批量提交。

Q11:秒杀系统怎么设计?

核心思想:Redis 负责性能(挡住 99.9% 流量),数据库负责正确性(最终兜底)。

六层设计

  1. 前端:按钮置灰防重复提交、CDN 静态化、答题/验证码削峰;
  2. 网关:限流(令牌桶把 10 万 QPS 削到 1 万)、黑名单;
  3. 应用层本地缓存"已售完"标记(售完后请求根本不到 Redis)、singleflight 合并;
  4. Redis(核心)Lua 脚本原子完成"库存校验 + 扣减 + 一人一单"。注意两个 key 要用 hash tag 保证同 slot(seckill:{skuID}:stock / seckill:{skuID}:users);
  5. 消息队列:Redis 扣减成功后发消息,异步创建订单(削峰填谷)。发消息失败必须回滚 Redis 扣减
  6. 数据库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 倍)。

四个局限

  1. 不支持删除(删一个元素会把别的元素共用的位清掉)。计数布隆过滤器可以删但内存翻几倍。实践中只能定期重建
  2. 有误判(虽然只是多查一次 DB,可接受);
  3. 需要预热:上线时要把全量已有 ID 灌进去(几亿条数据要用 BF.MADD + pipeline 批量跑很久);
  4. 容量要预估准:实际元素超过预设容量后误判率急剧上升。要留 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 而不是分布式锁(快一个数量级)。