Skip to content

缓存技术 ​

#缓存 · #Redis · #MySQL · #一致性 · #穿透 · #击穿 · #雪崩

缓存是提升系统性能的核心手段,但引入缓存也带来了数据一致性、可用性等一系列挑战。本专题聚焦缓存的设计模式、常见问题与解决方案。


为什么需要缓存 ​

  • 降低延迟:内存访问 ~100ns vs 磁盘 ~10ms,差距 5 个数量级
  • 减轻数据库压力:热点数据由缓存承担,DB 只处理冷数据和写入
  • 提升吞吐:单机 Redis 可达 10w+ QPS,远超 MySQL 的数千 QPS

缓存读写模式 ​

Cache Aside(旁路缓存) ​

最常用的模式——也是绝大多数业务系统默认采用的方案:

mermaid
sequenceDiagram
    participant App as "应用"
    participant Cache as "缓存 (Redis)"
    participant DB as "数据库"

    Note over App,DB: 读流程
    App->>Cache: 1. 查缓存
    alt 命中
        Cache-->>App: 返回数据 ✅
    else 未命中
        Cache-->>App: nil
        App->>DB: 2. 查数据库
        DB-->>App: 返回数据
        App->>Cache: 3. 写回缓存
    end

    Note over App,DB: 写流程
    App->>DB: 1. 更新数据库
    DB-->>App: OK
    App->>Cache: 2. 删除缓存(而非更新)

为什么是"删除缓存"而非"更新缓存":更新缓存有两个问题——(1) 并发写顺序不确定可能导致缓存和 DB 不一致;(2) 有些数据是计算出来的(聚合/Join),更新开销大。删除缓存让它"按需加载"是最简单的正确方案。

Read Through / Write Through ​

缓存层作为代理,应用只与缓存交互:

读:缓存未命中时,缓存层自动从 DB 加载
写:缓存层同步写入 DB + 缓存

优点:应用逻辑简单 缺点:需要缓存层支持(如 Guava LoadingCache)

Write Behind(异步写回) ​

写:只写缓存,异步批量刷入 DB

优点:写入性能极高 缺点:数据丢失风险,一致性最弱


缓存常见问题 ​

缓存穿透(Cache Penetration) ​

现象:查询一个不存在的数据,缓存和 DB 都没有,每次请求都打到 DB。

解决方案:

方案原理适用场景
缓存空值将 null 结果也缓存,设置较短 TTL偶发性穿透
布隆过滤器在缓存前加一层 BloomFilter 拦截大量恶意请求
参数校验在入口层拦截非法参数可预判的非法请求

布隆过滤器的误判率推导——为什么能用极少内存拦截大量无效请求?

布隆过滤器原理:
  - m 位的 bit 数组(初始全 0)
  - k 个独立哈希函数
  - 插入元素: 计算 k 个哈希值 → 对应 k 个位置置 1
  - 查询元素: 计算 k 个哈希值 → 如果所有位置都是 1 → "可能存在"
                                → 如果任一位置是 0 → "一定不存在"

误判率推导:
  设已插入 n 个元素,bit 数组大小 m,哈希函数 k 个

  单次哈希后某个特定位仍为 0 的概率:
    P(某位=0) = (1 - 1/m)^{kn}

  当 m 较大时:
    (1 - 1/m)^{kn} ≈ e^{-kn/m}

  误判率 = 所有 k 个位都恰好为 1 的概率:
    FPR = (1 - e^{-kn/m})^k

  最优哈希函数个数(使 FPR 最小):
    k_opt = (m/n) × ln2 ≈ 0.693 × (m/n)

  代入最优 k 后:
    FPR_min = (1/2)^k = (0.6185)^{m/n}

实际工程参数选择:

  | 目标误判率 | m/n (每元素bit数) | k (哈希函数数) | 内存开销 |
  |-----------|:-:|:-:|---------|
  | 1%        | 9.6 bits | 7 | 1.2 MB / 百万元素 |
  | 0.1%      | 14.4 bits | 10 | 1.8 MB / 百万元素 |
  | 0.01%     | 19.2 bits | 13 | 2.4 MB / 百万元素 |

  对比: 存储 100 万个 key(平均 32 字节)需要 32MB
       布隆过滤器只需 1.2MB 就能达到 1% 误判率 → 节省 96% 内存!

Redis 中的布隆过滤器:
  RedisBloom 模块: BF.ADD / BF.EXISTS
  默认参数: error_rate=0.01, capacity=100
  底层: 多层 sub-filter(可动态扩容)
python
# 布隆过滤器 + 缓存空值组合方案
def get_data(key):
    # 第一层:布隆过滤器快速判断
    if not bloom_filter.might_contain(key):
        return None

    # 第二层:查缓存
    value = redis.get(key)
    if value == "NULL_PLACEHOLDER":
        return None
    if value is not None:
        return value

    # 第三层:查 DB
    value = db.query(key)
    if value is None:
        redis.setex(key, 60, "NULL_PLACEHOLDER")  # 缓存空值 60s
    else:
        redis.setex(key, 3600, value)
    return value

