缓存穿透是指查询一个根本不存在的数据 , 缓存层和存储层都不会命中 , 通常出于容错的考虑 , 如果从存储
层查不到数据则不写入缓存层。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询 , 失去了缓存保护后端存储的意义。
造成缓存穿透的基本原因有两个:
第一 , 自身业务代码或者数据出现问题。
第二 , 一些恶意攻击、 爬虫等造成大量空命中。
缓存穿透问题解决方案:
1、缓存空对象
1 String get(String key) { 2 // 从缓存中获取数据 3 String cacheValue = cache.get(key); 4 // 缓存为空 5 if (StringUtils.isBlank(cacheValue)) { 6 // 从存储中获取 7 String storageValue = storage.get(key); 8 cache.set(key, storageValue); 9 // 如果存储数据为空, 需要设置一个过期时间(300秒) 10 if (storageValue == null) { 11 cache.expire(key, 60 * 5); 12 } 13 return storageValue; 14 } else { 15 // 缓存非空 16 return cacheValue; 17 } 18 } |
---|
2、布隆过滤器
对于恶意攻击 ,向服务器请求大量不存在的数据造成的缓存穿透 ,还可以用布隆过滤器先做一次过滤 ,对于不 存在的数据布隆过滤器一般都能够过滤掉 ,不让请求再往后端发送。当布隆过滤器说某个值存在时 ,这个值可 能不存在;当它说不存在时 ,那就肯定不存在。
向布隆过滤器询问 key 是否存在时 ,跟 add 一样 ,也会把 hash 的几个位置都算出来 ,看看位数组中这几个 位置是否都为 1 ,只要有一个位为 0 ,那么说明布隆过滤器中这个key 不存在。如果都是 1 ,这并不能说明这 个 key 就一定存在 ,只是极有可能存在 ,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位 数组比较稀疏 ,这个概率就会很大 ,如果这个位数组比较拥挤 ,这个概率就会降低。
这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景 , 代码维护较为 复杂 , 但是缓存空间占用很少。
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得 比较均匀。
向布隆过滤器中添加 key 时 ,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度 进行取模运算得到一个位置 ,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就 完成了 add 操作。
可以用redisson实现布隆过滤器 ,引入依赖:
1 2 org.redisson 3 redisson 4 3.6.5 5 |
---|
示例伪代码:
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 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 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 }
注意:布隆过滤器不能删除数据 ,如果要删除得重新初始化数据。
由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库 ,可能会造成数据库瞬间压力过大 甚至挂掉 ,对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同 时间。
示例伪代码:
1 String get(String key) { 2 // 从缓存中获取数据 3 String cacheValue = cache.get(key); 4 // 缓存为空 5 if (StringUtils.isBlank(cacheValue)) { 6 // 从存储中获取 7 String storageValue = storage.get(key); 8 cache.set(key, storageValue); 9 //设置一个过期时间(300到600之间的一个随机数) 10 int expireTime = new Random().nextInt(300) + 300; 11 if (storageValue == null) { 12 cache.expire(key, expireTime); 13 } 14 return storageValue; 15 } else { 16 // 缓存非空 17 return cacheValue; 18 } 19 } |
---|
缓存雪崩指的是缓存层支撑不住或宕掉后 , 流量会像奔逃的野牛一样 , 打向后端存储层。
由于缓存层承载着大量请求 , 有效地保护了存储层 , 但是如果缓存层由于某些原因不能提供服务(比如超大并 发过来 ,缓存层支撑不住 ,或者由于缓存设计不好 ,类似大量请求访问bigkey ,导致缓存能支撑的并发急剧下
降) , 于是大量请求都会打到存储层 , 存储层的调用量会暴增 , 造成存储层也会级联宕机的情况。 预防和解决缓存雪崩问题 , 可以从以下三个方面进行着手。
1) 保证缓存层服务高可用性 ,比如使用Redis Sentinel或Redis Cluster。
2) 依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。
比如服务降级 ,我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据(例如电商商 品属性 ,用户信息等)时 ,暂时停止从缓存中查询这些数据 ,而是直接返回预定义的默认降级信息、空值或是 错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时 ,仍然允许查询缓存 ,如果缓存缺失 , 也可以继续通过数据库读取。
3) 提前演练。 在项目上线前 , 演练缓存层宕掉后 , 应用以及后端的负载情况以及可能出现的问题 , 在此基 础上做一些预案设定。
开发人员使用“缓存+过期时间”的策略既可以加速数据读写 , 又保证数据的定期更新 , 这种模式基本能够
满足绝大部分需求。 但是有两个问题如果同时出现 , 可能就会对应用造成致命的危害:
当前key是一个热点key(例如一个热门的娱乐新闻) ,并发量非常大。
重建缓存不能在短时间完成 , 可能是一个复杂计算 , 例如复杂的SQL、 多次IO、 多个依赖等。 在缓存失效的瞬间 , 有大量线程来重建缓存 , 造成后端负载加大 , 甚至可能会让应用崩溃。
要解决这个问题主要就是要避免大量线程同时重建缓存。
我们可以利用互斥锁来解决 ,此方法只允许一个线程重建缓存 , 其他线程等待重建缓存的线程执行完 , 重新
从缓存获取数据即可。
示例伪代码:
1 String get(String key) { 2 // 从Redis中获取数据 3 String value = redis.get(key); 4 // 如果value为空, 则开始重构缓存 5 if (value == null) { 6 // 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex 7 String mutexKey = “mutext🔑” + key; 8 if (redis.set(mutexKey, “1”, “ex 180”, “nx”)) { 9 // 从数据源获取数据 10 value = db.get(key); 11 // 回写Redis, 并设置过期时间 12 redis.setex(key, timeout, value); 13 // 删除key_mutex 14 redis.delete(mutexKey); 15 }// 其他线程休息50毫秒后重试 16 else { 17 Thread.sleep(50); 18 get(key); 19 } 20 } 21 return value; 22 } |
---|
在大并发下 ,同时操作数据库与缓存会存在数据不一致性问题
1、双写不一致情况
2、读写并发不一致
解决方案:
1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等) ,这种几乎不用考虑这个问题 ,很少会发生 缓存不一致 ,可以给缓存数据加上过期时间 ,每隔一段时间触发读的主动更新即可。
2、就算并发很高 ,如果业务上能容忍短时间的缓存数据不一致(如商品名称 ,商品分类菜单等) ,缓存加上过期 时间依然可以解决大部分业务对于缓存的要求。
3、如果不能容忍缓存数据不一致 ,可以通过加读写锁保证并发读写或写写的时候按顺序排好队 ,读读的时候 相当于无锁。
4、也可以用阿里开源的canal通过监听数据库的bin log日志及时的去修改缓存 ,但是引入了新的中间件 ,增加 了系统的复杂度。
以业务名(或数据库名)为前缀(防止key冲突) ,用冒号分隔 ,比如业务名:表名:id
总结:
以上我们针对的都是读多写少的情况加入缓存提高性能 ,如果写多读多的情况又不能容忍缓存数据不一致 ,那 就没必要加缓存了 ,可以直接操作数据库。当然 ,如果数据库抗不住压力 ,还可以把缓存作为数据读写的主存 储 ,异步将数据同步到数据库 ,数据库只是作为数据的备份。
放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存 ,同时又要保证绝对的一 致性做大量的过度设计和控制 ,增加系统复杂性!
(1)【建议】: 可读性和可管理性
1 trade:order:1 |
---|
(2)【建议】:简洁性
保证语义的前提下 ,控制key的长度 ,当key较多时, 内存占用也不容忽视 ,例如:
1 user:{uid}:friends:messages:{mid} 简化为 u:{uid}🇫🇷m:{mid}
(3)【强制】:不要包含特殊字符
反例:包含空格、换行、单双引号以及其他转义字符
(1)【强制】:拒绝bigkey(防止网卡流量、慢查询)
在Redis中 ,一个字符串最大512MB ,一个二级数据结构(例如hash、list、set、zset)可以存 储大约40亿个(2 ^32-1)个元素 ,但实际中如果下面两种情况 ,我就会认为它是bigkey。
1. 字符串类型:它的big体现在单个value值很大 ,一般认为超过10KB就是bigkey。 2. 非字符串类型:哈希、列表、集合、有序集合 ,它们的big体现在元素个数太多。
一般来说 ,string类型控制在10KB以内 ,hash、list、set、zset元素个数不要超过5000。 反例:一个包含200万个元素的list。
非字符串的bigkey ,不要使用del删除 ,使用hscan、sscan、zscan方式渐进式删除 ,同时要注 意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期 ,会触发del操作 , 造成阻塞)
bigkey也就意味着每次获取要产生的网络流量较大 ,假设一个bigkey为1MB ,客户端每秒访问 量为1000 ,那么每秒产生1000MB的流量 ,对于普通的千兆网卡(按照字节算是128MB/s)的服务 器来说简直是灭顶之灾 ,而且一般服务器会采用单机多实例的方式来部署 ,也就是说一个bigkey
可能会对其他实例也造成影响 ,其后果不堪设想。
有个bigkey ,它安分守己(只执行简单的命令 ,例如hget、lpop、zscore等) ,但它设置了过 期时间 ,当它过期后 ,会被删除 ,如果没有使用Redis 4.0的过期异步删除(lazyfree-lazy-
expire yes) ,就会存在阻塞Redis的可能性。
一般来说 ,bigkey的产生都是由于程序设计不当 ,或者对于数据规模预料不清楚造成的 ,来看几
个例子:
(1) 社交类:粉丝列表 ,如果某些明星或者大v不精心设计下 ,必是bigkey。
(2) 统计类:例如按天存储某项功能或者网站的用户集合 ,除非没几个人用 ,否则必是bigkey。 (3) 缓存类:将数据从数据库load出来序列化放到Redis里 ,这个方式非常常用 ,但有两个地方 需要注意 ,第一 ,是不是有必要把所有字段都缓存;第二 ,有没有相关关联的数据 ,有的同学为 了图方便把相关数据都存一个key下 ,产生bigkey。
big list: list1、list2、…listN
big hash:可以讲数据分段存储 ,比如一个大的key ,假设存了1百万的用户数据 ,可以拆分成 200个key ,每个key下面存放5000个用户数据
(2)【推荐】:选择适合的数据类型。
例如:实体类型(要合理控制和使用数据结构 ,但也要注意节省内存和性能之间的平衡) 反例:
1 set user:1:name tom 2 set user:1:age 19 3 set user:1:favor football |
---|
但要注意控制一次批量操作的元素个数(例如500以内 ,实际也和元素字节数有关)。 注意两者不同:
正例:
1 hmset user:1 name tom age 19 favor football
3.【推荐】:控制key的生命周期 ,redis不是垃圾桶。
建议使用expire设置过期时间(条件允许可以打散过期时间 ,防止集中过期)。
例如hgetall、l range、smembers、zrange、s inter等并非不能使用 ,但是需要明确N的值。有 遍历的需求可以使用hscan、sscan、zscan代替。
禁止线上使用keys、flushall、flushdb等 ,通过redis的rename机制禁掉命令 ,或者使用scan的 方式渐进式处理。
redis的多数据库较弱 ,使用数字进行区分 ,很多客户端支持较差 ,同时多业务用多数据库实际 还是单线程处理 ,会有干扰。
1 原生命令:例如mget、mset。
2 非原生命令:可以使用pipeline提高效率。
1 1. 原生命令是原子操作,pipeline是非原子操作。 2 2. pipeline可以打包不同的命令,原生命令做不到 3 3. pipeline需要客户端和服务端同时支持。 |
---|