第4章:缓存策略详解
聊到高并发系统,缓存绝对是绕不开的话题。我经常跟团队说,没有缓存的系统就像没有水库的河流——流量一来就决堤。这一章,咱们把缓存策略掰开揉碎了讲清楚。
4.1 本地缓存:Guava Cache 实战
先说说本地缓存。说白了,就是把数据存在应用进程的内存里。我最早接触本地缓存是在一个电商项目里,当时商品分类信息几乎不变,但每次请求都要查数据库,那叫一个心疼。
Guava Cache 是我个人比较推荐的本地缓存方案。它不像 ConcurrentHashMap 那样裸奔,提供了自动加载、过期策略、最大容量等实用功能。
// 构建一个 Guava Cache
LoadingCache<String, User> userCache = CacheBuilder.newBuilder()
.maximumSize(10000) // 最多缓存1万个条目
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
.refreshAfterWrite(5, TimeUnit.MINUTES) // 5分钟后自动刷新
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) {
return getUserFromDB(key); // 缓存未命中时,自动加载
}
});
// 使用
User user = userCache.get("userId_123");
不过要注意,本地缓存有个硬伤——多实例部署时数据不一致。每个 JVM 各存各的,你更新了 A 机器,B 机器还是老数据。所以,本地缓存适合存那些变化不频繁、一致性要求不高的数据。
4.2 分布式缓存:Redis 核心玩法
当系统扩展到多台机器,就得请出分布式缓存了。Redis 是目前的主流选择,没有之一。
Redis 能火,说白了就两点:快(纯内存操作,单线程模型避免了并发问题),数据结构丰富(String、Hash、List、Set、Sorted Set 一应俱全)。
我习惯把 Redis 用在三个场景:
- 热点数据缓存:用户信息、配置参数等
- 分布式锁:用 SETNX 实现,注意要设置过期时间防止死锁
- 计数器:比如文章阅读量、点赞数,INCR 命令原子操作
// Redis 分布式锁的正确姿势
String lockKey = "lock:order:123";
String requestId = UUID.randomUUID().toString();
// 加锁,设置过期时间防止死锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 执行业务逻辑
processOrder();
} finally {
// 释放锁,用 Lua 脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Arrays.asList(lockKey), requestId);
}
}
4.3 缓存穿透、击穿、雪崩
这三个问题,面试必问,线上必踩。咱们一个一个说。
4.3.1 缓存穿透
什么是缓存穿透?就是请求的数据在缓存和数据库里都不存在。比如查一个不存在的用户 ID,每次请求都直接打到数据库。
解决方案我常用两种:
- 缓存空对象:查不到数据时,也缓存一个空值,设置较短的过期时间(比如 5 分钟)
- 布隆过滤器:把所有可能的 key 提前加载到布隆过滤器里,请求来了先判断 key 是否存在,不存在直接返回
// 布隆过滤器示例
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 预计元素数量
0.01 // 误判率
);
// 初始化时加载所有用户ID
for (String userId : allUserIds) {
bloomFilter.put(userId);
}
// 请求时先判断
if (!bloomFilter.mightContain(requestUserId)) {
return "用户不存在"; // 直接返回,不查数据库
}
4.3.2 缓存击穿
缓存击穿和穿透不一样。击穿是指某个热点 key 过期了,恰好有大量并发请求访问这个 key,全部打到数据库上。
嗯,这里要注意,击穿只针对热点 key。我遇到过最典型的场景是:某个爆款商品的详情页,缓存过期瞬间,几千个请求同时涌入。
解决方案:
- 互斥锁:缓存失效后,只让一个线程去查数据库,其他线程等待
- 逻辑过期:缓存里不设物理过期时间,而是存一个逻辑过期时间,后台异步刷新
// 互斥锁方案
public String getProduct(String productId) {
String cache = redisTemplate.opsForValue().get(productId);
if (cache != null) {
return cache;
}
// 缓存未命中,尝试获取锁
String lockKey = "lock:product:" + productId;
if (tryLock(lockKey)) {
try {
// 双重检查,防止其他线程已经更新了缓存
cache = redisTemplate.opsForValue().get(productId);
if (cache != null) {
return cache;
}
// 查数据库
String dbData = queryFromDB(productId);
redisTemplate.opsForValue().set(productId, dbData, 30, TimeUnit.MINUTES);
return dbData;
} finally {
unlock(lockKey);
}
} else {
// 没拿到锁,等待一会儿再重试
Thread.sleep(100);
return getProduct(productId); // 递归重试
}
}
4.3.3 缓存雪崩
雪崩比击穿更可怕。它是指大量缓存同时过期,或者 Redis 挂了,导致所有请求直接打到数据库。
你想想看,数据库能扛住吗?大概率直接挂掉。
解决方案:
- 过期时间加随机值:避免大量 key 同时过期
- Redis 高可用:主从 + 哨兵,或者 Redis Cluster
- 本地缓存兜底:Redis 挂了,还有 Guava Cache 顶着
- 限流降级:请求量超过阈值时,直接返回默认值或错误提示
// 过期时间加随机值,避免雪崩
int baseExpire = 3600; // 1小时
int randomExpire = new Random().nextInt(600); // 0-10分钟随机
redisTemplate.opsForValue().set(key, value, baseExpire + randomExpire, TimeUnit.SECONDS);
- 穿透:查不存在的数据 → 布隆过滤器或缓存空对象
- 击穿:热点 key 过期 → 互斥锁或逻辑过期
- 雪崩:大量 key 同时过期或 Redis 挂了 → 过期时间随机化 + 高可用 + 本地缓存兜底
最后说一句,缓存不是银弹。用得好,系统性能翻倍;用不好,数据一致性、内存溢出、缓存雪崩这些问题够你喝一壶的。我个人的原则是:能不缓存就不缓存,必须缓存就做好兜底方案。
下一章,咱们聊聊数据库层面的优化——读写分离与分库分表。到时候见。