网站设计计划书,小说网站80电子书怎么做,网站开发所遵循的,大连外贸网站建设前言当我们在做数据库与缓存数据同步时#xff0c;究竟更新缓存#xff0c;还是删除缓存#xff0c;究竟是先操作数据库#xff0c;还是先操作缓存#xff1f;本文带大家深度分析数据库与缓存的双写问题#xff0c;并且给出了所有方案的实现代码方便大家参考。本篇文章主… 前言当我们在做数据库与缓存数据同步时究竟更新缓存还是删除缓存究竟是先操作数据库还是先操作缓存本文带大家深度分析数据库与缓存的双写问题并且给出了所有方案的实现代码方便大家参考。本篇文章主要内容数据缓存为何要使用缓存哪类数据适合缓存缓存的利与弊如何保证缓存和数据库一致性不更新缓存而是删除缓存先操作缓存还是先操作数据库非要保证数据库和缓存数据强一致该怎么办缓存和数据库一致性实战实战先删除缓存再更新数据库实战先更新数据库再删缓存实战缓存延时双删实战删除缓存重试机制实战读取binlog异步删除缓存数据缓存在我们实际的业务场景中一定有很多需要做数据缓存的场景比如售卖商品的页面包括了许多并发访问量很大的数据它们可以称作是是“热点”数据这些数据有一个特点就是更新频率低读取频率高这些数据应该尽量被缓存从而减少请求打到数据库上的机会减轻数据库的压力。为何要使用缓存缓存是为了追求“快”而存在的。我们用代码举一个例子。我在自己的Demo代码仓库中增加了两个查询库存的接口getStockByDB和getStockByCache分别表示从数据库和缓存查询某商品的库存量。随后我们用JMeter进行并发请求测试。JMeter的使用请参考我之前写的文章点击这里需要声明的是我的测试并不严谨只是作对比测试不要作为实际服务性能的参考。这是两个接口的代码/*** 查询库存通过数据库查询库存* param sid* return*/
RequestMapping(/getStockByDB/{sid})
ResponseBody
public String getStockByDB(PathVariable int sid) {int count;try {count stockService.getStockCountByDB(sid);} catch (Exception e) {LOGGER.error(查询库存失败[{}], e.getMessage());return 查询库存失败;}LOGGER.info(商品Id: [{}] 剩余库存为: [{}], sid, count);return String.format(商品Id: %d 剩余库存为%d, sid, count);
}/*** 查询库存通过缓存查询库存* 缓存命中返回库存* 缓存未命中查询数据库写入缓存并返回* param sid* return*/
RequestMapping(/getStockByCache/{sid})
ResponseBody
public String getStockByCache(PathVariable int sid) {Integer count;try {count stockService.getStockCountByCache(sid);if (count null) {count stockService.getStockCountByDB(sid);LOGGER.info(缓存未命中查询数据库并写入缓存);stockService.setStockCountToCache(sid, count);}} catch (Exception e) {LOGGER.error(查询库存失败[{}], e.getMessage());return 查询库存失败;}LOGGER.info(商品Id: [{}] 剩余库存为: [{}], sid, count);return String.format(商品Id: %d 剩余库存为%d, sid, count);
}
首先设置为10000个并发请求的情况下运行JMeter结果首先出现了大量的报错10000个请求中98%的请求都直接失败了。让人很慌张~打开日志报错如下SpringBoot内置的Tomcat最大并发数搞的鬼其默认值为200对于10000的并发单机服务实在是力不从心。当然你可以修改这里的并发数设置但是你的小机器仍然可能会扛不住。将其修改为如下配置后我的小机器才在通过缓存拿库存的情况下保证了10000个并发的100%返回请求server.tomcat.max-threads10000
server.tomcat.max-connections10000
可以看到不使用缓存的情况下吞吐量为668个请求每秒使用缓存的情况下吞吐量为2177个请求每秒在这种“十分不严谨”的对比下有缓存对于一台单机性能提升了3倍多如果在多台机器更多并发的情况下由于数据库有了更大的压力缓存的性能优势应该会更加明显。测完了这个小实验我看了眼我挂着MySql的小水管腾讯云服务器生怕他被这么高流量搞挂。这种突发的流量指不定会被检测为异常攻击流量呢~我用的是腾讯云服务器1C4G2M活动买的很便宜。这里打个免费的广告请腾讯云看到后联系我给我打钱 ;)哪类数据适合缓存缓存量大但又不常变化的数据比如详情评论等。对于那些经常变化的数据其实并不适合缓存一方面会增加系统的复杂性缓存的更新缓存脏数据另一方面也给系统带来一定的不稳定性缓存系统的维护。但一些极端情况下你需要将一些会变动的数据进行缓存比如想要页面显示准实时的库存数或者其他一些特殊业务场景。这时候你需要保证缓存不能一直有脏数据这就需要再深入讨论一下。缓存的利与弊我们到底该不该上缓存的这其实也是个trade-off权衡的问题。上缓存的优点能够缩短服务的响应时间给用户带来更好的体验。能够增大系统的吞吐量依然能够提升用户体验。减轻数据库的压力防止高峰期数据库被压垮导致整个线上服务BOOM上了缓存也会引入很多额外的问题缓存有多种选型是内存缓存memcached还是redis你是否都熟悉如果不熟悉无疑增加了维护的难度本来是个纯洁的数据库系统。缓存系统也要考虑分布式比如redis的分布式缓存还会有很多坑无疑增加了系统的复杂性。在特殊场景下如果对缓存的准确性有非常高的要求就必须考虑缓存和数据库的一致性问题。本文想要重点讨论的就是缓存和数据库的一致性问题各位看官且往下看。如何保证缓存和数据库一致性说了这么多缓存的必要性那么使用缓存是不是就是一个很简单的事情了呢我之前也一直是这么觉得的直到遇到了需要缓存与数据库保持强一致的场景才知道让数据库数据和缓存数据保持一致性是一门很高深的学问。从远古的硬件缓存操作系统缓存开始缓存就是一门独特的学问。这个问题也被业界探讨了非常久争论至今。我翻阅了很多资料发现其实这是一个权衡的问题。值得好好讲讲。以下的讨论会引入几方观点我会跟着观点来写代码验证所提到的问题。不更新缓存而是删除缓存大部分观点认为做缓存不应该是去更新缓存而是应该删除缓存然后由下个请求去去缓存发现不存在后再读取数据库写入缓存。观点引用《分布式之数据库和缓存双写一致性方案解析》孤独烟原因一线程安全角度同时有请求A和请求B进行更新操作那么会出现1线程A更新了数据库2线程B更新了数据库3线程B更新了缓存4线程A更新了缓存这就出现请求A更新缓存应该比请求B更新缓存早才对但是因为网络等原因B却比A更早更新了缓存。这就导致了脏数据因此不考虑。原因二业务场景角度有如下两点1如果你是一个写数据库场景比较多而读数据场景比较少的业务需求采用这种方案就会导致数据压根还没读到缓存就被频繁的更新浪费性能。2如果你写入数据库的值并不是直接写入缓存的而是要经过一系列复杂的计算再写入缓存。那么每次写入数据库后都再次计算写入缓存的值无疑是浪费性能的。显然删除缓存更为适合。其实如果业务非常简单只是去数据库拿一个值写入缓存那么更新缓存也是可以的。但是淘汰缓存操作简单并且带来的副作用只是增加了一次cache miss建议作为通用的处理方式。先操作缓存还是先操作数据库那么问题就来了我们是先删除缓存然后再更新数据库还是先更新数据库再删缓存呢先来看看大佬们怎么说。《【58沈剑架构系列】缓存架构设计细节二三事》58沈剑对于一个不能保证事务性的操作一定涉及“哪个任务先做哪个任务后做”的问题解决这个问题的方向是如果出现不一致谁先做对业务的影响较小就谁先执行。假设先淘汰缓存再写数据库第一步淘汰缓存成功第二步写数据库失败则只会引发一次Cache miss。假设先写数据库再淘汰缓存第一步写数据库操作成功第二步淘汰缓存失败则会出现DB中是新数据Cache中是旧数据数据不一致。沈剑老师说的没有问题不过没完全考虑好并发请求时的数据脏读问题让我们再来看看孤独烟老师《分布式之数据库和缓存双写一致性方案解析》先删缓存再更新数据库该方案会导致请求数据不一致同时有一个请求A进行更新操作另一个请求B进行查询操作。那么会出现如下情形:1请求A进行写操作删除缓存2请求B查询发现缓存不存在3请求B去数据库查询得到旧值4请求B将旧值写入缓存5请求A将新值写入数据库上述情况就会导致不一致的情形出现。而且如果不采用给缓存设置过期时间策略该数据永远都是脏数据。所以先删缓存再更新数据库并不是一劳永逸的解决方案再看看先更新数据库再删缓存这种方案怎么样先更新数据库再删缓存这种情况不存在并发问题么不是的。假设这会有两个请求一个请求A做查询操作一个请求B做更新操作那么会有如下情形产生1缓存刚好失效2请求A查询数据库得一个旧值3请求B将新值写入数据库4请求B删除缓存5请求A将查到的旧值写入缓存ok如果发生上述情况确实是会发生脏数据。然而发生这种情况的概率又有多少呢发生上述情况有一个先天性条件就是步骤3的写数据库操作比步骤2的读数据库操作耗时更短才有可能使得步骤4先于步骤5。可是大家想想数据库的读操作的速度远快于写操作的不然做读写分离干嘛做读写分离的意义就是因为读操作比较快耗资源少因此步骤3耗时比步骤2更短这一情形很难出现。先更新数据库再删缓存依然会有问题不过问题出现的可能性会因为上面说的原因变得比较低所以如果你想实现基础的缓存数据库双写一致的逻辑那么在大多数情况下在不想做过多设计增加太大工作量的情况下请先更新数据库再删缓存!我非要数据库和缓存数据强一致怎么办那么如果我非要保证绝对一致性怎么办先给出结论没有办法做到绝对的一致性这是由CAP理论决定的缓存系统适用的场景就是非强一致性的场景所以它属于CAP中的AP。所以我们得委曲求全可以去做到BASE理论中说的最终一致性。最终一致性强调的是系统中所有的数据副本在经过一段时间的同步后最终能够达到一个一致的状态。因此最终一致性的本质是需要系统保证最终数据能够达到一致而不需要实时保证系统数据的强一致性大佬们给出了到达最终一致性的解决思路主要是针对上面两种双写策略先删缓存再更新数据库/先更新数据库再删缓存导致的脏数据问题进行相应的处理来保证最终一致性。缓存延时双删问先删除缓存再更新数据库中避免脏数据答案采用延时双删策略。上文我们提到在先删除缓存再更新数据库的情况下如果不采用给缓存设置过期时间策略该数据永远都是脏数据。那么延时双删怎么解决这个问题呢1先淘汰缓存2再写数据库这两步和原来一样3休眠1秒再次淘汰缓存这么做可以将1秒内所造成的缓存脏数据再次删除。那么这个1秒怎么确定的具体该休眠多久呢针对上面的情形读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加几百ms即可。这么做的目的就是确保读请求结束写请求可以删除读请求造成的缓存脏数据。如果你用了mysql的读写分离架构怎么办ok在这种情况下造成数据不一致的原因如下还是两个请求一个请求A进行更新操作另一个请求B进行查询操作。1请求A进行写操作删除缓存2请求A将数据写入数据库了3请求B查询缓存发现缓存没有值4请求B去从库查询这时还没有完成主从同步因此查询到的是旧值5请求B将旧值写入缓存6数据库完成主从同步从库变为新值上述情形就是数据不一致的原因。还是使用双删延时策略。只是睡眠时间修改为在主从同步的延时时间基础上加几百ms。采用这种同步淘汰策略吞吐量降低怎么办ok那就将第二次删除作为异步的。自己起一个线程异步删除。这样写的请求就不用沉睡一段时间后了再返回。这么做加大吞吐量。所以在先删除缓存再更新数据库的情况下可以使用延时双删的策略来保证脏数据只会存活一段时间就会被准确的数据覆盖。在先更新数据库再删缓存的情况下缓存出现脏数据的情况虽然可能性极小但也会出现。我们依然可以用延时双删策略在请求A对缓存写入了脏的旧值之后再次删除缓存。来保证去掉脏缓存。删缓存失败了怎么办重试机制看似问题都已经解决了但其实还有一个问题没有考虑到那就是删除缓存的操作失败了怎么办比如延时双删的时候第二次缓存删除失败了那不还是没有清除脏数据吗解决方案就是再加上一个重试机制保证删除缓存成功。参考孤独烟老师给的方案图方案一流程如下所示1更新数据库数据2缓存因为种种问题删除失败3将需要删除的key发送至消息队列4自己消费消息获得需要删除的key5继续重试删除操作直到成功然而该方案有一个缺点对业务线代码造成大量的侵入。于是有了方案二在方案二中启动一个订阅程序去订阅数据库的binlog获得需要操作的数据。在应用程序中另起一段程序获得这个订阅程序传来的信息进行删除缓存操作。方案二流程如下图所示1更新数据库数据2数据库会将操作信息写入binlog日志当中3订阅程序提取出所需要的数据以及key4另起一段非业务代码获得该信息5尝试删除缓存操作发现删除失败6将这些信息发送至消息队列7重新从消息队列中获得该数据重试操作。而读取binlog的中间件可以采用阿里开源的canal好了到这里我们已经把缓存双写一致性的思路彻底梳理了一遍下面就是我对这几种思路徒手写的实战代码方便有需要的朋友参考。缓存和数据库一致性实战实战先删除缓存再更新数据库终于到了实战我们在秒杀项目的代码上增加接口先删除缓存再更新数据库OrderController中新增/*** 下单接口先删除缓存再更新数据库* param sid* return*/
RequestMapping(/createOrderWithCacheV1/{sid})
ResponseBody
public String createOrderWithCacheV1(PathVariable int sid) {int count 0;try {// 删除库存缓存stockService.delStockCountCache(sid);// 完成扣库存下单事务orderService.createPessimisticOrder(sid);} catch (Exception e) {LOGGER.error(购买失败[{}], e.getMessage());return 购买失败库存不足;}LOGGER.info(购买成功剩余库存为: [{}], count);return String.format(购买成功剩余库存为%d, count);
}
stockService中新增Override
public void delStockCountCache(int id) {String hashKey CacheKey.STOCK_COUNT.getKey() _ id;stringRedisTemplate.delete(hashKey);LOGGER.info(删除商品id[{}] 缓存, id);
}
其他涉及的代码都在之前三篇文章中有介绍并且可以直接去Github拿到项目源码就不在这里重复贴了。实战先更新数据库再删缓存如果是先更新数据库再删缓存那么代码只是在业务顺序上颠倒了一下这里就只贴OrderController中新增/*** 下单接口先更新数据库再删缓存* param sid* return*/
RequestMapping(/createOrderWithCacheV2/{sid})
ResponseBody
public String createOrderWithCacheV2(PathVariable int sid) {int count 0;try {// 完成扣库存下单事务orderService.createPessimisticOrder(sid);// 删除库存缓存stockService.delStockCountCache(sid);} catch (Exception e) {LOGGER.error(购买失败[{}], e.getMessage());return 购买失败库存不足;}LOGGER.info(购买成功剩余库存为: [{}], count);return String.format(购买成功剩余库存为%d, count);
}
实战缓存延时双删如何做延时双删呢最好的方法是开设一个线程池在线程中删除key而不是使用Thread.sleep进行等待这样会阻塞用户的请求。更新前先删除缓存然后更新数据再延时删除缓存。OrderController中新增接口
// 延时时间预估读数据库数据业务逻辑的耗时用来做缓存再删除
private static final int DELAY_MILLSECONDS 1000;/*** 下单接口先删除缓存再更新数据库缓存延时双删* param sid* return*/
RequestMapping(/createOrderWithCacheV3/{sid})
ResponseBody
public String createOrderWithCacheV3(PathVariable int sid) {int count;try {// 删除库存缓存stockService.delStockCountCache(sid);// 完成扣库存下单事务count orderService.createPessimisticOrder(sid);// 延时指定时间后再次删除缓存cachedThreadPool.execute(new delCacheByThread(sid));} catch (Exception e) {LOGGER.error(购买失败[{}], e.getMessage());return 购买失败库存不足;}LOGGER.info(购买成功剩余库存为: [{}], count);return String.format(购买成功剩余库存为%d, count);
}
OrderController中新增线程池// 延时双删线程池
private static ExecutorService cachedThreadPool new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,new SynchronousQueueRunnable());/*** 缓存再删除线程*/
private class delCacheByThread implements Runnable {private int sid;public delCacheByThread(int sid) {this.sid sid;}public void run() {try {LOGGER.info(异步执行缓存再删除商品id[{}] 首先休眠[{}] 毫秒, sid, DELAY_MILLSECONDS);Thread.sleep(DELAY_MILLSECONDS);stockService.delStockCountCache(sid);LOGGER.info(再次删除商品id[{}] 缓存, sid);} catch (Exception e) {LOGGER.error(delCacheByThread执行出错, e);}}
}
来试验一下请求接口createOrderWithCacheV3日志中做到了两次删除实战删除缓存重试机制上文提到了要解决删除失败的问题需要用到消息队列进行删除操作的重试。这里我们为了达到效果接入了RabbitMq并且需要在接口中写发送消息并且需要消费者常驻来消费消息。Spring整合RabbitMq还是比较简单的我把简单的整合代码也贴出来。pom.xml新增RabbitMq的依赖dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-amqp/artifactId
/dependency
写一个RabbitMqConfigConfiguration
public class RabbitMqConfig {Beanpublic Queue delCacheQueue() {return new Queue(delCache);}}
添加一个消费者Component
RabbitListener(queues delCache)
public class DelCacheReceiver {private static final Logger LOGGER LoggerFactory.getLogger(DelCacheReceiver.class);Autowiredprivate StockService stockService;RabbitHandlerpublic void process(String message) {LOGGER.info(DelCacheReceiver收到消息: message);LOGGER.info(DelCacheReceiver开始删除缓存: message);stockService.delStockCountCache(Integer.parseInt(message));}
}
OrderController中新增接口/*** 下单接口先更新数据库再删缓存删除缓存重试机制* param sid* return*/
RequestMapping(/createOrderWithCacheV4/{sid})
ResponseBody
public String createOrderWithCacheV4(PathVariable int sid) {int count;try {// 完成扣库存下单事务count orderService.createPessimisticOrder(sid);// 删除库存缓存stockService.delStockCountCache(sid);// 延时指定时间后再次删除缓存// cachedThreadPool.execute(new delCacheByThread(sid));// 假设上述再次删除缓存没成功通知消息队列进行删除缓存sendDelCache(String.valueOf(sid));} catch (Exception e) {LOGGER.error(购买失败[{}], e.getMessage());return 购买失败库存不足;}LOGGER.info(购买成功剩余库存为: [{}], count);return String.format(购买成功剩余库存为%d, count);
}
访问createOrderWithCacheV4可以看到我们先完成了下单然后删除了缓存并且假设延迟删除缓存失败了发送给消息队列重试的消息消息队列收到消息后再去删除缓存。实战读取binlog异步删除缓存我们需要用到阿里开源的canal来读取binlog进行缓存的异步删除。我写了一篇Canal的入门文章其中用的入门例子就是读取binlog删除缓存。大家可以直接跳转到这里阿里开源MySQL中间件Canal快速入门扩展阅读更新缓存的的Design Pattern有四种Cache asideRead throughWrite throughWrite behind caching这里有陈皓的总结文章可以进行学习。https://coolshell.cn/articles/17416.html小结引用陈浩《缓存更新的套路》最后的总结语作为小结分布式系统里要么通过2PC或是Paxos协议保证一致性要么就是拼命的降低并发时脏数据的概率缓存系统适用的场景就是非强一致性的场景所以它属于CAP中的APBASE理论。异构数据库本来就没办法强一致只是尽可能减少时间窗口达到最终一致性。还有别忘了设置过期时间这是个兜底方案结束语本文总结并探讨了缓存数据库双写一致性问题。文章内容大致可以总结为如下几点对于读多写少的数据请使用缓存。为了保持数据库和缓存的一致性会导致系统吞吐量的下降。为了保持数据库和缓存的一致性会导致业务代码逻辑复杂。缓存做不到绝对一致性但可以做到最终一致性。对于需要保证缓存数据库数据一致的情况请尽量考虑对一致性到底有多高要求选定合适的方案避免过度设计。作者水平有限写文章过程中难免出现错误和疏漏请理性讨论与指正。参考https://cloud.tencent.com/developer/article/1574827https://www.jianshu.com/p/2936a5c65e6bhttps://www.cnblogs.com/rjzheng/p/9041659.htmlhttps://www.cnblogs.com/codeon/p/8287563.htmlhttps://www.jianshu.com/p/0275ecca2438https://www.jianshu.com/p/dc1e5091a0d8https://coolshell.cn/articles/17416.html如果文章对你有帮助不妨点赞收藏起来~
往期推荐
磊哥工作十几年了竟没有用过do-while文末送书2020-09-06 3种时间格式化的方法SpringBoot篇2020-09-04 多图证明Java到底是值传递还是引用传递2020-09-02 阿里为什么推荐使用LongAdder而不是volatile2020-08-25 关注下方二维码收获更多干货