返回文章列表

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 到底解决了什么,以及它没有解决什么。

项目地址:theHerta27/lab-reserve

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