连载中 1/16

为什么你的接口要 3 秒?我把 SQL 打进 Redis 后降到 30 毫秒

2026-05-15 · 8743 阅读 · 20 评论 · 294 赞

一个平平无奇的周二下午

周三下午三点,我正在改一个老接口,数据大盘突然弹了个告警——订单列表接口 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 争议,而是从一个真实的接口优化出发,把缓存这件事的决策框架梳理了一遍:

  1. 数据准入:读多写少、容忍不一致、数据量可控,三条全中才上缓存。
  2. TTL:按业务能容忍的不一致窗口来定,不是拍脑袋,是和产品谈出来的。
  3. 收益评估:命中率每提高 10%,平均耗时下降一个数量级。优化的核心是命中率,不是花哨。
  4. 落地:注解能简单搞定就用,需要精细控制就手动写。别忘了换 JSON 序列化器。

下一篇文章,我会聊缓存三大模式——Cache Aside、Read/Write Through、Write Behind。为什么大家都说 Cache Aside 是默认选择,但 MySQL 和 Redis 的配合又埋了那么多坑?等你看完那一篇,就明白"先更新库还是先删缓存"这种经典面试题背后的真实工程权衡。

咖啡还没凉,我们下篇见。

503

10 年全栈工程师 · 503咖啡馆主理人

#Redis#缓存#性能优化#实战

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 5 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