为什么要引入缓存?
因为数据库是持久化于磁盘中的,而缓存一般是存放于内存中。操作系统对于磁盘的读写性能是只能够达到毫秒级,远不如内存的纳秒级别。
如果使用了缓存来分担数据库的读取操作,尤其是对于写频繁的应用来说,提升是十分的显著的。
本文的缓存包括中间件(比如Redis)和应用的缓存库(比如Caffiene)
缓存和数据库如何操作?
当我们引入缓存中间件后来进行减轻数据库的读操作时,我们进而带来了一个问题:在写操作时如何保证数据库和缓存的一致性?
我们会从两个维度上入手:
-
是删除缓存还是更新缓存?
-
是先操作数据库还是先操作缓存?
删除缓存还是更新缓存?
大部分情况下我们会使用删除缓存的方案,因为考量缓存的性能指标时,一个很关键的因素是缓存利用率。 而对于1000次写请求,我们都进行1000次缓存更新时,那么对于缓存的更新操作是十分浪费的。 因为缓存中的数据并不会被骂上读取到,导致存放了很多不常访问的数据造成资源浪费。
先操作数据库还是先操作缓存
不论是先操作数据库还是先操作缓存,都分为两步,那么就很可能存在第一步成功,第二步失败的情况。
如果是数据库操作成功了,缓存操作失败了,那么缓存依然是旧值,下次读请求时用户看到的依然是旧值。
如果是先缓存操作成功了,后数据库操作失败,那么数据库中的值依然是旧值,就会导致用户的更新丢失。
解决方案:重试
好像两个方案都会造成问题,如何解决呢?
最简单的办法就是重试。 不论是哪一种方案,只要第二步发生了失败我们就对其进行重试,理论上就能够保证数据的一致性了。
但是同步重试的并不合理,因为立即重试大概率还是会失败且频繁重试的话会占用系统的资源。
那么我们就会想到异步的解决方案了。
而这个异步的方案我们一般会使用可靠的消息队列来保证其可靠性。
-
因为消息队列能够保证可靠性:即使应用重启了,消息也不会丢失。
-
同时消息队列能够保证成功的投递:下游成功消费后才会删除消息,否则还会继续投递给消费者。
解决方案:订阅日志
但是如果是额外引入消息队列,会对于增加我们的运维成本。如果我们不想使用消息队列还有什么解决方案吗?
订阅变更日志。
拿MySQL为例,每当一条数据发生修改时,MySQL就会产生一条变更日志,那么我们可以订阅这个binlog,当发生数据的变更时去删除对应的缓存。那么就能够实现数据一致性了。
目前比较成熟的开源中间件就是Canal。我们只需要编写一个Canal Client订阅目标数据库,然后监听对目标数据的更改时,同时操作缓存即可。
主从同步与延迟双删
当我们的数据库使用到了主从同步架构时,并发操作可能会发生如下问题:
- 线程 A 更新主库 X = 2(原值 X = 1)
- 线程 A 删除缓存
- 线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
- 从库「同步」完成(主从库 X = 2)
- 线程 B 将「旧值」写入缓存(X = 1)
最有效的办法就是把缓存删掉。
但是不能够立即删除,而是需要延迟删除,这就是业界的解决方案:延迟双删策略。
即线程A在删除缓存后,先休眠一会,再删除一次缓存。
而这个延迟时间改设置为多久呢? 在分布式和高并发场景下很难评估,只能够根据不断的尝试尽可能的降低不一致的概率,一般是500ms - 3000ms。
延迟双删还有一种使用场景是先操作缓存再操作数据库时,再次操作缓存。
强一致问题
看到这里你可能会觉得,这些方案依然不够完美,无法做到强一致的情况。 到底能不能做到呢?
其实很难,最常见的方案就是2PC、3PC之类的强一致性协议,而他们的缺陷就是性能较差。
但是我们引入缓存的目的是什么? 性能
所以我们决定使用缓存,就必须容忍一致性的问题,只能够尽可能地降低问题出现的概率。 使用先操作数据库再操作缓存的方案,我们依然有缓存失效时间来兜底达到最终一致性。