总结篇3:redis 典型缓存架构设计问题及性能优化
聊聊对于缓存预热、缓存穿透、缓存雪崩、缓存击穿、缓存更新、缓存降级的定义理解
缓存穿透
定义
当查询Redis中没有的数据时,该查询会下沉到数据库层,同时数据库层也没有该数据,当这种情况大量出现或被恶意攻击时,接口的访问全部透过Redis访问数据库,而数据库中也没有这些数据,我们称这种现象为"缓存穿透"。缓存穿透会穿透Redis的保护,提升底层数据库的负载压力,同时这类穿透查询没有数据返回也造成了网络和计算资源的浪费。
解决方案:
- 1 在接口访问层对用户做校验,如接口传参、登陆状态、n秒内访问接口的次数;
- 2 利用布隆过滤器,将数据库层有的数据key存储在位数组中,以判断访问的key在底层数据库中是否存在;核心思想是布隆过滤器,在redis里也有bitmap位图的类似实现,布隆过滤器过滤器不能实现动态删除,有时间可以研究下布谷鸟过滤器,是布隆过滤器增强版本。布隆过滤器有误判率,虽然不能完全避免数据穿透的现象,但已经可以将99.99%的穿透查询给屏蔽在Redis层了,极大的降低了底层数据库的压力,减少了资源浪费。
基于布隆过滤器,我们可以先将数据库中数据的key存储在布隆过滤器的位数组中,每次客户端查询数据时先访问Redis:
- 如果Redis内不存在该数据,则通过布隆过滤器判断数据是否在底层数据库内;
- 如果布隆过滤器告诉我们该key在底层库内不存在,则直接返回null给客户端即可,避免了查询底层数据库的动作;
- 如果布隆过滤器告诉我们该key极有可能在底层数据库内存在,那么将查询下推到底层数据库即可;
缓存击穿
定义
缓存击穿和缓存穿透从名词上可能很难区分开来,它们的区别是:穿透表示底层数据库没有数据且缓存内也没有数据,击穿表示底层数据库有数据而缓存内没有数据。当热点数据key从缓存内失效时,大量访问同时请求这个数据,就会将查询下沉到数据库层,此时数据库层的负载压力会骤增,我们称这种现象为"缓存击穿"。
解决方案
- 延长热点key的过期时间或者设置永不过期,如排行榜,首页等一定会有高并发的接口;
- 利用互斥锁保证同一时刻只有一个客户端可以查询底层数据库的这个数据,一旦查到数据就缓存至Redis内,避免其他大量请求同时穿过Redis访问底层数据库;
缓存雪崩
定义
缓存雪崩是缓存击穿的"大面积"版,缓存击穿是数据库缓存到Redis内的热点数据失效导致大量并发查询穿过redis直接击打到底层数据库,而缓存雪崩是指Redis中大量的key几乎同时过期,然后大量并发查询穿过redis击打到底层数据库上,此时数据库层的负载压力会骤增,我们称这种现象为"缓存雪崩"。
事实上缓存雪崩相比于缓存击穿更容易发生,对于大多数公司来讲,同时超大并发量访问同一个过时key的场景的确太少见了,而大量key同时过期,大量用户访问这些key的几率相比缓存击穿来说明显更大。
解决方案
- 在可接受的时间范围内随机设置key的过期时间,分散key的过期时间,以防止大量的key在同一时刻过期;
- 对于一定要在固定时间让key失效的场景(例如每日12点准时更新所有最新排名),可以在固定的失效时间时在接口服务端设置随机延时,将请求的时间打散,让一部分查询先将数据缓存起来;
- 延长热点key的过期时间或者设置永不过期,这一点和缓存击穿中的方案一样;
缓存预热
- 如字面意思,当系统上线时,缓存内还没有数据,如果直接提供给用户使用,每个请求都会穿过缓存去访问底层数据库,如果并发大的话,很有可能在上线当天就会宕机,因此我们需要在上线前先将数据库内的热点数据缓存至Redis内再提供出去使用,这种操作就成为"缓存预热"。
- 缓存预热的实现方式有很多,比较通用的方式是写个批任务,在启动项目时或定时去触发将底层数据库内的热点数据加载到缓存内。
缓存降级
- 缓存降级是指当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,即使是有损部分其他服务,仍然需要保证主服务可用。可以将其他次要服务的数据进行缓存降级,从而提升主服务的稳定性。
- 降级的目的是保证核心服务可用,即使是有损的。如去年双十一的时候淘宝购物车无法修改地址只能使用默认地址,这个服务就是被降级了,这里阿里保证了订单可以正常提交和付款,但修改地址的服务可以在服务器压力降低,并发量相对减少的时候再恢复。
- 降级可以根据实时的监控数据进行自动降级也可以配置开关人工降级。是否需要降级,哪些服务需要降级,在什么情况下再降级,取决于大家对于系统功能的取舍。
缓存更新
缓存服务(Redis)和数据服务(底层数据库)是相互独立且异构的系统,在更新缓存或更新数据的时候无法做到原子性的同时更新两边的数据,因此在并发读写或第二步操作异常时会遇到各种数据不一致的问题。如何解决并发场景下更新操作的双写一致是缓存系统的一个重要知识点。
redis 典型缓存架构设计问题及性能优化总结:
缓存穿透
查询一个根本不存在的数据,缓存层和存储层都不会命中。通常出于容错的考虑,如果从存储层查不到数据,则不写入缓存层。
原因:
- 自身业务代码或数据有问题
- 恶意攻击等造成大量空命中
解决方案1:缓存空对象
解决方案2:布隆过滤器
当布隆过滤哭喊 说某个值存在时,这个值可能不存在。当说它不存在时,那就肯定不存在。
对于不存在的数据布隆过滤器一般都能过滤掉,不再让请求再往后端发送。
布隆过滤器就是一个大型的位数组和几个不一样的无偏hash 函数,所谓无偏就是能够把元素的hash 值算得比较均匀。
这种方法适用于数据命中不高、数据相对稳定、实时性低的应用场景,通常是数据集较大,代码维护较为复杂,但是缓存空间占用较少。
可以用redisson实现布隆过滤器,引入依赖:
1 <dependency>
2 <groupId>org.redisson</groupId>
3 <artifactId>redisson</artifactId>
4 <version>3.6.5</version>
5 </dependency>
示例代码:
1 package com.redisson;
2
3 import org.redisson.Redisson;
4 import org.redisson.api.RBloomFilter;
5 import org.redisson.api.RedissonClient;
6 import org.redisson.config.Config;
7
8 public class RedissonBloomFilter {
9
10 public static void main(String[] args) {
11 Config config = new Config();
12 config.useSingleServer().setAddress("redis://localhost:6379");
13 //构造Redisson
14 RedissonClient redisson = Redisson.create(config);
15
16 RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
17 //初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
18 bloomFilter.tryInit(100000000L,0.03);
19 //将zhuge插入到布隆过滤器中
20 bloomFilter.add("zhuge");
21
22 //判断下面号码是否在布隆过滤器中
23 System.out.println(bloomFilter.contains("guojia"));//false
24 System.out.println(bloomFilter.contains("baiqi"));//false
25 System.out.println(bloomFilter.contains("zhuge"));//true
26 }
27 }
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器 缓存过滤伪代码:
1 //初始化布隆过滤器
2 RBloomFilter<String> bloomFilter=redisson.getBloomFilter("nameList");
3 //初始化布隆过滤器:预计元素为100000000L,误差率为3%
4 bloomFilter.tryInit(100000000L,0.03);
5
6 //把所有数据存入布隆过滤器
7 void init(){
8 for (String key: keys) {
9 bloomFilter.put(key);
10 }
11 }
12
13 String get(String key) {
14 // 从布隆过滤器这一级缓存判断下key是否存在
15 Boolean exist = bloomFilter.contains(key);
16 if(!exist){
17 return "";
18 }
19 // 从缓存中获取数据
20 String cacheValue = cache.get(key);
21 // 缓存为空
22 if (StringUtils.isBlank(cacheValue)) {
23 // 从存储中获取
24 String storageValue = storage.get(key);
25 cache.set(key, storageValue);
26 // 如果存储数据为空, 需要设置一个过期时间(300秒)
27 if (storageValue == null) {
28 cache.expire(key, 60 * 5);
29 }
30 return storageValue;
31 } else {
32 // 缓存非空
33 return cacheValue;
34 }
35 }
注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据。
缓存击穿
大量缓存同时失效导致请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大挂掉。最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。
int expireTime - new Random().nextInt(300) + 300;
缓存血崩
如果缓存架构设计得不好,大量请求访问bigkey,导致缓存能支撑的并发急剧下降,大量请求都会打到存储层,造成存储层也会级联宕机的情况。
解决问题:
- 1 保证缓存层服务高可用性,比如使用redis Sentinel 或 redis Cluster
- 2 依赖隔离组件为后端限流熔断并降级。比如使用 Sentinel 或 Hystrix 限流降级组件。
我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据,如商品属性,用户信息等,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据,如商品库存,仍然允许查询缓存,如果缓存缺失,可以继续通过数据库读取。
- 3 做好数据容灾。提前演练,并做一些预案。
热点数据缓存优化
使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。
但是这种策略存在的问题,对应用却是致命的。
- 当前key 是一个热点key, 如热门活动,并发量非常大
- 重建缓存不能在短时间完成,可能是一个复杂计算,例如繁杂的SQL,多次IO, 多个依赖等,在缓存操作新选的瞬间,有大量纯种来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。
解决这个问题,就是要避免大量纯种同时重建缓存。
可以利用互斥锁,此方法只允许一个纯种重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据。
String get(String key) {
// 从redis 中获取数据
String value = redis.get(key);
// 如果value 为空,则重构缓存
if(null == value){
// 只允许一个线程重建缓存,使用nx, 并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if(redis.set(mutexKey,"1","ex 180",nx)){
// 从数据库中取数据
value = db.get(key);
// 设置过期时间
redis.setex(key,timeout,value);
// 删除key_mutex
redis.delete(mutexKey);
} else {
Thread.sleep(50);
get(key);
}
}
return value;
}
缓存数据库读写不一致
- 1 双写不一致
- 2 读写不一致
解决方案:
- 1 对于并发几率很小的数据,如个人维度的订单数据,用户数据等,这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
- 2 就算并发很高,如果业务上能容忍短时间的缓存数据不一致,如商品名称,商品分类菜单等,缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
- 3 如果不能容忍缓存数据不一致,可以通过读写锁保证并发读写或写写的时间按顺序排好队,读读的时间相当于无锁。
- 4 也可以用阿里开源的canal 通过监听数据库的binlog 日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
小结:一般针对是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。如果数据库抗不住压力,还可以把缓存作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。
加入缓存的数据库应该是对实时性、一致性要求不是很高的数据,切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统的复杂性。
开发规范
key 名设计
- 1 建议:可读性和可管理性
以业务名或数据库名库前缀,防止key 冲突,用冒号分隔。如 业务名:表名:id
- 2 建议:简洁性
保证语义的前提下,控制key 长度,当key 较多时,内存占用也不容忽视。
- 3 强制:不要包含特殊字符
反例:空格,换行,单双引号以及其他转义字符
value 设计
- 1 强制:拒绝bigkey ,防止网卡流量,慢查询
在redis 中,一个字符串最大512M, 一个二级数据结构可以存储大约40亿(2^32 -1)个元素,但是实际中如果有下面两种情况,我们认为它是bigkey。
- 1 字符串类型:它的big 体现在单个value 值很大,一般认为超过10KB 就是bigkey。
- 2 非字符串类型:hash, list,set, zset,它们的big 体现在元素个数太多。
一般来说,String 类型控制在10kb 以内, hash, list, set, zset 元素个数不要超过5000。非字符串的bigkey,不要使用del 删除,使用hscan,sscan,zscan 方式浙进式删除,同时要注意防止bigkey 过期时间自动删除问题。(如一个200w 的zset 设置一个小时过期,会触发 del 操作,造成阻塞)
bigkey 性能优化
1 bigkey 的产生
一般来说,bigkey 的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的。
- 1 社交类:大V 粉丝列表,如果设计不当,必是bigkey
- 2 统计类:如按天存储某项功能或者网站的用户集合,除非没人用,否则必是bigkey
- 3 缓存类:将数据从数据库中load 出来序列化放在redis 中,但是要注意1-是不是有必要把所有字段都缓存;2-有没有相关关联的数据,为图方便而产生关联数据, 产生bigkey.
2 如何优化
1 拆
big list : list1, list2, ... , listN
big hash :可以将数据分段存储,比如一个大的key, 假设存了100w 的用户数据,可以拆分成200个key, 每个key 下面5000个用户数据。
2 推荐:选择适合的数据类型
举例:
// 正例:
hmset user:1 name tom age 20 favor swimming
// 反例:
set user 1 : name tom
set user 1 : age 20
set user 1 : favor swimming
3 推荐:控制key 的生命周期
建议使用expire 设置过期时间,同时过期时间要随机,防止集中过期。