缓存
缓存目的: 加速数据访问的存储。
- 延迟(Latency)
- 吞吐量(Throughput)
缓存关注点
- 读写流程
- 条目的设计
- 置换策略
读
- 关注穿透路径, 没有读到数据, 读取缓存后的存储
- L1 -> L2 -> L3 -> Memory -> Disk
- 穿透
- 命中(catch hit): 缓存条目存在
- 穿透(penetration): 没有命中
- 穿透率: 没有命中/总访问次数
- 命中率=1-穿透率
写
- 写入穿透(Write Through): 双写缓存和存储
- 回写(Write Back)
- 先写缓存
- 延迟写入存储
- 按时延迟
- 缓存延迟
- 其它事件触发-
volatile
缓存条目的设计
- 本质: 数据结构的设计
- CPU缓存
- 数据缓存
第一种直接映射, 缓存可能会覆盖, 效率不够高; 第二种, 一个地址映射2个cache
, 有点像哈希表的开放选址法.
数据缓存
- <key, value, expire>
- HashMap
- TreeMap
- LinkedHashMap
- HashSet
- TreeSet
- Object
- Queue(list)
- PriorityQueue(优先级)
- Array
缓存置换策略
空间有限, 先到先得(FIFO)
空间有限, 保留热数据, 移除冷数据.
最近最少使用
Least Frequently Used
– LFU : 在每个缓存条目增加频次数据(代价大)
物理内存需要做地址转换. 应用都是在访问虚拟内存, MMU(Memory Management Unit, 内存管理单元, 硬件)负责转换.
最近最久未使用
Least Recently Used
– LRU
方案1: 每个缓存条目增加失效时间定期清理, 代价大.
方案2: 队列模拟. 总体顺序 FIFO
, 先进去的是时间长的.
推荐数据结构: HashTable
+ LinkedList
-> LinkedHashMap
方案3: 用 binary tree
模拟, CPU cache
的设计.
L1 缓存有一部分要放指令(内存中映射), 目的是为了效率.
0 代表左边, 1 代表右边. 例如: 写入A, 写完后, 在一条链的都进行取反运算(一次运算, 高效), 指向相反方向. 以此类推.
2^n^-1 可以编码 2^n^ 位置.
1023bit
编码 1024
个条目.
1M bit编码100w条目.
MRU(Most Recently Used)缓存: 最近最常使用. 一般用于数据库的缓存.
多级缓存: 布隆过滤器
能够迅速地判断一个元素是否在一个集合里面. 0代表不存在, 1代表存在. 在同一个位置上可能存在多个key.
拦截器的概念。
第一级: 布隆过滤器; 第二级: 恶意字典URL.
占用空间小, 上图就12bit。
- 避免给用户重复推荐
- 过滤恶意邮件
- HBase减少对不存在的列的查找
概率问题, 可能正确80%, 看场景。
如果说要加多一层缓存, 那就加 布隆过滤器。
缓存穿透
key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据源,从⽽可能压垮数据源。⽐如⽤⼀个不存在的⽤户id获取⽤户信息,不论缓存还是数据库都没有,若⿊客利⽤此漏洞进⾏攻击可能压垮数据库。
解决方案
⼀个⼀定不存在缓存及查询不到的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写⼊缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
最常见的是采用布隆过滤器。将所有可能存在的数据哈希到一个足够大的bitmap
中,一个一定不存在的数据会被这个bitmap
拦截掉,从而避免对低层存储系统的查询压力。
另外一种就是,如果一个查询返回的数据为空,不管是数据不存在,还是系统故障,仍然把空结果进行缓存,但是过期时间会很短,例如不超过五分钟。
// 伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
String cacheValue = CacheHelper.Get(cacheKey);
if (cacheValue != null) {
return cacheValue;
}
cacheValue = CacheHelper.Get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
//数据库查询不到,为空
cacheValue = GetProductListFromDB();
if (cacheValue == null) {
//如果发现为空,设置个默认值,也缓存起来
cacheValue = string.Empty;
}
CacheHelper.Add(cacheKey, cacheValue, cacheTime);
return cacheValue;
}
}
缓存击穿
key对应的数据存在,但在redis中过期,此时若有⼤量并发请求过来,这些请求发现缓存过期⼀般都会从后端存储层加载数据并回设到缓存,这个时候⼤并发的请求可能会瞬间把后端存储层压垮。
使用互斥锁
mutex key,在缓存生效的时候,判断拿出来的值是空,不是立即去DB查询,而是使用缓存工具的某些成功操作返回值的操作(比如Redis的setbnx
或者memcache的Add)去设置一个mutex key,当操作成功时,再进行查询DB的操作并设置回缓存;否则就重试尝试从缓存中获取。
// 伪代码
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置3min的超时,防⽌del操作失败的时候,下次缓存过期⼀直不能load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else {
//这个时候代表同时有其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); //重试
}
} else {
return value;
}
}
缓存雪崩
大量的key集中过期或者缓存整体不能对外提供服务,导致大量的请求达到存储层,增加存储层(DB)的负担。
- 保存缓存层高可用。Redis哨兵或集群模式部署。
- 限流降级组件。Hystrix,Sentinel等。
- 缓存不过期。保存key不过期,随之而来的是需要更多的空间。
- 优化缓存过期时间。每个key选择适合的过期时间,利用加个随机值,避免大量key同时失效。
- 使用互斥锁重建缓存,避免大量请求并发达到存储层查询数据,重建缓存。根据key去缓存层查询数据,当缓存层未命中时,对
key
加锁,然后从存储层查询数据,再写入缓存层,最后释放锁。若其他线程发现获取失败时,则让线程休眠一段时间再重试。 - 异步重建缓存。从线程池获取线程异步构建缓存,从而不会让所有请求进入存储层。每个key维护一个逻辑超时时间,当逻辑超时时间小于当前时间时,则说明当前缓存已经失效了,应该更新缓存,否则直接返回缓存中的value值。如果Redis中的key过期时间设置为60min,那么对应的value逻辑过期时间可以设置为30min。当key到了30min的时候,就异步更新这个key的缓存,但是在更新时旧缓存依旧可用。
加锁排队的解决方案还要解决分布式锁的问题,线程还会阻塞,用户体验很差,所以不适合再高并发的场景下使用。
// 加锁排队伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
String lockKey = cacheKey;
String cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
synchronized(lockKey) {
cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
//这⾥⼀般是sql查询数据
cacheValue = GetProductListFromDB();
CacheHelper.Add(cacheKey, cacheValue, cacheTime);
}
}
return cacheValue;
}
}