半夜3点报警:Redis崩溃后的72小时与3行救命代码

那是一个周五的凌晨,我正准备合上电脑享受周末,钉钉的报警群突然像疯了一样炸锅。

“DB CPU Util > 90%” “Redis Connection Timeout”

作为那家电商公司的技术顾问,我眼睁睁看着Grafana监控大屏上的曲线,从平滑的波浪线变成了垂直的峭壁。在那之后的72小时里,我们重构了整个缓存层。

很多开发同学在面试时能把缓存穿透、击穿、雪崩背得滚瓜烂熟,但在写代码时,往往只写一句 redisTemplate.get(key) 就觉得万事大吉。真实的高并发战场,往往就是死在这些"以为没问题"的细节里。

今天不讲教科书理论,直接复盘那次事故中,我们是如何用代码填上这三个深坑的。

一、缓存穿透:当黑客盯上了不存在的ID

在那次事故中,第一波攻击来自一个竞品的爬虫。他们写了一个脚本,疯狂请求ID为 -1-999999 的商品详情。

Redis里显然没有这些负数ID的缓存,于是流量像洪水一样全部打到了MySQL。数据库连接池瞬间被占满,正常的商品查询也进不来了。这就是典型的缓存穿透——请求直通数据库,缓存成了摆设。

当时团队里的新人小张提议:“把这些不存在的key也存进Redis,值设为null不就行了?”

我摇摇头。对方如果随机生成一亿个不重复的UUID来请求呢?你的Redis内存瞬间就会被这些垃圾数据撑爆。

我们最终上线的方案是:布隆过滤器(Bloom Filter)。

你可以把它理解为一个内存占用极小的"黑名单/白名单"机制。在请求到达Redis之前,先问问布隆过滤器:“这个ID可能存在吗?”如果它说不存在,直接返回,连Redis都不用查。

这是一个基于Redisson的落地代码示例,比手写Bitmap稳健得多:

配图

// 初始化布隆过滤器,预计插入100万元素,误判率0.01
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("productIdList");
bloomFilter.tryInit(1000000L, 0.01);

// 业务代码逻辑
public Product getProductDetail(String productId) {
    // 1. 先过布隆过滤器(第一道防线)
    // 注意:布隆过滤器说不存在,那一定不存在;说存在,可能不存在(误判)
    if (!bloomFilter.contains(productId)) {
        return null; // 直接拦截,DB安全了
    }

    // 2. 查询Redis
    Product cacheProduct = redisTemplate.opsForValue().get("prod:" + productId);
    if (cacheProduct != null) {
        return cacheProduct;
    }

    // 3. 查询DB
    Product dbProduct = productMapper.selectById(productId);
    
    // 4. 回写缓存(如果DB也没有,这里可以配合缓存空对象策略,但设置极短TTL)
    if (dbProduct != null) {
        redisTemplate.opsForValue().set("prod:" + productId, dbProduct, 1, TimeUnit.HOURS);
    } else {
        //以此应对布隆过滤器的误判情况
        redisTemplate.opsForValue().set("prod:" + productId, "EMPTY", 5, TimeUnit.MINUTES);
    }
    
    return dbProduct;
}

实战复盘: 上线布隆过滤器后,那波爬虫流量的99%在进入Redis前就被拦截了,数据库CPU瞬间回落到10%以下。

二、缓存击穿:热点Key失效的"瞬时暴击"

穿透的问题刚解决,第二天上午10点,秒杀活动开始了。

一款爆款显卡,缓存在10:05分准时过期。就在这一秒,数千个并发请求同时发现缓存没了,同时涌入数据库去查这个显卡的信息。

数据库刚刚喘口气,又被瞬间打趴。这就是缓存击穿——针对某个热点Key,在过期的瞬间,大并发击穿缓存。

我也曾见过很多"老司机"使用synchronized关键字来解决。但在微服务集群部署下,本地锁锁住的只是当前这台机器的线程,几十台机器加起来,依然有几十个并发打到数据库。

硬核解法:分布式互斥锁(Mutex Lock)。

逻辑很简单:谁拿到了锁,谁去查DB并回写缓存;没拿到的,在门口等着(自旋)。

