缓存常见风险(缓存穿透、缓存击穿、缓存雪崩、缓存污染)与应对方法


缓存常见风险(缓存穿透、缓存击穿、缓存雪崩、缓存污染)与应对方法

1 缓存穿透

​ 缓存穿透是指由于请求要获取的数据不在实际的数据源中(数据库),导致每次请求都会穿透缓存,直接落在实际的数据源(数据库)上。这类请求使得缓存层无法缓解真实数据源上的 CUP 或 I/O 压力,最终使得服务可用性与稳定性下降。产生这类问题通常是业务逻辑本身不合理或存在问题,或者可能是被恶意攻击。解决缓存穿透常用的方法有下面三种。

1.1 接口校验

​ 正常业务流程中可能会存在少量访问不存在 key 的情况,如果有但的不存 key 的情况,很有可能系统遭受了恶意的攻击。对应这种问题有效的方法之一就是在服务调用的最外层对基本的数据合法性、用户访问权限进行校验。例如对于商品查询中,商品 ID 通过是正整数,对于负数业务层可以直接返回参数非法。

1.2 存储空值/业务合适的默认值

​ 虽然数据源(数据库)中,没有请求所要的数据,但可以考虑缓存空值或是业务合适的默认值来便向处理该问题。这么做要注意两点:一对应的缓存值要约定一个比较短的过期时间,使得一段时间内缓存最多被击穿一次。 二后继业务在数据中新增了该 key 对应的数据,那应当在插入之后主动清理掉缓存的 Key 值。

1.3 布隆过滤器(Bloom Filter)

​ 对于恶意攻击导致的缓存穿透,通常会在缓存之前设置一个布隆过滤器来解决。这类恶意攻击者通常会刻意制造数据库中肯定不存在的 key 值,然后发送大量的请求。布隆过滤器可以将不存在的 key 值拦截在缓存层之外,让到达缓存层的流量,几乎在数据源(数据库)中都是存在 key 值的。布隆过滤器由一个 bitSet 和 一组 Hash 函数(算法)组成,是一种空间效率极高的概率型算法和数据结构,主要用来判断一个元素是否在集合中存在。通常会将数据源(数据库)中存在的 key 值全量放入布隆过滤器中。如果请求的 key 值布隆过滤器中找不到,那么将直接返回,这类 key 值在源数据源(数据库中)中一定不存在,但要注意请求的 key 在布隆过滤器中,也有可以最后 key 值在数据源中找不到,因为布隆过滤器可能存在漏判的情况,但经过多次 hash 后这种情况的概率通常会很低。下面给一个误判示例。

bloomfilter

如上图,假设数据库中有 a 和 b 两条记录,通过布隆过滤器的 h1、h2、h3 三个散列函数得到 bitset 的位下标然后标记为 1,最后 a 与 b 两条记录在布隆过滤器中保存成功。然后查询请求要获取值为 c 的记录。c 虽然没有在数据库中但由于 c 的 h1(c)、h2(c)、h3(c) ,分别已数据 a 和 b 两条记录的 h2(a)、h1(b)、h3(a)值相同,最终导致误判。注意布隆过滤器有一个比较大的弊端就是存在其中的数据不支持删除。那么如果有数据要从隆过滤器剔除应该如何处理呢,剔除了又要存回隆过滤器该如何处理? 如果删除频率比较低且数据量少的话,可以用一个 HashMap 维护删除的记录,用于标记其不在隆过滤器内。请求时先走一下 HashMap,再走隆过滤器。那如果删除频率比较高且数据量很大的话,使用 HashMap 就不合适了。这个问题就先放这里吧。_ v _

2 缓存击穿

​ 缓存击穿是指由于某种原因缓存中的数据已失效(如缓存过期),而此时有大量的请求来获取该数据,最终由于缓存中没有数据,所有请求都落在实际的数据源(数据库)上。通常热点 key 的失效,容易导致缓存击穿。

2.1 热点数据管理

​ 热点数据由代码来手动管理,缓存击穿是仅针对热点数据被自动失效才引发的问题,对于这类数据,可以直接由开发者通过代码来有计划地完成更新、失效,避免由缓存的策略自动管理。极端情况可能设置热点永不过期。