缓存击穿(Cache Breakdown) ​

现象:某个热点 Key 过期瞬间,大量并发请求同时打到 DB。

解决方案:

方案原理优缺点
互斥锁只允许一个线程回源,其他等待保证一致性,但有等待开销
逻辑过期不设 TTL,由业务判断是否过期并异步刷新无等待,但有短暂脏数据
热点预加载提前续期或永不过期简单,但需识别热点

singleflight 实现原理——互斥锁方案的进化版:

go
// singleflight 的核心思想:同一时刻对同一个 key 的并发请求,只执行一次实际操作
// 其他请求等待并共享结果

import "golang.org/x/sync/singleflight"

var g singleflight.Group

func getHotData(key string) (interface{}, error) {
    // Do 方法:如果 key 已经有 goroutine 在执行 fn,
    // 当前 goroutine 会阻塞等待,共享第一个 goroutine 的返回值
    v, err, _ := g.Do(key, func() (interface{}, error) {
        return db.Query(key)  // 只有第一个 goroutine 真正执行
    })
    return v, err
}

singleflight 的内部原理:

type Group struct {
    mu sync.Mutex
    m  map[string]*call  // key → 正在进行中的调用
}

type call struct {
    wg  sync.WaitGroup   // 用于等待结果
    val interface{}       // 调用结果
    err error
}

Do(key, fn) 的工作流程:

goroutine-1 调用 Do("user:123", fn):
  mu.Lock()
  检查 m["user:123"] → 不存在
  创建 call{val: nil, err: nil}, wg.Add(1)
  m["user:123"] = call
  mu.Unlock()
  执行 fn() → 得到结果 → call.val, call.err
  wg.Done()  ← 唤醒等待者

goroutine-2 同时调用 Do("user:123", fn):
  mu.Lock()
  检查 m["user:123"] → 已存在 → 返回 call
  mu.Unlock()
  call.wg.Wait()  ← 阻塞在这里,等 goroutine-1 完成
  → call.wg 被 Done() 唤醒 → 直接返回 call.val, call.err
  ← fn 没有被执行!

关键优势:
  - 比 Redis 分布式锁更轻量(纯内存操作,无网络 IO,~50ns vs ~1ms+)
  - 自动共享结果,无需手动编码"等待-重试"逻辑
  - 内置 forget 机制(g.Forget(key)),防止 key 永久占用
  - 但仅在单个进程内有效,多实例仍需配合 Redis 锁
python
# 互斥锁方案
def get_hot_data(key):
    value = redis.get(key)
    if value is not None:
        return value

    # 尝试获取分布式锁
    lock_key = f"lock:{key}"
    if redis.set(lock_key, "1", nx=True, ex=10):
        try:
            value = db.query(key)
            redis.setex(key, 3600, value)
        finally:
            redis.delete(lock_key)
        return value
    else:
        # 等待后重试
        time.sleep(0.05)
        return get_hot_data(key)

缓存雪崩(Cache Avalanche) ​

现象:大量 Key 同时过期,或缓存服务宕机,请求全部涌向 DB。

解决方案:

  • 随机过期时间:在基础 TTL 上加随机偏移,避免集中过期
  • 多级缓存:L1(本地)+ L2(Redis),即使 Redis 挂了本地缓存仍可用
  • 熔断降级:DB 压力过大时触发熔断,返回默认值或缓存旧数据
  • 集群高可用:Redis Sentinel / Cluster 保证缓存层不宕机
python
import random

# 随机过期时间
base_ttl = 3600
random_ttl = base_ttl + random.randint(0, 600)  # 3600~4200s
redis.setex(key, random_ttl, value)

回源风暴:为什么缓存问题最后常常炸的是 MySQL ​

很多线上事故不是“Redis 慢了一点”,而是缓存问题沿着依赖链一路放大:

mermaid
flowchart LR
    U["用户流量"] --> APP["应用"]
    APP --> C["缓存 Redis/L1"]
    C -->|"miss / 超时 / 宕机"| DB["MySQL"]
    DB -->|"慢查询 / 连接池打满"| APP
    APP -->|"线程 / goroutine 堆积"| FAIL["超时、502、雪崩"]
text
热 Key 失效 / Redis 抖动
→ 大量请求同时回源 DB
→ DB QPS 暴涨、连接池打满
→ SQL 变慢、事务堆积、锁等待增加
→ 应用线程/goroutine 堆积
→ 更多请求超时重试
→ 整个链路雪崩

所以缓存设计本质上是在保护下游数据库,不是只为了“让读更快”。

