缓存击穿解决方案:逻辑过期时间实践

小豆丁 1月前 ⋅ 65 阅读
聊天记录
头像

我说的是缓存击穿不是缓存穿透,是缓存对象里设置一个ttl字段

头像

我明白了!您说的是用逻辑过期时间来避免缓存击穿,而不是物理过期。这是一个非常经典的解决方案!让我详细解释一下。

逻辑过期方案(Logical Expiration)

核心思想

不在Redis中设置真实的TTL,而是在缓存值内部嵌入一个过期时间字段。当读取缓存时,先检查这个逻辑过期时间,如果已过期,则异步重建缓存,同时返回旧数据。

数据结构设计

JSON
{
  "data": { /* 真实数据 */ },
  "expire_at": 1749254400,  // 逻辑过期时间戳
  "version": "v1"           // 可选:版本号
}

实现方案

方案一:异步刷新 + 互斥锁(推荐)

JAVA
public class CacheService {
    // 缓存逻辑过期时间(秒)
    private static final long LOGIC_EXPIRE_TIME = 30 * 60;  // 30分钟
    
    public Object getData(String key) {
        // 1. 从缓存获取
        String cacheValue = redis.get(key);
        if (cacheValue == null) {
            // 缓存不存在,走正常加载流程(可配合互斥锁防击穿)
            return loadAndSetCache(key);
        }
        
        // 2. 解析缓存对象
        CacheItem cacheItem = JSON.parseObject(cacheValue, CacheItem.class);
        
        // 3. 判断是否逻辑过期
        if (cacheItem.getExpireAt() > System.currentTimeMillis() / 1000) {
            // 未过期,直接返回
            return cacheItem.getData();
        }
        
        // 4. 已逻辑过期,异步刷新
        refreshAsync(key);
        
        // 5. 仍然返回旧数据(保证可用性)
        return cacheItem.getData();
    }
    
    private void refreshAsync(String key) {
        // 使用互斥锁,确保只有一个线程刷新
        String lockKey = "refresh_lock:" + key;
        boolean locked = redis.setnx(lockKey, "1", 5);  // 5秒锁
        
        if (!locked) {
            return;  // 已经有其他线程在刷新
        }
        
        try {
            // 双重检查,防止重复刷新
            String currentValue = redis.get(key);
            CacheItem currentItem = JSON.parseObject(currentValue, CacheItem.class);
            if (currentItem.getExpireAt() > System.currentTimeMillis() / 1000) {
                return;  // 已经被其他线程刷新过了
            }
            
            // 异步执行刷新
            CompletableFuture.runAsync(() -> {
                try {
                    Object newData = loadFromDB(key);
                    if (newData != null) {
                        CacheItem newItem = new CacheItem();
                        newItem.setData(newData);
                        newItem.setExpireAt(System.currentTimeMillis() / 1000 + LOGIC_EXPIRE_TIME);
                        redis.set(key, JSON.toJSONString(newItem));
                    }
                } finally {
                    redis.del(lockKey);
                }
            });
        } catch (Exception e) {
            redis.del(lockKey);
        }
    }
}

方案二:后台定时刷新

JAVA
@Component
public class CacheRefreshScheduler {
    
    @Scheduled(fixedDelay = 60000)  // 每分钟执行一次
    public void refreshExpiredCache() {
        // 方案A:扫描模式(适合数据量不大)
        scanAndRefresh();
        
        // 方案B:订阅binlog/CDC(推荐,实时性高)
        // 监听数据库变更,主动刷新对应缓存
    }
    
    private void scanAndRefresh() {
        // 使用SCAN命令遍历缓存
        String cursor = "0";
        do {
            ScanResult<String> scanResult = redis.scan(
                cursor, 
                new ScanOptions.ScanOptionsBuilder()
                    .match("cache:*")  // 模式匹配
                    .count(100)         // 分批
                    .build()
            );
            
            cursor = scanResult.getCursor();
            List<String> keys = scanResult.getResult();
            
            for (String key : keys) {
                refreshIfExpired(key);
            }
            
        } while (!"0".equals(cursor));
    }
    
    private void refreshIfExpired(String key) {
        try {
            String value = redis.get(key);
            CacheItem item = JSON.parseObject(value, CacheItem.class);
            
            // 提前刷新(如过期前5分钟)
            long expireSoonThreshold = 5 * 60;  // 5分钟
            boolean needRefresh = item.getExpireAt() - System.currentTimeMillis() / 1000 < expireSoonThreshold;
            
            if (needRefresh) {
                refreshCache(key);
            }
        } catch (Exception e) {
            log.error("Refresh cache error: {}", key, e);
        }
    }
}

逻辑过期方案的优势

