(讨论)缓存同步、如何保证缓存一致性、缓存误用

缓存误用

缓存,是互联网分层架构中,很是重要的一个部分,一般用它来下降数据库压力,提高系统总体性能,缩短访问时间。html

有架构师说“缓存是万金油,哪里有问题,加个缓存,就能优化”,缓存的滥用,可能会致使一些错误用法。nginx

缓存,你真的用对了么?redis

误用一:把缓存做为服务与服务之间传递数据的媒介
clipboard.png算法

如上图:
服务1和服务2约定好key和value,经过缓存传递数据
服务1将数据写入缓存,服务2从缓存读取数据,达到两个服务通讯的目的数据库

该方案存在的问题是:
一、数据管道,数据通知场景,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作通道json

二、多个服务关联同一个缓存实例,会致使服务耦合
(1)你们要彼此协同约定key的格式,ip地址等,耦合
(2)约定好同一个key,可能会产生数据覆盖,致使数据不一致
(3)不一样服务业务模式,数据量,并发量不同,会由于一个cache相互影响,例如service-A数据量大,占用了cache的绝大部份内存,会致使service-B的热数据所有被挤出cache,致使cache失效;又例如service-A并发量高,占用了cache的绝大部分链接,会致使service-B拿不到cache的链接,从而服务异常后端

误用二:使用缓存未考虑雪崩
clipboard.png缓存

常规的缓存玩法,如上图:
服务先读缓存,缓存命中则返回
缓存不命中,再读数据库网络

何时会产生雪崩?
答:若是缓存挂掉,全部的请求会压到数据库,若是未提早作容量预估,可能会把数据库压垮(在缓存恢复以前,数据库可能一直都起不来),致使系统总体不可服务。架构

如何应对潜在的雪崩?
答:提早作容量预估,若是缓存挂掉,数据库仍能扛住,才能执行上述方案。

不然,就要进一步设计。

常见方案一:高可用缓存
clipboard.png

如上图:使用高可用缓存集群,一个缓存实例挂掉后,可以自动作故障转移。

常见方案二:缓存水平切分
clipboard.png

如上图:使用缓存水平切分(推荐使用一致性哈希算法进行切分),一个缓存实例挂掉后,不至于全部的流量都压到数据库上。

误用三:调用方缓存数据
clipboard.png

如上图:
服务提供方缓存,向调用方屏蔽数据获取的复杂性(这个没问题)
服务调用方,也缓存一份数据,先读本身的缓存,再决定是否调用服务(这个有问题)

该方案存在的问题是:
一、调用方须要关注数据获取的复杂性(耦合问题)
二、更严重的,服务修改db里的数据,淘汰了服务cache以后,难以通知调用方淘汰其cache里的数据,从而致使数据不一致(带入一致性问题)
三、有人说,服务能够经过MQ通知调用方淘汰数据,额,难道下游的服务要依赖上游的调用方,分层架构设计不是这么玩的(反向依赖问题)

误用四:多服务共用缓存实例
clipboard.png

如上图:服务A和服务B共用一个缓存实例(不是经过这个缓存实例交互数据)

该方案存在的问题是:

一、可能致使key冲突,彼此冲掉对方的数据
画外音:可能须要服务A和服务B提早约定好了key,以确保不冲突,常见的约定方式是使用namespace:key的方式来作key。

二、不一样服务对应的数据量,吞吐量不同,共用一个实例容易致使一个服务把另外一个服务的热数据挤出去

三、共用一个实例,会致使服务之间的耦合,与微服务架构的“数据库,缓存私有”的设计原则是相悖的

建议的玩法是
clipboard.png

如上图:各个服务私有化本身的数据存储,对上游屏蔽底层的复杂性。

总结
一、服务与服务之间不要经过缓存传递数据

二、若是缓存挂掉,可能致使雪崩,此时要作高可用缓存,或者水平切分

三、调用方不宜再单独使用缓存存储服务底层的数据,容易出现数据不一致,以及反向依赖