层面防护动作
应用层singleflight / 分布式锁 / 本地缓存 / 限流
缓存层TTL 打散、热点预热、Cluster 高可用
DB 层连接池上限、只读副本、慢查询治理
架构层熔断、降级、隔离热点、异步化

缓存与数据库一致性 ​

经典问题:先更新 DB 还是先删缓存? ​

策略问题
先删缓存,再更新 DB并发读可能将旧值重新写入缓存
先更新 DB,再删缓存删缓存失败则不一致(概率较低)

延迟双删 — 为什么需要删两次 ​

单次删除的并发问题:

mermaid
sequenceDiagram
    participant A as "写请求"
    participant Cache as "Redis"
    participant DB as "MySQL"
    participant B as "并发读请求"

    A->>Cache: 删除缓存
    B->>Cache: 读缓存 (miss)
    B->>DB: 读 DB (此时 A 还没写完!)
    DB-->>B: 返回旧数据
    A->>DB: 更新 DB (新数据写入)
    B->>Cache: 将旧数据写回缓存
    Note over Cache: 🔴 缓存里是旧数据!<br/>直到下次写操作或 TTL 过期

    Note over A,B: 延迟双删修复:
    A->>A: sleep(200ms)
    A->>Cache: 第二次删除
    Note over Cache: ✅ 旧数据被清理
python
def update_with_double_delete(key, value):
    redis.delete(key)              # 第 1 次删除

    db.update(key, value)          # 更新 DB

    time.sleep(0.2)                 # 等待并发读完成
    redis.delete(key)              # 第 2 次删除(兜底清理)

    # 更可靠的做法:异步延迟删除
    # threading.Thread(target=lambda: (
    #     time.sleep(0.5), redis.delete(key)
    # )).start()

sleep 时间怎么定:通常取"一次读请求从 DB 加载到写回缓存的最大耗时"。如果业务可接受短暂不一致(如个人设置页),单次删除 + 短 TTL 足够;如果是支付/库存,延迟双删 + 异步补偿是底线。

延迟双删的 sleep 时间分析——为什么不能太短也不能太长:

时间轴分析:

  T0: 写请求删除缓存
  T1: 并发读请求查缓存(miss)→ 查 DB
  T2: 写请求更新 DB 完成
  T3: 并发读请求将 DB 查到的值写回缓存

  如果 sleep < T3 - T0:
    第二次删除发生在读请求写回缓存之前 → 旧数据仍会进入缓存 → 删除无效 ❌

  如果 sleep >> T3 - T0:
    不一致窗口拉长 → 用户在一段时间内看到旧数据

  最佳 sleep = 读请求"查DB → 写回缓存"的最大耗时 × 安全系数
              ≈ (DB查询P99 + 网络RTT + 序列化) × 1.5
              ≈ 通常在 200ms ~ 1s 之间

不一致窗口的量化分析——先更新 DB 再删缓存的并发窗口:

不一致发生的精确条件:

  设 T_read  = 读请求查DB到写回缓存的耗时
  设 T_write = 写请求更新DB的耗时

  不一致序列(概率极低):
    1. 读请求查缓存 → miss
    2. 写请求更新 DB → 成功
    3. 写请求删除缓存 → 成功(但读请求还没写回!)
    4. 读请求查 DB → 拿到旧数据?
       → 等等!步骤 2 已经更新了 DB → 读请求读到的是新数据 → 没有不一致

  真正导致不一致的序列:
    1. 缓存过期/被删除
    2. 读请求查缓存 → miss
    3. 读请求查 DB → 此时写请求还没更新完 → 拿到旧数据 ← 关键!
    4. 写请求更新 DB 完成
    5. 写请求删除缓存(此时缓存为空,删除无效果)
    6. 读请求将旧数据写回缓存 → 不一致!

  这个窗口 = P(缓存miss) × P(读查DB期间有写操作) × P(写完成时间 > 读查DB时间)

  在大部分场景下:
    P(缓存命中率高) → P(miss) 很小
    读查DB在毫秒级完成 → 窗口极窄
    → 实际不一致概率 < 0.001%

binlog + MQ 异步删缓存——生产级一致性的最终方案:

延迟双删仍然有失败的可能(第二次删除失败、服务重启)。binlog 异步方案通过监听数据库变更日志,保证了最终一致性:

mermaid
sequenceDiagram
    participant App as 应用
    participant DB as MySQL
    participant Canal as Canal (binlog监听)
    participant MQ as Kafka/RocketMQ
    participant Consumer as 缓存更新消费者
    participant Redis as Redis

    App->>DB: UPDATE user SET name='new' WHERE id=123
    DB->>DB: 写入 binlog (row 格式)

    Canal->>DB: 伪装成 slave,拉取 binlog
    Canal->>MQ: 发送消息 {table:user, id:123, type:UPDATE, old:{name:'old'}, new:{name:'new'}}

    MQ->>Consumer: 消费消息
    Consumer->>Redis: DEL user:123  (或 SET user:123 new_data)

    Note over Consumer,Redis: 消费失败?MQ 自动重试 → 最终一定删除成功 ✅

    应用也可以同时删除(双保险):
    App->>Redis: DEL user:123 (同步删除,减少不一致窗口)
