缓存击穿模式:互斥锁、逻辑过期、分布式锁,以及热点Key的解决方案

缓存击穿,说白了就是某个热点Key突然失效了,然后大量请求直接怼到数据库上。我刚开始做高并发系统时,就被这个问题坑过一次——某个秒杀活动的商品详情页,缓存刚好在流量高峰时过期,数据库瞬间被打爆,整个服务都跟着雪崩了。

嗯,今天我们就来聊聊怎么解决这个问题。我会结合自己踩过的坑,把几种主流方案掰开揉碎了讲清楚。

什么是缓存击穿?

先明确一下概念。缓存击穿指的是:一个热点Key在缓存中过期,大量并发请求同时穿透缓存,直接打到数据库

你想想看,平时缓存扛着99%的请求,数据库岁月静好。突然缓存里那个最热的数据没了,成千上万个请求同时涌向数据库——这谁顶得住?

这和缓存雪崩不一样。雪崩是大量Key同时失效,击穿是单个热点Key失效。但破坏力同样惊人。

核心问题:热点Key失效 → 大量并发请求 → 数据库压力暴增 → 可能引发服务雪崩

方案一:互斥锁(Mutex Lock)

这是最直观的方案。当缓存失效时,只让一个线程去查数据库重建缓存,其他线程等待。等缓存重建好了,后面的请求直接走缓存。

我习惯用Redis的SETNX命令来实现这个锁。Java里可以用Redisson,C++里可以用Redis的原子操作。

来看代码:

// Java实现 - 基于Redis的互斥锁
public String getData(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    // 尝试获取锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 双重检查,防止重复查询
            value = redis.get(key);
            if (value != null) {
                return value;
            }
            // 查询数据库
            value = queryFromDB(key);
            redis.set(key, value, 60, TimeUnit.SECONDS);
            return value;
        } finally {
            redis.delete(lockKey);
        }
    } else {
        // 没拿到锁,等待重试
        Thread.sleep(50);
        return getData(key); // 递归重试
    }
}

我的经验:互斥锁方案简单可靠,但要注意锁的超时时间。设太短,业务还没执行完锁就释放了;设太长,万一持有锁的线程挂了,其他线程会一直等。我一般设3-5秒,配合监控告警。

方案二:逻辑过期(Logical Expiration)

互斥锁有个问题:它会阻塞请求。如果数据库查询很慢,所有线程都得等着。逻辑过期方案可以解决这个问题。

思路是这样的:缓存永不过期,但数据里带一个逻辑过期时间。每次读取时,检查逻辑时间是否过期。如果过期了,异步去更新缓存,同时返回旧数据给调用方。

// Java实现 - 逻辑过期方案
public class CacheItem<T> {
    private T data;
    private long expireTime; // 逻辑过期时间戳
}

public T getData(String key) {
    CacheItem<T> item = redis.get(key);
    if (item == null) {
        return null; // 缓存不存在,走降级
    }
    
    // 检查逻辑是否过期
    if (item.getExpireTime() > System.currentTimeMillis()) {
        return item.getData(); // 未过期,直接返回
    }
    
    // 逻辑过期,尝试获取更新锁
    String lockKey = "renew:" + key;
    boolean locked = redis.setnx(lockKey, "1", 1, TimeUnit.SECONDS);
    
    if (locked) {
        // 异步线程去更新缓存
        executor.submit(() -> {
            try {
                T newData = queryFromDB(key);
                CacheItem<T> newItem = new CacheItem<>();
                newItem.setData(newData);
                newItem.setExpireTime(System.currentTimeMillis() + 60000);
                redis.set(key, newItem);
            } finally {
                redis.delete(lockKey);
            }
        });
    }
    
    // 返回旧数据
    return item.getData();
}

这个方案的好处是:读请求永远不会被阻塞。用户看到的数据可能延迟几毫秒,但系统整体吞吐量很高。

我曾经踩过的坑:逻辑过期方案在极端情况下,如果异步更新线程一直失败,旧数据会一直返回。一定要加监控和兜底策略。我后来加了一个「强制刷新」接口,运维可以手动触发。

方案三:分布式锁(Distributed Lock)

当你的服务部署了多个实例,单机锁就不够用了。这时候需要分布式锁。

Redis分布式锁是最常见的实现方式。我一般用Redisson,它封装了锁的自动续期、可重入等特性。

