Redis 雪崩场景重现与解决方案

Redis 雪崩场景重现与解决方案

Redis 雪崩是指大量缓存 key 在同一时间失效,导致所有请求直接打到数据库,造成数据库压力骤增甚至宕机的现象。

一、环境准备

1、依赖(Maven)

<dependencies>
    <!-- Redis客户端 -->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>4.4.3</version>
    </dependency>
    <!-- 数据库连接 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>
    <!-- 线程池 -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
        <version>2.12.0</version>
    </dependency>
</dependencies>

2、Redis/Jedis 配置

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class RedisConfig {
    private static final JedisPool jedisPool;

    static {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(200);
        config.setMaxIdle(50);
        config.setMinIdle(10);
        // 初始化连接池
        jedisPool = new JedisPool(config, "localhost", 6379, 10000);
    }

    public static Jedis getJedis() {
        return jedisPool.getResource();
    }

    public static void close(Jedis jedis) {
        if (jedis != null) {
            jedis.close();
        }
    }
}

二、雪崩场景重现

1、核心逻辑(大量 key 同时失效)

模拟 1000 个商品缓存 key,全部设置 10 秒后过期,然后用多线程模拟高并发请求,观察数据库压力。

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class RedisAvalancheDemo {
    // 数据库配置
    private static final String DB_URL = "jdbc:mysql://localhost:3306/test?useSSL=false";
    private static final String DB_USER = "root";
    private static final String DB_PWD = "123456";

    // 1. 初始化缓存:1000个key全部设置10秒过期(模拟雪崩前提)
    public static void initCache() {
        Jedis jedis = RedisConfig.getJedis();
        try {
            for (int i = 1; i <= 1000; i++) {
                String key = "product:" + i;
                // 所有key都设置10秒过期,模拟集中失效
                jedis.setex(key, 10, getProductFromDB(i));
            }
            System.out.println("缓存初始化完成,1000个key将在10秒后同时失效");
        } finally {
            RedisConfig.close(jedis);
        }
    }

    // 2. 模拟高并发请求
    public static void simulateHighConcurrency() throws InterruptedException {
        // 线程池:模拟100个并发请求
        ExecutorService executor = Executors.newFixedThreadPool(100);
        CountDownLatch latch = new CountDownLatch(10000); // 总请求数10000

        // 延迟10秒(等缓存失效)后发起请求
        Thread.sleep(10000);
        System.out.println("缓存已失效,开始发起高并发请求...");

        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            int productId = (int) (Math.random() * 1000) + 1;
            executor.submit(() -> {
                try {
                    getProduct(productId); // 每次请求都尝试从缓存/数据库获取数据
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("所有请求完成,耗时:" + (end - start) + "ms");
        executor.shutdown();
    }

    // 3. 核心业务方法:先查缓存,缓存失效查数据库
    public static String getProduct(int productId) {
        Jedis jedis = RedisConfig.getJedis();
        String key = "product:" + i;
        try {
            // 1. 查缓存
            String value = jedis.get(key);
            if (value != null) {
                return value;
            }
            // 2. 缓存失效,查数据库(雪崩时所有请求都会走到这一步)
            String dbValue = getProductFromDB(productId);
            // 3. 重新写入缓存(但此时并发请求会重复写入,加剧数据库压力)
            jedis.setex(key, 10, dbValue);
            return dbValue;
        } finally {
            RedisConfig.close(jedis);
        }
    }

    // 模拟从数据库查询(模拟数据库耗时)
    private static String getProductFromDB(int productId) {
        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PWD);
             PreparedStatement ps = conn.prepareStatement("SELECT name FROM product WHERE id = ?")) {
            // 模拟数据库查询耗时(50ms)
            Thread.sleep(50);
            ps.setInt(1, productId);
            ResultSet rs = ps.executeQuery();
            if (rs.next()) {
                return rs.getString("name");
            }
            return "product_" + productId;
        } catch (Exception e) {
            throw new RuntimeException("数据库查询失败", e);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 步骤1:初始化缓存(1000个key同时过期)
        initCache();
        // 步骤2:模拟高并发请求
        simulateHighConcurrency();
    }
}

2、雪崩现象表现

  • 缓存失效后,10000 个请求全部打到数据库;

  • 每个数据库查询耗时 50ms,100 并发下数据库连接池会被打满;

  • 最终总请求耗时会超过 50*10000/100 = 5000ms,且数据库 CPU/IO 飙升。

三、雪崩解决方案

针对雪崩的核心解决方案是避免大量 key 同时失效 + 保护数据库,下面是 4 种核心方案的实现:

方案 1:过期时间随机化(核心)

给每个 key 的过期时间增加随机偏移量,避免集中失效:

// 修改initCache和getProduct中的过期时间设置逻辑
public static void initCache() {
    Jedis jedis = RedisConfig.getJedis();
    try {
        for (int i = 1; i <= 1000; i++) {
            String key = "product:" + i;
            // 基础过期时间10秒 + 随机0-5秒偏移
            int expireTime = 10 + (int) (Math.random() * 5);
            jedis.setex(key, expireTime, getProductFromDB(i));
        }
    } finally {
        RedisConfig.close(jedis);
    }
}

方案 2:互斥锁(防止缓存击穿,间接缓解雪崩)

当缓存失效时,只有一个线程去查数据库,其他线程等待:

public static String getProductWithLock(int productId) {
    Jedis jedis = RedisConfig.getJedis();
    String key = "product:" + productId;
    String lockKey = "lock:product:" + productId;
    try {
        // 1. 查缓存
        String value = jedis.get(key);
        if (value != null) {
            return value;
        }

        // 2. 获取分布式锁(SET NX EX,过期时间3秒)
        String lockResult = jedis.set(lockKey, "1", "NX", "EX", 3);
        if ("OK".equals(lockResult)) {
            // 3. 拿到锁,查数据库
            String dbValue = getProductFromDB(productId);
            // 4. 写入缓存(带随机过期时间)
            int expireTime = 10 + (int) (Math.random() * 5);
            jedis.setex(key, expireTime, dbValue);
            // 释放锁
            jedis.del(lockKey);
            return dbValue;
        } else {
            // 5. 没拿到锁,休眠100ms后重试
            Thread.sleep(100);
            return getProductWithLock(productId); // 递归重试
        }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        RedisConfig.close(jedis);
    }
}

方案 3:缓存预热 + 永不过期 key

核心数据设置永不过期,通过后台线程定期更新缓存,避免失效:

// 1. 初始化永不过期缓存
public static void initPermanentCache() {
    Jedis jedis = RedisConfig.getJedis();
    try {
        for (int i = 1; i <= 1000; i++) {
            String key = "product:" + i;
            jedis.set(key, getProductFromDB(i)); // 不设置过期时间
        }
    } finally {
        RedisConfig.close(jedis);
    }
}

// 2. 后台线程定期更新缓存(每9秒更新一次)
public static void startCacheRefreshTask() {
    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        Jedis jedis = RedisConfig.getJedis();
        try {
            for (int i = 1; i <= 1000; i++) {
                String key = "product:" + i;
                jedis.set(key, getProductFromDB(i)); // 覆盖更新
            }
            System.out.println("缓存更新完成");
        } finally {
            RedisConfig.close(jedis);
        }
    }, 0, 9, TimeUnit.SECONDS);
}

