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 队列设上限与背压(超过阈值就退化为同步写,或拒绝写入),并监控“刷盘延迟”。