4、缓存登场:Redis 是如何拯救我的慢查询接口的。

说实话,做架构最怕什么?

不是系统崩了,而是接口慢。用户点一下,转圈转半天。老板站你身后问:「这页面怎么还没出来?」

我遇到过最夸张的一次,一个报表查询接口,跑了 12 秒。12 秒啊,用户早把浏览器关了。那会儿我就在想,能不能把那些「每次都要算半天」的数据,存到一个更快的地方去?

嗯,这就是缓存要干的事。

4.1 慢查询的根源:数据库扛不住了

先说说为什么接口会慢。

大多数 Web 应用的瓶颈,都在数据库。你想想看,每次请求都要查表、做 JOIN、聚合计算。数据量一上来,磁盘 I/O 就成了短板。

我见过一个典型的慢查询:

SELECT 
    u.name,
    COUNT(o.id) as order_count,
    SUM(o.amount) as total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id
ORDER BY total_amount DESC
LIMIT 100;

这个查询在 10 万用户、100 万订单的表上,跑了 3.8 秒。用户每次刷新页面都要等 3.8 秒,体验极差。

核心问题:数据库的磁盘读写速度,和内存相比差了 10 万倍。每次查询都要走磁盘,能不慢吗?

4.2 缓存是什么?说白了就是「存一份快的」

缓存的概念很简单:把经常查的数据,放到一个读取更快的地方。

数据库查一次要 3 秒,Redis 查一次只要 1 毫秒。差了 3000 倍。你想想看,如果能把那 3 秒的查询结果存到 Redis 里,下次直接读 Redis,是不是就快多了?

我个人习惯把缓存分为三个层次:

缓存层级 存储位置 读取速度 典型场景
本地缓存 应用服务器内存 纳秒级 配置信息、字典数据
分布式缓存 独立缓存服务器 毫秒级 用户会话、热点数据
数据库 磁盘 秒级 持久化数据、事务操作

Redis 属于分布式缓存。它把数据存在内存里,所以快。但它也支持持久化,不会一断电就全丢。

4.3 Redis 是怎么做到的?

Redis 快,有三个原因:

  • 纯内存操作:数据都在内存里,没有磁盘 I/O 的等待
  • 单线程模型:避免了多线程的锁竞争和上下文切换
  • 高效的数据结构:String、Hash、List、Set、Sorted Set 都是精心设计的

我举个例子。刚才那个慢查询,如果用 Redis 来优化,怎么做?

第一步:查询结果存到 Redis 里。

// 查询数据库
List<UserStat> stats = userMapper.getUserStats();

// 序列化成 JSON 字符串
String json = JSON.toJSONString(stats);

// 存入 Redis,设置过期时间 5 分钟
redisTemplate.opsForValue().set("user:stats:top100", json, 5, TimeUnit.MINUTES);

第二步:下次请求先查 Redis。

// 先从 Redis 查
String cached = redisTemplate.opsForValue().get("user:stats:top100");

if (cached != null) {
    // 缓存命中,直接返回
    return JSON.parseArray(cached, UserStat.class);
}

// 缓存没命中,查数据库
List<UserStat> stats = userMapper.getUserStats();

// 写回缓存
String json = JSON.toJSONString(stats);
redisTemplate.opsForValue().set("user:stats:top100", json, 5, TimeUnit.MINUTES);

return stats;

小技巧:过期时间设多久?我一般设 5 分钟。太短了缓存效果差,太长了数据可能不新鲜。具体要看业务容忍度。

优化之后,接口响应时间从 3.8 秒降到了 12 毫秒。用户点一下,瞬间就出来了。

4.4 缓存策略:不是所有数据都适合缓存

嗯,这里要注意。缓存不是万能的。有些数据不适合缓存:

  • 频繁更新的数据:比如股票价格,每秒都在变。缓存刚写进去就过期了,反而浪费资源
  • 数据量太大的数据:比如全量用户日志。内存比磁盘贵多了,存不起
  • 强一致性的数据:比如银行账户余额。缓存和数据库不一致会出大问题

那什么样的数据适合缓存?

  • 读多写少:比如商品详情、用户资料
  • 计算复杂:比如报表统计、排行榜
  • 热点数据:比如首页推荐、热门文章

避坑指南:我曾经在一个项目里,把所有数据都塞进 Redis。结果 Redis 内存爆了,OOM 导致整个缓存服务挂了。后来才明白,缓存不是存储,它只是加速器。该存数据库的,还是得存数据库。

