11.1 缓存的收益和成本

image.png

收益如下:

  • 加速读写
  • 降低后端负载

成本如下:

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

缓存的使用场景:

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

11.2 缓存更新策略

1. LRU/LFU/FIFO 算法剔除

使用场景。剔除算法通常用于缓存使用量超过了预设的最大值时候.

  • maxmemory-policy

一致性。要清理哪些数据是由具体算法决定, 开发人员只能决定使用哪种算法, 所以数据的一致性是最差的。

维护成本。算法不需要开发人员自己来实现, 通常只需要配置最大 maxmemory 和对应的策略即可。开发人员只需要知道每种算法的含义, 选择适合自己的算法即可。

2. 超时剔除

使用场景。超时剔除通过给缓存数据设置过期时间, 让其在过期时间后自动删除

  • expire

一致性。一段时间窗口内 (取决于过期时间长短) 存在一致性问题, 即缓存数据和真实数据源的数据不一致。

维护成本。维护成本不是很高, 只需设置 expire 过期时间即可, 当然前提是应用方允许这段时间可能发生的数据不一致。

3. 主动更新

使用场景。应用方对于数据的一致性要求高, 需要在真实数据更新后, 立即更新缓存数据。

一致性。一致性最高, 但如果主动更新发生了问题, 那么这条数据很可能很长时间不会更新, 所以建议结合超时剔除一起使用效果会更好

维护成本。维护成本会比较高, 开发者需要自己来完成更新, 并保证更新操作的正确性。

image.png

4. 最佳实践

  • 低一致性业务建议配置最大内存和淘汰策略的方式使用。
  • 高一致性业务可以结合使用超时剔除和主动更新, 这样即使主动更新出了问题, 也能保证数据过期时间后删除脏数据。

11.3 缓存粒度控制

image.png

将 MySQL 的用户信息使用 Redis 缓存:

  1. 从 MySQL 获取用户信息
  1. select * from user where id={id}
  1. 将用户信息缓存到 Redis 中
set user:{id} 'select * from user where id={id}'

假设用户表有100个列, 需要缓存到什么维度呢?

  • 缓存全部列
set user:{id} 'select * from user where id={id}'
  • 缓存部分重要列
set user:{id} 'select {importantColumn1}, {important Column2} ... {importantColumnN}
from user where id={id}'

上述这个问题就是缓存粒度问题:

  • 通用性。缓存全部数据比部分数据更加通用, 但从实际经验看, 很长时间内应用只需要几个重要的属性。
  • 空间占用。缓存全部数据要比部分数据占用更多的空间
    • 内存浪费
    • 网络流量大
    • 序列化/反序列化 CPU 开销大
  • 代码维护。全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码, 而且修改后通常还需要刷新缓存数据。

image.png

11.4 穿透优化

缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中, 通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层:

image.png

缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。

1. 缓存空对象

当第2步存储层不命中后, 仍然将空对象保留到缓存层中, 之后再访问这个数据将会从缓存中获取, 这样就保护了后端数据源。

image.png

缓存空对象会有两个问题:

  • 第一, 空值做了缓存, 意味着缓存层中存了更多的键, 需要更多的内存空间 (如果是攻击,问题更严重), 比较有效的方法是针对这类数据设置一个较短的过期时间, 让其自动剔除。
  • 第二, 缓存层和存储层的数据会有一段时间窗口的不一致, 可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致, 此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

2. 布隆过滤器拦截

在访问缓存层和存储层之前, 将存在的 key 用布隆过滤器提前保存起来, 做第一层拦截。

image.png

有关布隆过滤器的相关知识, 可以参考: https://en.wikipedia.org/wiki/Bloom_filter 可以利用 Redis 的 Bitmaps 实现布隆过滤器, GitHub 上已经开源了类似的方案, 读者可以进行参考: https://github.com/erikdubbelboer/redis-lua-scaling-bloom-filter

