Skip to content

缓存模式 ​

同一份商品资料同时存在数据库和缓存里,就有了两个会在不同时刻更新的副本。选缓存模式,实际上是在决定三件事:谁负责加载,写入成功究竟指写到了哪里,以及两个副本暂时不同步时怎么办。

本文把 Cache-Aside、Read-Through、Write-Through、Write-Behind 放在同一张读写图上,再拆解“先更新库再删缓存”“延迟双删”“订阅 binlog”各自能解决什么。读完应能为一条业务链路说明可接受的陈旧程度和失败处理,而不只是背出一个更新顺序。

正文的反例在 Go 1.27.2、Linux/amd64 上实际运行。它们是明确安排事件顺序的内存模型,没有连接 MySQL 或 Redis,用来证明某种交错可以产生旧值,不能代替真实服务的事务、故障与吞吐验证。

先说清“旧到什么程度还能用” ​

缓存是否可用,不能只按“最终一致”四个字判断。商品介绍旧几十秒可能无碍,结算时的价格、库存和权限检查通常需要更严格的校验。这里的例子都是假设场景,具体要求由业务决定。

需要的读写性质它实际要求什么对缓存方案的影响
允许短暂旧值更新后其他读者可以暂时看到旧版本可考虑 TTL 与异步失效,但要量陈旧时长
写后自己立即可见用户成功修改后,自己的下一次读不能倒退可让写入者直读权威库或携带最低版本要求
不允许读到更旧版本同一会话读到 v2 后,不能又见 v1多级缓存与副本切换要检查版本单调性
以当前状态做不可重复的决策扣库存、去重提交等必须受权威约束保护关键判定放在数据库事务、唯一约束或可证明的原子协议中

TTL 是缓存条目的寿命,不是内容年龄的完整上限。例如数据库副本已经落后 20 秒,回源从它读到旧值,再给缓存设置 60 秒 TTL,这份内容就可能旧到 80 秒。若每次后台刷新都从落后源读取并重新计时,陈旧程度还可能继续扩大。因此需要把源数据版本或更新时间与缓存装入时间分开。

四种模式,差别在责任与确认点 ​

本文按下面的约定使用术语。不同产品可能给同名功能附加不同持久化或失败语义,使用具体产品时仍要查其契约。Cache-Aside 模式

模式未命中由谁加载写入路径与确认点好处成本与失败点
Cache-Aside应用自己查库并回填应用写库,通常随后失效缓存接入直接,数据库仍是权威源各写入口都要维护失效;并发回填可能陈旧
Read-Through缓存层或封装后的 loader描述读路径,写路径另选读取逻辑集中,业务少重复代码loader 的超时、去重、错误缓存仍要设计
Write-Through可配合 Read-Through经统一写入口同步完成权威存储写入与缓存更新/失效后再按契约返回写入后的缓存状态更容易统一管理写延迟变长;两步之间失败仍需协议处理
Write-Behind常配合缓存读先确认缓存/写入缓冲,再异步写权威存储可合并高频写、降低前台等待未落库窗口、队列持久性、顺序与重放都由系统承担

Read-Through 和 Write-Through 不是必须成套使用的两件事。前者回答“未命中谁查”,后者回答“写请求经过谁、何时确认”。Redis 的普通 GET 不会自动去 MySQL 找数据;需要应用封装、库的 loader 或专门的数据访问层提供这种行为。

