系统架构 · 缓存机制

用一致性和复杂度换十倍读性能: 命中率是收入, 失效时机是地雷 — 穿透/击穿/雪崩都是"缓存不接盘的瞬间, 流量全部砸向 DB"

① Cache Aside · 读: GET → miss → 回源 → 回填 · 写: 先更库, 再删缓存 缓存层不代理读写, 一切在应用手里 ① GET item:42 ② HIT · 直接返回 ~0.5ms ② MISS · 回源 SELECT ③ 回填 SETEX 1800s ④ 未命中才回源 (~50ms) · 命中率 98% 时平均延迟仍在 1ms 量级 ① UPDATE items SET price (先动库) ② 提交成功 ③ DEL item:42 · 删失败兜底: binlog 订阅补偿重删 (Canal) 调用方 读 item:42 Redis 缓存 GET item:42 MySQL 主库 SELECT ... WHERE id=42 调用方 写 item:42 MySQL 主库 UPDATE ... commit Redis 缓存 DEL item:42 药方 药方 药方 ② 失效三兄弟 · 病根都是"失效瞬间的流量直达 DB" (rose 事故路径 / emerald 药方) 穿透 Penetration 请求根本不存在的数据 (id=-1) 缓存永远 MISS, 全部落到 DB slow.log: SELECT ... WHERE id = -1 × 30000/s 击穿 Breakdown 热点 key 到期瞬间 5w QPS 并发回源 DB 连接池秒没, 拖垮相邻业务 ERROR 1040 (HY000): Too many connections 雪崩 Avalanche 大批 key 同一时刻集中过期 或 Redis 整体宕机, 洪峰直冲 DB P99 800ms → 30s · 主从延迟 25s amber 虚线 = 药方都在应用/缓存侧生效 — DB 只看到合流后的少量回源 药方 · Bloom Filter 前置拦截 BF.RESERVE bf:item 0.01 5000000 BF.EXISTS = 0 → 直接 404, 不碰 DB 误判 1% 只是多回源, "不存在"必拦 药方 · singleflight 合并回源 同 key 1000 并发只放 1 个进 DB 其余协程等待并共享同一份结果 DB 回源 QPS: 50000 → 1 药方 · TTL 抖动 + 多级兜底 TTL = 1800s + rand(0,600s) 错开 本地缓存 + 限流降级保 DB 底线 DB 峰值倍数: ×50 → ×1.2 ③ TTL 治理 · 同一批 key 的两种过期排法 — 集中到期 vs 抖动错开 无抖动 · 同批 TTL=1800s 50 个 key 同 1 秒集中过期 t=0 t=1800s DB 瞬时 QPS ×50 → ERROR 1040 (HY000): Too many connections 抖动 · TTL=1800s + rand(0,600s) 每秒只漏 1~2 个 key · 回源曲线平稳 t=0 t=1800~2400s DB 每秒回源 1~2 个, 峰值 ×1.2 · 用户无感 ④ 读写策略光谱 — 从"应用自己管"到"缓存层全权代理" Cache Aside 应用直管: 读回填 · 写删 最常用 · 简单可控 Read/Write Through 缓存层代理读写库 应用只见缓存 · 层要写得好 Write Back/Behind 先写缓存, 异步刷回库 最快 · 宕机丢窗口数据 Refresh Ahead 到期前主动续期热点 热点零击穿 · 冷 key 浪费 多级缓存 L1+L2 本地 ns 级 + Redis 兜底 扛热 key · 需管多实例一致 ● 事故链: 热点 key 整点到期 → 5w QPS 同时回源 → ERROR 1040 (HY000): Too many connections → 主从延迟 30s → 全站读超时 ● 破局链: singleflight 合并回源 · Bloom 拦空 · TTL 抖动 · 多级缓存兜底 · DB 前限流降级 — 98% 命中率让 DB 只见 2% 流量

机制视角 — 读写都在应用手里

  • • Cache Aside: 读先查缓存, miss 回源回填; 写先更新库再删缓存
  • • 命中一次 = 省一次 DB 查询: 命中率 90%→99%, DB 负载降一个数量级
  • • 回填必须带 TTL: 缓存是"可丢弃的副本", 不是第二份持久化
  • • 多级缓存 L1 本地 + L2 Redis: 延迟 ms 级降到 ns 级, 代价是一致性窗口

