转自:https://mp.weixin.qq.com/s/9fbXiQe1sc2xpMKfBXNF3w
只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?
Cache Aside Pattern
最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存,而不是更新缓存?
主要有以下两点:
- 很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
- 另外更新缓存的代价有时候是很高的。如有些冷数据,1分钟内修改了N次,但是只读取1次,这样的场景下如果更新缓存N次的话代价就太大了。而删除缓存,只需要在读取时更新1次缓存即可。这其实就是LAZY延迟加载的思想。
比较复杂的数据不一致问题分析
在并发量比较大的情况下,极有可能会出现如下场景:
- 先更新数据库再删除缓存,在更新数据库与删除缓存之间,有读请求过来,这时缓存还未删除,读取到的是缓存脏数据。
- 先删除缓存再更新数据库,在删除缓存与更新数据库之间,有读请求过来,这时缓存中无数据,去读取数据库并加载到缓存,数据库这时还未更新完成,读取到的是数据库快照(也就是数据库脏数据),最终导致缓存中长期为脏数据(除非更新完数据库后再次删除缓存)。
解决方案:操作串行化
理论上,不管是先更新数据库再删除缓存,还是先删除缓存再更新数据库,只要保证读写操作是串行的,就没有数据一致性的问题。但是在实际操作过程中,如果选择先更新数据库再删除缓存的话,读请求无法得知哪些数据的读取需要串行化(除非去遍历更新数据库的请求队列,代价太高)。所以选择先删除缓存再更新数据库。
具体过程:更新数据库之前,先删除缓存,然后根据数据唯一标识,将更新操作路由到一个JVM 内部队列中。读请求过来的时候,发现缓存中无数据(需要串行化),那么将重新进行读取数据+更新缓存的操作,根据数据唯一标识路由到和更新数据库操作的同一个JVM内部队列中。
优化点1:同一个数据,在队列中可能会存在多个读请求排队,可以做一下过滤,如果发现队列中已经有读请求在排队,那么就不用再放第二个读请求进去了,第二个读请求直接等待前面的操作完成即可(不过这个操作需要遍历队列,另外,第二个读请求还需要做手动同步等待,操作复杂性以及性能不一定会有提高,可选吧)。
优化点2:在应用无状态、多节点部署时,将请求路由到一个JVM内部队列不太好实现,可以考虑使用分布式锁,强行将写请求与读请求串行化。要注意的是,一定要保证写请求先获取到锁,也即删除缓存必须在写操作的同步代码块中;另外,锁的对象是需要更新的数据唯一标识符;最后,读请求获取到锁后,最好进行缓存的double check。
写数据:
/**
删除缓存并更新数据库
**/
@lock("#key")
public void deleteAndUpdate(String key, Object newObj) {
//先删除缓存,让其它读请求排队等待
CacheUtil.delete(key);
//更新数据库
updateById(newObj);
}
读数据:
public Object get(String key) {
Object obj = CacheUtil.get(key);
return obj != null ? obj : loadCache(key);
}
/**
加载缓存
**/
//分布式锁,强制串行化,double check保证只加载一次
@lock("#key")
public Object loadCache(String key) {
Object obj = CacheUtil.get("key");
//double check
if(obj == null) {
obj = getFromDB(key);
CacheUtil.set(key, obj);
}
return obj;
}
一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,也就是说如果你的系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。
串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上请求。