跳至主要內容

十万个why:缓存击穿加个互斥锁就行了,为什么还要搞永不过期、逻辑过期这些方案?

程序员小富大约 8 分钟

大家好,我是小富。

《十万个why》系列持续更新中

缓存击穿这个问题,面试背过八股文的人都知道,热点 Key 过期的瞬间,大量请求同时穿透到数据库,把 DB 打挂。

解决方案也能张口就来:加互斥锁,只放一个线程去查 DB 重建缓存,其他线程等着。

逻辑没毛病,面试也能过。但你真把这个方案原封不动搬到线上,大概率会出事。

互斥锁到底有什么问题

先看一个标准的互斥锁实现:

public Object getData(String key) {
    Object value = redis.get(key);
    if (value != null) {
        return value;
    }

    // 缓存没命中,尝试获取锁
    String lockKey = "lock:" + key;
    boolean locked = redis.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (locked) {
        try {
            // 拿到锁,查 DB 重建缓存
            value = db.query(key);
            redis.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
        } finally {
            redis.delete(lockKey);
        }
    } else {
        // 没拿到锁,等一会儿再重试
        Thread.sleep(50);
        return getData(key);
    }
    return value;
}

看起来很合理。一个线程去查库重建缓存,其他线程 sleep 50 毫秒后重试,重试的时候缓存大概率已经建好了,皆大欢喜。

问题是,线上环境不是面试吹逼。

线程全在等,服务直接卡死

假设这个热点 Key 的并发量是每秒 5000 次请求。Key 过期的瞬间,5000 个请求同时打过来,只有 1 个拿到锁去查 DB,剩下的 4999 个全在 sleep + 重试。

如果用的是 Spring MVC + Tomcat,默认线程池大小是 200。这 200 个线程全被这个 Key 的请求占满了,在那 sleep 等着。这时候其他正常接口的请求进来,拿不到线程,直接排队超时。

一个热点 Key 的过期,把整个服务的所有接口都拖慢了。用户访问首页、查订单、看消息,全部转圈。

重建缓存如果慢,等待时间会爆炸

互斥锁方案有一个隐含假设:查 DB 重建缓存很快,几十毫秒就搞定。

但实际业务中,热点数据往往不是一条简单的 SELECT。比如一个商品详情页的缓存,可能要查商品基本信息、价格、库存、促销活动、评价统计,再拼装成一个大 JSON。这个过程涉及 5-6 次数据库查询甚至跨服务调用,耗时可能在 500 毫秒到 2 秒之间。

这 2 秒内,所有请求都在等。sleep 50 毫秒重试一次,2 秒就是重试 40 次。每次重试都要访问一次 Redis 检查缓存有没有建好,这本身也在消耗连接资源。

如果重建过程中 DB 本身也在承压,比如刚好赶上慢查询,耗时可能更长,等待的请求越堆越多,线程池耗尽,上游调用方开始超时,触发重试,请求量翻倍,恶性循环。

分布式环境下锁的问题

上面的代码用 Redis SETNX 做锁,在分布式部署下还有额外的坑。

拿到锁的那个节点如果在重建缓存的过程中挂了,锁虽然有过期时间兜底,但在锁过期之前,所有请求都在空等。如果锁的过期时间设得比重建时间短,锁提前释放了,另一个线程又拿到锁,可能出现两个线程同时在重建。

这些问题不是不能解决,用 Redisson 的看门狗机制可以自动续期。但问题是你为了解决一个缓存击穿,引入了一套分布式锁的复杂度,整体方案的维护成本已经很高了。

其他解决思路

互斥锁的矛盾点在于 它保证了数据一致性,只有一个线程重建缓存,但牺牲了可用性,其他所有线程都在等。

但很多业务场景下,并不需要这么强的一致性。

商品详情页的价格晚更新 2 秒,用户根本感知不到。排行榜数据晚刷新 5 秒,没有任何影响。推荐列表的内容旧了 10 秒,用户甚至分不出来。

对于这些场景,与其让 5000 个请求排队等,不如先把旧数据返回去,后台悄悄重建缓存,等建好了新请求自然就拿到新数据了。

这就是逻辑过期和永不过期方案的出发点。

逻辑过期

物理上不给 Key 设过期时间,让它永远存在于 Redis 里。但在缓存的 value 里面塞一个逻辑过期时间字段。

@Data
public class CacheData {
    private Object data;
    private long expireTime;  // 逻辑过期时间戳
}

读取的时候,先判断逻辑过期时间有没有到。如果没过期,直接返回。如果过期了,先把旧数据返回给用户,同时异步触发一个线程去重建缓存。

private static final ExecutorService REBUILD_EXECUTOR = 
    Executors.newFixedThreadPool(10);

