【Redis】缓存击穿


缓存击穿


在现代高并发系统中,缓存是提升性能的核心组件之一。然而,随着系统的复杂性和流量的增加,缓存相关的问题也逐渐显现,其中 缓存击穿 是一个非常典型且棘手的问题。

复制代码
======= 🌟 青柠来相伴,代码更简单。🌟 =======
📚 本文所有内容,我都整理在了 青柠合集 里。👇
🎯 搜索关注【青柠代码录】,即可查看所有合集文章 ~
======= 🌟 ================ 🌟 =======

热点数据和冷数据

热点数据,缓存才有价值

对于冷数据而言,大部分数据可能还没有再次访问到,就已经被挤出内存,不仅占用内存,而且价值不大。

频繁修改的数据,看情况考虑使用缓存

对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。

再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。

数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。

那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?

有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力。

比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。

缓存热点key

缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

解决方案

对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询

什么是缓存击穿?

缓存击穿是指 热点数据(key)在缓存中失效时,大量请求同时访问该 key,导致这些请求直接打到后端数据库,从而引发数据库压力剧增甚至崩溃 的现象。

简单来说,就是当某个热点 key 突然失效时,会导致大量请求直接冲击 MySQL 数据库。
img

缓存击穿 vs 缓存穿透

需要注意的是,缓存击穿与缓存穿透是完全不同的问题:

  • 缓存击穿:热点 key 失效,请求回源到数据库。
  • 缓存穿透:查询不存在的数据(既不在缓存也不在数据库),导致恶意或无效请求绕过缓存。

缓存击穿的危害

  1. 数据库压力剧增:大量请求直接访问数据库,可能导致数据库连接池耗尽、响应变慢甚至宕机。
  2. 用户体验下降:由于数据库负载过高,用户请求延迟显著增加。
  3. 系统稳定性风险:一旦数据库崩溃,整个系统可能陷入不可用状态。

因此,在设计高并发系统时,必须充分考虑如何预防和解决缓存击穿问题。


解决方案与最佳实践

针对缓存击穿问题,业界提出了多种解决方案。下面我们将逐一分析每种方案的工作原理、优缺点以及适用场景,并通过代码示例展示其实现方式。


1️⃣ 预加载热门数据

原理

在缓存失效之前,提前更新缓存中的热点数据,避免出现空窗期。

实现步骤

  1. 提前识别热点数据。在redis高峰访问之前,把一些热门数据,提前存入到redis里面,加大这些热门数据key的时长。
  2. 在缓存即将到期时,主动刷新其内容。
  3. 延长热点数据的过期时间,降低失效频率。

示例代码

复制代码
@Service
@Slf4j
public class CachePreloader {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public static final String HOT_KEY = "hot_data";

    // 定时任务预加载热点数据
    @Scheduled(fixedRate = 60000) // 每分钟执行一次
    public void preloadHotData() {
        log.info("开始预加载热点数据...");
        try {
            // 模拟从数据库获取最新数据
            List<String> hotData = fetchDataFromDatabase();
            
            // 更新缓存,设置较长的过期时间
            redisTemplate.opsForValue().set(HOT_KEY, hotData, 3600, TimeUnit.SECONDS);
            log.info("热点数据已成功预加载到缓存中");
        } catch (Exception e) {
            log.error("预加载热点数据失败", e);
        }
    }

    private List<String> fetchDataFromDatabase() {
        // 模拟从数据库加载数据
        return Arrays.asList("data1", "data2", "data3");
    }
}

应用场景

适合能够提前预测热点数据的场景,例如电商促销活动、新闻热点等。


2️⃣ 使用互斥锁(双检加锁)

原理

在缓存失效时,使用分布式锁,确保只有一个线程去加载数据,其他线程等待缓存被重新填充后、再读取。

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

实现步骤

  1. 当发现缓存为空时,尝试获取分布式锁。
  2. 如果获取成功,加载数据并写入缓存。
  3. 如果获取失败,等待一段时间后重试。

示例代码