2.2 加锁同步

​ 以请求该数据的 Key 值为锁,使得只有第一个请求可以流入到真实的数据源中,其他线程采取阻塞或重试策略。当第一个请求从数据源中获取到数据后,在将数据写入到缓存中。如果是进程内缓存出现问题,施加普通互斥锁即可,如果是分布式缓存中出现的问题,就施加分布式锁,这样数据源就不会同时收到大量针对同一个数据的请求了。下面给出伪代码的实现,这里没有区分进程内的情况与分布式的情况。

public static String getData(String key)  {
    //从缓存中获取数据
    String data = getDataFromCache(key);
    //缓存中存在数据直接返回
    if (data != null) {
        return data;
    }
    //尝试获取锁,并成功
    if (lock.tryLock(key)) {
        try {
            //再次尝试从缓存中获取数据
            data = getDataFromCache(key);
            //缓存中存在数据直接返回
            if (data != null) {
                return data;
            }
            //从DB中获取数据
            data = getDataFromDB(key);
            //数据存在设置缓存
            if (data != null) {
                setCache(key, data);
            }
            return data;
        } finally {
            // 释放获取到的锁
            lock.unlock(key);
        }
    } else {
    //尝试获取锁,但失败。线程sleep 100毫秒后,再次调用获取数据方法。
        Thread.sleep(100);
        return getData(key);
    }
}

3 缓存雪崩

​ 缓存击穿是针对单个热点数据失效,由大量请求击穿缓存而给真实数据源带来压力。而缓存雪崩侧是由于大量不同数据在短时间内一起失效,导致这些数据对应的大量请求在缓存层上无法命中,直接达到了真实的数据源,最终导致数据源在短时间内压力剧增。通常这种情况产生的原因有两种,一是由于缓存服务因某些原因崩溃后重启,此时也会造成大量数据同时失效;另外一种是由于大量公共数据从一次操作加载的,这样都可能出现由此载入缓存的大批数据具有相同的过期时间,在同一时刻一起失效。

3.1 增强缓存层可用性

​ 为了避免由于缓存层自崩溃后重启,大量请求导致缓存雪崩。缓存层应集群化部署,同时缓存系统前应该做相应的流量控制,超过阈值部分的流量直接丢弃或者先缓存在 MQ 中(流量高风过后在逐步处理请求)。

3.2 分散缓存过期时间

​ 为了避免大量热点数据在某个时间段内集中失效,我们在在设置失效时间时可以在原时间的基础上加上或者减去一个范围内的随机值。比如原本是一个小时过期,在缓存不同数据时,设置生存期为 55 分钟到 65 分钟之间的某个随机时间。

4 缓存污染

​ 所谓的缓存污染是指缓存中的数据与真实数据源中的数据不一致的情况。虽然缓存通常不追求强一致性,但这我们应当最大程度上保证缓存中的数据与真实数据源中的数据一致,并通过某种手段保证数据的最终的一致性。

​ 缓存污染大多数是由开发者更新缓存不规范造成的,比如执行某类业务操作,先更新了缓存中某些值,然后再执行业务逻辑更新数据源中的数据,但由于某些原因(后继业务发生异常回滚了)最终没有成功写入到数据源,此时缓存的数据是新的,而数据源中的数据却是旧的。另外一种常见的不规范的操作是先更新 BD,再更新缓存

错误更新缓存

​ 时间轴的顺是 T1、T2、T3、T4、T5,执行的业务逻辑分别是进程 A 更新 DB 数据 V 为 1、进程 B 更新 DB 数据 V 为 2、进程 B 更新缓存数据 V 为 2、进程 A 更新缓存数据 V 为 1、进程 C 读取缓存数据 V 为 1。可以看到 DB 中数据 V 对应的实际为 2,而缓存中存储的值为进程 A 更新的值 1,最终导致进程 C 读取到的缓存数据 V 的值为 1,该值与数据库中 V 的真实值不一致。

