我说的是缓存击穿不是缓存穿透,是缓存对象里设置一个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; // 低频,被动刷新
}
}
}
总结建议
适用场景:
- 热点数据,访问频繁
- 可以容忍短暂的数据不一致
- 数据库查询代价较高
推荐实现:
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();
}
- 监控指标:
- 缓存命中率
- 异步刷新成功率
- 逻辑过期数据的占比
- 刷新延迟时间
这种方法完美解决了缓存击穿问题,因为热点key永远不会出现"无缓存"的状态。代价是需要更复杂的缓存管理和潜在的数据不一致窗口。