一、Redis缓存设计 

一、redis缓存设计 
1.缓存 
缓存能够有效加速应用的读写速度,同时也可以降低后端负载。
缓存的收益:
加速读写:因为缓存通常都是全内存的(例如redis,memcache),而存储层通常读写性能 
不够强悍(如MySQL),通过缓存的使用可以有效加速读写,优化用户体验。
降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度
降低了后端的负载。

成本如下:
数据不一致:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。
代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。
运维成本:以redis cluster为例,加入后无形中增加了运维成本。

缓存使用的场景
开销大的复杂计算:以MySQL为例子,一些复杂的操作或者计算,如果不加缓存,不但 
无法满足高并发量,同时也会给MySQL带来巨大的负担。
加速请求响应:即使查询单条后端数据足够快,那么依然可以使用缓存,以redis为例子,
每秒可以完成数万次读写,并提供的批量操作可以优化整个IO链的响应时间。

2.缓存更新策略
缓存中的数据通常都是有生命周期的,需要在指定的时间后被删除或更新,这样可以保证缓存
空间在一个可控范围。但是缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,
需要利用某些策略更新。

(1)LRU/LFU/FIFO算法剔除  
使用场景:
剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。
例如redis使用 maxmemory-policy 这个配置作为内存最大值后对于数据的剔除策略。
一致性:要清理哪些数据由具体算法决定,开发人员只能决定使用哪种算法,所以数据
的一致性是最差的。
维护成本:算法不需要开发人员来实现,通常只需要配置最大maxmemory和对应策略即可。
(2)超时剔除 
使用场景:
超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如redis提供
的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以
为其设置过期时间。在数据过期后,再从真实数据源获取数据,重写放到缓存并设置
过期时间。
一致性:一段时间窗口内,存在一致性问题,即缓存数据和真实数据源的数据不一致。
维护成本:维护成本不是很高,只需要设置expire过期时间即可,当然前提是应用方允许 
这段时间可能发生的数据不一致。
(3)主动更新 
使用场景:
应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。
例如可以利用消息系统或者其他方式通知缓存更新。
一致性:一致性最高,但是如果主动更新发生了 问题,那么这条数据很可能很长时间不会
更新,所以建议结合超时剔除一起使用效果更好。
维护成本:维护成本比较高,开发者需要自己来完成更新。

最佳实践:
低一致性业务建议配置最大内存和淘汰策略的方式使用。
高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能 
保证数据过期时间后删除脏数据。

常见的场景:缓存层选用Redis,存储层选用MySQL  

3.缓存粒度控制 
通过下面几个方面说明缓存粒度:
通用性:缓存全部数据比部分数据更加通用,但从实际经验看,很长实际内应用只需要
几个重要的属性。
空间占用:
缓存全部数据要比部分数据占用更多的空间,可能存在的问题:
全部数据会造成内存的浪费 
全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在极端情况下会阻塞网络。
全部数据的序列化和反序列化的CPU开下更大。
代码维护:
全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常 
还需要刷新缓存数据。

4.穿透优化 
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常处于容错的考虑
如果从存储层查不到数据则不写入缓存层。
过程如下:
(1)缓存层不命中 
(2)存储层不命中,不将空结果写回缓存。
(3)返回空结果。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端
存储的意义。
缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,
甚至可能造成后端存储宕掉。通常可以在程序中分别统计总条用数,缓存层命中数
存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

造成缓存穿透有两个原因:
自身业务代码或者数据出现问题 
一些恶意攻击,爬虫等造成大量空命中。

解决缓存穿透问题的方法:
(1)缓存空对象 
存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据
将会从缓存中获取,这样就保护了后端数据源。

缓存空对象有两个问题:第一,空值做了缓存,意味着缓存中存了更多的键,需要更多
的内存空间,比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
第二:缓存层和存储层的数据会有一段数据窗口的不一致,可能会对业务有一定影响。
例如过期时间设置为5分钟,如果此时存储层添加了这个数据添加了这个数据,那这段时间
就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除缓存中的数据。

(2)布隆过滤器 
在访问缓存层和存储层之前,将存在的Key用布隆过滤器提前保存起来,做第一层拦截。

5.无底洞优化
大量新增节点,但是性能不但没有好转反而下降了,将这种现象称为的缓存的"无底洞"现象。
键值数据库由于通常采用哈希函数将key映射到各个节点上,造成key的分布与业务无关,
但是由于数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值
分布到更多的节点上,所以无论是memcache还是redis的分布式,批量操作通常需要从 
不同节点上获取,相比于单机批量操作只涉及一次网络操作,分布式批量操作会
涉及多次网络时间。
常见的IO优化思路:
命令本身的优化,例如优化SQL语句等。
减少网络通信次数。
降低接入成本,例如客户端使用长连接,连接池,NIO;
NIO支持直接在操作系统中分配缓冲区,避免了Java堆和系统内存之间的数据复制,
提高了性能。

redis批量操作 的三种实现方法:
客户端n次get:n次网络+n次get命令本身。
客户端1次pipeline get:1次网络+n次get命令本身 。
客户端1次mget:1次网络+1次mget命令本身。

IO优化的思路:
(1)串行命令 
由于N个key是比较均匀地分布在redis cluster的各个节点上,因此无法使用mget命令
一次获取,所以通常来讲要获取n个key的值,最简单的方法就是逐次执行n个get命令,
这种操作时间复杂度较高,他的操作时间=n次网络时间+n次命令时间,网络次数是n;
这种方案不是最优,但是实现简单。
(2)串行IO 
redis cluster 使用 CRC16 算法计算出散列值,再取对16383 的余数可以算出slot值。
Smart 客户端ui保存slot和节点的对应关系,有了这两个数据就可以将属于同一个
节点的key进行归档,得到每个节点的key子列表,之后对每个节点执行mget或者pipeline操作,
他的操作时间=Node次网络时间+n次命令时间,网络次数是node的个数。如果节点过多
,还是有一定的性能问题。
(3)并行IO;
多线程执行,网络次数虽然还是节点个数,但由于使用多线程网络时间变为O(1),这种方案 
会增加编程的复杂度。
(4)hash_tag 实现。
hash_tag 可以将多个key强制分配到一个节点上,他的操作时间=1次网络时间+n次命令时间。

6.雪崩优化
由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供
服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机。

预防和解决缓存雪崩问题,可以从三个方面着手:
(1)保证缓存层服务高可用性。如果缓存层设计成高可用的,即使个别节点,个别机器,设置是
机房宕机,依然可以提供服务。
(2)依赖隔离组件为后端限流并降级。
降级机制在高并发系统中是非常普遍的。在实际项目中,我们需要对重要的资源(如Redis,MySQL,
HBase,外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了
问题,对其他服务没有影响。
(3)提前演练。

7.热点key重建优化
开发人员使用"缓存+过期时间"的策略既可以加速数据读写,又保证数据的定期更新,
这种模式基本能够满足绝大部分需求。但是如果同时存在如下两个问题,则非常严重:
当前key是一个热点key,并发量非常大。
重建缓存不能在短时间完成,可能是一个复杂计算。
缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,设置可能让应用崩溃。

要制定如下目标:
减少重建缓存的次数。
数据尽可能一致 
较少的潜在危险 

(1)互斥锁(mutex key)
此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从 
缓存获取数据即可。
(2)永不过期
不设置过期时间,不出现热点key过期后产生的问题。
为每个value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用
单独的线程去构建缓存。

这种方法有效杜绝了热点key产生的问题,但是唯一不足的就是重构缓存期间,
会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