Loading

[26] Redis 缓存穿透&击穿&雪崩&过期

Redis 缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么就不能使用缓存。另外的一些典型问题就是:缓存穿透、缓存雪崩和缓存击穿。目前,业界也都有比较流行的解决方案。

1. 缓存穿透

1.1 概念

缓存穿透的概念很简单,用户想要查询一个数据,发现 Redis 内存数据库没有,也就是缓存没有命中,于是向持久层数据库查询。发现也没有,于是本次查询失败。当用户很多的时候,缓存都没有命中(秒杀),于是都去请求了持久层数据库。这会给持久层数据库造成很大的压力,这时候就相当于出现了缓存穿透。

第一次来查询后,一般我们有回写 Redis 机制第二次来查的时候 Redis 就有了,偶尔出现穿透现象一般情况无关紧要。但如果是恶意攻击 ...

1.2 解决方案

a. 缓存空对象

当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源。

但是这种方法会存在两个问题:

  1. 如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;如果是黑客或者恶意攻击,拿不存在的 id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉;
  2. 即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。

b. Google Guava

Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。

导入依赖

<!-- guava Google 开源的 Guava 中自带的布隆过滤器 -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>23.0</version>
</dependency>

HelloWorld

@Test
public void bloomFilter() {
    // 创建布隆过滤器对象
    BloomFilter<Integer> filter = BloomFilter.create(Funnels.integerFunnel(), 100);
    // 判断指定元素是否存在
    System.out.println(filter.mightContain(1));
    System.out.println(filter.mightContain(2));
    // 将元素添加进布隆过滤器
    filter.put(1);
    filter.put(2);
    System.out.println(filter.mightContain(1));
    System.out.println(filter.mightContain(2));
}

取样本 100W 数据,查查不在 100W 范围内的其它 10W 数据是否存在:

最终:

c. Redis Bloom

Guava 提供的布隆过滤器的实现还是很不错的,但是它有一个重大的缺陷就是只能单机使用 ,而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到 Redis 中的布隆过滤器了。

导入依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.4</version>
</dependency>

白名单架构说明:

  • 误判问题,但是概率小可以接受,不能从布隆过滤器删除;
  • 全部合法的 key 都需要放入 filter + Redis 里面,不然数据就是返回 null。

测试代码:

public static final int _1W = 10000;

// 布隆过滤器里预计要插入多少数据
public static int size = 100 * _1W;
// 误判率,它越小误判的个数也就越少
public static double fpp = 0.03;

static RedissonClient redissonClient = null;
static RBloomFilter rBloomFilter = null;

static {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://192.168.111.147:6379").setDatabase(0);
    // 构造 redisson
    redissonClient = Redisson.create(config);
    // 通过 redisson 构造 rBloomFilter
    rBloomFilter = redissonClient.getBloomFilter("phoneListBloomFilter", new StringCodec());

    rBloomFilter.tryInit(size, fpp);

    // a. 布隆过滤器有 + redis有
    rBloomFilter.add("10086");
    redissonClient.getBucket("10086", new StringCodec()).set("chinaMobile10086");

    // b. 布隆过滤器有 + redis无
    // rBloomFilter.add("10087");

    // c. 都没有

}

public static void main(String[] args) {
    String phoneListById = getPhoneListById("10087");
    System.out.println("------ query result: " + phoneListById);
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    redissonClient.shutdown();
}

private static String getPhoneListById(String IDNumber) {
    String result = null;

    if (IDNumber == null) {
        return null;
    }
    // 1. 先去布隆过滤器里面查询
    if (rBloomFilter.contains(IDNumber)) {
        // 2. 布隆过滤器里有,再去 redis 里面查询
        RBucket<String> rBucket = redissonClient.getBucket(IDNumber, new StringCodec());
        result = rBucket.get();
        if (result != null) {
            return "data come from redis: " + result;
        } else {
            result = getPhoneListByMySQL(IDNumber);
            if (result == null) {
                return null;
            }
            // 3. 重新将数据更新回 redis
            redissonClient.getBucket(IDNumber, new StringCodec()).set(result);
        }
        return "data come from mysql: " + result;
    }
    return result;
}

