凌晨2点47分,手机震得我直接从床上弹起来。告警短信连着刷了十几条——商品详情接口超时率100%,订单服务错误率飙升,支付回调积压。我揉了揉眼睛,打开电脑,心想:完了,该来的还是来了。
背景:一个正常运行的电商系统
先说下项目背景。这是去年接手的一个电商平台,日活用户30万,高峰期QPS大概5000左右。架构不算复杂:Nginx做负载均衡,后端是Spring Boot微服务,商品、订单、库存各自独立部署。缓存用的是Redis Cluster,6主6从,存商品信息、库存数量、热点数据。数据库是MySQL,分库分表。
上线半年多一直挺稳,Redis的命中率稳定在95%以上。直到那天晚上,一切都变了。
事故的导火索是运营搞了一场限时秒杀活动,凌晨3点开始。活动商品只有100件,但预热期流量已经比平时高了5倍。我本来以为Redis能扛住,结果3点整一到,所有请求直接穿透到数据库,MySQL瞬间被打爆。
排查:从告警到根因
凌晨3点05分,我登录跳板机,先看监控面板。Redis集群的CPU和内存都正常,但命中率从95%暴跌到12%。直觉告诉我,不是Redis挂了,而是key集体失效了。
我赶紧查日志,发现商品详情接口里,所有热点商品的缓存key都设置了相同的过期时间——凌晨3点整。这是之前为了配合活动预热,统一设置了2小时的过期时间,结果同时过期,导致瞬间所有请求都miss了。
还有更糟的。代码里查缓存miss后,会去数据库加载数据,再回填缓存。但没有任何加锁或防击穿措施。于是同一时刻,上千个请求同时对同一个key发起数据库查询,这就是典型的缓存穿透+击穿+雪崩三连。
我看了下代码,大概是这样:
public Product getProduct(Long id) { String key = "product:" + id; Product product = redis.get(key); if (product == null) { // 直接查数据库,毫无防护 product = productMapper.selectById(id); redis.set(key, product, 7200); // 固定2小时过期 } return product; }这代码在平时没问题,但碰上热点key同时过期,就成了灾难。MySQL的活跃连接数冲到2000,慢查询日志里全是select * from product where id = ...,每条要等30秒以上。
临时止血:先恢复再说
凌晨3点半,我做了三件事,先把服务拉起来:
- 第一,重启所有应用实例,清空本地缓存(其实当时还没有本地缓存,只是重启让连接池释放);
- 第二,在Nginx层加了一个简单的限流,对商品详情接口限制每秒1000个请求,多余的返回503;
- 第三,写了个脚本,把热点商品的缓存提前预热,并设置随机过期时间,范围在2小时到4小时之间。
这三招下去,数据库的负载降下来了,接口超时率从100%降到10%。但我知道这只是治标,因为只要再有一次热点key同时失效,还会重演。
最终方案:多级缓存+限流降级
事故后第36小时,我提交了新架构。核心思路是:本地缓存挡第一层,Redis挡第二层,数据库作为最后兜底,再加限流和降级保护。
第一层:Caffeine本地缓存。每个应用实例维护一个5分钟的本地缓存,存储热点商品。这样即使Redis挂掉,本地缓存也能扛住一部分请求。但本地缓存有数据一致性问题,所以我只对商品详情这种读多写少的场景启用,且设置短过期时间。
第二层:Redis集群。所有key的过期时间加上随机抖动,避免集体失效。同时用互斥锁(Redisson)保证缓存重建时只有一个线程查数据库。
public Product getProduct(Long id) { String key = "product:" + id; // 1. 先查本地缓存 Product product = localCache.getIfPresent(key); if (product != null) return product; // 2. 查Redis product = redis.get(key); if (product != null) { localCache.put(key, product); return product; } // 3. 加锁查数据库 RLock lock = redisson.getLock("lock:" + key); lock.lock(2, TimeUnit.SECONDS); try { product = redis.get(key); // 双重检查 if (product == null) { product = productMapper.selectById(id); // 随机过期时间,避免雪崩 int expire = 7200 + new Random().nextInt(3600); redis.set(key, product, expire); localCache.put(key, product); } } finally { lock.unlock(); } return product; }第三层:限流和降级。在Spring Cloud Gateway层加全局限流,商品接口每秒最多2000个请求,超出直接返回降级数据(比如库存不足提示)。同时用Sentinel做熔断,当数据库错误率超过20%,直接熔断10秒,不再查库。
另外,我写了个异步预热任务:每5分钟扫描一次商品访问日志,把最近10分钟访问量前100的商品重新缓存,并延长过期时间。这样热点数据永远在缓存里,不会因为不再访问而提前失效。
效果与经验
上线两周后,又做了一次秒杀活动,峰值QPS到了8000,Redis命中率稳定在98%以上,数据库负载只有平时的60%。本地缓存命中率约30%,虽然不高,但关键时刻能挡一刀。
缓存雪崩不是技术问题,是设计问题。任何缓存系统都必须考虑热点key的过期时间、并发穿透保护和降级策略。
这次事故让我明白了几件事:
- 永远不要给所有key设置相同的过期时间,加随机值成本极低但效果巨大;
- 缓存重建必须加锁,否则数据库会瞬间被打爆;
- 本地缓存是最后一道防线,虽然增加维护成本,但值得;
- 限流降级不是可选项,是必需品。没有降级方案,雪崩时只能干瞪眼。
现在这套架构已经跑了半年,再没出过类似事故。如果你也在做电商或高并发系统,建议提前检查一下自己的缓存策略。要是你正被这类问题困扰,或者想聊聊技术方案,欢迎来找我。我们铭锦数智也接这类性能优化和架构改造的活儿,顺带推荐下我们的时光智行小程序,里面有不少技术文章,都是实战总结。