行为视角 — 三兄弟一个病根

  • • 穿透: 数据不存在, 缓存永远 MISS, 恶意 id=-1 打穿 DB
  • • 击穿: 存在的热点 key 到期瞬间, 5w QPS 同时回源
  • • 雪崩: 大批 key 同时到期或 Redis 宕机, 洪峰整体后移
  • • 药方一一对应: Bloom 拦空 / singleflight 合并回源 / TTL 抖动+兜底

生产价值 — 仪表盘与底线

  • • 命中率 = keyspace_hits/(hits+misses), 必须进监控与环比告警
  • • 没有限流降级兜底的缓存架构 = DB 裸奔, 缓存一抖全站陪葬
  • • 热 key 探测 (redis-cli --hotkeys) + 本地兜底是单分片上限的解药
  • • 缓存永远可重建: 源头只有 DB, 缓存里不许有"只此一份"的数据

💡 一句话理解

把 DB 想成图书馆书库, 缓存是前台的快取架: 你先问前台有没有 (GET), 有就直接拿 (HIT, ~0.5ms); 没有就去书库借 (MISS 回源, ~50ms), 顺便复印一份放回前台 (回填 SETEX)。写的时候不往前台放新副本, 而是把旧复印件撕掉 (DEL), 下次读自然拿到新版 — 这就是 Cache Aside 的全部。

它用两个代价换十倍读性能: 一致性窗口 (删和读之间的短暂旧值) 与失效时机的地雷 — 穿透/击穿/雪崩本质都是"缓存不接盘的瞬间, 流量全部砸向 DB"。所以缓存系统的第一张仪表盘是命中率, 第一颗保险丝是 DB 前的限流降级; 三兄弟各配一味药: Bloom 拦"查无此物", singleflight 合"千手回源", TTL 抖动拆"整批同死"。

🧠 必知必会 必考 & 必会

Cache Aside 旁路缓存
应用同时直连缓存和 DB: 读时先 GET, miss 才回源并回填 (必须带 TTL); 写时先更新 DB 再 DEL 缓存。"先库后删"让不一致窗口只剩"删之后到下次回填之前"。
func Get(ctx context.Context, id int64) (string, error) {
    v, err := rdb.Get(ctx, "user:42").Result()
    if errors.Is(err, redis.Nil) {          // MISS, 全库命中约 98%
        v = loadFromDB(id)
        rdb.Set(ctx, "user:42", v, 30*time.Minute) // 关键: 回填必须带 TTL
    }
    return v, err  // → 命中 ~0.5ms; miss ~50ms 外加一次回填
}
Read/Write Through 读穿写穿
应用只跟缓存层说话, 缓存层负责读写 DB: Read Through 对应用透明回源, Write Through 同步写缓存+库。代价是缓存层要实现 DB 协议 — Caffeine 的 build(loader) 就是本地版 Read Through。
LoadingCache<String, User> c = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> userDao.findById(key)); // 关键: loader 内置, 应用只见 get()
User u = c.get("42");  // → miss 自动回源并缓存, 调用方无感
Write Back / Behind 写回
写只落缓存就返回, 缓存异步批量刷回 DB。写延迟最低、还能合并写放大, 但宕机会丢"窗口内"的数据 — 只用于可丢数据 (点赞数、浏览量), 绝不用于余额。
counts.Incr(key, 1)                 // 关键: 只写内存即返回
go flusher(100 * time.Millisecond) // 后台每 100ms 批量 INCRBY 刷回 DB
// → Redis 宕机最多丢 100ms 的点赞数, 业务可接受
Refresh Ahead 主动续期
不等 key 过期, 剩余 TTL 低于阈值时提前异步刷新。热点 key 永不过期、零击穿; 代价是刷新流量 — 只对 topN 热点开, 全量开等于"定时帮 DB 制造洪峰"。
if ttl := rdb.TTL(ctx, key).Val(); ttl < 10*time.Minute {
    go refresh(key)  // 异步刷新, 老值继续服务, 读零感知
}
// 关键: 只对热 key 续 — 冷 key 全续等于定时打 DB
Local Cache 本地缓存
进程内 map/LRU (Go: hashicorp/golang-lru; Java: Caffeine), 读取无网络开销 ~100ns。弱点: 每实例一份, 容量小、多实例间不一致 — 只放"读多改少 + 可短暂数据"。
Cache<String, User> local = Caffeine.newBuilder()
    .maximumSize(10_000).expireAfterWrite(60, TimeUnit.SECONDS)
    .build();