优点 说明
完全避免缓存击穿 热点key永远不会物理过期,总有数据返回
零阻塞读取 用户请求不会被刷新缓存阻塞
平滑更新 异步刷新,用户体验好

需要考虑的问题

1. 缓存一致性

JAVA
@Service
public class DataService {
    // 更新数据时
    public void updateData(String key, Object newData) {
        // 1. 先更新数据库
        db.update(newData);
        
        // 2. 使缓存失效
        // 方案A:直接删除
        redis.delete(key);
        
        // 方案B:更新缓存(推荐,保持可用性)
        CacheItem newItem = new CacheItem();
        newItem.setData(newData);
        newItem.setExpireAt(System.currentTimeMillis() / 1000 + LOGIC_EXPIRE_TIME);
        redis.set(key, JSON.toJSONString(newItem));
    }
}

2. 内存管理

JAVA
public class CacheCleaner {
    // 定期清理逻辑过期但未访问的数据
    @Scheduled(cron = "0 0 4 * * ?")  // 每天4点执行
    public void cleanOldCache() {
        String pattern = "cache:*";
        String cursor = "0";
        
        do {
            ScanResult<String> result = redis.scan(cursor, 
                ScanOptions.scanOptions()
                    .match(pattern)
                    .count(100)
                    .build()
            );
            
            cursor = result.getCursor();
            
            for (String key : result.getResult()) {
                String value = redis.get(key);
                CacheItem item = JSON.parseObject(value, CacheItem.class);
                
                // 清理逻辑:超过逻辑过期时间很久 + 最近很少访问
                long logicExpireTime = item.getExpireAt();
                long now = System.currentTimeMillis() / 1000;
                
                // 过期超过24小时
                if (now - logicExpireTime > 24 * 60 * 60) {
                    redis.delete(key);
                }
            }
        } while (!"0".equals(cursor));
    }
}

3. 版本控制(处理逻辑删除场景)

JAVA
public class CacheItem {
    private Object data;
    private long expireAt;
    private String version;  // 数据版本
    private boolean deleted; // 逻辑删除标记
    
    public static CacheItem createDeletedItem() {
        CacheItem item = new CacheItem();
        item.setData(null);
        item.setExpireAt(System.currentTimeMillis() / 1000 + 5 * 60);  // 5分钟
        item.setDeleted(true);
        item.setVersion("deleted");
        return item;
    }
}

实际应用中的变体

1. 双重TTL方案(结合物理和逻辑过期)

JAVA
public class DualTTLCache {
    // 设置较长的物理TTL(如24小时),保证数据不丢失
    private static final long PHYSICAL_TTL = 24 * 60 * 60;
    // 较短的逻辑TTL(如30分钟),控制数据新鲜度
    private static final long LOGIC_TTL = 30 * 60;
    
    public void setWithDualTTL(String key, Object data) {
        CacheItem item = new CacheItem();
        item.setData(data);
        item.setExpireAt(System.currentTimeMillis() / 1000 + LOGIC_TTL);
        
        // 设置物理过期,避免内存泄漏
        redis.setex(key, PHYSICAL_TTL, JSON.toJSONString(item));
    }
}

2. 分级刷新策略

JAVA
public class TieredRefreshStrategy {
    // 根据访问频率设置不同的刷新策略
    public RefreshPriority getRefreshPriority(String key) {
        Long accessCount = redis.zscore("cache:hotness", key);
        
        if (accessCount > 1000) {
            return RefreshPriority.HIGH;     // 高频热点,立即刷新
        } else if (accessCount > 100) {
            return RefreshPriority.MEDIUM;   // 中频,异步刷新
        } else {
            return RefreshPriority.LOW;      // 低频,被动刷新
        }
    }
}

总结建议

  1. 适用场景

    • 热点数据,访问频繁
    • 可以容忍短暂的数据不一致
    • 数据库查询代价较高
  2. 推荐实现

JAVA
// 简化版最佳实践
public Object getWithLogicalExpire(String key) {
    // 1. 尝试获取缓存
    CacheItem item = getCache(key);
    
    // 2. 缓存不存在,同步加载
    if (item == null) {
        return loadAndCache(key);
    }
    
    // 3. 逻辑过期,异步刷新
    if (item.isExpired()) {
        refreshAsync(key);
    }
    
    // 4. 返回数据(可能是稍旧的数据)
    return item.getData();
}
  1. 监控指标
    • 缓存命中率
    • 异步刷新成功率
    • 逻辑过期数据的占比
    • 刷新延迟时间

这种方法完美解决了缓存击穿问题,因为热点key永远不会出现"无缓存"的状态。代价是需要更复杂的缓存管理和潜在的数据不一致窗口。

全部评论: 0

    我有话说: