逻辑过期+定时预热(附Java代码)

5 阅读7分钟

前言

在 Java 后端开发中,Redis 缓存是提升系统性能的关键组件。然而,传统的缓存失效策略(物理过期)在高并发场景下存在隐患。当大量缓存同一时间过期,或者热点数据突然失效时,数据库可能面临瞬间巨大的查询压力,导致系统响应变慢甚至崩溃。

为了解决这一问题,业界衍生出了“逻辑过期”配合“定时预热”的方案。本文将深入浅出地讲解这一技术的原理,并提供完整的 Java 代码实战,帮助初中级开发者掌握这一高并发优化手段。

核心概念

1. 物理过期 vs 逻辑过期

为了方便理解,我们可以用超市商品来类比。

  • 物理过期:就像商品包装上印的保质期。Redis 利用 TTL(Time To Live)机制,时间一到,数据自动删除。
    • 缺点:时间一到,数据瞬间消失。如果此时有大量请求涌入,所有请求都会穿透到数据库。
  • 逻辑过期:就像超市管理员自己记录了一个“建议下架时间”,但商品依然摆在货架上。数据本身不过期,而是在数据内部包含一个过期时间字段。
    • 优点:数据始终存在,不会发生缓存穿透。
    • 缺点:可能读到旧数据,需要额外机制更新。

2. 定时预热

既然数据逻辑过期后还在缓存中,那么谁负责更新它呢?这就引入了“定时预热”。

  • 原理:启动一个独立的定时任务,定期检查缓存中的数据。如果发现某条数据即将逻辑过期,就异步地去数据库查询最新数据,并更新到缓存中。
  • 效果:在用户请求到来之前,数据已经更新好了。用户永远读到的是热点数据,且数据库压力被平滑分散。

3. 方案对比表

特性物理过期逻辑过期 + 定时预热
缓存命中率过期瞬间为 0始终接近 100%
数据库压力过期瞬间峰值高平滑,由定时任务承担
数据一致性较好存在短暂不一致窗口
实现复杂度中高
适用场景一般业务高频热点数据

代码实战

本示例基于 Spring Boot 和 RedisTemplate 编写。为了便于理解,我们将代码简化为核心逻辑,实际项目中请根据具体情况调整。

1. 定义数据实体

我们需要在实体类中增加一个逻辑过期时间字段。

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 缓存数据包装类
 * 包含实际数据和逻辑过期时间
 */
public class CacheData<T> implements Serializable {
    
    private static final long serialVersionUID = 1L;

    // 实际业务数据
    private T data;
    
    // 逻辑过期时间
    private LocalDateTime expireTime;

    public CacheData(T data, LocalDateTime expireTime) {
        this.data = data;
        this.expireTime = expireTime;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public LocalDateTime getExpireTime() {
        return expireTime;
    }

    public void setExpireTime(LocalDateTime expireTime) {
        this.expireTime = expireTime;
    }
}

2. 缓存工具类

封装 Redis 操作,包含查询和更新逻辑。

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

@Component
public class CacheClient {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // 创建线程池,用于异步更新缓存
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 将对象序列化为 JSON 字符串(简化示例,实际建议使用 Jackson 或 Fastjson)
     */
    private String toJson(Object obj) {
        // 实际项目中请使用 JSON 工具类
        return obj.toString(); 
    }

    /**
     * 从 JSON 字符串反序列化为对象(简化示例)
     */
    private <T> T fromJson(String json, Class<T> clazz) {
        // 实际项目中请使用 JSON 工具类
        return (T) json; 
    }

    /**
     * 查询缓存方法
     *
     * @param keyPrefix 键前缀
     * @param id 业务 ID
     * @param type 数据类型 Class
     * @param dbFallback 数据库查询回调
     * @param expireSeconds 物理过期时间(用于防止内存泄漏,设长一点)
     * @param logicExpireSeconds 逻辑过期时间
     * @return 业务数据
     */
    public <T, ID> T queryWithLogicalExpire(
            String keyPrefix, ID id, Class<T> type, 
            Function<ID, T> dbFallback, 
            long expireSeconds, long logicExpireSeconds) {
        
        String key = keyPrefix + id;
        
        // 1. 从 Redis 查询
        String json = stringRedisTemplate.opsForValue().get(key);
        
        // 2. 判断缓存是否存在
        if (json == null) {
            // 缓存不存在,直接查库并返回(此处可结合互斥锁优化,本示例侧重逻辑过期)
            T data = dbFallback.apply(id);
            // 存入缓存,不设 TTL 或设很长 TTL,因为靠逻辑过期控制
            stringRedisTemplate.opsForValue().set(key, toJson(new CacheData<>(data, null)), expireSeconds, TimeUnit.SECONDS);
            return data;
        }

        // 3. 缓存存在,解析数据
        // 注意:实际项目中需要处理 JSON 解析异常
        CacheData<T> cacheData = fromJson(json, CacheData.class);
        
        // 4. 检查逻辑是否过期
        if (cacheData.getExpireTime() == null || cacheData.getExpireTime().isAfter(java.time.LocalDateTime.now())) {
            // 未过期,直接返回
            return cacheData.getData();
        }

        // 5. 已过期,尝试重建缓存
        rebuildCache(key, id, type, dbFallback, expireSeconds, logicExpireSeconds);

        // 6. 返回旧数据(保证可用性,牺牲短暂一致性)
        return cacheData.getData();
    }