// 关键: maximumSize 必须设 — 无界本地缓存就是 OOM 倒计时
Distributed Cache 分布式缓存
Redis 集群做全局共享层: 容量大、各实例读同一份, 但多一跳网络 (~1ms)。Cluster 把哈希空间切成 16384 个 slot, key 按 CRC16(key) % 16384 落槽。
redis-cli -c -p 7001 SET item:42 v   # → OK
redis-cli -p 7002 GET item:42        # → MOVED <slot> 127.0.0.1:7001
# 关键: 落错节点要重定向, 客户端需带 -c 或智能路由重试
失效三兄弟 Penetration / Breakdown / Avalanche
穿透=查不存在的数据、击穿=热点 key 失效瞬间、雪崩=大面积同时失效。共同症状都是 DB QPS 突刺, 鉴别看 miss 的 key 分布。
// miss 突刺时先看 key 分布, 三种病三种药:
// 全是不存在 key (id=-1)   → Bloom Filter 前置拦截
// 单个热点 key 并发 miss   → singleflight 合并回源
// 大批 key 同秒 miss       → TTL = base + rand(0,600s)
// 关键: 病根都是失效瞬间流量直达 DB, 药方都为削峰
Bloom Filter 布隆过滤器
用 k 个哈希位判断"一定不存在或可能存在": 说没有必没有, 说有可能是误判 (可控到 1%)。放在缓存前拦掉穿透流量; 标准布隆不支持删除, 容量要按预期量提前 BF.RESERVE。
BF.RESERVE bf:user 0.01 1000000  # 错误率 1% · 100 万元素 ≈ 1.2MB
BF.ADD bf:user 10086             # → 1
BF.EXISTS bf:user 99999          # → 0 一定没见过, 直接拒, 不碰 DB
# 关键: 容量要提前给够, 超出预期容量误判率飙升
singleflight 合并回源
进程内把"同 key 的并发回源"合并成一次调用, 其余协程等待共享结果。golang.org/x/sync/singleflight 一个 Group 即可, 是击穿的进程内解药; 集群级要配 Redis 锁或请求合并层。
var g singleflight.Group
v, err, _ := g.Do(key, func() (any, error) { return loadFromDB(key) })
// 关键: 1000 个并发同 key → 只有 1 次 loadFromDB, 其余共享
TTL 与抖动 jitter
TTL 是缓存的寿命也是雪崩的引信: 相对过期 (EXPIRE/PX) 以 Redis 服务器钟为准; 同一批 key 同 TTL = 同时暴毙。写入时叠加随机抖动, 把"整批到期"摊成"细水长流"。
base := 30 * time.Minute
ttl := base + time.Duration(rand.Intn(600))*time.Second // 关键: ±10min 抖动
rdb.Set(ctx, key, v, ttl)
// → 同批 1000 个 key 的到期时刻被摊开在 30~40min 里
Cache Invalidation · 延迟双删
"先库后删"已挡住大多数不一致; 残余窗口来自"读旧值的线程慢半拍, 在删之后才回填"。延迟双删 (删 → 睡 500ms → 再删) 是无奈的补刀; 强一致诉求请直接读库或用 binlog 订阅对齐。
updateDB(u)
rdb.Del(ctx, key)
time.AfterFunc(500*time.Millisecond, func() { rdb.Del(ctx, key) })
// 关键: 第二删清掉"读旧值线程"慢悠悠回填的旧副本
命中率 Hit Rate
命中率 = keyspace_hits / (hits+misses), 是缓存系统的营业收入: 98% 意味着 DB 只扛 2% 流量; 掉到 90% 意味着 DB 压力翻 5 倍 — 所以要环比告警, 不是看看就好。
$ redis-cli info stats | grep -E 'keyspace_(hits|misses)'
keyspace_hits:9800
keyspace_misses:200   # 关键: 9800/(9800+200) = 98%, 环比掉 5% 告警
Hot Key 热 key
单个 key 的 QPS 远超均值, 在 Cluster 下集中打到单分片 → 单分片 CPU 100% 成为全局瓶颈。探测: redis-cli --hotkeys (要求 LFU 淘汰策略) 或客户端埋点; 解法: 本地兜底 + 加盐分散。
$ redis-cli --hotkeys  # 需 maxmemory-policy 为 allkeys-lfu 系
# → summary 区列出命中最多的 key, 例如 hot:rank:10086
# 关键: 热 key 本地兜底 1s, 单分片 QPS 立减一个量级

