Redis 缓存更新一致性
Redis 缓存更新的五种常见策略及优缺点分析
当执行写操作后,需要保证从缓存读取到的数据与数据库中持久化的数据是一致的,因此需要对缓存进行更新。
因为涉及到数据库和缓存两步操作,难以保证更新的原子性。所以在设计更新策略时,我们需要考虑多个方面的问题:
- 对系统吞吐量的影响:比如更新缓存策略产生的数据库负载小于删除缓存策略的负载
- 并发安全性:并发读写时某些异常操作顺序可能造成数据不一致,如缓存中长期保存过时数据
- 更新失败的影响:若某个操作失败,如何对业务影响降到最小
- 检测和修复故障的难度: 操作失败导致的错误会在日志留下详细的记录容易检测和修复。并发问题导致的数据错误没有明显的痕迹难以发现,且在流量高峰期更容易产生并发错误导致业务风险较大。
更新缓存有两种方式:
- 删除失效缓存: 删除旧缓存后,读取时会因为未命中缓存而从数据库中读取新的数据并更新到缓存中
- 更新缓存: 直接将新的数据写入缓存覆盖过期数据
更新缓存和更新数据库有两种顺序:
- 先数据库后缓存
- 先缓存后数据库
两两组合共有四种更新策略,现在我们逐一进行分析。
并发问题通常由于后开始的线程却先完成操作导致,我们把这种现象称为“抢跑”。 下面我们逐一分析四种策略中“抢跑”带来的错误。
先更新数据库,再删除缓存
若数据库更新成功,删除缓存操作失败,则此后读到的都是缓存中过期的数据,造成不一致问题。
可能存在读写线程竞争导致的并发错误:
时间 | 线程A | 线程B | 数据库 | 缓存 |
---|---|---|---|---|
1 | 缓存失效 | v1 | null | |
2 | 从数据库读取v1 | v1 | null | |
3 | 更新数据库 | v2 | null | |
4 | 删除缓存 | v2 | null | |
5 | 写入缓存 | v2 | v1 |
先更新数据库,再更新缓存
同删除缓存策略一样,若数据库更新成功缓存更新失败则会造成数据不一致问题。
该策略同样存在读写线程竞争导致数据不一致的问题:
时间 | 线程A | 线程B | 数据库 | 缓存 |
---|---|---|---|---|
1 | 缓存失效 | v1 | null | |
2 | 从数据库读取v1 | v1 | null | |
3 | 更新数据库 | v2 | null | |
4 | 写入缓存 | v2 | v2 | |
5 | 写入缓存 | v2 | v1 |
也可能因为两个写线程竞争导致并发错误:
时间 | 线程A | 线程B | 数据库 | 缓存 |
---|---|---|---|---|
0 | v0 | v0 | ||
1 | 更新数据库为 v1 | v1 | v0 | |
2 | 更新数据库为 v2 | v2 | v0 | |
3 | 更新缓存为 v2 | v2 | v2 | |
4 | 更新缓存为 v1 | v2 | v1 |
我们可以在写入缓存前先比较数据的版本号或者修改时间,禁止向缓存中写入更旧的版本。
时间 | 线程A | 线程B | 数据库 | 缓存 |
---|---|---|---|---|
0 | v0 | v0 | ||
1 | 更新数据库为 v1 | v1 | v0 | |
2 | 更新数据库为 v2 | v2 | v0 | |
3 | 更新缓存为 v2 | v2 | v2 | |
4 | 尝试向缓存中写入 v1,发现版本号低于缓存中的版本(v2),放弃写入 | v2 | v2 |
由上图可见,更新缓存前比较版本号可以有效的避免并发错误的发生。
先删除缓存,再更新数据库
可能发生的并发错误:
时间 | 线程A | 线程B | 数据库 | 缓存 |
---|---|---|---|---|
1 | 删除缓存 | v1 | null | |
2 | 缓存失效 | v1 | null | |
3 | 从数据库读取v1 | v1 | null | |
4 | 更新数据库为v2 | v2 | null | |
5 | 将v1写入缓存 | v2 | v1 |
先更新缓存,再更新数据库
若缓存更新成功数据库更新失败, 则此后读到的都是未持久化的数据。因为缓存中的数据是易失的,这种状态非常危险。
因为数据库中存在的键约束导致数据库写入失败的可能性较高,所以发生上述错误的概率会进一步升高。
该策略同样存在读写线程竞争导致的错误:
时间 | 线程A | 线程B | 数据库 | 缓存 |
---|---|---|---|---|
1 | 缓存失效 | v1 | null | |
2 | 从数据库读取v1 | v1 | null | |
3 | 更新缓存 | v1 | v2 | |
4 | 写入数据库 | v2 | v2 | |
5 | 写入缓存 | v2 | v1 |
两个写线程竞争也会导致数据不一致:
时间 | 线程A | 线程B | 数据库 | 缓存 |
---|---|---|---|---|
0 | v0 | v0 | ||
1 | 更新缓存为 v1 | v0 | v1 | |
2 | 更新缓存为 v2 | v0 | v2 | |
3 | 更新数据库为 v2 | v2 | v2 | |
4 | 更新数据库为 v1 | v1 | v2 |
异步更新
双写更新的逻辑复杂,一致性问题较多。我们可以采用订阅数据库更新的方式来更新缓存。
阿里开源了 MySQL 数据库binlog的增量订阅和消费组件 – canal。 canal 模拟从库获得主库的 binlog 更新,然后将更新数据写入 MQ 或直接进行消费。
我们可以让API服务器只负责写入数据库,另一个线程订阅数据库 binlog 增量进行缓存更新。
因为 binlog 是有序的,因此可以避免两个写线程竞争。但我们仍然需要解决读写线程竞争的问题:
时间 | 读线程 | 写线程 | 异步线程 | 数据库 | 缓存 |
---|---|---|---|---|---|
1 | 缓存失效 | v1 | null | ||
2 | 从数据库读取v1 | v1 | null | ||
3 | 更新数据库为v2 | v2 | null | ||
4 | 删除缓存/更新缓存 | v2 | null | ||
5 | 写入缓存 | v2 | v1 |
与双写策略类似,只需要在写入缓存前比较一下版本号即可:
时间 | 读线程 | 写线程 | 异步线程 | 数据库 | 缓存 |
---|---|---|---|---|---|
1 | 缓存失效 | v1 | null | ||
2 | 从数据库读取v1 | v1 | null | ||
3 | 更新数据库为v2 | v2 | null | ||
4 | 更新缓存 | v2 | v2 | ||
5 | 尝试更新缓存为v1,因版本号过低放弃更新 | v2 | v2 |