private static String getPhoneListByMySQL(String IDNumber) {
    return "chinaMobile" + IDNumber;
}

查看 bloom 在 redis 上存储的信息:

延伸:黑名单使用

2. 缓存击穿

2.1 概念

这里需要注意和缓存穿透的区别,缓存击穿,是指一个 key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。

当某个 key 在过期的瞬间,有大量的请求并发访问,这类数据一般是热点数据,由于缓存过期,会同时访问数据库来查询最新数据,并且回写缓存,会导使数据库瞬间压力过大。

2.2 解决方案

设置热点数据永不过期

从缓存层面来看,没有设置过期时间,所以不会出现热点 key 过期后产生的问题。

加互斥锁

多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

逻辑过期

  1. 在设置 key 的时候,设置一个过期时间字段一块存入缓存中,不给当前 key 设置过期时间;
  2. 当查询的时候,从 Redis 去除数据后判断时间是否过期;
  3. 如果过期则开启另外一个线程进行数据,当前线程正常返回(老)数据。

互斥更新、随机退避、差异失效时间

【举例说明】采用定时器将参与活动的特价商品新增进入 Redis 中:

@Slf4j
@Service
public class JHSTaskService {
    @Autowired
    private RedisTemplate redisTemplate;

    @PostConstruct
    public void initJHS() {
        log.info("启动定时器淘宝聚划算功能模拟..." + DateUtil.now());
        new Thread(() -> {
            // 模拟定时器,定时把数据库的特价商品,刷新到redis中
            while (true) {
                // 模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
                List<Product> list = this.products();

                // ====================== 高并发和原子性的对立和统一 ======================
                // 采用 Redis#list 结构来实现存储
                this.redisTemplate.delete(Constants.JHS_KEY);
                // 先删除旧数据,再推入新数据
                this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY, list);
                // ========== 这个替换操作不是原子性的,存在热点缓存突然失效的隐患 ==========

                // 每分钟更新一次特价商品列表
                try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) {}
                log.info("聚划算定时刷新...");
            }
        }, "t1").start();
    }

    /**
     * 模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
     */
    public List<Product> products() {
        List<Product> list = new ArrayList<>();
        for (int i = 1; i <= 20; i++) {
            Random rand = new Random();
            int id = rand.nextInt(10000);
            Product obj = new Product((long) id, "product" + i, i, "detail");
            list.add(obj);
        }
        return list;
    }
}

聚划算商品列表接口:

@RequestMapping(value = "/pruduct/find", method = RequestMethod.GET)
@ApiOperation("分页显示聚划算特价商品(7x24)")
public List<Product> find(int page, int size) {
    List<Product> list = null;
    long start = (page - 1) * size;
    long end = start + size - 1;
    try {
        // 采用 Redis#list 数据结构的 lrange 命令实现分页查询
        list = this.redisTemplate.opsForList().range(Constants.JHS_KEY, start, end);
        if (CollectionUtils.isEmpty(list)) {
            // TODO DB查询
        }
        log.info("查询结果:{}", list);
    } catch (Exception ex) {
        // 这里的异常一般是redis瘫痪/redis网络超时
        log.error("exception:", ex);
        // TODO DB查询
    }
    return list;
}

至此步骤,上述聚划算的功能算是完成,请思考在高并发下有什么经典生产问题?

热点 key 失效 + QPS 过高 = DB 被打爆

互斥更新、差异化失效时间:

