凌晨两点,某电商平台的核心商品详情页突然卡死,后台监控里缓存命中率直线掉到个位数,数据库连接数爆满。技术群里一片慌乱——原因很快被找到:一批热门商品的缓存过期时间都设在同一点,零点一过集体失效,所有请求直接打到数据库上,瞬间压垮了系统。这就是典型的缓存雪崩场景。

一、从一次线上事故说起

你可能会想,缓存过期这事不是挺正常吗?每个数据都有自己的生命周期,到期就清掉,再来新请求重新加载,天经地义。但问题就出在“同时”两个字上。假设你有一百个热门商品,每个商品设置了相同的过期时间,比如凌晨12点整。那么到了这个时间点,这一百个商品的缓存全部失效。如果这时候有大量用户同时访问这些商品,系统发现缓存没命中,就全部去数据库查,数据库一秒内可能收到平时十倍以上的压力,瞬间就趴窝了。

缓存雪崩的恐怖之处在于,它不是单个key失效,而是大量key在同一时间失效,导致流量直接绕过缓存冲进底层存储。它的症状和缓存击穿有点像,但击穿是只针对一个热点key,而雪崩是一批热点key同时出问题。我们通常说的“缓存穿透”是查询一个不存在的key,根本不会去缓存查,直接打数据库,那是另一回事。

生活里也有类似的场景。好比食堂开饭,大家平时错峰吃饭,排队很流畅。但突然有一天午饭时间规定全体人员必须同时去食堂打饭,窗口没变,人全挤在一起,结果就是谁都没饭吃。缓存雪崩就是这种“全员同时到点”的灾难。

二、缓存雪崩是怎么发生的

先理清一个概念:热点数据。所谓热点数据,指的是访问频率特别高的数据,比如购物车里的商品信息、热门新闻的列表、秒杀活动的库存。这些数据通常会被缓存在Redis这类内存数据库里,减少对官方数据库的查询压力。

我们在存储时,会给每个缓存key设置一个过期时间。这个时间有两种常见写法:

第一种,存的时候固定一个时间,比如:

// 设置缓存,过期时间3600秒
redisTemplate.opsForValue().set("product:1001", productData, 3600, TimeUnit.SECONDS);

第二种,定时刷新时统一设置某个整点过期,比如把所有热门商品都设置成每天凌晨0点过期。

这两种写法,只要key的数量一多,且过期时间相同,就会在那一瞬间形成“集体下岗”。所有请求发现缓存没有,就会同时去查数据库,数据库扛不住,系统就雪崩了。

还有另一种情况是,虽然设置了不同的过期时间,但由于某些业务原因,比如批量导入数据时用了同一个过期时间,或者重启服务时缓存全部清空,也会产生类似的雪崩效果。所以防雪崩的核心思路就两条:要么让过期时间分散开,要么让缓存重建过程更安全,避免大量请求直接压到数据库。

三、解决思路一:随机过期时间

3.1 基本做法

随机过期时间,说起来特别简单——就是给每个key的过期时间加上一个随机数,让它们的过期点不再整齐划一。还是拿食堂打饭举例,不再规定同一时刻,而是让大家随机分几波去,这样就不会挤在一起了。

比如原来所有商品都设3600秒过期,现在给每个商品在3600秒基础上加一个0到300秒的随机数,有的3602秒过期,有的3800秒过期。这样过期时间点被摊开,虽然还是会有部分key同时过期,但数量少了很多,数据库的压力也分散了。

3.2 代码示例

技术栈:Java + Spring Boot + Redis

下面这段代码演示了如何给缓存key设置随机过期时间。

import org.springframework.data.redis.core.RedisTemplate;
import java.util.Random;
import java.util.concurrent.TimeUnit;

