帮客户做了一次从本地缓存到Redis的分布式迁移实战:踩坑与经验总结
前不久帮一个做电商零售的客户重构了他们的库存中心。客户是个中型品牌,日均订单量大概在5万单左右,但在大促期间峰值能冲到20万。项目初期为了赶进度,我们直接用了应用本地的Caffeine缓存来扛QPS。说实话,刚上线那两周风平浪静,性能测试看着也漂亮,我以为这就稳了。直到上个月双11预热,流量稍微上来一点,几个节点的服务日志开始疯狂报错,库存扣减出现超卖现象,用户投诉电话都快被打爆了。
当时我觉得这样就行,结果发现错了。本地缓存最大的问题就是数据一致性太差。我在A节点更新了库存,B节点还是旧的旧数据,因为它们是各自维护一份内存拷贝。一旦某个节点宕机重启,或者发生服务漂移,缓存穿透和雪崩的风险就直接暴露出来了。客户急得要命,让我立刻出方案。我仔细复盘了一下,决定把这套复杂的本地缓存逻辑彻底砍掉,迁移到Redis集群。这不仅仅是换个组件的事,而是整个架构层面的重构。
方案选型与核心代码改造
迁移前,我和团队开了个短会,讨论了两个方案。方案A是使用Spring Cache注解配合Redis作为后端存储,简单粗暴,改几行代码就能搞定;方案B则是完全手写Redis操作逻辑,引入Redlock分布式锁来保证原子性。
我选了方案B。虽然方案A开发快,但在高并发库存扣减这种对一致性要求极高的场景下,Spring Cache默认的异步刷新机制很容易导致竞态条件。我们需要更细粒度的控制,特别是在处理“超卖”这个致命Bug时。
```java
// 核心扣减库存逻辑,使用Lua脚本保证原子性
public boolean deductStock(String itemId, int quantity) {
String key = "stock:" + itemId;
// Lua脚本确保读取和扣减是原子的
String script =
"if redis.call('get', KEYS[1]) == false then return -1 end " +
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if stock < tonumber(ARGV[1]) then return 0 end " +
"redis.call('decrby', KEYS[1], ARGV[1]) " +
"return 1";
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), quantity);
if (result == null || result == 0) {
return false; // 库存不足
}
return true;
}
```
有意思的是,在迁移过程中我发现了一个隐藏很深的性能瓶颈。原本本地缓存时,我们为了节省内存,把很多不常用的商品详情也塞进了缓存里。切换到Redis后,如果不做区分,巨大的Key空间会导致Redis内存碎片率飙升。我特意让DBA同学帮忙监控了一下,果然,碎片率一度达到了40%以上。
为了解决这个问题,我给缓存加了分层策略。热点商品数据(Top 1000 SKU)放入Redis Cluster的主节点,并且设置较短的TTL(5分钟),强制高频刷新以换取数据新鲜度。非热点数据则降级到MySQL数据库查询,并开启数据库层面的连接池优化。这样既保证了核心链路的极速响应,又避免了Redis资源被冷数据挤占。
应对雪崩与穿透的实战踩坑
迁移完成后的第一次压力测试就给了我一个下马威。模拟大促流量时,Redis CPU瞬间飙升至95%,随后大量请求超时。当时我有点懵,明明QPS才2万,怎么会这么卡?
排查日志后发现,原来是“缓存穿透”引发的连锁反应。有些恶意请求或者前端参数错误,查询了一些根本不存在的商品ID。在本地缓存时代,查不到直接走DB,因为并发低没感觉。现在虽然加了Redis,但我们在代码层面没有做空值缓存,导致每次查询不存在的数据都直接打到MySQL,瞬间打穿了数据库连接池。
坑死了,这种低级错误居然发生在生产环境重构时。
我立刻调整了策略,引入了布隆过滤器(Bloom Filter)作为第一道防线,拦截非法的Key请求。同时,对于查询结果为null的情况,我也写入Redis,但设置一个极短的过期时间(比如30秒),防止短时间内重复查询相同的不存在的数据。
```java
// 空值缓存处理
if (result == null) {
// 设置很短的过期时间,防止缓存穿透
redisTemplate.opsForValue().set(key, "", 30, TimeUnit.SECONDS);
return null;
}
```
另外,关于“缓存雪崩”的问题,我也做了针对性优化。之前所有缓存的过期时间都是硬编码的5分钟,一旦这批缓存同时失效,所有流量会瞬间涌向数据库。这次我把过期时间改成了“固定时间 + 随机抖动”。比如基础TTL是300秒,我在此基础上加上0-60秒的随机值。这样可以让缓存失效的时间分散开,避免同一时刻大量Key过期。
说实话,这次迁移花了我整整两周时间,除了改代码,大部分精力都花在调优Redis配置和监控报警规则上。客户最后跑完大促,库存准确率100%,系统零宕机,算是圆满交差。
回顾整个过程,从本地缓存到分布式Redis的迁移,看似只是技术栈的升级,实则是对系统一致性和可用性的一次深刻反思。别小看那几个配置项,在生产环境里,它们就是决定系统是稳如泰山还是瞬间崩塌的关键。
本文基于实际项目经验整理,如有技术问题欢迎私信交流。