缓存的合理使用确提升了系统的吞吐量和稳定性,然而这是有代价的,这个代价便是缓存和数据库的一致性带来了挑战。
新增数据时,数据直接写入数据库,缓存中不存在对应记录。首次查询请求会触发缓存回填,即从数据库读取新数据并写入缓存,后续请求可直接命中缓存,所以新增数据时不会有数据不一致性问题。
当一条数据同时存在数据库、缓存,现在你要更新此数据,你会怎么更新?先更新数据库?还是先更新缓存?
有以下四种更新方式:
- 先更新数据库后更新缓存
- 先更新缓存后更新数据库
- 先更新数据库后删除缓存
- 先删除缓存后更新数据
接下来会逐一分析这四种更新方式带来的数据不一致性问题并解决。
先更新数据库后更新缓存
一种常见的操作是,设置一个过期时间,让写请求以数据库为准,过期后,读请求同步数据库中的最新数据给缓存。那么在加入了过期时间后,是否就不会有问题了呢?并不是这样。
时间 | 线程A(写请求) | 线程B(写请求) | 问题 |
---|---|---|---|
T1 | 更新数据库为99 | ||
T2 | 更新数据库为88 | ||
T3 | 更新缓存为88 | ||
T4 | 更新缓存为99 | ||
T5 | 此时缓存的值为99,但是实际上数据库的值为88,数据不一致 |
数据不一致产生的场景:
- 并发写冲突:就是上面表格中的情况,线程A和线程B同时更新同一数据,线程A先完成数据库的更新,线程B然后完成数据库和缓存更新,最后线程A更新缓存,会导致缓存最终存储线程A的旧数据。
- 缓存更新失败:若数据库更新成功但缓存更新失败,后续请求会读取到旧缓存数据,直至缓存过期。
先更新缓存后更新数据库
先更新数据库后更新缓存会导致数据不一致,那你可能会想,这是否表示,我应该先让缓存更新,之后再去更新数据库呢?
时间 | 线程A(写请求) | 线程B(写请求) | 问题 |
---|---|---|---|
T1 | 更新缓存为99 | ||
T2 | 更新缓存为88 | ||
T3 | 更新数据库为88 | ||
T4 | 更新数据库为99 | ||
T5 | 此时缓存的值为88,但是实际上数据库的值为99,数据不一致 |
数据不一致产生的场景:
- 并发写冲突:就是上面表格中的情况,线程A和线程B同时更新同一数据,线程A先完成缓存的更新,线程B然后完成缓存和数据库更新,最后线程A更新数据库,会导致缓存最终存储线程B的旧数据。
- 缓存成功但数据库失败:缓存存储新数据,数据库仍为旧值,后续请求直接命中缓存错误数据。
- 主从延迟问题:若数据库为主从架构,缓存更新后从库未同步完成,读请求可能从从库获取旧数据并覆盖缓存。
先删除缓存后更新数据库
既然更新数据库前后更新缓存都会导致数据不一致,那如果采取删除缓存的策略呢?也就是说我们在更新数据库的时候失效对应的缓存,让缓存在下次触发读请求时进行更新,是否会更好呢?
时间 | 线程A(写请求) | 线程B(读请求) | 问题 |
---|---|---|---|
T1 | 删除缓存 | ||
T2 | 从数据库中读取值为100,设置缓存中的值为100 | ||
T3 | 更新数据库为99 | ||
T4 | 此时缓存的值为100,但是实际上数据库的值为99,数据不一致 |
数据不一致产生的场景:
- 读写并发冲突:就是上面表格中的情况,线程A先删除缓存,线程B然后从数据库读取旧的值并更新缓存,最后线程A更新数据库,会导致缓存最终存储数据库的旧数据。
针对这种场景,有个做法是所谓的“延迟双删策略”,就是说,既然可能因为读请求把一个旧的值又写回去,那么我在写请求处理完之后,等到差不多的时间延迟再重新删除这个缓存值。
时间 | 线程A(写请求) | 线程B(读请求) | 线程C(读请求) | 问题 |
---|---|---|---|---|
T1 | 删除缓存 | |||
T2 | 从数据库中读取值为100,设置缓存中的值为100 | 读到脏数据 | ||
T3 | 更新数据库为99 | 读到脏数据 | ||
T4 | sleep(N) | 读到脏数据 | ||
T5 | 删除缓存 | |||
T6 | 从数据库中读取值为99,设置缓存中的值为99 |
这种解决思路的关键在于对N的时间的判断,如果N时间太短,线程A第二次删除缓存的时间依旧早于线程B把脏数据写回缓存的时间,那么相当于做了无用功。而N如果设置得太长,那么在触发双删之前,新请求看到的都是脏数据。
先更新数据库后删除缓存
那如果我们把更新数据库放在删除缓存之前呢,问题是否解决?我们继续从读写并发的场景看下去,有没有类似的问题。
时间 | 线程A(写请求) | 线程B(读请求) | 线程C(读请求) | 问题 |
---|---|---|---|---|
T1 | 更新数据库为99 | |||
T2 | 从数据库中读取值为100,设置缓存中的值为100 | 读到脏数据 | ||
T3 | 删除缓存 | |||
T4 | 从数据库中读取值为99,设置缓存中的值为99 |
可以看到,大体上,采取先更新数据库再删除缓存的策略是没有问题的,仅在更新数据库成功到缓存删除之间的时间差内——[T2,T3)的窗口,可能会被别的线程读取到老值,但是这个时间窗口非常的短
但是真实场景下,还是会有一个情况存在不一致的可能性,这个场景是读线程发现缓存不存在,于是读写并发时,读线程回写进去老值。并发情况如下:
时间 | 线程A(写请求) | 线程B(读请求) | 问题 |
---|---|---|---|
T1 | 查询缓存,缓存缺失,查询数据库得到当前值100 | ||
T2 | 更新数据库为99 | ||
T3 | 删除缓存 | ||
T4 | 将100写入缓存 | ||
T5 | 此时缓存的值为100,但是实际上数据库的值为99,数据不一致 |
总的来说,这个不一致场景出现条件非常严格,因为并发量很大时,缓存不太可能不存在;如果并发很大,而缓存真的不存在,那么很可能是这时的写场景很多,因为写场景会删除缓存。
数据不一致性的根本原因
为什么我们几乎没办法做到缓存和数据库之间的强一致呢?
理想情况下,我们需要在数据库更新完后把对应的最新数据同步到缓存中,以便在读请求的时候能读到新的数据而不是旧的数据(脏数据)。但是很可惜,由于数据库和Redis之间是没有事务保证的,更新数据库和更新(删除)缓存不是一个原子操作,所以我们无法确保写入数据库成功后,写入Redis也是一定成功的;即便Redis写入能成功,在数据库写入成功后到Redis写入成功前的这段时间里,Redis数据也肯定是和MySQL不一致的。
所以说这个时间窗口是没办法完全消灭的,除非我们付出极大的代价,使用分布式事务等各种手段去维持强一致,但是这样会使得系统的整体性能大幅度下降,甚至比不用缓存还慢,这样不就与我们使用缓存的目标背道而驰了吗?
不过虽然无法做到强一致,但是我们能做到的是缓存与数据库达到最终一致,而且不一致的时间窗口我们能做到尽可能短,要是数据库更新完毕后,删除缓存失败了咋办?
对于这种情况,一种常见的解决方案就是使用消息中间件来实现删除的重试。大家知道,MQ一般都自带消费失败重试的机制,当我们要删除缓存的时候,就往MQ中扔一条消息,缓存服务读取该消息并尝试删除缓存,删除失败了就会自动重试。
异步延迟双删
延迟双删:先执行缓存清除操作,再执行数据库更新操作,延迟N秒之后再执行一次缓存清除操作,这样就不用担心缓存中的数据和数据库中的数据不一致了。
那么这个延迟N秒,N是多大比较合适呢?一般来说,N要大于一次写操作的时间,如果延迟时间小于写入缓存的时间,会导致请求A 已经延迟清除了缓存,但是此时请求B缓存还未写入,具体是多少,就要结合自己的业务来统计这个数值了。
通过订阅MySQL binlog的方式处理缓存
上面讲到的MQ处理方式需要业务代码里面显式地发送MQ消息。还有一种优雅的方式便是订阅MySQL的binlog,监听数据的真实变化情况以处理相关的缓存。
目前业界类似的产品有Canal,具体的操作图如下: