系统刚启动,大量请求直接打到数据库,数据库瞬间崩溃——这就是缓存雪崩。本文将揭秘大厂如何通过缓存预热和定时刷新策略,在系统启动时就构建起完整的缓存防线,彻底告别缓存雪崩问题。

在这里插入图片描述


一、场景引入:一次凌晨上线的惨痛教训

1.1 真实案例

某电商平台大促前夜,技术团队信心满满地发布了新版本:

时间线:
00:00 系统发布完成,服务启动
00:01 运维打开流量入口
00:02 数据库CPU飙升至95%
00:03 大量查询超时,连接池耗尽
00:05 数据库主库宕机,自动切换到从库
00:08 从库也扛不住,服务全面瘫痪
00:30 紧急回滚,大促泡汤

问题根源:系统刚启动时Redis缓存是空的,大量请求直接穿透到数据库,这就是典型的缓存雪崩

1.2 什么是缓存雪崩?

缓存雪崩是指在某个时间点,大量缓存同时失效或缓存未建立,导致大量请求直接打到数据库,引发数据库压力激增甚至宕机。

缓存雪崩的典型场景:

场景1:系统冷启动
┌─────────┐     ┌─────────┐     ┌─────────┐
│  请求    │────→│  Redis  │────→│  MySQL  │
│  10000/s │     │  空缓存  │     │  被打爆  │
└─────────┘     └─────────┘     └─────────┘

场景2:缓存集中过期
┌─────────┐     ┌─────────┐     ┌─────────┐
│  请求    │────→│  Redis  │────→│  MySQL  │
│  10000/s │     │  大量Key │     │  被打爆  │
│         │     │  同时过期│     │         │
└─────────┘     └─────────┘     └─────────┘

场景3:Redis集群故障
┌─────────┐     ┌─────────┐     ┌─────────┐
│  请求    │────→│  Redis  │────→│  MySQL  │
│  10000/s │     │  集群宕机│     │  被打爆  │
└─────────┘     └─────────┘     └─────────┘

1.3 缓存雪崩 vs 缓存击穿 vs 缓存穿透

问题类型 定义 触发条件 解决方案
缓存雪崩 大量缓存同时失效或未建立 系统启动、集中过期、集群故障 缓存预热、随机过期时间、多级缓存
缓存击穿 单个热点Key失效,大量请求打到DB 热点Key过期 互斥锁、逻辑过期
缓存穿透 查询不存在的Key,每次都查DB 恶意攻击、数据不存在 布隆过滤器、缓存空值

二、解决方案:缓存预热+定时刷新策略

2.1 整体架构设计

┌─────────────────────────────────────────────────────────────────────┐
│                        缓存预热与刷新架构                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   ┌─────────────────────────────────────────────────────────────┐   │
│   │                    系统启动阶段                               │   │
│   │  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐      │   │
│   │  │  预热任务1   │    │  预热任务2   │    │  预热任务N   │      │   │
│   │  │ 商品分类数据 │    │ 热门商品数据 │    │  系统配置    │      │   │
│   │  └──────┬──────┘    └──────┬──────┘    └──────┬──────┘      │   │
│   │         └─────────────────┼──────────────────┘              │   │
│   │                           ▼                                 │   │
│   │                    ┌─────────────┐                          │   │
│   │                    │   Redis     │                          │   │
│   │                    │   缓存层     │                          │   │
│   │                    └─────────────┘                          │   │
│   └─────────────────────────────────────────────────────────────┘   │
│                                                                      │
│   ┌─────────────────────────────────────────────────────────────┐   │
│   │                    运行阶段(定时刷新)                        │   │
│   │                                                              │   │
│   │   ┌──────────┐    ┌──────────┐    ┌──────────┐              │   │
│   │   │ 定时任务  │    │ 消息队列  │    │ 手动刷新  │              │   │
│   │   │ (Xxl-Job)│    │ (Canal)  │    │ (管理后台)│              │   │
│   │   └────┬─────┘    └────┬─────┘    └────┬─────┘              │   │
│   │        └───────────────┼───────────────┘                    │   │
│   │                        ▼                                    │   │
│   │                 ┌─────────────┐                             │   │
│   │                 │  刷新策略    │                             │   │
│   │                 │- 全量刷新   │                             │   │
│   │                 │- 增量刷新   │                             │   │
│   │                 │- 延迟双删   │                             │   │
│   │                 └──────┬──────┘                             │   │
│   │                        ▼                                    │   │
│   │                 ┌─────────────┐                             │   │
│   │                 │   Redis     │                             │   │
│   │                 └─────────────┘                             │   │
│   └─────────────────────────────────────────────────────────────┘   │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