这种方法适用于数据命中不高、数据相对固定、实时性低 (通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用少。

3. 两种方案对比

image.png

11.5 无底洞优化

2010年, Facebook 的 Memcache 节点已经达到了3000个, 承载着 TB 级别的缓存数据。但开发和运维人员发现了一个问题, 为了满足业务要求添加了大量新 Memcache 节点, 但是发现性能不但没有好转反而下降了, 当时将这种现象称为缓存的“无底洞”现象

由于数据量和访问量的持续增长, 造成需要添加大量节点做水平扩容, 导致键值分布到更多的节点上, 所以无论是 Memcache 还是 Redis 的分布式, 批量操作通常需要从不同节点上获取, 相比于单机批量操作只涉及一次网络操作, 分布式批量操作会涉及多次网络时间。

一次 mget 操作需要访问多个 Redis 节点, 需要多次网络时间:

image.png

由于所有键值都集中在一个节点上, 所以一次批量操作只需要一次网络时间:

image.png

无底洞问题分析:

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

如何在分布式条件下优化批量操作?

常见的 IO 优化思路:

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

减少网络操作次数:

以 Redis 批量获取 n 个字符串为例, 有三种实现方法:

  • 单节点的优化方法

image.png

结合 Redis Cluster 的一些特性对四种分布式的批量操作方式进行说明:

  1. 串行命令

操作时间 = n 次网络 + n 次命令

image.png

  1. 串行 IO

操作时间 = node 次网络 + n 次命令

对于要获取的多个 key, 计算它们都在哪些 slot 上, smart 客户端保存了 slot 和 node 的对应关系. 对于同一个 node 上的 key, 可以使用 mget 或者 pipeline.

image.png

  1. 并行 IO

此方案是将方案2中的最后一步改为多线程执行, 网络次数虽然还是节点个数, 但由于使用多线程网络时间变为 O(1), 这种方案会增加编程的复杂度。它的操作时间为:

max_slow(node 网络时间 )+n 次命令时间

image.png

  1. hash_tag 实现

将多个 key 强制分配到一个节点上, 它的操作时间 = 1次网络时间 + n 次命令时间

image.png

所有 key 属于 node2 节点:

image.png
图11-13 hashtag 只需要1次网络时间

image.png

11.6 雪崩优化

如果缓存层由于某些原因不能提供服务, 于是所有的请求都会达到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况:

image.png

预防和解决缓存雪崩问题, 可以从以下三个方面进行着手:

  • 保证缓存层服务高可用性
  • 依赖隔离组件为后端限流并降级
  • 提前演练

11.7 热点key重建优化

缓存+过期时间可以满足大部分需求, 但是有两个问题如果同时出现, 可能就会对应用造成致命的危害:

  • 当前 key 是一个热点 key (例如一个热门的娱乐新闻), 并发量非常大
  • 重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的 SQL、多次IO、多个依赖等

在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃.

image.png

目标:

  • 减少重建缓存的次数
  • 数据尽可能一致
  • 较少的潜在危险

两种优化方法:

  1. 互斥锁 (mutex key)
    • setnx 命令

此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可.

image.png

  1. 永远不过期
  • 从缓存层面来看, 确实没有设置过期时间, 所以不会出现热点 key 过期后产生的问题, 也就是“物理”不过期
  • 从功能层面来看, 为每个 value 设置一个逻辑过期时间, 当发现超过逻辑过期时间后, 会使用单独的线程去构建缓存

应用程序维护过期与更新.

image.png

作为一个并发量较大的应用, 在使用缓存时有三个目标:

  • 加快用户访问速度, 提高用户体验
  • 降低后端负载, 减少潜在的风险, 保证系统平稳
  • 保证数据“尽可能”及时更新

两种方法的比较:

image.png

11.8 本章重点回顾

  1. 缓存的使用带来的收益是能够加速读写, 降低后端存储负载
  2. 缓存的使用带来的成本是缓存和存储数据不一致性, 代码维护成本增大, 架构复杂度增大
  3. 比较推荐的缓存更新策略是结合剔除、超时、主动更新三种方案共同完成
  4. 穿透问题: 使用缓存空对象和布隆过滤器来解决, 注意它们各自的使用场景和局限性
  5. 无底洞问题: 分布式缓存中, 有更多的机器不保证有更高的性能。有四种批量操作方式:
    1. 串行命令
    2. 串行 IO
    3. 并行 IO
    4. hash_tag
  6. 雪崩问题: 缓存层高可用、客户端降级、提前演练是解决雪崩问题的重要方法
  7. 热点 key 问题: 互斥锁、“永远不过期”能够在一定程度上解决热点 key 问题, 开发人员在使用时要了解它们各自的使用成本