四、不一样服务,缓存实例要作垂直拆分

缓存,到底是淘汰,仍是修改?

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回缓存

clipboard.png

(1)先从cache中尝试get数据,结果miss了
(2)再从db中读取数据,从库,读写分离
(3)最后把数据set回cache,方便下次读命中

对于写请求
先操做数据库,再淘汰缓存(淘汰缓存,而不是更新缓存)
clipboard.png

如上图:
(1)第一步要操做数据库,第二步操做缓存
(2)缓存,采用delete淘汰,而不是set更新

Cache Aside Pattern为何建议淘汰缓存,而不是更新缓存?
答:若是更新缓存,在并发写时,可能出现数据不一致。
clipboard.png

如上图所示,若是采用set缓存。

在1和2两个并发写发生时,因为没法保证时序,此时无论先操做缓存仍是先操做数据库,均可能出现:
(1)请求1先操做数据库,请求2后操做数据库
(2)请求2先set了缓存,请求1后set了缓存
致使,数据库与缓存之间的数据不一致。
因此,Cache Aside Pattern建议,delete缓存,而不是set缓存。

Cache Aside Pattern为何建议先操做数据库,再操做缓存?
答:若是先操做缓存,在读写并发时,可能出现数据不一致。
clipboard.png

如上图所示,若是先操做缓存。

在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,读从库把旧数据存入缓存的状况。此时怎么办呢?

数据库主从不一致
先回顾下,无缓存时,数据库主从不一致问题。
clipboard.png

如上图,发生的场景是,写后马上读:
(1)主库一个写请求(主从没同步完成)
(2)从库接着一个读请求,读到了旧数据
(3)最后,主从同步完成
致使的结果是:主动同步完成以前,会读取到旧数据。

能够看到,主从不一致的影响时间很短,在主从同步完成后,就会读到新数据。

2、缓存与数据库不一致
再看,引入缓存后,缓存和数据库不一致问题。
clipboard.png

如上图,发生的场景也是,写后马上读:
(1+2)先一个写请求,淘汰缓存,写数据库

(3+4+5)接着马上一个读请求,读缓存,cache miss,读从库,写缓存放入数据,以便后续的读可以cache hit(主从同步没有完成,缓存中放入了旧数据)

(6)最后,主从同步完成

致使的结果是:旧数据放入缓存,即便主从同步完成,后续仍然会从缓存一直读取到旧数据。

能够看到,加入缓存后,致使的不一致影响时间会很长,而且最终也不会达到一致。

3、问题分析
能够看到,这里提到的缓存与数据库数据不一致,根本上是由数据库主从不一致引发的。当主库上发生写操做以后,从库binlog同步的时间间隔内,读请求,可能致使有旧数据入缓存。

思路:那能不能写操做记录下来,在主从时延的时间段内,读取修改过的数据的话,强制读主,而且更新缓存,这样子缓存内的数据就是最新。在主从时延事后,这部分数据继续读从库,从而继续利用从库提升读取能力。

3、不一致解决方案
选择性读主
能够利用一个缓存记录必须读主的数据。
clipboard.png

如上图,当写请求发生时:
(1)写主库
(2)将哪一个库,哪一个表,哪一个主键三个信息拼装一个key设置到cache里,这条记录的超时时间,设置为“主从同步时延”
PS:key的格式为“db:table:PK”,假设主从延时为1s,这个key的cache超时时间也为1s。

clipboard.png

如上图,当读请求发生时:
这是要读哪一个库,哪一个表,哪一个主键的数据呢,也将这三个信息拼装一个key,到cache里去查询,若是,
(1)cache里有这个key,说明1s内刚发生过写请求,数据库主从同步可能尚未完成,此时就应该去主库查询。而且把主库的数据set到缓存中,防止下一次cahce miss。
(2)cache里没有这个key,说明最近没有发生过写请求,此时就能够去从库查询

以此,保证读到的必定不是不一致的脏数据。