2.2 核心思路

  1. 缓存预热:系统启动时,将热点数据提前加载到Redis
  2. 定时刷新:通过定时任务定期更新缓存,避免集中过期
  3. 增量更新:只更新变更的数据,减少系统压力
  4. 多级防护:本地缓存+Redis+数据库三级防护

三、实战代码:从零实现缓存预热与定时刷新

3.1 缓存预热实现

方案一:Spring Boot启动时预热
/**
 * 缓存预热执行器
 * 系统启动时将热点数据加载到Redis
 */
@Component
@Slf4j
public class CachePreheatRunner implements CommandLineRunner, Ordered {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private CategoryService categoryService;
    
    @Autowired
    private ProductService productService;
    
    @Autowired
    private ConfigService configService;
    
    /**
     * 执行顺序:优先级最高,最先执行
     */
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
    
    @Override
    public void run(String... args) {
        log.info("🚀 开始执行缓存预热...");
        
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        
        try {
            // 1. 预热商品分类数据(优先级最高)
            preheatCategoryCache();
            
            // 2. 预热热门商品数据
            preheatHotProducts();
            
            // 3. 预热系统配置数据
            preheatSystemConfig();
            
            // 4. 预热字典数据
            preheatDictionaryData();
            
        } catch (Exception e) {
            log.error("❌ 缓存预热失败", e);
            // 预热失败发送告警
            sendAlert("缓存预热失败", e.getMessage());
        }
        
        stopWatch.stop();
        log.info("✅ 缓存预热完成,耗时: {}ms", stopWatch.getTotalTimeMillis());
    }
    
