前言
对于一些并发较高,且读多写少的场景,为了提高性能,降低DB压力,大家肯定会想到缓存,这也是我们最常见的提高并发能力的手段;还有就是DB的读写分离,将一些对实时性没有这么高的请求,分流到从库,降低主库的压力。 对于大部分的业务场景来说,使用缓存就足够了(可能在业务发展初期,缓存都没有太大必要)。但是随着业务的发展,请求在经过缓存过滤了一层之后,DB主库负载还是很高,那么可能会再上读写分离,来分流请求。那么问题来了,这时候如果你的缓存是使用懒加载的模式,那么就很有可能会遇到缓存一致性的问题。
DB缓存一致性
在正常的场景下,DB缓存如何保证一致性? 一般有两种模式,Cache Aside 和 Cache Through, 我们基本上都是使用Cache Aside模式,即我们的程序直接和DB以及缓存打交道。
Cache Through(Read/Write Through)的方式一般是我们应用程序只和缓存进行交互,缓存再去通过各种方式去持久化数据到硬盘,这种方式正常场景下我们一般不会使用。它更多的是在类似于操作系统或缓存中间件中实现。
先更新缓存还是DB?
首先不管是更新缓存还是失效缓存,肯定是先更新DB再处理缓存,否则没办法保证数据一致性。
场景1
只要DB更新异常,那么缓存中就会保留错误的数据。虽然在更新DB失败时,可以回滚缓存,但是很麻烦,回滚缓存也可能失败。
场景2 并发场景下,会将旧数据写入缓存,而由于缓存操作要比DB操作快很多,一并出现并发,这种情况会频繁出现。
场景2 一般可以使用双删,但根本上也就是将缓存操作置后,保证缓存操作在DB操作之后来解决问题。
缓存失效 + 懒加载
一般场景下,我们会倾向于使用失效缓存的方式,和懒加载配合使用,代码复杂性低,且在大部分异常场景下,也可以保证数据最终一致性。
和先更新缓存再更新DB不同的是,假如我们删除缓存失败,那么事务回滚,DB和缓存的数据还是可以保持一致。
但是这种方式和上面一样,在并发场景下,一样也有小概率出现不一致的问题:
这个问题出现的概率极小,因为在删除缓存和提交事务之间,这个时间窗口是极短的。但是在读写分离的场景下,异常的时间窗口会变得很长,因为主从同步延迟的时间窗口远比上面这种场景要久得多,出现问题的概率也会更大:
它的异常窗口约等于主从同步的延迟
对于个问题,业界也有解决方案,我们也可以和双删一样,在更新完成后开个线程延迟一段时间再次删除缓存,或者投递到消息队列删除,本质上都是延迟删除,保证即使数据不一致,在过一小段时间后,我们也能保证数据的最终一致性。
但是在读写分离场景下(特别是跨机房的主从同步),上层调用方读到数据错误的概率太大了,甚至不需要并发场景,一个单线程的简单的先写后读的操作,几乎必然会读到旧数据。而且异常数据的存在时间取决于缓存过期时间
更好的解决办法?
这时候我会倾向于不再使用失效缓存的方式,而是先更新DB -> 再更新缓存的模式。
5秒的过期时间只是一个栗子,应该根据实际场景设置得比主从同步时间长一些
我们来看几种更新异常情况:
- DB更新异常,此时DB和缓存都不会更新。
- DB更新正常,缓存更新失败,DB回滚,缓存未更新。
- DB更新正常,由于网络问题,缓存更新超时,但是实际上更新成功了,但是超时异常导致DB回滚,此时DB数据是旧的,缓存数据是新的。
- 多个线程并发更新,先更新DB的线程却后更新了缓存,导致DB数据是新数据,缓存数据为旧数据。
对于1、2这种异常情况,由于事务的存在,数据一致性没有问题。
而3、4的异常情况,由于我们将缓存设置了一个极短的过期时间,错误的缓存数据也会快速失效,而由下次请求将正确的数据加载进缓存中。 这种方式在绝大部分的场景下 ,调用方读到的数据都是正确的数据,即使错误,也可以在短时间内纠正,从而保证数据的最终一致性。
这种方式不能说更好,只能说在部分场景更合适,而且它并不适用于那种缓存数据十分复杂,需要大量聚合操作的构成的场景。
结论
无论使用哪种方式,我们都只能保证数据的最终一致性,我们为了获得并发下的高性能,那么就必然会损失部分数据的一致性。这就像一个跷跷板,压下这头,另一头就会翘起来。而我们要做的,则是根据实际的业务场景,进行权衡,选取最合适的方案。