那如果我们把更新数据库放在删除缓存之前呢,问题是否解决?我们继续从读写并发的场景看下去,有没有类似的问题。
时间 | 线程 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 | 查询缓存,缓存缺失,查询数据库得到当前值 100 | ||
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 有并发读请求会读到旧的脏数据 | 可以忽略 |
写 + 读(缓存不命中) | 读请求不命中缓存,写请求处理完之后读请求才回写缓存,此时缓存不一致 | 分布式锁(操作重) |
从一致性的角度来看,采取更新数据库后删除缓存值,是更为适合的策略。因为出现不一致的场景的条件更为苛刻,概率相比其他方案更低。
那么是否更新缓存这个策略就一无是处呢?不是的!
删除缓存值意味着对应的 key 会失效,那么这时候读请求都会打到数据库。如果这个数据的写操作非常频繁,就会导致缓存的作用变得非常小。而如果这时候某些 Key 还是非常大的热 key,就可能因为扛不住数据量而导致系统不可用。