前言
缓存一致性的问题无法在客观上完全解决。因为无法保证数据库和缓存的操作在一个事务里。我们能做到的只是缩短不一致的时间窗口。
以下是四种处理缓存一致性的方案的总结:
策略 | 并发场景 | 潜在问题 | 应对方案 |
更新数据库+更新缓存 | 写+读 | 线程 A 未更新完缓存之前,线程 B 的读请求会短暂读到旧值 | 可以忽略 |
更新缓存+更新数据库 | 无并发 | 线程 A 还未更新完缓存但是更新数据库可能失败 | 利用 MQ 确认数据库更新成功(较复杂) |
删除缓存+更新数据库 | 写+读 | 写请求的线程 A 删除了缓存在更新数据库之前,这时候读请求线程 B 到来,因为缓存缺失,则把当前数据读取出来放到缓存,而后线程 A 更新成功了数据库 | 延迟双删(但是延迟的时间不好估计,且延迟的过程中依旧有不一致的时间窗口) |
更新数据库+删除缓存 | 写+读(缓存命中) | 线程 A 完成数据库更新成功后,尚未删除缓存,线程 B 有并发读请求会读到旧的脏数据 | 可以忽略 |
从一致性的角度来看,采取先更新数据库再删除缓存的策略是最为合适的。因为出现不一致的场景的条件更为苛刻,概率相比其它方案更低。
但是删除策略频繁的缓存失效导致读请求无法利用缓存,导致读请求都会打到数据库。如果这个数据的写操作非常频繁,就会导致缓存的作用变得非常小。而如果这时候某些key还是非常大的热点key,就有可能扛不住数据量而导致系统不可用。
总结:
- 针对大部分读多写少的场景,建议选择先更新数据库再删除缓存的策略。
- 针对读写相当或者写多读少的场景,建议选择先更新数据库再更新缓存的策略。
方案一、先更新数据库再更新缓存
updateMysql(); // 先更新数据库
updateRedis(key, data); // 再更新缓存
特殊场景下,可能会遇到如下这种情况:
1、线程A、线程B 同时更新一条数据。
2、更新数据库的顺序是先 A 后 B。
3、更新缓存的顺序是先 B 后 A。
时间 | 线程A(写请求) | 线程B(写请求) | 问题 |
T1 | 更新数据库为99 | ||
T2 | 更新数据库为98 | ||
T3 | 更新缓存为98 | ||
T4 | 更新缓存为99 | 数据库与缓存的数据不一致 |
这个不一致只能等到下次更新数据库(连带着更新缓存)或者缓存失效(如果对缓存数据设置了过期时间的话),才有可能修复。
此外,如果更新Redis失败(此时Redis存储着的是脏数据),也会造成数据库与缓存的数据不一致。
方案二、先更新缓存再更新数据库
updateRedis(key, data); // 先更新缓存
updateMysql(); // 再更新数据库
特殊场景下,可能会遇到如下这种情况:
1、线程A、线程B 同时更新一条数据。
2、更新缓存的顺序是先 A 后 B。
3、更新数据库的顺序是先 B 后 A。
时间 | 线程A(写请求) | 线程B(写请求) | 问题 |
T1 | 更新缓存为0 | ||
T2 | 更新缓存为1 | ||
T3 | 更新数据库为1 | ||
T4 | 更新数据库为0 | 数据库与缓存的数据不一致 |
此外,如果更新数据库失败,Redis缓存的数据就是错误数据。遇到这种情况,依赖Redis进行数据回滚,也是非常不靠谱的。
方案三、先删除缓存再更新数据库
deleteRedis(key); // 先删除缓存
updateMySQL(); // 再更新数据库
在并发两个写请求中,无论怎么样的执行顺序,缓存最后的值都是被删除的,也就是说在并发写写的请求中这样的处理是没有问题的。
但是在并发读写请求中,可能会遇到如下特殊情况:
1、线程A先删除缓存。
2、线程B读取缓存,发现缓存未命中,于是从数据库读取数据。
3、线程A接着更新数据库。
4、线程B将从数据库读取到的数据同步到缓存中。
时间 | 线程A(写请求) | 线程B(读请求) | 问题 |
T1 | 删除缓存 | ||
T2 | 读取缓存,发现未命中,于是读取数据库为100 | ||
T3 | 更新数据库为99 | ||
T4 | 更新缓存为100 | 数据库与缓存的数据不一致 |
针对这种场景,可以采用“延迟双删”的策略。既然读请求会把旧值写回到缓存中,因此可以在数据库写请求处理完之后,等到差不多的时间再次删除缓存。
时间 | 线程A(写请求) | 线程C(新的读请求) | 线程D(新的读请求) | 问题 |
T5 | sleep(N) | 缓存命中,读取缓存旧值100 | 其它线程可能在延迟双删成功前读到脏数据 | |
T6 | 删除缓存 | |||
T7 | 缓存未命中,从数据库读取新值100 |
“延迟双删”的方案关键在于时间N的判断,如果太短,线程A第二次删除缓存早于线程B将脏数据写回到缓存的时间,那么相当于做了无用功;如果太长,则在延迟双删成功前,新请求从缓存中读到的是脏数据。
方案四、先更新数据库再删除缓存
时间 | 线程A(写请求) | 线程B(新的读请求) | 线程C(新的读请求) | 问题 |
T1 | 更新数据库为99 | |||
T2 | 读取缓存并命中,返回100 | 数据不一致 | ||
T3 | 删除缓存 | |||
T4 | 读取缓存,发现未命中则读取数据库为99 | |||
T5 | 更新缓存为99 |
在更新数据库与删除缓存之间,可能会被其它线程从缓存中读到旧值。在先更新数据库后删除缓存这个场景下,不一致窗口仅仅是 T2 到 T3 的时间,内网状态下通常不过 1ms,在大部分业务场景下我们都可以忽略不计。因为大部分情况下一个用户的请求很难能再 1ms 内快速发起第二次。
真实场景下,还是会有一个情况存在不一致的可能性,这个场景是读线程发现缓存未命中,于是读写并发时,读线程将数据库读到的旧值更新到缓存中。并发情况如下:
时间 | 线程 A(写请求) | 线程 B(读请求) | 问题 |
T1 | 查询缓存,发现未命中,查询数据库得到当前值 100 | ||
T2 | 更新数据库为99 | ||
T3 | 删除缓存 | ||
T4 | 将 100 写入缓存 | 数据不一致 |
这个不一致场景出现条件非常严格,因为并发量很大时,缓存不太可能不存在;如果并发很大,而缓存真的不存在,那么很可能是这时的写场景很多,因为写场景会删除缓存。
最终一致性如何保证
无法确定MySQL更新完成后,缓存的更新/删除一定成功,例如Redis挂了导致写入失败,或者出现网络故障,最常见的就是服务当时刚好发生重启了,没有执行这一步的代码。
为了避免这种不一致永久存在,使用的缓存的时候加一个过期时间,例如1分钟,这样即使出现了更新Redis失败的极端场景,不一致的时间窗口最多也只是1分钟。
这是最终一致性的兜底方案,万一出现任何情况导致数据不一致,最后都能通过缓存失效后重新查询数据库,然后回写到缓存中,来做到缓存与数据库的最终一致性。
如何减少缓存更新/删除的失败
因为消息中间件有 at least once 的机制,如下图所示:
极端情况下,更新数据库后,MQ消息没有发送成功,或者没机会发送出去机器就重启。
对此,可以借助MQ的事务消息,来让删除/更新缓存的消息最终一定发送出去。
如何处理复杂的多缓存场景
有时候一个数据库记录的更新可能会牵扯多个key的更新,还有更新不同数据库的记录时需要更新同一个key。
以一个数据库记录对应多个key的场景举例。
假如系统设计上我们缓存了一个粉丝的主页信息、主播打赏榜 TOP10 的粉丝、单日 TOP 100 的粉丝等多个信息。如果这个粉丝注销了,或者这个粉丝触发了打赏的行为,上面多个 Key 可能都需要更新。只是一个打赏的记录,你可能就要做:
updateMySQL();//更新数据库一条记录
deleteRedisKey1();//失效主页信息的缓存
updateRedisKey2();//更新打赏榜TOP10
deleteRedisKey3();//更新单日打赏榜TOP100
这就涉及多个 Redis 的操作,每一步都可能失败,影响到后面的更新。甚至从系统设计上,更新数据库可能是单独的一个服务,而这几个不同的 Key 的缓存维护却在不同的 3 个微服务中,这就大大增加了系统的复杂度和提高了缓存操作失败的可能性。最可怕的是,操作更新记录的地方很大概率不只在一个业务逻辑中,而是散发在系统各个零散的位置。
针对这个场景,解决方案和上文提到的保证最终一致性的操作一样,就是把更新缓存的操作以 MQ 消息的方式发送出去,由不同的系统或者专门的一个系统进行订阅,而做聚合的操作。如下图:
不同业务系统订阅 MQ 消息单独维护各自的缓存 Key
专门更新缓存的服务订阅 MQ 消息维护所有相关 Key 的缓存操作。
订阅MySQL binlog的方式处理缓存
上面讲到的 MQ 处理方式需要业务代码里面显式地发送 MQ 消息。还有一种优雅的方式便是订阅 MySQL 的 binlog,监听数据的真实变化情况以处理相关的缓存。
利用 Canel 订阅数据库 binlog 变更从而发出 MQ 消息,让一个专门消费者服务维护所有相关 Key 的缓存操作