缓存的收益和成本

image.png
加入缓存后带来的收益如下:

  • 加速读写:因为缓存通常都是全内存的(如Redis、Memcache),而存储层通常读写性能不够强悍(如MySQL),通过缓存的使用可以有效 地加速读写,优化用户体验。
  • 降低后端负载:帮助后端减少访问量和复杂计算(如复杂的SQL 语句),在很大程度降低了后端的负载。

加入缓存带来的成本:

  • 数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。
  • 代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑, 增大了开发者维护代码的成本。
  • 运维成本:以Redis Cluster为例,加入后无形中增加了运维成本。

缓存的使用场景基本包含如下两种:

  • 开销大的复杂计算:以MySQL为例,一些复杂的操作(如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发量,同时也会给MySQL带来巨大的负担。
  • 加速请求响应:即使查询单条后端数据足够快(如select * from table where id= xxx),那么依然可以使用缓存,以Redis为例,每秒可以完成数万次读写,并且提供的批量操作可以优化整个IO链的响应时间。

缓存更新策略

缓存中的数据通常都是有生命周期的,需要在指定时间后被删除或更新,这样可以保证缓存空间在一个可控的范围。但是缓存中的数据会和数据 源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新。三种缓存更新策略如下:

LRU/LFU/FIFO算法剔除

  • 使用场景:通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。
  • 一致性:要清理哪些数据是由具体算法决定,开发人员只能决定使用哪 种算法,所以数据的一致性是最差的。
  • 维护成本。算法不需要开发人员自己来实现,通常只需要配置最大maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择适合自己的算法即可。

    超时剔除

  • 使用场景:超时剔除通过给缓存数据设置过期时间,让其在过期时间后 自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。

  • 一致性:一段时间窗口内(取决于过期时间长短)存在缓存数据和真实数据源的数据不一致问题。
  • 维护成本:维护成本不是很高,只需设置expire过期时间即可,前提是应用方允许这段时间可能发生的数据不一致。

    主动更新

  • 使用场景:应用方对于数据的一致性要求高,需要在真实数据更新后, 立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。

  • 一致性:一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。
  • 维护成本:维护成本比较高,开发者需要自己来完成更新,并保证更新操作的正确性。

    实践建议

  • 低一致性业务建议配置最大内存和淘汰策略的方式使用。

  • 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。

缓存粒度控制

很多项目采用Redis+MySQL架构,缓存层选用Redis,存储层选用MySQL。
image.png
假设需要将MySQL的用户信息使用Redis缓存,用户表有100个列,需要缓存到什么维度呢?这个问题就是缓存粒度问题,究竟是缓存全部属性还是只缓存部分重要属性呢?

  1. # 缓存全部列
  2. set user:{id} 'select * from user where id={id}'
  3. # 缓存部分列
  4. set user:{id} 'select {importantColumn1}, ... , {importantColumnN}
  5. from user where id={id}'

从通用性、空间占用、代码维护三个角度进行说明该问题:

  • 通用性:缓存全部数据比部分数据更加通用,但从实际经验看,很长时 间内应用只需要几个重要的属性。
  • 空间占用:缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:
    • 全部数据会造成内存的浪费;
    • 全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在 极端情况下会阻塞网络;
    • 全部数据的序列化和反序列化的CPU开销更大。
  • 代码维护:全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据。

缓存粒度问题是一个需要综合数据通用性、空间占用比、代码维护性三点进行取舍。

穿透优化

缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层。如图 11-3所示整个过程分为如下3步:

  • 缓存层不命中;
  • 存储层不命中,不将空结果写回缓存;
  • 返回空结果。

image.png
缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高 并发性,甚至可能造成后端存储宕掉。
造成缓存穿透的基本原因有两个:

  • 自身业务代码或者数据出现问题;
  • 一些恶意攻击、爬虫等造成大量空命中。

    缓存穿透的解两种决方案

    缓存空对象

    如图11-4所示,当第2步存储层不命中后,仍然将空对象保留到缓存层 中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。
    缓存空对象会有两个问题:

  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),解决方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

  • 缓存层和存储层的数据会有一段时间不一致,可能会对业务有一定影响。 例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

image.png

布隆过滤器拦截

如图11-5所示,在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。例如:一个推荐系统有4亿个用户id,每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中,但是最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户id不存在,那么就不会访问存储层,在一定程度保护了存储层。
image.png

两种方案对比

解决方案 使用场景 维护成本
缓存空对象
- 数据命中不高
- 数据频繁变化,实时性高

- 代码维护简单
- 需要过多的缓存空间
- 数据不一致
布隆过滤器
- 数据命中不高
- 数据相对固定实时性

- 代码维护复杂
- 缓存空间占用少

无底洞优化

无底洞问题分析:

  • 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。
  • 网络连接数变多,对节点的性能也有一定影响。

更多的节点不代表更高的性能,所谓”无底洞”就是说投入越多不一定产出越多。如何高效地在分布式缓存中批量操作是一个难点。
image.png

单结点批量操作优化

常见的IO优化思路

  • 命令本身的优化,例如优化SQL语句等;
  • 减少网络通信次数;
  • 降低接入成本,例如客户端使用长连/连接池、NIO等。

对于减少网络通信次数,以Redis单节点批量获取n个字符串为例,有三种实现方法:

  • 客户端n次get:n次网络+n次get命令本身。
  • 客户端1次pipeline get:1次网络+n次get命令本身。
  • 客户端1次mget:1次网络+1次mget命令本身(单个节点的批量操作优化方式)。

    分布式批量操作的优化方式

    串行命令

    由于n个key是比较均匀地分布在Redis Cluster的各个节点上,因此无法使用mget命令一次性获取,所以通常来讲要获取n个key的值,最简单的方法就是逐次执行n个get命令,这种操作时间复杂度较高,它的操作时间=n次网络时间+n次命令时间,网络次数是n。很显然这种方案不是最优的,但是实现起来比较简单,如图11-9所示。
    image.png

    串行IO

    并行IO

    hash_tag实现

    雪崩优化

热点key重建优化