    /**
     * 预热商品分类数据
     */
    private void preheatCategoryCache() {
        log.info("📦 开始预热商品分类数据...");
        
        List<Category> categories = categoryService.getAllCategories();
        
        if (CollUtil.isEmpty(categories)) {
            log.warn("⚠️ 商品分类数据为空");
            return;
        }
        
        // 使用Pipeline批量写入,提升性能
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (Category category : categories) {
                String key = "category:" + category.getId();
                String value = JSON.toJSONString(category);
                // 设置随机过期时间,避免集中过期
                int expireTime = 3600 + RandomUtil.randomInt(300); // 60-65分钟
                connection.setEx(key.getBytes(), expireTime, value.getBytes());
            }
            return null;
        });
        
        // 同时缓存分类树结构
        List<CategoryVO> categoryTree = categoryService.buildCategoryTree(categories);
        redisTemplate.opsForValue().set("category:tree", JSON.toJSONString(categoryTree), 
            3600 + RandomUtil.randomInt(300), TimeUnit.SECONDS);
        
        log.info("✅ 商品分类数据预热完成,共 {} 条", categories.size());
    }
    
    /**
     * 预热热门商品数据
     */
    private void preheatHotProducts() {
        log.info("🔥 开始预热热门商品数据...");
        
        // 从数据库获取热门商品(销量Top 1000)
        List<Product> hotProducts = productService.getHotProducts(1000);
        
        if (CollUtil.isEmpty(hotProducts)) {
            log.warn("⚠️ 热门商品数据为空");
            return;
        }
        
        // 分批写入,每批100条
        List<List<Product>> partitions = Lists.partition(hotProducts, 100);
        
        for (List<Product> batch : partitions) {
            redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                for (Product product : batch) {
                    String key = "product:detail:" + product.getId();
                    String value = JSON.toJSONString(product);
                    // 热门商品缓存时间更长
                    int expireTime = 7200 + RandomUtil.randomInt(600); // 2-2.1小时
                    connection.setEx(key.getBytes(), expireTime, value.getBytes());
                }
                return null;
            });
        }
        
        // 缓存热门商品ID列表
        List<Long> hotProductIds = hotProducts.stream()
            .map(Product::getId)
            .collect(Collectors.toList());
        redisTemplate.opsForValue().set("product:hot:list", JSON.toJSONString(hotProductIds), 
            3600, TimeUnit.SECONDS);
        
        log.info("✅ 热门商品数据预热完成,共 {} 条", hotProducts.size());
    }
    
    /**
     * 预热系统配置数据
     */
    private void preheatSystemConfig() {
        log.info("⚙️ 开始预热系统配置数据...");
        
        Map<String, String> configs = configService.getAllConfigs();
        
        if (CollUtil.isEmpty(configs)) {
            log.warn("⚠️ 系统配置数据为空");
            return;
        }
        
        // 配置数据使用Hash存储
        Map<String, String> configMap = new HashMap<>();
        configs.forEach((k, v) -> configMap.put(k, v));
        
        redisTemplate.opsForHash().putAll("config:system", configMap);
        // 配置数据过期时间较长
        redisTemplate.expire("config:system", 1, TimeUnit.DAYS);
        
        log.info("✅ 系统配置数据预热完成,共 {} 条", configs.size());
    }
    
    /**
     * 预热字典数据
     */
    private void preheatDictionaryData() {
        log.info("📚 开始预热字典数据...");
        
        List<Dictionary> dictionaries = configService.getAllDictionaries();
        
        for (Dictionary dict : dictionaries) {
            String key = "dict:" + dict.getType();
            redisTemplate.opsForValue().set(key, JSON.toJSONString(dict.getItems()), 
                2, TimeUnit.HOURS);
        }
        
        log.info("✅ 字典数据预热完成,共 {} 种类型", dictionaries.size());
    }
    
    /**
     * 发送告警
     */
    private void sendAlert(String title, String content) {
        // 集成企业微信/钉钉告警
        log.error("🚨 告警: {} - {}", title, content);
    }
}
方案二:分布式预热(多实例场景)
/**
 * 分布式缓存预热
 * 使用Redis分布式锁确保只有一个实例执行预热
 */
@Component
@Slf4j
public class DistributedCachePreheatRunner implements CommandLineRunner {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private CachePreheatRunner cachePreheatRunner;
    
    private static final String PREHEAT_LOCK_KEY = "lock:cache:preheat";
    private static final long LOCK_EXPIRE_SECONDS = 300; // 5分钟
    
    @Override
    public void run(String... args) {
        // 尝试获取分布式锁
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(PREHEAT_LOCK_KEY, "1", LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(locked)) {
            try {
                log.info("🔒 获取预热锁成功,开始执行缓存预热");
                cachePreheatRunner.run(args);
            } finally {
                // 释放锁
                redisTemplate.delete(PREHEAT_LOCK_KEY);
                log.info("🔓 释放预热锁");
            }
        } else {
            log.info("⏭️ 其他实例正在执行预热,跳过");
        }
    }
}

3.2 定时刷新策略实现

方案一:Spring Scheduler定时刷新
/**
 * 缓存定时刷新任务
 * 使用Spring Scheduler定期刷新缓存数据
 */
@Component
@Slf4j
public class CacheRefreshScheduler {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private CategoryService categoryService;
    
    @Autowired
    private ProductService productService;
    
