由于数据库是基于磁盘的,所以当我们查询数据时会比较慢,于是就有了把数据库中的数据放入缓存中的操作,这样可以大幅度提升查询速度。但是在高并发环境下,修改数据时,如何保证数据库中的数据和缓存中的保持一致呢?
一般有如下四种处理方法
- 先更新缓存、后更新数据库
- 先更新数据库、后更新缓存
- 先删除缓存、后更新数据库
- 先更新数据库、后删除缓存
下面就让我们一起分析下,哪种处理方法可以解决数据的不一致问题呢?
一、先更新缓存、后更新数据库
可能会出现如下图所示的情况
| 时刻 | 线程A | 线程B |
|---|---|---|
| t1 | 更新缓存 | |
| t2 | 更新缓存 | |
| t3 | 更新数据库 | |
| t4 | 更新数据库 |
线程A更新缓存后,还未更新数据库时,有线程B更新了缓存,并更新了数据库,最后线程A才更新数据库。这样会导致缓存中是线程B修改后的值,而数据库中是线程A修改后的值,存在数据不一致的问题。由此可见,先更新缓存、后更新数据库方案不可行。
二、先更新数据库、后更新缓存
针对数据X,可能会出现如下图所示的情况
| 时刻 | 线程A | 线程B |
|---|---|---|
| t1 | 更新数据库 | |
| t2 | 更新数据库 | |
| t3 | 更新缓存 | |
| t4 | 更新缓存 |
线程A更新数据库后,还未更新缓存时,有线程B更新了数据库和缓存,最后线程A才更新了缓存。这样导致了缓存中是线程A修改后的值,而数据库中是线程B修改后的值,存在数据不一致的问题。由此可见,先更新数据库、后更新缓存的方案也不可行。但是相对先更新缓存来说,这种方案导致的数据不一致概率较小,因为更新缓存是一个很快的操作,一般不会出现“卡顿”的情况。
三、先删除缓存、后更新数据库
| 时刻 | 线程A | 线程B |
|---|---|---|
| t1 | 删除缓存 | |
| t2 | 缓存未命中,查询数据库 | |
| t3 | 放入缓存 | |
| t4 | 更新数据库 |
线程A先删除缓存,还未更新数据库时,线程B查询缓存未命中,查询数据库后把结果放入缓存,最后线程A才更新数据库。导致缓存中是旧数据,数据库是新数据。由此可见先删除缓存、后更新数据库的方案也不可行。
四、先更新数据库、后删除缓存
| 时刻 | 线程A | 线程B |
|---|---|---|
| t1 | 缓存未命中,查询数据库 | |
| t2 | 更新数据库 | |
| t3 | 删除缓存(删的空数据) | |
| t4 | 放入缓存 |
该方案会导致缓存中的旧数据,也不可行,但是这种情况发什么的概率很小。
以上四种方案不能保证缓存一致性的原因是因为在操作数据库和缓存期间,有其他线程也并发操作了该数据,即没有保证原子性。鉴于此,引入分布式锁也能解决该问题,当某一线程要修改或查询该数据前,必须先获取锁,这样操作数据库和缓存就能保证原子性,避免缓存不一致的问题。
五、延迟双删
引入分布式锁可能会降低系统的并发,影响系统的性能,所以我们也不太推荐这种方案。
延迟双删是在第一次删除缓存的基础上,再删一次缓存。如先删除了缓存、后更新了数据库,休眠一会儿后再删一次缓存。第二次删缓存是为了解决上述三和四出现的问题。休眠的时间不好控,它需要大于【查询数据+放入缓存】的时间。
六、主从模式下的数据不一致
先删缓存、后更新数据库方案下,如果更新数据库后同步从库出现延迟,此时有线程查询缓存未命中后,会查询从库中的旧数据,并放入缓存。
解决方案:缓存中没有数据,强制去查主库。
七、缓存更新失败导致的数据不一致
重试,重试后依然失败,可以引入消息队列。将更新失败的数据放入消息队列,按顺序消费,消费失败后可重复消费,直至成功为止。
八、强一致性
除非使用分布式锁,在读和写之前必须都拿到锁,否则无法实现真正意义上的强一致性,但是加锁之后又导致性能很低,失去了数据放缓存的意义。一般情况下,最终一致性已经能满足我们日常生产中绝大多数据需求了,性能和一致性就像CAP定理那样,永远无法同时满足所有的条件。
九、我们在生产用什么方式来保证数据一致性?
我们目前是采用先更新数据、后更新缓存的方式。因为我们的缓存使用频率很高,且读多写少,几乎没有并发修改的场景,先更新数据库还可以避免更新缓存成功,数据写入是失败的问题。没有采用删除的方案是因为这样可以提高缓存的命中率。