78、重构与缓存:重构缓存策略,提升性能
缓存这东西,用好了是神器,用不好就是定时炸弹。我在项目中见过太多因为缓存策略不合理导致的性能事故——有的系统刚上线跑得飞快,过两天就慢得像蜗牛;有的明明加了缓存,数据库压力反而更大了。说白了,缓存重构不是简单地加个Redis就完事,你得想清楚:什么时候该缓存?缓存多久?怎么保证数据一致性?
缓存策略的常见误区
先说说我踩过的坑。有一次,一个电商系统的商品详情页,团队把所有数据都塞进了缓存,过期时间设了24小时。结果呢?商品价格变了,用户看到的还是旧价格,投诉电话被打爆。这就是典型的「缓存滥用」——不该缓存的也缓存了,缓存时间还设得特别长。
另一个常见问题是缓存穿透。我记得有个日志分析系统,每次查询都先查缓存,缓存没有就去查数据库。结果某个不存在的ID被频繁查询,缓存永远命中不了,数据库直接被拖垮。嗯,这就是典型的「缓存穿透」问题。
核心原则:缓存不是万能的。重构缓存策略的第一步,就是搞清楚哪些数据适合缓存,哪些不适合。
缓存策略重构的四个维度
我个人习惯从四个维度来审视缓存策略:
- 缓存粒度:是缓存整个对象,还是只缓存部分字段?
- 缓存时长:是固定过期,还是动态调整?
- 缓存更新:是主动更新,还是被动失效?
- 缓存保护:如何防止缓存穿透、雪崩、击穿?
你想想看,这四个维度组合起来,能衍生出多少种策略?但别慌,我们一个一个来拆解。
实战案例:用户信息缓存重构
拿一个用户信息查询系统举例。原来的代码是这样的:
// 重构前:简单粗暴的缓存策略
public User getUserById(Long userId) {
// 先查缓存
User user = redis.get("user:" + userId);
if (user != null) {
return user;
}
// 缓存没有,查数据库
user = userDao.findById(userId);
if (user != null) {
// 缓存24小时
redis.set("user:" + userId, user, 24, TimeUnit.HOURS);
}
return user;
}
这段代码有什么问题?问题大了去了:
- 缓存时间固定24小时,用户信息变了怎么办?
- 如果userId不存在,每次都会查数据库,缓存穿透
- 如果大量用户同时过期,数据库瞬间压力暴增,缓存雪崩
重构后的代码:
// 重构后:分层缓存 + 动态过期 + 防穿透
public User getUserById(Long userId) {
// 1. 先查一级缓存(本地缓存,毫秒级)
User user = localCache.get("user:" + userId);
if (user != null) {
return user;
}
// 2. 再查二级缓存(Redis,秒级)
user = redis.get("user:" + userId);
if (user != null) {
// 异步刷新本地缓存
localCache.put("user:" + userId, user);
return user;
}
// 3. 缓存都没有,查数据库
user = userDao.findById(userId);
if (user != null) {
// 缓存时间根据用户活跃度动态调整
int expireTime = calculateExpireTime(user);
redis.set("user:" + userId, user, expireTime, TimeUnit.SECONDS);
localCache.put("user:" + userId, user);
} else {
// 防止缓存穿透:缓存空对象,过期时间短一些
redis.set("user:" + userId, EMPTY_USER, 30, TimeUnit.SECONDS);
}
return user;
}
// 根据用户最后登录时间计算缓存过期时间
private int calculateExpireTime(User user) {
long lastLogin = user.getLastLoginTime();
long now = System.currentTimeMillis();
long diff = now - lastLogin;
// 最近登录的用户,缓存时间长一些
if (diff < 3600000) { // 1小时内
return 600; // 10分钟
} else if (diff < 86400000) { // 24小时内
return 300; // 5分钟
} else {
return 60; // 1分钟
}
}
我的经验:动态过期时间是个好办法。活跃用户的数据变化快,缓存时间短一点;不活跃用户的数据变化慢,缓存时间长一点。这样既保证了数据一致性,又减少了数据库压力。
缓存更新策略的选择
缓存更新策略,说白了就是「数据变了,缓存怎么办?」。我常用的有三种:
| 策略 | 原理 | 适用场景 | 注意事项 |
|---|---|---|---|
| Cache-Aside | 读时更新缓存,写时删除缓存 | 读多写少,数据一致性要求不高 | 删除缓存后,下次读会重新加载 |
| Read-Through | 缓存层自动加载数据 | 数据访问模式固定 | 需要实现缓存加载器 |
| Write-Through | 写数据时同时更新缓存和数据库 | 数据一致性要求高 | 写入延迟会增加 |
我个人最常用的是Cache-Aside模式。为什么?因为它简单、灵活。写数据时直接删除缓存,下次读的时候再重新加载。这样即使并发写,也不会出现缓存和数据库不一致的问题。
避坑指南:我曾经在Cache-Aside模式下犯过一个错误——先更新数据库,再删除缓存。结果在并发场景下,一个线程更新了数据库,还没来得及删除缓存,另一个线程就读到了旧缓存。正确的做法是:先删除缓存,再更新数据库。或者用延迟双删策略。
缓存雪崩、穿透、击穿的应对
这三个问题,是缓存重构中必须面对的。我画了一张图来说明它们的区别和应对方案:
缓存重构的实战步骤
如果你现在要重构一个系统的缓存策略,我建议按这个步骤来:
- 梳理缓存数据:列出所有被缓存的数据,标注数据变化频率、访问频率、一致性要求
- 评估缓存粒度:粗粒度缓存(整个对象) vs 细粒度缓存(部分字段)
- 设计过期策略:固定过期、动态过期、还是永不过期+主动更新
- 实现缓存保护:空对象缓存、互斥锁、随机过期时间
- 监控与调优:缓存命中率、过期率、数据库压力变化
一个小技巧:重构缓存策略时,不要一次性改完所有代码。先挑一个核心接口做试点,观察缓存命中率和数据库压力的变化。效果好了再推广到其他接口。这样即使出了问题,影响范围也有限。
总结
缓存重构,说白了就是找到「性能」和「一致性」之间的平衡点。没有银弹,只有根据业务场景不断调整。我做了这么多年架构,最大的体会就是:缓存策略不是写一次就完事的,它需要持续监控、持续优化。
嗯,最后说一句:别迷信缓存。有时候,优化数据库查询、加个索引,效果比加缓存好得多。缓存只是工具,不是目的。
公众号:蓝海资料掘金营,微信deep3321