高并发下缓存和数据库一致性问题(更新淘汰缓存不得不注意的细节)
缓存和数据库一致性问题
本文讨论的背景是,cache如memcache,redia等缓存来缓存数据库读取出来的数据,以提高读性能,如何处理缓存里的数据和数据库数据的一致性是本文讨论的内容:正常的缓存步骤是:
1查询缓存数据是否存在,2不存在即查询数据库,3将数据添加到缓存同时返回结果,4下一次访问发现缓存存在即直接返回缓存数据。那么当更新数据库数据的时候,该如果更新缓存呢,至少要考虑尽量短时间的一致性,这个看业务需求,比如用户信息缓存时间越短越好,比如排行榜可能是一天更新一次,本文纯技术讨论,就是尽量缩短非一致性的时间以此来学习思路。
1、当更新数据库时候,缓存应该如何更新
1.1、更新缓存VS淘汰缓存
答:更新缓存很直接,但是涉及到本次更新的数据结果需要一堆数据运算(例如更新用户余额,可能需要先看看有没有优惠券等),复杂度就增加了。而淘汰缓存仅仅会增加一次cache miss,代价可以忽略,所以建议淘汰缓存
1.2、先淘汰后写数据库vs先写数据库后淘汰
答: 先写后淘汰,如果淘汰失败,cache里一直是脏数据
先淘汰后写,下次请求的时候缓存就会miss hit一次,这个代价是可以忽略的,(如果淘汰失败return false)
综合比较统,推荐先淘汰缓存再写数据库
下次请求直接从数据库取然后再写在缓存里。(当然这里可能会大并发一起击穿(通过下面1.3的方式可以解决),还有在淘汰缓存再写数据库的这一瞬间,再来一个读取请求,这个读取比上一个请求的写先完成,那么就会出现脏数据。网上有人说 修改数据库的连接池方法,就是对于同一个ID的数据请求,比如query(id),edit(id)都使用同一个连接对象,这样来保证先来的先完成,貌似还是挺复杂的,关于后来的读取请求先与先来的写请求完成,只能通过这样的串行方式执行)
关于脏数据,如果需要强一致性
1.2.1、可以通过数据库无论是读或写操作都是通过一个请求db connection连接完成(目的是串行),这样就需要修改连接池
1.2.2、可以采用更新缓存而不是淘汰缓存,前提是更新的代价比较低
1.2.3、可以先更新数据库再淘汰缓存(更新的原则是谁影响小先更新谁,此处倒着来了,一般推荐先更新数据库再淘汰缓 存),不过一般情况,淘汰缓存失败的可能性很小,可以以缓存处理100%不失败为前期。
1.2.4、双淘汰发,即:淘汰缓存-更新数据库-淘汰缓存,可以尽量减少脏数据的留存时间。
1.2.5、以上实现起来,要么极短时间的不一致要么一致性代价比较高,实际项目我会这样处理,更新数据库的地方和读取的地方上同样key的分布式锁,这样就能保证,先操作(或读或写)数据的先获得结果,实际中这样的强一致需求比较少,参考思路即可。
当然数据既然都缓存起来了,绝大部分都不要求强一致性,为了尽可能的缩短一致性的时间,可以如下处理:
1.2.6、异步消息总线esb更新法,即:修改数据库往消息总线里发送一个消息,在接收端去处理这个消息更新缓存,缺点是有代码入侵
1.2.7,异步binlog扫描更新法,增量的去扫描binlog中的修改记录,符合条件的更新缓存,相比消息总线法没有代码入侵
1.3、在1.2缓存miss hit的时候,此时大并发请求这个过程,会出现什么异常
答:缓存击穿(关于缓存丢失导致雪崩击穿参考:https://blog.csdn.net/zeb_perfect/article/details/54135506)
主要是热点key的请求或者一直没写缓存成功会出现这种情况,解决方案网上很多,这里我写下的我解决方案:
伪代码:
//主要是数据库查询的时候串行,会带来毫秒级的卡顿,综合复杂度性能等,推荐此方法
String json="";
cache=redis.get(key);
if(cache is not null)
{
return cache
}
}
else
{
lock();//如果分布式部署,这里有用分布式锁哦,分布式锁来锁住数据库查询请求,应尽量避免锁,这样程序就是单线程达不到并发要求,这里使用锁主要是因 极少概率会穿透到数据库,锁一点点时间不影响性能
//为什么再来一次判断,自行想象下高并发场景下
cache=redis.get(key);
if(cache is not null)
{
return cache;
}
data=json=server.Query(Sql);
redis.set(data,key);
unlock();
return data;
}
分布式锁:
//分布式锁
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, "1")) {
// 3 min timeout to avoid mutex holder crash
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
redis.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
备注:设计缓存的时候,尤其是热点key过期的时候 需要考虑击穿,以及雪崩,穿透等情形对下游DB的并发请求带来的影响
https://blog.csdn.net/zeb_perfect/article/details/54135506
https://blog.csdn.net/wang0112233/article/details/79558612
2、1中的方案都是在单主库的环境下讨论的,如果涉及到主从数据库如何处理呢?
一般主从都是 写主读从,写主后,立马读从,而从还没有更新,有一定的延迟,这个延迟时间我们经验总结暂定500ms,超过500ms超时返回。如果主从的话涉及到强一致性更复杂,这里暂且按照弱一致性的需求,只是要尽量的缩短非一致性的时间
2.1、淘汰缓存,修改完数据,thread.wait(500s),再次淘汰缓存,下次读从库就是最新数据,在期间有可能500ms的旧数据
2.2、1.2.6和1.2.7一样,只是在处理更新缓存的时候加上500ms的延迟时间,以此来保证从库更新完成,再更新缓存
3、主从一致性,即修改完立马就要读取到最新的数据(本方案不涉及到缓存的同步,如果涉及可以结合全篇思路去设计) 方案如下:
3.1、半同步复制,理应数据库原生的功能,等从库同步完才返回结果,缺点吞吐量下降
3.2、强制读主库,部分有一致性要求的,代码中强制读取主库,这个时候一定要结合好缓存,提高读性能
3.3、数据库中间件,一般情况数据库中间件把写路由到主,把读路由到从,此处是记录所以写的key,在500ms内读主库,超过500ms后读从库,能保证绝对的一致性,缺点是成本比较高
3.4、缓存记录写key法,发生写操作,把此key记录在缓存里过期时间500ms,key存在表示刚更新过,还没完成同步,强制路由到主库,没有则路由到从库
关于强一致的需求,现实是不多的,本身就使用cache了还要求强一致,貌似本末倒置,但是不排除特殊情况的存在,主要是思路和大家分享。
--------------------- 作者:zlhzhj 来源:CSDN 原文:https://blog.csdn.net/ZLHZHJ/article/details/80176988?utm_source=copy 版权声明:本文为博主原创文章,转载请附上博文链接!