go
// Canal 方案的关键实现要点

// 1. 消费者幂等性:同一条 binlog 可能被重复消费
func handleBinlogEvent(event BinlogEvent) error {
    // 用 binlog 的 (filename, position) 做去重 key
    dedupKey := fmt.Sprintf("binlog:%s:%d", event.FileName, event.Position)
    if !redis.SetNX(dedupKey, "1", 10*time.Minute) {
        return nil // 已处理过
    }

    // 删除或更新缓存
    switch event.Type {
    case "UPDATE", "DELETE":
        redis.Del(cacheKey(event.Table, event.Row["id"]))
    case "INSERT":
        // 不删缓存,等读时自然加载
    }
    return nil
}

// 2. 顺序性保证:同一行的操作必须按顺序处理
// Kafka: 用 主键ID 做分区 key → 同一行的变更进入同一分区 → 顺序消费
// RocketMQ: 用 MessageQueueSelector 按 ID 选择队列

// 3. 重试机制
func consumeWithRetry(event BinlogEvent) {
    for i := 0; i < maxRetries; i++ {
        if err := handleBinlogEvent(event); err == nil {
            return
        }
        time.Sleep(backoff(i)) // 指数退避
    }
    log.Error("finally failed", event) // 进入死信队列,人工处理
}

各一致性方案的对比总结:

方案不一致窗口复杂度失败处理适用
先更新DB,后删缓存极窄 (~ms)⭐ 低删失败需等 TTL 过期大多数场景
延迟双删200ms~1s⭐⭐ 中第二次删失败仍有残留中等一致性
binlog + MQ秒级⭐⭐⭐ 高MQ 重试 → 最终必成功支付/库存
分布式事务 (Seata)0 (强一致)⭐⭐⭐⭐⭐两阶段提交金融核心

一致性方案怎么选:不要只会“延迟双删” ​

场景推荐方案原因
用户资料、商品详情更新 DB 后删缓存 + 短 TTL允许短暂不一致,方案最简单
高并发热点读删缓存 + singleflight/互斥回源防止热点击穿
库存、优惠券余量缓存只做展示,加写路径串行化/原子扣减不能只依赖最终一致性
搜索索引、推荐结果MQ 异步更新缓存/索引接受秒级延迟
强一致要求读写都走主存储,缓存只做旁路加速不要把缓存当真相来源

一个很重要的判断原则:

text
缓存适合加速“读模型”
不适合承担“最终正确性来源”

如果业务的正确性依赖“缓存一定和 DB 完全一致”,通常说明架构职责已经混乱。


热 Key 问题 ​

识别热 Key ​

  • Redis 4.0+ redis-cli --hotkeys
  • 客户端统计(如 Lettuce metrics)
  • 代理层统计(如 Codis/Twemproxy)

解决方案 ​

方案原理
本地缓存热 Key 提升到 L2,减少 Redis 压力
Key 分片将 hot_key 拆为 hot_key_1 ~ hot_key_N,分散到不同节点
读写分离读请求走从节点

Key 分片的一致性问题:

分片方案让读请求随机选一个分片读取,写请求写入所有分片。但写入所有分片不是原子操作:

python
# 写入所有分片
for i in range(SHARD_COUNT):
    redis.setex(f"{key}:{i}", ttl, value)
写入时可能的异常:
  [分片0] ✅ 写入成功
  [分片1] ✅ 写入成功
  [分片2] ❌ 网络超时 → 没写入
  [分片3] ✅ 写入成功
  ...

后果:
  - 客户端随机读到一个分片,可能拿到旧值 → 短暂不一致
  - 分片 2 上的旧值一直存在,直到 TTL 过期

缓解方案:
  1. 客户端做"读修复":读到旧值后,检查版本号,如果不匹配则写回新值
  2. 仅用于"低一致要求"的场景(如推荐列表、排行榜)
  3. 对一致性敏感的数据,用本地缓存(单实例内一致)代替分片
  4. Redis Cluster 模式的 hash slot 自动分片,无需应用层分片
python
# Key 分片方案
import random

SHARD_COUNT = 10

def get_hot_key(key):
    # 随机选择一个分片读取
    shard_key = f"{key}:{random.randint(0, SHARD_COUNT - 1)}"
    return redis.get(shard_key)

def set_hot_key(key, value, ttl):
    # 写入所有分片
    for i in range(SHARD_COUNT):
        shard_key = f"{key}:{i}"
        redis.setex(shard_key, ttl, value)

