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 秒。
优化方案:
- 定时任务预热:每 5 分钟跑一次统计,结果存到 Redis 的 Sorted Set 里
- 接口直接读缓存:从 Sorted Set 里取前 100 名
- 兜底策略:如果 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 集群和高可用的故事了。