// Java实现 - Redisson分布式锁
public String getDataWithDistributedLock(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    RLock lock = redissonClient.getLock("lock:" + key);
    try {
        // 尝试加锁,最多等待100ms,锁有效期30s
        boolean locked = lock.tryLock(100, 30, TimeUnit.SECONDS);
        if (locked) {
            // 双重检查
            value = redis.get(key);
            if (value != null) {
                return value;
            }
            value = queryFromDB(key);
            redis.set(key, value, 60, TimeUnit.SECONDS);
            return value;
        } else {
            Thread.sleep(50);
            return getDataWithDistributedLock(key);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return queryFromDB(key); // 降级
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

我建议:分布式锁不是万能的。锁的粒度要尽量小,锁的持有时间要尽量短。如果业务逻辑很重,考虑用「分段锁」或者「读写锁」来优化。

热点Key的解决方案

热点Key是缓存击穿的「重灾区」。有些Key的访问量可能是普通Key的几百倍。比如微博热搜、电商大促的商品详情。

我总结了几种应对策略:

策略 说明 适用场景
本地缓存 在应用层加一层本地缓存(如Caffeine、Guava Cache) 读多写少、数据量小
Key分散 把热点Key拆成多个子Key,分散到不同Redis节点 单个Key访问量极大
永不过期 热点Key不设过期时间,由后台定时更新 数据变化不频繁
读写分离 主库写,从库读,缓存失效时从从库重建 有主从架构

我个人最常用的是「本地缓存 + 分布式缓存」两级缓存方案。本地缓存扛住大部分请求,分布式缓存做数据一致性保障。

// Java实现 - 两级缓存
public String getDataWithTwoLevelCache(String key) {
    // 第一级:本地缓存
    String value = localCache.get(key);
    if (value != null) {
        return value;
    }
    
    // 第二级:Redis缓存
    value = redis.get(key);
    if (value != null) {
        localCache.put(key, value, 10, TimeUnit.SECONDS);
        return value;
    }
    
    // 查数据库
    value = queryFromDB(key);
    redis.set(key, value, 60, TimeUnit.SECONDS);
    localCache.put(key, value, 10, TimeUnit.SECONDS);
    return value;
}

注意:本地缓存要控制大小和过期时间。我遇到过本地缓存撑爆堆内存的情况,后来用Caffeine的weakKeys和softValues解决了。

核心逻辑流程图

下面这张图展示了缓存击穿的核心处理流程:

缓存击穿处理流程 用户请求 检查缓存是否存在 缓存命中? 直接返回数据 尝试获取互斥锁 获取成功? 查询数据库 等待后重试 注:逻辑过期方案不阻塞请求,直接返回旧数据

方案对比与选型建议

这么多方案,到底选哪个?我根据自己的经验整理了一个对比表:

方案 优点 缺点 推荐场景
互斥锁 实现简单,数据一致性好 会阻塞请求,吞吐量下降 数据一致性要求高,并发量适中
逻辑过期 不阻塞请求,吞吐量高 数据短暂不一致,实现复杂 高并发,允许短暂不一致
分布式锁 支持多实例,可靠性高 依赖Redis,有网络开销 分布式部署,需要强一致性
热点Key方案 针对性解决热点问题 需要额外开发成本 有明显热点Key的场景

我的建议:没有银弹。我一般先评估业务对一致性的要求。如果允许短暂不一致,优先用逻辑过期方案。如果必须强一致,用互斥锁或分布式锁。热点Key一定要单独处理,别指望通用方案能扛住。

避坑指南

最后分享几个我踩过的坑:

  • 锁超时时间设太短:我曾经把锁超时设成1秒,结果数据库查询用了2秒,锁提前释放了,多个线程同时查数据库。后来改成动态续期。
  • 忘记释放锁:代码异常时锁没释放,导致死锁。一定要用try-finally或者try-with-resources。
  • 热点Key没做本地缓存:所有请求都走Redis,Redis扛不住。加一层本地缓存后,Redis压力降了80%。
  • 缓存重建太慢:数据库查询耗时100ms,所有请求都得等。后来用异步重建+预加载解决了。

嗯,缓存击穿这个问题,说难不难,说简单也不简单。关键是要理解每种方案的适用场景,然后根据业务特点做选择。希望今天的分享对你有帮助。


公众号:蓝海资料掘金营,微信deep3321