    /**
     * 刷新商品分类缓存
     * 每30分钟执行一次
     */
    @Scheduled(cron = "0 0/30 * * * ?")
    public void refreshCategoryCache() {
        log.info("🔄 开始刷新商品分类缓存...");
        
        try {
            List<Category> categories = categoryService.getAllCategories();
            
            // 使用延迟双删策略
            String tempKey = "category:temp:" + System.currentTimeMillis();
            
            // 1. 先写入临时Key
            redisTemplate.opsForValue().set(tempKey, JSON.toJSONString(categories), 5, TimeUnit.MINUTES);
            
            // 2. 删除旧Key
            redisTemplate.delete("category:tree");
            
            // 3. 写入新数据
            redisTemplate.opsForValue().set("category:tree", JSON.toJSONString(categories), 
                3600 + RandomUtil.randomInt(300), TimeUnit.SECONDS);
            
            // 4. 删除临时Key
            redisTemplate.delete(tempKey);
            
            log.info("✅ 商品分类缓存刷新完成");
        } catch (Exception e) {
            log.error("❌ 商品分类缓存刷新失败", e);
        }
    }
    
    /**
     * 刷新热门商品缓存
     * 每小时执行一次
     */
    @Scheduled(cron = "0 0 * * * ?")
    public void refreshHotProductsCache() {
        log.info("🔄 开始刷新热门商品缓存...");
        
        try {
            List<Product> hotProducts = productService.getHotProducts(1000);
            
            // 分批刷新,避免一次性加载过多数据
            List<List<Product>> partitions = Lists.partition(hotProducts, 100);
            
            for (int i = 0; i < partitions.size(); i++) {
                List<Product> batch = partitions.get(i);
                
                redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                    for (Product product : batch) {
                        String key = "product:detail:" + product.getId();
                        String value = JSON.toJSONString(product);
                        int expireTime = 7200 + RandomUtil.randomInt(600);
                        connection.setEx(key.getBytes(), expireTime, value.getBytes());
                    }
                    return null;
                });
                
                // 每批处理完后休眠100ms,避免Redis压力过大
                if (i < partitions.size() - 1) {
                    Thread.sleep(100);
                }
            }
            
            log.info("✅ 热门商品缓存刷新完成,共 {} 条", hotProducts.size());
        } catch (Exception e) {
            log.error("❌ 热门商品缓存刷新失败", e);
        }
    }
    
    /**
     * 刷新系统配置缓存
     * 每5分钟执行一次(配置变更相对频繁)
     */
    @Scheduled(cron = "0 0/5 * * * ?")
    public void refreshConfigCache() {
        log.info("🔄 开始刷新系统配置缓存...");
        
        try {
            Map<String, String> configs = configService.getAllConfigs();
            
            // 获取当前缓存
            Map<Object, Object> oldConfigs = redisTemplate.opsForHash().entries("config:system");
            
            // 对比差异,只更新变更的配置
            Set<String> changedKeys = new HashSet<>();
            configs.forEach((k, v) -> {
                String oldValue = (String) oldConfigs.get(k);
                if (!v.equals(oldValue)) {
                    changedKeys.add(k);
                    redisTemplate.opsForHash().put("config:system", k, v);
                }
            });
            
            // 删除已失效的配置
            oldConfigs.keySet().forEach(k -> {
                if (!configs.containsKey(k)) {
                    redisTemplate.opsForHash().delete("config:system", k);
                    changedKeys.add((String) k);
                }
            });
            
            log.info("✅ 系统配置缓存刷新完成,变更 {} 条", changedKeys.size());
        } catch (Exception e) {
            log.error("❌ 系统配置缓存刷新失败", e);
        }
    }
}
方案二:Xxl-Job分布式定时任务
/**
 * Xxl-Job分布式定时任务刷新缓存
 * 适合多实例部署场景
 */
@JobHandler(value = "cacheRefreshJobHandler")
@Component
@Slf4j
public class CacheRefreshJobHandler extends IJobHandler {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private ProductService productService;
    