复制代码
@Service
@Slf4j
public class MutexCacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public static final String LOCK_PREFIX = "lock:";

    public String getData(String key) {
        // 尝试从缓存中获取数据
        String data = (String) redisTemplate.opsForValue().get(key);
        if (data != null) {
            return data;
        }

        // 缓存为空,尝试获取分布式锁
        String lockKey = LOCK_PREFIX + key;
        boolean locked = false;
        try {
            locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "LOCKED", 10, TimeUnit.SECONDS);
            if (locked) {
                log.info("获取分布式锁成功,开始加载数据...");
                // 模拟从数据库加载数据
                data = fetchDataFromDatabase(key);

                // 写入缓存
                redisTemplate.opsForValue().set(key, data, 300, TimeUnit.SECONDS);
                log.info("数据已加载并写入缓存");
            } else {
                // 等待一段时间后重试
                Thread.sleep(100);
                return getData(key);
            }
        } catch (InterruptedException e) {
            log.error("线程中断异常", e);
        } finally {
            if (locked) {
                // 释放分布式锁
                redisTemplate.delete(lockKey);
            }
        }
        return data;
    }

    private String fetchDataFromDatabase(String key) throws InterruptedException {
        // 模拟从数据库加载数据
        Thread.sleep(200); // 模拟延迟
        return "data_from_db_" + key;
    }
}

应用场景

适合需要严格控制并发访问的场景,例如秒杀活动、抢购等。


3️⃣ 差异化过期时间

原理

为热点数据设置随机的过期时间,避免多个 key 同时失效。

实现步骤

  1. 在设置缓存时,为每个 key ,添加一个随机的过期时间偏移量。
  2. 确保热点数据,不会在同一时刻全部失效。

示例代码

复制代码
@Service
@Slf4j
public class RandomExpirationService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public void setCacheWithRandomExpiration(String key, Object value, long baseTTL) {
        // 生成随机偏移量(例如 0-300 秒)
        long randomOffset = new Random().nextInt(300);
        long ttl = baseTTL + randomOffset;

        // 设置缓存及过期时间
        redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
        log.info("缓存已设置,key={}, TTL={}秒", key, ttl);
    }
}

应用场景

适合无法完全预知热点数据,但希望分散失效时间的场景。


4️⃣ 双缓存策略

原理

维护两套缓存(A 和 B),当 A 缓存失效时,切换到 B 缓存,确保服务不中断。

实现步骤

  1. 初始化时,同时加载 A 和 B 缓存。
  2. A 缓存作为主缓存,B 缓存作为备用缓存。
  3. 当 A 缓存失效时,立即切换到 B 缓存,并重新加载 A 缓存。

示例代码

参考文章下面的 JHSTaskServiceJHSTaskController 示例。


实战案例:高并发聚划算业务

我们以聚划算案例为例,结合上述解决方案,进一步优化其实现方式。
img

优化后的代码结构

  1. 预加载热门商品:通过定时任务定期更新缓存。
  2. 互斥锁防止击穿:在缓存失效时,使用分布式锁保护数据库。
  3. 差异失效时间:为商品列表设置随机过期时间。
  4. 双缓存策略:引入 A/B 缓存,确保服务平稳运行。

img

实体类 **Product**

首先定义一个实体类Product,用于表示参与聚划算活动的商品信息。

复制代码
package com.luojia.redis7_study.entities;

import io.swagger.annotations.ApiModel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value="聚划算活动Product信息")
public class Product {

    // 产品id
    private Long id;
    // 产品名称
    private String name;
    // 产品价格
    private Integer price;
    // 产品详情
    private String detail;

}

缓存服务 JHSTaskService

接下来是JHSTaskService,它负责将商品数据,从数据库加载到Redis中,并设置了不同的过期时间来防止缓存击穿。

复制代码
package com.luojia.redis7_study.service;

