41.1 缓存模式 (旁路 / 读穿 / 写穿 / 写回)

一句话

四种缓存模式的差别只有两点:真相在谁手里(应用还是缓存层)与谁来维护它(应用代码、缓存库、还是异步刷盘)——旁路与读穿只是“谁写加载逻辑”,写穿与写回才是真正的取舍:写延迟 vs 丢数据窗口。

四种模式,两条轴

模式 读路径 写路径 谁维护缓存 一致性窗口 崩溃丢数据
Cache-Aside(旁路) 应用查缓存;miss 则查库并回填 应用写库,删除缓存 应用代码 删除前的旧缓存 不丢
Read-Through(读穿) 缓存库自己 miss 后加载 同旁路 缓存库(加载器) 同旁路 不丢
Write-Through(写穿) 同读穿 应用写库 + 同步写缓存 缓存库 几乎为 0 不丢
Write-Behind(写回) 同读穿 只写缓存,异步批量刷库 缓存库 刷盘间隔内缓存领先于库 会丢(未刷盘部分)

🔑 选型的两个问题:① 写完是否立刻要读到自己写的值(要 → 写穿/写回;不要 → 旁路)?② 能否接受进程崩溃丢最近 N 毫秒的写(不能 → 写回直接出局)?

代码:同一份数据,四种写法的延迟差多少

package main

import (
	"fmt"
	"sort"
	"sync"
	"time"
)

const (
	dbLatency    = time.Millisecond      // 数据库单次操作
	cacheLatency = 100 * time.Microsecond // 缓存单次操作
)

// ---------- 被缓存的“真相”:数据库 ----------
type DB struct {
	mu sync.Mutex
	m  map[string]string
}

func (d *DB) Get(k string) string {
	time.Sleep(dbLatency)
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.m[k]
}

func (d *DB) Set(k, v string) {
	time.Sleep(dbLatency)
	d.mu.Lock()
	defer d.mu.Unlock()
	d.m[k] = v
}

// ---------- 缓存 ----------
type Cache struct {
	mu    sync.Mutex
	m     map[string]string
	dirty map[string]bool
}

func newCache() *Cache { return &Cache{m: map[string]string{}, dirty: map[string]bool{}} }

func (c *Cache) Get(k string) (string, bool) {
	time.Sleep(cacheLatency)
	c.mu.Lock()
	defer c.mu.Unlock()
	v, ok := c.m[k]
	return v, ok
}

func (c *Cache) Set(k, v string) {
	time.Sleep(cacheLatency)
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[k] = v
}

func (c *Cache) Del(k string) {
	time.Sleep(cacheLatency)
	c.mu.Lock()
	defer c.mu.Unlock()
	delete(c.m, k)
}

// ---------- 四种写模式(读路径略,差别只在 miss 时谁来加载) ----------
type Store struct {
	db    *DB
	cache *Cache
}

// 旁路:写库 + 删缓存(下一节讲顺序)
func (s *Store) writeAside(k, v string) {
	s.db.Set(k, v)
	s.cache.Del(k)
}

// 读穿:miss 时由缓存层加载(这里用函数模拟“缓存库内部的加载器”)
func (s *Store) readThrough(k string) string {
	if v, ok := s.cache.Get(k); ok {
		return v
	}
	v := s.db.Get(k)
	s.cache.Set(k, v)
	return v
}

// 写穿:写库 + 同步写缓存
func (s *Store) writeThrough(k, v string) {
	s.db.Set(k, v)
	s.cache.Set(k, v)
}

// 写回:只写缓存,后台每 50ms 批量刷库
func (s *Store) writeBehind(k, v string) {
	s.cache.Set(k, v)
	s.cache.mu.Lock()
	s.cache.dirty[k] = true
	s.cache.mu.Unlock()
}

func (s *Store) flusher(stop <-chan struct{}) {
	t := time.NewTicker(50 * time.Millisecond)
	defer t.Stop()
	for {
		select {
		case <-t.C:
			s.cache.mu.Lock()
			batch := map[string]string{}
			for k := range s.cache.dirty {
				batch[k] = s.cache.m[k]
				delete(s.cache.dirty, k)
			}
			s.cache.mu.Unlock()
			for k, v := range batch {
				s.db.Set(k, v)
			}
		case <-stop:
			return
		}
	}
}

func p50(ds []time.Duration) time.Duration {
	sort.Slice(ds, func(i, j int) bool { return ds[i] < ds[j] })
	return ds[len(ds)/2]
}

func main() {
	const n = 100
	measure := func(name string, write func(int)) {
		ds := make([]time.Duration, 0, n)
		for i := 0; i < n; i++ {
			t0 := time.Now()
			write(i)
			ds = append(ds, time.Since(t0))
		}
		fmt.Printf("%-26s 写 p50 = %v\n", name, p50(ds).Round(10*time.Microsecond))
	}

	s1 := &Store{db: &DB{m: map[string]string{}}, cache: newCache()}
	measure("Cache-Aside(写库+删缓存)", func(i int) { s1.writeAside(fmt.Sprint(i), "v") })

	s2 := &Store{db: &DB{m: map[string]string{}}, cache: newCache()}
	measure("Write-Through(写库+写缓存)", func(i int) { s2.writeThrough(fmt.Sprint(i), "v") })

	s3 := &Store{db: &DB{m: map[string]string{}}, cache: newCache()}
	stop := make(chan struct{})
	go s3.flusher(stop)
	measure("Write-Behind(只写缓存)", func(i int) { s3.writeBehind(fmt.Sprint(i), "v") })

	// 读穿:第一次 miss 要访问库,第二次命中
	s4 := &Store{db: &DB{m: map[string]string{"k": "v"}}, cache: newCache()}
	t0 := time.Now()
	s4.readThrough("k")
	miss := time.Since(t0)
	t0 = time.Now()
	s4.readThrough("k")
	hit := time.Since(t0)
	fmt.Printf("\nRead-Through 首次读(miss) = %v,再次读(hit) = %v\n",
		miss.Round(10*time.Microsecond), hit.Round(10*time.Microsecond))

	// 收尾:确认写回模式下未刷盘的数据确实只在缓存里
	s3.cache.mu.Lock()
	pending := len(s3.cache.dirty)
	s3.cache.mu.Unlock()
	fmt.Printf("Write-Behind 在刷盘间隔内未落库的 key 数:%d(此时崩溃即丢)\n", pending)
	close(stop)
}