Cache-Aside:最容易接入,也最容易把责任散落到各处 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TD
    Q["应用读取 user:7"] --> C{"缓存命中?"}
    C -->|是| R["返回缓存值"]
    C -->|否| D["读取数据库"]
    D --> F["带 TTL 回填缓存"]
    F --> R
    W["应用更新 user:7"] --> B["数据库事务提交"]
    B --> I["删除/失效 user:7 缓存"]
    I --> A["按定义的成功语义返回"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TD
    Q["应用读取 user:7"] --> C{"缓存命中?"}
    C -->|是| R["返回缓存值"]
    C -->|否| D["读取数据库"]
    D --> F["带 TTL 回填缓存"]
    F --> R
    W["应用更新 user:7"] --> B["数据库事务提交"]
    B --> I["删除/失效 user:7 缓存"]
    I --> A["按定义的成功语义返回"]

回填失败时,刚从数据库查出的值通常仍可返回;数据库查询失败则不能随手缓存成“用户不存在”。读路径要区分不存在、临时错误和取消。写路径上,数据库已经提交但删缓存失败,业务状态已经改变,返回一个笼统错误并让用户重试,可能把非幂等写执行两次。

更实用的做法是把这两件事分别记录:业务写是否成功,缓存修复是否仍待处理。用唯一业务请求号避免重复写,把失效失败放入可靠重试机制,监控积压与最老任务年龄。缓存错误可以被降级,但不能悄悄变成无限量回源。

Read-Through:集中读入口,不自动增加一致性 ​

调用方只调用 Load(key),缓存封装负责查缓存、合并同键并发、加载数据库、回填。它让应用少写重复流程,也方便统一超时和指标。可是如果 loader 读的是落后的副本,或者失效与回填会竞争,换一个函数名并不会使旧值消失。

缓存层还必须定义 loader 错误是否被短期保留、未命中是否返回显式 NotFound、取消一个请求会不会终止所有等待者。把这些契约集中管理,是 Read-Through 的实际价值。

Write-Through:统一入口很有用,两份存储仍需协调 ​

设想统一入口先提交数据库,再写入缓存。若数据库成功、缓存失败,入口必须明确是否返回“业务已成功但缓存降级”,或者先重试缓存并把陈旧读挡住。若先写缓存、数据库失败,则缓存可能对外暴露从未提交的值。仅把两个调用写在一个函数里,不会产生跨系统原子提交。

同步双写还有乱序风险:写者 A 提交 v2 后暂停,写者 B 提交 v3 并更新缓存,A 恢复后再写入 v2,缓存便倒退。需要按键串行化、版本条件写入或适当事务协议,而不是把最后完成的网络请求当成最新业务版本。统一写入口只有在其他写入口都遵守同一协议时才成立。

Write-Behind:确认越早,持久性责任越重 ​

Write-Behind 先接受新状态,后台再批量落库。对“只关心最终值”的频繁更新,可以合并多次覆盖写,减少存储压力。但对每次动作都有意义的扣款、计数事件,直接覆盖中间写会丢掉业务含义。

本地模型在确认版本 2 后,模拟丢失尚未持久化的缓存与缓冲:

text
write-behind: acknowledged=2 durable=1

这不是 Redis 丢数据实测,只是把确认点提前后的责任画清:如果没有可靠的持久缓冲,调用方得到成功时,数据库仍只有版本 1。若采用持久队列,还要定义写队列与确认的先后、重复投递的幂等性、同键事件顺序,以及落库长期失败时如何背压。把内存队列换成磁盘队列只能补其中一部分。

为什么通常先更新库,再删除缓存 ​

先删缓存、后更新库,有一个直接的窗口:读者在两步之间未命中,从尚未更新的数据库读到旧值,又把旧值填回去;接下来数据库更新成功,但缓存仍旧。

所以常用默认流程是数据库事务成功后,再失效缓存。它避免了这个最直接的窗口,也避免“删完缓存但数据库更新最终失败”的不必要抖动。删除而非盲目双写还能减少多个写者把缓存写乱序的问题。微软模式说明

但这个顺序没有消除读者早已开始、后来才回填的情况。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant A as 读者 A
    participant C as 缓存
    participant D as 数据库
    participant B as 写者 B
    A->>C: GET 未命中
    A->>D: SELECT,得到 v1
    Note over A: A 暂停,尚未回填
    B->>D: 提交 v2
    B->>C: DEL 成功
    A->>C: 恢复,将 v1 SET 回缓存
    Note over C,D: 缓存 v1,数据库 v2
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant A as 读者 A
    participant C as 缓存
    participant D as 数据库
    participant B as 写者 B
    A->>C: GET 未命中
    A->>D: SELECT,得到 v1
    Note over A: A 暂停,尚未回填
    B->>D: 提交 v2
    B->>C: DEL 成功
    A->>C: 恢复,将 v1 SET 回缓存
    Note over C,D: 缓存 v1,数据库 v2

这段交错不需要 Redis 出错,也不需要数据库返回错误。只需要读者比写者晚完成回填。文末模型把每一步写成确定顺序:

go
loaded := db            // A 读到 v1
db = Value{2, "new"}    // B 提交 v2,并失效空缓存
cache := loaded         // A 恢复,填回 v1

实际输出:

text
aside: db=2 cache=1

因此,普通 Cache-Aside 适用于容忍一定陈旧的读路径。把默认顺序当成强一致证明,会在长查询、线程暂停、跨地区链路和数据库副本延迟出现时出错。

延迟双删:再清一次可能有用,但延迟不是锁 ​

常见变体会在写入前后删除,或在数据库提交、第一次删除后,再安排一次延迟删除。它试图让中间读到旧值的请求先完成回填,然后由第二次删除扫掉旧缓存。

问题在于,什么时候能确信所有旧读者都结束了?设延迟 500 ms,但某个读者暂停 2 秒,第二次删除已完成,它仍然能在之后写回旧值。延迟长度只能基于观测降低概率,不能覆盖任意进程停顿、网络延迟和查询时长。

它可能改善的情况它单独解决不了的情况
大多数旧读取在已知短窗口内结束比延迟更久的旧读者
第一次失效后发生了短暂陈旧回填第二次失效任务本身丢失或执行失败
少量更新,增加一次删除成本可接受长期读取落后数据库副本
有可靠任务调度与失败告警多级缓存漏失效、写入口漏接入

如果确实使用延迟双删,应把延迟任务写进可靠系统,记录失败并重试,测量真正的陈旧率和最坏可接受窗口。不要用业务进程里一个无监控的 time.Sleep goroutine 代替可靠任务;进程重启时它就消失了。

这里对双删的判断来自上面的时序推演,而非某个产品给出的强一致保证。换一个更长的常数不会改变这个逻辑。

用版本或代次阻止旧回填,补的是哪一个缺口 ​

一种更强的做法是让读者开始回源前记住缓存的代次,失效时递增代次;回填时只有代次没变才接受结果。关键是“比较代次并写入”必须原子执行。

go
func (c *Cache) Fill(g uint64, v Value) bool {
    c.mu.Lock()
    defer c.mu.Unlock()
    if g != c.generation { return false }
    c.value, c.present = v, true
    return true
}

本地模型里,读者拿着旧代次回来时被拒绝:

text
guarded: accepted=false db=2 hit=false next=2

hit=false 表示拒绝后缓存仍空,下一次独立读取重新查到版本 2,再正常回填。这验证了模型中“收到失效后拒绝更早启动的回填”这一性质。大型缓存系统的 lease 思路可见 NSDI 原始论文

将它搬到分布式缓存时,还有几个前提不能删:

  • 代次不能在旧请求仍可能回来时被过早丢弃,否则旧请求可能再次符合初始值,形成 ABA 问题
  • 失效与代次递增必须原子;比较与写入也必须原子,不能拆成两条不受保护的命令
  • 数据库提交到失效通知送达之间,旧缓存仍可能被读到;这个机制没有消除通知延迟
  • 回源若读取落后副本,当前代次也可能带回旧数据;业务版本与缓存代次需要正确关联
  • Redis 故障切换、元数据淘汰、客户端重试,都可能改变本地模型未覆盖的前提

另一种办法是使用数据库业务版本,例如条件更新 incoming.version >= cached.version。只有值存在时才比较仍不够:如果删除把版本信息也删掉,迟到的 v1 看见空缓存后又能写入。需要保留版本水位或使用可证明的失效协议,并明确元数据保留时间和故障恢复方式。

对于只要求“我写完自己立即看到”的页面,一个简单可控的选择是写后直接返回已提交的新值,短时间读取走权威主库,或者把最低可接受版本带进下一次读取。它不能替所有读者提供强一致,但可能准确满足当前产品需求。

订阅 binlog:把失效来源集中到已提交的数据变化 ​

应用逐处删缓存容易漏:除了 API,还有管理后台、脚本、批处理和其他服务可能写数据库。CDC(Change Data Capture)读取数据库变化日志,由统一消费者失效缓存,可以覆盖这些写入口。MySQL 8.4 binary log

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    W["API/后台/批处理"] --> T["数据库事务提交"]
    T --> L["已提交事务的日志事件"]
    L --> C["CDC:快照衔接、偏移与重连"]
    C --> Q["可重放事件通道"]
    Q --> I["按主键失效或版本条件更新"]
    I --> R["Redis / 各层缓存"]
    I --> O["确认消费进度"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    W["API/后台/批处理"] --> T["数据库事务提交"]
    T --> L["已提交事务的日志事件"]
    L --> C["CDC:快照衔接、偏移与重连"]
    C --> Q["可重放事件通道"]
    Q --> I["按主键失效或版本条件更新"]
    I --> R["Redis / 各层缓存"]
    I --> O["确认消费进度"]

图表示已提交事务的事件向下游传播,不表示 InnoDB 提交、redo 和 binlog 写入或刷盘的物理先后;数据库内部提交协议是另一层问题。

优点是数据库事实先产生,缓存派生状态随后更新,写入口不必各自猜需要发什么通知。代价是数据路径变长,增加日志权限、快照初始化、消费状态和积压管理。CDC 仍然是异步的:在日志产生到消费者执行之间,缓存可以陈旧。故障恢复还可能重放事件,不能假定只来一次。Debezium MySQL 连接器

删除事件比盲目覆盖更容易重放,但也不是无成本 ​

重复 DEL 通常不会把值倒退,只会多制造一次未命中。若消费 v3 后又收到 v2,盲目 SET v2 会回退;带业务版本的条件更新能拒绝旧版本。如果消息被拆到多个分区、并行工作线程或重试队列,必须重新确认“同一业务键”的顺序,不能把源日志有序误当成所有下游执行都按原顺序完成。

删除数据尤其要小心:数据库中的删除事件要能映射到对应缓存键,多级缓存都要失效;版本水位若随缓存一起删除,就可能挡不住迟到的旧更新。对查询结果缓存还需要知道哪些列表、聚合结果受这一行改变影响。CDC 告诉你哪行变了,不自动知道所有缓存依赖。

失效消费者需要可观察的失败路径 ​

至少记录消费位点、最后成功时间、最老未处理事件年龄、失败重试次数,以及日志保留能覆盖多长中断。位点应在副作用成功后按协议提交;否则消费者可能“已确认但未失效”。崩溃后重做副作用应当安全。如果日志被清理而位点落后,需要重新快照或清空相关缓存重建,不能继续装作同步正常。

应用自己的 outbox 也是一种选择:业务表更新与“待发布的失效事件”在同一个数据库事务里写入,再由后台发布。它把数据库写成功却通知丢失的问题变成可重试的待办,但不使缓存同步变为零延迟,也需要处理重复发布和消费幂等。采用哪一种,取决于是否需要覆盖其他写入口,以及能否承担 CDC 的运维成本。

把模式写成一份可检查的协议 ​

选方案时,用下面这份清单检查具体业务,不必一开始就引入最复杂的机制。

要回答的问题可以写进设计或测试的明确答案
谁是权威数据源?数据库主库;缓存可重建,关键判定回主库
什么构成缓存键?租户、实体、查询参数、权限范围和数据格式版本
什么算成功?业务事务提交;失效失败有可靠补偿,响应不诱发重复业务写
允许旧多久?具体时长与场景,而非只写“最终一致”
谁使所有缓存失效?每个写入口或已提交日志;包括本地层与查询结果
缓存失效时回源有多大?并发上限、合并同键请求、超时与降级路径
如何发现没同步?数据版本抽样比对、CDC 延迟、失败队列和陈旧年龄
如何证明边界?人为安排迟到回填、乱序、重放、断连和进程重启

普通读多写少、可接受短暂旧值的业务,Cache-Aside 加合理 TTL、可靠失效重试和回源保护往往是一个清楚的起点。如果业务不能承受这种窗口,应改变读写协议或把关键判断留在权威存储,而不是再加几次删除就宣称问题已经解决。

依据与验证范围 ​

  • Azure Cache-Aside:按需加载、写后失效与模式边界;本文对并发交错的分析由可运行模型具体展开。
  • Scaling Memcache at Facebook,NSDI 2013 原始论文:大规模缓存中 stale set、lease 与失效路径的原始设计讨论。本文的本地代次模型不是该系统实现。
  • MySQL 8.4 binary log、Debezium MySQL connector:日志变化、快照、偏移和恢复要求。CDC 的具体配置需按部署版本核对。
  • 实测:Go 1.27.2,Linux/amd64;go run .、go test -count=1 ./...、go test -race -count=1 ./...、go vet ./... 均成功。断言覆盖旧回填反例、代次拒绝与 Write-Behind 易失缓冲的丢失窗口。
  • 未验证:真实数据库事务、Redis Lua 条件写、CDC 端到端恢复、网络分区、持久队列。文中的取舍与补强条件不能被这几个本地测试替代。

完整可运行代码与测试 ​

将三个文件放在空目录。测试有意断言“错误方案产生陈旧值”,因此测试通过说明反例被稳定重现,而不是方案安全。

bash
go version
go run .
go test -count=1 ./...
go test -race -count=1 ./...
go vet ./...

go.mod ​

go
module example.com/cachepatterns

go 1.27.0

main.go ​

代码块 · 68 行 · 点击收起 / 展开
go
package main

import (
	"fmt"
	"sync"
)

type Value struct {
	Version int
	Text    string
}
type Cache struct {
	mu         sync.Mutex
	generation uint64
	value      Value
	present    bool
}

func (c *Cache) Begin() uint64 { c.mu.Lock(); defer c.mu.Unlock(); return c.generation }
func (c *Cache) Invalidate()   { c.mu.Lock(); defer c.mu.Unlock(); c.generation++; c.present = false }
func (c *Cache) Fill(g uint64, v Value) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	if g != c.generation {
		return false
	}
	c.value, c.present = v, true
	return true
}
func (c *Cache) Get() (Value, bool) { c.mu.Lock(); defer c.mu.Unlock(); return c.value, c.present }

func staleRefill() (int, int) {
	db := Value{1, "old"}
	loaded := db         // A misses the cache and reads the old database row.
	db = Value{2, "new"} // B commits and invalidates an already empty cache.
	cache := loaded      // A resumes and fills after B's invalidation.
	return db.Version, cache.Version
}
func guardedRefill() (bool, int, bool, int) {
	db := Value{1, "old"}
	c := &Cache{}
	g := c.Begin()
	loaded := db
	db = Value{2, "new"}
	c.Invalidate()
	accepted := c.Fill(g, loaded)
	_, hit := c.Get()
	// A later independent read obtains current generation and current row.
	c.Fill(c.Begin(), db)
	current, _ := c.Get()
	return accepted, db.Version, hit, current.Version
}
func lostWriteBehind() (int, int) {
	db := Value{1, "old"}
	cache := Value{2, "accepted"} // Acknowledged while durable DB is still old.
	accepted := cache.Version
	cache = Value{} // Simulated process loss; volatile pending write is gone.
	_ = cache
	return accepted, db.Version
}
func main() {
	d, c := staleRefill()
	fmt.Printf("aside: db=%d cache=%d\n", d, c)
	ok, d, hit, current := guardedRefill()
	fmt.Printf("guarded: accepted=%v db=%d hit=%v next=%d\n", ok, d, hit, current)
	ack, d := lostWriteBehind()
	fmt.Printf("write-behind: acknowledged=%d durable=%d\n", ack, d)
}

main_test.go ​

代码块 · 44 行 · 点击收起 / 展开
go
package main

import (
	"sync"
	"testing"
)

func TestStaleRefill(t *testing.T) {
	d, c := staleRefill()
	if d != 2 || c != 1 {
		t.Fatal(d, c)
	}
}
func TestGuardedRefill(t *testing.T) {
	ok, d, hit, next := guardedRefill()
	if ok || d != 2 || hit || next != 2 {
		t.Fatal(ok, d, hit, next)
	}
}
func TestWriteBehindLoss(t *testing.T) {
	ack, d := lostWriteBehind()
	if ack != 2 || d != 1 {
		t.Fatal(ack, d)
	}
}
func TestGenerationRejectsOldFills(t *testing.T) {
	c := &Cache{}
	old := c.Begin()
	c.Invalidate()
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if c.Fill(old, Value{1, "old"}) {
				t.Error("accepted stale generation")
			}
		}()
	}
	wg.Wait()
	if _, ok := c.Get(); ok {
		t.Fatal("stale value present")
	}
}

保留各书来源与许可 · 示例运行条件以正文为准