学习目标
学完本章你应该能够:
- 用自己的话讲清"缓存为什么快"——从内存与磁盘的延迟量级、DB 压力、就近访问三个角度各说一段。
- 讲清缓存承接读流量、削峰填谷的机制,并估算加缓存后系统读能力提升了多少倍。
- 说出 Redis 与 Memcached 在线程模型、数据类型、持久化、集群、内存管理上的差异,以及各自还适合什么场景。
- 设计一套"缓存不可用也不打穿数据库"的读路径:超时 + 重试上限 + 熔断 + 降级 + 本地兜底。
- 独立设计一次缓存预热方案(上线前批量加载 / 定时预热 / 灰度放量),并说清双写一致性的取舍。
前置知识:Redis 基本命令与数据类型、缓存三大问题(穿透 / 击穿 / 雪崩)与淘汰策略(回看 09-Redis全系列.md
)、MySQL 读写与事务基础(10-MySQL全系列.md
)、Go 的 context、sync 基础。
本章你会动手做的事:
- 写一个带
singleflight的 Cache-Aside 读函数,压测同一个冷 key 并发 1000 请求时到底穿透了几次 DB。 - 给读缓存加上超时 + 熔断 + 本地兜底三层保护,然后故意
kill掉 Redis,观察接口是"整体 500"还是"降级返回旧数据"。 - 写一个分批限速的预热脚本,把 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 -- 回填 --> L11.2 工程要点
第一根支柱:内存访问比磁盘快几个数量级
面试时能报出量级,比说"内存比磁盘快"有说服力得多:
| 操作 | 典型耗时 | 相对倍数 |
|---|---|---|
| CPU L1 缓存读取 | ~1 ns | 1x |
| 内存随机读取 | ~100 ns | 100x |
| SSD 随机读取 | ~100 μs | 10 万 x |
| 机械磁盘随机寻道 | ~10 ms | 1000 万 x |
| 同机房网络往返 | ~0.5 ms | 50 万 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 流量翻数倍
必须监控告警"]关键手段有四个:
- 多级缓存:本地缓存挡住最热的那一小撮 key(比如首页配置、热门商品)。
- 集群分片:Redis Cluster 按 slot 把 key 打散到多个节点,容量和 QPS 都线性扩展。
- 读写分离 + 多副本:热点读可以打到多个从节点,客户端做负载均衡。
- 热点 key 打散:把
hot:item:1复制成hot:item:1#0~hot:item:1#9,随机读一个,避免单个 slot 被打爆(详见 09-Redis全系列.md 的热点 Key 处理)。
削峰:把尖刺磨平
削峰针对的是瞬时洪峰,典型场景是秒杀、开抢、大促首屏。三种常用做法:
- 请求合并(singleflight):同一时刻 1000 个请求要同一个失效 key,只放 1 个去查库,其余等结果。
- 异步写 / 消息队列:写请求先落 Redis 或 Kafka,返回"已受理",后台匀速消费落库。库存扣减就是这么做的:Redis 原子扣减,DB 异步对账。
- 限流 + 队列缓冲:入口令牌桶限流,超出直接返回"活动太火爆",绝不让洪峰接触数据库(限流详见 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逐项对比
| 维度 | Redis | Memcached |
|---|---|---|
| 数据类型 | 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,再更新缓存 | 双写 | 并发两个写请求的更新顺序可能颠倒,缓存留下旧值;且计算型缓存做了无用功 |
| 先删缓存,再更新 DB | Cache 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 到期三种加固手段
- 延迟双删:更新 DB 后立刻删一次,再延迟几百毫秒异步删第二次,覆盖上面那个交错窗口。
- 给缓存加 TTL 兜底:无论如何都要设过期时间。这是"最终一致"的最后保险——就算所有删除都失败了,最多脏 TTL 那么久。
- 订阅 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 note5.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% 流量,逐步放量 | 数据量大到无法全量预热,用真实流量自然填充 |
热点数据从哪来
预热的前提是知道哪些数据是热的,三种来源:
- 历史访问日志离线统计:跑一个离线任务算出昨天访问 Top 1 万的 key,直接作为预热清单——最常用、最准。
- 业务规则圈定:首页推荐位、活动商品池、榜单前 100、全部配置项——业务上就知道一定会被访问。
- 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 判死、反复重启。要么异步预热 + 只把"核心小集合"放在同步路径,要么把探针超时调够。
预热的三条工程纪律
- 预热不能压垮 DB:限速 + 分批 + 走从库,必要时错峰。凌晨全量预热、白天只增量补。
- 预热要能观测:暴露"已预热条数 / 预计总数 / 命中率"指标,发布时看着曲线放流量。
- 预热和摘挂流量联动:K8s 场景把预热做进 readiness 探针——没热完就不算就绪,流量进不来。
本章考点总结:缓存预热 = 在流量到达前把热点数据灌进缓存,避免冷启动把 DB 打爆。三种做法(上线前批量加载 / 定时预热 / 灰度放量)+ 三条纪律(限速、可观测、与流量摘挂联动),并且预热 TTL 必须加抖动。
八、缓存数据的淘汰策略
内存是有限的,写满了就必须扔掉一些数据。Redis 的 8 种淘汰策略(noeviction、allkeys-lru、volatile-lru、allkeys-lfu、volatile-lfu、allkeys-random、volatile-random、volatile-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-lru 或 allkeys-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 --> Share9.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 的核心非常精简,三个关键结构:
Group:一个合并器实例,内部用一个map[string]*call记录"当前正在进行的回源"(inFlight请求)。call:代表一次正在进行的回源,内部用sync.WaitGroup(wg)让后续等待者阻塞,用val/err保存结果。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.err。map只是用来判断"现在有没有人在回源这个 key"。源码里Forget则是手动把 key 从 map 中摘除,用于"失败结果不想被复用"的场景(如第二章提到的错误共享)。
与双重检查锁 / Mutex 对比
很多人第一反应是"用 Mutex + 双重检查锁(DCL)也能防击穿",确实能,但差别明显:
| 维度 | singleflight | Mutex + 双重检查锁 |
|---|---|---|
| 回源次数 | 同一 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 + 双重检查锁更简洁,但两者都只在单进程内有效。
十、自测题与动手练习
自测题(合上书能答出来,才算懂):
缓存实现高性能的三个来源分别是什么?为什么"命中率从 99% 降到 95%“对数据库是一次数倍的流量放大?
Redis 和 Memcached 在线程模型、数据类型、持久化、集群方案上分别有什么区别?什么情况下你还会选 Memcached?
为什么双写一致性的标准答案是"先更新数据库、再删除缓存"而不是"先删缓存、再更新数据库”?画出前者仍可能不一致的时序图,并说明延迟双删补的是哪个窗口。
查询缓存报错时,如果直接把流量转给数据库会发生什么?请按顺序说出五层防护手段,以及为什么
redis.Nil不能计入熔断失败率。什么是缓存预热?为什么预热时给所有 key 设置相同 TTL 是个严重错误?在 K8s 环境里预热应该和什么机制联动?
singleflight 是怎么把"同一 key 的并发回源"收敛成一次的?它内部靠哪两个关键机制(map 和什么)让等待者共享结果?为什么它只在单进程内有效?
手写 singleflight 和用 Mutex + 双重检查锁都能防缓存击穿(回源次数都收敛成 1 次),但哪个更简洁、额外能力更强(超时 / Forget)?真正跨多个 Pod 的分布式热点还要再叠加什么方案?
动手练习(建议真做一遍):
- 实现第二章的
GetUserSF,用go test -bench或wrk对同一个冷 key 打 1000 并发,在loadFromDB里加计数器,验证到底查了几次库;再去掉singleflight对比。 - 把第五章的
GetUserSafe跑起来,接着docker stop redis,观察接口行为:熔断是否在几秒内跳闸、是否返回了本地兜底数据、DB 的 QPS 有没有暴涨。然后去掉信号量限流再测一次,对比 DB 的连接数曲线。 - 写一个预热脚本把 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——它是解耦和削峰的利器,但引入异步后带来了新的问题:消息可靠性、顺序性、幂等性。
十、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 缓存实现高性能的三个来源分别是什么?为什么"命中率从 99% 降到 95%“对数据库是一次数倍的流量放大?
- Redis 和 Memcached 在线程模型、数据类型、持久化、集群方案上分别有什么区别?什么情况下你还会选 Memcached?
- 为什么双写一致性的标准答案是"先更新数据库、再删除缓存"而不是"先删缓存、再更新数据库”?画出前者仍可能不一致的时序图,并说明延迟双删补的是哪个窗口。
- 查询缓存报错时,如果直接把流量转给数据库会发生什么?请按顺序说出五层防护手段,以及为什么
redis.Nil不能计入熔断失败率。 - 什么是缓存预热?为什么预热时给所有 key 设置相同 TTL 是个严重错误?在 K8s 环境里预热应该和什么机制联动?
- singleflight 是怎么把"同一 key 的并发回源"收敛成一次的?它内部靠哪两个关键机制(map 和什么)让等待者共享结果?为什么它只在单进程内有效?
- 手写 singleflight 和用 Mutex + 双重检查锁都能防缓存击穿(回源次数都收敛成 1 次),但哪个更简洁、额外能力更强(超时 / Forget)?真正跨多个 Pod 的分布式热点还要再叠加什么方案?
动手练习(建议真做一遍):
- 实现第二章的
GetUserSF,用go test -bench或wrk对同一个冷 key 打 1000 并发,在loadFromDB里加计数器,验证到底查了几次库;再去掉singleflight对比。 - 把第五章的
GetUserSafe跑起来,接着docker stop redis,观察接口行为:熔断是否在几秒内跳闸、是否返回了本地兜底数据、DB 的 QPS 有没有暴涨。然后去掉信号量限流再测一次,对比 DB 的连接数曲线。 - 写一个预热脚本把 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 打挂了"→ 穿透。