本地缓存三大雷区:Java高并发下的致命隐患
导语
某电商大促因缓存误用导致数据库瘫痪!本文通过压测重现+源码剖析,揭示缓存穿透、缓存雪崩、数据不一致三大致命陷阱,提供百万QPS验证的解决方案。文末附缓存健康检查脚本。
一、缓存穿透:数据库的隐形杀手
灾难现场:
接口被刷/user?id=-1,导致数据库CPU 100%
问题代码:
public User getUser(long id) {
User user = cache.get(id); // 1. 查缓存
if (user == null) {
user = db.query("SELECT * FROM users WHERE id = " + id); // 2. 查数据库
cache.put(id, user); // 3. 写缓存(缓存null值!)
}
return user;
}
// 攻击者传入不存在的ID,缓存大量null值
压测数据:
攻击QPS | 缓存命中率 | 数据库QPS |
500 | 0% | 500 |
5000 | 0% | 5000(数据库宕机) |
解决方案:
// 1. 布隆过滤器拦截(Guava)
private static final BloomFilter<Long> userFilter = BloomFilter.create(
Funnels.longFunnel(), 1000000, 0.01); // 100万用户,1%误判
public User safeGetUser(long id) {
if (!userFilter.mightContain(id)) return null; // 拦截非法ID
User user = cache.get(id);
if (user != null) return user;
user = db.getById(id);
if (user != null) {
cache.put(id, user);
} else {
cache.put(id, User.EMPTY, 30); // 缓存空对象30秒
}
return user;
}
// 2. 缓存预热机制
@PostConstruct
public void preheatCache() {
List<Long> allIds = db.getAllUserIds();
allIds.forEach(id -> cache.get(id)); // 触发加载
}
二、缓存雪崩:系统崩溃的导火索
血泪案例:
万级缓存同时过期,数据库瞬时压力激增500%
错误配置:
// 所有缓存设置相同TTL
public Product getProduct(long id) {
Product product = cache.get(id);
if (product == null) {
product = db.getProduct(id);
cache.put(id, product, 60 * 60); // 全部1小时过期
}
return product;
}
雪崩效应:
缓存数量 | 同时过期比例 | 数据库峰值压力 |
1万 | 100% | 10倍 |
10万 | 100% | 数据库宕机 |
工业级方案:
// 1. 过期时间随机化
int baseTtl = 60 * 60; // 1小时
int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(300); // +随机5分钟
cache.put(id, product, randomTtl);
// 2. 热点数据永不过期+异步更新
public Product getHotProduct(long id) {
Product product = cache.get(id);
if (product == null) {
product = db.getProduct(id);
cache.put(id, product); // 永不过期
scheduleRefresh(id); // 后台定时更新
}
return product;
}
// 3. 熔断降级机制(Hystrix)
@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProductWithCircuitBreaker(long id) {
// 业务逻辑
}
三、数据不一致:脏缓存的业务灾难
诡异故障:
订单已支付,页面仍显示"待付款"
问题根源:
public void updateOrder(Order order) {
db.update(order); // 1. 更新数据库
cache.delete(order.getId()); // 2. 删除缓存
// 极端情况下:读请求在1和2之间执行,加载旧数据到缓存
}
解决方案:
// 1. 延迟双删策略(最终一致性)
public void safeUpdate(Order order) {
db.update(order);
cache.delete(order.getId());
// 延迟二次删除
executor.schedule(() -> cache.delete(order.getId()), 1, TimeUnit.SECONDS);
}
// 2. 数据库Binlog同步(强一致)
@Transactional
public void updateOrder(Order order) {
db.update(order);
// 通过Canal监听Binlog更新缓存
}
// 3. 版本号控制
public class VersionedCache {
private final Map<Long, Pair<Integer, Object>> cache = new ConcurrentHashMap<>();
public void put(long id, int version, Object value) {
cache.compute(id, (k, v) ->
(v == null || version > v.getLeft()) ? Pair.of(version, value) : v
);
}
}