PS:若是系统能够接收短期的不一致,建议建议定时更新缓存就能够了。避免系统过于复杂。

进程内缓存

除了常见的redis/memcache等进程外缓存服务,缓存还有一种常见的玩法,进程内缓存。

什么是进程内缓存?

答:将一些数据缓存在站点,或者服务的进程内,这就是进程内缓存。

进程内缓存的实现载体,最简单的,能够是一个带锁的Map。又或者,可使用第三方库,例如leveldb、guave本地缓存

进程内缓存能存储啥?

答:redis/memcache等进程外缓存服务能存什么,进程内缓存就能存什么。

clipboard.png

如上图,能够存储json数据,能够存储html页面,能够存储对象。

进程内缓存有什么好处?

答:与没有缓存相比,进程内缓存的好处是,数据读取再也不须要访问后端,例如数据库。
clipboard.png
如上图,整个访问流程要通过1,2,3,4四个步骤。

若是引入进程内缓存,
clipboard.png
如上图,整个访问流程只要通过1,2两个步骤。

与进程外缓存相比(例如redis/memcache),进程内缓存省去了网络开销,因此一来节省了内网带宽,二来响应时延会更低。

进程内缓存有什么缺点?

答:统一缓存服务虽然多一次网络交互,但还是统一存储。
clipboard.png
如上图,站点和服务中的多个节点访问统一的缓存服务,数据统一存储,容易保证数据的一致性。

clipboard.png
而进程内缓存,如上图,若是数据缓存在站点和服务的多个节点内,数据存了多份,一致性比较难保障。

如何保证进程内缓存的数据一致性?
答:保障进程内缓存一致性,有三种方案。

第一种方案
能够经过单节点通知其余节点。如上图:写请求发生在server1,在修改完本身内存数据与数据库中的数据以后,能够主动通知其余server节点,也修改内存的数据。以下图:
clipboard.png

这种方案的缺点是:同一功能的一个集群的多个节点,相互耦合在一块儿,特别是节点较多时,网状链接关系极其复杂。

第二种方案
能够经过MQ通知其余节点。如上图,写请求发生在server1,在修改完本身内存数据与数据库中的数据以后,给MQ发布数据变化通知,其余server节点订阅MQ消息,也修改内存数据。
clipboard.png

这种方案虽然解除了节点之间的耦合,但引入了MQ,使得系统更加复杂。

前两种方案,节点数量越多,数据冗余份数越多,数据同时更新的原子性越难保证,一致性也就越难保证。

第三种方案
为了不耦合,下降复杂性,干脆放弃了“实时一致性”,每一个节点启动一个timer,定时从后端拉取最新的数据,更新内存缓存。在有节点更新后端数据,而其余节点经过timer更新数据之间,会读到脏数据。
clipboard.png

为何不能频繁使用进程内缓存?

答:分层架构设计,有一条准则:站点层、服务层要作到无数据无状态,这样才能任意的加节点水平扩展,数据和状态尽可能存储到后端的数据存储服务,例如数据库服务或者缓存服务。
能够看到,站点与服务的进程内缓存,实际上违背了分层架构设计的无状态准则,故通常不推荐使用。

何时可使用进程内缓存?

答:如下状况,能够考虑使用进程内缓存。

状况一
只读数据,能够考虑在进程启动时加载到内存。
画外音:此时也能够把数据加载到redis / memcache,进程外缓存服务也能解决这类问题。

状况二
极其高并发的,若是透传后端压力极大的场景,能够考虑使用进程内缓存。
例如,秒杀业务,并发量极高,须要站点层挡住流量,可使用内存缓存。

状况三
必定程度上容许数据不一致业务。
例如,有一些计数场景,运营场景,页面对数据一致性要求较低,能够考虑使用进程内页面缓存。

再次强调,进程内缓存的适用场景并不如redis/memcache普遍,不要为了炫技而使用。更多的时候,仍是老老实实使用redis/mc吧。

相关文章
相关标签/搜索