    @XxlJob("refreshHotProductCache")
    public ReturnT<String> refreshHotProductCache() {
        log.info("🔄 [Xxl-Job] 开始刷新热门商品缓存...");
        
        XxlJobHelper.log("开始执行热门商品缓存刷新任务");
        
        try {
            // 分片处理:每个实例处理一部分数据
            int shardIndex = XxlJobHelper.getShardIndex();
            int shardTotal = XxlJobHelper.getShardTotal();
            
            XxlJobHelper.log("当前分片: {}/{}, 处理范围: {}%", shardIndex, shardTotal, 
                (shardIndex * 100 / shardTotal) + "% ~ " + ((shardIndex + 1) * 100 / shardTotal) + "%");
            
            List<Product> hotProducts = productService.getHotProducts(1000);
            
            // 根据分片索引过滤数据
            List<Product> shardData = hotProducts.stream()
                .filter(p -> p.getId() % shardTotal == shardIndex)
                .collect(Collectors.toList());
            
            // 批量刷新缓存
            for (Product product : shardData) {
                String key = "product:detail:" + product.getId();
                redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 
                    7200 + RandomUtil.randomInt(600), TimeUnit.SECONDS);
            }
            
            XxlJobHelper.log("热门商品缓存刷新完成,处理 {} 条", shardData.size());
            return SUCCESS;
            
        } catch (Exception e) {
            XxlJobHelper.log("热门商品缓存刷新失败: {}", e.getMessage());
            log.error("❌ [Xxl-Job] 热门商品缓存刷新失败", e);
            return FAIL;
        }
    }
    
    @XxlJob("refreshCategoryCache")
    public ReturnT<String> refreshCategoryCache() {
        log.info("🔄 [Xxl-Job] 开始刷新分类缓存...");
        
        try {
            // 幂等性检查:检查是否已有任务在执行
            Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent("lock:refresh:category", "1", 5, TimeUnit.MINUTES);
            
            if (!Boolean.TRUE.equals(locked)) {
                XxlJobHelper.log("已有任务在执行,跳过本次执行");
                return SUCCESS;
            }
            
            // 执行刷新逻辑
            // ...
            
            // 释放锁
            redisTemplate.delete("lock:refresh:category");
            
            return SUCCESS;
            
        } catch (Exception e) {
            log.error("❌ [Xxl-Job] 分类缓存刷新失败", e);
            return FAIL;
        }
    }
}

3.3 增量更新实现(Canal监听)

/**
 * Canal监听MySQL Binlog实现增量缓存更新
 */
@Component
@Slf4j
public class CanalCacheUpdateListener {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private ProductService productService;
    
    @KafkaListener(topics = "canal_product_db")
    public void onProductChange(CanalMessage message) {
        String table = message.getTable();
        String type = message.getType(); // INSERT/UPDATE/DELETE
        List<Map<String, Object>> data = message.getData();
        
        if (!"product".equals(table)) {
            return;
        }
        
        log.info("🔄 Canal监听到商品数据变更, 操作类型: {}, 数据条数: {}", type, data.size());
        
        for (Map<String, Object> row : data) {
            Long productId = Long.valueOf(row.get("id").toString());
            String cacheKey = "product:detail:" + productId;
            
            switch (type) {
                case "INSERT":
                case "UPDATE":
                    // 查询最新数据并更新缓存
                    Product product = productService.getById(productId);
                    if (product != null) {
                        redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 
                            7200 + RandomUtil.randomInt(600), TimeUnit.SECONDS);
                        log.debug("✅ 更新商品缓存: {}", cacheKey);
                    }
                    break;
                    
                case "DELETE":
                    // 删除缓存
                    redisTemplate.delete(cacheKey);
                    log.debug("🗑️ 删除商品缓存: {}", cacheKey);
                    break;
            }
        }
    }
}

3.4 缓存刷新管理后台

/**
 * 缓存管理Controller
 * 提供手动刷新缓存的接口
 */
@RestController
@RequestMapping("/admin/cache")
@Slf4j
public class CacheManageController {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private CachePreheatRunner cachePreheatRunner;
    
    @Autowired
    private CacheRefreshScheduler cacheRefreshScheduler;
    
