缓存设计:高性能、高并发、可用性与预热

2022-02-11T10:00:00+08:00 | 34分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@
缓存设计高性能高并发可用性内存访问削峰填谷五层防护挡住 DB 压力Redis vs Memcached超时→重试→熔断→降级

学习目标

学完本章你应该能够:

  1. 用自己的话讲清"缓存为什么快"——从内存与磁盘的延迟量级、DB 压力、就近访问三个角度各说一段。
  2. 讲清缓存承接读流量、削峰填谷的机制,并估算加缓存后系统读能力提升了多少倍。
  3. 说出 Redis 与 Memcached 在线程模型、数据类型、持久化、集群、内存管理上的差异,以及各自还适合什么场景。
  4. 设计一套"缓存不可用也不打穿数据库"的读路径:超时 + 重试上限 + 熔断 + 降级 + 本地兜底。
  5. 独立设计一次缓存预热方案(上线前批量加载 / 定时预热 / 灰度放量),并说清双写一致性的取舍。

前置知识:Redis 基本命令与数据类型、缓存三大问题(穿透 / 击穿 / 雪崩)与淘汰策略(回看 09-Redis全系列.md )、MySQL 读写与事务基础(10-MySQL全系列.md )、Go 的 contextsync 基础。

本章你会动手做的事

  1. 写一个带 singleflight 的 Cache-Aside 读函数,压测同一个冷 key 并发 1000 请求时到底穿透了几次 DB。
  2. 给读缓存加上超时 + 熔断 + 本地兜底三层保护,然后故意 kill 掉 Redis,观察接口是"整体 500"还是"降级返回旧数据"。
  3. 写一个分批限速的预热脚本,把 Top 1 万热点数据灌进 Redis,记录预热前后首屏 P99 延迟的变化。

一、缓存如何实现高性能

1.1 用生活类比先建立直觉

类比:你在图书馆写论文。每次要查一个数据,都得跑到地下三层的书库,管理员翻箱倒柜找出那本书,来回二十分钟——这是查数据库。聪明的做法是:把这两周反复要看的十几本书借出来,摊在自己桌面上。伸手就能拿到,一秒钟的事。

桌面就是缓存:容量小、离你近、拿得快。书库是数据库:容量大、离你远、拿得慢

对应到工程里就是:缓存的高性能来自三件事——数据放在内存里(快)、请求不再打到数据库(DB 不再是瓶颈)、缓存节点部署得离用户更近(少走网络)。

下面这张图展示了一次读请求在多级存储上的路径,越靠上越快、越靠下越慢。

