前言:不止是“活下来”,我们想“跑得更快”

大家好,我又来交作业了。

在上一篇**《数据库篇》**里,我们通过一系列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)

这里最大的一个疑问是:为什么是删除缓存,而不是更新缓存?

因为“更新缓存”可能会有并发问题。想象一下这个场景:

  1. 线程A更新数据库。

  2. 线程B更新数据库(一个更新的值)。

  3. 线程B更新缓存。

  4. 线程A更新缓存。

此时,数据库里是线程B的最新值,而缓存里却是线程A的旧值,数据就不一致了。而“删除缓存”可以极大地降低这种并发冲突的概率。下次读请求进来时,发现缓存没有,会自然地去数据库加载最新值,这个过程是“懒加载”,也保证了缓存里不会有非热点的数据。

总结:缓存是“蜜糖”,也是“砒霜”

引入缓存,让我们的系统性能迈上了一个新台阶,API响应如丝般顺滑,数据库也终于可以“高枕无忧”。

但这次的经历也让我们明白,缓存绝不是一个可以随意使用的“银弹”。它在提升性能的同时,也带来了系统复杂度的急剧增加:你需要考虑高可用、数据一致性、各种异常情况。用好它,是蜜糖;用不好,就可能成为引发系统雪崩的砒霜。

至此,我们的性能优化之旅的核心部分就告一段落了。从数据库的深度优化,到缓存的精细化使用,我们为系统构建了坚固的“两层防御体系”。当然,优化的道路永无止境,接下来还有异步化、消息队列等更多的故事。

如果这篇分享对你有帮助,别忘了点赞收藏!也欢迎大家分享你们在缓存使用中踩过的坑!

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