啊哈!缓存
缓存在分布式系统中应用广泛,如何在架构设计中使用缓存来优化业务一直都是一个重要的话题。本文主要对引入缓存需要解决的问题以及一些优秀的实践,让读者对缓存有一个比较宏观的了解。
一、无处不在的缓存
缓存对性能的提升十分明显,特别是在分布式系统中,80%的业务访问集中在 20%的数据上,如何用好缓存是架构设计的必修课。
目前,很多系统框架可能会涉及到方方面面,如下图所示:
从客户端访问,到最终的数据存储,整个流程中有各式各样的缓存,主要有如下几个部分:
客户端缓存
对于互联网通常来说的是 BS 架构应用,可以分为页面缓存和浏览器缓存;
对于移动互联网来说,指的是 APP 自身所使用的缓存。
代理服务器缓存(如 Nginx)
向用户提供静态内容,内容缓存等
分布式缓存
如 Redis,可以供分布式下的应用使用,提高查询效率
数据库缓存
Mysql 使用了查询缓冲机制,将 select 语句和查询结果放在缓冲区中,以后对同样的 SQL 语句,将直接从缓冲区中读取结果,节省查询时间,提高 SQL 查询的效率。
本地缓存
如 Ehcache、Guava,应用自身使用
... ...
本质
空间换时间 - 利用分布式下不同介质的快速存储设备,来替换数据库,加快系统的数据处理和响应速度。
就近原则 - 将数据缓存到离用户最近的位置;将数据缓存到离应用最近的位置。
二. 缓存要解决的问题
引入缓存我们获取的数据的过程就变成如图所示:
先从缓存中获取数据,如果有则直接返回
如果没有命中,则查数据库。
如果没有值,则直接返回提示信息,如未找到 XXX
如果有值,则将值存入缓存供下次查询使用,并返回数据
引入一方面减轻了数据库的压力、提升查询性能、提供吞吐量,但同时也要考虑诸多带来的问题,如:
缓存穿透
缓存雪崩
缓存并发
一致性问题
缓存升级
数据迁移
... ...
接下来,我们一个个来看看。
1、缓存穿透
缓存穿透指的是使用不存在的 key 进行大量的高并发查询,这导致缓存无法命中。每次请求都要穿透到后端数据库系统进行查询,使数据库压力过大,甚至使数据库被压死。
从缓存视角看无法在缓存中找到记录;从数据库视角看无法在数据库中找到记录。缓存穿透的解决方法,可以通过空对象(NullObject)或者 布隆过滤器来解决。
空对象(NullObject)
我们通常将空值缓存起来,再次接收到同样的查询请求时。若命中缓存并且值为空对象,就会转换成业务需要的结果返回(包含错误码和结果不存在的错误信息),这样就不会透传到数据库,避免缓存穿透。
布隆过滤器
将所有可能存在的数据哈希到一个足够大的 bitmap 中去,不存在的数据会被 bitmap 拦截,从而避免了对底层存储系统的压力。
布隆过滤器可以使用 Guava 来做,一个简单的示例如下:
2、缓存雪崩
缓存雪崩指在服务器重启或者大量缓存集中在某一个时间段内失效,给后端数据库造成瞬时的负载升高的压力,甚至压垮数据库的情况。
缓存雪崩一个简单有效的解决方法就是设置不同的失效时间。通常的解决办法是对不同的数据使用不同的失效时间。比如我们要缓存一个 Product 的数据,会对每个产品的缓存数据设置不同的缓存过期时间。可以定义一个基础时间,假设是 30 分钟,然后加上一个 5 分钟的随机数据,缓存数据将被打散,失效时间为 30~35 之间,这样就会避免缓存雪崩。
其他解决方法包括使用二级缓存,缓存不过期(可以通过其他定时或者某种策略来删除)等。
3、缓存并发
缓存并发的问题通常发生在高并发的场景下,当一个缓存 key 过期时候,因为访问这个缓存 key 的请求量较大,多个请求同时发现缓存过期。因此多个请求会同时访问数据库来查询最新的数据,并且写回缓存,这样造成应用和数据库的负载增加,性能降低。
缓存并发可以通过分布式锁、软过期等方法解决。
分布式锁
使用分布式锁,保证对每个 key 同时只有一个线程去查询后端服务,其他线程没有获得分布式锁的权限,因此只需要等待即可。这种方式将高并发的压力转移到了分布式锁,因此对分布式锁的考验很大。
软过期
对缓存的数据设置失效时间,就是不使用缓存服务提供的过期时间,而是业务在数据存储过期时间信息,由业务程序判断是否过期并更新,在发现了数据即将过期时,将缓存的失效延长,程序可以派遣一个线程去数据库获取最新的数据,其他线程这时看到了延长了的过期时间,就会继续使用旧数据,等派遣的线程获取最新数据后再更新缓存。
其它解决方法入手多级缓存、永不过期等。
4、一致性问题
当数据变化时候,为了去保持数据库和缓存数据的一致性,数据库和缓存处理的方式主要有 2 种:
方法 1: 先删除缓存,再更新数据库
方法 2:先更新数据库,再删除缓存(个人使用这个,推荐)
先删除缓存,再更新数据库
理由
1、原子性考量
2、因为如果删除缓存成功,更新数据库失败,最多只是会造成缓存穿透,引起一次 Cache miss,后面还会更新缓存。而如果更新数据库成功,删除缓存失败,会引起比较严重的数据不一致情况。
有很多同学选择该方式来处理,原因主要是如上所述吧。
但是,这个在并发的场景中,两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
这种方式也是有数据不一致的场景存在,当然可以通过双删除的方式解决,即更新数据库后再删除一次缓存。
我为什么没有选择这种方式,主要还是因为,我觉得数据还是要以数据库的数据为准,缓存应该是个辅助,其操作成功不意味着最终真正的成功。写缓存失败就不再写数据库,虽然保证了原子性,但这种做法对比较影响业务。所以,个人还是比较倾向于方法二:先更新数据库,再删除缓存。
先更新数据库,再删除缓存
注: 这里使用的是删除 del 而不是 set,之所以这样是怕两个并发的写操作导致脏数据。
这种主要采用了 Cache Aside Patter, 其主要的思想是:
失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从 cache 中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失
该种处理方式基本没有很大的并发问题,其造成脏数据的情况相对概率相对较低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。
脏数据场景
一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。
不过,这种情况下,还有其它的问题,比如如下情况:
数据库更新成功,但是数据库做读写分离的场景,是否会有问题?
数据库更新成功,缓存删除失败(比如网络抖动等),如何做?
针对读写分离的场景,我们可以对某些表进行设置,就从主库读取即可获取最新的数据。针对缓存删除失败的场景,可以引入消息队列,让业务收发消息队列,然后重试删除。
这种方式,业务逻辑需要考虑缓存失败的逻辑处理,对代码有侵入性。那么,有没有什么手段去让业务层只关注业务,而不需要考虑缓存的失效?
可以借助 Canal,来完成数据库 binlog 的订阅,然后完成缓存的更新操作,如:
这样处理之后,业务层就关注业务,缓存失效由非业务代码来处理,这样就分开了,如下图所示:
补充
关于先更新数据库还是先删除缓存,这些还是要根据自己的业务场景、特点来选择。个人而言,我更加倾向于【先更新数据库,后删除缓存】的方式。
5、本地缓存
在很多场景中,我们还会引入本地缓存(比如采用 Guava 来做),以增加查询效率。
场景
有两个(dubbo)应用 product 和 device,其中:
product 服务接口提供中获取产品详情的方法,包含产品基本信息定义,以及包含的数据点等定义。
device 服务需要使用到获取 product 的获取产品详情方法。
在上述场景中,详情的获取是这样的 device -->product --> 缓存,
因为产品的定义修改是低频的,所以如果在 device 中增加 product 详情的本地缓存,将减少调用 product 服务的次数,减少网络带来的开销,提升 device 相关接口的效率。
引入本地缓存后,变成如下模样。
这样一来,本地查询是加快了,但是同时也带来了如下的问题:
如果产品信息更新,分布式环境下 device 的应用服务可能有几十个,每个 device 应用中的本地缓存如何同步更新?
一个较靠谱的解决方法便是引入消息队列进行通知,采用广播的方式。
6、缓存的对象扩展
思考
前期对某个对象(如产品 Product)进行了缓存,缓存对象包含产品的名称、productKey。现在需要在该缓存对象中增加一个所述品类的的信息,需要在原来的基础上进行扩容。
上线发布需要注意些什么呢?
解决方法:
对需要改变的 key 的名字增加版本加以区分,如原来 prod:{productKey},改成 prod:{productKey}:v2
配置中心增加应急开关,用于是否启用新的 key
不能够在灰度的时候打开应急开关,因为,如果原来产品信息如名称有更新,则:
灰度机器处理的话,其不会更新原来的 key
非灰度机器处理的话,其不会更新新的 key
这两者都会造成产品信息查询的时候,有部分流量返回的数据是不正确的。
如线上发现问题,直接关闭开关
7、版本升级,数据迁移
思考
Redis 版本需要升级,比如 4.0 升级到 5.0,需要进行数据迁移。
如何做呢?
这个时候需要考虑业务场景了,是否是静态迁移还是动态迁移。
静态迁移(需要做好评估,一般在晚上交易量小或者非核心业务场景中用)
停机应用,先将应用停止
迁移历史数据,按照新的规则把历史数据迁移到新的缓存集群中
更改应用的数据源配置,指向新的缓存集群
重新启动应用
如果不能停机,进行数据迁移如何做呢?
平滑迁移(适合对可用性要求较高的场景,如停机会带来较大损失,无交易低峰)
双写。按照新旧规则同时往新缓存和旧缓存中写数据
迁移历史数据,如果在一定的时间内新缓存就有足够的数据,那么可以不需要进行此操作
切读。把应用层所有的读操作路由到新的缓存集群上
下线双写。把写入旧的逻辑下线(可以采用线上配置开关处理)
三. 缓存使用优秀实践
缓存系统主要消耗的是服务器的内存。因此,在使用缓存时必须先对应用需要缓存的数据大小进行评估,包括缓存的数据结构、数据大小、缓存数量、缓存的失效时间。
核心和非核心业务使用不同的缓存实例。
设置缓存超时时间,缓存超时设置过长,从而拖垮服务器的线程池,最终导致服务器雪崩的情况。
如果多个业务为了成本共用实例,缓存 key 需要添加业务唯一的前缀,防止缓存互相覆盖
key 的失效时间要打散,不要集中在一个点,否则导致缓存占满内存或者缓存雪崩
缓存的对象不宜过大,会阻塞其他请求的处理
一定要有降级开关机制,尤其是对关键的业务环节,缓存有问题或者失效时也能回源到数据库进行处理
所有的缓存实例一定要有监控,这个非常重要。我们需要对慢查询、大对象、内存使用情况等做可靠性的监控。
温馨提示
缓存是一个大的课题,可以做的事情很多,比如优化、稳定性等。
所谓技无止境,没有最好只有更好。在不同的场景,使用缓存可能会遇到这样那样的问题,还需要选择合适的方式去解决。
参考资料
[1]. https://www.cnblogs.com/jay-huaxiao/p/11462232.html
[2]. https://www.cnblogs.com/fanguangdexiaoyuer/p/9545181.html
[3]. https://blog.csdn.net/chuanyingcao2675/article/details/101048933
[4]. 李艳鹏等. 可伸缩服务架构-框架与中间件. 电子工业出版社
评论