import com.luojia.redis7_study.entities.Product;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class JHSTaskService {

    public static final String JHS_KEY_A = "jhs:a";
    public static final String JHS_KEY_B = "jhs:b";

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private List<Product> getProductsFromMysql() {
        ArrayList<Product> list = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            Random random = new Random();
            int id = random.nextInt(10000);
            Product product = new Product((long) id, "product" + i, i, "detail");
            list.add(product);
        }
        return list;
    }

    // 双缓存
    @PostConstruct
    public void initJHSAB() {
        log.info("模拟定时任务,从数据库中不断获取参加聚划算的商品");
        // 1 用多线程模拟定时任务,将商品从数据库刷新到redis
        new Thread(() -> {
            while(true) {
                // 2 模拟从数据库查询数据
                List<Product> list = this.getProductsFromMysql();

                // 3 先更新B缓存且让B缓存过期时间超过A缓存,如果突然失效还有B兜底,防止击穿
                // 更新B缓存并设置较长的过期时间
                redisTemplate.delete(JHS_KEY_B);
                redisTemplate.opsForList().leftPushAll(JHS_KEY_B, list);
                // 设置过期时间为1天+10秒
                redisTemplate.expire(JHS_KEY_B, 86410L, TimeUnit.SECONDS);

                // 4 在更新缓存A
                // 更新A缓存并设置正常的过期时间
                redisTemplate.delete(JHS_KEY_A);
                redisTemplate.opsForList().leftPushAll(JHS_KEY_A, list);
                redisTemplate.expire(JHS_KEY_A, 86400L, TimeUnit.SECONDS);

                // 5 暂停1分钟,模拟聚划算参加商品下架上新等操作
                try {
                    Thread.sleep(60000); // 每分钟更新一次
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "t1").start();
    }
}

img

delete命令执行的一瞬间有空隙,其他请求线程继续找redis,但是结果为null,请求直接打到redis,暴击数据库

控制器 JHSTaskController

控制器JHSTaskController负责处理来自客户端的请求,并提供分页功能。

如果主缓存(A)失效,则尝试从备用缓存(B)读取数据。

复制代码
package com.luojia.redis7_study.controller;

import com.luojia.redis7_study.entities.Product;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Api(tags = "模拟聚划算商品上下架")
@RestController
@Slf4j
public class JHSTaskController {

    public static final String JHS_KEY_A = "jhs:a";
    public static final String JHS_KEY_B = "jhs:b";

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @ApiOperation("聚划算案例,AB双缓存,防止热key突然失效")
    @GetMapping("/product/findab")
    public List<Product> findAB(int page, int size) {
        List<Product> list = null;
        long start = (page - 1) * size;
        long end = start + size - 1;

        // 尝试从A缓存中获取数据
        list = redisTemplate.opsForList().range(JHS_KEY_A, start, end);
        if (CollectionUtils.isEmpty(list)) {
            log.info("A缓存已经失效或活动已经结束");
            // 如果A缓存为空,则尝试从B缓存中获取数据
            list = redisTemplate.opsForList().range(JHS_KEY_B, start, end);
            if (CollectionUtils.isEmpty(list)) {
                // 如果B缓存也为空,则需要去数据库查询
                // todo: 这里应该添加逻辑,从数据库中加载数据并写入缓存
            }
        }

        log.info("参加活动的商家: {}", list);
        return list;
    }
}

在这个案例中,我们通过设置两个缓存(A 和 B),并在它们之间轮流切换,确保即使其中一个缓存失效,另一个缓存仍然可以提供服务,从而有效避免了缓存击穿的问题。同时,我们还设置了不同的过期时间,进一步减少了所有缓存同时失效的风险。

面试题

什么是缓存击穿 ? 怎么解决 ?

缓存击穿的意思是对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期,一般都会从后端 DB 加载数据,并回设到缓存,这个时候大并发的请求可能会瞬间把 DB 压垮。
img

解决方案有两种方式:

一、使用互斥锁

第一可以使用互斥锁:当缓存失效时,不立即去load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时,再进行 load db的操作,并回设缓存,否则重试get缓存的方法
img

二、逻辑过期

第二种方案可以设置当前key逻辑过期,大概是思路如下:

①:在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间

②:当查询的时候,从redis取出数据后判断时间是否过期

③:如果过期,则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据不是最新
img

当然两种方案各有利弊:

如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题

如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致。


本文由mdnice多平台发布

相关推荐
uzong3 小时前
《企业IT架构转型之道:阿里巴巴中台战略思想与架构实战》从业务痛点到架构革命,企业转型的底层逻辑(精华解读)
后端·架构
计算机学姐4 小时前
基于SpringBoot的在线学习网站平台【个性化推荐+数据可视化+课程章节学习】
java·vue.js·spring boot·后端·学习·mysql·信息可视化
南囝coding4 小时前
Claude Code 多 Agent 协作:Subagents 和 Agent Teams 怎么选?
前端·后端
uzong4 小时前
《大型网站技术架构》-大型网站技术架构背后的系统性思维(精华解读)
后端·架构
星晨雪海4 小时前
Spring Boot 常用注解
java·spring boot·后端
Leinwin4 小时前
实战教程:3步接入Azure OpenAI调用GPT-5,国内IP直连
后端·python·flask
rrrjqy4 小时前
深入浅出 RAG:基于 Spring AI 的文档分块 (Chunking) 策略详解与实战
java·人工智能·后端·spring
二月龙4 小时前
Python 异常处理机制:从基础语法到自定义异常的实战指南
后端
Go_error4 小时前
Go 语言 const & iota
后端