    /**
     * 手动刷新指定缓存
     */
    @PostMapping("/refresh")
    @PreAuthorize("hasRole('ADMIN')")
    public Result refreshCache(@RequestBody @Valid CacheRefreshDTO dto) {
        String cacheType = dto.getCacheType();
        
        log.info("👤 手动刷新缓存, 类型: {}, 操作人: {}", cacheType, SecurityUtil.getCurrentUserName());
        
        switch (cacheType) {
            case "category":
                cacheRefreshScheduler.refreshCategoryCache();
                break;
            case "product":
                cacheRefreshScheduler.refreshHotProductsCache();
                break;
            case "config":
                cacheRefreshScheduler.refreshConfigCache();
                break;
            case "all":
                cacheRefreshScheduler.refreshCategoryCache();
                cacheRefreshScheduler.refreshHotProductsCache();
                cacheRefreshScheduler.refreshConfigCache();
                break;
            default:
                return Result.error("不支持的缓存类型: " + cacheType);
        }
        
        return Result.success("缓存刷新成功");
    }
    
    /**
     * 删除指定缓存
     */
    @DeleteMapping("/clear")
    @PreAuthorize("hasRole('ADMIN')")
    public Result clearCache(@RequestParam String pattern) {
        log.info("👤 手动清理缓存, pattern: {}, 操作人: {}", pattern, SecurityUtil.getCurrentUserName());
        
        // 使用scan命令避免阻塞Redis
        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();
        Cursor<byte[]> cursor = redisTemplate.executeWithStickyConnection(
            redisConnection -> redisConnection.scan(options));
        
        List<String> keysToDelete = new ArrayList<>();
        while (cursor.hasNext()) {
            keysToDelete.add(new String(cursor.next()));
            // 每100条删除一次
            if (keysToDelete.size() >= 100) {
                redisTemplate.delete(keysToDelete);
                keysToDelete.clear();
            }
        }
        
        // 删除剩余的
        if (!keysToDelete.isEmpty()) {
            redisTemplate.delete(keysToDelete);
        }
        
        return Result.success("缓存清理成功");
    }
    
    /**
     * 获取缓存统计信息
     */
    @GetMapping("/stats")
    @PreAuthorize("hasRole('ADMIN')")
    public Result getCacheStats() {
        Map<String, Object> stats = new HashMap<>();
        
        // Redis信息
        Properties info = redisTemplate.execute(
            (RedisCallback<Properties>) RedisServerCommands::info);
        stats.put("redisInfo", info);
        
        // Key数量统计
        Long categoryKeys = redisTemplate.keys("category:*").size();
        Long productKeys = redisTemplate.keys("product:*").size();
        Long configKeys = redisTemplate.keys("config:*").size();
        
        stats.put("categoryKeys", categoryKeys);
        stats.put("productKeys", productKeys);
        stats.put("configKeys", configKeys);
        
        return Result.success(stats);
    }
    
    /**
     * 重新执行缓存预热
     */
    @PostMapping("/preheat")
    @PreAuthorize("hasRole('ADMIN')")
    public Result preheatCache() {
        log.info("👤 手动执行缓存预热, 操作人: {}", SecurityUtil.getCurrentUserName());
        
        ThreadUtil.execute(() -> {
            try {
                cachePreheatRunner.run();
            } catch (Exception e) {
                log.error("手动预热失败", e);
            }
        });
        
        return Result.success("缓存预热任务已启动");
    }
}

四、高级进阶:多级缓存一致性方案

4.1 缓存刷新策略对比

策略 适用场景 优点 缺点
定时全量刷新 数据量小、变更少 简单可靠 数据量大时性能差
定时增量刷新 数据量大、变更频繁 性能好 实现复杂
Canal实时同步 对一致性要求高 实时性好 依赖Canal组件
延迟双删 高并发写场景 避免脏读 有短暂不一致窗口
消息队列异步 大数据量更新 削峰填谷 有延迟

4.2 延迟双删实现

/**
 * 延迟双删工具类
 * 解决缓存与数据库不一致问题
 */
@Component
public class DelayDoubleDeleteUtil {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private ThreadPoolExecutor delayDeleteExecutor;
    