🏭 生产实战 real world

场景 1 · 商品详情 5w QPS, DB 单机上限 3000 — Cache Aside 标准读写模板

详情页读多写少, 直连 DB 必挂。标准模板: 读走缓存 miss 回源回填, 写先更库再删缓存; Redis 故障时降级读库而不是报错。

// go-redis v9: github.com/redis/go-redis/v9
func GetItem(ctx context.Context, id int64) (*Item, error) {
    key := fmt.Sprintf("item:%d", id)
    s, err := rdb.Get(ctx, key).Result()
    if err == nil {
        return decodeItem(s), nil                    // HIT ~0.5ms
    }
    if !errors.Is(err, redis.Nil) {
        log.Warn("redis down, fallback db", "err", err) // 缓存故障不把请求打死
    }
    it, err := db.GetItem(ctx, id)                    // MISS 回源 ~20ms
    if err != nil {
        return nil, err
    }
    _ = rdb.Set(ctx, key, encodeItem(it), 30*time.Minute).Err() // 关键: 回填必带 TTL
    return it, nil
}

func UpdatePrice(ctx context.Context, id, price int64) error {
    if err := db.UpdatePrice(ctx, id, price); err != nil { return err } // 先动库
    return rdb.Del(ctx, "item:"+strconv.FormatInt(id, 10)).Err()       // 再删缓存
}

上线后 DB 峰值 QPS 从 5w 压到 900, P99 从 120ms 降到 2ms。

场景 2 · 抢券开场 5w 并发怼同一个 key — singleflight 合并回源防击穿

券热点 key 到期瞬间, 5w 并发同时 miss 回源, DB 连接池秒没。进程内 singleflight 把同 key 并发合并成一次回源。

var g singleflight.Group // golang.org/x/sync/singleflight

func GetCoupon(ctx context.Context, id int64) (*Coupon, error) {
    key := fmt.Sprintf("coupon:%d", id)
    if v, err := rdb.Get(ctx, key).Result(); err == nil {
        return decodeCoupon(v), nil
    }
    v, err, _ := g.Do(key, func() (any, error) { // 关键: 同 key 并发只放 1 个进 DB
        c, err := db.GetCoupon(ctx, id)
        if err != nil {
            return nil, err
        }
        rdb.Set(ctx, key, encodeCoupon(c), 10*time.Minute)
        return c, nil
    })
    if err != nil {
        return nil, err
    }
    return v.(*Coupon), nil
}

开抢瞬间 DB 回源从 5w QPS 压到每实例 1 QPS, 全集群 < 40 QPS。

场景 3 · 爬虫拿 id=-1 疯狂试探 — RedisBloom 前置拦截穿透

攻击流量专查不存在的商品 id, 空值缓存也挡不住海量不同 id。Bloom 说"没有"就一定没有, 直接 404。

# 大促前建过滤器: 错误率 1%, 预期 500 万商品 id, 内存约 6MB
BF.RESERVE bf:item 0.01 5000000

# 商品上架/审核通过时登记 (漏登记会误杀, 上架要双写)
BF.ADD bf:item 10086             # → 1

# 读路径最前置: 说"没有"就一定没有, 直接 404
BF.EXISTS bf:item 99999999       # → 0, 不查缓存更不查 DB

接入后穿透类 DB 查询从 3w QPS 降到接近 0, 误杀率 1% 内可接受。

场景 4 · 凌晨定点 DB 报警 30 秒 — TTL 抖动把雪崩摊成细流

定时任务批量回填的 key 全部 TTL=1800s, 到期也整批同秒 — 每 30 分钟一次 DB 突刺。写入时叠加随机抖动摊开。