方案 4:限流 + 降级(保护数据库)

通过信号量限制并发请求数,超出部分直接降级返回默认值:

import java.util.concurrent.Semaphore;

public class ProductService {
    // 限制数据库并发数为20
    private static final Semaphore DB_SEMAPHORE = new Semaphore(20);

    public static String getProductWithLimit(int productId) {
        Jedis jedis = RedisConfig.getJedis();
        String key = "product:" + productId;
        try {
            // 1. 查缓存
            String value = jedis.get(key);
            if (value != null) {
                return value;
            }

            // 2. 尝试获取信号量,失败则降级
            if (!DB_SEMAPHORE.tryAcquire()) {
                return "default_product"; // 降级返回默认值
            }

            try {
                // 3. 拿到信号量,查数据库
                String dbValue = getProductFromDB(productId);
                jedis.setex(key, 10 + (int) (Math.random() * 5), dbValue);
                return dbValue;
            } finally {
                DB_SEMAPHORE.release(); // 释放信号量
            }
        } finally {
            RedisConfig.close(jedis);
        }
    }
}

四、完整解决方案整合

public class RedisAvalancheSolution {
    private static final JedisPool jedisPool;
    private static final Semaphore DB_SEMAPHORE = new Semaphore(20);
    private static final String DB_URL = "jdbc:mysql://localhost:3306/test?useSSL=false";
    private static final String DB_USER = "root";
    private static final String DB_PWD = "123456";