    /**
     * 异步重建缓存
     */
    private <T, ID> void rebuildCache(
            String key, ID id, Class<T> type, 
            Function<ID, T> dbFallback, 
            long expireSeconds, long logicExpireSeconds) {
        
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                // 1. 查询数据库
                T data = dbFallback.apply(id);
                
                // 2. 计算新的逻辑过期时间
                LocalDateTime expireTime = java.time.LocalDateTime.now().plusSeconds(logicExpireSeconds);
                
                // 3. 写入 Redis
                CacheData<T> newCacheData = new CacheData<>(data, expireTime);
                stringRedisTemplate.opsForValue().set(key, toJson(newCacheData), expireSeconds, TimeUnit.SECONDS);
                
            } catch (Exception e) {
                // 记录日志,防止异常影响主线程
                e.printStackTrace();
            }
        });
    }
}

3. 定时预热任务

除了被动触发重建,我们还可以主动定时检查热点数据。

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;

@Component
public class CachePreheatTask {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    
    @Resource
    private CacheClient cacheClient;

    // 假设这是热点 Key 的列表,实际可从配置或数据库获取
    private static final List<Long> HOT_SPOT_IDS = List.of(1L, 2L, 5L, 10L);

    /**
     * 每隔 30 秒检查一次热点数据
     */
    @Scheduled(fixedRate = 30000)
    public void preheatHotSpotData() {
        for (Long id : HOT_SPOT_IDS) {
            String key = "shop:stock:" + id;
            String json = stringRedisTemplate.opsForValue().get(key);
            
            if (json != null) {
                // 解析逻辑过期时间(简化处理)
                // 实际项目中需要解析 JSON 获取 expireTime
                // 此处伪代码演示逻辑
                LocalDateTime expireTime = getExpireTimeFromJson(json); 
                
                // 如果剩余时间小于 5 分钟,则主动更新
                if (expireTime != null && 
                    java.time.Duration.between(LocalDateTime.now(), expireTime).toMinutes() < 5) {
                    
                    // 触发更新逻辑,可复用 CacheClient 中的重建方法
                    // 注意:此处需要防止多个定时任务实例重复更新,建议加分布式锁
                    updateCacheAsync(key, id);
                }
            }
        }
    }

    private LocalDateTime getExpireTimeFromJson(String json) {
        // 解析 JSON 获取过期时间
        return null; 
    }

    private void updateCacheAsync(String key, Long id) {
        // 异步更新实现,逻辑同 CacheClient.rebuildCache
        // 建议在此处获取分布式锁,避免集群环境下重复更新
    }
}

常见问题与踩坑点

在实际落地过程中,以下几个问题最容易导致线上故障:

  1. 缓存一致性窗口 逻辑过期方案中,用户请求命中过期数据时,返回的是旧数据,同时后台异步更新。这意味着在更新完成前,用户会读到旧数据。

    • 对策:适用于对一致性要求不极高的场景,如商品详情、新闻内容。涉及资金、库存扣减等强一致性场景慎用。
  2. 定时任务重复执行 如果服务部署了多个实例(集群),定时任务会在每台机器上同时运行,导致数据库压力倍增。

    • 对策:使用分布式锁(如 Redis Lock)确保同一时间只有一个实例执行预热任务。或者使用 XXL-JOB 等分布式任务调度平台。
  3. 线程池资源耗尽 异步重建缓存使用了线程池。如果热点 Key 非常多,且同时过期,可能导致线程池队列满,拒绝策略触发,更新失败。

    • 对策:合理评估热点数量,调整线程池大小。增加降级策略,更新失败时记录日志,下次重试。
  4. 内存泄漏风险 逻辑过期的数据在 Redis 中不会自动删除,必须设置一个较长的物理 TTL 作为兜底。

    • 对策:物理过期时间应设置为逻辑过期时间的 1.5 倍或更长,确保逻辑过期机制失效时,数据最终能被清理。
  5. 序列化开销 每次读取都需要反序列化,写入需要序列化。如果对象很大,会影响性能。

    • 对策:缓存数据尽量精简,只存必要字段。使用高效的序列化协议(如 Protobuf、Kryo)。

总结

逻辑过期配合定时预热是解决高并发缓存失效问题的有效手段。

  • 核心优势在于将数据库压力从“用户请求时刻”转移到了“后台异步时刻”,实现了削峰填谷。
  • 核心代价是牺牲了短暂的数据一致性,并增加了系统复杂度。