public class RandomExpireService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private final Random random = new Random();
    
    public RandomExpireService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    /**
     * 写入缓存,并添加随机过期时间
     * @param key   缓存key
     * @param value 缓存value
     * @param baseExpireSeconds 基础过期时间(秒),例如3600
     * @param randomRange       随机数范围(秒),例如300
     */
    public void setWithRandomExpire(String key, Object value, long baseExpireSeconds, long randomRange) {
        // 生成0到randomRange之间的随机数
        long randomExtra = (long) (random.nextDouble() * randomRange);
        long finalExpire = baseExpireSeconds + randomExtra;
        
        // 写入redis,过期时间使用最终计算出来的值
        redisTemplate.opsForValue().set(key, value, finalExpire, TimeUnit.SECONDS);
        
        // 方便观察,打印过期时间
        System.out.println("key: " + key + ",过期时间: " + finalExpire + "秒");
    }
    
    public static void main(String[] args) {
        // 假设已有RedisTemplate实例
        // 模拟批量设置热门商品缓存,基础过期1小时,随机范围0-300秒
        RandomExpireService service = new RandomExpireService(null); // 真正使用时需注入RedisTemplate
        for (int i = 1; i <= 10; i++) {
            service.setWithRandomExpire("product:100" + i, "商品数据" + i, 3600, 300);
        }
    }
}

注释写得很清晰,核心逻辑就是那三行:生成随机数、计算最终过期时间、写入缓存。你可能会问,随机范围设多大合适?一般来说,基础过期时间的5%到10%作为随机区间比较合理。比如一小时3600秒,随机300秒,大概在5%到8%左右。如果随机范围太大,可能有些key存活时间过短,导致缓存命中率下降;如果太小,效果又不明显。

3.3 优缺点分析

随机过期时间最大的优点是实现极其简单,几乎不引入额外代码,也不需要改变读取逻辑。它适合绝大多数缓存场景,尤其是数据更新不频繁但访问量大的场景。缺点也很明显:它只能降低同时过期的概率,并不能完全避免。如果系统里只有几个热点key,这几个key恰好随机到了差不多的过期时间,那么依然可能发生击穿。另外,随机过期并没有真正解决缓存重建期间并发请求的问题。假设某个热点key失效了,在它失效后的几毫秒内,如果有100个请求同时到达,它们会同时去查数据库,依然可能给数据库造成压力。

所以随机过期更像是一个“缓冲”手段,而不是“根治”方案。要根治,还得配合互斥锁。

四、解决思路二:互斥锁重建缓存

4.1 基本做法

互斥锁的思路是:当某个热点key过期,发现缓存没命中时,先尝试获取一把“锁”。只有拿到锁的请求才能去查数据库并重建缓存,其他没拿到锁的请求暂时等待,或者直接返回旧数据、降级数据。这样数据库只被一个请求查询,压力瞬间下降。

还是拿食堂打饭举例。假设一个菜窗口前突然挤满了人,但窗口只允许一个人进去打菜,其他人排队等着。那个人打完菜出来,把菜端到窗口,大家就可以直接吃现成的了。互斥锁就是“只让一个人进厨房”的机制。

4.2 代码示例

技术栈:Java + Spring Boot + Redis

下面演示使用Redis的SET NX EX命令实现分布式锁,然后重建缓存。

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;

public class MutexLockService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    
    public MutexLockService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    /**
     * 获取缓存,如果不存在则通过互斥锁重建
     * @param key 缓存key
     * @return 缓存值
     */
    public Object getDataWithLock(String key) {
        // 第一次查缓存
        Object value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            return value;  // 缓存命中,直接返回
        }
        
        // 缓存未命中,尝试获取锁
        String lockKey = "lock:" + key;      // 锁的key和业务key区分开
        String requestId = UUID.randomUUID().toString(); // 每个线程持有唯一标识
        // 尝试加锁,setIfAbsent相当于SETNX,设置过期时间防止锁一直不释放
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 拿到锁后需要再次检查缓存是否被其他线程重建了(双重检查)
                value = redisTemplate.opsForValue().get(key);
                if (value != null) {
                    return value;
                }
                // 真正去数据库查询
                value = queryFromDataBase(key);
                // 重建缓存,设置过期时间
                redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);
                return value;
            } finally {
                // 释放锁,需要用Lua脚本保证原子性,防止误删别人的锁
                String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
                redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId);
            }
        } else {
            // 没拿到锁,可能是其他线程正在重建缓存,这里做降级处理
            // 比如等待一小会儿后再重试获取缓存
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            // 重试获取缓存,也可以设置最大重试次数
            return getDataWithLock(key);
        }
    }
    
    /**
     * 模拟数据库查询
     */
    private Object queryFromDataBase(String key) {
        // 为了演示,这里直接返回字符串
        System.out.println("从数据库查询: " + key);
        return "数据库中的数据-" + key;
    }
}