// 错法: 整批 Set 同一个 TTL — 同生同死, 30 分钟后一起暴毙
// 对法: base + rand 抖动, 把"整批到期"摊成 10 分钟的细流
base := 30 * time.Minute
items := loadAllHotItems()
pipe := rdb.Pipeline()
for _, it := range items {
    jitter := time.Duration(rand.Intn(600)) * time.Second // 0~10min 抖动
    pipe.Set(ctx, "item:"+it.ID, encode(it), base+jitter)
}
if _, err := pipe.Exec(ctx); err != nil {
    log.Error("warm up failed", "err", err)
}
// 关键: Pipeline 一把发, 上万 key 预热从 30s 缩到 2s, 且到期不再扎堆

场景 5 · 用户详情 P99 40ms → 3ms — Caffeine 本地 + Redis 两级缓存

Redis 一次 ~1ms, 万级 QPS 下网络与序列化都是钱。本地 Caffeine 扛 topN 热点, Redis 兜底, Spring Boot 一段配置搞定 L1。

# application.yml — L1 本地 Caffeine, L2 Redis 在代码里兜底
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=60s,refreshAfterWrite=30s
# 说明: 本地只放读多改少数据; 60s 过期 = 多实例不一致窗口上限
# 收益: 用户详情 P99 40ms → 3ms, Redis QPS 降 80%

场景 6 · 单分片 CPU 100% 而集群空闲 — 热 key 探测与本地兜底

明星官宣, 排行榜 key 被打爆; Cluster 总 QPS 不高但一个分片先死。排查靠 --hotkeys, 修法是热 key 下沉到进程内。

# 现象: 7003 分片 CPU 100% 打满, 其余分片空闲 — 典型热 key 击穿单分片
redis-cli -p 7003 info stats | grep instantaneous_ops
# → instantaneous_ops_kps:98000  (其余分片 < 8000)

# 排查: --hotkeys 需要 LFU 计数 (maxmemory-policy allkeys-lfu)
redis-cli --hotkeys
# → summary 区列出命中最多的 key: 例如 'rank:live:10086'

# 修法: 客户端本地兜底缓存 1s, 热 key 的 95% 读不再出网
# 关键: 分片上限 = 单核, 热 key 不下沉到本地, 加机器也没用

场景 7 · 大促 0 点缓存冷启动, DB 开抢即挂 — 预热脚本

不预热的话 0 点第一分钟全是 miss, DB 直接 ERROR 1040。提前 1 小时把 top 商品灌入 Redis 并登记 Bloom。

#!/bin/bash
# warmup.sh — 大促前 1 小时跑: top 50 万商品灌入 Redis + 登记 Bloom
while read -r id json; do
  ttl=$((86400 + RANDOM % 3600))  # 预热批也抖动, 别再制造集中过期
  redis-cli SET "item:$id" "$json" EX "$ttl" >/dev/null
  redis-cli BF.ADD bf:item "$id" >/dev/null
done < top_items.txt
# 收益: 开抢首分钟 DB QPS 从峰值 8w 压到 1.2w

场景 8 · 首页 banner 不能有半秒空窗 — 逻辑过期方案

物理过期瞬间必然 miss + 同步回源, 对"永不能空窗"的热点不行。逻辑过期: key 永不设物理 TTL, 值里埋 expireAt, 过期了返回旧值 + 异步刷新。

type cachedBanner struct {
    Data     json.RawMessage `json:"d"`
    ExpireAt int64           `json:"e"` // 逻辑过期时间戳(ms)
}

func GetBanner(ctx context.Context) json.RawMessage {
    s, _ := rdb.Get(ctx, "banner:home").Result() // key 不设物理 TTL
    var cb cachedBanner
    _ = json.Unmarshal([]byte(s), &cb)
    if time.Now().UnixMilli() < cb.ExpireAt {
        return cb.Data                        // 未到期: 直接用
    }
    if mu.TryLock() {                         // 只放一个协程刷新 (mu 为包级 Mutex)
        go func() {
            defer mu.Unlock()
            if d, err := loadBanner(ctx); err == nil {
                nc := cachedBanner{d, time.Now().Add(5 * time.Minute).UnixMilli()}
                rdb.Set(ctx, "banner:home", marshal(nc), 0) // 关键: 永不过期
            }
        }()
    }
    return cb.Data                            // 刷新期间返回旧值, 零空窗
}

