从瓶颈到高速公路:一次微服务性能优化的深度复盘(缓存篇)
前言:不止是“活下来”,我们想“跑得更快”
大家好,我又来交作业了。
在上一篇**《数据库篇》**里,我们通过一系列SQL和索引的优化,成功将数据库从崩溃的边缘拉了回来,API响应时间也回归到了一个可接受的范围。可以说,系统“活下来”了。
但对于一个有追求的技术团队来说,仅仅“活着”是远远不够的。随着业务的增长,我们预见到未来的读请求压力会越来越大。数据库作为我们最宝贵的“核心资产”,应该专注于处理核心的写事务和复杂的查询,而不是疲于应对海量的、重复的读请求。
如何给数据库套上一个“金钟罩”,让它能轻松应对未来的挑战?答案不言而喻——缓存。
我们选择了业界主流的Redis作为缓存中间件。然而,引入缓存看似简单,只是在读数据前加一层判断,但真正实践起来,才发现里面的“坑”一点也不比数据库少。
深坑一:缓存的“经典三连问”
刚上线缓存时,效果确实不错,热门接口的响应速度直接进入了10毫秒级别。但好景不长,在一次营销活动中,我们遭遇了缓存引入后的第一次“大考”,也直面了传说中的“经典三连问”。
现象: 监控发现,有大量的请求涌入,查询的都是一些根本不存在的商品ID。这些请求每次都会“穿透”缓存层,直接打到数据库,导致数据库压力陡增。这很可能是恶意攻击,也可能是前端的bug。
分析: 缓存穿透的核心是查询了一个不存在的数据。正常的逻辑是:缓存没有,查数据库,数据库也没有,然后返回空。下一次同样的请求进来,依然是这个循环,缓存层形同虚设。
解决方案:
我们采用了最简单有效的一种方式:缓存空值(Cache Nulls)。
code Java
// 伪代码
public Product getProduct(Long id) {
// 1. 从缓存获取
String productJson = redis.get("product:" + id);
// 命中缓存
if (productJson != null) {
// 如果缓存的是"空值"标记,直接返回null
if ("EMPTY".equals(productJson)) {
return null;
}
return JSON.parse(productJson, Product.class);
}
// 2. 缓存未命中,查询数据库
Product productFromDB = productDao.selectById(id);
// 3. 数据库存在,写入缓存
if (productFromDB != null) {
redis.setex("product:" + id, 3600, JSON.toJSONString(productFromDB)); // 缓存1小时
return productFromDB;
} else {
// 4. 数据库也不存在,缓存一个"空值"标记,并设置较短的过期时间
redis.setex("product:" + id, 300, "EMPTY"); // 缓存5分钟
return null;
}
}
当数据库查询结果为空时,我们依然向Redis写入一个特殊的标记值(比如"EMPTY"),并设置一个较短的TTL。这样,后续对同一个ID的查询就会直接命中缓存的“空值”,从而保护了数据库。
现象: 在秒杀活动开始的瞬间,某个爆款商品因为缓存刚巧失效,导致成千上万的并发请求在同一时刻全部打向数据库,数据库瞬间“假死”。
分析: 缓存击穿是指单个热点Key在高并发下,缓存失效的瞬间,大量请求直接访问数据库。它和穿透的区别在于,这个Key是真实存在的,只是缓存过期了。
解决方案:
使用互斥锁(Mutex Lock)。当缓存未命中时,我们不让所有线程都去查数据库,而是只让第一个线程去查,其他线程等待结果。
code Java
// 伪代码,使用Redis的SETNX作为分布式锁
public Product getProductWithMutex(Long id) {
// 1. 从缓存获取
String productJson = redis.get("product:" + id);
if (productJson != null) { ... } // 逻辑同上
// --- 缓存未命中,开始加锁 ---
String lockKey = "lock:product:" + id;
try {
// 2. 尝试获取锁
boolean locked = redis.setnx(lockKey, "1", 10); // 尝试加锁,10秒后自动释放防死锁
if (locked) {
// 3. 获取锁成功,去数据库查询
Product productFromDB = productDao.selectById(id);
// ... 查询后写入缓存 ...
redis.setex("product:" + id, 3600, JSON.toJSONString(productFromDB));
return productFromDB;
} else {
// 4. 获取锁失败,说明有其他线程在查询,休眠一会再重试
Thread.sleep(50); // 休眠50ms
return getProductWithMutex(id); // 递归或循环重试
}
} finally {
// 5. 释放锁
redis.del(lockKey);
}
}
注意: 实际应用中,还需要在获取锁成功后,进行一次“Double Check”,再次查询缓存,因为可能在你等待锁的过程中,前面的线程已经把缓存写好了。
现象: 某一个下午,Redis集群的一个主节点突然宕机,导致该节点上的所有缓存全部失效。几乎所有的读请求都瞬间涌向了数据库,整个系统陷入瘫痪。
分析: 缓存雪崩是指大规模的Key在同一时间失效,或者缓存服务本身不可用,导致请求全部打到数据库。
解决方案:
-
针对Key同时失效:在设置缓存TTL时,增加一个随机值。例如,基础过期时间是1小时,那么最终的过期时间可以是 3600 + Random.nextInt(600),让过期时间分散开。
-
针对缓存服务不可用:
-
构建高可用的Redis集群:使用哨兵(Sentinel)或Cluster模式,确保一个节点宕机后能自动故障转移。
-
做好服务降级和熔断:在应用层,当检测到Redis不可用时,可以暂时关闭非核心业务的读缓存逻辑,直接返回一个兜底数据或者提示,保证核心业务不受影响。
-
深坑二:数据一致性的“灵魂拷问”
解决了高并发下的缓存稳定性问题后,我们迎来了另一个终极拷问:如何保证数据库和缓存的数据一致性?当商品价格、库存等信息更新后,如何确保用户看到的是最新的数据?
我们最终采用了业界最经典也最常用的模式——旁路缓存模式(Cache-Aside Pattern)。
核心思想:
-
读操作:先读缓存,缓存命中则直接返回。缓存未命中,则读数据库,然后将数据写入缓存,再返回。
-
写操作:先更新数据库,再删除缓存(Delete Cache)。
这里最大的一个疑问是:为什么是删除缓存,而不是更新缓存?
因为“更新缓存”可能会有并发问题。想象一下这个场景:
-
线程A更新数据库。
-
线程B更新数据库(一个更新的值)。
-
线程B更新缓存。
-
线程A更新缓存。
此时,数据库里是线程B的最新值,而缓存里却是线程A的旧值,数据就不一致了。而“删除缓存”可以极大地降低这种并发冲突的概率。下次读请求进来时,发现缓存没有,会自然地去数据库加载最新值,这个过程是“懒加载”,也保证了缓存里不会有非热点的数据。
总结:缓存是“蜜糖”,也是“砒霜”
引入缓存,让我们的系统性能迈上了一个新台阶,API响应如丝般顺滑,数据库也终于可以“高枕无忧”。
但这次的经历也让我们明白,缓存绝不是一个可以随意使用的“银弹”。它在提升性能的同时,也带来了系统复杂度的急剧增加:你需要考虑高可用、数据一致性、各种异常情况。用好它,是蜜糖;用不好,就可能成为引发系统雪崩的砒霜。
至此,我们的性能优化之旅的核心部分就告一段落了。从数据库的深度优化,到缓存的精细化使用,我们为系统构建了坚固的“两层防御体系”。当然,优化的道路永无止境,接下来还有异步化、消息队列等更多的故事。
如果这篇分享对你有帮助,别忘了点赞收藏!也欢迎大家分享你们在缓存使用中踩过的坑!
更多推荐



所有评论(0)