public Product getHotProduct(String productId) {
    String cacheKey = "prod:" + productId;
    
    // 1. 查缓存
    Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
    if (product != null) {
        return product;
    }

    // 2. 缓存未命中,尝试获取分布式锁
    String lockKey = "lock:prod:" + productId;
    // 使用setIfAbsent (SETNX) 原子操作
    Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

    if (isLocked) {
        try {
            // 3. 获取锁成功,再次检查缓存(双重检查锁 DCL)
            // 防止在获取锁的过程中,已经有别的线程重建了缓存
            product = (Product) redisTemplate.opsForValue().get(cacheKey);
            if (product != null) {
                return product;
            }

            // 4. 真正查询DB
            product = productMapper.selectById(productId);
            
            // 5. 重建缓存
            redisTemplate.opsForValue().set(cacheKey, product, 1, TimeUnit.HOURS);
        } finally {
            // 6. 释放锁
            redisTemplate.delete(lockKey);
        }
    } else {
        // 7. 获取锁失败,休眠一会再重试(自旋)
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return getHotProduct(productId);
    }
    
    return product;
}

配图

这段代码看似简单,但有三个细节救了命:

  1. 锁要加过期时间:防止拿到锁的服务挂了,导致死锁。
  2. 双重检查(Double Check):拿到锁后必须再查一次Redis,否则会重复查库。
  3. 自旋机制:没抢到锁的线程不能直接报错,要等一会再试。

三、缓存雪崩:定时任务引发的"多米诺骨牌"

最后也是最壮观的一次崩溃,发生在凌晨3点。

为了保证数据一致性,当时的业务逻辑是:每天凌晨2点半,由批处理任务把所有商品的缓存全部刷新一遍,过期时间统一设为12小时。

结果到了下午2点半(14:30),所有缓存集体同时失效。这时候正是下午的流量高峰,Redis瞬间变空,所有流量全部砸向数据库。数据库直接宕机,重启都起不来,因为一起动就被流量打死。

这就是缓存雪崩——大量Key同时过期。

解决这个问题,不需要复杂的分布式锁,只需要一个简单的数学技巧:随机值

我们在重构代码时,给每个Key的过期时间加了一个随机的抖动因子。

// 原始逻辑:固定12小时
// long expireTime = 12 * 60 * 60; 

// 优化后逻辑:12小时 + 随机0-60分钟
public void refreshCache(String key, Object value) {
    long baseTime = 12 * 60 * 60;
    // 生成一个随机数,比如 0 到 3600秒
    long randomDelta = new Random().nextInt(3600);
    
    long finalExpire = baseTime + randomDelta;
    
    redisTemplate.opsForValue().set(key, value, finalExpire, TimeUnit.SECONDS);
}

除了随机TTL,我还强烈建议在运维层面做隔离:

  • 启用Sentinel或Cluster集群:防止Redis单点故障导致的雪崩。
  • 开启限流降级:如果数据库真的扛不住了,Hystrix或Sentinel(阿里的那个组件)直接熔断,返回默认值或者"系统繁忙",保住数据库这条命。

这种代码,值得写在简历里

经历了那次72小时的救援,我深刻体会到:架构的稳定性,往往不取决于你用了多牛的框架,而在于你对边缘情况(Edge Case)的处理。

很多人觉得为了那1%的极端情况,写这么复杂的锁逻辑不值得。但当系统用户量突破十万、百万量级时,正是这1%的疏忽,会毁掉整个系统的99%。

针对以上三种场景,我的落地建议如下:

  1. 排查现有代码:全局搜索 set 操作,检查是否有固定的过期时间,加上随机数。
  2. 引入保护层:对于核心高并发接口(如商品详情、库存查询),不要裸奔,务必加上互斥锁机制。
  3. 数据预热:对于已知的热点数据(如大促商品),提前手动加载到缓存,不要等用户来触发。

最后,我想做一个小调查: 在你的生产环境中,遇到过最棘手的缓存问题是哪一种? A. 莫名其妙的缓存穿透(被爬虫搞死) B. 热点Key失效导致的瞬间卡顿 C. 还没遇到过,但我现在开始慌了

欢迎在评论区告诉我你的经历,或者转发给那个这周五要上线代码的同事。