4.5 缓存穿透、击穿、雪崩:三个经典问题

用缓存不是一劳永逸的。有三个坑,你早晚会遇到。

4.5.1 缓存穿透

什么意思?就是请求的数据在数据库里也不存在。每次请求都绕过缓存,直接查数据库。

比如查一个不存在的用户 ID。Redis 里没有,数据库里也没有。每次请求都白查一次数据库。

解决方案:

// 查数据库,没找到
User user = userMapper.findById(userId);
if (user == null) {
    // 把空值也缓存起来,过期时间短一点
    redisTemplate.opsForValue().set("user:" + userId, "NULL", 1, TimeUnit.MINUTES);
    return null;
}

4.5.2 缓存击穿

一个热点 key 突然过期了。这时候大量请求同时涌进来,全部打到数据库上。

我遇到过双十一的时候,某个爆款商品的详情页缓存过期了。瞬间几千个请求打到数据库,差点把数据库打崩。

解决方案:

// 使用互斥锁,只让一个请求去查数据库
String lockKey = "lock:user:stats:top100";
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(locked)) {
    try {
        // 查数据库
        List<UserStat> stats = userMapper.getUserStats();
        // 写回缓存
        redisTemplate.opsForValue().set("user:stats:top100", JSON.toJSONString(stats), 5, TimeUnit.MINUTES);
        return stats;
    } finally {
        // 释放锁
        redisTemplate.delete(lockKey);
    }
} else {
    // 没拿到锁的请求,等一会儿再重试
    Thread.sleep(100);
    return getUserStats(); // 递归重试
}

4.5.3 缓存雪崩

大量 key 在同一时间过期。或者 Redis 服务挂了。所有请求都打到数据库上。

这比击穿更可怕。击穿只是一个 key,雪崩是一大片。

解决方案:

  • 过期时间加随机值:避免大量 key 同时过期
  • Redis 高可用:主从复制、哨兵模式、集群模式
  • 本地缓存兜底:Redis 挂了,还有本地缓存顶着
// 过期时间加随机值,避免雪崩
int baseExpire = 300; // 5分钟
int randomExpire = new Random().nextInt(60); // 随机0-60秒
redisTemplate.opsForValue().set(key, value, baseExpire + randomExpire, TimeUnit.SECONDS);

4.6 实战:用 Redis 优化一个真实接口

最后,我分享一个真实的优化案例。

之前做一个电商后台,有个「今日热销榜单」接口。每次请求都要统计当天所有订单,按销量排序。数据量大概 50 万条订单,接口响应时间 6 秒。

优化方案:

  1. 定时任务预热:每 5 分钟跑一次统计,结果存到 Redis 的 Sorted Set 里
  2. 接口直接读缓存:从 Sorted Set 里取前 100 名
  3. 兜底策略:如果 Redis 里没有数据,才去查数据库
// 定时任务:每5分钟更新一次热销榜单
@Scheduled(fixedRate = 300000)
public void refreshHotRank() {
    // 查数据库,统计今日销量
    List<ProductSales> salesList = orderMapper.getTodaySales();
    
    // 清空旧的榜单
    redisTemplate.delete("rank:hot:today");
    
    // 写入 Sorted Set,销量作为 score
    for (ProductSales sales : salesList) {
        redisTemplate.opsForZSet().add(
            "rank:hot:today", 
            sales.getProductId(), 
            sales.getSalesCount()
        );
    }
}

// 接口:获取热销榜单
public List<ProductVO> getHotRank() {
    // 从 Sorted Set 取前100名
    Set<Object> top100 = redisTemplate.opsForZSet()
        .reverseRange("rank:hot:today", 0, 99);
    
    if (top100 == null || top100.isEmpty()) {
        // 兜底:查数据库
        return getHotRankFromDB();
    }
    
    // 根据 productId 查商品详情
    return productService.getByIds(top100);
}

优化后,接口响应时间从 6 秒降到了 30 毫秒。而且因为用了定时任务预热,用户永远读到的是「5 秒前」的数据,新鲜度完全够用。

总结一下:缓存不是银弹,但它确实是解决慢查询最有效的手段之一。用好 Redis,你的接口响应时间能从秒级降到毫秒级。但记住,缓存策略要因地制宜,别一股脑全塞进去。

下一章,我们会聊聊另一个经典问题:当用户量上来之后,单机 Redis 扛不住了怎么办?嗯,那就是 Redis 集群和高可用的故事了。