    /**
     * 执行延迟双删
     * @param key 缓存Key
     * @param delayMillis 延迟时间(毫秒)
     */
    public void doubleDelete(String key, long delayMillis) {
        // 第一次删除
        redisTemplate.delete(key);
        log.debug("🗑️ 第一次删除缓存: {}", key);
        
        // 延迟后第二次删除
        delayDeleteExecutor.execute(() -> {
            try {
                Thread.sleep(delayMillis);
                redisTemplate.delete(key);
                log.debug("🗑️ 第二次删除缓存: {}", key);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
    
    /**
     * 带更新的延迟双删
     */
    public <T> void doubleDeleteAndUpdate(String key, T data, long delayMillis) {
        // 先更新数据库(调用方完成)
        
        // 第一次删除
        redisTemplate.delete(key);
        
        // 延迟后删除并重新加载
        delayDeleteExecutor.execute(() -> {
            try {
                Thread.sleep(delayMillis);
                redisTemplate.delete(key);
                // 重新加载缓存
                redisTemplate.opsForValue().set(key, JSON.toJSONString(data), 
                    3600, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

五、预判问题与解答

Q1:缓存预热失败会影响系统启动吗?

A:建议采用失败降级策略:

@Override
public void run(String... args) {
    try {
        preheatCache();
    } catch (Exception e) {
        log.error("缓存预热失败", e);
        // 发送告警,但不阻塞启动
        sendAlert("缓存预热失败", e.getMessage());
        
        // 启动兜底任务:定时重试预热
        scheduleRetryPreheat();
    }
}

预热失败不应该阻塞系统启动,但需要通过告警通知运维人员。

Q2:预热数据量太大,Redis内存不够怎么办?

A:可以采用分级预热策略:

预热优先级:
P0(必须预热):商品分类、系统配置、热门商品Top 100
P1(建议预热):热门商品Top 1000、字典数据
P2(可选预热):普通商品、历史数据

根据Redis内存情况决定预热到哪个级别

代码实现:

private void preheatByPriority() {
    // 获取Redis内存使用情况
    Long usedMemory = getRedisUsedMemory();
    Long maxMemory = getRedisMaxMemory();
    double usageRatio = (double) usedMemory / maxMemory;
    
    if (usageRatio < 0.5) {
        // 内存充足,预热P0+P1+P2
        preheatP0();
        preheatP1();
        preheatP2();
    } else if (usageRatio < 0.7) {
        // 内存中等,预热P0+P1
        preheatP0();
        preheatP1();
    } else {
        // 内存紧张,只预热P0
        preheatP0();
    }
}

Q3:如何避免定时任务集中执行导致Redis压力过大?

A:采用错峰执行策略:

// 使用随机延迟,避免集中执行
@Scheduled(cron = "0 0 * * * ?")
public void refreshCache() {
    // 随机延迟0-60秒
    long delay = RandomUtil.randomLong(60000);
    Thread.sleep(delay);
    
    // 执行刷新
    doRefresh();
}

或者使用Xxl-Job的分片功能,将任务分散到多个实例执行。

Q4:缓存预热和刷新的数据一致性如何保证?

A:采用版本号机制

public void refreshWithVersion(String key, Object data) {
    // 生成新版本号
    long newVersion = System.currentTimeMillis();
    String versionKey = key + ":version";
    
    // 获取当前版本号
    String currentVersion = redisTemplate.opsForValue().get(versionKey);
    
    // 如果新版本号大于当前版本号,才更新
    if (currentVersion == null || newVersion > Long.parseLong(currentVersion)) {
        redisTemplate.opsForValue().set(key, JSON.toJSONString(data));
        redisTemplate.opsForValue().set(versionKey, String.valueOf(newVersion));
    }
}

Q5:如何监控缓存预热和刷新的效果?

A:建议接入Prometheus+Grafana监控:

@Component
public class CacheMetrics {
    
    private final Counter preheatCounter = Counter.build()
        .name("cache_preheat_total")
        .help("Total cache preheat operations")
        .labelNames("status")
        .register();
    
    private final Histogram preheatDuration = Histogram.build()
        .name("cache_preheat_duration_seconds")
        .help("Cache preheat duration")
        .buckets(0.1, 0.5, 1.0, 2.0, 5.0, 10.0)
        .register();
    
    public void recordPreheatSuccess() {
        preheatCounter.labels("success").inc();
    }
    
    public void recordPreheatFailure() {
        preheatCounter.labels("failure").inc();
    }
    
    public Histogram.Timer startPreheatTimer() {
        return preheatDuration.startTimer();
    }
}

六、面试高频考点

考点1:缓存预热有哪些实现方案?

参考答案

方案 触发时机 优点 缺点
启动预热 应用启动时 简单直接 启动时间变长
定时预热 定时任务触发 可控制执行时间 有延迟
懒加载 首次访问时 按需加载 首次访问慢
事件驱动 数据变更时 实时性好 实现复杂

考点2:缓存雪崩、击穿、穿透的区别和解决方案?

参考答案

缓存雪崩:
- 现象:大量缓存同时失效,请求打到DB
- 解决:随机过期时间、缓存预热、多级缓存、熔断降级

缓存击穿:
- 现象:单个热点Key失效,大量请求打到DB
- 解决:互斥锁、逻辑过期、热点Key永不过期

缓存穿透:
- 现象:查询不存在的Key,每次都查DB
- 解决:布隆过滤器、缓存空值、参数校验

考点3:如何保证缓存与数据库的一致性?

参考答案

最终一致性方案:

1. 写操作:
   - 先更新数据库
   - 再删除缓存(不是更新缓存)
   - 延迟双删(500ms后再次删除)

2. 读操作:
   - 先读缓存
   - 缓存未命中读数据库
   - 写入缓存

3. 异步同步:
   - Canal监听Binlog
   - 消息队列异步更新
   - 定时任务兜底

为什么不先删缓存再更新DB?
- 如果先删缓存,在更新DB前另一个线程读取旧数据并写入缓存,导致不一致

考点4:Redis内存满了怎么办?

参考答案

1. 查看内存使用情况:
   INFO memory
   
2. 分析Key占用:
   --bigkeys 或 redis-rdb-tools
   
3. 优化方案:
   - 设置合理的过期时间
   - 使用Hash结构存储小对象
   - 开启内存淘汰策略(allkeys-lru)
   - 拆分大Key
   - 冷热数据分离
   
4. 扩容方案:
   - 增加Redis分片
   - 使用Redis Cluster

七、总结与最佳实践

7.1 核心要点回顾

缓存预热与定时刷新核心流程:

┌─────────────────────────────────────────────────────────────┐
│  1. 系统启动预热                                              │
│     ├── 使用CommandLineRunner在启动时执行                     │
│     ├── 分布式锁确保只有一个实例执行                          │
│     ├── 分批写入避免Redis阻塞                                 │
│     └── 设置随机过期时间避免集中过期                          │
│                                                              │
│  2. 定时刷新策略                                              │
│     ├── Spring Scheduler(单机)                             │
│     ├── Xxl-Job(分布式,支持分片)                           │
│     └── 错峰执行避免集中压力                                  │
│                                                              │
│  3. 增量更新                                                  │
│     ├── Canal监听Binlog实时同步                               │
│     ├── 对比差异只更新变更数据                                │
│     └── 版本号机制保证一致性                                  │
│                                                              │
│  4. 管理后台                                                  │
│     ├── 手动刷新缓存接口                                      │
│     ├── 清理缓存接口                                          │
│     └── 缓存统计监控                                          │
└─────────────────────────────────────────────────────────────┘

7.2 缓存预热 checklist

  • 确定需要预热的数据类型和优先级
  • 预估数据量,确保Redis内存充足
  • 实现分批写入,避免阻塞Redis
  • 设置随机过期时间
  • 添加预热失败告警
  • 实现预热降级策略
  • 分布式环境下使用分布式锁

7.3 性能提升数据

某电商平台实测数据:

指标 优化前 优化后 提升
系统启动后DB QPS 50,000 500 99%↓
系统启动时间 30s 45s 可接受
缓存命中率 0%→60% 95%+ 大幅提升
服务可用性 95% 99.99% 4.99%↑

八、参考与拓展


互动讨论:你在项目中遇到过缓存雪崩问题吗?是如何解决的?欢迎在评论区分享你的经验!

如果本文对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,持续获取更多Java后端技术干货!

更多推荐