@PostConstruct
public void initJHS() {
    log.info("启动定时器淘宝聚划算功能模拟..." + DateUtil.now());
    new Thread(() -> {
        // 模拟定时器,定时把数据库的特价商品,刷新到redis中
        while (true) {
            // 模拟从数据库读取100件特价商品,用于加载到聚划算的页面中
            List<Product> list = this.products();

            // 先更新 B 缓存
            this.redisTemplate.delete(Constants.JHS_KEY_B);
            this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY_B, list);
            this.redisTemplate.expire(Constants.JHS_KEY_B, 70L, TimeUnit.SECONDS);
             // 再更新 A 缓存
            this.redisTemplate.delete(Constants.JHS_KEY_A);
            this.redisTemplate.opsForList().leftPushAll(Constants.JHS_KEY_A, list);
            this.redisTemplate.expire(Constants.JHS_KEY_A, 60L, TimeUnit.SECONDS);

            // 每分钟更新一次特价商品列表
            try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) {}
            log.info("聚划算定时刷新...");
        }
    }, "t1").start();
}

先查询 A 再查询 B:

@RequestMapping(value = "/pruduct/find", method = RequestMethod.GET)
@ApiOperation("分页显示聚划算特价商品(7x24)")
public List<Product> find(int page, int size) {
    List<Product> list = null;
    long start = (page - 1) * size;
    long end = start + size - 1;
    try {
        // 采用 Redis#list 数据结构的 lrange 命令实现分页查询
        list = this.redisTemplate.opsForList().range(Constants.JHS_KEY, start, end);
        if (CollectionUtils.isEmpty(list)) {
            log.info("========= A 缓存已经失效了,记得人工修补,B 缓存自动延续 10s");
            // 用户先查询缓存 A,如果查询不到(例如更新缓存的时候删除了)再查询缓存 B
            this.redisTemplate.opsForList().range(Constants.JHS_KEY_B, start, end);
        }
        log.info("查询结果:{}", list);
    } catch (Exception ex) {
        // 这里的异常一般是redis瘫痪/redis网络超时
        log.error("exception:", ex);
        // TODO DB查询
    }
    return list;
}

3. 缓存雪崩

3.1 概念

缓存雪崩,是指在某一个时间段,缓存集中过期失效。Redis 宕机!

产生雪崩的原因之一,比如在写本文的时候,马上就要到双十二零点,很快就会迎来一波抢购,这波商品时间比较集中的放入了缓存,假设缓存一个小时。那么到了凌晨一点钟的时候,这批商品的缓存就都过期了。而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。

其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。

3.2 解决方案

  1. TTL增加随机值。将缓存失效时间分散开,比如可以在原有的失效时间基础上增加一个随机值。
  2. Redis 高可用。主从+哨兵;集群
  3. 限流降级。ehcache 本地缓存 + Hystrix/Sentinel 限流&降级;
  4. 数据预热。数据加热的含义就是在正式部署之前,先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的 key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
  5. 给业务添加多级缓存。

4. 缓存一致性

当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致(双写一致性)。

结合使用场景考虑:① 一致性要求高 ② 允许延迟一致。

4.1 延迟双删

"延迟双删"指的是在更新缓存时,为了确保缓存与数据库(或其他持久化存储)的一致性,需要进行两次操作:

  • 首次删除操作(Delete 1): 首先删除缓存中的数据。
  • 延迟的第二次删除操作(Delete 2): 等待一段时间后再次尝试删除缓存数据。

这里的关键在于第二次删除操作的延迟时间设定,这段时间通常略长于第一次删除操作所需时间,以确保在该段时间内,数据库的更新操作能够完成,从而保证缓存与数据库的一致性。

在实际应用中,可以通过以下方式实现延迟双删的策略:

  1. 设置合理的延迟时间: 根据具体业务场景和系统的性能特征,设定一个适当的延迟时间。通常建议设置为几十毫秒到几百毫秒,具体时间取决于数据库操作的复杂性和性能预期。
  2. 使用分布式锁保证操作原子性: 在进行延迟双删时,需要使用分布式锁来确保两次删除操作的原子性,避免并发情况下的数据不一致。
  3. 实现方式
    • 使用 Lua 脚本和 Redis 的事务机制: 可以编写 Lua 脚本将两次删除操作封装在一个事务中,同时在第一次删除后等待设定的延迟时间再执行第二次删除。
    • 利用消息队列实现延时任务: 第一次删除后,将第二次删除的请求发送到消息队列中,并设置合适的延时时间,在消息队列中处理第二次删除操作。
  4. 注意事项:
    • 保证延迟时间的合理性: 延迟时间设置过短可能导致数据库更新尚未完成就执行第二次删除,从而引发数据不一致;过长则可能影响系统的响应速度和吞吐量。
    • 处理异常情况: 考虑网络延迟、系统故障等异常情况对延迟双删策略的影响,确保系统在异常情况下依然能够保持数据的一致性。

