缓存策略设计实战
缓存是提升系统性能最有效的手段。合理的缓存设计可以将响应时间从几百毫秒降低到几毫秒。但缓存也带来了一致性、穿透、雪崩等问题。本文详解缓存架构设计的核心技术和最佳实践。
一、缓存架构设计
1.1 多级缓存架构
graph TB
User[用户请求] --> L1[L1: CDN 缓存<br/>静态资源 TTL:1 天 -1 年<br/>命中率 80%+]
L1 --> L2[L2: Nginx 缓存<br/>API 响应 TTL:1 分钟 -1 小时<br/>命中率 60%+]
L2 --> L3[L3: 本地缓存 Caffeine<br/>热点数据 TTL:1 秒 -10 分钟<br/>命中率 90%+]
L3 --> L4[L4: Redis 集群<br/>业务数据 TTL:5 分钟 -24 小时<br/>命中率 70%+]
L4 --> L5[L5: 数据库 MySQL<br/>持久化数据/复杂查询/事务操作]
1.2 缓存选型
| 缓存类型 | 代表产品 | 特点 | 适用场景 |
|---|---|---|---|
| 本地缓存 | Caffeine、Guava | 最快、无网络开销、容量有限 | 热点数据、配置数据 |
| 分布式缓存 | Redis、Memcached | 共享、容量大、网络开销 | 业务数据、Session |
| 数据库缓存 | Query Cache | 自动、粗粒度 | SQL 查询结果 |
| CDN 缓存 | 阿里云 CDN | 边缘节点、静态资源 | 图片、视频、静态文件 |
二、本地缓存实战
2.1 Caffeine 使用
/**
* Caffeine 本地缓存配置
*/
@Configuration
public class CaffeineConfig {
/**
* 用户信息缓存
*/
@Bean
public Cache<String, User> userCache() {
return Caffeine.newBuilder()
.maximumSize(10000) // 最大 1 万条
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后 10 分钟过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后 5 分钟过期
.recordStats() // 记录统计信息
.build();
}
/**
* 配置数据缓存(永不过期,手动刷新)
*/
@Bean
public Cache<String, Object> configCache() {
return Caffeine.newBuilder()
.maximumSize(1000)
.build();
}
/**
* 热点数据缓存(基于访问频率)
*/
@Bean
public Cache<String, Object> hotCache() {
return Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterAccess(1, TimeUnit.MINUTES)
.weigher((key, value) -> {
// 大对象占用更多权重
if (value instanceof String) {
return ((String) value).length() / 100;
}
return 1;
})
.build();
}
}
/**
* 使用示例
*/
@Service
public class UserService {
@Autowired
private Cache<String, User> userCache;
@Autowired
private UserMapper userMapper;
public User getUserById(String userId) {
// 从缓存获取
User user = userCache.getIfPresent(userId);
if (user == null) {
// 缓存未命中,查询数据库
user = userMapper.selectById(userId);
if (user != null) {
// 写入缓存
userCache.put(userId, user);
}
}
return user;
}
public void updateUser(User user) {
// 更新数据库
userMapper.update(user);
// 删除缓存(保证一致性)
userCache.invalidate(user.getId());
}
/**
* 批量查询(避免缓存击穿)
*/
public List<User> getUsersByIds(List<String> userIds) {
// 批量从缓存获取
Map<String, User> cachedUsers = userCache.getAllPresent(userIds);
// 找出未命中的 ID
List<String> missedIds = userIds.stream()
.filter(id -> !cachedUsers.containsKey(id))
.collect(Collectors.toList());
// 批量查询数据库
if (!missedIds.isEmpty()) {
List<User> dbUsers = userMapper.selectByIds(missedIds);
// 批量写入缓存
userCache.putAll(dbUsers.stream()
.collect(Collectors.toMap(User::getId, u -> u)));
// 合并结果
cachedUsers.putAll(dbUsers.stream()
.collect(Collectors.toMap(User::getId, u -> u)));
}
return userIds.stream()
.map(cachedUsers::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}
2.2 缓存监听
/**
* 缓存监听器
*/
@Component
public class CacheListener {
@PostConstruct
public void init() {
// 监听缓存移除
userCache.asMap().forEach((key, value) -> {
log.info("缓存 key: {}, value: {}", key, value);
});
}
@Autowired
private Cache<String, User> userCache;
/**
* 缓存移除回调
*/
public void onRemoval(String key, User user, RemovalCause cause) {
switch (cause) {
case EXPIRED:
log.info("缓存过期:key={}, user={}", key, user.getName());
break;
case SIZE:
log.info("缓存淘汰:key={}, user={}", key, user.getName());
break;
case EXPLICIT:
log.info("缓存删除:key={}, user={}", key, user.getName());
break;
default:
break;
}
}
}
三、Redis 缓存实战
3.1 缓存设计模式
/**
* 缓存设计模式
*/
@Service
public class CachePatternService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
/**
* 模式 1:Cache Aside(旁路缓存)
*
* 读:先读缓存,未命中则读 DB 并写入缓存
* 写:先写 DB,再删除缓存
*
* 适用:大多数场景
*/
public User getUserById(Long id) {
String key = "user:" + id;
// 读缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 读数据库
user = userMapper.selectById(id);
if (user != null) {
// 写入缓存(设置过期时间)
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
return user;
}
public void updateUser(User user) {
// 先写数据库
userMapper.update(user);
// 再删除缓存(下次读取时更新)
String key = "user:" + user.getId();
redisTemplate.delete(key);
}
/**
* 模式 2:Read Through(读穿透)
*
* 缓存代理数据库,应用只操作缓存
* 缓存未命中时,缓存系统自动加载数据
*
* 适用:缓存系统支持 Read Through(如 Redis 未原生支持,需自行实现)
*/
public User getUserWithReadThrough(Long id) {
String key = "user:" + id;
return (User) redisTemplate.execute((RedisCallback<User>) connection -> {
// 尝试从缓存获取
byte[] value = connection.get(key.getBytes());
if (value != null) {
return deserialize(value);
}
// 缓存未命中,加载数据
User user = userMapper.selectById(id);
if (user != null) {
connection.setEx(key.getBytes(), 1800, serialize(user));
}
return user;
});
}
/**
* 模式 3:Write Behind(异步回写)
*
* 先更新缓存,再异步写入数据库
*
* 适用:高并发写场景,允许短暂不一致
*/
@Autowired
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
private final Queue<User> writeQueue = new ConcurrentLinkedQueue<>();
public void updateUserWithWriteBehind(User user) {
// 先更新缓存
String key = "user:" + user.getId();
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
// 加入异步队列
writeQueue.offer(user);
}
@PostConstruct
public void initWriteBehind() {
// 每秒批量写入数据库
scheduler.scheduleAtFixedRate(this::batchWriteToDb, 1, 1, TimeUnit.SECONDS);
}
private void batchWriteToDb() {
List<User> batch = new ArrayList<>();
User user;
while ((user = writeQueue.poll()) != null && batch.size() < 100) {
batch.add(user);
}
if (!batch.isEmpty()) {
userMapper.batchUpdate(batch);
}
}
private byte[] serialize(Object obj) {
// 序列化实现
return SerializationUtils.serialize((Serializable) obj);
}
private Object deserialize(byte[] bytes) {
// 反序列化实现
return SerializationUtils.deserialize(bytes);
}
}
3.2 缓存一致性保障
/**
* 缓存一致性保障方案
*/
@Service
public class CacheConsistencyService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 方案 1:延时双删
*
* 1. 先删除缓存
* 2. 更新数据库
* 3. 休眠 N 毫秒
* 4. 再次删除缓存
*
* 目的:防止更新 DB 时,有读请求写入旧数据
*/
public void updateUserWithDoubleDelete(User user) {
String key = "user:" + user.getId();
// 1. 删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
userMapper.update(user);
// 3. 延时双删(异步执行)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 休眠 500ms
redisTemplate.delete(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
/**
* 方案 2:监听 Binlog 删除缓存
*
* 使用 Canal 监听 MySQL Binlog
* 数据变更后自动删除缓存
*/
@StreamListener("canal:user")
public void handleUserChange(UserChangeMessage message) {
if (message.getEventType() == "UPDATE" || message.getEventType() == "DELETE") {
String key = "user:" + message.getUserId();
redisTemplate.delete(key);
}
}
/**
* 方案 3:消息队列保证最终一致性
*
* 更新 DB 后发送消息
* 消费者删除缓存
* 失败重试
*/
public void updateUserWithMQ(User user) {
// 更新数据库
userMapper.update(user);
// 发送删除缓存消息
CacheMessage message = new CacheMessage();
message.setKey("user:" + user.getId());
message.setOperation("DELETE");
rocketMQTemplate.send("cache_topic", message);
}
@RocketMQMessageListener(
topic = "cache_topic",
consumerGroup = "cache_consumer_group"
)
public class CacheConsumer implements RocketMQListener<CacheMessage> {
@Override
public void onMessage(CacheMessage message) {
if ("DELETE".equals(message.getOperation())) {
redisTemplate.delete(message.getKey());
} else if ("UPDATE".equals(message.getOperation())) {
// 重新加载缓存
reloadCache(message.getKey());
}
}
private void reloadCache(String key) {
// 从数据库加载数据并写入缓存
Long userId = Long.valueOf(key.split(":")[1]);
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
}
}
/**
* 方案 4:分布式锁保证一致性
*
* 更新时加锁,防止并发读写
*/
public void updateUserWithLock(User user) {
String lockKey = "lock:user:" + user.getId();
String cacheKey = "user:" + user.getId();
// 获取分布式锁
RLock lock = redissonClient.getLock(lockKey);
if (lock.tryLock()) {
try {
// 更新数据库
userMapper.update(user);
// 删除缓存
redisTemplate.delete(cacheKey);
} finally {
lock.unlock();
}
} else {
throw new BusinessException("系统繁忙,请稍后重试");
}
}
@Autowired
private RedissonClient redissonClient;
}
3.3 缓存预热
/**
* 缓存预热服务
*/
@Service
public class CachePreloadService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
@Autowired
private ProductMapper productMapper;
/**
* 全量预热(系统启动时)
*/
@PostConstruct
public void preloadAll() {
// 异步执行,避免阻塞启动
CompletableFuture.runAsync(() -> {
log.info("开始缓存预热...");
// 预热用户数据(VIP 用户)
preloadVipUsers();
// 预热商品数据(热销商品)
preloadHotProducts();
// 预热配置数据
preloadConfigs();
log.info("缓存预热完成");
});
}
/**
* 定时预热(每天凌晨)
*/
@Scheduled(cron = "0 0 3 * * ?")
public void scheduledPreload() {
log.info("定时缓存预热开始");
preloadHotProducts();
}
private void preloadVipUsers() {
List<User> vipUsers = userMapper.selectVipUsers();
for (User user : vipUsers) {
String key = "user:" + user.getId();
redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
}
log.info("VIP 用户预热完成,共{}条", vipUsers.size());
}
private void preloadHotProducts() {
List<Product> hotProducts = productMapper.selectHotProducts(100);
for (Product product : hotProducts) {
String key = "product:" + product.getId();
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
log.info("热销商品预热完成,共{}条", hotProducts.size());
}
private void preloadConfigs() {
List<Config> configs = configMapper.selectAll();
for (Config config : configs) {
String key = "config:" + config.getKey();
redisTemplate.opsForValue().set(key, config.getValue());
}
log.info("配置数据预热完成,共{}条", configs.size());
}
}
四、热点数据处理
4.1 热点发现
/**
* 热点数据发现
*/
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<String, LongAdder> keyCounter = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
@PostConstruct
public void init() {
// 每分钟统计一次热点 key
scheduler.scheduleAtFixedRate(this::detectHotKeys, 1, 1, TimeUnit.MINUTES);
}
/**
* 记录 key 访问
*/
public void recordAccess(String key) {
LongAdder counter = keyCounter.computeIfAbsent(key, k -> new LongAdder());
counter.increment();
}
/**
* 发现热点 key
*/
private void detectHotKeys() {
// 找出访问频率超过阈值的 key
List<String> hotKeys = keyCounter.entrySet().stream()
.filter(entry -> entry.getValue().sum() > 1000) // 阈值:1000 次/分钟
.map(Map.Entry::getKey)
.collect(Collectors.toList());
if (!hotKeys.isEmpty()) {
log.info("发现热点 Key: {}", hotKeys);
// 推送到本地缓存
for (String key : hotKeys) {
preloadToCaffeine(key);
}
// 发送到消息队列(其他节点同步)
rocketMQTemplate.send("hotkey_topic", hotKeys);
}
// 重置计数器
keyCounter.clear();
}
private void preloadToCaffeine(String key) {
// 从 Redis 加载数据到本地缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
}
}
@StreamListener("hotkey_topic")
public void handleHotKey(List<String> hotKeys) {
for (String key : hotKeys) {
preloadToCaffeine(key);
}
}
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private Cache<String, Object> localCache;
@Autowired
private RocketMQTemplate rocketMQTemplate;
}
4.2 热点缓存
/**
* 热点数据缓存服务
*/
@Service
public class HotKeyCacheService {
@Autowired
private Cache<String, Object> localCache;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 获取数据(多级缓存)
*/
public Object get(String key) {
// L1: 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// L2: Redis 缓存
value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 回写到本地缓存
localCache.put(key, value);
return value;
}
return null;
}
/**
* 热点数据特殊处理
*/
public Object getHotKey(String key) {
// 热点数据永不过期(本地缓存)
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// Redis 缓存(较长 TTL)
value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 永久缓存到本地
localCache.put(key, value);
return value;
}
return null;
}
}
五、总结
5.1 核心要点
- 多级缓存:CDN → Nginx → 本地 → Redis → DB
- 更新策略:Cache Aside 最常用、Read Through 简化代码
- 一致性保障:延时双删、Binlog 监听、MQ 保证
- 热点处理:发现机制、本地缓存、永不过期
- 缓存预热:启动预热、定时预热、手动触发
5.2 最佳实践
mindmap
root((缓存设计最佳实践))
推荐做法
设置合理的过期时间
使用 Cache Aside 模式
热点数据使用本地缓存
批量查询避免缓存击穿
缓存预热提升体验
监控缓存命中率
避免做法
缓存永不过期
大 key>10KB
缓存穿透
缓存雪崩
缓存与 DB 长期不一致
缓存是双刃剑,用得好提升性能,用不好带来灾难。合理设计、持续监控、及时优化是关键。