大 Key 问题 ​

危害 ​

  • 网络带宽占用大,阻塞其他请求
  • 单次操作耗时长,触发慢查询
  • 集群数据倾斜,内存不均衡
  • 过期删除时阻塞主线程

解决方案 ​

  • 拆分:Hash 大 Key 按字段分组拆为多个小 Hash
  • 压缩:对 Value 进行 gzip/snappy 压缩
  • 异步删除:Redis 4.0+ UNLINK 替代 DEL
  • 渐进式处理:HSCAN / SSCAN 分批处理

缓存预热与降级 ​

预热策略 ​

  • 启动预热:服务启动时主动加载热点数据
  • 定时预热:定时任务刷新即将过期的热点
  • 流量预热:大促前通过压测流量预热缓存

降级策略 ​

python
def get_with_fallback(key):
    try:
        value = redis.get(key)
        if value:
            return value
    except RedisException:
        pass  # Redis 不可用,降级

    # 降级方案:查本地缓存或返回默认值
    value = local_cache.get(key)
    if value:
        return value

    return default_value(key)

本地缓存实现对比 ​

本地缓存(L1)是多级缓存架构中延迟最低的一层(纳秒级),但容量有限且存在进程间不一致问题。选型需要权衡淘汰策略、并发性能和内存效率:

维度Go sync.MapGo RistrettoJava CaffeineJava Guava Cache
淘汰策略无(只增不删)TinyLFU + 采样W-TinyLFULRU
并发模型读写分离 Map分片 + 无锁 ring buffer分段锁 + 异步写分段锁
命中率—高(接近最优)极高(业界最优)中等
内存开销高(双 Map)低(Bloom Filter 计数)低中
过期支持❌✅ TTL✅ TTL + 访问过期✅ TTL + 访问过期
统计/监控❌✅✅ 命中率/淘汰数✅
适用场景读多写少的简单缓存Go 通用本地缓存Java 通用本地缓存旧项目兼容

W-TinyLFU 算法原理 ​

Caffeine 的核心创新——结合了 LRU 的"新鲜度"和 LFU 的"频率"优势:

mermaid
flowchart LR
    New["新元素"] --> Window["Window Cache<br/>(1% 容量, LRU)<br/>保护新元素"]
    Window -->|"淘汰"| Filter{"TinyLFU<br/>频率过滤器"}
    Filter -->|"频率高于 Main 淘汰候选"| Main["Main Cache<br/>(99% 容量, 分段 LRU)"]
    Filter -->|"频率低"| Discard["丢弃"]
    Main -->|"淘汰候选"| Filter

为什么 Caffeine 命中率最高:传统 LRU 对突发扫描(一次性大量访问)很脆弱——扫描数据会把热点数据挤出去。TinyLFU 用 Count-Min Sketch(4 bit 计数器 + 4 个哈希函数)以极低内存(每元素 8 字节)估算访问频率,只有频率足够高的元素才能进入主缓存。

Go Ristretto 使用示例 ​

go
import "github.com/dgraph-io/ristretto"

cache, _ := ristretto.NewCache(&ristretto.Config{
    NumCounters: 1e7,     // 跟踪频率的计数器数量(10x 最大条目数)
    MaxCost:     1 << 30, // 最大内存 1GB
    BufferItems: 64,      // 每个 Get buffer 大小
})

// 写入(cost = 对象大小估算)
cache.Set("user:123", user, int64(unsafe.Sizeof(user)))
cache.Wait() // 等待异步写入完成

// 读取
if val, found := cache.Get("user:123"); found {
    user := val.(*User)
}

缓存容量规划 ​

缓存不是越大越好——过大浪费内存,过小命中率低。容量规划的核心公式:

所需缓存容量 = 热点数据量 × 单对象大小 × 安全系数

其中:
  热点数据量 = 总数据量 × 热点比例(通常 20/80 法则)
  安全系数 = 1.2 ~ 1.5(预留碎片和元数据开销)

实战估算示例 ​

场景:电商商品缓存
  - 总商品数:1000 万
  - 热点商品(80% 流量集中在 20% 商品):200 万
  - 单商品缓存大小:~2KB(JSON 序列化)
  - 安全系数:1.3

  所需 Redis 内存 = 200万 × 2KB × 1.3 ≈ 5.2 GB
  → 选择 8GB Redis 实例(预留 GC 和 fork 开销)

命中率与容量的关系 ​

命中率
  │
  │         ╭─────────────────── 接近 100%
  │        ╱
  │       ╱   ← 边际收益递减点(通常 85-95%)
  │      ╱
  │     ╱
  │    ╱
  │   ╱
  │  ╱
  │ ╱
  └──────────────────────────── 缓存容量
  0     热点20%    50%    100%

  经验值:
  - 缓存 20% 数据 → 命中率 ~80%
  - 缓存 30% 数据 → 命中率 ~90%
  - 缓存 50% 数据 → 命中率 ~95%
  - 超过 50% 后边际收益极低

