Cache Aside 到底怎么保持缓存和数据库一致:从 LabReserve 的资源缓存说起
从 LabReserve 的设备资源缓存出发,解释 Cache Aside 的读写路径、TTL、缓存失效、Redis 故障降级,以及缓存与数据库为什么很难做到强一致。
LabReserve 是我用 Go 实现的实验室设备预约后端。
系统里的设备资源信息,例如:
GPU-01
类型:gpu
状态:active
描述:A100 80GB
主要保存在 MySQL。
但设备详情和设备列表又属于典型的“读多写少”数据。
相比预约记录,这类信息不会频繁变化,却可能被很多用户反复查询。
于是一个很自然的想法是:
把查询结果放进 Redis,下次直接从缓存读取。
LabReserve 也确实这样做了。
但真正开始实现以后,我发现“加个 Redis 缓存”远不只是:
GET
没有
SET
马上就会遇到几个更重要的问题:
数据库更新以后,缓存里的旧数据怎么办?
Redis 挂了以后,整个接口应该跟着失败吗?
为什么缓存还需要 TTL,明明更新时已经主动删除了?
“先更新数据库,再删除缓存”是不是就能保证数据库和缓存完全一致?
这篇文章从 LabReserve 的资源缓存实现出发,说明 Cache Aside 到底解决了什么,以及它没有解决什么。
Redis 在这里不是数据的事实来源
LabReserve 中真正保存设备资源的是 MySQL:
MySQL
└── resources
├── id
├── name
├── type
├── description
└── status
Redis 只是保存一份可以丢失、可以重建的副本。
所以两者的地位并不相同:
MySQL
→ Source of Truth
Redis
→ Cache
这意味着一个很重要的设计原则:
Redis 里有没有这份数据,不应该决定资源本身存不存在。
Redis 全部清空以后,系统最多变慢一些。
只要 MySQL 还在,资源数据就仍然存在。
这也是 Cache Aside 最基本的前提。
LabReserve 的读取路径
查询单个资源时,Service 层会先生成缓存 key:
key := "resource:" + strconv.FormatInt(id, 10)
随后查询 Redis:
if c.Cache != nil && c.Cache.Get(ctx, key, &v) {
return v, nil
}
如果命中,直接返回。
如果没有命中,再查询 MySQL:
v, err := c.Store.Resource(ctx, id)
数据库查询成功以后,再写回 Redis:
if err == nil && c.Cache != nil {
c.Cache.Set(ctx, key, v)
}
整个过程可以表示成:
flowchart TD
Request["GET /resources/{id}"] --> Redis["查询 Redis"]
Redis -->|Hit| ReturnCache["直接返回缓存"]
Redis -->|Miss| MySQL["查询 MySQL"]
MySQL --> ReturnDB["返回数据库结果"]
MySQL --> SetCache["写入 Redis"]
这就是 Cache Aside 最典型的读取方式:
应用
↓
先查缓存
↓ miss
再查数据库
↓
应用自己回填缓存
数据库本身并不知道 Redis 的存在。
Redis 也不会自动去 MySQL 加载数据。
缓存的读取、回填和失效全部由应用控制。
这也是为什么叫:
Cache Aside
可以理解成:
缓存放在数据库访问路径的“旁边”。
第一次请求为什么一定比较慢
假设 Redis 一开始没有:
labreserve:resource:10
第一次用户访问:
GET /resources/10
会发生:
Redis GET
↓
MISS
↓
MySQL SELECT
↓
Redis SET
↓
返回
第二次访问才变成:
Redis GET
↓
HIT
↓
返回
因此缓存并不是:
第一次查询也能凭空加速。
它加速的是:
后续重复读取。
LabReserve 的测试也会真实执行两次相同资源查询:
第一次 GET
→ 缓存 miss,读取数据库并回填
第二次 GET
→ 缓存 hit
随后检查:
if redis.Hits.Load() == 0 {
t.Fatal("cache not used")
}
也就是说,测试不仅检查:
接口返回 200
还检查缓存路径确实被走到了。
为什么只缓存第一页资源列表
LabReserve 不仅缓存:
resource:{id}
还缓存默认的资源列表第一页:
resources:first
但代码里有一个限制:
cached := after == 0 && limit == 20 && c.Cache != nil
只有:
after = 0
limit = 20
也就是默认第一页会进入缓存。
而不是任意:
after=20&limit=20
after=40&limit=50
after=100&limit=10
都创建一个新缓存 key。
这是一个比较小但实际的取舍。
如果所有分页组合都缓存:
resources:after:0:limit:20
resources:after:20:limit:20
resources:after:40:limit:20
resources:after:0:limit:50
...
缓存 key 数量会迅速增加。
更麻烦的是,一旦管理员创建或修改资源:
到底需要删除哪些列表缓存?
第一页只是一个访问频率较高、失效规则又比较简单的场景。
所以当前项目只缓存:
默认第一页
+
单个资源详情
而没有试图“所有能缓存的东西全部缓存”。
这让我更容易理解:
缓存不是越多越好。缓存得越多,需要维护的一致性状态也越多。
数据库更新以后,缓存为什么会变脏
假设:
MySQL:
resource 10 = active
Redis:
resource 10 = active
管理员执行:
PATCH /admin/resources/10
status = disabled
MySQL 已经变成:
disabled
如果什么都不处理,Redis 里仍然是:
active
下一次查询:
GET /resources/10
可能直接命中旧缓存。
客户端看到的就不是数据库当前状态。
这种情况可以简单理解成:
缓存脏了。
于是写路径必须考虑:
数据库更新以后,怎样处理缓存副本?
LabReserve 选择更新数据库之后删除缓存
资源状态更新的逻辑大致是:
err := c.Store.SetResourceStatus(ctx, id, status)
if err == nil && c.Cache != nil {
c.Cache.Invalidate(ctx, id)
}
也就是说顺序是:
更新 MySQL
↓
成功
↓
删除 Redis 缓存
Invalidate 会同时删除:
labreserve:resource:{id}
以及:
labreserve:resources:first
源码中对应:
r.Client.Del(
ctx,
"labreserve:resources:first",
"labreserve:resource:"+strconv.FormatInt(id, 10),
)
为什么列表缓存也要删?
因为资源状态变化后:
单个资源详情
可能变化。
同时:
资源列表
也可能变化。
比如资源从:
active
变成:
disabled
而 MySQL 查询资源列表时本来就只有:
WHERE status = 'active'
因此旧列表缓存也必须一起失效。
为什么通常选择“删除缓存”,而不是“更新缓存”
假设管理员把资源:
active
改成:
disabled
一种方案是:
UPDATE MySQL
↓
UPDATE Redis
另一种方案是:
UPDATE MySQL
↓
DELETE Redis
LabReserve 选择第二种。
因为 Redis 只是数据库数据的副本。
删除以后:
下一次读取
↓
cache miss
↓
重新查询 MySQL
↓
重新生成正确缓存
也就是说:
不主动维护缓存里的新值,而是直接宣布旧副本失效。
这能少维护一份状态。
尤其一个数据库更新可能影响多个缓存:
resource:10
resources:first
其他可能的查询缓存
如果每个都主动重新计算并更新,就需要保证所有缓存副本都正确同步。
而删除的语义简单得多:
我不确定你以后应该是什么值
但我确定你现在这个旧值不能再用了
因此 Cache Aside 常见的写路径就是:
更新数据库
↓
删除缓存
为什么不是先删除缓存,再更新数据库
假设顺序反过来:
DELETE Redis
↓
UPDATE MySQL
考虑两个并发请求。
管理员 A:
删除缓存
此时数据库还没有更新。
用户 B 恰好查询:
Redis MISS
↓
读取 MySQL 旧值
↓
把旧值写回 Redis
随后管理员 A 才:
UPDATE MySQL
最后就变成:
MySQL = 新值
Redis = 旧值
而且缓存已经被重新写回。
可以表示为:
sequenceDiagram
participant W as 写请求
participant R as 读请求
participant C as Redis
participant DB as MySQL
W->>C: DELETE
R->>C: GET → MISS
R->>DB: SELECT → 旧值
R->>C: SET 旧值
W->>DB: UPDATE 新值
Note over C,DB: Redis 旧,MySQL 新
所以相比:
先删缓存
再更新数据库
LabReserve 使用:
先更新数据库
再删除缓存
能够避免这个很明显的窗口。
但这里还有一个更重要的边界:
“先更新数据库,再删除缓存”也不等于强一致。
更新数据库再删除缓存,仍然可能出现旧值
考虑一个稍微不同的并发过程。
一开始 Redis 没有缓存。
用户 B 开始读:
Redis MISS
然后去 MySQL 查询旧数据:
status = active
但 B 还没有来得及把结果写回 Redis。
与此同时管理员 A:
UPDATE MySQL
status = disabled
然后:
DELETE Redis
因为缓存此时本来就不存在,所以删除成功也没有实际内容。
接下来 B 才执行:
SET Redis = active
于是最终:
MySQL = disabled
Redis = active
过程是:
sequenceDiagram
participant R as 读请求
participant W as 写请求
participant C as Redis
participant DB as MySQL
R->>C: GET → MISS
R->>DB: SELECT → active
W->>DB: UPDATE → disabled
W->>C: DELETE
R->>C: SET → active
Note over C,DB: Redis 被回填成旧值
这说明:
Cache Aside 的“更新数据库 + 删除缓存”主要是在降低不一致概率,并不是一个数据库与缓存之间的原子事务。
MySQL 和 Redis 是两个独立系统。
项目没有实现:
MySQL UPDATE
+
Redis DELETE
之间的分布式原子提交。
因此不能声称:
缓存和数据库永远强一致。
这也是我觉得缓存面试里特别容易说得过头的一点。
TTL 是第二道兜底
LabReserve 写缓存时:
r.Client.Set(
ctx,
"labreserve:"+key,
b,
30*time.Second,
)
也就是说缓存 TTL 是:
30 秒
即使某次失效没有成功,或者出现刚才那种并发回填旧值的情况,缓存也不会永久存在。
最多经过 TTL:
旧缓存
↓
30 秒
↓
自动过期
↓
下一次读取 MySQL
因此 TTL 的意义不只是:
防止 Redis 占太多内存。
在这里它也是一致性的最后兜底之一。
主动失效负责:
正常情况下尽快删除旧数据
TTL 负责:
即使主动失效没有达到预期
旧数据也不能无限期存在
所以两者的关系更像:
主动 Invalidate
→ 第一层
TTL
→ 第二层
而不是二选一。
30 秒 TTL 是不是越短越好
如果 TTL 改成:
1 秒
旧数据存在的时间确实更短。
但缓存命中率也会下降。
大量请求可能变成:
Redis MISS
↓
MySQL
如果 TTL 改成:
24 小时
命中率可能很好。
但某次失效失败以后,旧数据可能存在很久。
所以 TTL 本身是一种取舍:
更短 TTL
→ 更新更快被重新读取
→ 更多数据库查询
更长 TTL
→ 更高缓存命中
→ 潜在陈旧时间更长
LabReserve 当前使用 30 秒,是一个比较保守的短 TTL。
但它不是通过性能测试计算出来的“最优值”。
如果以后真正上线,应该根据:
数据修改频率
访问频率
允许的陈旧时间
MySQL 压力
缓存命中率
再决定。
不能因为代码里写了:
30 * time.Second
就说“30 秒是最佳缓存时间”。
Redis 挂了以后,查询接口应该怎么办
LabReserve 的缓存读取还有一个我比较在意的设计:
func (r *Redis) Get(
ctx context.Context,
key string,
out any,
) bool
它返回的是:
true
→ 命中有效缓存
false
→ 当成 miss
如果 Redis 返回:
redis.Nil
说明 key 不存在。
这是正常 miss。
但如果 Redis 本身连接失败:
connection refused
timeout
代码也不会让资源查询直接失败。
它会:
r.failure(err)
记录错误,然后返回:
false
上层看到的就是:
cache miss
于是继续:
查询 MySQL
完整路径变成:
Redis 故障
↓
Get 返回 miss
↓
MySQL 查询
↓
正常返回用户
这意味着:
资源缓存故障时,系统选择降级为直接访问数据库。
Redis 在这里是性能组件,不是正确性依赖。
这是因为即使没有 Redis:
MySQL 仍然知道资源是什么
所以没有必要因为一个缓存系统临时不可用,就把整个设备查询接口一起打挂。
为什么 Redis 操作还需要很短的超时
如果 Redis 挂了,但是每次连接都等待:
5 秒
那“降级到 MySQL”其实没有多少意义。
因为每一个请求都会先:
卡 Redis 5 秒
然后才:
查询 MySQL
整个接口延迟仍然会非常高。
LabReserve 创建 Redis Client 时设置了:
DialTimeout: 300 * time.Millisecond,
ReadTimeout: 300 * time.Millisecond,
WriteTimeout: 300 * time.Millisecond,
MaxRetries: 0,
每次缓存操作外面又额外包了一层:
ctx, cancel :=
context.WithTimeout(
ctx,
350*time.Millisecond,
)
因此缓存路径的设计更接近:
Redis 很快
→ 用 Redis
Redis 不可用或明显变慢
→ 尽快放弃
→ 回 MySQL
这比单纯说:
Redis 挂了就查数据库。
多了一层很重要的现实问题:
你必须先及时确认 Redis 不可用。
否则故障缓存本身就可能成为接口延迟的来源。
为什么这里关闭了 Redis 自动重试
配置中还有:
MaxRetries: 0
也就是缓存请求失败以后,不在 Redis Client 内部自动重复尝试多次。
这和前面的短超时属于同一个思路:
缓存不是事实来源,所以没必要为了“必须拿到缓存”长时间阻塞业务请求。
如果:
GET Redis
失败
资源查询还有另一条完全正确的路径:
SELECT MySQL
因此相比:
不断重试 Redis
当前项目更愿意:
快速失败
→ 数据库回源
当然,这不是说:
所有 Redis 使用场景都应该 MaxRetries=0。
例如 LabReserve 的认证限流也使用 Redis。
那个功能的语义和缓存完全不同。
缓存失败:
可以查 MySQL
认证限流失败:
如果直接放行
可能绕过安全限制
所以代码里认证限流失败时并不会静默允许请求。
这也说明:
“Redis 挂了怎么办”没有统一答案,要看 Redis 在这个功能里承担什么职责。
Redis 里的坏数据怎么办
缓存还有一种失败不是:
Redis 连不上
而是:
Redis 能访问
但 value 本身无法解析
LabReserve 会把结构体序列化为 JSON:
b, err := json.Marshal(value)
读取时:
b, err := r.Client.Get(...).Bytes()
if err == nil &&
json.Unmarshal(b, out) == nil {
return true
}
如果 Redis 中意外出现:
bad JSON
json.Unmarshal 会失败。
这时候缓存不会被当作有效结果返回给用户,而是:
当成 miss
↓
重新查询 MySQL
测试里也专门写入了:
bad JSON
随后验证:
corrupt cache
不能被接受为 hit
这依然符合前面的原则:
Redis 的内容可以丢,可以坏,可以重新生成。
真正不能被缓存污染的是:
业务事实
缓存写失败为什么也不让主请求失败
数据库查询成功以后会:
c.Cache.Set(ctx, key, v)
但是 Set 本身没有把错误继续返回给业务层。
如果写 Redis 失败:
记录错误
但 MySQL 已经给出了正确数据。
因此当前请求仍然可以正常返回。
因为:
MySQL 查询成功
Redis SET 失败
带来的后果只是:
下一次还要再查一次 MySQL
而不是:
这次返回的数据是错的
所以缓存写失败是一个:
性能问题
而不是:
当前请求正确性问题
这里同样体现出缓存和数据库地位的区别。
但缓存失效失败比缓存写失败更麻烦
Invalidate 当前也是:
if err := r.Client.Del(...).Err(); err != nil {
r.failure(err)
}
也就是说:
MySQL 更新成功
Redis DELETE 失败
并不会回滚数据库。
这样做有很实际的理由。
如果反过来:
数据库已经 Commit
Redis 删除失败
↓
接口返回“更新失败”
客户端可能以为操作没有成功,然后再次重试。
但数据库实际上已经改变。
而且 Redis 删除失败也无法简单通过回滚 MySQL 修复,因为两个系统不存在同一个本地事务。
所以当前实现选择:
数据库更新是主结果
缓存失效失败记录错误
依靠 TTL 最终过期
代价就是:
Redis 故障期间可能短暂读到旧缓存。
这是当前实现明确存在的可用性与一致性取舍。
Cache Aside 到底是什么一致性
因此如果面试官问:
你用了 Cache Aside,是不是就保证缓存和数据库一致了?
我不会回答:
是。
更准确的说法是:
LabReserve 以 MySQL 为事实来源,读取时先查 Redis,miss 后查询 MySQL 并回填;更新资源时先提交 MySQL,再删除对应详情和第一页列表缓存,同时设置 30 秒 TTL 作为兜底。
这能让大部分更新很快使旧缓存失效,但 MySQL 和 Redis 之间没有分布式事务,因此不能保证强一致。例如并发 cache miss 可能在数据库更新和缓存删除之后重新回填旧值,失效失败也可能留下旧缓存。当前项目通过短 TTL 限制这种陈旧状态的持续时间,所以更接近最终一致,而不是强一致。
我觉得这里:
最终一致
比:
保证一致
重要得多。
那怎样进一步降低旧值回填的问题
如果业务对一致性要求更高,可以继续增加机制。
例如一种常见思路是:
更新数据库
↓
删除缓存
↓
延迟一段时间
↓
再次删除缓存
也就是常说的延迟双删。
它试图把这种情况:
并发读拿到旧值
↓
写请求第一次删除
↓
并发读又把旧值写回来
通过第二次删除清理掉。
还可以通过:
消息队列
binlog / CDC
版本号
更严格的写入协调
进一步同步缓存。
但这些方案都会增加复杂度。
而且即使看到“延迟双删”这个名字,也不能机械地认为:
加上就彻底一致了。
比如:
延迟多久?
第二次删除失败怎么办?
消息会不会丢?
多个服务实例如何协调?
都会继续产生新的问题。
所以 LabReserve 当前没有加入这些机制。
对于一个实验室预约项目里的设备描述缓存:
30 秒以内短暂陈旧
和:
引入一整套更复杂的一致性基础设施
之间,需要权衡实际收益。
如果缓存的是:
账户余额
库存扣减
支付状态
这个选择可能完全不同。
这也是为什么预约容量本身没有放到 Redis 缓存里做事实判断。
为什么预约名额不从这个缓存里读
这是整个设计里很重要的边界。
LabReserve 会缓存:
资源描述
资源列表
但预约时判断:
当前 booked 数量
仍然直接使用 MySQL。
不会:
Redis GET remaining
↓
认为还有名额
↓
直接预约
因为设备描述短暂陈旧:
可能只是用户晚几十秒看到最新状态
但预约容量如果陈旧:
可能直接产生超卖
两种数据对一致性的要求根本不同。
因此:
适合缓存
不能只看:
这个查询是不是很频繁。
还要看:
如果读到旧值,会发生什么?
这也是我在这个项目里觉得非常有用的一条判断标准。
缓存穿透、击穿、雪崩和这个项目有什么关系
Redis 面试里经常会一起问三个词:
缓存穿透
缓存击穿
缓存雪崩
我更愿意把它们放回实际请求里理解,而不是只背定义。
缓存穿透
假设有人不断请求:
/resources/999999999
Redis 中没有。
MySQL 中也没有。
因为 LabReserve 当前不会缓存:
“不存在”
所以每次都会:
Redis MISS
↓
MySQL 查询
↓
404
如果大量恶意请求都访问不存在的 ID,就可能反复穿过缓存打到数据库。
这就是这个项目中可能出现的缓存穿透场景。
常见的进一步方案包括:
缓存空值
Bloom Filter
请求限流
但当前 LabReserve 没有实现这些机制,因此不能说项目已经解决了缓存穿透。
缓存击穿
假设某个特别热门资源:
resource:1
TTL 到期瞬间,大量请求同时进来。
它们都可能看到:
MISS
随后一起查询 MySQL。
这就是热点 key 失效时可能产生的缓存击穿。
更复杂的系统可能使用:
singleflight
互斥重建
逻辑过期
避免大量请求同时回源。
LabReserve 当前也没有实现这一层。
缓存雪崩
如果大量 key:
在接近同一时间过期
或者:
Redis 整体不可用
大量请求都会直接回到 MySQL。
如果请求规模足够大,数据库可能突然承受远高于平时的压力。
LabReserve 当前缓存 TTL 都是固定:
30 秒
没有加入随机抖动。
在当前项目规模里问题不明显,但如果缓存 key 数量和流量扩大,这是一个需要进一步考虑的点。
所以对这三个问题,我不会说:
项目都解决了。
更准确的是:
当前设计中它们可能在哪里出现
以及系统规模扩大以后应该从哪里继续处理
怎样验证缓存逻辑
LabReserve 的缓存单元测试覆盖了几个基础行为。
第一次查询必须 miss
if r.Get(ctx, "resource:1", &value) {
t.Fatal("expected miss")
}
Set 以后必须能够命中
r.Set(
ctx,
"resource:1",
map[string]int{"id": 1},
)
再次读取:
HIT
而且数据必须正确。
Invalidate 必须同时删除详情和列表
测试先创建:
resource:1
resources:first
再:
r.Invalidate(ctx, 1)
最终两个 key 都不能存在。
TTL 必须真正生效
测试通过时间推进:
31 秒
确认原本 30 秒 TTL 的 key 已经不能继续命中。
损坏缓存不能作为正确数据
Redis 被手动写入:
bad JSON
随后:
Get
必须表现成 miss,而不是返回损坏结果。
Redis 故障必须退化为 miss
测试关闭 Redis 后再执行:
Get
不能得到成功缓存。
同时:
Errors
计数必须增加。
另外 HTTP 集成测试还会真实请求同一个资源两次,并验证:
redis.Hits > 0
这比只测试:
Set 和 Get 函数能不能调用
更接近真正的业务路径。
监控缓存不能只看“Redis 活着没有”
LabReserve 还记录:
Hits
Misses
Errors
分别对应:
命中次数
未命中次数
Redis 操作错误次数
为什么这些数据有意义?
假设 Redis:
PING = PONG
只能说明 Redis 当前还能响应。
但如果:
Hits = 0
Misses 很高
可能说明:
缓存根本没有起作用
如果:
Errors 持续增加
说明:
业务正在频繁降级访问 MySQL
因此判断一个缓存系统是否健康,不能只问:
Redis 进程还在吗?
还需要看:
命中率怎么样?
错误率怎么样?
回源压力怎么样?
接口延迟有没有变化?
当前项目只做了比较基础的计数,但已经比完全看不到缓存行为更容易定位问题。
面试时我会怎样回答
如果面试官问:
你的项目里 Redis 缓存是怎么做的?
我会回答:
LabReserve 对读多写少的设备资源使用 Cache Aside,MySQL 是事实来源。读取单个资源时先查 Redis,miss 后查询 MySQL,再把结果以 30 秒 TTL 回填 Redis;默认第一页资源列表也会缓存,但没有对所有分页组合都做缓存。
资源创建或状态更新成功以后,先提交 MySQL,再删除对应资源详情和第一页列表缓存。Redis 读取或者缓存写入失败时不会让资源查询一起失败,而是快速降级到 MySQL,因此缓存操作用了大约 300 毫秒的短超时并关闭自动重试。
这套方案不是强一致。比如并发 cache miss 仍然可能在更新完成后把旧值重新写回 Redis,失效请求本身也可能失败,所以项目还设置 30 秒 TTL 限制旧数据存在时间。对于设备元数据可以接受这种最终一致,但真正的预约容量判断仍然直接基于 MySQL 事务和行锁,不依赖缓存。
如果继续问:
为什么更新数据库以后删除缓存,而不是更新缓存?
我会回答:
因为缓存只是数据库结果的可重建副本。删除的状态更简单,下一次读取可以重新从 MySQL 构造正确值。如果主动更新多个缓存副本,应用就需要额外保证这些副本全部同步成功。
如果继续问:
Redis 挂了怎么办?
我会补一句:
要看 Redis 在具体功能里承担什么角色。资源缓存挂了可以回源 MySQL,所以选择 fail open 到数据库;但认证限流依赖 Redis 保证安全边界,失败时不能简单放行。不能笼统地说“Redis 挂了都降级”。
我从这个问题中理解到的事情
最开始接触缓存时,我更容易把它理解成:
Redis 比 MySQL 快
所以先查 Redis
但真正把缓存放进业务以后,我觉得更重要的其实不是:
怎么 GET
怎么 SET
而是三个问题。
第一个:
谁才是事实来源?
在 LabReserve 中答案是:
MySQL
所以 Redis 丢失以后可以重建。
第二个:
如果数据库改变了,旧副本什么时候失效?
当前项目使用:
数据库成功更新
↓
删除缓存
↓
30 秒 TTL 兜底
第三个:
缓存出错以后,业务还能不能继续?
对于资源读取:
可以
所以:
Redis failure
↓
cache miss
↓
MySQL fallback
最终我会把 LabReserve 当前的缓存关系概括成:
MySQL
→ 保存业务事实
Redis
→ 保存可丢失的读取副本
Cache Aside
→ 应用控制读取、回填和失效
Invalidate
→ 正常更新后尽快清理旧值
TTL
→ 限制异常情况下的陈旧时间
短超时
→ Redis 故障时快速降级
Hits / Misses / Errors
→ 观察缓存是否真正发挥作用
而其中最重要的边界是:
缓存不是为了把所有读取都搬进 Redis,而是在业务允许的陈旧程度内,用额外的一份状态换取更低的读取成本。
一旦加入缓存,系统其实没有变简单。
原来只有:
MySQL
一份状态。
现在变成:
MySQL
+
Redis
两份可能暂时不同的数据。
所以缓存设计真正需要考虑的,从来不只是“怎么让查询更快”。
还包括:
什么数据值得缓存、旧值能存在多久、更新以后怎样失效、Redis 故障怎样降级,以及哪些数据根本不应该依赖缓存来保证正确性。
这也是我在 LabReserve 的 Cache Aside 实现里真正理解到的东西。
延伸阅读
- Redis Documentation:Caching
- Redis Documentation:Expiration
- Redis Documentation:Key eviction
- Go Redis Client:go-redis