1 缓存的收益和成本分析

缓存架构流程图

缓存架构流程图

1.1 缓存的收益

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

1.2 缓存的成本

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

1.3 缓存的使用场景

  1. 开销大的复杂计算:对于像 MySQL 中大量联表操作和分组计算(不是很理解这里联表操作怎么在缓存中优化),如果不加入缓存,不但无法满足高并发量,同时也会给 MySQL 带来巨大的负担。
  2. 加速请求响应:即使查询单条后端数据足够快,但如果使用缓存,比如说使用 Redis,那么可以提供每秒数万次读写,并且提供的批量操作可以优化整个 IO 链的响应时间。

2 缓存更新策略的选择和使用场景

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

2.1 LRU / LFU / FIFO 算法剔除

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

2.2 超时剔除

  1. 使用场景:超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如 redis 提供的 expire 命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务,后果可想而知。
  2. 一致性:一段时间窗口内(取决于过期时间长短)存在一致性问题,即缓存数据和真实数据源的数据不一致。
  3. 维护成本:维护成本不是很高,只需要设置 expire 过期时间即可,当然前提是应用方允许这段时间可能发生的数据不一致。

2.3 主动更新

  1. 使用场景:应用方对于数据的一致性要求高,需要在真实数据更新后立刻更新缓存数据。例如可以利用消息系统或者其它方式通知缓存更新。
  2. 一致性:一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果比较好。
  3. 维护成本:维护成本比较高,开发者需要自己来完成更新,并保证更新操作的正确性。

下图是三种更新策略的对比

三种更新策略的对比

2.4 最佳实践

两个建立:

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

3 缓存粒度控制方法

下面通过一种常见的架构来讨论控制的粒度

MySQL + Redis 架构s

对于这种架构,我们到底是缓存 MysSQL 中全部数据还是部分重要数据,这个问题就是缓存粒度问题,究竟是缓存全部属性还是只缓存部分重要属性。下面从通用性、空间占用和代码维护三个角度说明。

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

下图是两者的比较

缓存全部数据和部分数据的对比

4 穿透问题优化

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

因为查不到所以不写入缓存层,这里就存在一个问题了,如果业务代码或数据存在问题,那么会发生非常高的并发穿透,那么缓存层就失去了意义,其次就算一切正常,但是遇到恶意攻击或者是爬虫爬取数据的情况,那么同样可能产生很多空命中,依然使缓存层失去了意义,从而可能导致数据库奔溃。

4.1 解决方案

4.1.1 缓存空对象

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

4.1.2 布隆过滤器

备注:布隆过滤器的作用是判断一个元素是否在一个集合当中,这个算法的好处是只需要使用比较小的空间就可以在很短的时间内判断出结果,但是缺点是有一定的误判率。

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

4.1.3 两种方案的对比

两种方案的对比

5 无底洞问题优化

6 雪崩问题优化

缓存雪崩:由于缓存层承担了大量的请求,有效的保护了存储层,但是如果缓存层突然挂了,那么所有的请求就会全部打到存储层,会造成存储层的级联宕机。预防和解决缓存雪崩问题,可以从下面三个方面着手:

  1. 保证缓存层服务高可用:缓存层设计成高可用,做集群。
  2. 依赖隔离组建为后端限流并降级:比如说对于个性化推荐服务不可用,可以降级补充热点数据
  3. 提前演练:及时发现可能出现的问题,做好预案。

7 热点 key 重建优化

如果某一个 key 是一个热门 key,并发量非常大,当这个 key 到期时,重建缓存不能再短时间内完成,重建缓存可能涉及复杂计算。在缓存失效的瞬间,可能触发非常多的线程去重建缓存,形式参考下图

触发多次重建缓存

当出现这种情况的时候,可能瞬间将系统中的资源消耗干净了,非常危险,下面讨论解决方案

7.1 分布式锁

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

等待重建缓存完成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    // 代码实现
    public String get(String key) throws InterruptedException {
        // 从 Redis 中获取数据
        String value = redis.get(key);
        // 如果 value 为空,则开始重构缓存
        if (value == null) {
            // 只允许一个线程重构缓存,使用 nx,并设置过期时间 ex
            String mutexKey = "mutex🔑" + 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);
                return get(key);
            }
        }
        return value;
    }

7.2 永远不过期

从缓存层面看,不设置过期时间确实不会过期。从功能层面设置定时任务去刷新这个缓存。

7.3 两者对比

两种策略的对比

参考资料

1.Redis 开发与运维