QPS 容量验证 ​

验证公式:
  Redis 实例 QPS 上限 = 单实例 QPS × 节点数
  业务所需 QPS = 总 QPS × 缓存命中率

  例:总 QPS = 10万,命中率 90%
  → Redis 需承受 9万 QPS
  → 单实例 10万 QPS → 1 个主节点即可
  → 考虑读写分离:1 主 2 从,读 QPS 分摊到从节点

多级缓存一致性 ​

多级缓存(L1 本地 + L2 Redis + L3 DB)的核心挑战是:L1 是进程内缓存,多个实例的 L1 之间无法直接通信。当数据变更时,如何让所有实例的 L1 失效?

失效传播方案 ​

mermaid
sequenceDiagram
    participant App1 as "实例 A"
    participant App2 as "实例 B"
    participant Redis as "Redis (L2)"
    participant PubSub as "Redis Pub/Sub"
    participant DB as "MySQL"

    Note over App1: 数据变更
    App1->>DB: 1. 更新 DB
    App1->>Redis: 2. 删除 L2 缓存
    App1->>App1: 3. 删除本地 L1
    App1->>PubSub: 4. 发布失效消息<br/>channel: cache_invalidate<br/>payload: {key: "user:123"}

    PubSub->>App2: 5. 收到失效通知
    App2->>App2: 6. 删除本地 L1 中的 user:123

    Note over App2: 下次访问时
    App2->>Redis: 7. L1 miss → 查 L2
    Redis-->>App2: miss
    App2->>DB: 8. 查 DB
    DB-->>App2: 返回最新数据
    App2->>Redis: 9. 写回 L2
    App2->>App2: 10. 写入 L1

实现代码 ​

go
// 多级缓存失效传播
type MultiLevelCache struct {
    local  *ristretto.Cache    // L1: 本地缓存
    redis  *redis.Client       // L2: Redis
    pubsub *redis.PubSub       // 失效通知
}

func (c *MultiLevelCache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
    // 写 L2
    data, _ := json.Marshal(value)
    if err := c.redis.Set(ctx, key, data, ttl).Err(); err != nil {
        return err
    }
    // 写 L1(较短 TTL,兜底过期)
    c.local.SetWithTTL(key, value, 1, ttl/10)
    return nil
}

func (c *MultiLevelCache) Invalidate(ctx context.Context, key string) error {
    // 1. 删 L1
    c.local.Del(key)
    // 2. 删 L2
    c.redis.Del(ctx, key)
    // 3. 广播失效(通知其他实例删 L1)
    c.redis.Publish(ctx, "cache:invalidate", key)
    return nil
}

// 订阅失效通知(每个实例启动时调用)
func (c *MultiLevelCache) SubscribeInvalidation(ctx context.Context) {
    ch := c.pubsub.Channel()
    for msg := range ch {
        c.local.Del(msg.Payload) // 收到通知,删除本地缓存
    }
}

各方案对比 ​

方案实时性可靠性复杂度适用
Redis Pub/Sub毫秒级弱(消息可能丢失)低大多数场景
Kafka/RocketMQ秒级强(持久化)中金融/支付
短 TTL 兜底TTL 时间强最低容忍短暂不一致
版本号比对每次请求强中强一致性要求

最佳实践:Redis Pub/Sub 做主动失效 + 短 TTL(如 30s)做兜底。即使 Pub/Sub 消息丢失,最多 30s 后 L1 也会自动过期。


线上缓存问题排查 Runbook ​

先判断是哪一类问题 ​

症状高概率问题第一反应
Redis QPS 突降,DB QPS 暴涨缓存 miss 激增 / Redis 不可用先保护 DB
某个接口 RT 飙高热点 key 失效 / 回源慢查 key 命中率和回源次数
Redis CPU 很高热 key / 大 key / 命令复杂度高查 hotkeys、slowlog
Redis 内存持续涨大 key、淘汰不生效、TTL 设置不合理查内存、key 大小分布
数据不一致投诉删缓存失败、L1/L2 失效传播丢失查写链路和消息链路

排查顺序 ​

bash
# 1. Redis 自身状态
redis-cli info stats
redis-cli info memory
redis-cli info commandstats
redis-cli slowlog get 20

# 2. 命中率 / 淘汰 / 过期
redis-cli info keyspace
redis-cli info stats | egrep 'keyspace_hits|keyspace_misses|expired_keys|evicted_keys'

# 3. 热 key(Redis 4.0+)
redis-cli --hotkeys

# 4. 大 key 抽样
redis-cli --bigkeys

# 5. 应用侧是否回源风暴
# 看接口 QPS、DB QPS、连接池等待、singleflight 命中、线程/goroutine 数