public Object getData(String key) {
    String json = redis.opsForValue().get(key);
    if (json == null) {
        // 缓存完全不存在,走 DB 查询(冷启动场景)
        return rebuildAndReturn(key);
    }

    CacheData cacheData = JSON.parseObject(json, CacheData.class);

    // 没过期,直接返回
    if (cacheData.getExpireTime() > System.currentTimeMillis()) {
        return cacheData.getData();
    }

    // 逻辑过期了,返回旧数据,异步重建
    String lockKey = "lock:" + key;
    boolean locked = redis.opsForValue()
        .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (locked) {
        REBUILD_EXECUTOR.submit(() -> {
            try {
                Object newData = db.query(key);
                CacheData newCache = new CacheData();
                newCache.setData(newData);
                newCache.setExpireTime(
                    System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30));
                redis.opsForValue().set(key, JSON.toJSONString(newCache));
            } finally {
                redis.delete(lockKey);
            }
        });
    }

    // 不管有没有拿到锁,都返回旧数据
    return cacheData.getData();
}

注意最后那一行,不管有没有拿到锁都返回旧数据。这是跟互斥锁方案最本质的区别。

互斥锁方案里,没拿到锁的线程在 sleep 等待,直到缓存重建完毕才能返回。逻辑过期方案里,所有线程都是立即返回,没有任何等待,只不过在缓存重建完成之前,返回的是旧数据。

这意味着不管并发量多大,接口响应时间始终是毫秒级的。 不会出现线程堆积、不会占满线程池、不会拖垮其他接口。

代价就是在重建缓存的那几百毫秒到几秒内,一部分用户拿到的数据是旧的。

永不过期 + 主动更新

比逻辑过期更彻底的做法:Key 永远不设过期时间,连逻辑过期的判断都省了。数据更新完全靠主动推送。

// 数据变更时主动更新缓存
@EventListener
public void onProductUpdate(ProductUpdateEvent event) {
    Product product = event.getProduct();
    redis.opsForValue().set(
        "product:" + product.getId(),
        JSON.toJSONString(product)
    );
}

或者用定时任务兜底:

@Scheduled(fixedRate = 60000)
public void refreshHotProductCache() {
    List<String> hotProductIds = getHotProductIds();
    for (String id : hotProductIds) {
        Product product = productMapper.selectById(id);
        redis.opsForValue().set("product:" + id, JSON.toJSONString(product));
    }
}

Key 永远不过期,就从根上杜绝了缓存击穿,因为击穿的前提是 Key 过期,Key 不过期就不存在击穿。

这个方案适合数据变更有明确触发点的场景。比如后台管理员改了商品信息,改完顺手刷一下缓存;或者数据本身是定时计算的,比如排行榜每小时算一次,算完直接写入 Redis。

但它有一个硬伤:如果主动更新的链路出了问题,缓存里就是永远的脏数据。

比如消息队列丢了一条更新消息,或者定时任务某次执行失败了,缓存里的数据就一直是旧的,而且因为没有过期时间,它不会自动被淘汰,会一直躺在那里。

所以用这个方案的时候,通常要配一个兜底的定时全量刷新,确保即使某次增量更新失败了,数据也不会长期不一致。

三个方案到底怎么选

不是哪个方案更高级就用哪个,是看你的业务能容忍什么。

互斥锁:数据必须准确,宁可慢一点也不能返回旧数据。比如账户余额、库存数量、支付状态。用户查到自己余额是昨天的,打客服电话的概率非常高。

逻辑过期:数据晚几秒更新无所谓,但接口绝对不能慢。比如商品详情页、搜索结果列表、内容推荐流。电商场景下页面加载每慢 100 毫秒转化率就会下降,响应速度比数据实时性重要得多。

永不过期 + 主动更新:数据变更不频繁而且有明确的触发时机。比如系统配置、商品类目树、城市列表、数据字典。这些东西可能一周才改一次,没必要设过期时间让它去承受击穿的风险。

在实际项目里,这三种方案经常混着用。同一个系统里,配置数据用永不过期,商品详情用逻辑过期,库存数据用互斥锁,根据业务特点各取所需。

说在最后

互斥锁方案逻辑上是正确的,但它的适用范围比大多数人以为的要窄。在高并发场景下,它可能引发的线程堆积、接口超时、服务雪崩等问题,比缓存击穿本身还严重。

逻辑过期和永不过期不是在故意搞复杂,它们本质上是在用一点数据新鲜度换系统的稳定性和响应速度。在大多数业务场景下,这笔交易是划算的。

如果选方案决解,可以先假设一个问题,这个数据晚更新 3 秒,用户会不会打电话投诉? 如果不会,互斥锁基本不是最优解。

上次编辑于: