摘要:总结允许的缓存写场景大部分情况,修改成本会高于增加一次,因此应该淘汰缓存如果还在纠结,总是淘汰缓存,问题也不大先操作数据库,还是先操作缓存这里分了两种观点,的观点沈老师的观点。这里我觉得沈老师可能忽略了并发的问题,比如说以下情况一个写请求
缓存误用
缓存,是互联网分层架构中,非常重要的一个部分,通常用它来降低数据库压力,提升系统整体性能,缩短访问时间。
有架构师说“缓存是万金油,哪里有问题,加个缓存,就能优化”,缓存的滥用,可能会导致一些错误用法。
缓存,你真的用对了么?
误用一:把缓存作为服务与服务之间传递数据的媒介
如上图:
服务1和服务2约定好key和value,通过缓存传递数据
服务1将数据写入缓存,服务2从缓存读取数据,达到两个服务通信的目的
该方案存在的问题是:
1、数据管道,数据通知场景,MQ更加适合
(1)MQ是互联网常见的逻辑解耦,物理解耦组件,支持1对1,1对多各种模式,非常成熟的数据通道,而cache反而会将service-A/B/C/D耦合在一起,大家要彼此协同约定key的格式,ip地址等
(2)MQ能够支持push,而cache只能拉取,不实时,有时延
(3)MQ天然支持集群,支持高可用,而cache未必
(4)MQ能支持数据落地,cache具备将数据存在内存里,具有“易失”性,当然,有些cache支持落地,但互联网技术选型的原则是,让专业的软件干专业的事情:nginx做反向代理,db做固化,cache做缓存,mq做通道
2、多个服务关联同一个缓存实例,会导致服务耦合
(1)大家要彼此协同约定key的格式,ip地址等,耦合
(2)约定好同一个key,可能会产生数据覆盖,导致数据不一致
(3)不同服务业务模式,数据量,并发量不一样,会因为一个cache相互影响,例如service-A数据量大,占用了cache的绝大部分内存,会导致service-B的热数据全部被挤出cache,导致cache失效;又例如service-A并发量高,占用了cache的绝大部分连接,会导致service-B拿不到cache的连接,从而服务异常
误用二:使用缓存未考虑雪崩
常规的缓存玩法,如上图:
服务先读缓存,缓存命中则返回
缓存不命中,再读数据库
什么时候会产生雪崩?
答:如果缓存挂掉,所有的请求会压到数据库,如果未提前做容量预估,可能会把数据库压垮(在缓存恢复之前,数据库可能一直都起不来),导致系统整体不可服务。
如何应对潜在的雪崩?
答:提前做容量预估,如果缓存挂掉,数据库仍能扛住,才能执行上述方案。
否则,就要进一步设计。
常见方案一:高可用缓存
如上图:使用高可用缓存集群,一个缓存实例挂掉后,能够自动做故障转移。
常见方案二:缓存水平切分
如上图:使用缓存水平切分(推荐使用一致性哈希算法进行切分),一个缓存实例挂掉后,不至于所有的流量都压到数据库上。
误用三:调用方缓存数据
如上图:
服务提供方缓存,向调用方屏蔽数据获取的复杂性(这个没问题)
服务调用方,也缓存一份数据,先读自己的缓存,再决定是否调用服务(这个有问题)
该方案存在的问题是:
1、调用方需要关注数据获取的复杂性(耦合问题)
2、更严重的,服务修改db里的数据,淘汰了服务cache之后,难以通知调用方淘汰其cache里的数据,从而导致数据不一致(带入一致性问题)
3、有人说,服务可以通过MQ通知调用方淘汰数据,额,难道下游的服务要依赖上游的调用方,分层架构设计不是这么玩的(反向依赖问题)
误用四:多服务共用缓存实例
如上图:服务A和服务B共用一个缓存实例(不是通过这个缓存实例交互数据)
该方案存在的问题是:
1、可能导致key冲突,彼此冲掉对方的数据
画外音:可能需要服务A和服务B提前约定好了key,以确保不冲突,常见的约定方式是使用namespace:key的方式来做key。
2、不同服务对应的数据量,吞吐量不一样,共用一个实例容易导致一个服务把另一个服务的热数据挤出去
3、共用一个实例,会导致服务之间的耦合,与微服务架构的“数据库,缓存私有”的设计原则是相悖的
建议的玩法是
如上图:各个服务私有化自己的数据存储,对上游屏蔽底层的复杂性。
总结
1、服务与服务之间不要通过缓存传递数据
2、如果缓存挂掉,可能导致雪崩,此时要做高可用缓存,或者水平切分
3、调用方不宜再多带带使用缓存存储服务底层的数据,容易出现数据不一致,以及反向依赖
4、不同服务,缓存实例要做垂直拆分
缓存,究竟是淘汰,还是修改?KV缓存都缓存了一些什么数据?
答:
(1)朴素类型的数据,例如:int
(2)序列化后的对象,例如:User实体,本质是binary
(3)文本数据,例如:json或者html
(4)...
淘汰缓存中的这些数据,修改缓存中的这些数据,有什么差别?
答:
(1)淘汰某个key,操作简单,直接将key置为无效,但下一次该key的访问会cache miss
(2)修改某个key的内容,逻辑相对复杂,但下一次该key的访问仍会cache hit
可以看到,差异仅仅在于一次cache miss。
缓存中的value数据一般是怎么修改的?
答:
(1)朴素类型的数据,直接set修改后的值即可
(2)序列化后的对象:一般需要先get数据,反序列化成对象,修改其中的成员,再序列化为binary,再set数据
(3)json或者html数据:一般也需要先get文本,parse成dom树对象,修改相关元素,序列化为文本,再set数据
结论:对于对象类型,或者文本类型,修改缓存value的成本较高,一般选择直接淘汰缓存。
问:对于朴素类型的数据,究竟应该修改缓存,还是淘汰缓存?
答:仍然视情况而定。
案例1:
假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,购买了一个商品pid=456。
分析:如果修改缓存,可能需要:
(1)去db查询pid的价格是50元
(2)去db查询活动的折扣是8折(商品实际价格是40元)
(3)去db查询用户的优惠券是10元(用户实际要支付30元)
(4)从cache查询get用户的余额是100元
(5)计算出剩余余额是100 - 30 = 70
(6)到cache设置set用户的余额是70
为了避免一次cache miss,需要额外增加若干次db与cache的交互,得不偿失。
结论:此时,应该淘汰缓存,而不是修改缓存。
案例2:
假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,需要扣减30元。
分析:如果修改缓存,需要:
(1)从cache查询get用户的余额是100元
(2)计算出剩余余额是100 - 30 = 70
(3)到cache设置set用户的余额是70
为了避免一次cache miss,需要额外增加若干次cache的交互,以及业务的计算,得不偿失。
结论:此时,应该淘汰缓存,而不是修改缓存。
案例3:
假设,缓存里存了某一个用户uid=123的余额是money=100元,业务场景是,余额要变为70元。
分析:如果修改缓存,需要:
(1)到cache设置set用户的余额是70
修改缓存成本很低。
结论:此时,可以选择修改缓存。当然,如果选择淘汰缓存,只会额外增加一次cache miss,成本也不高。
总结:
允许cache miss的KV缓存写场景:
大部分情况,修改value成本会高于“增加一次cache miss”,因此应该淘汰缓存
如果还在纠结,总是淘汰缓存,问题也不大
这里分了两种观点,Cache Aside Pattern的观点、沈老师的观点。下面两种观点分析一下。
Cache Aside Pattern什么是“Cache Aside Pattern”?
答:旁路缓存方案的经验实践,这个实践又分读实践,写实践。
对于读请求
先读cache,再读db
如果,cache hit,则直接返回数据
如果,cache miss,则访问db,并将数据set回缓存
(1)先从cache中尝试get数据,结果miss了
(2)再从db中读取数据,从库,读写分离
(3)最后把数据set回cache,方便下次读命中
对于写请求
先操作数据库,再淘汰缓存(淘汰缓存,而不是更新缓存)
如上图:
(1)第一步要操作数据库,第二步操作缓存
(2)缓存,采用delete淘汰,而不是set更新
Cache Aside Pattern为什么建议淘汰缓存,而不是更新缓存?
答:如果更新缓存,在并发写时,可能出现数据不一致。
如上图所示,如果采用set缓存。
在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:
(1)请求1先操作数据库,请求2后操作数据库
(2)请求2先set了缓存,请求1后set了缓存
导致,数据库与缓存之间的数据不一致。
所以,Cache Aside Pattern建议,delete缓存,而不是set缓存。
Cache Aside Pattern为什么建议先操作数据库,再操作缓存?
答:如果先操作缓存,在读写并发时,可能出现数据不一致。
如上图所示,如果先操作缓存。
在1和2并发读写发生时,由于无法保证时序,可能出现:
(1)写请求淘汰了缓存
(2)写请求操作了数据库(主从同步没有完成)
(3)读请求读了缓存(cache miss)
(4)读请求读了从库(读了一个旧数据)
(5)读请求set回缓存(set了一个旧数据)
(6)数据库主从同步完成
导致,数据库与缓存的数据不一致。
所以,Cache Aside Pattern建议,先操作数据库,再操作缓存。
Cache Aside Pattern方案存在什么问题?
答:如果先操作数据库,再淘汰缓存,在原子性被破坏时:
(1)修改数据库成功了
(2)淘汰缓存失败了
导致,数据库与缓存的数据不一致。
个人见解:这里个人觉得可以使用重试的方法,在淘汰缓存的时候,如果失败,则重试一定的次数。如果失败一定次数还不行,那就是其他原因了。比如说redis故障、内网出了问题。
关于这个问题,沈老师的解决方案是,使用先操作缓存(delete),再操作数据库。假如删除缓存成功,更新数据库失败了。缓存里没有数据,数据库里是之前的数据,数据没有不一致,对业务无影响。只是下一次读取,会多一次cache miss。这里我觉得沈老师可能忽略了并发的问题,比如说以下情况:
一个写请求过来,删除了缓存,准备更新数据库(还没更新完成)。
然后一个读请求过来,缓存未命中,从数据库读取旧数据,再次放到缓存中,这时候,数据库更新完成了。此时的情况是,缓存中是旧数据,数据库里面是新数据,同样存在数据不一致的问题。
如图:
答:发生写请求后(不管是先操作DB,还是先淘汰Cache),在主从数据库同步完成之前,如果有读请求,都可能发生读Cache Miss,读从库把旧数据存入缓存的情况。此时怎么办呢?
数据库主从不一致
先回顾下,无缓存时,数据库主从不一致问题。
如上图,发生的场景是,写后立刻读:
(1)主库一个写请求(主从没同步完成)
(2)从库接着一个读请求,读到了旧数据
(3)最后,主从同步完成
导致的结果是:主动同步完成之前,会读取到旧数据。
可以看到,主从不一致的影响时间很短,在主从同步完成后,就会读到新数据。
二、缓存与数据库不一致
再看,引入缓存后,缓存和数据库不一致问题。
如上图,发生的场景也是,写后立刻读:
(1+2)先一个写请求,淘汰缓存,写数据库
(3+4+5)接着立刻一个读请求,读缓存,cache miss,读从库,写缓存放入数据,以便后续的读能够cache hit(主从同步没有完成,缓存中放入了旧数据)
(6)最后,主从同步完成
导致的结果是:旧数据放入缓存,即使主从同步完成,后续仍然会从缓存一直读取到旧数据。
可以看到,加入缓存后,导致的不一致影响时间会很长,并且最终也不会达到一致。
三、问题分析
可以看到,这里提到的缓存与数据库数据不一致,根本上是由数据库主从不一致引起的。当主库上发生写操作之后,从库binlog同步的时间间隔内,读请求,可能导致有旧数据入缓存。
思路:那能不能写操作记录下来,在主从时延的时间段内,读取修改过的数据的话,强制读主,并且更新缓存,这样子缓存内的数据就是最新。在主从时延过后,这部分数据继续读从库,从而继续利用从库提高读取能力。
三、不一致解决方案
选择性读主
可以利用一个缓存记录必须读主的数据。
如上图,当写请求发生时:
(1)写主库
(2)将哪个库,哪个表,哪个主键三个信息拼装一个key设置到cache里,这条记录的超时时间,设置为“主从同步时延”
PS:key的格式为“db:table:PK”,假设主从延时为1s,这个key的cache超时时间也为1s。
如上图,当读请求发生时:
这是要读哪个库,哪个表,哪个主键的数据呢,也将这三个信息拼装一个key,到cache里去查询,如果,
(1)cache里有这个key,说明1s内刚发生过写请求,数据库主从同步可能还没有完成,此时就应该去主库查询。并且把主库的数据set到缓存中,防止下一次cahce miss。
(2)cache里没有这个key,说明最近没有发生过写请求,此时就可以去从库查询
以此,保证读到的一定不是不一致的脏数据。
PS:如果系统可以接收短时间的不一致,建议建议定时更新缓存就可以了。避免系统过于复杂。
进程内缓存除了常见的redis/memcache等进程外缓存服务,缓存还有一种常见的玩法,进程内缓存。
什么是进程内缓存?
答:将一些数据缓存在站点,或者服务的进程内,这就是进程内缓存。
进程内缓存的实现载体,最简单的,可以是一个带锁的Map。又或者,可以使用第三方库,例如leveldb、guave本地缓存
进程内缓存能存储啥?
答:redis/memcache等进程外缓存服务能存什么,进程内缓存就能存什么。
如上图,可以存储json数据,可以存储html页面,可以存储对象。
进程内缓存有什么好处?
答:与没有缓存相比,进程内缓存的好处是,数据读取不再需要访问后端,例如数据库。
如上图,整个访问流程要经过1,2,3,4四个步骤。
如果引入进程内缓存,
如上图,整个访问流程只要经过1,2两个步骤。
与进程外缓存相比(例如redis/memcache),进程内缓存省去了网络开销,所以一来节省了内网带宽,二来响应时延会更低。
进程内缓存有什么缺点?
答:统一缓存服务虽然多一次网络交互,但仍是统一存储。
如上图,站点和服务中的多个节点访问统一的缓存服务,数据统一存储,容易保证数据的一致性。
而进程内缓存,如上图,如果数据缓存在站点和服务的多个节点内,数据存了多份,一致性比较难保障。
如何保证进程内缓存的数据一致性?
答:保障进程内缓存一致性,有三种方案。
第一种方案
可以通过单节点通知其他节点。如上图:写请求发生在server1,在修改完自己内存数据与数据库中的数据之后,可以主动通知其他server节点,也修改内存的数据。如下图:
这种方案的缺点是:同一功能的一个集群的多个节点,相互耦合在一起,特别是节点较多时,网状连接关系极其复杂。
第二种方案
可以通过MQ通知其他节点。如上图,写请求发生在server1,在修改完自己内存数据与数据库中的数据之后,给MQ发布数据变化通知,其他server节点订阅MQ消息,也修改内存数据。
这种方案虽然解除了节点之间的耦合,但引入了MQ,使得系统更加复杂。
前两种方案,节点数量越多,数据冗余份数越多,数据同时更新的原子性越难保证,一致性也就越难保证。
第三种方案
为了避免耦合,降低复杂性,干脆放弃了“实时一致性”,每个节点启动一个timer,定时从后端拉取最新的数据,更新内存缓存。在有节点更新后端数据,而其他节点通过timer更新数据之间,会读到脏数据。
为什么不能频繁使用进程内缓存?
答:分层架构设计,有一条准则:站点层、服务层要做到无数据无状态,这样才能任意的加节点水平扩展,数据和状态尽量存储到后端的数据存储服务,例如数据库服务或者缓存服务。
可以看到,站点与服务的进程内缓存,实际上违背了分层架构设计的无状态准则,故一般不推荐使用。
什么时候可以使用进程内缓存?
答:以下情况,可以考虑使用进程内缓存。
情况一
只读数据,可以考虑在进程启动时加载到内存。
画外音:此时也可以把数据加载到redis / memcache,进程外缓存服务也能解决这类问题。
情况二
极其高并发的,如果透传后端压力极大的场景,可以考虑使用进程内缓存。
例如,秒杀业务,并发量极高,需要站点层挡住流量,可以使用内存缓存。
情况三
一定程度上允许数据不一致业务。
例如,有一些计数场景,运营场景,页面对数据一致性要求较低,可以考虑使用进程内页面缓存。
再次强调,进程内缓存的适用场景并不如redis/memcache广泛,不要为了炫技而使用。更多的时候,还是老老实实使用redis/mc吧。
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/61731.html
摘要:总结允许的缓存写场景大部分情况,修改成本会高于增加一次,因此应该淘汰缓存如果还在纠结,总是淘汰缓存,问题也不大先操作数据库,还是先操作缓存这里分了两种观点,的观点沈老师的观点。这里我觉得沈老师可能忽略了并发的问题,比如说以下情况一个写请求 缓存误用 缓存,是互联网分层架构中,非常重要的一个部分,通常用它来降低数据库压力,提升系统整体性能,缩短访问时间。 有架构师说缓存是万金油,哪里有问...
摘要:先更新数据库,再更新缓存这套方案,大家是普遍反对的。采用这种同步淘汰策略,吞吐量降低怎么办,那就将第二次删除作为异步的。比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况了。 引言 为什么写这篇文章? 首先,缓存由于其高并发和高性能的特性,已经在项目中被广泛使用。在读取缓存方面,大家没啥疑问,都是按照下图的流程来进行业务操作。 showImg(https://s...
摘要:本文会先阐述在并发编程中解决的问题多线程可见性,然后再详细讲解原则本身。所以与内存之间的高速缓存就是导致线程可见性问题的一个原因。原则上面讨论了中多线程共享变量的可见性问题及产生这种问题的原因。 Happens-Before是一个非常抽象的概念,然而它又是学习Java并发编程不可跨域的部分。本文会先阐述Happens-Before在并发编程中解决的问题——多线程可见性,然后再详细讲解H...
摘要:当缓存空间满了,同步失败,网络阻塞,缓存写失败等原因,会出现缓存服务器上并没有这个。这种问题,以前有过实践,修改数据库成功,而修改缓存失败的情况,最主要就是缓存服务器挂了。而缓存服务器挂了,请求首先自然也就无法到达,从而直接访问到数据库。 原文摘自: 缓存穿透、并发和失效,来自一线架构师的解决方案https://community.qingcloud.com/topic/463 在我们...
阅读 1087·2021-09-22 15:37
阅读 1113·2021-09-13 10:27
阅读 2431·2021-08-25 09:38
阅读 2428·2019-08-26 11:42
阅读 1504·2019-08-26 11:39
阅读 1531·2019-08-26 10:58
阅读 2260·2019-08-26 10:56
阅读 2551·2019-08-23 18:08