前言
所谓缓存,实际上就是用空间换时间,准确地说是用更高速的空间来换时间,从而整体上提升读的性能。但引入缓存后必然带来数据一致性的挑战, 因为数据同时存放在多个空间中,比如数据同时存放在MySQL和Redis中(后文的缓存无特别说明均指Redis缓存),MySQL和Redis之间是没有事务保证的,所以我们无法确保写入数据库成功后,写入 Redis 也是一定成功的;即便 Redis 写入能成功,在数据库写入成功后到 Redis 写入成功前的这段时间里,Redis 数据也肯定是和 MySQL 不一致的。
当然可以使用分布式事务等各种手段去维持强一致,但这样会使得系统的整体性能大服务下降,甚至比不用缓存还慢,这样就与添加缓存的初衷背道而驰了。实际开发中,只要我们能保证缓存与数据库能达到最终一致,然后将不一致的时间尽可能短,不一致时间在1ms之内的就不会产生太大的影响。
更新缓存的手段
常情况下,我们在处理查询请求的时候,使用缓存的逻辑如下:
data = queryDataRedis(key);
if (data ==null) {
data = queryDataMySQL(key); //缓存查询不到,从MySQL做查询
if (data!=null) {
updateRedis(key, data);//查询完数据后更新MySQL最新数据到Redis
}
}
也就是说优先查询缓存,查询不到才查询数据库。如果这时候数据库查到数据了,就将缓存的数据进行更新。这是我们常说的 cache aside 的策略,也是最常用的策略。
这样的逻辑是正确的,而一致性的问题一般不来源于此,而是出现在处理写请求的时候。所以我们简化成最简单的写请求的逻辑,此时你可能会面临多个选择,究竟是直接更新缓存,还是失效缓存?而无论是更新缓存还是失效缓存,都可以选择在更新数据库之前,还是之后操作。
这样就演变出 4 个策略:更新数据库后更新缓存、更新数据库前更新缓存、更新数据库后删除缓存、更新数据库前删除缓存。
更新数据库后更新缓存的不一致问题
假如这里有一个计数器,把数据库自减 1,原始数据库数据是 100,同时有两个写请求申请计数减一,假设线程 A 先减数据库成功,线程 B 后减数据库成功。那么这时候数据库的值是 98,缓存里正确的值应该也要是 98。
但是特殊场景下,你可能会遇到这样的情况:
- 线程 A 和线程 B 同时更新这个数据
- 更新数据库的顺序是先 A 后 B
- 更新缓存时顺序是先 B 后 A
如果我们的代码逻辑还是更新数据库后立刻更新缓存的数据,那么就可能出现:数据库的值是 100->99->98,但是缓存的数据却是 100->98->99,也就是数据库与缓存的不一致。而且这个不一致只能等到下一次数据库更新或者缓存失效才可能修复。
时间 | 线程 A(写请求) | 线程 B(写请求) | 问题 |
---|---|---|---|
T1 | 更新数据库为 99 | ||
T2 | 更新数据库为 98 | ||
T3 | 更新缓存数据为 98 | ||
T4 | 更新缓存数据为 99 | 此时缓存的值被显式更新为 99,但是实际上数据库的值已经是 98,数据不一致 |
更新数据库前更新缓存的不一致问题
假如采用先更新缓存,再更新数据库的方式,如果数据库更新失败了,那么缓存中的数据就变成错误数据了。另外,再并发场景中,你可能遇到以下情况:
- 线程 A 和线程 B 同时更新同这个数据
- 更新缓存的顺序是先 A 后 B
- 更新数据库的顺序是先 B 后 A
举个例子。线程 A 希望把计数器置为 0,线程 B 希望置为 1。而按照以上场景,缓存确实被设置为 1,但数据库却被设置为 0。
时间 | 线程 A(写请求) | 线程 B(写请求) | 问题 |
---|---|---|---|
T1 | 更新缓存为 0 | ||
T2 | 更新缓存为 1 | ||
T3 | 更新数据库为 1 | ||
T4 | 更新数据库数据为 0 | 此时缓存的值被显式更新为 1,但是实际上数据库的值是 0,数据不一致 |
所以通常情况下,更新缓存再更新数据库是我们应该避免使用的一种手段。
更新数据库前删除缓存的问题
那如果采取删除缓存的策略呢?也就是说我们在更新数据库的时候失效对应的缓存,让缓存在下次触发读请求时进行更新,是否会更好呢?然而,这种处理在读写并发的场景下却存在着隐患。
例如现在缓存的数据是 100,数据库也是 100,这时候需要对此计数减 1,减成功后,数据库应该是 99。如果这之后触发读请求,缓存如果有效的话,里面应该也要被更新为 99 才是正确的。
那么思考下这样的请求情况:
- 线程 A 更新这个数据的同时,线程 B 读取这个数据
- 线程 A 成功删除了缓存里的老数据,这时候线程 B 查询数据发现缓存失效
- 线程 A 更新数据库成功
时间 | 线程 A(写请求) | 线程 B(读请求) | 问题 |
---|---|---|---|
T1 | 删除缓存值 | ||
T2 | 1.读取缓存数据,缓存缺失,从数据库读取数据 100 | ||
T3 | 更新数据库中的数据 X 的值为 99 | ||
T4 | 将数据 100 的值写入缓存 | 此时缓存的值被显式更新为 100,但是实际上数据库的值已经是 99 了 |
可以看到,在读写并发的场景下,一样会有不一致的问题。
更新数据库后删除缓存
那如果我们把更新数据库放在删除缓存之前呢,问题是否解决?我们继续从读写并发的场景看下去,有没有类似的问题。
时间 | 线程 A(写请求) | 线程 B(读请求) | 线程 C(读请求) | 潜在问题 |
---|---|---|---|---|
T1 | 更新主库 X = 99(原值 X = 100) | |||
T2 | 读取数据,查询到缓存还有数据,返回 100 | 线程 C 实际上读取到了和数据库不一致的数据 | ||
T3 | 删除缓存 | |||
T4 | 查询缓存,缓存缺失,查询数据库得到当前值 99 | |||
T5 | 将 99 写入缓存 |
采取先更新数据库再删除缓存的策略是没有问题的,仅在更新数据库成功到缓存删除之间的时间差内——[T2,T3)的窗口 ,可能会被别的线程读取到老值。
在更新数据库后删除缓存这个场景下,不一致窗口仅仅是 T2 到 T3 的时间,内网状态下通常不过 1ms,在大部分业务场景下我们都可以忽略不计。因为大部分情况下一个用户的请求很难能再 1ms 内快速发起第二次。
但是真实场景下,还是会有一个情况存在不一致的可能性,这个场景是读线程发现缓存不存在,于是读写并发时,读线程回写进去老值。并发情况如下:
时间 | 线程 A(写请求) | 线程 B(读请求–缓存不存在场景) | 潜在问题 |
---|---|---|---|
T1 | |||
T2 | 更新主库 X = 99(原值 X = 100) | ||
T3 | 删除缓存 | ||
T4 | 将 100 写入缓存 | 此时缓存的值被显式更新为 100,但是实际上数据库的值已经是 99 了 |
总的来说,这个不一致场景出现条件非常严格,因为并发量很大时,缓存不太可能不存在;如果并发很大,而缓存真的不存在,那么很可能是这时的写场景很多,因为写场景会删除缓存。
总结四种更新策略
终上所述,我们对比了四个更新缓存的手段,做一个总结对比,其中应对方案也提供参考,具体不做展开,如下表:
策略 | 并发场景 | 潜在问题 | 应对方案 |
---|---|---|---|
更新数据库+更新缓存 | 写+读 | 线程 A 未更新完缓存之前,线程 B 的读请求会短暂读到旧值 | 可以忽略 |
写+写 | 更新数据库的顺序是先 A 后 B,但更新缓存时顺序是先 B 后 A,数据库和缓存数据不一致 | 分布式锁(操作重) | |
更新缓存+更新数据库 | 无并发 | 线程 A 还未更新完缓存但是更新数据库可能失败 | 利用 MQ 确认数据库更新成功(较复杂) |
写+写 | 更新缓存的顺序是先 A 后 B,但更新数据库时顺序是先 B 后 A | 分布式锁(操作很重) | |
删除缓存值+更新数据库 | 写+读 | 写请求的线程 A 删除了缓存在更新数据库之前,这时候读请求线程 B 到来,因为缓存缺失,则把当前数据读取出来放到缓存,而后线程 A 更新成功了数据库 | 延迟双删(但是延迟的时间不好估计,且延迟的过程中依旧有不一致的时间窗口) |
更新数据库+删除缓存值 | 写+读(缓存命中) | 线程 A 完成数据库更新成功后,尚未删除缓存,线程 B 有并发读请求会读到旧的脏数据 | 可以忽略 |
写+读(缓存不命中) | 读请求不命中缓存,写请求处理完之后读请求才回写缓存,此时缓存不一致 | 分布式锁(操作重) |
做个简单总结:
- 针对大部分读多写少场景,建议选择更新数据库后删除缓存的策略。
- 针对读写相当或者写多读少的场景,建议选择更新数据库后更新缓存的策略。
最终一致性如何保证
缓存设置过期时间
当我们无法确定 MySQL 更新完成后,缓存的更新/删除一定能成功,例如 Redis 挂了导致写入失败了,或者当时网络出现故障,或者服务当时刚好发生重启了,没有执行这一步的代码。这些时候 MySQL 的数据就无法刷到 Redis 了。为了避免这种不一致性永久存在,使用缓存的时候,我们必须要给缓存设置一个过期时间,例如 1 分钟,这样即使出现了更新 Redis 失败的极端场景,不一致的时间窗口最多也只是 1 分钟。
如何减少缓存删除/更新的失败?
我们把删除 Redis 的请求以消费 MQ 消息的手段去失效对应的 Key 值,如果 Redis 真的存在异常导致无法删除成功,我们依旧可以依靠 MQ 的重试机制来让最终 Redis 对应的 Key 失效。
如何处理复杂的多缓存场景?
有些时候,真实的缓存场景并不是数据库中的一个记录对应一个 Key 这么简单,有可能一个数据库记录的更新会牵扯到多个 Key 的更新。还有另外一个场景是,更新不同的数据库的记录时可能需要更新同一个 Key 值,这常见于一些 App 首页数据的缓存。
针对这个场景,把更新缓存的操作以 MQ 消息的方式发送出去,由不同的系统或者专门的一个系统进行订阅,而做聚合的操作。如下图:
通过订阅 MySQL binlog 的方式处理缓存
上面讲到的 MQ 处理方式需要业务代码里面显式地发送 MQ 消息。还有一种优雅的方式便是订阅 MySQL 的 binlog,监听数据的真实变化情况以处理相关的缓存。
目前业界类似的产品有 Canal,具体的操作图如下:
利用 Canel 订阅数据库 binlog 变更从而发出 MQ 消息,让一个专门消费者服务维护所有相关 Key 的缓存操作
参考资料:
缓存数据一致性探究
万字图文讲透数据库缓存一致性问题