用一致性和复杂度换十倍读性能: 命中率是收入, 失效时机是地雷 — 穿透/击穿/雪崩都是"缓存不接盘的瞬间, 流量全部砸向 DB"
把 DB 想成图书馆书库, 缓存是前台的快取架: 你先问前台有没有 (GET), 有就直接拿 (HIT, ~0.5ms); 没有就去书库借 (MISS 回源, ~50ms), 顺便复印一份放回前台 (回填 SETEX)。写的时候不往前台放新副本, 而是把旧复印件撕掉 (DEL), 下次读自然拿到新版 — 这就是 Cache Aside 的全部。
它用两个代价换十倍读性能: 一致性窗口 (删和读之间的短暂旧值) 与失效时机的地雷 — 穿透/击穿/雪崩本质都是"缓存不接盘的瞬间, 流量全部砸向 DB"。所以缓存系统的第一张仪表盘是命中率, 第一颗保险丝是 DB 前的限流降级; 三兄弟各配一味药: Bloom 拦"查无此物", singleflight 合"千手回源", TTL 抖动拆"整批同死"。
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 外加一次回填 }
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 自动回源并缓存, 调用方无感counts.Incr(key, 1) // 关键: 只写内存即返回 go flusher(100 * time.Millisecond) // 后台每 100ms 批量 INCRBY 刷回 DB // → Redis 宕机最多丢 100ms 的点赞数, 业务可接受
if ttl := rdb.TTL(ctx, key).Val(); ttl < 10*time.Minute { go refresh(key) // 异步刷新, 老值继续服务, 读零感知 } // 关键: 只对热 key 续 — 冷 key 全续等于定时打 DB
hashicorp/golang-lru; Java: Caffeine), 读取无网络开销 ~100ns。弱点: 每实例一份, 容量小、多实例间不一致 — 只放"读多改少 + 可短暂数据"。 Cache<String, User> local = Caffeine.newBuilder()
.maximumSize(10_000).expireAfterWrite(60, TimeUnit.SECONDS)
.build();
// 关键: maximumSize 必须设 — 无界本地缓存就是 OOM 倒计时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 或智能路由重试
// miss 突刺时先看 key 分布, 三种病三种药: // 全是不存在 key (id=-1) → Bloom Filter 前置拦截 // 单个热点 key 并发 miss → singleflight 合并回源 // 大批 key 同秒 miss → TTL = base + rand(0,600s) // 关键: 病根都是失效瞬间流量直达 DB, 药方都为削峰
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 # 关键: 容量要提前给够, 超出预期容量误判率飙升
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, 其余共享
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 里
updateDB(u) rdb.Del(ctx, key) time.AfterFunc(500*time.Millisecond, func() { rdb.Del(ctx, key) }) // 关键: 第二删清掉"读旧值线程"慢悠悠回填的旧副本
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% 告警
redis-cli --hotkeys (要求 LFU 淘汰策略) 或客户端埋点; 解法: 本地兜底 + 加盐分散。 $ redis-cli --hotkeys # 需 maxmemory-policy 为 allkeys-lfu 系 # → summary 区列出命中最多的 key, 例如 hot:rank:10086 # 关键: 热 key 本地兜底 1s, 单分片 QPS 立减一个量级
详情页读多写少, 直连 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。
券热点 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。
攻击流量专查不存在的商品 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% 内可接受。
定时任务批量回填的 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, 且到期不再扎堆
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%
明星官宣, 排行榜 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 不下沉到本地, 加机器也没用
不预热的话 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
物理过期瞬间必然 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 // 刷新期间返回旧值, 零空窗 }
"先库后删"仍有小概率被"慢半拍的读线程"回填旧值, 主从延迟还会让旧值多活几秒。延迟双删做补刀。
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 覆盖"读旧值→回填"窗口, 也覆盖主从延迟大头 }
全量商品详情序列化 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% 带宽
// 错: rdb.Del(key); db.Update(u) // → 并发读在两步之间回填旧值, 旧数据活满 30min (到 TTL) // 对: db.Update(u); rdb.Del(key) // 先库后删, 不一致窗口毫秒级
used_memory 周涨, 大量 user:-1 式 key. 原因: 防穿透缓存了空值但没设过期, 垃圾 key 只进不出. 正解: 空值 TTL 30~60s + Bloom 前置。 # 错: SET user:-1 "" # 空值永不过期 # → 一周后 used_memory 92%, 淘汰开始误伤正常 key # 对: SET user:-1 "" EX 60 # 短 TTL 空值 + Bloom 前置
threads_running 飙 200, 一秒后恢复. 原因: 热点 key TTL 恰好整点到期, 击穿. 正解: 热点 key 逻辑过期 + singleflight。 // 错: rdb.Set(ctx, hot, v, 距整点) // 整点准时击穿 // 对: g.Do(key, 回源) + 逻辑过期 // 并发合并成 1 次回源 // → DB 回源 QPS 从 50000 降到 1
// 错: rdb.Set(ctx, k, v, 1800*time.Second) // 同批同死 // 对: rdb.Set(ctx, k, v, 1800*time.Second + // time.Duration(rand.Intn(600))*time.Second) // 摊开 10min
// 错: 本地缓存只写不失效 // 多实例新旧并存到自然过期 // 对: 更新后 rdb.Publish(ctx, "inv", key), 各实例订阅后 local.Delete // → 不一致窗口从 60s 缩到毫秒级
json.Unmarshal. 原因: 8MB 大对象全量进缓存, 每次读全量反序列化+全量过网卡. 正解: hash 拆字段 + 按需取 + gzip。 // 错: rdb.Get(ctx, "item:42") // 8MB 全量, 反序列化 100ms // → 命中率 99% 也救不了: 瓶颈从 DB 挪到了 CPU 和网卡 // 对: rdb.HGet(ctx, "item:42", "base") // 拆字段按需取 + gzip
// 错: var cache = map[string][]byte{} // 只进不出, OOM 倒计时 // → runtime: out of memory 后实例被 K8s 反复重启 // 对: c, _ := lru.New[string, []byte](10_000) // 容量在评审时定死
key#0..N 随机读)。 # 错: 全部读 rank:live:10086 # 落单槽, 单核 10w QPS 封顶 # 对: 写 10 份 rank:live:10086#0..#9, 读随机挑一份 # → 单分片压力 1/10; 或进程内兜底 1s 直接不出网
// 错: rdb.Set(ctx, "cfg:"+uid, cfg, 0) // 只写缓存, DB 无备份 // → FLUSHALL 后用户配置永久丢失, 工单爆炸 // 对: db.Save(cfg); rdb.Set(ctx, k, cfg, time.Hour) // 随时可重建
hits/(hits+misses) 环比告警。 # 错: 没有任何缓存指标 # 命中率从 98% 阴跌到 40% 无人知 # 对: 抓 keyspace_hits/misses 算比率, 环比掉 5% 就告警 # redis-cli info stats | grep keyspace
nx: 前缀 key 上亿条, 且几乎不再被命中. 原因: 对所有 miss 都缓存空值且 TTL 长, 攻击者每次换 id, 缓存成垃圾场. 正解: Bloom 先拦一层, 空值缓存加短 TTL。 # 错: 所有 miss 都 SET nx:<id> EX 3600 # 攻击 id 无限多 → 垃圾场 # → 一晚上写入 2 亿条, 集群带宽打满 # 对: BF.EXISTS 先拦一层, 过滤后的 miss 才缓存空值 EX 30
json: cannot unmarshal 报错. 原因: key 不带版本, 新结构写进老 key, 老代码按老结构读. 正解: 结构变更时 key 带版本或字段只增不删。 // 错: key := "item:42" // 新老结构共用同一 key // → json: cannot unmarshal number into Go struct field .price // 对: key := "item:v2:42" // 版本进 key, 灰度期新老互不踩
# 错: 升级把 JSON 换成 gob, Redis 里还是旧字节 # → 滚动发布期老实例 Unmarshal 全报错, 新实例正常 # 对: 换协议 = 新 key 双写过渡; 旧 key 的字节旧代码还能解
keys *, 全站超时 10 秒 — 症状: 扫键期间 Redis 所有命令排队, P99 飙到 10s. 原因: KEYS O(N) 全库扫描, 单线程模型阻塞一切. 正解: SCAN 游标增量遍历。 # 错: KEYS item:* # 5000 万 key 全库扫描, 单线程被卡死 # → 期间所有命令 P99 > 10s, 全站超时 # 对: SCAN 0 MATCH item:* COUNT 500 # 游标分批, 每批微秒级
DEL 大 key 同步释放内存, 阻塞主线程. 正解: UNLINK 异步删或分批删, 开 lazyfree 系配置。 # 错: DEL big:hash # 200 万 field 同步释放, 主线程卡 ~1s # → 期间 P99 尖刺, 下游批量超时 # 对: UNLINK big:hash + lazyfree-lazy-user-del yes # 异步释放
Del 不 Set, 回填交给读路径; 或 binlog 订阅单点回写。 # 错: 双写: update DB 后由另一服务 SET cache # → 两步无全局序, 旧值后写覆盖新值, 且长存到 TTL # 对: 写路径只 Del 缓存, 回填交给读路径 — 回填源永远是最新 DB
// 错: 定时全量续期 100w key // 冷 key 占 95%, 白白回源 // → 凌晨 DB 回源 QPS ×2, 全是没人读的 key // 对: 只续"近 5min 有访问"的热 key, 刷新量 -95%
// 错: redis 报错就 fallback 到 db.Query // 2% 回源变 100% // → ERROR 1040 (HY000): Too many connections // 对: 熔断 + 本地缓存兜底 + DB 前限流, 宁可降级不可雪崩
// 错: ttl := 1800 * time.Second // 写死, 全批同刻过期 // → 每次发布后第 30min, DB 定点突刺 // 对: ttl += time.Duration(rand.Intn(600)) * time.Second // 摊开
# 错: 文档写 "Read Through", 代码里 20 处调用方各自 miss → db → set # → 回源逻辑散落 20 处, TTL 三个版本, 谁也不敢动 # 对: Aside 就明说 Aside; 真要 Through 就收敛进统一的缓存层封装