一、缓存的受益与成本
1. 受益
(1)加速读写
- 通过缓存加速读写速度:CPU L1/L2/L3 Cache、Linux Page Cache加速硬盘读写、浏览器缓存、Ehcache缓存等
(2)降低后端负载(MySQL)
2.成本
(1)数据不一致:缓存层和数据层有时间窗口不一致,和更新策略有光
(2)代码维护成本:多了一层缓存逻辑
(3)运维成本:例如Redis Cluster
3.场景
- 降低后端负载
对高消耗的SQL: join结果集 / 分组统计结果缓存 - 加速请求相应
利用Redis / Memcache 优化IO响应时间 - 大量写合并为批量写
如计数器先Redis累加再批量写DB
二、缓存更新策略
- LRU / LFU / FIFO 算法剔除:例如maxmemory-policy
先删掉 - 超时剔除:例如:expire
- 主动更新:开发控制key的生命周期
LUR/LIRS算法剔除 | 最差 | 低 |
超时剔除 | 较差 | 低 |
主动更新 | 高 | 高 |
两条建议
- 低一致性:最大内存和淘汰策略
- 高一致性:超时剔除和主动更新结合,最大内存和淘汰策略兜底
无法保证将来某一天内存增上去了,而你监控又没到位,这个时候需要一个最大内存和查询策略进行兜底,保证缓存依旧可用了,直接就OOM。
三、缓存粒度控制
1. 方式
(1)缓存全部属性:select * from table
(2)缓存部分属性:select column1, column2... from table
2. 粒度控制的三个角度
(1)通用性:全属性最好
(2)占用空间:部分属性最好
(3)代码维护:表面上全属性最好
3.思考
真的需要缓存全量属性吗?有必要考虑拓展性吗?像user表、字典表 缓存全量属性很好,但是其他场景的
四、缓存穿透优化
大量请求不命中,返回null
1. 产生原因
(1)业务代码自身问题
(2)恶意攻击、爬虫
- eg:视频网站回显到HTML中的URL页面的内容加密,防止爬虫。获取到爬虫或攻击程序调用url,但是参数加密它不知道怎么传,此时就获取不到数据,即可能产生缓存穿透危害。
2. 如何发现
(1)业务的响应时间
(2) 业务本身问题
(3)监控系统
(4) 相关指标:总调用数、缓存层命中数、存储层命中数
3. 解决方案
方案一:缓存空对象
示例代码:
public String getPassThrough(Map records,String key) {
Jedis jedis = new JedisPool().getResource();
String cacheValue = jedis.get(key);
if(null == cacheValue || ("").equals(cacheValue)) {
String storageValue = records.get(key);
if(null == storageValue || ("").equals(storageValue))
jedis.setex(key, 5, storageValue);
else
jedis.set(key, storageValue);
return storageValue;
} else {
return cacheValue;
}
}
两个问题:
产生大量的空值键
如果产生了大量的 { nullKey : null } ,那么也会对Redis的访问产生影响,所以一般设置一个过期时间。缓存层和存储层 “短期” 数据不一致。(记得更新缓存)
方案二:布隆过滤器拦截(额外的代码,可能引申出新问题)
五、无底洞问题优化
- 问题来源
2010年facebook有3000个 Memcache节点,增加新缓存机器,性能不升反降。
- 问题关键点:
1)更多机器 不代表性能提升
2)批量接口需求(mget、mset)
3)数据增长与水平拓展需求
- 优化IO的集中方法
1)命令本身优化:例如慢查询keys、hgetall bigkey
2)减少网络通信次数
3)优化SQL
4)降低客户端接入成本,例如客户端长连接 、连接池、NIO等
- 四种优化方法(参见上节)
1) 串行mget
2)串行IO
3)并行IO
4)hash_tag
六、缓存雪崩优化
七、热点key重建优化
1. 三个方案
1)减少重缓存次数
2)数据尽可能一致
3)减少潜在危险
2. 两个解决方案
1)互斥锁(mutex key)
public String get(String key) {
String value = jedis.get(key);
if(value == null) {
String mutexKey = "mutex:key:"+key;
if(jedis.set(mutexKey,"1","ex 180","nx")) {
value = db.get(key);
jedis.set(key,value);
jedis.delete(mutexKey);
} else {
//其它线程休息50秒后重试
TimeUnit.SECONDS.sleep(50);
get(key);
}
}
return value;
}
2)永不过期
- 缓存层面:
没有设置过期时间(没用expire) - 功能层面:
为每个value添加
当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。
优点: 相比互斥而言,不会等待,而且只有一个独立线程去重建
问题: 可能存在数据不一致。重建过程进行中,拿了老的值。
public String get2(final String key) {
V v = jedis.get(key);
String value = v.getValue();
long logicTimeout = v.getTimeout();
if(logicTimeout > System.currentTimeMillis()) {
String mutexKey = "mutex:key:"+key;
if(jedis.set(mutexKey,"1","ex 180","nx")) {
threadPool.execute(new Runnable() {
@Override
public void run() {
String dbValue = db.get(key);
jedis.set(key,dbValue);
jedis.delete(mutexKey);
}
});
}
}
return value;
}
3)两种方案对比
方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | - 思路简单 - 保证一致性 |
- 代码复杂度增加 - 存在风险 |
永不过期 | - 基本杜绝热点key问题 | - 不保证一致性 - 逻辑过期时间增加维护成本和内存成本 - 策略:让logicTimeout < realTimeout, - 策略考量:为缓存重建提供宽裕的时间 |
八、本章总结
♦ 缓存收益:加速读写、降低后端存储负载。
♦ 缓存成本:缓存和存储数据不一致性、代码维护成本、运维成本。
♦ 推荐方案:结合剔除、超时、主动更新三种方案共同完成。
♦ 穿透问题:使用缓存空对象和布隆过滤器来解决,注意它们各自的使用场
景和局限性。
♦ 无底洞问题:分布式缓存中,有更多的机器不保证有更高的性能。
有四种 批量操作方式:串行命令、串行10、并行1〇、hashjag。
♦ 雪崩问题:缓存层高可用、客户端降级、提前演练是解决雪崩问题的重要
方法。
♦ 热点key问题:互斥锁、"7卞远不过期"能够在一定程度上解决热点key问
题,开发人员在使用时要了解它们各自的使用成本。