这段代码有几个关键点:

  • 使用setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS)实现加锁,30秒是锁的自动过期时间,防止持有锁的线程崩溃导致死锁。
  • 获取锁成功后,再次检查缓存,这叫“双重检查”,因为可能其他线程拿到锁后已经重建好了缓存,避免重复查询。
  • 释放锁时使用Lua脚本,先判断当前锁的值是不是自己的requestId,是才删除,避免误删其他线程的锁。
  • 没拿到锁的线程采用自旋重试的方式,sleep 50毫秒后递归调用自己。实际项目中建议限制重试次数,比如重试3次后返回默认值或者抛出异常。

4.3 优缺点分析

互斥锁的优点是能精准保护数据库,不管有多少个请求同时打到同一个key上,最终只会有一个请求真正去查数据库,其他请求等待或降级。这对热点key特别有效,能够彻底避免缓存击穿。缺点是增加了一定的复杂度和延迟。比如获取锁本身就有网络开销,而且没拿到锁的线程需要等待,如果热点key重建缓存比较慢(比如数据库查询要几百毫秒),那么等待的请求可能堆积,响应时间变长。另外,锁粒度设计要小心,如果所有key都共用一把锁,就会把并发性能压得很低;如果每个key单独一把锁,又可能造成内存里锁对象过多。

还有一个隐患:如果持有锁的线程在查询数据库时出现异常,锁可能不能及时释放。虽然有过期时间兜底,但过期时间内其他线程依然会阻塞。所以使用互斥锁时,务必要把缓存重建逻辑放快一些,比如尽量减少数据库查询耗时,或使用异步重建。

五、两种方案对比与实现细节

我们现在把两个方案放在一起仔细对比,从多个维度分析。

5.1 实现复杂度

随机过期时间:几乎为零。只需要在写入缓存时多一行生成随机数的代码,不需要改读缓存逻辑。互斥锁:相对复杂。需要引入分布式锁,处理锁的获取、释放、重试、双重检查等,代码量多了几倍,还要考虑锁的可靠性,比如Redis主从切换可能丢锁。

5.2 对数据库的压力

随机过期时间:只能降低并发峰值,不能消除。如果有1000个key同时过期,分散到不同时间后,每个时间点依然有几十个key同时失效,假设每个key被100个请求访问,那数据库还是会面临几千的并发。互斥锁:对单个key可以做到100%的拦截,同一时刻只有一个请求查数据库,其他key也一样。但要注意,互斥锁只保护“热点key”,如果很多key同时失效,每个key都需要锁,那么数据库的并发等于同时失效的key数量,依然可能很高。所以最理想的是把两者结合。

5.3 对业务响应的影响

随机过期时间:完全不影响响应,因为不需要等待锁,请求立即查到缓存或直接查库(查库可能慢但也是直接)。互斥锁:没拿到锁的请求需要sleep后重试,可能增加几十到几百毫秒的延迟。如果数据库查询很慢,这个等待时间可能更长。

5.4 适用场景

随机过期时间适合大部分数据访问相对均匀、不太极端的情况;互斥锁适合极端热点key,比如秒杀商品、爆款新闻,它们一过期就可能被海量请求同时访问。

由此我们可以看出,单独使用任何一种方案都有短板。随机过期像“大锅饭分时段”,互斥锁像“单间小灶”,但把两者结合起来,才能做出最稳的防雪崩设计。

六、最佳组合:防击穿设计

6.1 组合策略

最佳组合可以概括为三个层次:

第一,在源头让过期时间随机化。这是基础防线,负责把雪崩扩散成小股击穿。

第二,针对每个热点key,在重建缓存时使用互斥锁。即使随机过后仍有个别key同时失效,锁也能保证数据库只被查一次。

第三,增加“逻辑过期”机制。所谓逻辑过期,就是缓存不设置物理过期时间,而是在value值中存储一个逻辑过期时间戳,比如“应该在10分钟后过期”。每次读取时,先看逻辑过期是否到了,如果没到,直接返回。如果到了,则返回旧数据的同时,尝试获取互斥锁去后台异步刷新缓存。这样即使热点key过期了,旧的缓存数据还能继续用,不会让请求直接透穿到数据库。

这三种手段合在一起,就能实现“过期时间分散 + 并发重建隔离 + 旧值兜底”的立体防护。

6.2 完整代码示例