client → cache → db 故障传播模板 ​

mermaid
flowchart TD
    A["接口超时 / RT 飙高"] --> B{"缓存层表现?"}
    B -->|"命中率骤降"| C["大量回源 DB"]
    B -->|"Redis CPU 高"| D["热 key / 大 key / 慢命令"]
    B -->|"Redis 不可用"| E["进入降级 / 熔断"]

    C --> F{"DB 是否顶住?"}
    F -->|"否"| G["连接池满、慢查询、锁等待"]
    G --> H["应用 goroutine/线程堆积"]
    H --> I["全链路超时、重试、雪崩"]

    D --> J["拆 key / 本地缓存 / 分片 / 优化命令"]
    E --> K["启用本地缓存 / 默认值 / 限流 / 熔断"]

止血与根治 ​

类型动作说明
止血打开降级开关避免所有请求直冲 DB
止血给热点 key 手工预热降低瞬时回源
止血限流 + 熔断不让慢下游拖垮全链路
根治singleflight / 分布式锁控制并发回源
根治多级缓存Redis 抖动时还有 L1 兜底
根治TTL 打散 + 热点永续刷新避免同时过期
根治建立命中率 / 回源率 / 热 key / 大 key 监控提前发现趋势

生产级 binlog 方案:削峰、降级与双写补偿 ​

为什么 MQ 会积压? ​

基于 binlog + MQ 的缓存更新链路:

MySQL → Canal → Kafka → Consumer → Redis

当发生大批量数据订正(如运营批量更新百万用户的标签、数据清洗重跑)时:

正常 QPS:  1000 条/秒 → Consumer 轻松消费
订正 QPS: 50000 条/秒 → MQ 瞬间堆积 → Consumer 跟不上

问题链:

  1. MQ 堆积 → 消费延迟从毫秒级变成分钟级甚至小时级
  2. 缓存清理命令阻塞在 MQ 中 → 用户读到的是旧缓存
  3. 应用层也做了同步删缓存(只是删了一次),但读回源时 DB 压力不大
  4. 真正的风险是:用户持续看到旧数据,但以为"系统正常"——因为没有报错

积压时的降级架构 ​

mermaid
flowchart TB
    subgraph Normal["正常链路"]
        A["App 写入 DB"] --> B["Canal 监听 binlog"]
        B --> C["Kafka Topic"]
        C --> D["Consumer 更新 Redis"]
        A --> E["同步删 Redis(双保险)"]
    end

    subgraph Burst["订正链路(百万行)"]
        F["批量 UPDATE"] --> G["binlog 瞬间 N 万条"]
        G --> H["Kafka 积压"]
    end

    subgraph Fallback["降级策略"]
        H --> I{"Consumer 滞后 > 阈值?"}
        I -->|"否"| D
        I -->|"是"| J["⚠️ 触发降级"]

        J --> K["策略1: 跳过中间状态<br/>同 key 只保留最新一条"]
        J --> L["策略2: 全量失效<br/>直接 DEL 整个 key 前缀"]
        J --> M["策略3: 暂停 MQ 消费<br/>改走全量重建缓存"]

        K --> N["Redis DEL 最新版本"]
        L --> O["SCAN + DEL pattern:*"]
        M --> P["mysqldump → 批量写 Redis<br/>(绕过 MQ 直写)"]
    end

三种积压处理策略 ​

策略 1:消息合并 — 同一 key 只取最新 ​

go
// 思路:消费到积压消息时,对同一主键的消息做"去重合并"
// 如果 user:123 在 1 秒内被修改了 100 次,只处理最后一次

type DedupConsumer struct {
    recentKeys *ristretto.Cache // 记录最近处理的 key + 最新 offset
    window     time.Duration    // 合并窗口(如 1s)
}

func (c *DedupConsumer) Consume(msg *kafka.Message) {
    key := extractPK(msg) // 提取主键,如 "user:123"

    // 记录该 key 的最新消息 offset
    c.recentKeys.SetWithTTL(key, msg.Offset, 1, c.window)

    // 延迟处理:等合并窗口结束后,才处理该 key 的最新消息
    // 窗口内的中间版本全部跳过 → 大幅减少 Redis 操作
}

局限性:需要 Consumer 能承受积压期间的额外内存;窗口内的中间版本被丢弃,窗口期内 Redis 可能读到旧值。

策略 2:全量失效 — 整个前缀直接清空 ​

go
// 当某张表发生大面积变更时,直接失效整个缓存前缀
// 比逐条 DEL 快 N 个数量级

