我的新课
《C2C 电商系统微服务架构120天实战训练营》
在公众号
儒猿技术窝
上线了,感兴趣的同学,可以长按扫描下方二维码了解课程详情:
课程大纲请参见文末
本文来自中华石杉架构班学员魏武归心的投稿
感谢魏武归心同学的技术分享
相信只要是个稍微像样点的互联网公司,或多或少都有自己的一套缓存体系。
只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,遂笔者想在这想和大家聊一聊:
如何解决一致性问题?
如何保证缓存与数据库双写一致性,也是现在Java面试中面试官非常喜欢问的一个问题!
一般来说,如果允许缓存可以稍微跟数据库偶尔有不一致,也就是说如果你的系统不是严格要求
缓存 + 数据库
必须保持一致性的话,最好不要做这个方案。
即:读请求和写请求串行化,串到一个内存队列里去,从而达到防止并发请求导致数据错乱的问题,场景如图所示:
值得注意的是,
串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求(土豪请自觉无视此提醒)
解决思路如下图:
代码实现大致如下:
/**
* 请求异步处理的service实现
* @author Administrator
*
*/
@Service("requestAsyncProcessService")
publicclassRequestAsyncProcessServiceImplimplementsRequestAsyncProcessService {
@Override
publicvoid process(Request request) {
try {
// 先做读请求的去重
RequestQueue
requestQueue = RequestQueue.getInstance();
Map<Integer, Boolean> flagMap = requestQueue.getFlagMap();
if(request instanceofProductInventoryDBUpdateRequest) {
// 如果是一个更新数据库的请求,那么就将那个productId对应的标识设置为true
flagMap.put(request.getProductId(), true);
} elseif(request instanceofProductInventoryCacheRefreshRequest) {
Boolean flag = flagMap.get(request.getProductId());
// 如果flag是null
if(flag == null) {
flagMap.put(request.getProductId(), false);
}
// 如果是缓存刷新的请求,那么就判断,如果标识不为空,而且是true,就说明之前有一个这个商品的数据库更新请求
if(flag != null && flag) {
flagMap.put(request.getProductId(), false);
}
// 如果是缓存刷新的请求,而且发现标识不为空,但是标识是false
// 说明前面已经有一个数据库更新请求+一个缓存刷新请求了,大家想一想
if(flag != null && !flag) {
// 对于这种读请求,直接就过滤掉,不要放到后面的内存队列里面去了
return;
}
}
// 做请求的路由,根据每个请求的商品id,路由到对应的内存队列中去
ArrayBlockingQueue<Request> queue = getRoutingQueue(request.getProductId());
// 将请求放入对应的队列中,完成路由操作
queue.put(request);
}
catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取路由到的内存队列
* @param productId 商品id
* @return 内存队列
*/
privateArrayBlockingQueue<Request> getRoutingQueue(Integer productId) {
RequestQueue requestQueue = RequestQueue.getInstance();
// 先获取productId的hash值
String key = String.valueOf(productId);
int h;
int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// 对hash值取模,将hash值路由到指定的内存队列中,比如内存队列大小8
// 用内存队列的数量对hash值取模之后,结果一定是在0~7之间
// 所以任何一个商品id都会被固定路由到同样的一个内存队列中去的
int index = (requestQueue.queueSize() - 1) & hash;
System.out.println("===========日志===========: 路由内存队列,商品id=" + productId + ", 队列索引=" + index);
return requestQueue.getQueue(index);
}
}
Cache Aside Pattern
下面我们来聊聊最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
更新的时候,先更新数据库,然后再删除缓存。
为什么是删除缓存,而不是更新缓存?
原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。
比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
另外更新缓存的代价有时候是很高的,是不是每次修改数据库的时候,都一定要将其对应的缓存更新一份?
也许有的场景是这样,但是对于比较复杂的缓存数据计算的场景,就不是这样了。
如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新。但是问题在于,这个缓存到底会不会被频繁访问到?
举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;
但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。
实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低,用到缓存才去算缓存。
其实删除缓存,而不是更新缓存,就是一个
lazy 计算
的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。
像 mybatis,hibernate,都有懒加载思想,查询一个部门,部门带了一个员工的 list,没有必要说每次查询部门,都把里面的 1000 个员工的数据也同时查出来。
80% 的情况,查这个部门,就只是要访问这个部门的信息就可以了,先查部门,同时要访问里面的员工,那么这时只有在你要访问里面的员工的时候,才会去数据库里面查询 1000 个员工。
最初级的缓存不一致问题及解决方案
问题:先修改数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
解决思路
:先删除缓存,再修改数据库。如果数据库修改失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。
因为读的时候缓存没有,则读数据库中旧数据,然后更新到缓存中。
比较复杂的数据不一致问题分析
数据发生了变更,先删除了缓存,然后要去修改数据库。
但是还没来得及修改,一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。
随后数据变更的程序完成了数据库的修改。
完了,数据库和缓存中的数据不一样了。。。
为什么上亿流量高并发场景下,缓存会出现这个问题?
只有在对一个数据在并发的进行读写的时候,才可能会出现这种问题。
如果说你的并发量很低的话,特别是读并发很低,每天访问量就 1 万次,那么很少的情况下,会出现刚才描述的那种不一致的场景。
但是问题是,如果每天的是上亿的流量,每秒并发读是几万,每秒只要有数据更新的请求,就可能会出现上述的数据库+缓存不一致的情况。
解决方案如下: