应用与设计:设计考量和无底洞问题
缓存能够有效地加速应用的读写速度,同时也可以降低后端负载,对日 常应用的开发至关重要。但是将缓存加入应用架构后也会带来一些问题,本章将针对这些问题介绍
缓存使用技巧和设计方案,包含如下内容:
- 缓存的收益和成本分析。
- 缓存更新策略的选择和使用场景。
- 缓存粒度控制方法
- 无底洞问题优化
作为一个并发量较大的应用,在使用缓存时有三个目标:
- 加快用 户访问速度,提高用户体验。
- 降低后端负载,减少潜在的风险,保证系统平稳。
- 保证数据“尽可能”及时更新。
缓存的收益和成本
收益如下
- 加速读写:因为缓存通常都是全内存的(例如Redis、Memcache),而存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效 地加速读写,优化用户体验。
- 降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL 语句),在很大程度降低了后端的负载
成本如下
- 数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致 性,时间窗口跟更新策略有关。
- 代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑, 增大了开发者维护代码的成本。
- 运维成本:以Redis Cluster为例,加入后无形中增加了运维成本。
使用场景
- 开销大的复杂计算:以MySQL为例子,一些复杂的操作或者计算(例 如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发
量,同时也会给MySQL带来巨大的负担。 - 加速请求响应:即使查询单条后端数据足够快(例如select*from table where id=),那么依然可以使用缓存,以Redis为例子,每秒可以完成数万 次读写,并且提供的批量操作可以优化整个IO链的响应时间。
缓存更新策略
缓存中的数据通常都是有生命周期的,需要在指定时间后被删除或更新,这样可以保证缓存空间在一个可控的范围。但是缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新。下 面将分别从使用场景、一致性、开发人员开发/维护成本三个方面介绍三种缓存的更新策略。
LRU/LFU/FIFO算法剔除
使用场景:剔除算法通常用于缓存使用量超过了预设的最大值时候,如 何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内 存最大值后对于数据的剔除策略。
一致性:要清理哪些数据是由具体算法决定,开发人员只能决定使用哪 种算法,所以数据的一致性是最差的。
维护成本:算法不需要开发人员自己来实现,通常只需要配置最大 maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择适合自己的算法即可。
超时剔除
使用场景:超时剔除通过给缓存数据设置过期时间,让其在过期时间后 自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓 存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期 后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务, 后果可想而知。
一致性:一段时间窗口内(取决于过期时间长短)存在一致性问题,即 缓存数据和真实数据源的数据不一致。
维护成本:维护成本不是很高,只需设置expire过期时间即可,当然前 提是应用方允许这段时间可能发生的数据不一致。
主动更新
使用场景:应用方对于数据的一致性要求高,需要在真实数据更新后, 立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。
一致性:一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。
维护成本:维护成本会比较高,开发者需要自己来完成更新,并保证更 新操作的正确性。
三种常见更新策略的对比
最佳实践
有两个建议:
- 低一致性业务建议配置最大内存和淘汰策略的方式使用。
- 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新 出了问题,也能保证数据过期时间后删除脏数据。
缓存粒度控制
例如现在需要将MySQL的用户信息使用Redis缓存,假设用户表有100个列,需要缓存到什么维度呢?
上述这个问题就是缓存粒度问题,究竟是缓存全部属性还是只缓存部分重要属性呢?下面将从通用性、空间占用、代码维护三个角度进行说明。
通用性:缓存全部数据比部分数据更加通用,但从实际经验看,很长时 间内应用只需要几个重要的属性。
空间占用:缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:
- 全部数据会造成内存的浪费。
- 全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在 极端情况下会阻塞网络。
- 全部数据的序列化和反序列化的CPU开销更大
代码维护:全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据。‘
下表给出缓存全部数据和部分数据在通用性、空间占用、代码维护上 的对比,开发人员可以酌情选择。
缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很 多无用空间的浪费,网络带宽的浪费,代码通用性较差等情况,需要综合数 据通用性、空间占用比、代码维护性三点进行取舍。
无底洞问题
2010年,Facebook的Memcache节点已经达到了3000个,承载着TB级别 的缓存数据。但开发和运维人员发现了一个问题,为了满足业务要求添加了 大量新Memcache节点,但是发现性能不但没有好转反而下降了,当时将这 种现象称为缓存的“无底洞”现象。
那么为什么会产生这种现象呢,通常来说添加节点使得Memcache集群 性能应该更强了,但事实并非如此。键值数据库由于通常采用哈希函数将 key映射到各个节点上,造成key的分布与业务无关,但是由于数据量和访问 量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的 节点上,所以无论是Memcache还是Redis的分布式,批量操作通常需要从不同节点上获取,相比于单机批量操作只涉及一次网络操作,分布式批量操作会涉及多次网络时间。
无底洞问题分析:
客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随 着节点的增多,耗时会不断增大。
网络连接数变多,对节点的性能也有一定影响。
用一句通俗的话总结就是,更多的节点不代表更高的性能,所谓“无底洞”就是说投入越多不一定产出越多。但是分布式又是不可以避免的,因为 访问量和数据量越来越大,一个节点根本抗不住,所以如何高效地在分布式缓存中批量操作是一个难点。
下面介绍如何在分布式条件下优化批量操作。在介绍具体的方法之前, 我们来看一下常见的IO优化思路:
- 命令本身的优化,例如优化SQL语句等。
- 减少网络通信次数。
- 降低接入成本,例如客户端使用长连/连接池、NIO等。
上面已经给出了IO的优化思路以及单个节点的批量操作优化方式,下面 我们将结合Redis Cluster的一些特性对四种分布式的批量操作方式进行说明
串行命令
由于n个key是比较均匀地分布在Redis Cluster的各个节点上,因此无法 使用mget命令一次性获取,所以通常来讲要获取n个key的值,最简单的方法就是逐次执行n个get命令,这种操作时间复杂度较高,它的操作时间=n次网络时间+n次命令时间,网络次数是n。很显然这种方案不是最优的,但是实现起来比较简单
串行IO
Redis Cluster使用CRC16算法计算出散列值,再取对16383的余数就可以 算出slot值,Smart客户端会保存slot和节点的对应关 系,有了这两个数据就可以将属于同一个节点的key进行归档,得到每个节 点的key子列表,之后对每个节点执行mget或者Pipeline操作,它的操作时间 =node次网络时间+n次命令时间,网络次数是node的个数,很明显这种方案比第一种要好很多,但是如果节点数太多,还是有 一定的性能问题。
并行IO
此方案是将方案2中的最后一步改为多线程执行,网络次数虽然还是节 点个数,但由于使用多线程网络时间变为O(1),这种方案会增加编程的 复杂度。
hash_tag实现
Redis Cluster的hash_tag功能,它可以将多个key强制分配到 一个节点上,它的操作时间=1次网络时间+n次命令时间