41.2 本地缓存与分布式缓存
一句话
本地缓存赢在纳秒级延迟与零网络开销,输在 “多实例各存一份、改了通知不到”;分布式缓存赢在 “单一事实源”,输在每次访问 0.2–2ms 的网络往返与序列化。真实系统不是二选一,而是两级一起用,并接受一个明确的陈旧窗口。
两者根本不是一类东西
| 维度 | 本地缓存(进程内) | 分布式缓存(Redis / Memcached) |
|---|---|---|
| 典型延迟 | 50–200ns(一次 map 查找) | 0.2–2ms(同机房网络 RTT + 序列化) |
| 容量上限 | 受进程堆限制(几百 MB 到几 GB) | 可到 TB 级(可水平扩展) |
| 数据视图 | 每个实例一份,天然不一致 | 所有实例共享同一份 |
| 失效 | 需要广播,可能丢消息 | 删一次全局生效 |
| 故障影响 | 只影响本实例 | 缓存故障影响全部实例(要防雪崩) |
| 进程重启 | 缓存全丢,瞬时回源 | 数据仍在 |
| 成本 | 近乎免费(用的是已有内存) | 按容量 / 带宽计费 |
| 一致性上限 | 最终一致(有窗口) | 可做到接近实时 |
🔑 一句话记牢:**本地缓存是 “性能 + 每个实例各自的真相”,分布式缓存是 “一个真相 + 网络成本”。**混用这两句话,就能解释所有缓存一致性问题。
延迟到底差多少:一次请求的三跳
请求 ─▶ L1 本地缓存 ─▶ L2 Redis ─▶ DB
~0.1μs ~500μs ~5–20ms
按 30% / 60% / 10% 的命中分布算平均延迟:
0.30 × 0.1ms + 0.60 × 0.5ms + 0.10 × 10ms
= 0.03 + 0.30 + 1.00 = 1.33ms
对比 “只用 Redis”:0.9 × 0.5 + 0.1 × 10 = 1.45ms。看起来只省 8%?把 L1 命中率提到 80%:
0.80 × 0.001 + 0.15 × 0.5 + 0.05 × 10 = 0.001 + 0.075 + 0.5 = 0.58ms
L1 命中率是唯一能把手伸进 “微秒级” 的杠杆——因为省下的不是 Redis 的计算时间,而是整个网络往返。在 QPS 5 万的服务里,这 0.9ms 的差值等于少买 40% 的机器。
二级缓存的完整结构
┌────────── 单个 Pod ──────────┐
请求 ──────▶ │ L1:进程内 map / LRU │ 命中 ~0.1μs
│ ↑ 回填 ↓ miss │
└──────┼──────────────┼─────────┘
│ │
│ ┌─────▼──────────┐
│ │ L2:Redis 集群 │ 命中 ~0.5ms
│ └─────┬──────────┘
│ │ miss
│ ┌─────▼──────────┐
└────────│ DB / 源服务 │ ~5–20ms
└────────────────┘
失效广播:写操作 ──▶ Pub/Sub / MQ ──▶ 所有 Pod 删 L1
四个必须设计的点:
| 设计点 | 做法 | 不做的后果 |
|---|---|---|
| L1 容量上限 | 按 key 数或字节数限制 + LRU 淘汰 | 内存无限增长 → OOM / GC 压力飙升 |
| L1 TTL | 1–5s 的短 TTL 作为兜底 | 广播丢一条 → 该实例永久脏数据 |
| 失效广播 | 写后发布 cache.del 事件,各实例删本地 | 多实例读到不同数据 |
| 回源保护 | singleflight 合并同 key 并发回源 | 缓存失效瞬间,N 个请求一起打 DB(击穿) |
⚠️ L1 的 TTL 不是 “优化”,是正确性保险。广播是尽力而为的 (Pub/Sub 不保证送达、实例可能刚好在重启),TTL 决定了 “最坏情况下脏多久”。TTL=5s 就是承诺 “最多脏 5 秒”。
本地缓存能存什么、不能存什么
| 数据类型 | 能放 L1? | 理由 |
|---|---|---|
| 配置、字典、开关 | ✅ | 变更少、可容忍秒级陈旧 |
| 商品类目、门店信息 | ✅ | 变更频率低,TTL 兜底 |
| 用户昵称 / 头像 | ✅ | 陈旧几秒无感知 |
| 热点文章正文 | ✅ | 读多写少,配上失效广播 |
| 用户权限 / 角色 | ❌ | 撤权要立刻生效,脏窗口 = 越权 |
| 库存 / 余额 / 配额 | ❌ | 陈旧 = 超卖 / 超支 |
| 订单状态 | ❌ | 用户会看到「已取消又变成待支付」 |
| 分布式锁 / 计数器 | ❌ | 天然需要单一事实源 |
判断标准一句话:“读到 5 秒前的值,业务能不能接受?” 不能 → 不放 L1。
内存怎么算:本地缓存不是免费的
每实例内存 ≈ key 数 × (key 长度 + value 长度 + 对象头与索引开销约 50–80B)
算一个真实规模:100 万个 key,平均 key 32B、value 200B、开销 70B → 约 300MB/实例。
- 20 个实例 → 6GB 总内存,全是副本;
- Go 程序里这 300MB 的 map 会让 GC 扫描成本显著上升 (对象多 → 标记时间长);
- 所以本地缓存必须限容量,而不是 “能塞多少塞多少”。经验值:L1 占用不超过进程内存的 10%–20%。
可运行的两级缓存
包含 L1 TTL、L2、singleflight 回源保护,用计数器暴露 “每一层各命中多少次” (go run main.go 直接可跑):
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// ===== L2:模拟 Redis(分布式缓存,单一事实源)=====
type redisLike struct {
mu sync.Mutex
data map[string]string
}
func newRedis() *redisLike { return &redisLike{data: map[string]string{}} }
func (r *redisLike) Get(k string) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
v, ok := r.data[k]
return v, ok
}
func (r *redisLike) Set(k, v string) {
r.mu.Lock()
defer r.mu.Unlock()
r.data[k] = v
}
func (r *redisLike) Del(k string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.data, k)
}
// ===== L1:进程内缓存,带 TTL 与容量上限 =====
type entry struct {
val string
expire time.Time
}
type localCache struct {
mu sync.Mutex
data map[string]entry
max int
}
func newLocal(max int) *localCache {
return &localCache{data: map[string]entry{}, max: max}
}
func (c *localCache) Get(k string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.data[k]
if !ok || time.Now().After(e.expire) { // TTL 是兜底:广播丢了也不会永久脏
delete(c.data, k)
return "", false
}
return e.val, true
}
func (c *localCache) Set(k, v string, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.data) >= c.max { // 真实实现用 LRU;这里演示「必须限容量」
for key := range c.data {
delete(c.data, key)
break
}
}
c.data[k] = entry{val: v, expire: time.Now().Add(ttl)}
}
func (c *localCache) Del(k string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, k)
}
// ===== 两级缓存 =====
type call struct {
wg sync.WaitGroup
val string
}
type cache struct {
l1 *localCache
l2 *redisLike
load func(k string) string // 回源(DB)
mtx sync.Mutex
inflight map[string]*call
hit1, hit2, hitDB, merged int64
}
func newCache(l1 *localCache, l2 *redisLike, load func(string) string) *cache {
return &cache{l1: l1, l2: l2, load: load, inflight: map[string]*call{}}
}
func (c *cache) Get(k string) (string, string) {
if v, ok := c.l1.Get(k); ok {
atomic.AddInt64(&c.hit1, 1)
return v, "L1"
}
if v, ok := c.l2.Get(k); ok {
atomic.AddInt64(&c.hit2, 1)
c.l1.Set(k, v, 500*time.Millisecond)
return v, "L2"
}
// 击穿保护:同一个 key 只允许一个请求回源,其余等它的结果
c.mtx.Lock()
if cl, ok := c.inflight[k]; ok {
c.mtx.Unlock()
atomic.AddInt64(&c.merged, 1)
cl.wg.Wait()
return cl.val, "singleflight"
}
cl := &call{}
cl.wg.Add(1)
c.inflight[k] = cl
c.mtx.Unlock()
v := c.load(k) // 只有这里会打到 DB
cl.val = v
cl.wg.Done()
c.mtx.Lock()
delete(c.inflight, k)
c.mtx.Unlock()
atomic.AddInt64(&c.hitDB, 1)
c.l2.Set(k, v)
c.l1.Set(k, v, 500*time.Millisecond)
return v, "DB"
}
func main() {
var dbLoads int64
load := func(k string) string {
atomic.AddInt64(&dbLoads, 1)
time.Sleep(5 * time.Millisecond) // 模拟 DB 查询
return "value-of-" + k
}
c := newCache(newLocal(1000), newRedis(), load)
// ① 第一次读:L1 miss → L2 miss → DB,并回填两级
v, src := c.Get("item:1")
fmt.Printf("① 首次读取:值 = %s,来源 = %s\n", v, src)
// ② 再读 99 次:全部命中 L1
for i := 0; i < 99; i++ {
c.Get("item:1")
}
fmt.Printf("② 再读 99 次:L1 命中 %d 次\n", c.hit1)
// ③ 模拟「收到失效广播」:只删本实例 L1,L2 还在 -> 读一次就回填
c.l1.Del("item:1")
_, src = c.Get("item:1")
fmt.Printf("③ L1 失效后读取:来源 = %s(L2 命中 %d 次)\n", src, c.hit2)
// ④ L1、L2 同时失效 + 50 个并发请求:只回源 1 次
c.l1.Del("item:2")
c.l2.Del("item:2")
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() { defer wg.Done(); c.Get("item:2") }()
}
wg.Wait()
fmt.Printf("④ 50 并发同时穿透:实际回源 %d 次(被合并 %d 次)\n",
atomic.LoadInt64(&dbLoads)-1, c.merged)
fmt.Printf("统计:L1=%d L2=%d DB=%d\n", c.hit1, c.hit2, c.hitDB)
}
输出:
① 首次读取:值 = value-of-item:1,来源 = DB
② 再读 99 次:L1 命中 99 次
③ L1 失效后读取:来源 = L2(L2 命中 1 次)
④ 50 并发同时穿透:实际回源 1 次(被合并 49 次)
统计:L1=99 L2=1 DB=2
第 ④ 行是关键:没有 singleflight,50 个并发请求会全部打到 DB——这就是缓存击穿 (41.3 缓存穿透 / 击穿 / 雪崩)。有它,DB 只见到 1 次。
失效广播的三种通道,都要配合 TTL
| 通道 | 延迟 | 可靠性 | 说明 |
|---|---|---|---|
| Redis Pub/Sub | 1–10ms | ❌ 不保证送达,实例断线期间的消息直接丢 | 最简单,必须配 TTL |
| MQ 广播(Kafka / RocketMQ) | 10–100ms | ✅ 可重放,但消费有延迟 | 需要消费进度与幂等 |
| 版本号 / 时间戳轮询 | 1–30s | ✅ 最终可达 | 每次读带版本比较,或定时拉取版本表 |
🔑 真实系统的标准答案是 “广播 (快)+ TTL (兜底)”。只靠广播,一次网络抖动就是永久脏数据;只靠 TTL,TTL 内用户能看到不一致。
Redis 缓存实例的基线配置
# /etc/redis/cache.conf —— 纯缓存实例,不承担锁/队列
bind 127.0.0.1 # 多机部署换成私网 IP,别 0.0.0.0 + 公网
protected-mode yes
port 6379
maxmemory 8gb
maxmemory-policy allkeys-lru
appendonly no # 缓存可重建,关持久化
save ""
tcp-keepalive 300
io-threads 4
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
| 参数 | 值 | 依据 | 配错的后果 |
|---|---|---|---|
maxmemory | 物理内存的 50–70% | 留余量给 fork、复制缓冲、内存碎片 | 不设 = 一直涨到 OOM killer |
maxmemory-policy | allkeys-lru | 纯缓存可以丢任何 key | 同实例放锁/队列时会把它们淘汰,锁失效 |
appendonly / save | 关 / 空 | 缓存能回源重建,持久化只会拖慢写 | 把缓存当存储用,重启后拿到过期数据 |
io-threads | 4(≥4 核时) | 只加速网络 I/O,不加速命令执行;读路径还要 io-threads-do-reads yes | 小流量下无收益,还多耗 CPU |
lazyfree-* | 开 | 大 key 淘汰/过期异步释放 | 大 key 过期瞬间主线程卡顿 |
tcp-keepalive | 300s | 及时发现死连接 | 半开连接白占内存 |
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys"
redis-cli --bigkeys # 找大 key,先拆再谈扩容
redis-cli SLOWLOG GET 10
redis-cli LATENCY DOCTOR
命中率 = hits / (hits + misses)。低于 90% 先查 key 设计与 TTL 分布,不要先加内存;evicted_keys 持续增长说明 maxmemory 太小或 TTL 太集中。
两条上线纪律:
CONFIG SET只改内存、不落盘,重启就回退;要持久必须跟一条CONFIG REWRITE(或直接改文件后滚动重启)。生产上最忌讳“查到的配置和文件里的配置不是一回事”。- 缓存故障要能降级:应用走 cache-aside,回源前先限流(singleflight / 信号量)。最差的降级是“缓存挂了就全量打 DB”,那不是降级,是转移故障。
什么时候只用本地、什么时候必须分布式
| 你的情况 | 选择 |
|---|---|
| 配置、字典、枚举、开关(变更 < 1 次/分钟) | 只 L1,TTL 30s,无需广播 |
| 极热少量 key(前 1000 个商品),想省掉 0.5ms | L1 + 广播,L2 可选 |
| 会话、登录态、验证码 | 必须 L2,多实例要共享 |
| 分布式锁、计数器、限流 | 必须 L2(单一事实源,且需原子操作) |
| 权限、库存、余额 | 都不能缓存,或只能缓存「非权威快照」并带版本校验 |
| 单实例服务(无水平扩展) | L1 就够,加 Redis 是多余的复杂度 |
反模式
| 反模式 | 后果 |
|---|---|
| 本地缓存不设容量上限 | 内存持续增长 → OOM 或 GC 停顿飙升 |
| 本地缓存不设 TTL,只靠广播 | 广播丢一次,该实例永久返回旧数据 |
| 把权限 / 库存放本地缓存 | 越权、超卖;且无法追溯 |
| 用本地缓存做分布式锁 | 每个实例各锁各的,等于没锁 |
| 双写 L1 与 L2 不保证顺序 | 旧值覆盖新值,脏数据长期存在 |
| 缓存 key 不带版本 / 租户 | 多租户串数据、发版后新旧结构混读 |
| 只加 L2 不加回源保护 | 大 key 失效瞬间,几百请求同时打 DB |
| 缓存 value 用大对象(1MB+) | 网络与序列化成本超过回源;GC 压力大 |
| 把「缓存命中率」当唯一指标 | 命中率高但值错了,比不缓存更危险 |
自检
Q1 你的服务 20 个实例、每个实例都缓存了商品价格,TTL 10 分钟。运营改价后,有用户投诉 “不同人看到不同价格”,持续了 6 分钟。解释原因并给出两层修法。
▸ 答案:L1 各自持有 10 分钟 TTL 的副本,改价只更新了 DB (可能还更新了 L2),没有失效各实例的 L1;不同用户落到不同 Pod,看到的自然不同,最长脏 10 分钟。修法:① 加失效广播 (写后发 price.del,各实例删 L1),把典型不一致时间从 10 分钟压到 10–100ms;② TTL 降到 1–5s 作兜底,即使广播丢失,最坏也只脏几秒。不要用 “把 TTL 调到 0” 来修——那等于放弃本地缓存。
Q2 单机 QPS 5 万,接口延迟要求 P99 < 2ms,其中 Redis 往返 0.5ms。你已经把 Redis 换成了集群、加了 Pipeline,P99 还是 1.8ms。下一步该动哪里?
▸ 答案:动 L1。在 2ms 的预算里,0.5ms 的网络往返占 25%,而且它是串行的——Pipeline 只能减少多次调用,不能消除单次 RTT。把最热的 1%–5% key 放进进程内缓存,命中率 80% 时平均延迟降到 0.6ms 量级。前提是先确认:这批 key 能容忍秒级陈旧 (价格能、库存不能),且配了 TTL 与失效广播。
Q3 一个 200 万 key 的本地缓存,每个实例占 600MB。团队说 “反正内存够,多缓存点总没坏处”。这个判断哪里错了?
▸ 答案:错在把 “命中率” 当成唯一收益,忽略了三个成本:① GC 成本:Go 的 GC 标记阶段要扫描存活对象,600MB 的小对象会显著拉长 STW 与标记时间,P99 反而变差;② 回源放大:实例越多副本越多,一次失效广播要淘汰 20 份 600MB 中的同一批 key,回源峰值被放大;③ 数据一致性面变大:缓存越多 key,意味着越多数据处在 “可能陈旧” 的状态。正确做法是按 “访问频率 × 陈旧容忍度” 选 key,L1 控制在进程内存的 10%–20%,其余交给 L2。
Q4 把 maxmemory-policy 设成 allkeys-lru,有什么前提?
▸ 答案:前提是实例里只存可丢的缓存。一旦同实例放了分布式锁、队列、计数器这类“语义上不能丢”的 key,allkeys-lru 会把它们当普通缓存淘汰——锁被淘汰就是双持有。要么改用 volatile-lru 并给持久 key 不设 TTL,要么直接拆成两个实例。命中率掉下来时,先看 evicted_keys 与 TTL 分布,再决定加不加内存。
缓存里最重的一类数据是计数:每条 70 字节的 key 开销 vs 自研 12 字节,见 44.22 计数系统。
关联
- 并列:41.3 缓存穿透 / 击穿 / 雪崩
- 下游:44.22 计数系统(缓存里最重的一类数据:每 key 70 字节 vs 自研 12 字节)