-
2021-02-07 17:33:12
先更新数据库,还是先更新缓存?
一.转自拉勾教育分布式技术原理与实战45讲 邴越
应用缓存以后,缓存和数据库何时同步。
二.数据不一致问题
我们知道,除了少部分配置信息类缓存,比如业务中的黑白名单信息、页面展示配置等,大部分缓存应用一般是作为前端请求和持久化存储的中间层,承担前端的海量请求。
缓存层和数据库存储层是独立的系统,我们在数据更新的时候,最理想的情况当然是缓存和数据库同时更新成功。但是由于缓存和数据库是分开的,无法做到原子性的同时进行数据修改,可能出现缓存更新失败,或者数据库更新失败的情况,这时候会出现数据不一致,影响前端业务。
以电商中的商品服务为例,针对 C 端用户的大部分请求都是通过缓存来承载的,假设某次更新操作将商品详情 A 的价格从 1000 元更新为 1200 元,数据库更新成功,但是缓存更新失败。这时候就会出现 C 端用户在查看商品详情时,看到的还是 1000 元,实际下单时可能是别的价格,最终会影响用户的购买决策,影响平台的购物体验。
可以看到,在使用缓存时,如果不能很好地控制缓存和数据库的一致性,可能会出现非常多的业务问题。三.更新缓存有哪些方式
缓存更新方案是通过对更新缓存和更新数据库这两个操作的设计,来实现数据的最终一致性,避免出现业务问题。
先来看一下什么时候创建缓存,前端请求的读操作先从缓存中查询数据,如果没有命中数据,则查询数据库,从数据库查询成功后,返回结果,同时更新缓存,方便下次操作。
在数据不发生变更的情况下,这种方式没有问题,如果数据发生了更新操作,就必须要考虑如何操作缓存,保证一致性。先更新数据库,再更新缓存
先来看第一种方式,在写操作中,先更新数据库,更新成功后,再更新缓存。这种方式最容易想到,但是问题也很明显,数据库更新成功以后,由于缓存和数据库是分布式的,更新缓存可能会失败,就会出现上面例子中的问题,数据库是新的,但缓存中数据是旧的,出现不一致的情况。
先删缓存,再更新数据库
这种方案是在数据更新时,首先删除缓存,再更新数据库,这样可以在一定程度上避免数据不一致的情况。
现在考虑一个并发场景,假如某次的更新操作,更新了商品详情 A 的价格,线程 A 进行更新时失效了缓存数据,线程 B 此时发起一次查询,发现缓存为空,于是查询数据库并更新缓存,然后线程 A 更新数据库为新的价格。
在这种并发操作下,缓存的数据仍然是旧的,出现业务不一致。先更新数据库,再删缓存
这个是经典的缓存 + 数据库读写的模式,有些资料称它为 Cache Aside 方案。具体操作是这样的:读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应,更新的时候,先更新数据库,数据库更新成功之后再删除缓存。
为什么说这种方式经典呢?
在 Cache Aside 方案中,调整了数据库更新和缓存失效的顺序,先更新数据库,再失效缓存。
目前大部分业务场景中都应用了读写分离,如果先删除缓存,在读写并发时,可能出现数据不一致。考虑这种情况:- 线程 A 删除缓存,然后更新数据库主库;
- 线程 B 读取缓存,没有读到,查询从库,并且设置缓存为从库数据;
- 主库和从库同步。
在这种情况下,缓存里的数据就是旧的,所以建议先更新数据库,再失效缓存。当然,在 Cache Aside 方案中,也存在删除缓存失败的可能,因为缓存删除操作比较轻量级,可以通过多次重试等来解决,你也可以考虑下有没有其他的方案来保证。
四.对缓存更新的思考
为什么删除而不是更新缓存
现在思考一个问题,为什么是删除缓存,而不是更新缓存呢?删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。
在实际业务中,缓存的数据可能不是直接来自数据库表,也许来自多张底层数据表的聚合。比如上面提到的商品详情信息,在底层可能会关联商品表、价格表、库存表等,如果更新了一个价格字段,那么就要更新整个数据库,还要关联的去查询和汇总各个周边业务系统的数据,这个操作会非常耗时。
从另外一个角度,不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长时间不被访问,所以说,从计算资源和整体性能的考虑,更新的时候删除缓存,等到下次查询命中再填充缓存,是一个更好的方案。
系统设计中有一个思想叫 Lazy Loading,适用于那些加载代价大的操作,删除缓存而不是更新缓存,就是懒加载思想的一个应用。多级缓存如何更新
再看一个实际应用中的问题,多级缓存如何更新?
多级缓存是系统中一个常用的设计,服务端缓存分为应用内缓存和外部缓存,比如在电商的商品信息展示中,可能会有多级缓存协同。
那么多级缓存之间如何同步数据呢?
常见的方案是通过消息队列通知的方式,也就是在数据库更新后,通过事务性消息队列加监听的方式,失效对应的缓存。
多级缓存比较难保证数据一致性,通常用在对数据一致性不敏感的业务中,比如新闻资讯类、电商的用户评论模块等。
上面的内容是几种常用的缓存和数据库的双写一致性方案,大家在开发中肯定应用过设计模式,这些缓存应用套路和设计模式一样,是前人在大量工程开发中的总结,是一个通用的解决范式。
在具体业务中,还是需要有针对性地进行设计,比如通过给数据添加版本号,或者通过时间戳 + 业务主键的方式,控制缓存的数据版本实现最终一致性。更多相关内容 -
两难!先更新数据库再删缓存?还是先删缓存再更新数据库?
2020-09-07 08:00:00前言当我们在做数据库与缓存数据同步时,究竟更新缓存,还是删除缓存,究竟是先操作数据库,还是先操作缓存?本文带大家深度分析数据库与缓存的双写问题,并且给出了所有方案的实现代码方便大家参考。...前言
当我们在做数据库与缓存数据同步时,究竟更新缓存,还是删除缓存,究竟是先操作数据库,还是先操作缓存?本文带大家深度分析数据库与缓存的双写问题,并且给出了所有方案的实现代码方便大家参考。
本篇文章主要内容
数据缓存
为何要使用缓存
哪类数据适合缓存
缓存的利与弊
如何保证缓存和数据库一致性
不更新缓存,而是删除缓存
先操作缓存,还是先操作数据库
非要保证数据库和缓存数据强一致该怎么办
缓存和数据库一致性实战
实战:先删除缓存,再更新数据库
实战:先更新数据库,再删缓存
实战:缓存延时双删
实战:删除缓存重试机制
实战:读取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-threads=10000 server.tomcat.max-connections=10000
可以看到,不使用缓存的情况下,吞吐量为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)自己消费消息,获得需要删除的key
(5)继续重试删除操作,直到成功
然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
方案二:
流程如下图所示:
(1)更新数据库数据
(2)数据库会将操作信息写入binlog日志当中
(3)订阅程序提取出所需要的数据以及key
(4)另起一段非业务代码,获得该信息
(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 SynchronousQueue<Runnable>()); /** * 缓存再删除线程 */ 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的依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
写一个RabbitMqConfig:
@Configuration public class RabbitMqConfig { @Bean public Queue delCacheQueue() { return new Queue("delCache"); } }
添加一个消费者:
@Component @RabbitListener(queues = "delCache") public class DelCacheReceiver { private static final Logger LOGGER = LoggerFactory.getLogger(DelCacheReceiver.class); @Autowired private StockService stockService; @RabbitHandler public 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 aside
Read through
Write through
Write behind caching,这里有陈皓的总结文章可以进行学习。
https://coolshell.cn/articles/17416.html
小结
引用陈浩《缓存更新的套路》最后的总结语作为小结:
分布式系统里要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率
缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP,BASE理论。
异构数据库本来就没办法强一致,只是尽可能减少时间窗口,达到最终一致性。
还有别忘了设置过期时间,这是个兜底方案
结束语
本文总结并探讨了缓存数据库双写一致性问题。
文章内容大致可以总结为如下几点:
对于读多写少的数据,请使用缓存。
为了保持数据库和缓存的一致性,会导致系统吞吐量的下降。
为了保持数据库和缓存的一致性,会导致业务代码逻辑复杂。
缓存做不到绝对一致性,但可以做到最终一致性。
对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计。
作者水平有限,写文章过程中难免出现错误和疏漏,请理性讨论与指正。
参考
https://cloud.tencent.com/developer/article/1574827
https://www.jianshu.com/p/2936a5c65e6b
https://www.cnblogs.com/rjzheng/p/9041659.html
https://www.cnblogs.com/codeon/p/8287563.html
https://www.jianshu.com/p/0275ecca2438
https://www.jianshu.com/p/dc1e5091a0d8
https://coolshell.cn/articles/17416.html
如果文章对你有帮助,不妨点赞,收藏起来~
往期推荐
阿里为什么推荐使用LongAdder,而不是volatile?
关注下方二维码,收获更多干货!
-
先更新缓存还是先更新数据库
2020-06-26 21:08:45文章目录概览先更新缓存,再更新数据库考虑两个并发操作:线程A写,线程B读考虑两个并发操作:线程A写,线程B写先更新数据库,再更新缓存考虑两个并发操作:线程A写,线程B读考虑两个并发操作:线程A写,线程B写 ...文章目录
一、提前阅读
讨论这个问题之前可以先看下缓存模式(Cache Aside、Read Through、Write Through、Write Behind)这篇文章。
二、先更新缓存,再更新数据库
1、考虑并发操作:线程A写,线程B读
- 1、线程A发起一个写操作,第一步delete cache
- 2、此时线程B发起一个读操作,cache miss
- 3、线程B继续读数据库,读出来一个老数据
- 4、然后老数据入cache
- 5、线程A写入了最新的数据
这样以后每次从缓存中读到的都是老数据,造成数据不一致。
既然这种情况下先删除缓存会有数据不一致的情况,那我们来试试第一步不删除缓存而是直接更新缓存试试看。
2、考虑并发操作:线程A写,线程B写
- 1、线程A发起一个写操作,第一步set cache
- 2、线程B发起一个写操作,第一步set cache
- 3、线程B写入数据到数据库
- 4、线程A写入数据到数据库
这样以后每次从缓存中读到的都是线程B设置的数据,但数据库中存储的是线程A写入的数据,导致数据不一致。
3、小结
可看到先操作缓存不论是先删除缓存还是先更新缓存都会发生数据不一致的情况,所以不推荐这两种做法。
从理论上说,只要我们设置了缓存的过期时间,我们就能保证缓存和数据库的数据最终是一致的。因为只要缓存数据过期了,就会被删除。随后读的时候,因为缓存里没有,就得去查数据库的数据,然后将查出来的数据写入到缓存中。除了设置过期时间,我们还需要做更多的措施来尽量避免数据库与缓存处于不一致的情况发生。但是设置过期时间是基本操作,只要数据不是静态数据,就应该给缓存中的此类数据设置过期时间,它并不是解决“先更新缓存,再更新数据库”造成数据不一致问题的方法。
三、先更新数据库,再更新缓存
1、考虑并发操作:线程A写,线程B读
- 1、线程A发起一个写操作,第一步写入数据到数据库
- 2、线程A第二步delete cache
- 3、线程B发起一个读操作,cache miss
- 4、线程B从数据库获取最新数据
- 5、线程B同时set cache
一个是读操作,一个是写操作的并发,首先没有了文章开始删除cache数据的操作了,而是先更新了数据库中的数据,此时缓存依然有效,所以此时读操作查到的是没有更新的旧数据,但是更新操作马上让缓存失效了,后续的查询操作再把数据从数据库中查出来。而不会像文章开头的那个逻辑产生的问题,即后续的查询操作一直都在读旧的数据。
这是标准的
Cache Aside Pattern
:
包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据,这个问题我们下面会说到。特殊情况:
在高并发的场景下,可能会出现数据库与缓存数据不一致的的情况,考虑下面情形:
- 1、线程A发起一个写操作,还未操作数据库
- 2、缓存刚好失效
- 3、线程B查询数据库,得一个旧值
- 4、线程A将新值写入数据库
- 5、线程A删除缓存
- 6、线程B将查到的旧值写入缓存
但是这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必须在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率其实并不大。
2、考虑并发操作:线程A写,线程B写,线程C读
- 1、线程A发起一个写操作,第一步写入数据到数据库
- 2、线程B发起一个写操作,第一步写入数据到数据库
- 3、线程B第二步delete cache
- 4、线程C发起一个读操作,cache miss
- 5、线程C从数据库获取最新数据
- 6、线程C同时set cache
- 7、线程A第二步delete cache
该情况下由于线程A、B最初都把数据写入了数据库,接着都有delete cache,此时如果有线程C来读数据,你会发现不管线程C的动作做任意顺序穿插在A、B动作之间,最后查询数据最差也就是在线程A、B删除cache之前获取到了旧数据,其余都会获取到新数据,并不会影响后来的请求获取到新数据。
为什么最后是把缓存的数据删掉,而不是把更新的数据写到缓存里。这么做引发的问题是,如果A、B两个线程同时做数据更新,A先更新了数据库,B后更新数据库,则此时数据库里存的是B的数据。而更新缓存的时候,是B先更新了缓存,而A后更新了缓存,则缓存里是A的数据。这样缓存和数据库的数据会发生不一致。
3、小结
- 先操作数据库再删除缓存能有让人可接受的结果,所以最推荐这种做法。
- 先操作缓存再更新数据库可能造成数据不一致的场景,不推荐这种做法。
四、异常情况
上面的讨论与对比都是在
更新缓存
和更新数据库
这两步操作都成功的情况下叙述的。当然系统正常运行时的操作基本上都是成功的,那么如果两步操作有其中一步操作失败了呢?(以先操作数据库再操作缓存举例)- 第一步失败:这种情况很简单,不会影响第二步操作,也不会影响数据一致性,直接抛异常出去就好了。
- 第二步失败:
- 将需要删除的缓存key发送到消息队列中
- 另起终端消费队列消息,获得需要删除的缓存key
- 设置重试删除操作,超过最大重试次数(比如5次)后将消息转入死信队列并报警给运维人员
五、参考
-
python操作之更新数据库中某个字段的数据
2020-11-17 20:02:24请注意这是连接数据库操作,还不是更新。 import pymysql #导包 #连接数据库 db = pymysql.connect(host='localhost', user='用户名', password='数据库密码', port=3306, db='你的数据库名字') #定义游标 cursor = ...连接数据库基本操作,我把每一步的操作是为什么给大家注释一下,老手自行快进。
请注意这是连接数据库操作,还不是更新。
import pymysql #导包 #连接数据库 db = pymysql.connect(host='localhost', user='用户名', password='数据库密码', port=3306, db='你的数据库名字') #定义游标 cursor = db.cursor() #sql语句 sql = 'select * from students;' cursor.execute(sql) #关闭游标 cursor.close() #数据回滚 db.rollback() #关闭数据库 db.close()
更新数据库中单个字段的值
上面注释过的这里就不在注释了,直接上代码,为了让大家看到更直观我就不改数据库的相关信息了
import pymysql #连接数据库 db = pymysql.connect(host='localhost',user='root', password='123456', db='qu') #定义游标 cursor = db.cursor() id=input("请输入要更新的id") k = input("请输入要更新的字段名:") v = input("请输入更新后的值:") try: update = "update test set "+k+"='"+v+"' where id="+id print(update) cursor.execute(update) print('数据更新成功') db.commit()#提交数据 except: print('数据更新失败') db.rollback() cursor.close() db.close()
这里我的k,v指的是键值对,意思就是我输入想要更新的字段里的数据,那么我先输入这个字段所在的id,在输入想修改的字段,再输入此字段想要修改的内容。
大家看我原来的数据表
比如我想修改这里的id为3的name字段里的内容,现在为66,我们想要更新为520,代码运行如下
让我们在回到数据库,这里要先刷新数据库
看!原来的66是不是就变成520了
我们再改变talk这个字段的内容试试
如果你觉得还不错就点个赞吧
新手博主,如有错误请前辈批评指正——@丁一
-
Windows——[检测到可能的windows 更新数据库错误]解决方案
2019-12-03 18:02:51使用疑难解答后 显示 “检测到可能的windows 更新数据库错误” 且无法修复 解决方案 在管理员命令提示符下键入以下命令: Dism /Online /Cleanup-Image /ScanHealth 这条命令将扫描全部系统文件并和官方系统... -
python更新数据库中某个字段的数据(方法详解)
2020-12-15 11:12:55请注意这是连接数据库操作,还不是更新。import pymysql #导包#连接数据库db = pymysql.connect(host='localhost', user='用户名', password='数据库密码', port=3306, db='你的数据库名字')#定义游标cursor = db.... -
flask 更新数据库操作
2020-02-19 16:43:371、安装数据库管理插件 虚拟环境:pipenv install flask-migrate 安装完成后 2、在app.py文件中创建实例: migrate = Migrate(app,db) 3、命令行执行:flask db init ,进行...5、更新数据库:flask db upgrad... -
C# WinForm DataGridView同步更新数据库 TreeView最简单的绑定数据库
2010-03-25 16:59:45C# WinForm DataGridView同步更新数据库 TreeView最简单的绑定数据库 要注意:数据库的表必须有主键。 -
到底是先更新数据库还是先更新缓存?
2021-03-19 00:51:19如何保证数据库和缓存数据的一致性? -
微信小程序云函数更新数据库通用代码
2020-05-11 16:27:01在小程序中如果需要修改其他用户的数据,经常需要在云函数进行,而无法在本地小程序进行数据库的修改,经常需要云函数,今天给大家写一个通用的云函数更新数据库的代码 云函数代码 const cloud = require('wx-server... -
更新数据是先更新缓存(redis),还是先更新数据库(mysql)
2019-11-15 21:26:15老师,上次面试我的回答是先更新数据库,在更新缓存。 但是面试官给我的回答是,先更新缓存,再更新数据库。 后来我也上网查了下,先更新缓存,再异步将缓存中的数据同步到数据库。 但是我并没有查到相关的代码逻辑... -
Idea中无法更新数据库表的问题以及解决方法
2021-03-13 17:47:351.在SQLyog中创建了一张表-user,并在Idea的数据库界面连接到了这个对应的数据库,并可以看到这张表user。 2.在SQLyog中再创建两张表-student,teacher时,此时在Idea的数据库界面却看不到这两张新增的表。 问题... -
EF Core Code First初始化及更新数据库
2019-08-28 11:22:136、更新数据库 1、添加实体类 using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text; ... -
python django项目更新数据库表结构
2018-10-15 10:01:55python django项目更新数据库表结构 使用django框架自带ORM数据库映射,有些时候数据库表想做更新,如何修改呢。 第一步: 进入数据库删除你所创建的数据库。 第二部: 进入django你所创建的app,再进入文件夹... -
java多线程批量更新数据库的数据
2019-08-01 15:24:07需求:更新机构表的字段,该字段的内容为包括当前机构的机构号和所有父机构的机构号以逗号拼接的形式保存到数据库 该表的数据量大约为10万条(数据库类型:postgresql) 10万条数据每1000条跑一次事务(机构表:... -
小程序云开发(更新数据库数据)
2019-09-29 21:08:42// 根据_id(添加数据的时候会自动创建_id)更新数据库数据 onCounterInc: function() { const db = wx.cloud.database() const newCount = this.data.count + 1 //更新集合counters里的_id为"f885cb355d90a28e... -
EF CodeFirst下,当实体结构发生修改怎么更新数据库结构
2018-06-24 17:50:45在使用EF的实际编程中我们经常遇到这样的问题:发现实体结构需要新增加一个字段,或者减少一个字段,急需把实体结构修改,并让数据库更新这种修改。在用Model First或者Database First的情况下我们可以通过在实体... -
为什么你的缓存更新策略是先更新数据库后删除缓存,讲讲其他的情况有什么问题?...
2019-03-25 22:34:00这么做引发的问题是,如果A,B两个线程同时要更新数据,并且A,B已经都做完了删除缓存这一步,接下来,A先更新了数据库,C线程读取数据,由于缓存没有,则查数据库,并把A更新的数据,写入了缓存,最后B更新数据库。... -
帆软更新数据库配置
2019-01-12 21:29:03etl数据库更新或者切换数据库的时候帆软需要配置如下: 1.本地连接的数据源记得换。 2.服务器里面的数据库信息也需要更新,D:\apache-tomcat-8.5.6_8.0\webapps\WebReport\WEB-INF\resources datasoure.xml. ... -
深入理解分布式技术 - 先更新数据库,还是先更新缓存
2021-03-18 19:31:16文章目录Pre数据不一致更新缓存的方式先更新数据库,再更新缓存先删缓存,再更新数据库先更新数据库,再删缓存 Pre 深入理解分布式技术 - 探究缓存穿透、缓存击穿、缓存雪崩解决方案 缓存穿透、缓存击穿和缓存雪崩... -
使用JPA查询到的对象属性被set后,自动执行update语句,更新数据库
2020-05-14 09:28:49记一次开发bug1、Spring data JPA查询到的对象被set值后,自动更新数据库 1、Spring data JPA查询到的对象被set值后,自动更新数据库 做项目开发的时候遇到这样一个问题:数据库有临时表和正式表,数据审批通过后才... -
SQL语句:批量更新数据库中的日期时间或者指定字符串
2019-06-26 14:18:52最近在批量修改一批数据,遇到了需要批量修改数据库中日期时间的问题,现总结如下: 1、批量替换日期中的年份(以替换为2019年为例) update 数据表名 set 日期时间字段=STUFF(convert(nvarchar(23),日期时间字段... -
微信小程序使用 update更新数据库表格内容提示更新成功 但内容不变 解决办法
2020-05-16 21:57:57今天在写小程序的时候想要更新数据库指定表格的内容的时候使用了数据库的 update 方法,但是console 也能提示更新成功 在后台看数据库内容却没有改变 解决办法 在表格里添加字段_openid 值为自己_openid的值 ... -
DataGridView直接修改数据并且更新数据库
2016-08-09 23:56:273、可通过CellValueChanged事件来实现更新数据库的数据数据链接 private SqlConnection connection () { string strconn = "server = XQ-20160210KQLE\\SA;uid = sa;pwd = 123456;database = JYXinXi... -
mysql更新数据库中所有相同的某个字段的值
2017-07-31 15:11:001. 查看所有的数据表 -- 查看所有数据表; SHOW TABLES; 2. 查看某个数据表的所有的字段-- 查看表tb_watch_back的...3. 查询数据库中某个字段以及所在的表-- 使用 INFORMATION_SCHEMA.Columns查询所有的表以及字段 SE -
Django如何更新数据库
2014-12-31 10:14:12总所周知Django对数据库的操作包装做的非常靓,绝大数对数据库的访问全都转变成了普通的对象访问,对Web编程而言确实是个福音吧。Manage.py中有个syncdb命令就体现出了这个意思,但是这个命令也有个缺陷:即只能创建... -
一个事物下jpa更新数据库实体对象属性后自动update
2018-12-13 00:29:27记录今天偶然发现的一个隐藏的bug 具体是这样的:在一个事物中查询出一条记录例如Student id=1,然后修改student对象的...最后发现数据库中除了age更新之外,name字段也做了更新,一条update 指令执行了两次update。... -
C#访问SQL显示到DataGridView,同时根据DataGridView实时更新数据库
2009-07-09 08:46:02C#实现将SQL数据库的内容读取出来显示到DataGridview中,同时根据DataGridview更新数据库内容 -
如何快速更新数据库中的百万条数据
2018-10-25 14:04:00https://blog.csdn.net/abcd1f2/article/details/51249698 -
使用Idea更新数据库表的数据
2018-01-21 11:18:44tags: IDEA 我们在做案例的时候,经常需要改变数据表中的数据来进行简单测试。 那我们在Idea下是如何修改数据表的数据的呢???我们可以看下面的图片 只要选择updata就行了,后面再按自动提交的标志: ...