flowchart TB
    U[用户请求] --> L1["进程内本地缓存
纳秒~微秒级"] L1 -- 未命中 --> L2["Redis 分布式缓存
0.5~2 毫秒"] L2 -- 未命中 --> DB["MySQL 数据库
5~50 毫秒,还要占连接"] DB -- 回写 --> L2 L2 -- 回填 --> L1

1.2 工程要点

第一根支柱:内存访问比磁盘快几个数量级

面试时能报出量级,比说"内存比磁盘快"有说服力得多:

操作典型耗时相对倍数
CPU L1 缓存读取~1 ns1x
内存随机读取~100 ns100x
SSD 随机读取~100 μs10 万 x
机械磁盘随机寻道~10 ms1000 万 x
同机房网络往返~0.5 ms50 万 x

关键结论:Redis 的耗时几乎全花在网络往返上,不在数据查找上。一次本地 Redis 查询 0.51ms,而 MySQL 一条走索引的查询即便命中 Buffer Pool 也要 15ms,一旦回表读磁盘、或者遇上锁等待,就是几十毫秒。数据量越大、查询越复杂(多表 JOIN、聚合统计),差距越夸张——一个要跑 800ms 的报表聚合,缓存起来就是 1ms。

第二根支柱:把 DB 的压力挡在外面

数据库慢,不只是因为磁盘,更因为它:每个连接要占内存、要走 SQL 解析和优化器、要维护事务和锁。MySQL 单实例常见的连接池上限是几百,QPS 打到几千就开始排队,而 Redis 单实例轻松 10 万 QPS。

所以缓存的第二层价值是"保护":命中率 95% 意味着数据库只需要面对 5% 的流量。原本 10 万 QPS 直接压垮 DB,加了缓存只有 5000 QPS 到 DB,正好在它的舒适区。

⚠️ 命中率的杠杆是非线性的。命中率从 99% 掉到 95%,落到 DB 的流量不是"多了 4%",而是从 1% 变成 5%,翻了 5 倍。所以线上缓存命中率的告警阈值一定要设,掉几个点就是 DB 的灾难。

第三根支柱:就近访问,省掉网络距离

同城机房往返 0.5ms,跨地域(比如上海到深圳)就是 30ms 起,跨国 200ms+。所以"高性能"的另一半是把数据搬到离用户更近的地方:

flowchart LR
    User[用户浏览器] --> CDN["CDN 边缘节点
静态资源/页面片段"] CDN --> LB[接入层网关] LB --> App["应用进程
本地缓存 local cache"] App --> Redis["同机房 Redis 集群"] Redis --> DB["MySQL 主库/从库"]

这就是多级缓存架构:浏览器缓存 → CDN → 网关缓存 → 进程内本地缓存 → 分布式缓存 → 数据库。每一级都拦掉一部分流量,越靠前拦下的越"便宜"。

一段最基础的 Cache-Aside 读代码

package cache

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"math/rand"
	"time"

	"github.com/redis/go-redis/v9"
)

type User struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

type UserRepo struct {
	rdb *redis.Client
	db  *sql.DB
}

// GetUser 经典 Cache-Aside(旁路缓存)读路径:先查缓存,未命中查库再回写。
func (r *UserRepo) GetUser(ctx context.Context, id int64) (*User, error) {
	key := fmt.Sprintf("user:%d", id)

	// 步骤 1:先读缓存。命中就直接返回,这是 95% 以上请求走的路径。
	val, err := r.rdb.Get(ctx, key).Bytes()
	if err == nil {
		var u User
		if json.Unmarshal(val, &u) == nil {
			return &u, nil
		}
		// 反序列化失败说明缓存里是脏数据,当作未命中继续往下走。
	} else if !errors.Is(err, redis.Nil) {
		// 步骤 2:注意区分"没这个 key"和"Redis 出错了"。
		// redis.Nil 是正常的未命中;其它错误说明缓存不可用,
		// 这里不能直接返回错误,要降级去查库(详见第五章)。
		return r.loadFromDB(ctx, id)
	}

	// 步骤 3:未命中,查数据库。
	u, err := r.loadFromDB(ctx, id)
	if err != nil {
		return nil, err
	}

	// 步骤 4:回写缓存。TTL 一定要加随机抖动,避免同一批 key 同时到期。
	buf, _ := json.Marshal(u)
	ttl := 30*time.Minute + time.Duration(rand.Intn(300))*time.Second
	// 用 Set 而不是 SetNX:这里就是要覆盖旧值。
	// 忽略写缓存的错误——写不进去只是性能退化,不该让业务请求失败。
	_ = r.rdb.Set(ctx, key, buf, ttl).Err()

	return u, nil
}

func (r *UserRepo) loadFromDB(ctx context.Context, id int64) (*User, error) {
	var u User
	err := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = ?", id).
		Scan(&u.ID, &u.Name)
	if err != nil {
		return nil, err
	}
	return &u, nil
}

⚠️ 新手必踩的坑:把 redis.Nil 当成错误往上抛redis.Nil 只是"key 不存在",是业务上完全正常的情况。写成 if err != nil { return nil, err } 会导致所有冷启动请求全部报错。必须用 errors.Is(err, redis.Nil) 单独判断。

本章考点总结:缓存高性能 = 内存访问(比磁盘快 10 万倍以上)+ 挡住 DB 压力(DB 连接和 SQL 解析很贵)+ 就近访问(多级缓存省网络往返)。面试时一定要报出延迟量级,并强调"命中率下降对 DB 的压力是非线性放大的"。


二、缓存如何实现高并发

2.1 用生活类比先建立直觉

类比:演唱会散场,五万人同时要走出体育馆。只有一个出口(数据库),大家就得排队两小时;在门口摆二十个自助闸机(缓存),绝大多数人刷一下就走了,只有丢票的少数人才去人工窗口。

再想暴雨:雨水一次性冲进下水道会淹街,所以城市要修蓄水池——水先进池子,再匀速放出去。这就是削峰。

对应到工程里就是:缓存的高并发能力来自两点——用廉价的内存副本承接绝大部分读流量,以及用队列/合并/限流把突发写和突发回源削平再交给数据库。

2.2 工程要点

承接读流量:把 DB 的能力"复制"很多份

单机能力的量级差异是高并发的物理基础:

组件单实例 QPS 量级扩容方式
MySQL千级(复杂查询几百)分库分表,成本高、改造大
Redis 单实例8 万~12 万Cluster 分片,加节点即可
进程内本地缓存百万级(无网络开销)随应用实例天然水平扩展

所以扛并发的标准套路是读扩散

flowchart TB
    In["10 万 QPS 读请求"] --> Local["本地缓存
拦住 70%"] Local -- "剩 3 万" --> RC["Redis 集群
分片 + 多副本,拦住 95%"] RC -- "剩 1500" --> DB["MySQL
轻松承受"] Local -.->|"命中率掉了会怎样"| Warn["命中率 -5% = DB 流量翻数倍
必须监控告警"]

关键手段有四个:

  1. 多级缓存:本地缓存挡住最热的那一小撮 key(比如首页配置、热门商品)。
  2. 集群分片:Redis Cluster 按 slot 把 key 打散到多个节点,容量和 QPS 都线性扩展。
  3. 读写分离 + 多副本:热点读可以打到多个从节点,客户端做负载均衡。
  4. 热点 key 打散:把 hot:item:1 复制成 hot:item:1#0 ~ hot:item:1#9,随机读一个,避免单个 slot 被打爆(详见 09-Redis全系列.md 的热点 Key 处理)。

削峰:把尖刺磨平

削峰针对的是瞬时洪峰,典型场景是秒杀、开抢、大促首屏。三种常用做法:

  1. 请求合并(singleflight):同一时刻 1000 个请求要同一个失效 key,只放 1 个去查库,其余等结果。
  2. 异步写 / 消息队列:写请求先落 Redis 或 Kafka,返回"已受理",后台匀速消费落库。库存扣减就是这么做的:Redis 原子扣减,DB 异步对账。
  3. 限流 + 队列缓冲:入口令牌桶限流,超出直接返回"活动太火爆",绝不让洪峰接触数据库(限流详见 14-微服务CICD与限流.md )。
sequenceDiagram
    participant C as 大量客户端
    participant A as 应用层
    participant R as Redis
    participant Q as 消息队列
    participant D as MySQL
    C->>A: 瞬时 5 万写请求
    A->>R: 原子扣减库存 DECR
    R-->>A: 剩余库存
    A->>Q: 投递订单消息
    A-->>C: 返回"已受理"
    Q->>D: 消费者匀速落库 1000 TPS

用 singleflight 合并回源请求

package cache

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

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

type HotRepo struct {
	*UserRepo
	sf singleflight.Group // 步骤 1:同一个 key 的并发回源会被合并成一次
}

func (r *HotRepo) GetUserSF(ctx context.Context, id int64) (*User, error) {
	key := fmt.Sprintf("user:%d", id)

	// 步骤 2:缓存命中的快路径不进 singleflight,避免无谓的锁竞争。
	if val, err := r.rdb.Get(ctx, key).Bytes(); err == nil {
		var u User
		if json.Unmarshal(val, &u) == nil {
			return &u, nil
		}
	}

	// 步骤 3:未命中才进入合并。1000 个并发只有 1 个真正查库,
	// 其余 999 个阻塞等待同一个结果——这就是"击穿"的标准解法之一。
	v, err, _ := r.sf.Do(key, func() (any, error) {
		u, err := r.loadFromDB(ctx, id)
		if err != nil {
			return nil, err
		}
		buf, _ := json.Marshal(u)
		_ = r.rdb.Set(ctx, key, buf, 30*time.Minute).Err()
		return u, nil
	})
	if err != nil {
		return nil, err
	}
	return v.(*User), nil
}

⚠️ singleflight 的两个坑。一是错误会被共享:领头请求失败,所有等待者拿到同一个错误,建议对失败结果调用 Forget(key) 立即清掉,避免后续请求继续复用失败态。二是它只在单进程内生效:100 个 Pod 就还有 100 次回源,真正的热点还得叠加分布式锁或逻辑过期方案。

本章考点总结:高并发靠"读流量被便宜的副本层层拦截"(多级缓存 + 集群分片 + 热点打散)加"洪峰被削平后再交给 DB"(singleflight 合并、异步落库、入口限流)。面试时最好带上"10 万 → 3 万 → 1500"这样的漏斗数字。


三、Redis 和 Memcached 的区别

3.1 用生活类比先建立直觉

类比:Memcached 像一个只收行李的寄存柜——给你一个号码牌,塞进去一个包裹,取的时候整包拿走,柜子从不关心里面装的是什么,也不会帮你整理。Redis 像一个带分类货架的仓库——有专门放列表的架子、放集合的架子、放排行榜的架子,还能帮你在货架上直接做"排序"“求交集”,仓库还每天做盘点备份、有备用仓库随时接班。

对应到工程里就是:Memcached 只做"KV 内存缓存"这一件事,做得简单极致;Redis 是"内存数据结构服务器",功能面宽得多,但也更重。

3.2 工程要点

flowchart TB
    subgraph MC["Memcached:多线程模型"]
        M1[线程 1] --> MH["共享哈希表
需要加锁"] M2[线程 2] --> MH M3[线程 N] --> MH end subgraph RD["Redis:单线程命令执行"] R1[IO 线程读写 socket] --> RE["单线程事件循环
命令串行执行,天然原子"] RE --> RM["内存数据结构
String/List/Hash/Set/ZSet"] end

逐项对比

维度RedisMemcached
数据类型String / List / Hash / Set / ZSet / Bitmap / HyperLogLog / GEO / Stream只有 String(KV 字节块)
线程模型命令执行单线程(6.0 起 IO 多线程),无锁、天然原子纯多线程,靠锁保护哈希表,能吃满多核
持久化支持 RDB + AOF + 混合持久化,可做数据恢复完全不支持,重启数据全丢
高可用主从复制 + Sentinel 自动故障转移 + Cluster原生无复制、无故障转移,挂了就是空节点
集群方案官方 Redis Cluster(16384 slot,服务端分片)只有客户端一致性哈希分片
内存管理jemalloc 分配,可能有内存碎片Slab Allocation 预分配,碎片少但有空间浪费
过期与淘汰惰性删除 + 定期删除,8 种淘汰策略惰性过期 + LRU
单 value 上限512MB默认 1MB(可调)
事务 / 脚本MULTI/EXEC、Lua 脚本不支持
其它能力Pub/Sub、Stream、分布式锁、地理位置、限流计数

面试怎么答才不像背表

三句话就够,然后等追问:

“本质区别是定位:Memcached 是纯 KV 内存缓存,Redis 是内存数据结构服务器。所以 Redis 多出了丰富数据类型、持久化、主从/Sentinel/Cluster 高可用、Lua 和事务,代价是更重、单线程执行大 key 命令会阻塞。Memcached 的优势只剩两条——多线程能吃满多核,Slab 分配对纯大量小 KV 场景内存碎片更少。所以新项目基本都选 Redis,Memcached 多见于老系统里存 session 或页面片段。”

追问常见的两个:

  • 为什么 Redis 单线程还这么快? 纯内存操作 + IO 多路复用 + 无锁无上下文切换 + 高效数据结构。瓶颈在网络和内存带宽,不在 CPU(详见 09-Redis全系列.md 的单线程模型)。
  • Memcached 多线程不是更快吗? 单机极限 QPS 相近(都被网卡限制),但 Memcached 要加锁、还得客户端自己做分片和容错。多核优势在缓存这种"IO 密集"场景收益有限。

本章考点总结:一句话抓核心——Memcached 是纯 KV,Redis 是数据结构服务器(多类型 + 持久化 + 高可用 + Lua);Redis 命令执行单线程所以原子,Memcached 多线程所以要加锁。


四、用缓存可能出现的问题

4.1 用生活类比先建立直觉

类比:你在桌面上摊了一堆借来的书(缓存),麻烦随之而来:

  • 有人一直找一本图书馆根本没有的书,你每次都得跑一趟书库确认"真没有"——缓存穿透
  • 你借的书恰好同一天全部到期,一起被收回,第二天所有查询都涌向书库——缓存雪崩
  • 大家都在抢看的那本畅销书刚好被收回了,几百人同时冲向书库——缓存击穿
  • 书库里的书出了修订版,你桌上还是旧版,讲错了内容——缓存与数据库不一致

对应到工程里就是:前三个是流量打穿问题,最后一个是数据正确性问题,后者才是缓存设计里最难、也最容易被追问到底的部分。

flowchart TB
    P["缓存引入的问题"] --> A["流量类问题"]
    P --> B["数据一致性问题"]
    A --> A1["穿透:查不存在的数据"]
    A --> A2["雪崩:大批 key 同时失效或缓存宕机"]
    A --> A3["击穿:单个热点 key 失效"]
    B --> B1["双写不一致:先写谁、失败怎么办"]
    B --> B2["并发读写导致脏数据回写"]
    B --> B3["主从延迟读到旧值"]

4.2 流量三兄弟:一句话带过

穿透、雪崩、击穿的成因与完整解法(布隆过滤器、空值缓存、TTL 抖动、互斥重建、逻辑过期、多级缓存兜底等)已在 详见 09-Redis全系列.md 的"缓存三大问题"章节展开,本章只做定位对照,不再重复:

问题一句话成因一句话对策
穿透查的数据缓存和 DB 都没有布隆过滤器 + 空值缓存(详见 09
雪崩大批 key 同时失效 / 缓存集群宕机TTL 加随机抖动 + 高可用 + 限流降级(详见 09
击穿单个超热点 key 到期互斥重建 + 逻辑过期永不删(详见 09

4.3 重点展开:一致性与双写

这是本章要真正讲透的部分。核心矛盾:缓存和数据库是两个系统,没有分布式事务的话,两次写操作之间永远存在窗口期

四种更新顺序,各错在哪

方案做法问题
先更新缓存,再更新 DB双写缓存成功 DB 失败 → 脏数据被读到,且 DB 是最终事实源,缓存反而先"撒谎"
先更新 DB,再更新缓存双写并发两个写请求的更新顺序可能颠倒,缓存留下旧值;且计算型缓存做了无用功
先删缓存,再更新 DBCache Aside 变体删完还没写库时,读请求把旧值加载回缓存,脏数据长期存在
先更新 DB,再删缓存标准 Cache Aside只有极小概率不一致(读请求恰好在删除前回写),业界默认选择

为什么"更新 DB 后删缓存"是主流?因为它出问题需要一个非常苛刻的时序:读请求未命中 → 查到旧值 → 此时写请求完成更新并删除缓存 → 读请求才把旧值写回。而"读 DB + 回写缓存"通常比"写 DB"快得多,这个交错概率很低。

下面这张时序图画的就是那个低概率的坏情况——理解它,才知道延迟双删在补什么。

sequenceDiagram
    participant R as 读请求
    participant W as 写请求
    participant C as Redis
    participant D as MySQL
    R->>C: GET key(未命中)
    R->>D: SELECT 得到旧值 v1
    W->>D: UPDATE 为 v2
    W->>C: DEL key
    R->>C: SET key = v1(旧值被写回!)
    Note over C: 缓存中残留 v1,直到 TTL 到期

三种加固手段

  1. 延迟双删:更新 DB 后立刻删一次,再延迟几百毫秒异步删第二次,覆盖上面那个交错窗口。
  2. 给缓存加 TTL 兜底:无论如何都要设过期时间。这是"最终一致"的最后保险——就算所有删除都失败了,最多脏 TTL 那么久。
  3. 订阅 binlog 异步删除(推荐的生产方案):用 Canal / Debezium 订阅 MySQL binlog,由消费者统一删缓存。好处是业务代码不用管缓存、删除失败可以重试、天然覆盖"别人直接改库"的情况。
flowchart LR
    App[业务服务] -->|"只写 DB"| DB[(MySQL)]
    DB -->|binlog| Canal[Canal / Debezium]
    Canal --> MQ[消息队列]
    MQ --> Worker[缓存清理消费者]
    Worker -->|"DEL key,失败重试"| Redis[(Redis)]

代码:更新 DB + 删缓存 + 延迟双删

package cache

import (
	"context"
	"fmt"
	"time"
)

// UpdateUserName 标准 Cache Aside 写路径:先改库,再删缓存。
func (r *UserRepo) UpdateUserName(ctx context.Context, id int64, name string) error {
	key := fmt.Sprintf("user:%d", id)

	// 步骤 1:先写数据库。DB 是唯一的事实源,写失败就直接返回,缓存不用动。
	if _, err := r.db.ExecContext(ctx,
		"UPDATE users SET name = ? WHERE id = ?", name, id); err != nil {
		return err
	}

	// 步骤 2:删缓存(不是更新缓存)。删除是幂等的,
	// 并发写也不会像"更新缓存"那样出现顺序颠倒。
	if err := r.rdb.Del(ctx, key).Err(); err != nil {
		// 删除失败不能忽略:要么投消息队列重试,要么记录告警。
		// 兜底靠的是 key 本身设置了 TTL。
		r.enqueueRetryDelete(key)
	}

	// 步骤 3:延迟双删,覆盖"读请求把旧值写回"的交错窗口。
	// 注意用新的 context —— 请求的 ctx 在函数返回后就被取消了。
	go func() {
		time.Sleep(500 * time.Millisecond)
		bgCtx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		if err := r.rdb.Del(bgCtx, key).Err(); err != nil {
			r.enqueueRetryDelete(key)
		}
	}()

	return nil
}

func (r *UserRepo) enqueueRetryDelete(key string) {
	// 生产环境这里投 MQ,由消费者重试删除,避免进程重启后丢失重试任务。
}

⚠️ 新手必踩的坑:在 goroutine 里继续用请求的 ctx。HTTP handler 返回后 ctx 立即被 cancel,延迟删除会直接报 context canceled,等于没删。异步任务必须用 context.Background() 重新派生。

⚠️ 第二个坑:go func + Sleep 不抗重启。进程被 kill 掉,延迟删除就丢了。真正要一致,用延迟消息队列而不是裸 goroutine。

需要强一致怎么办

先反问业务:真的需要强一致吗? 绝大多数读场景(商品详情、用户资料、文章内容)能接受几百毫秒的最终一致。如果确实需要:

  • 读写都加分布式锁串行化(性能大幅下降,慎用)。
  • 强一致读直接绕过缓存打主库(SELECT ... FOR UPDATE 或强制走主),只让非关键读走缓存。
  • 金额类数据以 DB 为唯一事实源,缓存只用于展示,扣减一律走 DB 事务。

⚠️ 别忘了主从延迟这条暗线。“更新主库 → 删缓存 → 读请求未命中 → 从从库读到旧值 → 回写缓存”,脏数据同样产生。所以缓存回源查询在关键路径上应当读主库,或等待复制位点。

本章考点总结:缓存问题分"流量类"(穿透/雪崩/击穿,见 09)和"一致性类"(双写)。双写标准答案是"先更新 DB、再删缓存",加上延迟双删 + TTL 兜底 + binlog 订阅重试;一致性只能做到最终一致,强一致就绕开缓存。


五、查询缓存报错时,怎么提高可用性

5.1 用生活类比先建立直觉

类比:桌上的书突然被人搬走了(Redis 挂了)。最愚蠢的反应是全班同学一起冲向地下书库——书库当场瘫痪,连原本能借到书的人都借不到了。成熟的做法是:门口保安只放少量人进去(限流),发现书库已经忙不过来就先关门十分钟(熔断),期间大家先看手里的旧笔记(本地兜底/降级),十分钟后放一两个人进去试试水温(半开探测)。

对应到工程里就是:缓存故障时,最大的风险不是"缓存读不到",而是"所有流量瞬间打到数据库,把数据库也搞死"。可用性方案的核心目标是"隔离故障,不让它扩散"。

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 错误率超阈值
    Open --> HalfOpen: 冷却时间到
    HalfOpen --> Closed: 探测请求成功
    HalfOpen --> Open: 探测请求仍失败
    note right of Open
        直接返回兜底值
        不再访问 Redis
    end note

5.2 工程要点

按"从快到慢、从局部到全局"排列的五层防护:

层次手段作用
1. 超时读写超时 50~200ms,连接超时更短防止请求线程被卡死堆积
2. 重试控制最多重试 1 次,且只对幂等读重试避免重试风暴把故障放大
3. 熔断错误率超阈值直接跳闸,快速失败隔离故障,保护 DB 和自身线程池
4. 降级返回本地缓存旧值 / 默认值 / 静态兜底页保证核心功能可用(有损服务)
5. 限流回源 DB 前加信号量或令牌桶缓存全挂时,只放少量流量到 DB

再补两条基础设施层面的:

  • 缓存本身高可用:Sentinel 或 Cluster,主挂了自动切换;客户端配置好节点探活与重连。
  • 多级缓存兜底:本地缓存哪怕数据旧一点,也远好过接口 500。这在大促场景里是最后一道命门。

代码:超时 + 熔断 + 本地兜底 + 回源限流

package cache

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"sync/atomic"
	"time"

	"github.com/redis/go-redis/v9"
)

// breaker 一个极简熔断器:连续失败达到阈值就跳闸,冷却后自动半开。
type breaker struct {
	failures  int64 // 连续失败次数
	openUntil int64 // 熔断解除的 UnixNano 时间点
}

const (
	failThreshold = 20
	coolDown      = 5 * time.Second
)

func (b *breaker) allow() bool {
	// 步骤 1:熔断期内直接拒绝,不去碰 Redis,这是"快速失败"。
	return time.Now().UnixNano() >= atomic.LoadInt64(&b.openUntil)
}

func (b *breaker) onResult(err error) {
	if err == nil {
		atomic.StoreInt64(&b.failures, 0)
		return
	}
	// 步骤 2:失败累计到阈值就跳闸,冷却时间后自动进入"半开"重试。
	if atomic.AddInt64(&b.failures, 1) >= failThreshold {
		atomic.StoreInt64(&b.openUntil, time.Now().Add(coolDown).UnixNano())
		atomic.StoreInt64(&b.failures, 0)
	}
}

type SafeRepo struct {
	*UserRepo
	br    breaker
	local LocalCache          // 进程内兜底缓存,允许存旧值
	dbSem chan struct{}       // 回源 DB 的信号量,限制并发
}

type LocalCache interface {
	Get(key string) (*User, bool)
	Set(key string, u *User)
}

func (r *SafeRepo) GetUserSafe(ctx context.Context, id int64) (*User, error) {
	key := fmt.Sprintf("user:%d", id)

	// 步骤 3:熔断关闭时才访问 Redis,并且给一个独立的短超时。
	if r.br.allow() {
		rctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
		val, err := r.rdb.Get(rctx, key).Bytes()
		cancel()

		if err == nil {
			var u User
			if json.Unmarshal(val, &u) == nil {
				r.local.Set(key, &u) // 顺手更新本地兜底副本
				return &u, nil
			}
		}
		// redis.Nil 是正常未命中,不该计入熔断失败。
		if err != nil && !errors.Is(err, redis.Nil) {
			r.br.onResult(err)
		} else {
			r.br.onResult(nil)
		}
	}

	// 步骤 4:Redis 不可用时,先看本地兜底副本。
	// 数据可能旧几秒,但"旧数据"远好过"接口报错"。
	if u, ok := r.local.Get(key); ok {
		return u, nil
	}

	// 步骤 5:最后才回源 DB,且用信号量限流——
	// 这一步是保命的关键:缓存全挂时只允许少量并发打到数据库。
	select {
	case r.dbSem <- struct{}{}:
		defer func() { <-r.dbSem }()
	case <-time.After(50 * time.Millisecond):
		// 拿不到令牌就降级返回,绝不排队等死。
		return nil, errors.New("service busy, degraded")
	}

	u, err := r.loadFromDB(ctx, id)
	if err != nil {
		return nil, err
	}
	r.local.Set(key, u)
	return u, nil
}

⚠️ 新手必踩的坑:把 redis.Nil 计入熔断失败率。冷启动时大量正常未命中会瞬间把熔断器打开,缓存彻底失效。必须显式排除 redis.Nil

⚠️ 第二个坑:给 Redis 调用复用请求级 timeout。如果整个请求超时 3s,Redis 也等 3s,那一次抖动就能把 goroutine 全部占满。缓存调用要有自己的、远小于请求超时的独立超时。

面试话术模板

“查缓存报错,我会分三步处理。第一,别把错误直接抛给用户——redis.Nil 是正常未命中,真异常才走降级。第二,别把流量转手全丢给 DB,所以回源要加信号量限流 + 熔断,本地缓存做兜底返回旧值。第三,从架构上让故障不容易发生:Redis 用 Sentinel/Cluster、客户端配好超时和连接池、加多级缓存。核心原则是有损服务优于不可用。”

本章考点总结:五层防护记牢——超时、重试上限、熔断、降级(本地兜底)、回源限流;外加缓存集群高可用。答题的题眼是"防止故障扩散到数据库"和"有损服务优于整体不可用"。


六、缓存穿透、雪崩、击穿(简引)

这三个问题的成因、代码实现和排错细节已在 详见 09-Redis全系列.md 的"缓存三大问题"一章完整展开,此处只留一句话索引,避免重复:

  • 6. 如何避免缓存穿透:查询不存在的数据反复打到 DB——用布隆过滤器提前拦截 + 对空结果缓存短 TTL 的空值 + 入口参数校验。详见 09-Redis全系列.md
  • 7. 如何避免缓存雪崩:大批 key 同时失效或缓存集群整体宕机——TTL 加随机抖动打散过期时间 + 缓存高可用(Sentinel/Cluster)+ 熔断降级限流 + 多级缓存兜底(可用性细节见本文第五章)。详见 09-Redis全系列.md
  • 8. 如何避免缓存击穿:单个超热点 key 到期瞬间被打穿——互斥锁/singleflight 只放一个请求重建(本文 2.2 有代码)+ 热点 key 逻辑过期不设物理 TTL、由后台异步续期。详见 09-Redis全系列.md

本章考点总结:一句话区分三者——穿透是"数据本就不存在",击穿是"一个热点 key 没了",雪崩是"一大批 key 或整个缓存没了"。


七、什么是缓存预热,如何实现

7.1 用生活类比先建立直觉

类比:冬天开车,发动机冷的时候直接猛踩油门,磨损大、动力还上不来,所以要先热车。餐厅开门前,厨师会提前把高汤炖好、配菜切好——不然第一批客人来了现杀鱼现切菜,全都得等一小时。

对应到工程里就是:新实例刚启动、或缓存刚被清空时,缓存里是空的,所有请求都要回源。这一瞬间数据库要承受平时几十倍的压力,很容易直接被打死——冷启动雪崩。缓存预热就是"提前把菜备好":在流量进来之前,先把热点数据灌进缓存。

flowchart LR
    Deploy[新版本部署/缓存清空] --> Cold["缓存为空
命中率 0%"] Cold -->|"不预热"| Boom["请求全部回源
DB 被打爆"] Cold -->|"先预热"| Warm["热点数据已加载
命中率 90%+"] Warm --> Serve["接入流量,平稳服务"]

7.2 工程要点

三类预热方式

方式时机适用场景
上线前批量加载发布流程中,服务启动后、接入流量前数据量可控的热点集合(Top N 商品、配置、字典表)
定时预热定时任务周期性刷新 / 大促前定点执行数据会变、需要持续保温;活动开始前集中加载
灰度预热新实例先接 1% 流量,逐步放量数据量大到无法全量预热,用真实流量自然填充

热点数据从哪来

预热的前提是知道哪些数据是热的,三种来源:

  1. 历史访问日志离线统计:跑一个离线任务算出昨天访问 Top 1 万的 key,直接作为预热清单——最常用、最准。
  2. 业务规则圈定:首页推荐位、活动商品池、榜单前 100、全部配置项——业务上就知道一定会被访问。
  3. Redis 自身数据:从旧集群 SCAN 出还活着的 key 列表(迁移场景),或者用 redis-cli --hotkeys 采样。

完整预热流程

这张图是生产环境发布时的标准动作,注意"预热完成才接流量"这个顺序。

sequenceDiagram
    participant CD as 发布系统
    participant P as 新实例
    participant D as MySQL
    participant R as Redis
    participant LB as 负载均衡
    CD->>P: 启动进程
    P->>D: 分批查询热点数据(限速)
    D-->>P: 返回数据
    P->>R: Pipeline 批量写入,TTL 加抖动
    P->>P: 加载本地缓存 Top 1000
    P-->>CD: 健康检查就绪(readiness = true)
    CD->>LB: 摘除旧实例、挂入新实例
    LB->>P: 开始导入真实流量

代码:分批限速预热

package cache

import (
	"context"
	"encoding/json"
	"fmt"
	"math/rand"
	"time"

	"github.com/redis/go-redis/v9"
	"golang.org/x/time/rate"
)

// Warmup 分批 + 限速把热点数据灌进 Redis。
// 关键点:预热本身也是压力,绝不能自己把 DB 打死。
func (r *UserRepo) Warmup(ctx context.Context, hotIDs []int64) error {
	const batchSize = 500

	// 步骤 1:限速器控制"每秒最多查多少批",给线上业务留出 DB 余量。
	limiter := rate.NewLimiter(rate.Limit(10), 1) // 每秒 10 批 = 5000 行/秒

	for start := 0; start < len(hotIDs); start += batchSize {
		end := start + batchSize
		if end > len(hotIDs) {
			end = len(hotIDs)
		}

		// 步骤 2:拿令牌,拿不到就阻塞等待——天然实现匀速。
		if err := limiter.Wait(ctx); err != nil {
			return err
		}

		users, err := r.batchLoadFromDB(ctx, hotIDs[start:end])
		if err != nil {
			// 预热失败不该阻断启动:记日志继续,缓存未命中只是性能退化。
			continue
		}

		// 步骤 3:用 Pipeline 批量写,把 500 次 RTT 压成 1 次。
		pipe := r.rdb.Pipeline()
		for _, u := range users {
			buf, _ := json.Marshal(u)
			// 步骤 4:TTL 一定要加随机抖动,
			// 否则这批一起预热的 key 将来会一起过期 —— 亲手制造雪崩。
			ttl := time.Hour + time.Duration(rand.Intn(1800))*time.Second
			pipe.Set(ctx, fmt.Sprintf("user:%d", u.ID), buf, ttl)
		}
		if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil {
			continue
		}
	}
	return nil
}

func (r *UserRepo) batchLoadFromDB(ctx context.Context, ids []int64) ([]*User, error) {
	// 实际实现用 WHERE id IN (...) 一次查回,注意 IN 的长度上限。
	users := make([]*User, 0, len(ids))
	for _, id := range ids {
		u, err := r.loadFromDB(ctx, id)
		if err != nil {
			continue
		}
		users = append(users, u)
	}
	return users, nil
}

⚠️ 新手必踩的坑:预热时给所有 key 设同一个 TTL。一小时后这批 key 整齐地一起过期,你就亲手造出了一场缓存雪崩。预热的 TTL 抖动比平时更重要。

⚠️ 第二个坑:预热跑在启动的同步路径上还不设超时。几十万条数据串行加载,K8s 的 readiness 探针超时直接把 Pod 判死、反复重启。要么异步预热 + 只把"核心小集合"放在同步路径,要么把探针超时调够。

预热的三条工程纪律

  1. 预热不能压垮 DB:限速 + 分批 + 走从库,必要时错峰。凌晨全量预热、白天只增量补。
  2. 预热要能观测:暴露"已预热条数 / 预计总数 / 命中率"指标,发布时看着曲线放流量。
  3. 预热和摘挂流量联动:K8s 场景把预热做进 readiness 探针——没热完就不算就绪,流量进不来。

本章考点总结:缓存预热 = 在流量到达前把热点数据灌进缓存,避免冷启动把 DB 打爆。三种做法(上线前批量加载 / 定时预热 / 灰度放量)+ 三条纪律(限速、可观测、与流量摘挂联动),并且预热 TTL 必须加抖动。


八、缓存数据的淘汰策略

内存是有限的,写满了就必须扔掉一些数据。Redis 的 8 种淘汰策略(noevictionallkeys-lruvolatile-lruallkeys-lfuvolatile-lfuallkeys-randomvolatile-randomvolatile-ttl)、近似 LRU 的实现、LFU 的计数衰减机制,都已在 详见 09-Redis全系列.md 的"过期与淘汰策略"一章详细展开,这里只做名称速查:

策略一句话说明适合场景
LRU(Least Recently Used)淘汰最久没被访问的,看"最后一次访问时间"通用缓存,有明显时间局部性
LFU(Least Frequently Used)淘汰访问次数最少的,看"访问频率"(带衰减)有稳定热点、想抗住偶发扫描污染
FIFO(First In First Out)先进先出,只看写入顺序实现最简单,命中率通常最差
Random随机淘汰无明显热点,或想省掉统计开销
TTL / volatile-ttl优先淘汰剩余存活时间最短的key 的过期时间本身就代表重要性

选型只需记两句:通用场景用 allkeys-lru;有明确热点、又怕爬虫或批量扫描把热数据冲掉,用 allkeys-lfu。生产上千万别用 noeviction——内存打满后写操作直接报错,等于缓存变只读。

本章考点总结:LRU 看"最近一次访问",LFU 看"访问频率",FIFO 只看写入顺序;Redis 默认 noeviction 要改成 allkeys-lruallkeys-lfu


九、singleflight:合并并发回源(深度源码解析)

9.1 用生活类比先建立直觉

类比:公司要订一批饮用水,群里 100 个人同时发现水没了,各自给供应商打电话下单——供应商接到 100 通电话,仓库被 100 个重复订单搞崩溃(这就是缓存击穿:同一个失效 key 被 100 个请求同时回源 DB)。

singleflight 的做法:群里第一个发现没水的人(领头请求)负责打电话下单,他在群里喊一声"我来订,大家别打了,到了分给大家"。其余 99 个人只要等结果、共享这同一批水。100 通电话变成 1 通。

对应到工程里:singleflight 把"对同一个 key 的并发回源"合并成"一次真实的回源",其余请求阻塞等待并复用同一个结果。它是防缓存击穿、削平回源尖峰的标准武器之一。

下面这张图是 N 个并发请求打到同一个失效 key 时的流转:只有 1 个成为"领头者"真正查库,其余全部共享结果。

flowchart TB
    R1[请求1 同一key] --> SF["singleflight.Do(key)"]
    R2[请求2 同一key] --> SF
    R3[请求N 同一key] --> SF
    SF --> Leader{谁是领头?}
    Leader -->|第一个到达| Call["只放行 1 个
真正执行 loadFromDB"] Leader -->|其余请求| Wait["阻塞等待
复用同一结果"] Call --> DB[(数据库
只被打 1 次)] DB --> Share["结果广播给
所有等待者"] Wait --> Share

9.2 工程要点

singleflight 是什么、为什么能防击穿

缓存击穿的本质是:某个超热点 key 在失效瞬间,大量并发请求同时发现缓存 miss,一起涌向数据库。singleflight 在"回源"这一层加了一个合并器:

  • 同一个 key 的并发 Do 调用,只有第一个会执行你传入的 fn(真正查库/回源);
  • 后续到达的调用不执行 fn,而是等到第一个 fn 返回后,大家共享同一个结果(和 error);
  • 因此 DB 只被访问 1 次,而不是 N 次。

⚠️ 易混点:第二章已经给出了 singleflight 的"用法级"代码(HotRepo.GetUserSF)。本章往下挖一层:它是怎么做到的? 以及它和"双重检查锁 / Mutex"方案相比好在哪。

groupcache / singleflight 源码思路

golang.org/x/sync/singleflight 的核心非常精简,三个关键结构:

  1. Group:一个合并器实例,内部用一个 map[string]*call 记录"当前正在进行的回源"(inFlight 请求)。
  2. call:代表一次正在进行的回源,内部用 sync.WaitGroupwg)让后续等待者阻塞,用 val/err 保存结果。
  3. Do(key, fn):对外入口。

源代码的精妙之处在于:用 map 记录 InFlight 请求,用 WaitGroup 实现"共享等结果"。下面这张图对应 Do 的执行分支:

flowchart TB
    Do["Do(key, fn) 被调用"] --> Check{"map 里已有
该 key 的 call?"} Check -->|"有:说明别人在回源"| Join["join 进去
wg.Wait 阻塞等待
复用同一 val/err"] Check -->|"没有:我是领头"| Create["新建 call 写入 map
执行 fn 回源"] Create --> Done["fn 返回 val/err
wg.Done 唤醒所有等待者
从 map 删除该 key"] Join --> Return["返回共享的 val/err"] Done --> Return

核心源码(简化、保留骨架)如下:

package singleflight

import "sync"

// call 代表一次正在进行的回源。
// wg 让后续并发请求阻塞等待;val/err 是共享结果。
type call struct {
	wg  sync.WaitGroup
	val any
	err error
}

// Group 是合并器,用 map 记录 InFlight 的回源。
type Group struct {
	mu sync.Mutex
	m  map[string]*call // key -> 正在进行的 call
}

// Do 合并对同一个 key 的并发回源:
// 第一个到达的执行 fn,其余阻塞复用其结果。
func (g *Group) Do(key string, fn func() (any, error)) (any, error) {
	g.mu.Lock()
	if g.m == nil {
		g.m = make(map[string]*call)
	}
	// 步骤1:如果已有同 key 的 call,说明别人正在回源。
	if c, ok := g.m[key]; ok {
		g.mu.Unlock()
		c.wg.Wait()         // 步骤2:阻塞等领头者完成(共享结果的关键)
		return c.val, c.err // 步骤3:复用同一个结果,DB 只被打 1 次
	}
	// 步骤4:没有 → 我是领头者,新建 call 并登记进 map。
	c := new(call)
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()

	// 步骤5:只有领头者真正执行回源函数。
	c.val, c.err = fn()

	g.mu.Lock()
	delete(g.m, key) // 步骤6:回源结束,从 InFlight 移除,允许后续新请求再回源
	g.mu.Unlock()
	c.wg.Done() // 步骤7:唤醒所有等待者,它们拿到共享的 val/err
	return c.val, c.err
}

⚠️ 字节原题深挖点:面试官常在这里追问"singleflight 底层靠什么让并发请求共享结果?"——答案是 sync.WaitGroup:领头者 Add(1) 后执行 fn,其余人 wg.Wait() 挂起;fn 返回后 wg.Done() 唤醒所有人,所有人读到的都是领头者写入的同一个 c.val / c.errmap 只是用来判断"现在有没有人在回源这个 key"。源码里 Forget 则是手动把 key 从 map 中摘除,用于"失败结果不想被复用"的场景(如第二章提到的错误共享)。

与双重检查锁 / Mutex 对比

很多人第一反应是"用 Mutex + 双重检查锁(DCL)也能防击穿",确实能,但差别明显:

维度singleflightMutex + 双重检查锁
回源次数同一 key 并发只回源 1 次同一 key 并发只回源 1 次(锁内再查一次缓存)
并发等待者全部复用同一结果,无锁竞争抢锁串行,第二个进来发现已回源就直接返回
代码量一个 Do 调用,极简要手写 Lock → 查缓存 → 没命中 → 回源 → Unlock → 再查 多层
结果共享天然共享(WaitGroup)靠"回源后写回共享缓存"间接共享
额外能力Forget 清失败态、DoChan 支持超时/select需自己加超时、加 context
局限仅单进程内有效仅单进程内有效(都需叠加分布式锁才行)
package cache

import (
	"context"
	"encoding/json"
	"fmt"
	"sync"
	"time"
)

// 双重检查锁版防击穿(对比用,需给 repo 加一把锁)
type lockedRepo struct {
	*UserRepo
	mu sync.Mutex
}

func (r *lockedRepo) GetUserDCL(ctx context.Context, id int64) (*User, error) {
	key := fmt.Sprintf("user:%d", id)
	// 步骤1:第一次无锁快路径
	if val, err := r.rdb.Get(ctx, key).Bytes(); err == nil {
		var u User
		if json.Unmarshal(val, &u) == nil {
			return &u, nil
		}
	}
	// 步骤2:加锁,进入临界区
	r.mu.Lock()
	defer r.mu.Unlock()
	// 步骤3:双重检查——拿到锁后别人可能已经回源写好了
	if val, err := r.rdb.Get(ctx, key).Bytes(); err == nil {
		var u User
		if json.Unmarshal(val, &u) == nil {
			return &u, nil
		}
	}
	// 步骤4:确实没有,才回源
	u, err := r.loadFromDB(ctx, id)
	if err != nil {
		return nil, err
	}
	buf, _ := json.Marshal(u)
	_ = r.rdb.Set(ctx, key, buf, 30*time.Minute).Err()
	return u, nil
}

对比结论:DCL 也能把回源收敛成 1 次,但"锁内再查一次缓存"是必需的(否则多个请求都以为自己要回源),且所有等待者都得过一把锁、多了一次缓存查询。singleflight 把这套模式封装掉了——你要做的只是把回源逻辑塞进 fn真正的分布式热点(100 个 Pod 各回源 1 次 = 100 次)两者都挡不住,必须再叠加 Redis 分布式锁或"逻辑过期 + 后台续期"。

本章考点总结:singleflight 把"同一 key 的并发回源"合并成一次,靠 map 记录 InFlight 请求、靠 sync.WaitGroup 让等待者共享结果;它是防缓存击穿的标准武器,比手写 Mutex + 双重检查锁更简洁,但两者都只在单进程内有效。


十、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 缓存实现高性能的三个来源分别是什么?为什么"命中率从 99% 降到 95%“对数据库是一次数倍的流量放大?

  2. Redis 和 Memcached 在线程模型、数据类型、持久化、集群方案上分别有什么区别?什么情况下你还会选 Memcached?

  3. 为什么双写一致性的标准答案是"先更新数据库、再删除缓存"而不是"先删缓存、再更新数据库”?画出前者仍可能不一致的时序图,并说明延迟双删补的是哪个窗口。

  4. 查询缓存报错时,如果直接把流量转给数据库会发生什么?请按顺序说出五层防护手段,以及为什么 redis.Nil 不能计入熔断失败率。

  5. 什么是缓存预热?为什么预热时给所有 key 设置相同 TTL 是个严重错误?在 K8s 环境里预热应该和什么机制联动?

  6. singleflight 是怎么把"同一 key 的并发回源"收敛成一次的?它内部靠哪两个关键机制(map 和什么)让等待者共享结果?为什么它只在单进程内有效?

  7. 手写 singleflight 和用 Mutex + 双重检查锁都能防缓存击穿(回源次数都收敛成 1 次),但哪个更简洁、额外能力更强(超时 / Forget)?真正跨多个 Pod 的分布式热点还要再叠加什么方案?

动手练习(建议真做一遍)

  1. 实现第二章的 GetUserSF,用 go test -benchwrk 对同一个冷 key 打 1000 并发,在 loadFromDB 里加计数器,验证到底查了几次库;再去掉 singleflight 对比。
  2. 把第五章的 GetUserSafe 跑起来,接着 docker stop redis,观察接口行为:熔断是否在几秒内跳闸、是否返回了本地兜底数据、DB 的 QPS 有没有暴涨。然后去掉信号量限流再测一次,对比 DB 的连接数曲线。
  3. 写一个预热脚本把 1 万条数据灌进 Redis:第一版所有 key 用相同 TTL,第二版加随机抖动。用 redis-cli --stat 观察一小时后两版的 expires 变化和回源 QPS 曲线,把两张图贴在一起做对比分析。

十一、本章小结

  • 缓存的价值是两句话:高性能来自内存访问 + 挡住 DB + 就近访问;高并发来自用便宜的副本层层拦截读流量 + 把写洪峰削平后匀速交给数据库。
  • 选型记核心定位:Memcached 是纯 KV 内存缓存(多线程、无持久化、客户端分片),Redis 是内存数据结构服务器(多类型、持久化、Sentinel/Cluster、Lua)。新项目基本无脑选 Redis。
  • 缓存的问题分两类:流量类(穿透 / 雪崩 / 击穿,详见 09-Redis全系列.md )和一致性类(双写)。双写的标准答案是"先更新 DB、再删缓存",加延迟双删 + TTL 兜底 + binlog 订阅重试,目标是最终一致,需要强一致就绕开缓存。
  • 可用性的题眼是"别让故障扩散":超时 → 重试上限 → 熔断 → 降级本地兜底 → 回源限流,五层缺一不可;有损服务永远优于整体不可用。
  • 预热是冷启动的保命符:上线前批量加载、定时预热、灰度放量三种方式,务必限速、可观测、与流量摘挂联动,TTL 必须加抖动。
  • 下一步建议把本章的可用性思路和 14-微服务CICD与限流.md 的限流熔断串起来看——缓存降级只是整个服务治理体系里的一环,真实的大促方案是缓存、限流、降级、扩容四件事一起做。
复习提示:
  • 缓存性能的核心:内存访问速度(纳秒级)vs 磁盘(毫秒级),差距在 5 万倍以上;命中率从 99% 降到 95% 会让 DB QPS 暴增 20 倍。
  • 双写一致性:“先更新 DB、再删缓存"是标准答案;延迟双删补的是"删缓存后、DB 写入前"的极短窗口;TTL 兜底应对极端情况。
  • 单进程 singleflight vs 分布式热点:singleflight 只能收敛单机内的重复回源,跨 Pod 热点需要用 Redis 分布式锁或 Redis 分布式锁 + Lua 原子删除方案。
  • 缓存五层防护链:超时 → 重试上限 → 熔断 → 降级本地兜底 → 回源限流,缺一不可;有损服务永远优于整体不可用。
  • 下一章我们讲消息队列 Kafka——它是解耦和削峰的利器,但引入异步后带来了新的问题:消息可靠性、顺序性、幂等性。

十、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 缓存实现高性能的三个来源分别是什么?为什么"命中率从 99% 降到 95%“对数据库是一次数倍的流量放大?
  2. Redis 和 Memcached 在线程模型、数据类型、持久化、集群方案上分别有什么区别?什么情况下你还会选 Memcached?
  3. 为什么双写一致性的标准答案是"先更新数据库、再删除缓存"而不是"先删缓存、再更新数据库”?画出前者仍可能不一致的时序图,并说明延迟双删补的是哪个窗口。
  4. 查询缓存报错时,如果直接把流量转给数据库会发生什么?请按顺序说出五层防护手段,以及为什么 redis.Nil 不能计入熔断失败率。
  5. 什么是缓存预热?为什么预热时给所有 key 设置相同 TTL 是个严重错误?在 K8s 环境里预热应该和什么机制联动?
  6. singleflight 是怎么把"同一 key 的并发回源"收敛成一次的?它内部靠哪两个关键机制(map 和什么)让等待者共享结果?为什么它只在单进程内有效?
  7. 手写 singleflight 和用 Mutex + 双重检查锁都能防缓存击穿(回源次数都收敛成 1 次),但哪个更简洁、额外能力更强(超时 / Forget)?真正跨多个 Pod 的分布式热点还要再叠加什么方案?

动手练习(建议真做一遍)

  1. 实现第二章的 GetUserSF,用 go test -benchwrk 对同一个冷 key 打 1000 并发,在 loadFromDB 里加计数器,验证到底查了几次库;再去掉 singleflight 对比。
  2. 把第五章的 GetUserSafe 跑起来,接着 docker stop redis,观察接口行为:熔断是否在几秒内跳闸、是否返回了本地兜底数据、DB 的 QPS 有没有暴涨。然后去掉信号量限流再测一次,对比 DB 的连接数曲线。
  3. 写一个预热脚本把 1 万条数据灌进 Redis:第一版所有 key 用相同 TTL,第二版加随机抖动。用 redis-cli --stat 观察一小时后两版的 expires 变化和回源 QPS 曲线,把两张图贴在一起做对比分析。
面试官
缓存穿透、缓存击穿、缓存雪崩有什么区别?面试时怎么一眼看出面试官想问哪个?
候选人
三个词只有一字之差,但本质完全不同:

缓存穿透:查询不存在的数据(如不存在的商品 ID),缓存和 DB 都没有,每次请求都打到 DB。解决方案:布隆过滤器预判 + 空值缓存。
缓存击穿热点 key过期瞬间,大量并发请求同时打到 DB。解决方案:互斥锁(Mutex/singleflight)+ 不过期的热点 key。
缓存雪崩大量 key 同时过期或 Redis 宕机,请求全部打到 DB。解决方案:TTL 加随机抖动、Redis 高可用集群、多级缓存兜底。

面试技巧:如果面试官问"某 key 过期后高并发"→ 击穿;如果问"大量 key 同时失效"→ 雪崩;如果问"查不存在的 ID 把 DB 打挂了"→ 穿透。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!