以下是一个简单的示例 Lua 脚本,展示了如何在 Redis 中利用事务和延迟任务实现延迟双删策略:

-- 定义键和参数
local cacheKey = KEYS[1]
local delayMillis = tonumber(ARGV[1]) or 100 -- 默认延迟100毫秒

-- 开启 Redis 事务
redis.call('MULTI')

-- 第一步操作:删除缓存数据
redis.call('DEL', cacheKey)

-- 第二步操作:设置延迟任务
redis.call('SET', '__delayed_delete:' .. cacheKey, '1', 'PX', delayMillis)

-- 执行事务
local result = redis.call('EXEC')

return result

在这个示例中,MULTI 开启了一个事务,然后依次执行删除缓存数据和设置延迟任务的操作。最后,EXEC 执行事务并返回结果。使用事务可以确保两步操作作为一个原子操作进行,从而避免并发环境下可能出现的数据不一致问题。

请注意,Redis 中的 Lua 脚本执行是单线程的,这意味着在执行事务期间,其他操作无法插入执行,这对于保证操作的原子性是非常有利的。

4.2 分布式锁

  • 共享锁:读锁 readLock,加锁之后,其他线程可以共享读操作;
  • 排他锁:写锁 writeLock,加锁之后,阻塞其他线程读写操作。

4.3 异步通知

  • MQ 方式
  • Canal 方式

5. 缓存过期

  1. 生产上你们的 Redis 内存设置多少?
  2. 如何配置、修改 Redis 的内存大小?
  3. 如果内存满了你怎么办?
  4. Redis 清理内存的方式?定期删除和惰性删除了解过吗?
  5. Redis 缓存淘汰策略?
  6. Redis 的 LRU 了解过吗?

5.1 内存配置

如果不设置最大内存大小或设置最大内存大小为 0,在 64 位操作系统下不限制内存大小,在 32 位操作系统下最多使用 3GB 内存。

生产上一般推荐 Redis 设置内存为最大物理内存的 3/4。

如何修改 Redis 内存设置?

  • 通过配置修改 maxmemory <bytes>
  • 通过命令修改 config set maxmemory <bytes>

真要打满了会怎么样?如果 Redis 内存使用超出了设置的最大值会怎样?

5.2 删除策略

a. 立即删除

Redis 不可能时时刻刻遍历所有被设置了生存时间的 key,来检测数据是否已经到达过期时间,然后对它进行删除。

立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。但是立即删除对 CPU 是最不友好的。因为删除操作会占用 CPU 的时间,如果刚好碰上了 CPU 很忙的时候,比如正在做交集或排序等计算的时候,就会给 CPU 造成额外的压力,这会产生大量的性能消耗,同时也会影响数据的读取操作。

【小结】对 CPU 不友好,用处理器性能换取存储空间 (拿时间换空间)。

b. 惰性删除

数据到达过期时间,不做处理。等下次访问该数据时,

  • 如果未过期,返回数据 ;
  • 发现已过期,删除&返回不存在。

惰性删除策略的缺点是,它对内存是最不友好的。

如果一个 key 已经过期,而这个 key 又仍然保留在 Redis 中,那么只要这个过期 key 不被删除,它所占用的内存就不会释放。

在使用惰性删除策略时,如果数据库中有非常多的过期 key,而这些过期 key 又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行 FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏 —— 无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的 Redis 服务器来说,肯定不是一个好消息。

【小结】对 Memory 不友好,用存储空间换取处理器性能(拿空间换时间)。

c. 定期删除

