二、缓存设计
2.1缓存穿透
2.1.1定义
缓存穿透是指查询一个根本不存在的数据 , 缓存层和存储层都不会命中 , 通常出于容错的考虑 , 如果从存储 层查不到数据则不写入缓存层。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询 , 失去了缓存保护后端存储的意义。
造成缓存穿透的基本原因有两个:
第一 , 自身业务代码或者数据出现问题。
第二 , 一些恶意攻击、 爬虫等造成大量空命中。
2.1.2缓存穿透问题解决方案:
1、缓存空对象
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key); // 缓存为空
if (StringUtils.isBlank(cacheValue)) { // 从存储中获取
String storageValue = storage.get(key); cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
2、布隆过滤器
对于恶意攻击 , 向服务器请求大量不存在的数据造成的缓存穿透 ,还可以用布隆过滤器先做一次过滤 ,对于不 存在的数据布隆过滤器一般都能够过滤掉 ,不让请求再往后端发送。 当布隆过滤器说某个值存在时 ,这个值可 能不存在; 当它说不存在时 ,那就肯定不存在。
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得 比较均匀。
向布隆过滤器中添加 key 时 ,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度 进行取模运算得到一个位置 ,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就 完成了 add 操作。
向布隆过滤器询问 key 是否存在时 ,跟 add 一样 ,也会把 hash 的几个位置都算出来 ,看看位数组中这几个位 置是否都为 1 ,只要有一个位为 0 ,那么说明布隆过滤器中这个key 不存在。如果都是 1 ,这并不能说明这个 key 就一定存在 ,只是极有可能存在 , 因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组 比较稀疏 ,这个概率就会很大 ,如果这个位数组比较拥挤 ,这个概率就会降低。
这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景 , 代码维护较为 复杂 , 但是缓存空间占用很少。
可以用redisson实现布隆过滤器 , 引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
<dependency>
示例代码:
package com.redisson;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config; 7
public class RedissonBloomFilter { 9
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
//构造Redisson
RedissonClient redisson = Redisson.create(config); 15
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);
//将zhuge插入到布隆过滤器中
bloomFilter.add("zhuge"); 21
//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("guojia"));//false
System.out.println(bloomFilter.contains("baiqi"));//false
System.out.println(bloomFilter.contains("zhuge"));//true
}
}
使用布隆过滤器需要把所有数据提前放入布隆过滤器 ,并且在增加数据时也要往布隆过滤器里放 ,布隆过滤器缓存过滤伪代码:
/初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03); 5
//把所有数据存入布隆过滤器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
} 12
String get(String key) {
// 从布隆过滤器这一级缓存判断下key是否存在
Boolean exist = bloomFilter.contains(key);
if(!exist){
return "";
}
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
注意 :布隆过滤器不能删除数据 ,如果要删除得重新初始化数据。
2.2缓存失效(击穿)
2.2.1定义
由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库 ,可能会造成数据库瞬间压力过大 甚至挂掉 ,对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。
示例伪代码:
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
//设置一个过期时间(300到600之间的一个随机数)
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
2.3缓存雪崩
2.3.1定义
缓存雪崩指的是缓存层支撑不住或宕掉后 , 流量会像奔逃的野牛一样 , 打向后端存储层。
由于缓存层承载着大量请求 , 有效地保护了存储层 , 但是如果缓存层由于某些原因不能提供服务(比如超大并 发过来 ,缓存层支撑不住 ,或者由于缓存设计不好 ,类似大量请求访问bigkey ,导致缓存能支撑的并发急剧下 降) , 于是大量请求都会打到存储层 , 存储层的调用量会暴增 , 造成存储层也会级联宕机的情况。
2.3.2预防和解决缓存雪崩问题 , 可以从以下三个方面进行着手。
1) 保证缓存层服务高可用性 ,比如使用Redis Sentinel或Redis Cluster。
2) 依赖隔离组件为后端限流熔断并降级。 比如使用Sentinel或Hystrix限流降级组件。
比如服务降级 ,我们可以针对不同的数据采取不同的处理方式。 当业务应用访问的是非核心数据(例如电商商 品属性 ,用户信息等) 时 ,暂时停止从缓存中查询这些数据 ,而是直接返回预定义的默认降级信息、空值或是 错误提示信息; 当业务应用访问的是核心数据(例如电商商品库存) 时 ,仍然允许查询缓存 ,如果缓存缺失 , 也可以继续通过数据库读取。
3) 提前演练。 在项目上线前 , 演练缓存层宕掉后 , 应用以及后端的负载情况以及可能出现的问题 , 在此基 础上做一些预案设定。
2.4热点缓存key重建优化
开发人员使用“缓存+过期时间”的策略既可以加速数据读写 , 又保证数据的定期更新 , 这种模式基本能够满 足绝大部分需求。 但是有两个问题如果同时出现 , 可能就会对应用造成致命的危害:
. 当前key是一个热点key(例如一个热门的娱乐新闻) ,并发量非常大。
. 重建缓存不能在短时间完成 , 可能是一个复杂计算 , 例如复杂的SQL、 多次IO、 多个依赖等。 在缓存失效的瞬间 , 有大量线程来重建缓存 , 造成后端负载加大 , 甚至可能会让应用崩溃。
要解决这个问题主要就是要避免大量线程同时重建缓存。
我们可以利用互斥锁来解决 ,此方法只允许一个线程重建缓存 , 其他线程等待重建缓存的线程执行完 , 重新从 缓存获取数据即可。
示例伪代码:
String get(String key) {
// 从Redis中获取数据
String value = redis.get(key);
// 如果value为空, 则开始重构缓存
if (value == null) {
// 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) {
// 从数据源获取数据
value = db.get(key);
// 回写Redis, 并设置过期时间
redis.setex(key, timeout, value);
// 删除key_mutex
redis.delete(mutexKey);
}// 其他线程休息50毫秒后重试
else {
Thread.sleep(50);
get(key);
}
}
return value;
}
2.5缓存与数据库双写不一致
在大并发下 , 同时操作数据库与缓存会存在数据不一致性问题
1、双写不一致情况
2、读写并发不一致
解决方案:
1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等) ,这种几乎不用考虑这个问题 ,很少会发生 缓存不一致 ,可以给缓存数据加上过期时间 ,每隔一段时间触发读的主动更新即可。
2、就算并发很高 ,如果业务上能容忍短时间的缓存数据不一致(如商品名称 ,商品分类菜单等) ,缓存加上过期 时间依然可以解决大部分业务对于缓存的要求。
3、如果不能容忍缓存数据不一致 ,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队 ,读读的时候相当于无锁。
4、也可以用阿里开源的canal通过监听数据库的bin log日志及时的去修改缓存 ,但是引入了新的中间件 ,增加 了系统的复杂度。
总结:
以上我们针对的都是读多写少的情况加入缓存提高性能 ,如果写多读多的情况又不能容忍缓存数据不一致 ,那 就没必要加缓存了 ,可以直接操作数据库。 当然 ,如果数据库抗不住压力 ,还可以把缓存作为数据读写的主存 储 ,异步将数据同步到数据库 ,数据库只是作为数据的备份。
放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存 , 同时又要保证绝对的一 致性做大量的过度设计和控制 ,增加系统复杂性!
三、多级缓存架构在大厂中的实践
3.1为什么需要多级缓存?
3.1.1使用本地缓存的好处:
- 减少网络请求,提高性能
- 分布式系统中,天然分布式缓存
- 减少远程缓存的读压力
3.1.2使用本地缓存的缺点:
- 进程空间大小有限,不支持大数据量存储
- 重启程序会丢失数据
- 分布式场景下,系统之间数据可能存在不一致
- 和远程缓存数据可能会存在不一致
3.1.3什么场景下需要用到多级缓存?
- 热点商品详情页
- 热搜
- 热门帖子
- 热门用户主页
综上所述,一般都是在高并发的情况下需要用到多级缓存
3.2热点探测服务的原理与实现
3.2.1什么是热点?
热点通常意义来说,是指在一段时间内,被广泛关注的物品或事件,例如微博热搜,热卖商品,热点新闻,明星直播等等,
所以热点产生主要包含 2 个条件:
- 有限时间
- 流量高聚
而在互联网领域,热点又主要分为 2 大类:
1. 有预期的热点:比如在电商活动当中推出的爆款联名限量款的商品,又或者是秒杀的会场活动等 2. 无预期的热点:比如受到了黑客的恶意攻击,网络爬虫频繁访问,又或者突发新闻带来的流量冲击等
3.2.2热点探测使用场景
- MySQL 中被频繁访问的数据 ,如热门商品的主键 Id Redis
- 缓存中被密集访问的 Key,如热门商品的详情需要 get goods$Id
- 恶意攻击或机器人爬虫的请求信息,如特定标识的 userId、机器 IP
- 频繁被访问的接口地址,如获取用户信息接口 /userInfo/ + userId
3.2.3使用热点探测的好处
提升性能,规避风险
对于无预期的热数据(即突发场景下形成的热 Key),可能会对业务系统带来极大的风险,可将风险分为两个层次:
1、对数据层的风险
正常情况下,Redis 缓存单机就可支持十万左右 QPS,并能通过集群部署提高整体负载能力。对于并发量一般的系统,用 Redis 做缓存就足够了。但是对于瞬时过高并发的请求,因为 Redis 单线程原因会导致正常请求排队,或者因为热点集中导致分片集群压力过载而瘫痪,从而击穿到 DB 引起服务器雪崩。
2、对应用服务的风险
每个应用在单位时间所能接受和处理的请求量是有限的,如果受到恶意请求的攻击,让恶意用户独自占用了大量请求处理资源,就会导致其他人畜无害的正常用户的请求无法及时响应。
因此,需要一套动态热 Key 检测机制,通过对需要检测的热 Key 规则进行配置,实时监听统计热 Key 数据,当无预期的热点数据出现时,第一时间发现他,并针对这些数据进行特殊处理。如本地缓存、拒绝恶意用户、接口限流 / 降级等
3.2.4如何实现热点探测
我们知道热点产生的条件是 2 个:一个时间,一个流量。
那么根据这个条件我们可以简单定义一个规则:比如 1 秒内访问 1000 次的数据算是热数据,当然这个数据需要根据具体的业务场景和过往数据进行具体评估。 对于单机应用,检测热数据很简单,直接在本地为每个 Key 创建一个滑动窗口计数器,统计单位时间内的访问总数(频率),并通过一个集合存放检测到的热 Key。
而对于分布式应用,对热 Key 的访问是分散在不同的机器上的,无法在本地独立地进行计算,因此,需要一个独立的、集中的热 Key 计算单元。
我们可以简单理解为:分布式应用节点感知热点规则配置,将热点数据进行上报,工作节点进行热点数据统计,对于符合阈值的热点进行推送给客户端,应用收到热点信息进行本地缓存等策略这五个步骤:
1、热点规则:配置热 Key 的上报规则,圈出需要重点监测的 Key
2、热点上报:应用服务将自己的热 Key 访问情况上报给集中计算单元
3、热点统计:收集各应用实例上报的信息,使用滑动窗口算法计算 Key 的热度
4、热点推送:当 Key 的热度达到设定值时,推送热 Key 信息至所有应用实例
5、热点缓存:各应用实例收到热 Key 信息后,对 Key 值进行本地缓存