下面给出一个结合了随机过期、互斥锁和逻辑过期的完整Java示例。这个示例采用最常用的方案:value存的是封装了数据和时间戳的对象。

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

public class CacheAntiCrashService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private final Random random = new Random();
    
    public CacheAntiCrashService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    /**
     * 内部类:逻辑缓存,包含业务数据和逻辑过期时间
     */
    private static class CacheData {
        Object data;        // 真实业务数据
        long expireTime;    // 逻辑过期时间戳(毫秒)
        
        CacheData(Object data, long expireTime) {
            this.data = data;
            this.expireTime = expireTime;
        }
    }
    
    /**
     * 写入缓存:设置随机物理过期时间,同时写入逻辑过期时间
     * @param key        缓存key
     * @param data       业务数据
     * @param logicSec   逻辑过期时长(秒)
     * @param maxMissSec 随机物理过期额外缓冲(秒)
     */
    public void writeCache(String key, Object data, long logicSec, long maxMissSec) {
        long now = System.currentTimeMillis();
        // 逻辑过期时间 = 当前时间 + 逻辑时长 + 随机缓冲
        long logicExpire = now + (logicSec + random.nextLong(maxMissSec)) * 1000;
        CacheData cacheData = new CacheData(data, logicExpire);
        
        // 物理过期时间 = 逻辑时长 + 随机缓冲,再加额外180秒保护
        long physicalExpire = logicSec + maxMissSec + 180;
        redisTemplate.opsForValue().set(key, cacheData, physicalExpire, TimeUnit.SECONDS);
    }
    
    /**
     * 读取缓存:逻辑过期判断 + 互斥锁异步重建
     * @param key 缓存key
     * @return 业务数据
     */
    public Object readCache(String key) {
        CacheData cacheData = (CacheData) redisTemplate.opsForValue().get(key);
        
        if (cacheData == null) {
            // 物理过期或者key不存在,直接加锁重建(因为逻辑过期已经防不住这种裸空)
            return rebuildCacheByLock(key);
        }
        
        long now = System.currentTimeMillis();
        if (now <= cacheData.expireTime) {
            // 逻辑过期时间未到,直接返回数据,这里用new Object是演示,实际直接返回业务对象
            return cacheData.data;
        }
        
        // 逻辑过期已到,先返回旧数据,再尝试异步更新
        tryRefreshCacheAsync(key, cacheData);
        return cacheData.data;  // 返回旧数据,保证响应不等待
    }
    
    /**
     * 通过互斥锁重建缓存(仅处理缓存缺失的场景)
     */
    private Object rebuildCacheByLock(String key) {
        String lockKey = "lock:" + key;
        String requestId = UUID.randomUUID().toString();
        boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
                .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS));
        
        if (locked) {
            try {
                // 双重检查
                CacheData cacheData = (CacheData) redisTemplate.opsForValue().get(key);
                if (cacheData != null) {
                    return cacheData.data;
                }
                Object dbData = loadFromDatabase(key);
                long now = System.currentTimeMillis();
                // 写入新的逻辑缓存,例如10秒有效
                CacheData newData = new CacheData(dbData, now + 10000);
                redisTemplate.opsForValue().set(key, newData, 190, TimeUnit.SECONDS);
                return dbData;
            } finally {
                releaseLock(lockKey, requestId);
            }
        } else {
            // 拿不到锁,说明其他线程正在重建,稍微等待后重试(可加次数限制)
            try {
                Thread.sleep(30);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            CacheData cacheData = (CacheData) redisTemplate.opsForValue().get(key);
            return (cacheData != null) ? cacheData.data : rebuildCacheByLock(key);
        }
    }
    
    /**
     * 异步刷新缓存:拿到锁后重新生成数据,不需要等待
     */
    private void tryRefreshCacheAsync(String key, CacheData oldData) {
        String lockKey = "lock:" + key;
        String requestId = UUID.randomUUID().toString();
        // 尝试加锁,失败说明有其他线程在刷新
        boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
                .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS));
        if (!locked) {
            return;  // 已经有人刷新,直接放弃
        }
        try {
            // 再次检查逻辑时间是否已被人刷新(双重检查)
            CacheData current = (CacheData) redisTemplate.opsForValue().get(key);
            if (current != null && current.expireTime > System.currentTimeMillis()) {
                return;  // 已被其他人刷新
            }
            // 模拟异步线程池中执行数据库查询
            new Thread(() -> {
                try {
                    Object dbData = loadFromDatabase(key);
                    long now = System.currentTimeMillis();
                    CacheData newData = new CacheData(dbData, now + 10000);
                    redisTemplate.opsForValue().set(key, newData, 190, TimeUnit.SECONDS);
                } finally {
                    releaseLock(lockKey, requestId);
                }
            }).start();
        } catch (Exception e) {
            releaseLock(lockKey, requestId);
        }
    }
    
    /**
     * 释放锁的Lua脚本
     */
    private void releaseLock(String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        redisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId);
    }
    
    /**
     * 模拟数据库查询
     */
    private Object loadFromDatabase(String key) {
        System.out.println("数据库查询: " + key + ",线程: " + Thread.currentThread().getName());
        try {
            Thread.sleep(100);  // 模拟耗时操作
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "最新数据-" + key;
    }
}

