那是一个周五的凌晨,我正准备合上电脑享受周末,钉钉的报警群突然像疯了一样炸锅。
“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;
}
这段代码看似简单,但有三个细节救了命:
- 锁要加过期时间:防止拿到锁的服务挂了,导致死锁。
- 双重检查(Double Check):拿到锁后必须再查一次Redis,否则会重复查库。
- 自旋机制:没抢到锁的线程不能直接报错,要等一会再试。
三、缓存雪崩:定时任务引发的"多米诺骨牌"
最后也是最壮观的一次崩溃,发生在凌晨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%。
针对以上三种场景,我的落地建议如下:
- 排查现有代码:全局搜索
set操作,检查是否有固定的过期时间,加上随机数。 - 引入保护层:对于核心高并发接口(如商品详情、库存查询),不要裸奔,务必加上互斥锁机制。
- 数据预热:对于已知的热点数据(如大促商品),提前手动加载到缓存,不要等用户来触发。
最后,我想做一个小调查: 在你的生产环境中,遇到过最棘手的缓存问题是哪一种? A. 莫名其妙的缓存穿透(被爬虫搞死) B. 热点Key失效导致的瞬间卡顿 C. 还没遇到过,但我现在开始慌了
欢迎在评论区告诉我你的经历,或者转发给那个这周五要上线代码的同事。