    static {
        // 初始化Redis连接池
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(200);
        config.setMaxIdle(50);
        config.setMinIdle(10);
        jedisPool = new JedisPool(config, "localhost", 6379, 10000);

        // 启动缓存预热和定期更新任务
        initPermanentCache();
        startCacheRefreshTask();
    }

    // 缓存预热
    private static void initPermanentCache() {
        try (Jedis jedis = jedisPool.getResource()) {
            for (int i = 1; i <= 1000; i++) {
                String key = "product:" + i;
                jedis.set(key, getProductFromDB(i));
            }
        }
    }

    // 后台更新缓存
    private static void startCacheRefreshTask() {
        Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
            try (Jedis jedis = jedisPool.getResource()) {
                for (int i = 1; i <= 1000; i++) {
                    String key = "product:" + i;
                    jedis.set(key, getProductFromDB(i));
                }
            }
        }, 0, 9, TimeUnit.SECONDS);
    }

    // 核心方法:整合锁 + 限流 + 随机过期
    public static String getProduct(int productId) {
        String key = "product:" + productId;
        String lockKey = "lock:product:" + productId;

        try (Jedis jedis = jedisPool.getResource()) {
            // 1. 查缓存
            String value = jedis.get(key);
            if (value != null) {
                return value;
            }

            // 2. 尝试获取分布式锁
            String lockResult = jedis.set(lockKey, "1", "NX", "EX", 3);
            if ("OK".equals(lockResult)) {
                try {
                    // 3. 限流:获取数据库访问许可
                    if (!DB_SEMAPHORE.tryAcquire()) {
                        return "default_product";
                    }
                    // 4. 查数据库
                    String dbValue = getProductFromDB(productId);
                    // 5. 写入缓存(随机过期时间)
                    int expireTime = 10 + (int) (Math.random() * 5);
                    jedis.setex(key, expireTime, dbValue);
                    return dbValue;
                } finally {
                    DB_SEMAPHORE.release();
                    jedis.del(lockKey); // 释放锁
                }
            } else {
                // 6. 没拿到锁,重试
                Thread.sleep(100);
                return getProduct(productId);
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    // 数据库查询
    private static String getProductFromDB(int productId) {
        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PWD);
             PreparedStatement ps = conn.prepareStatement("SELECT name FROM product WHERE id = ?")) {
            Thread.sleep(50); // 模拟耗时
            ps.setInt(1, productId);
            ResultSet rs = ps.executeQuery();
            return rs.next() ? rs.getString("name") : "product_" + productId;
        } catch (Exception e) {
            throw new RuntimeException("数据库查询失败", e);
        }
    }

    // 测试
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(100);
        CountDownLatch latch = new CountDownLatch(10000);

        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            int productId = (int) (Math.random() * 1000) + 1;
            executor.submit(() -> {
                try {
                    getProduct(productId);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("优化后总耗时:" + (end - start) + "ms");
        executor.shutdown();
    }
}

总结

  1. 雪崩核心原因:大量缓存 key 集中失效 + 高并发请求直接穿透到数据库;

  2. 核心解决方案

    • 过期时间随机化:避免 key 集中失效(最基础有效);

    • 分布式锁:防止缓存击穿,减少数据库重复查询;

    • 限流降级:保护数据库,避免被打垮;

    • 缓存预热 + 后台更新:核心数据永不过期,定期主动更新。

  3. 效果:优化后请求耗时会从秒级降至百毫秒级,数据库压力大幅降低。

Java 中的垃圾回收算法 2026-03-03
畅快观影(MoonTV) 2026-03-15

评论区