场景 9 · 改价后偶发读到旧值, 大促前必须根治 — 延迟双删

"先库后删"仍有小概率被"慢半拍的读线程"回填旧值, 主从延迟还会让旧值多活几秒。延迟双删做补刀。

func UpdatePrice(ctx context.Context, id, price int64) error {
    key := "item:" + strconv.FormatInt(id, 10)
    if err := db.UpdatePrice(ctx, id, price); err != nil {
        return err                                // 先动库
    }
    rdb.Del(ctx, key)                             // 第一删: 立刻清
    time.AfterFunc(800*time.Millisecond, func() { // 迟来的第二删
        rdb.Del(ctx, key)
    })
    return nil
    // 关键: 800ms 覆盖"读旧值→回填"窗口, 也覆盖主从延迟大头
}

场景 10 · 一个 8MB 的大 value 把网卡打满 — 拆分与压缩

全量商品详情序列化 8MB, 一次 GET 网络传 8MB、反序列化 100ms, 命中率 99% 也白搭。拆字段按需取 + 大字段压缩。

// 拆: 8MB 全量详情 → hash 三字段, 列表页只取 base (~20KB)
rdb.HSet(ctx, "item:42", "base", baseJSON, "sku", skuJSON, "desc", descHTML)
base, _ := rdb.HGet(ctx, "item:42", "base").Result()

