一个平平无奇的周二下午
周三下午三点,我正在改一个老接口,数据大盘突然弹了个告警——订单列表接口 P99 飙到 3.2 秒。看着这条线我手边的咖啡差点洒了,因为这接口前两周还跑得挺欢。
打开 Grafana 一看,QPS 没涨多少,数据库连接池倒是打满了。再翻 SQL,发现这个列表接口除了查订单主表,还做了 4 张关联表的 JOIN,其中一张字典表 80 万行。MySQL 像个老实人,老老实实按这条 SQL 走了一遍嵌套循环,3 秒就这么过去了。
能优化 SQL 吗?能。该加索引加索引,该拆拆,该建冗余字段建冗余字段。但这些改动要评审、要灰度、要走变更流程,至少两天。而此刻我的 P99 还在告警,运营群里已经有人发问号了。
于是我做了一个工程师该做的决定:先把数据缓存起来。一杯咖啡还没凉,P99 回到了 30 毫秒。
为什么缓存这么管用
先别急着写代码,我们得想清楚一个问题:为什么加一层 Redis,性能就能从 3 秒降到 30 毫秒?
答案藏在两个时间差里:
- MySQL 查询:磁盘 IO + 复杂 JOIN + 锁等待,几百毫秒到几秒;
- Redis 查询:全内存 + 单线程无锁,亚毫秒级。
中间差了两个数量级。这就是缓存能"治病"的物理基础——只要数据能进内存,访问路径从"硬盘 → 内存 → CPU"压缩成"内存 → CPU",剩下的差距就是数据结构和锁竞争的工程优化了。
但这里有个隐含前提:被缓存的数据得是"读多写少"。如果我缓存的是一个每秒被改 100 次的计数器,那加 Redis 等于把瓶颈从 MySQL 搬到 Redis,治标不治本。所以第一步永远是问自己:这块数据多久变一次?
一句话法则:读多写少 → 加缓存;写多读少 → 优化写路径或走异步,别指望缓存救你。
不是什么数据都该往 Redis 塞
缓存能治病不等于什么病都该用缓存治。我见过太多团队一上 Redis 就把所有 SQL 结果都塞进去,结果缓存命中率长期 50% 以下,Redis 内存涨得比 MySQL 还快,纯属给系统添堵。
能进缓存的数据得满足三个条件,我称它们为"缓存准入三问":
1. 读频率是不是远高于写频率?
字典数据、用户资料、配置项、商品详情——这些一天改几次,但每秒读几百次,缓存价值直接拉满。反过来,订单状态、库存数字这种高频变更的,缓存只会带来一致性问题。
2. 一致性能容忍多久?
这是最容易被忽视的一问。你的业务场景能不能接受 1 秒 / 10 秒 / 1 分钟的不一致?金融账户余额必须强一致,不能缓存;商品库存可以容忍 1 秒延迟,可以缓存配短 TTL;文章阅读数可以容忍 10 分钟延迟,缓存价值巨大。
3. 数据量在不在可控范围?
Redis 是内存数据库,每条数据都要占内存。缓存一个 10 万行的报表结果集,等于一次塞进去几十 MB——这是把 Redis 当对象存储用了,很快就会撞上 maxmemory 上限。要么改结构分页缓存,要么干脆别缓存。
回到我那个订单接口——读多写少(订单半小时改一次状态,但每秒被查 200 次)、能容忍 10 秒不一致、单次结果 2KB——三个条件全中,所以缓存能救我。
TTL 不是拍脑袋定的
数据决定缓存了,下一步是 TTL(Time To Live)给多少。TTL 决定了缓存的"保质期"——过短会导致缓存频繁失效变回打 DB,过长会导致数据陈旧带来一致性问题。
我的实践经验是按数据"新鲜度敏感度"分档:
- 秒级敏感:库存、秒杀剩余名额。TTL 给 1-5 秒,或者干脆走 Lua 原子操作直接在 Redis 维护。
- 分钟级:订单状态、用户资料。TTL 给 1-10 分钟。这类数据允许短暂不一致,但用户切个页面回来还是希望看到最新。
- 小时级 / 天级:文章、字典、配置。TTL 给 1-24 小时,配合主动失效(写库后删缓存)。
我那个订单接口给的是 10 秒 TTL,因为业务方明确说"10 秒前后的差异可接受"。这个数字是和产品谈出来的,不是工程师拍脑袋。所以下次别再问"TTL 给多少合适"——先去问产品:你的用户能接受多久的不一致?
收益评估:加缓存之前先算一笔账
加缓存不是免费的。它会让系统多一个故障点、多一份运维成本、多一层一致性问题。所以动 Redis 之前,先把这笔账算清楚:
收益估算公式:
缓存命中率 H,DB 查询耗时 T_db,Redis 查询耗时 T_redis
加缓存后平均耗时 = H * T_redis + (1 - H) * (T_db + T_redis)
例:T_db=3000ms, T_redis=2ms, 命中率 90%
平均 = 0.9*2 + 0.1*(3000+2) = 1.8 + 300.2 = 302ms
→ 比原来 3000ms 提升 10 倍
命中率 99% 时
平均 = 0.99*2 + 0.01*3002 = 1.98 + 30.02 = 32ms
→ 比原来 3000ms 提升 93 倍看明白了吗?命中率每提高 10%,平均耗时下降一个数量级。所以缓存优化的核心不是把 Redis 用得多花哨,而是把命中率做到 95% 以上。这要求你充分理解访问模式——热数据集中度高,命中率就高;长尾访问多,命中率就上不去,缓存就是个鸡肋。
上线后我的订单接口实测命中率 97%,平均耗时从 3000ms 降到 92ms。剩下 3% 的未命中是订单状态变化后的第一次查询——这部分本来就该回源。
落地:从 Spring Cache 注解到手动控制
理论讲完,看一下怎么落地。Spring Boot 接 Redis 缓存最简单的姿势是Spring Cache 注解,三行配置加一个注解就能跑:
// Spring Boot 启动类加上 @EnableCaching
@EnableCaching
@SpringBootApplication
public class App { ... }
// Service 方法直接注解
@Cacheable(value = "order:list", key = "#userId + ':' + #page")
public Page<Order> listOrders(Long userId, int page) {
// 这个方法只在缓存未命中时才执行
return orderMapper.selectPage(userId, page);
}但注解模式有个坑——它的失效策略很死板。订单状态被更新后,你没办法精确地"只失效某个用户的某一页缓存",只能整组清掉。对我那个接口这是不可接受的,因为订单状态变更非常频繁,每次变更都清整组缓存,命中率会被打到 60% 以下。
所以最终我选择了手动控制:
public Page<Order> listOrders(Long userId, int page) {
String key = "order:list:" + userId + ":" + page;
Page<Order> cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached; // 命中直接返回
}
Page<Order> result = orderMapper.selectPage(userId, page);
redisTemplate.opsForValue().set(key, result, Duration.ofSeconds(10));
return result;
}
// 订单状态变更时主动失效
public void updateOrderStatus(Long orderId, String status) {
orderMapper.updateStatus(orderId, status);
// 只删该用户相关的缓存键,避免整组清理
Long userId = orderMapper.selectUserIdByOrderId(orderId);
Set<String> keys = redisTemplate.keys("order:list:" + userId + ":*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}这里有个工程取舍:keys 命令在大 key 空间下会阻塞 Redis,生产环境一般要用 SCAN 替代。我这里因为用户订单列表最多几十页,模糊匹配的开销可以接受。如果是大规模系统,应该用更精细的缓存键设计,比如维护一个"用户 → 缓存键集合"的反向索引,这部分我们后面专门开一篇讲。
一个容易被忽视的坑:缓存序列化
最后说一个让我吃过亏的细节——序列化。Spring Boot 默认用 JDK 序列化存 Redis,结果存进去的对象在 Redis 里看起来是一串乱码,DEBUG 时用 redis-cli 完全看不懂;而且 JDK 序列化的对象一旦改了字段,旧缓存就反序列化失败。
解决方案:换成 JSON 序列化器。
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}换完之后 redis-cli 里能直接读出 JSON,DEBUG 体验提升一个数量级。这一步看起来小,但会大幅降低你后续排查缓存问题的成本——毕竟排查问题 80% 的时间花在"这玩意儿到底存了什么"上。
小结
这一篇我们没讲什么高深的分布式锁、也没讲 RedLock 争议,而是从一个真实的接口优化出发,把缓存这件事的决策框架梳理了一遍:
- 数据准入:读多写少、容忍不一致、数据量可控,三条全中才上缓存。
- TTL:按业务能容忍的不一致窗口来定,不是拍脑袋,是和产品谈出来的。
- 收益评估:命中率每提高 10%,平均耗时下降一个数量级。优化的核心是命中率,不是花哨。
- 落地:注解能简单搞定就用,需要精细控制就手动写。别忘了换 JSON 序列化器。
下一篇文章,我会聊缓存三大模式——Cache Aside、Read/Write Through、Write Behind。为什么大家都说 Cache Aside 是默认选择,但 MySQL 和 Redis 的配合又埋了那么多坑?等你看完那一篇,就明白"先更新库还是先删缓存"这种经典面试题背后的真实工程权衡。
咖啡还没凉,我们下篇见。
评论 (0)