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 计数系统。

关联