func bulkInvalidate(rdb *redis.Client, tableName string) error {
    // 方案 A: Redis SCAN + DEL(逐个 key)
    var cursor uint64
    pattern := fmt.Sprintf("cache:%s:*", tableName)
    for {
        keys, nextCursor, _ := rdb.Scan(ctx, cursor, pattern, 100).Result()
        if len(keys) > 0 {
            rdb.Del(ctx, keys...) // 批量删除
        }
        cursor = nextCursor
        if cursor == 0 {
            break
        }
    }

    // 方案 B: 使用版本号前缀(推荐)
    // key 设计: cache:user:v{version}:123
    // 全量失效 = version++ → 新版本的所有 key 自然 miss
    // 无需 DEL(旧版本等 TTL 自然过期)
    return nil
}
text
版本号前缀方案详解:

缓存 key 格式:  cache:{table}:v{version}:{pk}

正常读取:
  version = getCurrentVersion("user") // Redis 存当前版本号
  key = fmt.Sprintf("cache:user:v%d:%d", version, userID)
  data = redis.Get(key)

全量失效:
  INCR cache:user:version  // 版本号 +1
  → 所有读请求自然使用新版本号的 key
  → 旧版本 key 不会有新数据写入
  → 旧版本 key 等 TTL(如 3600s)自然过期

优点:
  - 零 DEL 开销
  - 瞬间完成(一个 INCR)
  - 不会阻塞 Redis

缺点:
  - 所有缓存重新从 DB 加载(可能引起短暂的 DB 压力上升)
  - 需配合 singleflight 防止缓存击穿

策略 3:暂停 MQ 消费,走全量重建 ​

go
// 极端情况:MQ 积压数千万条,消费速度远跟不上生产速度
// → 暂停 Consumer,直接用 DB 数据全量重建缓存

func fullRebuild(rdb *redis.Client, db *sql.DB, tableConfig TableConfig) error {
    version := rdb.Incr(ctx, fmt.Sprintf("cache:%s:version", tableConfig.Name)).Val()

    // 分批从 DB 读取并写入 Redis
    offset := 0
    batchSize := 1000
    for {
        rows, _ := db.Query(
            fmt.Sprintf("SELECT %s FROM %s LIMIT %d OFFSET %d",
                tableConfig.PK, tableConfig.Name, batchSize, offset))

        pipe := rdb.Pipeline()
        count := 0
        for rows.Next() {
            // 写入新版本号的 key
            key := fmt.Sprintf("cache:%s:v%d:%s",
                tableConfig.Name, version, rows.PKValue())
            pipe.Set(ctx, key, rows.CacheValue(), tableConfig.TTL)
            count++
        }
        pipe.Exec(ctx)

        if count < batchSize {
            break // 最后一批
        }
        offset += batchSize
    }

    // 重建完成后,更新版本号(激活新缓存)
    rdb.Set(ctx, fmt.Sprintf("cache:%s:version", tableConfig.Name), version, 0)
    return nil
}

双写补偿通道 — MQ 消费 + 应用同步双路径 ​

生产环境最佳实践是"应用层同步删缓存 + MQ 异步兜底"两条路径同时存在:

mermaid
flowchart TB
    App["应用更新 DB"] --> SyncDel["同步删缓存<br/>(毫秒级,最佳努力)"]
    App --> Binlog["binlog 产生"]
    Binlog --> Canal["Canal"]
    Canal --> MQ["Kafka"]
    MQ --> Consumer["Consumer 消费"]
    Consumer --> AsyncDel["异步删缓存<br/>(秒级,保证最终一致)"]

    SyncDel -->|"失败"| AsyncDel
    AsyncDel -->|"积压"| Fallback["降级:全量失效/重建"]
go
// 双路径实现
func updateWithDualPath(db *sql.DB, rdb *redis.Client, key string, value interface{}) error {
    // 1. 更新 DB
    if err := db.Exec("UPDATE ...", value); err != nil {
        return err
    }

    // 2. 同步删缓存(主路径 — 毫秒级)
    delErr := rdb.Del(ctx, key).Err()

    // 3. 异步补偿(兜底 — 秒级)
    // binlog → Canal → MQ → Consumer 自动处理
    // 如果同步删除失败,MQ 链路会兜底

    if delErr != nil {
        log.Warn("sync delete failed, relying on MQ fallback", "key", key, "err", delErr)
        // 可选:写入一个"补偿任务"到优先级更高的队列
    }

    return nil
}

监控告警 ​

bash
# 关键指标
1. MQ 消费滞后量(Consumer Lag)→ 超过阈值告警
2. binlog 产生速率 vs 消费速率 → 持续落后则告警
3. Redis key 命中率 → 突降说明缓存失效有问题
4. DB QPS → 突增说明回源压力大
指标正常阈值告警阈值处理
Consumer Lag< 100> 1000检查消费能力,考虑扩 Consumer
消费速率> 产生速率< 产生速率 × 0.8触发降级策略
缓存命中率> 95%< 90%检查失效链路
DB QPS基线> 基线 × 2singleflight 兜底

相关专题 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记