​ 为了提高数据的最终一致性,业界提出了很多缓存更新的模式,如 Read/Write Through、Write Behind Caching 、 Cache Aside 等。

4.1 Read/Write Through

​ Read/Write Through 简单的来讲就是通过读写来去更新缓存,而真实数据源层对业务操作来说是透明的,这样种模式的个显著特点就是业务代码与缓存、真实数据源之间的耦合度低,代码简洁。但缓存层要单独去实现与维护。

​ Read Through 业务层直接从缓存中读取数据,不关心缓存中数据获取的逻辑。

Cache-Read-through

​ Write Through 写缓存与真实数据源在同一个事务中执行,要么同时成功要么同时失败。这样一定程度上增加了请求处理的时间,但保证了缓存与真实数据源中数据的一致性。

Cache-Write-through
4.2 Write Behind Caching

​ Write Behind Caching 是指先写缓存,然后再按一定的策略去更新真实数据源。可以同步刷新、也可以是异步刷新。对于一些大批量写的业务场景,该模式能极大提高系统处理能力、降低系统的延时性。该模式的缺点是可能导致缓存中的数据与真实数据源中的数据不一致。Write Behind Caching 内部可以将这些批量操作在缓存中做一定的聚合,然后再按某种策略写到真实的数据源中。这种模式 Write Behind Caching 与 Write Through 共同点是缓存层与真实数据源之间的交互对应业务层面来说都是透明的。与 Write Through 对比能发现该模式适合处理大批量的写操作,同时允许存在一定的数据不一致性的业务场景,比如非关键业务的日志处理。

4.3 Cache Aside

​ 该模式简单同时实现成本低,而且能较好的保证数据一致性。其获取数据整体流程可以分为以下几步:

1、业务层请求获取某个 key 对应的数据。

2、业务层检查缓存中是否存在请求 key 对应的值。如果缓存存在(缓存命中),从缓存中拿出对应值,如果缓存不存在先再从实际数据源(数据库)中获取对应的数据,在将数据写入缓存中。

3、业务层返回 key 对应的值。

cache-aside

其更新流程总体可分为以下步骤:

1、业务层更新数据源(数据库)对应的数据。

2、如果更新数据源(数据库)中的数据成功,设置缓存层数据失效。

注意第二步骤中如果更新数据源(数据库)中的数据成功,接着是要让缓存层数据失效,而不是去更新缓存中的数据。前面已分析了先更新数据源(数据库)再更新缓存可能导致的数据不一致问题。那么是不是遵循 Cache Aside 的模式处理数据就不会存在数据不一致性的问题呢?实际上还是有数据还是有可能存在不一致只是这种情况的概率很小。

时间轴的顺是 T1、T2、T3、T4、T5、T6,执行的业务逻辑分别是进程 A 获取缓存数据 V 不存在、进程 A 获取 DB 中数据 V 为 1、进程 B 更新 DB 数据 V 为 2、进程 B 设置数据 V 缓存失效、进程 A 设置缓存数据 V 为 1、进程 C 读取缓存数据 V 为 1。可以看到 DB 中数据 V 对应的实际为 2,而缓存中存储的值为进程 A 更新的值 1,最终导致进程 C 读取到的缓存数据 V 的值为 1,该值与数据库中 V 的真实值不一致。但这种情况的概率很小,理论上进程 B 在执行更新 DB 数据 V 为 2 与进程 B 设置数据 V 缓存失效两步(操作 DB 与操作缓存)所要花费的时间运大于进程 A 设置缓存数据 V 为 1(操作缓存)时间。

总结

​ 本文总结了缓存常见风险(缓存穿透、缓存击穿、缓存雪崩、缓存污染)与应对方法。跟这个话题相关的文章应该是到处都是,而且面试也经常会被问到,但个人觉得很多东西还是要系统性地去总结一下,然后再总结的过程中去更多的思考一些自己平时忽视的关键性点。这些关键性点往往也是别人容易忽视的。在写总结性文章时不能去找出一些关键性点意义可能就没那么大了。

参考

Caching Strategies Overview

缓存风险

缓存更新的套路


评论
  目录