在高并发的Web应用中,缓存是提升性能的关键手段。但当缓存过期时,多个线程同时发现缓存为空,就会一窝蜂地冲向数据库,造成“缓存击穿”,轻则拖慢系统,重则直接压垮服务。
缓存击穿的日常场景
想象一下,某电商平台的爆款商品详情页依赖缓存展示价格和库存。凌晨一点缓存刚好过期,紧接着秒杀开始,上千用户同时刷新页面。每个请求发现缓存没数据,全跑去查数据库。数据库瞬间被挤爆,页面打不开,老板急得跳脚——这就是典型的缓存击穿。
加锁能解决问题,但别太粗暴
最简单的办法是给缓存读取加个同步锁(synchronized),确保只有一个线程能去查数据库,其他线程等着。但这会导致所有请求排队,哪怕缓存已经重建好了,后面的请求还得等前面的锁释放,效率低下。
双检锁模式:既安全又高效
双检锁(Double-Checked Locking)模式就是为这种场景量身定制的。它的核心思想是:先检查缓存是否已存在,没有再加锁,加锁后再次确认缓存是否已被其他线程重建,避免重复加载。
下面是一个Java风格的伪代码示例,展示如何用双检锁处理缓存失效:
public class CacheService {
private volatile Map<String, Object> cache = new HashMap<>();
private final Object lock = new Object();
public Object getData(String key) {
// 第一次检查:缓存是否存在
Object result = cache.get(key);
if (result == null) {
synchronized (lock) {
// 第二次检查:防止多个线程重复重建
result = cache.get(key);
if (result == null) {
result = loadFromDB(key); // 模拟从数据库加载
cache.put(key, result);
}
}
}
return result;
}
private Object loadFromDB(String key) {
// 模拟耗时的数据库查询
return "data_from_db_" + key;
}
}
volatile 关键字不能少
注意上面代码中 cache 被声明为 volatile。这是为了防止指令重排序导致的问题。如果对象创建过程被优化重排,可能其他线程会拿到一个还未完全初始化的实例。volatile 能保证可见性和有序性,是双检锁正确运行的关键。
适用场景与注意事项
双检锁适合读多写少、重建成本高的缓存场景,比如配置信息、热点商品数据等。但如果业务逻辑本身很轻量,或者并发不高,直接加锁反而更简单可靠。另外,在分布式系统中,单机的双检锁不适用,需要配合分布式锁(如Redis实现)来处理。
把双检锁用对地方,既能防住缓存击穿,又不会拖慢正常请求,是提升系统稳定性的实用技巧之一。