// 压: 大字段 gzip 后再存, 4MB desc → 600KB, 网卡不再被打满
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
zw.Write(descRaw)
zw.Close()
rdb.Set(ctx, "item:42:desc", buf.Bytes(), 30*time.Minute)
// 读回: gzip.NewReader(bytes.NewReader(b)) 解压, ~5ms CPU 换 85% 带宽

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 偶发读到几小时前的旧数据 — 症状: 更新接口返回成功, 读到的还是旧值. 原因: 先删缓存再更新库, 删后、库提交前的并发读把旧值回填, 旧值活到 TTL. 正解: 先更新库再删缓存。
// 错: rdb.Del(key); db.Update(u)
//   → 并发读在两步之间回填旧值, 旧数据活满 30min (到 TTL)
// 对: db.Update(u); rdb.Del(key)  // 先库后删, 不一致窗口毫秒级
坑 2 · 空值缓存没有 TTL, 一周后 Redis 内存 90% — 症状: used_memory 周涨, 大量 user:-1 式 key. 原因: 防穿透缓存了空值但没设过期, 垃圾 key 只进不出. 正解: 空值 TTL 30~60s + Bloom 前置。
# 错: SET user:-1 ""             # 空值永不过期
#   → 一周后 used_memory 92%, 淘汰开始误伤正常 key
# 对: SET user:-1 "" EX 60       # 短 TTL 空值 + Bloom 前置
坑 3 · 爆款整点开抢, DB 连接数瞬间打满 — 症状: 每到整点 threads_running 飙 200, 一秒后恢复. 原因: 热点 key TTL 恰好整点到期, 击穿. 正解: 热点 key 逻辑过期 + singleflight。
// 错: rdb.Set(ctx, hot, v, 距整点)      // 整点准时击穿
// 对: g.Do(key, 回源) + 逻辑过期          // 并发合并成 1 次回源
//   → DB 回源 QPS 从 50000 降到 1
坑 4 · 每天凌晨 3 点 DB 定点报警 — 症状: 报警时刻与定时刷新任务分秒不差. 原因: 同批 key 同一循环写入, TTL 全相同 → 雪崩. 正解: 写入时加随机抖动。
// 错: rdb.Set(ctx, k, v, 1800*time.Second)           // 同批同死
// 对: rdb.Set(ctx, k, v, 1800*time.Second +
//        time.Duration(rand.Intn(600))*time.Second)   // 摊开 10min
坑 5 · 刷新一次新一次旧, 负载均衡轮询的错 — 症状: 同一用户反复刷新, 新旧头像交替出现. 原因: 本地缓存多实例各自为政, 失效通知缺失. 正解: 本地 TTL 压短 + Redis pub/sub 广播失效。
// 错: 本地缓存只写不失效        // 多实例新旧并存到自然过期
// 对: 更新后 rdb.Publish(ctx, "inv", key), 各实例订阅后 local.Delete
//   → 不一致窗口从 60s 缩到毫秒级
坑 6 · 命中率 99% 接口还是 300ms — 症状: 火焰图一半在 json.Unmarshal. 原因: 8MB 大对象全量进缓存, 每次读全量反序列化+全量过网卡. 正解: hash 拆字段 + 按需取 + gzip。
// 错: rdb.Get(ctx, "item:42")          // 8MB 全量, 反序列化 100ms
//   → 命中率 99% 也救不了: 瓶颈从 DB 挪到了 CPU 和网卡
// 对: rdb.HGet(ctx, "item:42", "base") // 拆字段按需取 + gzip
坑 7 · 实例内存每天涨 2GB, pprof 全在本地缓存 — 症状: OOM 后被 K8s 反复重启. 原因: 拿裸 map 当缓存, key 无限增长无淘汰. 正解: 上带容量上限的 LRU/LFU (Caffeine / ristretto / golang-lru)。
// 错: var cache = map[string][]byte{}   // 只进不出, OOM 倒计时
//   → runtime: out of memory 后实例被 K8s 反复重启
// 对: c, _ := lru.New[string, []byte](10_000) // 容量在评审时定死
坑 8 · Cluster 8 个分片, 挂的总是同一个 — 症状: 总 QPS 不高, 单分片 CPU 100%. 原因: 热 key 哈希落定单个 slot, 分片上限=单核. 正解: 本地兜底 + 热 key 加盐分散 (key#0..N 随机读)。
# 错: 全部读 rank:live:10086          # 落单槽, 单核 10w QPS 封顶
# 对: 写 10 份 rank:live:10086#0..#9, 读随机挑一份
#   → 单分片压力 1/10; 或进程内兜底 1s 直接不出网
坑 9 · Redis 一清, 用户配置全没 — 症状: 迁移/FLUSH 后业务数据消失. 原因: 唯一一份只写进了缓存, 把缓存当存储. 正解: 缓存必须可由 DB 全量重建, 唯一事实源只有 DB。
// 错: rdb.Set(ctx, "cfg:"+uid, cfg, 0)   // 只写缓存, DB 无备份
//   → FLUSHALL 后用户配置永久丢失, 工单爆炸
// 对: db.Save(cfg); rdb.Set(ctx, k, cfg, time.Hour) // 随时可重建
坑 10 · 缓存名存实亡没人发现 — 症状: 命中率早已跌到 40%, 回源把 DB 压垮才被意识到. 原因: 命中率没进监控, TTL 被误改/大面积误删都无感. 正解: hits/(hits+misses) 环比告警。
# 错: 没有任何缓存指标        # 命中率从 98% 阴跌到 40% 无人知
# 对: 抓 keyspace_hits/misses 算比率, 环比掉 5% 就告警
#   redis-cli info stats | grep keyspace
坑 11 · 负缓存越积越多, 反过来拖垮缓存 — 症状: nx: 前缀 key 上亿条, 且几乎不再被命中. 原因: 对所有 miss 都缓存空值且 TTL 长, 攻击者每次换 id, 缓存成垃圾场. 正解: Bloom 先拦一层, 空值缓存加短 TTL。
# 错: 所有 miss 都 SET nx:<id> EX 3600  # 攻击 id 无限多 → 垃圾场
#   → 一晚上写入 2 亿条, 集群带宽打满
# 对: BF.EXISTS 先拦一层, 过滤后的 miss 才缓存空值 EX 30
坑 12 · 改了缓存结构, 新老接口互读对方的格式 — 症状: 上线后部分请求 json: cannot unmarshal 报错. 原因: key 不带版本, 新结构写进老 key, 老代码按老结构读. 正解: 结构变更时 key 带版本或字段只增不删。
// 错: key := "item:42"   // 新老结构共用同一 key
//   → json: cannot unmarshal number into Go struct field .price
// 对: key := "item:v2:42" // 版本进 key, 灰度期新老互不踩
坑 13 · 滚动发布中老实例批量反序列化崩溃 — 症状: 新实例正常, 老实例疯狂报错. 原因: 缓存里存着旧协议字节, 新代码换协议 (或改序列化字段) 后不兼容. 正解: 跨版本只用向后兼容协议, 或新 key 双写过渡。
# 错: 升级把 JSON 换成 gob, Redis 里还是旧字节
#   → 滚动发布期老实例 Unmarshal 全报错, 新实例正常
# 对: 换协议 = 新 key 双写过渡; 旧 key 的字节旧代码还能解
坑 14 · 一把 keys *, 全站超时 10 秒 — 症状: 扫键期间 Redis 所有命令排队, P99 飙到 10s. 原因: KEYS O(N) 全库扫描, 单线程模型阻塞一切. 正解: SCAN 游标增量遍历。
# 错: KEYS item:*    # 5000 万 key 全库扫描, 单线程被卡死
#   → 期间所有命令 P99 > 10s, 全站超时
# 对: SCAN 0 MATCH item:* COUNT 500  # 游标分批, 每批微秒级
坑 15 · 删一个 2GB 的 hash, Redis 卡 1 秒 — 症状: 每次清 big key, 全局 P99 尖刺. 原因: DEL 大 key 同步释放内存, 阻塞主线程. 正解: UNLINK 异步删或分批删, 开 lazyfree 系配置。
# 错: DEL big:hash      # 200 万 field 同步释放, 主线程卡 ~1s
#   → 期间 P99 尖刺, 下游批量超时
# 对: UNLINK big:hash + lazyfree-lazy-user-del yes  # 异步释放
坑 16 · 双写顺序错, 旧值长期覆盖新值 — 症状: 缓存里偶尔是"两步前的旧值"且长期不过期. 原因: 跨服务"写库+写缓存"无全局序, 写缓存晚于后续写库. 正解: 写路径只 Del 不 Set, 回填交给读路径; 或 binlog 订阅单点回写。
# 错: 双写: update DB 后由另一服务 SET cache
#   → 两步无全局序, 旧值后写覆盖新值, 且长存到 TTL
# 对: 写路径只 Del 缓存, 回填交给读路径 — 回填源永远是最新 DB
坑 17 · 开了 refresh ahead, DB 压力不降反升 — 症状: 凌晨 DB 回源 QPS 翻倍, 刷的 key 根本没人读. 原因: 全量 key 提前续期, 冷 key 占 95%, 白造回源流量. 正解: 只对"近期有访问"的热 key 续期。
// 错: 定时全量续期 100w key     // 冷 key 占 95%, 白白回源
//   → 凌晨 DB 回源 QPS ×2, 全是没人读的 key
// 对: 只续"近 5min 有访问"的热 key, 刷新量 -95%
坑 18 · Redis 抖 30 秒, DB 3 秒内被全量流量打挂 — 症状: 缓存故障必然升级成 DB 故障. 原因: Redis 不可用时直连 DB 无任何保护, 平时 2% 回源变 100%. 正解: 熔断 + 本地缓存兜底 + DB 前限流。
// 错: redis 报错就 fallback 到 db.Query  // 2% 回源变 100%
//   → ERROR 1040 (HY000): Too many connections
// 对: 熔断 + 本地缓存兜底 + DB 前限流, 宁可降级不可雪崩
坑 19 · 每次发布后 30 分钟必有一次 DB 突刺 — 症状: 发布完成即埋雷, 曲线分秒不差. 原因: TTL 是写死的秒级常量, 发布后整批重建的 key 同刻过期. 正解: TTL 常量上叠加随机抖动, 预热分批限速。
// 错: ttl := 1800 * time.Second              // 写死, 全批同刻过期
//   → 每次发布后第 30min, DB 定点突刺
// 对: ttl += time.Duration(rand.Intn(600)) * time.Second // 摊开
坑 20 · 把 Cache Aside 说成 Read Through, 架构造假 — 症状: 设计文档写"Read Through", 代码里每个调用方自己 if miss 回源. 原因: 语义混淆 — Through 是缓存层代理 (应用只 get), Aside 是应用自己两边跑. 正解: 分清四种语义再画架构图。
# 错: 文档写 "Read Through", 代码里 20 处调用方各自 miss → db → set
#   → 回源逻辑散落 20 处, TTL 三个版本, 谁也不敢动
# 对: Aside 就明说 Aside; 真要 Through 就收敛进统一的缓存层封装