本机实测(macOS,Apple M1,Go 1.27):

Cache-Aside(写库+删缓存)        写 p50 = 1.28ms
Write-Through(写库+写缓存)      写 p50 = 1.28ms
Write-Behind(只写缓存)         写 p50 = 130µs

Read-Through 首次读(miss) = 1.4ms,再次读(hit) = 130µs
Write-Behind 在刷盘间隔内未落库的 key 数:100(此时崩溃即丢)

读数:

模式 写延迟 说明
Cache-Aside / Write-Through ≈ DB 延迟 + 缓存延迟 写操作永远等数据库,缓存只是陪跑
Write-Behind ≈ 缓存延迟 写路径不碰库,延迟降一个数量级
Read-Through 首次读 ≈ DB + 缓存 读穿不改变延迟,只改变“加载逻辑写在哪”

🔑 写回是唯一能把数据库从写路径上摘掉的模式,也是唯一会丢数据的模式。它适合“可重放/可重算”的数据(计数器、点赞数、会话、指标聚合),不适合订单、账务、库存这类“丢了就赔钱”的数据。

四种模式的真实差异:不是延迟,是责任

模式 代码里要写什么 出问题时查什么
旁路 读:查缓存→miss 查库→回填;写:写库→删缓存 回填逻辑是否散落多处、删除是否失败
读穿 只写一个 loader 函数 缓存库配置、loader 异常处理
写穿 写库与写缓存两步 两步之间失败(库成功、缓存失败)怎么办
写回 只写缓存 + 刷盘策略 刷盘失败、进程崩溃、重复刷盘

⚠️ 写穿的一个隐藏问题:“写库成功、写缓存失败” 时,缓存里是旧值,且不会自动修复(除非有 TTL)。所以写穿也必须配 TTL 或后续的失效兜底——它降低了一致性窗口,但没有消除“缓存可能落后”这件事。

反模式

反模式 后果
用写回存订单/账务 进程崩溃丢最近一批写,且无法从缓存恢复
旁路模式里“写库 + 更新缓存”而不是删除 并发写导致缓存永久旧值(41.4 会算这个窗口)
读穿/写穿的 loader 里抛异常不处理 一次脏数据被缓存住,直到 TTL
缓存 key 没有 TTL,只靠删除 删除失败 = 永久脏数据
写回模式不限制 dirty 集合大小 刷盘跟不上写入,内存爆掉
把“缓存命中率”当作唯一指标 命中率高但值是旧的,比不缓存更糟

自检

Q1 一个点赞计数器(每秒数千次自增,允许最终一致)该用哪种模式?换成订单状态呢?

▸ 答案:计数器用 Write-Behind:写路径只碰缓存(延迟低、抗峰值),异步批量刷库;即使崩溃丢几十毫秒的增量,也可以用“缓存里的当前值 + 定期快照”恢复,且点赞数不是资金。订单状态必须用 Cache-Aside + 写库后删缓存:数据库是唯一真相,崩溃不能丢写;缓存的角色只是“加速读”,任何时候丢缓存都不影响正确性。

Q2 读穿(Read-Through)与旁路(Cache-Aside)在读路径上做的事几乎一样,为什么还要分两种?

▸ 答案:差别在加载逻辑的所有权。旁路把“miss 后查库并回填”写在每个调用方(或一个公共函数)里,容易出现“有的地方回填、有的地方忘了”;读穿把 loader 注册进缓存库,miss 由缓存层统一调用 loader,所有调用方都只调 Get。代价是 loader 必须能表达“数据不存在”(否则穿透),且 loader 的异常语义由缓存库决定。团队小、语言单一时旁路更简单;多语言、想统一行为时读穿更可控。

Q3 写穿(Write-Through)能保证“写完立刻读到新值”吗?

▸ 答案:只保证本进程/本缓存实例。如果缓存是分布式的、且写只写了其中一个节点(或本地缓存没同步),其它节点仍可能是旧值;如果“写库成功、写缓存失败”,缓存就是旧值。所以写穿的真实保证是“降低不一致窗口”,不是“消除”。要消除必须让读也走同一份缓存(单实例)或引入版本号校验。

Q4 为什么写回模式必须限制 dirty 集合的大小?

▸ 答案:因为写回把“数据库的压力”换成了“内存里的待刷队列”。如果写入速率持续高于刷盘速率,dirty 集合会无界增长:先是内存上涨,然后触发淘汰(可能把还没刷盘的 key 淘汰掉 → 直接丢数据),最后 OOM。正确做法是给 dirty 队列设上限与背压(超过阈值就退化为同步写,或拒绝写入),并监控“刷盘延迟”。

关联