缓存双写一致性问题
首先缓存双写一致性是指缓存和数据库中的数据保持一致。当修改数据库中的数据时,需要同时修改缓存中的数据,以保证两者的一致性。如果只修改了数据库中的数据,而没有及时更新缓存,会导致缓存中的数据与数据库中的数据不一致,进而影响系统的正确性和性能。
一般使用缓存的流程可以是这样:
主要步骤就是先查询缓存看是否存在数据,存在则直接返回,不存在再去查询数据库,数据库中不存在的话也直接返回,存在的话则回写缓存并且返回。
四种更新策略
先更新数据库,再更新缓存(❌不推荐)
该策略会导致两种问题出现:
更新数据库成功,更新缓存失败
比如有个商品的库存为1,有订单来了需要扣库存,如果先更新数据库为0,再更新缓存,但是更新数据库成功了而更新缓存时失败,这样就会导致缓存中库存依然是1没有扣减,数据库中库存扣减成功为0,这样的数据库与缓存的不一致问题。后续就会读到缓存中的脏数据。
多个线程同时更新导致不一致
比如A,B两个线程,假如A在更新完数据库后还没来得及更新缓存,此时B已经更新数据库并且更新了缓存,最后A才更新缓存,将会是下面这种情况,最终还是导致了数据库和缓存的不一致问题:
先更新缓存,再更新数据库(❌不推荐)
多个线程同时更新导致不一致
比如A,B两个线程,假如A在更新完缓存后还没来得及更新数据库,此时B已经更新缓存并且更新了数据库,最后A才更新数据库,将会是下面这种情况,最终还是导致了数据库和缓存的不一致问题:
先删除缓存,再更新数据库(❌不推荐)
读写请求并发的情况下,在写线程删除缓存后且更新数据库的事务提交之前,这时读线程进来了,读线程此时查不到缓存,就去数据库里查到了旧数据然后将数据放入缓存。这种情况下,直到下一次新的写操作进来之前,缓存中的数据将一直是脏数据。
如上图所示,开始时数据库和缓存值都是100,写线程在失效缓存成功后(数据库值为100,缓存没有值),读线程此时请求发现缓存数据为空的话,就会从数据库中读取旧值放入到缓存中(数据库和缓存值都是100),最后写线程在将值写入数据库(数据库值为80,缓存没有100),这样就导致了不一致的问题。另外,数据库如果采用的是主从复制+读写分离的架构,读线程读出来的数据也有可能是主从未同步完成造成的脏数据。
针对这样的情况可以采用延时双删的策略来有效避免,可以在更新完数据库后使用线程休眠一段时间,再次删除缓存:
cache.delKey(key);
db.update(data);
Thread.sleep(xxx);
cache.delKey(key);
主要是在写请求更新完数据库后休眠一段时间(休眠时间=读数据耗时+主从同步耗时),然后再删除一次缓存,将可能由并发读请求带来的脏数据失效掉。这种通过延时双删的方式需要线程休眠,因此很显然会降低系统吞吐量,并不是一种比较好的解决方式,也可以采用异步删除的方式。当然也可以设置缓存过期时间,到期后缓存自动失效,但这样做需要系统能够容忍一段时间的数据不一致。
先更新数据库,再删除缓存(⚠️推荐)
实际上这种方式也存在数据不一致的情况。
- 读线程发起查询请求,此时缓存恰好失效了(可能是到期了),直接到数据库查询,查到了数据还没有来得及去设置缓存。(此时数据库值为100缓存没有值)
- 写线程要更新值,先更新数据库,再失效缓存。(此时数据库值为80缓存没有值)
- 读线程这时才设置缓存。(此时数据库值为80缓存值为100)
这样的话最终还是会导致缓存和数据库不一致,主要就是因为缓存突然失效了,而且还要保证写线程的更新操作比读线程的查询并且更新缓存的操作还要快。(实际上这种情况发生的概率很低)要发生这种情况的前提条件是写数据库要先于读数据库完成,一般而言数据库的读相比于写耗时更短,这种前提条件成立的概率很低。采用上文提到的延时双删方法可以达到最终一致。
比较于上面的先删除缓存再更新数据库,先更新数据库再失效缓存依然会有问题,但是概率非常低。
导致不一致的原因
- 逻辑失败造成的数据不一致:在并发的情况下,无论是先删除缓存后更新数据库,还是先更新数据库后失效缓存,都会有数据不一致的情况,主要是由读写请求在并发情况下的操作时序导致的,这种特殊时序造成的不一致称之为“逻辑失败”,解决这种因为并发时序导致的不一致,核心的解决思想是将操作进行串行化。
- 物理失败造成的数据不一致:先更新数据库后删除缓存,如果删除缓存操作出现失败,也会出现数据不一致的情况。但是数据库更新以及缓存操作不适合放到一个事务中,一般来说,如果使用分布式缓存有网络传输的耗时,如果这个耗时较长,那么将更新数据库以及失效缓存放到一个事务中,就会造成事务耗时过长,很快耗尽数据库的连接池,严重的降低系统性能,导致系统崩溃。像这种因为操作失败导致的数据不一致称之为“物理失败”,大多数物理失败的情况可以用重试的方案解决。
最终保证一致性的方案
主要分为以下几步:
- 更新数据库数据。
- 数据库会将操作信息写入Binlog日志当中。
- 订阅程序提取出所需要的数据以及key。
- 另起一段非业务代码,获得该信息。
- 尝试删除缓存操作。
- 如果删除缓存操作失败,将这些信息发送至消息队列。
- 重新从消息队列中获得该数据,重试删除操作。
采用这种方案,业务系统只负责处理业务逻辑,更新MySQL,完全不用管如何去更新缓存。而负责更新缓存的服务,把自己伪装成一个MySQL的从节点,从MySQL接收Binlog,解析Binlog之后,可以得到实时的数据变更信息,然后根据这个变更信息去更新缓存。这个方案的缺点是,实现缓存更新服务有点儿复杂,毕竟解析Binlog文件还是很麻烦的。