Redis缓存双写的学习(五)
一、缓存双写一致性
1.1、为什么会有双写?
-
- 缓存和数据库的数据不一致,导致数据错乱。
- 原因:先更新数据库,再更新缓存;或者先更新缓存,再更新数据库。
- 解决:先删除缓存,再更新数据库;或者先更新数据库,再删除缓存。
1.2、缓存操作
Redis作为缓存,有以下两种模式:
-
- 只读缓存:Redis有就读取,没有就向数据库查询,不做写回操作
-
- 读写缓存:
- 同步直写策略:
- 写数据库后也同步写redis,保证缓存和数据库一致。
- 缺点:写数据库和缓存是串行的,在高并发场景下,会影响性能。
- 异步缓写策略(推荐):
- mysql数据变动了,但是可以在业务上容许出现一定时间后才作用于redis,比如仓库,物流系统
- 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者rabbitMQ之类的消息队列来做,实现重试重写。
1.3、实际操作
// 同步直写策略
string getUserName(string userId)
{//查询redis缓存string userName = redis.get(userId);if (userName == null) {// 缓存中没有,查询数据库userName = db.queryUserNameByUserId(userId);redis.set(userId, userName);}return userName;
}
这段代码在并发量小的时候,没问题,但是如果并发量大,就会出现问题:当并发量很大时,多个线程同时查询缓存中没有数据,就会同时去数据库中查询,然后同时写入缓存。这样就会导致缓存中的数据被多次覆盖,从而导致数据不一致。
解决方案:采用双检加锁策略让其变为原子操作
/** 双检加锁策略 **/
/* 当多个线程同时去查询数据时,可以在第一个查询数据的请求上,
* 加一把就互斥锁,后续的查询数据请求都会被阻塞。直到第一个线程把数据写入缓存之后,释放锁,
* 其他线程发现缓存当中已经有数据了,就不会再去数据库中查询
*/
string getUserName(string userId)
{//查询redis缓存string userName = redis.get(userId);if (userName.empty()) {// 加锁,防止多个线程同时去数据库中查询数据lock_guard<mutex> lock(mutex); // 互斥锁// 再次检查缓存是否为空userName = redis.get(userId);if (userName.empty()) {// 缓存中没有,查询数据库userName = db.queryUserNameByUserId(userId);redis.set(userId, userName);}}return userName;
}
这段代码解决了并发,导致数据多次被覆盖,数据不一致问题,但还是存在问题:在高并发场景下,key突然失效,大量请求打到数据库,导致出现缓存击穿,所以还要再优化下
/** 避免缓存击穿 **/
/**相对于上面的代码就多了对MySQL查询完数据之后,对数据进行判空,设置key的过期时间**/
string getUserName(string userId)
{//查询redis缓存string userName = redis.get(userId);if (userName.empty()) {// 加锁,防止多个线程同时去数据库中查询数据lock_guard<mutex> lock(mutex); // 互斥锁// 再次检查缓存是否为空userName = redis.get(userId);if (userName .empty()) {// 缓存中没有,查询数据库userName = db.queryUserNameByUserId(userId);if(userName.empty()){return "";}else{// 设置过期时间,防止数据冗余redis.setex(userId, userName, 7L, TimeUnit.DAYS);}}}return userName;
}
1.4、缓存与数据库一致的几种策略
重要理念:最新数据是以MySQL之类的数据库为准,不是缓存
-
- 可以停机的情况
先发出公告,告知用户,何时停机,停机多久,然后停机更新数据库和缓存。比如游戏服务器,晚上凌晨停机。
- 可以停机的情况
-
- 不允许停机的情况(线上保持数据一致)
-
2.1 先更新数据库,再更新缓存
- 异常问题1:更新数据库成功了,更新缓存失败;导致数据库和缓存不一致,后续会读到redis脏数据
- 异常问题2:在高并发情况下,假设存在A,B两个线程:
- 正常情况下:
- A线程update mysql 100
- A线程update redis 100
- B线程update mysql 80
- B线程update redis 80
- 异常情况:(线程会抢占资源,不是顺序依次进行的)
- A线程update mysql 100
- B线程update mysql 80
- B线程update redis 80
- A线程update redis 100
- 正常情况下:
-
2.2 先更新缓存,再更新数据库
- 不推荐,最新数据是以MySQL之类的数据库为准,不是缓存。
- 异常问题1:在高并发情况下,假设存在A,B两个线程:
- 正常情况下:
- A线程update redis 100
- A线程update mysql 100
- B线程update redis 80
- B线程update mysql 80
- 异常情况:
- A线程update redis 100
- B线程update redis 80
- B线程update mysql 80
- A线程update mysql 100
- 正常情况下:
-
2.3 先删除缓存,再更新数据库
- 异常问题:在高并发情况下,假设存在A,B两个线程,A负责更新数据库,B负责读取数据
- 正常情况下:
- A线程delete redis
- A线程update mysql 100
- A线程set redis 100
- B线程get redis 100
- 异常情况下:
- A线程delete redis
- A线程update mysql 100
- B线程get redis 空值,去数据库查询到旧值80,然后set到redis中
- 正常情况下:
- 小结:如果数据库更新失败或超时或返回不及时,导致B线程请求访问缓存时,发现redis里面没有数据,就去数据库查询到了旧值80,然后set到redis中。这样就导致A线程做无用功。
- 解决办法:延迟双删策略,即在更新数据库之后,延迟一段时间再删除缓存。
- 异常问题:在高并发情况下,假设存在A,B两个线程,A负责更新数据库,B负责读取数据
/** 延迟双删策略 **/
/** 1.线程A先删除redis缓存,然后去更新数据库 ** 2.为了让线程B能够先从数据库读取数据,再把缺失的数据写入redis,然后线程A再进行删除** 3.线程A sleep的时间 > 线程B读取数据再写入缓存的时间
**/
void delayDoubleDelete(const string& key, const string& newValue, int delayTime = 500)
{// 第一次删除缓存redis.del(key);// 更新数据库db.update(key, newValue);// 延迟一段时间再删除缓存Thread::sleep(delayTime);// 再删除一次缓存,确保缓存中的数据是最新的redis.del(key);
}
问题:
- 那么延迟双删策略的延迟时间应该设置为多少?
-
- 适用有经验的开发者:统计该线程读取数据和写缓存的操作时间,来进行估算,然后再 + 几百毫秒的时间
-
- 适用于新人:启动后台监控程序,使用watch dog机制,即开启一个定时任务,每隔一段时间就去检查缓存是否是最新的数据。如果不是最新的,就删除缓存,然后重新加载最新数据到缓存中。
-
- 延时双删,同步策略,吞吐量降低了,如何解决?
采用异步方案
/** 异步延时双删函数 **/
void asyncDelayedDoubleDeletion(const string& key, const string& newValue, int delayTime = 500)
{// 第一次删除缓存redis.del(key);// 更新数据库db.update(key, newValue);// 启动异步线程来执行等待std::async(std::launch::async, [delayTime]() {std::this_thread::sleep_for(std::chrono::milliseconds(delayTime));// 再次删除缓存,确保缓存中的数据是最新的redis.del(key);});
}
- 2.4 先更新数据库,再删除缓存(推荐)
- 异常问题1:在更新数据库,还未及时更新redis时,其他线程请求读取redis缓存旧数据,会使得数据不一致,不过只是暂时的
- 异常问题1:在更新数据库,还未及时更新redis时,其他线程请求读取redis缓存旧数据,会使得数据不一致,不过只是暂时的
以上删除缓存时,都默认删除能成功,但如果删除失败,怎么办呢?
- 解决办法:重试机制,即在删除缓存失败时,进行重试。
- 可以把要删除的缓存值或者是要更新的数据库值存到消息队列中(kafka/RabbitMQ/RocketMQ等)
- 当程序没有能够成功删除缓存值或是要更新数据库时,可以重新从消息队列中获取这些值,然后再次进行删除或更新。
- 如果能够成功地删除或更新,我们就要把这些值从消息队列中移除,避免重复处理。此时,缓存和数据库最终会保持一致。
- 如果重试超过一定次数后(3,5次),还是没有成功,那么我们通知运维人员手动处理这些数据。
小结:
这几种方案都或多或少存在一些问题,但是目前业界公认的最佳实践是:先更新数据库,再删除缓存。理由如下:
-
- 先删除缓存再更新数据库,有可能导致请求因缓存确实而访问数据库,给数据库带来压力,导致打满数据库。
-
- 如果业务应用中读取数据库和写缓存的时间不好估算,延时双删的时间不好设置。
1.5、canal
-
- 为什么需要canal
当直接操作数据库,并非通过业务代码操作数据库,不会触发写回缓存的操作,此时可以手动触发缓存更新,但每次数据库变更都需要手动触发,工作量太大,而且容易出错;可以监听数据库的binlog日志,通过解析binlog日志来实现缓存更新-----这就是canal的作用
- 为什么需要canal
-
- canal是什么
canal是基于MySQL数据库binlog的增量订阅&消费组件,主要用于实时数据同步。
- canal是什么
-
- canal下载地址:
https://github.com/alibaba/canal
- canal下载地址:
-
- canal工作原理:
- canal工作原理:
-
- canal作用:
- canal作用:
-
- canal使用配置
官网文档:https://github.com/alibaba/canal/wiki/QuickStart
就不再赘述
- canal使用配置
二、总结
-
- 为什么会有缓存双写?
因为缓存和数据库的数据不一致,导致数据不准确,所以需要缓存双写,保持数据一致性。
- 为什么会有缓存双写?
-
- 缓存双写的策略有哪些?
有如下四种:- 先更新数据库,再更新缓存
- 先更新缓存,再更新数据库
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
- 缓存双写的策略有哪些?
四种都或多或少存在一些问题,但是目前业界公认的最佳实践是:先更新数据库,再删除缓存。
-
- 为什么业界公认的最佳实践是:先更新数据库,再删除缓存?
-
- 先删除缓存再更新数据库,有可能导致请求因缓存确实而访问数据库,给数据库带来压力,导致打满数据库。
-
- 如果业务应用中读取数据库和写缓存的时间不好估算,延时双删的时间不好设置。
-
- 为什么业界公认的最佳实践是:先更新数据库,再删除缓存?
-
- 如何知道数据库信息变更?
通过观察binlog日志得知数据库操作记录变更。
- 如何知道数据库信息变更?
0vice·GitHub