缓存的作用
大部分面向公众的互联网系统,其并发请求数量与在线用户数量都是正相关的,而 MySQL 能够承担的并发读写量是有一定上限的,当系统的访问量超过一定程度的时候,纯 MySQL 就很难应付了。绝大多数互联网系统都是采用 MySQL+Redis 这对经典组合来解决高并发问题的。
Redis 作为 MySQL 的前置缓存,可以应对绝大部分查询请求,从而在很大程度上缓解 MySQL 并发请求的压力。
缓存可以提升性能,缓解数据库压力,但是同时缓存也会出现「缓存和数据库数据不一致」的问题。如果数据不一致,就会导致应用在缓存中读取的不是最新的数据,这显然是不能接受的。
双写不一致的原因
我们先来看看缓存和数据库一致性定义:
- 缓存中有数据,且和数据库数据一致;
- 缓存中无数据,数据库数据是最新的。
那么不符合这两种情况就属于缓存和数据库不一致的问题了。
当客户端发送一个数据修改的请求,我们不仅要修改数据库,还要一并操作(修改/删除)缓存。对数据库和缓存的操作又存在一个顺序的问题:到底是先操作数据库还是先操作缓存。
有好几种解决方案,
1. 先更新缓存,再更新数据库
2. 先更新数据库,再更新缓存
3、先删除缓存,后更新数据库
4、先更新数据库,后删除缓存
我们一一来看看:
更新缓存类
1、先更新缓存,再更新 DB
这个方案我们一般不考虑。原因是更新缓存成功,更新数据库出现异常了,
导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。
2. 先更新 DB,再更新缓存
这个方案也我们一般不考虑,原因跟第一个一样,数据库更新成功了,缓存更新失败,同样会出现数据不一致问题。
到这里可以看出,无论这两个操作的执行顺序谁先谁后,只要「第二步的操作」失败了,就会导致客户端读取到旧值。
我们继续分析,除了「第二步操作失败」的问题,还有什么场景会影响数据一致性:并发问题。
并发引发的一致性问题
这里列出来所有策略,并且对删除和修改操作分开讨论:
- 先更新数据库,后更新缓存
- 先更新缓存,后更新数据库
- 先更新数据库,后删除缓存
- 先删除缓存,后更新数据库
先更新数据库,后更新缓存
假设我们采用「先更新数据库,后更新缓存」的方案,并且在两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?
有线程 A 和线程 B 两个线程,需要更新「同一条」数据 x,可能会发生这样的场景:
- 线程 A 更新数据库(x = 1)
- 线程 B 更新数据库(x = 2)
- 线程 B 更新缓存(x = 2)
- 线程 A 更新缓存(x = 1)
最后我们发现,数据库中的 x 是2,而缓存中是1。显然是不一致的。
另外这种场景一般是不推荐使用的。因为某些业务因素,最后写到缓存中的值并不是和数据库是一致的,可能需要一系列计算得出的,最后才把这个值写到缓存中;如果此时有大量的对数据库进行写数据的请求,但是读请求并不多,那么此时如果每次写请求都更新一下缓存,那么性能损耗是非常大的。
比如现在数据库中 x = 1,此时我们有10个请求对其每次加一的操作。但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有10个请求对缓存进行更新,会有大量的冷数据产生。
至于「先更新缓存,后更新数据库」这种情况和上述问题的是一致的,就不继续讨论。
不管是先修改缓存还是后修改缓存,这样不仅对缓存的利用率不高,还浪费了机器性能。所以此时我们需要考虑另外一种方案:删除缓存。
先删除缓存,后更新数据库
假设有两个线程:线程A(更新 x ),线程B(读取 x )。可能会发生如下场景:
- 线程 A 先删除缓存中的 x ,然后去数据库进行更新操作;
- 线程 B 此时来读取 x,发现数据不在缓存,查询数据库并补录到缓存中;
- 而此时线程 A 的事务还未提交。
时间 | 线程A | 线程B | 问题 |
T1 | 删除数据x的缓存值 | ||
T2 | 读取缓存x数据值,发现缺失 | 缓存中是旧值,数据库新值,二者不一致 | |
T3 | 更新数据库中的x |
这个时候「先删除缓存,后更新数据库」仍会产生数据库与缓存的不一致问题。
如何解决呢?其实最简单的解决办法就是延时双删的策略。就是
( 1 )先淘汰缓存
( 2 )再写数据库
( 3 )休眠 1 秒,再次淘汰缓存
这么做,可以将 1 秒内所造成的缓存脏数据,再次删除。
那么,这个 1 秒怎么确定的,具体该休眠多久呢?
针对上面的情形,自行评估自己的项目的读数据业务逻辑的耗时。然后写数
据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
但是上述的保证事务提交完以后再进行删除缓存还有一个问题,就是如果你
使用的是Mysql的读写分离的架构的话,那么其实主从同步之间也会有时间差。
此时来了两个请求,请求 A (更新操作) 和请求 B (查询操作)
请求 A 更新操作,删除了 Redis ,
请求主库进行更新操作,主库与从库进行同步数据的操作,
请 B 查询操作,发现 Redis 中没有数据,
去从库中拿去数据,此时同步数据还未完成,拿到的数据是旧数据。
此时的解决办法有两个:
1 、还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间
基础上,加几百 ms 。
2 、就是如果是对 Redis 进行填充数据的查询数据库操作,那么就强制将其
指向主库进行查询。
继续深入, 采用这种同步淘汰策略,吞吐量降低怎么办?
那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请
求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。
不过总的来说,先删除缓存值再更新数据库有可能导致请求因缓存缺失而访
问数据库,给数据库带来压力; 2. 业务应用中读取数据库和写缓存的时间有时不好估算,导致延迟双删中的 sleep 时间不好设置。
先更新数据库,后删除缓存
我们还用两个线程:线程 A(更新 x ),线程B(读取 x )举例。
- 线程 A 要把数据 x 的值从 1更新为 2,首先先成功更新了数据库;
- 线程 B 需要读取 x 的值,但线程 A 还没有把新的值更新到缓存中;
- 这个时候线程 B 读到的还是旧数据 1;
时间 | 线程A | 线程B | 问题 |
T1 | 更新数据库中x的值为2 | ||
T2 | 读取缓存x数据值,缓存命中读到旧值 | 线程A未删除缓存,导致线程B读到的是旧值 | |
T3 | 删除缓存中的数据x |
不过,这种情况发生的概率很小,线程 A 会很快删除缓存中值。这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。
由此,我们可以采用这种方案,来尽量避免数据库和缓存在并发情况下的一致性问题。
下面,我们继续分析「第二步操作失败」,我们该如何处理?
如何保证双写一致性
如何保证「第二步操作失败」的双写一致?
前面我们分析到,无论是「更新缓存」还是「删除缓存」,只要第二步发生失败,那么就会导致数据库和缓存不一致。
这里的关键在于如何保证第二步执行成功。
首先,介绍一种方法:「基于消息队列的重试机制」。
基于消息队列的重试机制
具体来说,就是把操作缓存,或者操作数据库的请求暂存到队列中。通过消费队列来重新处理这些请求。
流程如下:
- 请求 A 先对数据库进行更新操作;
- 在对 Redis 进行删除操作的时候发现删除失败;
- 此时将 对 Redis 的删除操作 作为消息体发送到消息队列中;
- 系统接收到消息队列发送的消息,再次对 Redis 进行删除操作。
消息队列的两个特性满足了我们重试的需求:
- 保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心);
- 保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)。
引入队列带来的问题:
- 业务代码造成大量的侵入,同时增加了维护成本;
- 写队列时也会存在失败的问题。
对于这两个问题,第一个,我们在项目中一般都会用到消息队列,维护成本并没有新增很多。而且对于同时写队列和缓存都失败的概率还是很小的。
如果是实在不想在应用中使用队列重试的,目前也有比较流行的解决方案:订阅数据库变更日志,再操作缓存。我们对 MySQL 数据库进行更新操作后,在 binlog 日志中我们都能够找到相应的操作,可以订阅 MySQL 数据库的 binlog 日志对缓存进行操作。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal。
大概流程如下:
1、请求 A 先对数据库进行更新操作
2、在对 Redis 进行删除操作的时候发现报错,删除失败
3、此时将 Redis 的 key 作为消息体发送到消息队列中
4、系统接收到消息队列发送的消息后
5、再次对 Redis 进行删除操作
总结
- 「更新数据库 + 更新缓存」方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生,一般不建议使用
- 在「更新数据库 + 删除缓存」的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案
- 在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性
- 在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「强制读主库」或者「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。
test;
final int nonfairTryAcquireShared(int acquires) {// 入参是获取的资源数 等于acquire(1)方法中的入参值
for (;;) {
// available就是创建semaphore的许可资源
int available = getState();
int remaining = available - acquires;
// 当资源小于0或者CAS成功,则返回资源数
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
参考文档:https://blog.csdn.net/yehongzhi1994/article/details/107880162