这段代码展示了一个相对完整的防击穿设计。写缓存时既有逻辑过期时间,又有随机物理过期时间,物理过期是逻辑过期加一个随机缓冲,确保即使有旧值长期存在,最终也能被清理。读缓存时,如果逻辑没过期,直接返回;如果逻辑过期,返回旧值同时触发异步刷新。只有当缓存彻底消失时,才走同步加锁重建。这样做的好处是,用户感知不到任何卡顿,数据库压力也被分散。

当然,实际生产环境里,异步刷新通常会使用线程池,而不是直接new Thread()。这里为了简化演示,用Thread示意,但在博客中要说明。

6.3 其他补充手段

除了上述组合,还有两个常见的辅助手段。

一是多级缓存。在Redis前面加一层本地缓存(如Caffeine),本地缓存设置的过期时间更短,比如1分钟。当Redis大规模失效时,本地缓存还能顶上,形成第二道保护层。本地缓存与业务机器数量有关,每台机器只缓存自己服务的数据,所以Redis雪崩时本地缓存可以拦截掉大部分流量。

二是热点数据永不过期。对于极少数的“超级热点”key,干脆不设置物理过期时间,只通过逻辑过期来更新。比如某个明星的新闻详情页,你可以设计成逻辑上每5分钟更新一次,但物理上这个key永远存在。这样即使并发再大,也不会出现缓存空窗期。

这些手段可以结合使用,但要注意成本。多级缓存会引入缓存一致性维护问题,热点永不过期则依赖后台定期更新,都可能增加开发量。

七、注意事项

在实际落地时,有几个坑一定要避开。

  1. 锁的过期时间设置。如果锁过期时间太短,比如1秒,但数据库查询花了2秒,锁自动释放后其他线程又抢到锁,相当于锁失效。通常把锁过期时间设置为预估操作时间的3到5倍,同时加上监控报警。

  2. 随机过期时间的随机范围不能太大。比如基础过期时间是3600秒,你随机范围给了3600秒,那有些key实际存活只有一小会儿,缓存命中率会大幅下降。建议随机范围控制在基础过期时间的5%到20%之间。

  3. 互斥锁的粒度。如果锁的key粒度太粗,比如所有商品共用一把锁,那不同商品的数据重建也会互相阻塞,严重影响吞吐。应该做到“一key一锁”。

  4. 自旋重试要有上限。互斥锁方案里,没拿到锁的请求如果无限重试,一旦数据库恢复正常后,大量线程同时涌进来,仍然可能造成压力。设置最大重试次数,比如3次,超过后直接返回兜底数据或空值,并打日志告警。

  5. 注意Redis故障时的降级。如果Redis本身挂了,那么随机过期和互斥锁都失效,这时候需要启动本地熔断机制,直接返回默认数据或空页面,而不是继续重试引发雪上加霜。

八、文章总结

缓存雪崩是分布式系统里非常典型的故障,它告诉我们一个道理:任何集中失效的事件,都要提前想好对策。随机过期时间用“分散风险”的方式降低雪崩概率,互斥锁用“串行化”的方式保护数据库不被同一时刻的并发击穿,而把它们组合起来,再加上逻辑过期、多级缓存等手段,才能构建出一道可靠的防洪堤。

实际开发中,没有银弹,但理解了这两种基础方案的原理,你就能够根据业务场景灵活组合。希望这篇文章能帮你避开缓存雪崩的巨坑,让系统在流量高峰下依然稳如老狗。