定期删除策略是前两种策略的折中:

  1. 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响;
  2. 周期性轮询 Redis 库中的时效性数据,采用“随机抽取”的策略,利用过期数据占比的方式控制删除频度。

特点:

  • CPU 性能占用设置有峰值,检测频度可自定义设置;
  • 内存压力不是很大,长期占用内存的冷数据会被持续清理;

【举例】Redis 默认每隔 100ms 检查是否有过期的 key,有过期 key 则删除。注意:Redis 不是每隔 100ms 将所有的 key 检查一次而是随机抽取进行检查!因此,如果只采用定期删除策略,会导致很多 key 到时间没有删除。

定期删除策略的难点是确定删除操作执行的时长和频率:

  • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成立即删除策略,以至于将 CPU 时间过多地消耗在删除过期键上面;
  • 如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除束略一样,出现浪费内存的情况。

因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

定期清理有两种模式:

  • SLOW 模式是定时任务,执行频率默认为 10hz,每次不超过 25ms(通过修改配置文件 redis.conf 的 hz 选项来调整这个次数);
  • FAST 模式执行频率不固定,每次事件循环都会尝试执行,但两次间隔不低于 2ms,每次耗时不超过 1ms。

Redis 的过期删除策略:惰性删除 + 定期删除两种策略进行配合使用。

6. 缓存淘汰

数据的淘汰策略:当 Redis 中的内存不够用时,此时再向 Redis 中添加新的 key,那么 Redis 就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。

引入 2x4=8 种 key 的过期淘汰策略:

  • 2 个维度
    • 过期键中筛选
    • 所有键中筛选
  • 4 个方面
    • LRU
    • LFU
    • random
    • ttl

LRU(Least Recently Used)最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。

LFU(Least Frequently Used)最少频率使用。会统计每个 key 的访问频率,值越小淘汰优先级越高。

策略 简述
noeviction(默认) 不会驱逐任何 key
allkeys-lru 对所有 key 使用 LRU 算法进行删除
volatile-lru 对所有设置了过期时间的 key 使用 LRU 算法进行删除
allkeys-random 对所有 key 随机删除
volatile-random 对所有设置了过期时间的 key 随机删除
volatile-ttl 删除马上要过期的 key
allkeys-lfu 对所有 key 使用 LFU 算法进行删除
volatile-lfu 对所有设置了过期时间的 key 使用 LFU 算法进行删除

配置方式依旧是配置文件和命令两种:

使用建议:

  1. 优先使用 allkeys-lru 策略。充分利用 LRU 算法的优势,把最近最常访问的数据留在缓存中。如果业务有明显的冷热数据区分,建议使用。
  2. 如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用 allkeys-random,随机选择淘汰。
  3. 如果业务中有置顶的需求,可以使用 volatile-lru 策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据。
  4. 如果业务中有短时高频访问的数据,可以使用 allkeys-lfu 或 volatile-lfu 策略。

Quiz:

(1)数据库有 1000w 数据,Redis 只能缓存 20w 数据,如何保证 Redis 中的数据都是热点数据?

使用 allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略,留下来的都是经常访问的热点数据。

(2)Redis 的内存用完了会发生什么?

主要看数据淘汰策略是什么。如果是默认的配置(noeviction),不删除任何数据,内存不足直接报错。

(3)手写一个 LRU 算法?

不求自己纯手工从底层开始打造出自己的 LRU,但是起码要知道如何利用已有的 JDK 数据结构实现一个 Java 版的 LRU。

class LRUCache<K, V> extends LinkedHashMap<K, V> {

    private final int CACHE_SIZE;

    public LRUCache(int cacheSize) {
        // true 表示让 linkedHashMap 按照访问顺序来进行排序,最近访问的放在头部,最老访问的放在尾部。
        super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
        CACHE_SIZE = cacheSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当 map 中的数据量大于指定的缓存个数的时候,就自动删除最老的数据。
        return size() > CACHE_SIZE;
    }

}
posted @ 2023-02-15 23:07  tree6x7  阅读(49)  评论(0编辑  收藏  举报