缓存能够有效地加速应用的读写速度, 同时也可以降低后端负载, 对日常应用的开发至关重要。 但是将缓存加入应用架构后也会带来一些问题, 本章将针对这些问题介绍缓存使用技巧和设计方案, 包含如下内容:
·缓存的收益和成本分析。
·缓存更新策略的选择和使用场景。
·缓存粒度控制方法。
·穿透问题优化。
·无底洞问题优化。
·雪崩问题优化。
·热点key重建优化。
1 缓存的收益和成本
收益如下:
·加速读写: 因为缓存通常都是全内存的(例如Redis、 Memcache) , 而存储层通常读写性能不够强悍(例如MySQL) , 通过缓存的使用可以有效地加速读写, 优化用户验。
·降低后端负载: 帮助后端减少访问量和复杂计算(例如很复杂的SQL语句) , 在很大程度降低了后端的负载。
成本如下:
·数据不一致性: 缓存层和存储层的数据存在着一定时间窗口的不一致性, 时间窗口跟更新策略有关。
·代码维护成本: 加入缓存后, 需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。
·运维成本: 以Redis Cluster为例, 加入后无形中增加了运维成本。
2 缓存更新策略
从使用场景、 一致性、 开发人员开发/维护成本三个方面介绍三种缓存的更新策略
1.LRU/LFU/FIFO算法剔除
使用场景。 剔除算法通常用于缓存使用量超过了预设的最大值时候, 如何对现有的数据进行剔除。 例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。
2.超时剔除
使用场景。 超时剔除通过给缓存数据设置过期时间, 让其在过期时间后自动删除, 例如Redis提供的expire命令。 如果业务可以容忍一段时间内, 缓存层数据和存储层数据不一致, 那么可以为其设置过期时间。
3.主动更新
使用场景。 应用方对于数据的一致性要求高, 需要在真实数据更新后,立即更新缓存数据。 例如可以利用消息系统或者其他方式通知缓存更新。
一致性。 一致性最高, 但如果主动更新发生了问题, 那么这条数据很可能很长时间不会更新, 所以建议结合超时剔除一起使用效果会更好。
维护成本。 维护成本会比较高, 开发者需要自己来完成更新, 并保证更新操作的正确性
3 缓存粒度控制
表11-2给出缓存全部数据和部分数据在通用性、 空间占用、 代码维护上的对比, 开发人员可以酌情选择。
缓存粒度问题是一个容易被忽视的问题, 如果使用不当, 可能会造成很多无用空间的浪费, 网络带宽的浪费, 代码通用性较差等情况, 需要综合数据通用性、 空间占用比、 代码维护性三点进行取舍。
4 穿透优化
缓存穿透是指查询一个根本不存在的数据, 缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层, 如图
11-3所示整个过程分为如下3步:
1) 缓存层不命中。
2) 存储层不命中, 不将空结果写回缓存。
3) 返回空结果。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。
造成缓存穿透的基本原因有两个。 第一, 自身业务代码或者数据出现问题, 第二, 一些恶意攻击、 爬虫等造成大量空命中。 下面我们来看一下如何解决缓存穿透问题。
1.缓存空对象
如图11-4所示, 当第2步存储层不命中后, 仍然将空对象保留到缓存层中, 之后再访问这个数据将会从缓存中获取, 这样就保护了后端数据源。
缓存空对象会有两个问题: 第一, 空值做了缓存, 意味着缓存层中存了更多的键, 需要更多的内存空间(如果是攻击, 问题更严重) , 比较有效的方法是针对这类数据设置一个较短的过期时间, 让其自动剔除。 第二, 缓存层和存储层的数据会有一段时间窗口的不一致, 可能会对业务有一定影响。
2.布隆过滤器拦截
如图11-5所示, 在访问缓存层和存储层之前, 将存在的key用布隆过滤器提前保存起来, 做第一层拦截。 例如: 一个推荐系统有4亿个用户id, 每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中, 但是最新的用户由于没有历史行为, 就会发生缓存穿透的行为, 为此可以将所有推荐数据的用户做成布隆过滤器。 如果布隆过滤器认为该用户id不存在, 那么就不会访问存储层, 在一定程度保护了存储层。
5 无底洞优化
2010年, Facebook的Memcache节点已经达到了3000个, 承载着TB级别的缓存数据。 但开发和运维人员发现了一个问题, 为了满足业务要求添加了大量新Memcache节点, 但是发现性能不但没有好转反而下降了, 当时将这种现象称为缓存的“无底洞”现象。
无底洞问题分析:
·客户端一次批量操作会涉及多次网络操作, 也就意味着批量操作会随着节点的增多, 耗时会不断增大。
·网络连接数变多, 对节点的性能也有一定影响。
常见的IO优化思路:
·命令本身的优化, 例如优化SQL语句等。
·减少网络通信次数。
·降低接入成本, 例如客户端使用长连/连接池、 NIO等。
6 雪崩优化
图11-14描述了什么是缓存雪崩: 由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务, 于是所有的请求都会达到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况。
预防和解决缓存雪崩问题, 可以从以下三个方面进行着手。
1) 保证缓存层服务高可用性。 和飞机都有多个引擎一样, 如果缓存层设计成高可用的, 即使个别节点、 个别机器、 甚至是机房宕掉, 依然可以提供服务, 例如前面介绍过的Redis Sentinel和Redis Cluster都实现了高可用。
2) 依赖隔离组件为后端限流并降级。
这里推荐一个Java依赖隔离工具Hystrix( https://github.com/netflix/hystrix) , Hystrix是解决依赖隔离的利器。
7 热点key重建优化
·当前key是一个热点key(例如一个热门的娱乐新闻) , 并发量非常大。
·重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。在缓存失效的瞬间, 有大量线程来重建缓存(如图11-16所示) , 造成后端负载加大, 甚至可能会让应用崩溃。
·减少重建缓存的次数
·数据尽可能一致。
·较少的潜在危险。
1.互斥锁(mutex key)
此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可, 整个过程如图11-17所示。
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.永远不过期
“永远不过期”包含两层意思:
·从缓存层面来看, 确实没有设置过期时间, 所以不会出现热点key过期后产生的问题, 也就是“物理”不过期。
·从功能层面来看, 为每个value设置一个逻辑过期时间, 当发现超过逻辑过期时间后, 会使用单独的线程去构建缓存。
作为一个并发量较大的应用, 在使用缓存时有三个目标: 第一, 加快用户访问速度, 提高用户体验。 第二, 降低后端负载, 减少潜在的风险, 保证系统平稳。 第三, 保证数据“尽可能”及时更新。 下面将按照这三个维度对上述两种解决方案进行分析。
互斥锁(mutex key) : 这种方案思路比较简单, 但是存在一定的隐患, 如果构建缓存过程出现问题或者时间较长, 可能会存在死锁和线程池阻塞的风险, 但是这种方法能够较好地降低后端存储负载, 并在一致性上做得比较好。
·“永远不过期”: 这种方案由于没有设置真正的过期时间, 实际上已经不存在热点key产生的一系列危害, 但是会存在数据不一致的情况, 同时代码复杂度会增大。
备注:文章参考《Redis开发与运维》,作者:付磊,张益军