主题
缓存模式
同一份商品资料同时存在数据库和缓存里,就有了两个会在不同时刻更新的副本。选缓存模式,实际上是在决定三件事:谁负责加载,写入成功究竟指写到了哪里,以及两个副本暂时不同步时怎么办。
本文把 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["按定义的成功语义返回"]
回填失败时,刚从数据库查出的值通常仍可返回;数据库查询失败则不能随手缓存成“用户不存在”。读路径要区分不存在、临时错误和取消。写路径上,数据库已经提交但删缓存失败,业务状态已经改变,返回一个笼统错误并让用户重试,可能把非幂等写执行两次。
更实用的做法是把这两件事分别记录:业务写是否成功,缓存修复是否仍待处理。用唯一业务请求号避免重复写,把失效失败放入可靠重试机制,监控积压与最老任务年龄。缓存错误可以被降级,但不能悄悄变成无限量回源。
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
这段交错不需要 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=2hit=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["确认消费进度"]
图表示已提交事务的事件向下游传播,不表示 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.0main.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")
}
}