缓存击穿(Cache Breakdown) 是在高并发场景下,当某个热点数据在缓存中失效或不存在时,瞬间大量请求同时击中数据库,导致数据库压力骤增甚至崩溃的现象。为了解决这一问题,“永不过期” + “逻辑过期” 的策略是一种有效的解决方案。这种方法通过将缓存数据设为永不过期,同时在数据内部维护一个逻辑过期时间,从而控制何时更新缓存,避免大量请求直接访问数据库。
本文将详细介绍这一解决方案,并提供完整的 Java 实现示例,使用 Redis 作为缓存存储。
一、“永不过期” + “逻辑过期” 策略概述
1. 永不过期
将缓存数据设置为永不过期(即不依赖 Redis 的 TTL),这样缓存项本身不会因时间原因自动失效。所有的过期逻辑由应用程序内部控制。
2. 逻辑过期
每个缓存数据项内部包含一个逻辑过期时间(如时间戳)。当应用程序读取数据时,会检查当前时间与逻辑过期时间的关系:
- 未过期:直接返回缓存数据。
- 已过期:
- 触发后台线程(或异步任务)刷新缓存数据。
- 立即返回旧的缓存数据,保持应用响应性。
通过这种方式,可以避免大量请求同时刷新缓存,减轻数据库压力,同时确保数据在逻辑上是最新的。
二、实现步骤
- 定义缓存数据结构:将数据与逻辑过期时间一起存储在 Redis 中。
- 读取数据时检查逻辑过期时间:
- 如果未过期,直接返回数据。
- 如果已过期,异步刷新缓存,并返回旧数据。
- 刷新缓存数据:
- 仅允许一个线程进行数据刷新,避免多线程同时刷新。
- 更新 Redis 中的数据及其逻辑过期时间。
三、Java 实现示例
以下是一个基于 Java 和 Redis 的完整实现示例。我们将使用 Redisson 作为 Redis 客户端,它支持分布式锁和异步操作,适合实现“永不过期” + “逻辑过期” 策略。
1. 引入依赖
首先,在项目的 pom.xml
中添加 Redisson 依赖:
<dependencies>
<!-- Redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.23.6</version>
</dependency>
<!-- JSON 处理(如使用 Jackson) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.0</version>
</dependency>
</dependencies>
2. 定义缓存数据结构
我们需要一个数据结构来存储实际数据和逻辑过期时间。以下是一个示例类:
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
public class CacheData<T> {
@JsonProperty("data")
private T data;
@JsonProperty("expiryTime")
private long expiryTime; // 逻辑过期时间,单位毫秒
public CacheData() {
}
public CacheData(T data, long expiryTime) {
this.data = data;
this.expiryTime = expiryTime;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public long getExpiryTime() {
return expiryTime;
}
public void setExpiryTime(long expiryTime) {
this.expiryTime = expiryTime;
}
@JsonIgnore
public boolean isExpired() {
return System.currentTimeMillis() > expiryTime;
}
}
3. Redis 配置与初始化
配置 Redisson 客户端以连接 Redis:
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedisConfig {
private static RedissonClient redissonClient;
static {
Config config = new Config();
// 配置单机模式
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setConnectionTimeout(10000)
.setRetryAttempts(3)
.setRetryInterval(1500);
redissonClient = Redisson.create(config);
}
public static RedissonClient getRedissonClient() {
return redissonClient;
}
}
4. 缓存管理器实现
实现缓存读取、逻辑过期检查和异步刷新:
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class CacheManager {
private RedissonClient redissonClient;
private ObjectMapper objectMapper;
private ExecutorService executorService;
// 缓存逻辑过期时间,单位毫秒
private final long LOGICAL_EXPIRY = 5 * 60 * 1000; // 5分钟
public CacheManager() {
this.redissonClient = RedisConfig.getRedissonClient();
this.objectMapper = new ObjectMapper();
// 创建固定线程池用于异步刷新
this.executorService = Executors.newFixedThreadPool(10);
}
/**
* 获取缓存数据
*
* @param key Redis 键
* @param dbQueryFunc 查询数据库的函数
* @param <T> 数据类型
* @return 缓存数据或旧数据
*/
public <T> T getCacheData(String key, DBQueryFunc<T> dbQueryFunc) {
try {
String json = redissonClient.getBucket(key).get().toString();
if (json != null) {
// 反序列化
CacheData<T> cacheData = objectMapper.readValue(json, CacheData.class);
if (!cacheData.isExpired()) {
// 未过期,返回数据
return cacheData.getData();
} else {
// 已过期,异步刷新
refreshCacheAsync(key, dbQueryFunc);
// 返回旧数据
return cacheData.getData();
}
} else {
// 缓存不存在,尝试刷新
refreshCacheAsync(key, dbQueryFunc);
// 返回 null 或者可以选择同步查询数据库
return null;
}
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
/**
* 异步刷新缓存
*
* @param key Redis 键
* @param dbQueryFunc 查询数据库的函数
* @param <T> 数据类型
*/
private <T> void refreshCacheAsync(String key, DBQueryFunc<T> dbQueryFunc) {
executorService.submit(() -> {
RLock lock = redissonClient.getLock("lock:" + key);
boolean isLockAcquired = false;
try {
// 尝试获取锁,防止多线程同时刷新
isLockAcquired = lock.tryLock(500, 3000, TimeUnit.MILLISECONDS);
if (isLockAcquired) {
// 再次检查缓存是否过期,防止被其他线程刷新
String json = redissonClient.getBucket(key).get().toString();
CacheData<T> cacheData = objectMapper.readValue(json, CacheData.class);
if (cacheData.isExpired()) {
// 查询数据库
T data = dbQueryFunc.query();
// 更新缓存
CacheData<T> newCacheData = new CacheData<>(data, System.currentTimeMillis() + LOGICAL_EXPIRY);
String newJson = objectMapper.writeValueAsString(newCacheData);
redissonClient.getBucket(key).set(newJson);
}
}
} catch (InterruptedException | IOException e) {
e.printStackTrace();
} finally {
if (isLockAcquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
});
}
/**
* 刷新缓存数据(同步调用,用于缓存不存在时)
*
* @param key Redis 键
* @param dbQueryFunc 查询数据库的函数
* @param <T> 数据类型
*/
public <T> T refreshCache(String key, DBQueryFunc<T> dbQueryFunc) {
RLock lock = redissonClient.getLock("lock:" + key);
boolean isLockAcquired = false;
try {
// 获取锁,等待最多 500 毫秒
isLockAcquired = lock.tryLock(500, 3000, TimeUnit.MILLISECONDS);
if (isLockAcquired) {
// 查询数据库
T data = dbQueryFunc.query();
// 更新缓存
CacheData<T> newCacheData = new CacheData<>(data, System.currentTimeMillis() + LOGICAL_EXPIRY);
String newJson = objectMapper.writeValueAsString(newCacheData);
redissonClient.getBucket(key).set(newJson);
return data;
} else {
// 获取锁失败,可能由其他线程刷新,等待一段时间后尝试获取
Thread.sleep(100);
String json = redissonClient.getBucket(key).get().toString();
if (json != null) {
CacheData<T> cacheData = objectMapper.readValue(json, CacheData.class);
return cacheData.getData();
} else {
// 最终未获取到数据,返回 null 或选择其他处理方式
return null;
}
}
} catch (InterruptedException | IOException e) {
e.printStackTrace();
return null;
} finally {
if (isLockAcquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 关闭缓存管理器,释放资源
*/
public void shutdown() {
executorService.shutdown();
redissonClient.shutdown();
}
/**
* 数据库查询函数接口
*
* @param <T> 数据类型
*/
public interface DBQueryFunc<T> {
T query();
}
}
5. 使用示例
假设我们有一个 User
数据模型,并希望缓存用户信息:
public class User {
private String id;
private String name;
private int age;
// 构造方法、getter、setter等
public User() {
}
public User(String id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}
// Getters and Setters
// ...
}
模拟数据库查询方法:
public class UserService {
/**
* 模拟数据库查询
*
* @param userId 用户 ID
* @return 用户信息
*/
public User getUserFromDB(String userId) {
// 模拟数据库延迟
try {
Thread.sleep(100); // 100ms 延迟
} catch (InterruptedException e) {
e.printStackTrace();
}
// 返回模拟数据
return new User(userId, "User_" + userId, 25);
}
}
主程序示例:
public class Main {
public static void main(String[] args) {
CacheManager cacheManager = new CacheManager();
UserService userService = new UserService();
String userId = "12345";
String cacheKey = "user:" + userId;
// 定义数据库查询函数
CacheManager.DBQueryFunc<User> dbQueryFunc = () -> userService.getUserFromDB(userId);
// 第一次访问,缓存可能不存在或已过期
User user = cacheManager.getCacheData(cacheKey, dbQueryFunc);
if (user == null) {
// 缓存不存在,进行同步刷新
user = cacheManager.refreshCache(cacheKey, dbQueryFunc);
}
System.out.println("User: " + user.getName() + ", Age: " + user.getAge());
// 之后的访问,如果缓存未过期,直接返回缓存数据
User cachedUser = cacheManager.getCacheData(cacheKey, dbQueryFunc);
System.out.println("Cached User: " + cachedUser.getName() + ", Age: " + cachedUser.getAge());
// 关闭缓存管理器
cacheManager.shutdown();
}
}
6. 运行流程说明
-
首次访问:
- 调用
getCacheData
方法。 - 缓存可能不存在或已逻辑过期。
- 触发异步刷新缓存,通过
refreshCacheAsync
方法。 - 如果缓存不存在,调用
refreshCache
方法进行同步刷新。 - 从数据库获取数据并更新缓存。
- 返回获取到的数据。
- 调用
-
后续访问:
- 调用
getCacheData
方法。 - 检查逻辑过期时间。
- 如果未过期,直接返回缓存数据。
- 如果已过期,触发异步刷新缓存,同时返回旧数据,保持高响应性。
- 调用
7. 优点与注意事项
优点
- 防止缓存击穿:通过锁机制和异步刷新,避免高并发下大量请求同时触发数据库访问。
- 高响应性:即使缓存已逻辑过期,也能立即返回旧数据,不会造成请求阻塞。
- 灵活性:逻辑过期时间可根据业务需求动态调整。
注意事项
- 数据一致性:旧数据可能与数据库中的最新数据存在一定的时间差,需要根据业务需求权衡。
- 锁的可靠性:确保分布式锁机制的可靠性,避免死锁或锁丢失。
- 线程池管理:合理配置线程池大小,避免过多异步任务导致资源竞争。
- 异常处理:完善异常处理机制,确保在数据刷新失败时系统稳定。
四、扩展与优化
1. 使用 Redis Lua 脚本优化原子性
为了进一步确保操作的原子性,可以考虑使用 Redis 的 Lua 脚本,将读取和写入操作合并为一个原子操作。
2. 引入消息队列进行异步刷新
对于大规模分布式系统,可以引入消息队列(如 Kafka、RabbitMQ)来异步处理缓存刷新任务,提升系统的可扩展性和可靠性。
3. 监控与报警
建立完善的监控机制,实时监控缓存命中率、数据库访问量、缓存刷新失败次数等指标,及时发现并处理异常情况。
五、总结
通过 “永不过期” + “逻辑过期” 的策略,可以有效防止缓存击穿问题,确保系统在高并发下的稳定性和高可用性。本文详细介绍了该策略的原理及其 Java 实现,包括数据结构设计、缓存读取与逻辑过期检查、异步刷新机制等关键环节。根据实际业务需求,开发者可以进一步优化和扩展这一策略,以构建高性能、高可靠性的分布式系统。