redis 简单整理——缓存设计[三十二]
前言
简单整理一下缓存设计。
正文
缓存的好处:
·加速读写:因为缓存通常都是全内存的(例如Redis、Memcache),而 存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效 地加速读写,优化用户体验。
·降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL 语句),在很大程度降低了后端的负载。
可能产生的负面影响:
·数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。
代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑, 增大了开发者维护代码的成本。
运维成本:以Redis Cluster为例,加入后无形中增加了运维成本。
缓存的使用场景基本包含如下两种:
·开销大的复杂计算:以MySQL为例子,一些复杂的操作或者计算(例 如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发 量,同时也会给MySQL带来巨大的负担。
加速请求响应:即使查询单条后端数据足够快(例如select*from table where id=),那么依然可以使用缓存,以Redis为例子,每秒可以完成数万 次读写,并且提供的批量操作可以优化整个IO链的响应时间。
下面将分别从使用场景、一致性、开发人员开发/维护成本三个方面介绍三种 缓存的更新策略。
1.LRU/LFU/FIFO算法剔除
使用场景。剔除算法通常用于缓存使用量超过了预设的最大值时候,如 何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内 存最大值后对于数据的剔除策略。
一致性。要清理哪些数据是由具体算法决定,开发人员只能决定使用哪 种算法,所以数据的一致性是最差的。
维护成本。算法不需要开发人员自己来实现,通常只需要配置最大 maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择 适合自己的算法即可。
2.超时剔除
使用场景。超时剔除通过给缓存数据设置过期时间,让其在过期时间后 自动删除,例如Redis提供的expire命令。
如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。
在数据过期 后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务, 后果可想而知。
一致性。一段时间窗口内(取决于过期时间长短)存在一致性问题,即 缓存数据和真实数据源的数据不一致。
维护成本。维护成本不是很高,只需设置expire过期时间即可,当然前 提是应用方允许这段时间可能发生的数据不一致。
3.主动更新
使用场景。应用方对于数据的一致性要求高,需要在真实数据更新后, 立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。
一致性。一致性最高,但如果主动更新发生了问题,那么这条数据很可 能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。
维护成本。维护成本会比较高,开发者需要自己来完成更新,并保证更 新操作的正确性。
实践:
·低一致性业务建议配置最大内存和淘汰策略的方式使用。
·高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新 出了问题,也能保证数据过期时间后删除脏数据。
缓存粒度
到底是缓存全部数据,还是缓存部分数据。
比如说,缓存select * from xxx where 。。。 还是select a,b,c from xxx where 。。。
这里可能有人疑惑,查询a,b,c 这样的性能更好啊。但是如果是多个查询要用到xxx这张表的话,那么是否是缓存更多更加有效呢?
通用性。缓存全部数据比部分数据更加通用,但从实际经验看,很长时 间内应用只需要几个重要的属性。
空间占用。缓存全部数据要比部分数据占用更多的空间,可能存在以下 问题:
·全部数据会造成内存的浪费。
·全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在 极端情况下会阻塞网络
全部数据的序列化和反序列化的CPU开销更大。
代码维护。全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据。
穿透优化
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命 中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层,如图 11-3所示整个过程分为如下3步:
1)缓存层不命中。
2)存储层不命中,不将空结果写回缓存。
3)返回空结果。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓 存保护后端存储的意义。
缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高 并发性,甚至可能造成后端存储宕掉。
通常可以在程序中分别统计总调用 数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是 出现了缓存穿透问题。 造成缓存穿透的基本原因有两个。
第一,自身业务代码或者数据出现问 题,
第二,一些恶意攻击、爬虫等造成大量空命中。下面我们来看一下如何 解决缓存穿透问题。
1.缓存空对象
当第2步存储层不命中后,仍然将空对象保留到缓存层 中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。
缓存空对象会有两个问题:第一,空值做了缓存,意味着缓存层中存了 更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的 方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
第二,缓存 层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。
例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间 就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方 式清除掉缓存层中的空对象。
在访问缓存层和存储层之前,将存在的key用布隆过滤 器提前保存起来,做第一层拦截。
例如:一个推荐系统有4亿个用户id,每 个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层 中,但是最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可 以将所有推荐数据的用户做成布隆过滤器。
如果布隆过滤器认为该用户id不 存在,那么就不会访问存储层,在一定程度保护了存储层。
这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据 集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。
无底洞优化
2010年,Facebook的Memcache节点已经达到了3000个,承载着TB级别 的缓存数据。
但开发和运维人员发现了一个问题,为了满足业务要求添加了 大量新Memcache节点,但是发现性能不但没有好转反而下降了,当时将这 种现象称为缓存的“无底洞”现象。
那么为什么会产生这种现象呢,通常来说添加节点使得Memcache集群 性能应该更强了,但事实并非如此。
键值数据库由于通常采用哈希函数将 key映射到各个节点上,造成key的分布与业务无关,但是由于数据量和访问 量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的 节点上,所以无论是Memcache还是Redis的分布式,批量操作通常需要从不 同节点上获取,
相比于单机批量操作只涉及一次网络操作,分布式批量操作 会涉及多次网络时间。
·客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随 着节点的增多,耗时会不断增大。
·网络连接数变多,对节点的性能也有一定影响。
用一句通俗的话总结就是,更多的节点不代表更高的性能,所谓“无底 洞”就是说投入越多不一定产出越多。但是分布式又是不可以避免的,因为 访问量和数据量越来越大,一个节点根本抗不住,所以如何高效地在分布式 缓存中批量操作是一个难点。
·命令本身的优化,例如优化SQL语句等。
·减少网络通信次数。
·降低接入成本,例如客户端使用长连/连接池、NIO等。 这里我们假设命令、客户端连接已经为最优,重点讨论减少网络操作次 数。
以Redis批量获取n个字符串为例,有三种实现方法
·客户端n次get:n次网络+n次get命令本身。
·客户端1次pipeline get:1次网络+n次get命令本身。
·客户端1次mget:1次网络+1次mget命令本身。
公司集群比较小,没有这个问题,故而不考虑。
雪崩优化
由于缓存层承载着大量请求,有效地 保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请 求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情 况。
缓存雪崩的英文原意是stampeding herd(奔逃的野牛),指的是缓存层 宕掉后,流量会像奔逃的野牛一样,打向后端存储。
1)保证缓存层服务高可用性。和飞机都有多个引擎一样,如果缓存层 设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提 供服务,例如前面介绍过的Redis Sentinel和Redis Cluster都实现了高可用
2)依赖隔离组件为后端限流并降级。无论是缓存层还是存储层都会有 出错的概率,可以将它们视同为资源。
作为并发量较大的系统,假如有一个 资源不可用,可能会造成线程全部阻塞(hang)在这个资源上,造成整个系 统不可用。
降级机制在高并发系统中是非常普遍的:比如推荐服务中,如果 个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面是开天窗。
在实际项目中,我们需要对重要的资源(例如Redis、MySQL、 HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。
但是线程池如何管理,比如如何关闭资源池、开启资源池、资源池阀值管理,这些做起来还是 相当复杂的。
3)提前演练。在项目上线前,演练缓存层宕掉后,应用以及后端的负 载情况以及可能出现的问题,在此基础上做一些预案设定。
热点key重建优化
开发人员使用“缓存+过期时间”的策略既可以加速数据读写,又保证数 据的定期更新,这种模式基本能够满足绝大部分需求。
但是有两个问题如果 同时出现,可能就会对应用造成致命的危害:
·当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
·重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次IO、多个依赖等。
.在缓存失效的瞬间,有大量线程来重建缓存,造成 后端负载加大,甚至可能会让应用崩溃。
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来 更多的麻烦,所以需要制定如下目标:
·减少重建缓存的次数。
·数据尽可能一致。
·较少的潜在危险。
1.互斥锁(mutex key)
此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行 完,重新从缓存获取数据即可
思路:
2.永远不过期
“永远不过期”包含两层意思:
·从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期 后产生的问题,也就是“物理”不过期。
·从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻 辑过期时间后,会使用单独的线程去构建缓存。
从实战看,此方法有效杜绝了热点key产生的问题,但唯一不足的就是 重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不 一致。下面代码使用Redis进行模拟:
String get(final String key)
{
V v = redis.get(key);
String value = v.getValue(); // 逻辑过期时间
long logicTimeout = v.getLogicTimeout(); // 如果逻辑过期时间小于当前时间,开始后台构建
if (v.logicTimeout <= System.currentTimeMillis())
{ String mutexKey = "mutex:key:" + key; if (redis.set(mutexKey, "1", "ex 180", "nx"))
{
// 重构缓存 threadPool.execute(new Runnable() {
public void run() { String dbValue = db.get(key);
redis.set(key, (dbvalue,newLogicTimeout));
redis.delete(mutexKey);
}}); } }
return value; }
结
redis 整理就此结束,开始整理mysql。