☰
数据服务性能调优:从慢SQL到缓存的全链路诊断
2026/10/5 7:46:13 网站建设 项目流程

那段时间我印象很深:线上一个查询类数据服务的接口 P99 从 50 毫秒直接飙到 2 秒多,流量并没有明显变化,数据库 CPU 却一直在 90% 以上打转。排障的时候发现,大家都在争论到底是 SQL 写得有问题,还是缓存策略早就失效了。后来把问题拆开才意识到,数据服务性能调优这件事,从来不是"要么优化 SQL,要么优化缓存"的二选一,而是一条从 SQL 到缓存的全链路诊断过程。这篇文章就围绕这条链路展开:先讲怎么定位瓶颈,再讲慢 SQL 的执行计划怎么读,接着是缓存层那些比 SQL 更隐蔽的坑,最后聊怎么把调优动作沉淀进日常迭代里。主要面向做后端开发、维护数据服务的同学,也适合准备面试时想拿实战案例说话的人。

1. 先分清故障层:SQL慢还是缓存拖累了服务

1.1 用三层拆解法定位真正的瓶颈

很多人一接到性能告警,第一反应是打开慢 SQL 日志,或者直接看代码。这其实顺序反了。正确的第一步,是先搞清楚请求的时间到底消耗在哪一层。

我习惯把一条数据服务的请求链路拆成三层:网络传输层、应用逻辑层、数据存储层。数据存储层又可以继续拆成数据库和缓存两个支线。定位时用"RT 分层"来观察,而不是凭感觉猜。

具体做法是,先看整体监控里的 QPS 和 TP99,再把同一个接口的耗时按网关注入、应用内方法调用、数据库查询、缓存读写做拆分。如果你没有接入链路追踪系统,最朴素的办法是在应用日志里打时间戳,或者在数据库慢日志里看查询耗时,再对比缓存中间件的响应时间。

举个我们当时的数据:接口整体耗时 2200ms,其中应用内纯 Java 代码逻辑只有 120ms,Redis 读写平均 2ms,MySQL 查询平均却占了 1900ms。这就是很明显的数据库侧瓶颈。反过来,如果数据库耗时 30ms,Redis 平均耗时 400ms,那你优化 SQL 根本没用,问题在缓存的 key 设计或 IO 阻塞上。

这种拆解法听起来很简单,但实际排障时容易乱,因为大家经常被表象带偏。比如数据库 CPU 高,第一反应确实会想到 SQL 问题,但也可能是缓存大批量过期后,所有请求同时穿透到数据库,把数据库打爆了。所以看监控时不要只看数据库指标,还要看缓存命中率曲线。如果 Redis 的 keyspace_misses 在故障时间点同时飙升,那瓶颈大概率在缓存侧,SQL 只是背了锅。

1.2 没有基线数据,所有调优都是空谈

每次排障结束后,团队里常犯的毛病是:内存里调了一个参数,觉得快了,就直接上线。其实在数据服务性能调优里,最忌讳的就是没有对照组。

我建议每个核心数据服务都维护一份性能基线,至少包括这几个指标:每天固定时间段的接口平均耗时、TP99、数据库 QPS、慢查询数量、缓存命中率。不需要多复杂的平台,用脚本定期采集就行。

举个采集基线数据的例子,比如统计 MySQL 慢查询的数量变化,可以定期抓 slow log:

grep -c "Query_time:.*" $(mysql -N -e "show variables like 'slow_query_log_file'" | awk '{print $2}')

Redis 的命中率可以直接从 INFO 里算:

redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"

拿到这两组数,再结合应用监控,你就能建立一个简单的"优化前 vs 优化后"对比坐标系。更重要的是,我建议一次调优只改一个变量。比如你今天既改了 SQL 索引,又调了缓存过期时间,还动了连接池大小,那最后性能提升到底归功于哪一步,你根本说不清,后续遇到问题也无法复用经验。这是很多团队调优反复折腾却无法沉淀方法论的核心原因。

2. 从慢SQL日志和执行计划里挖出真凶

2.1 慢SQL日志正确读法:Rows_examined 比执行时间更值钱

慢 SQL 日志大家都会开,但很多人只盯着 Query_time。我会先教团队看两个字段:Rows_examined 和 Rows_sent。Rows_examined 是这条 SQL 扫描过的行数,Rows_sent 是最终返回的行数。如果 Rows_examined 是 500 万,Rows_sent 只有 20,说明存储引擎翻遍了 500 万行,最后只给你留下 20 条,这种 SQL 不管执行时间当前看起来是否超过阈值,都迟早会出事。

另一个很实用的排查工具是 SHOW FULL PROCESSLIST。慢日志是事后看的,但线上数据库 CPU 正在飙升时,你需要马上知道当前哪几条 SQL 在火上浇油。执行:

SHOW FULL PROCESSLIST; -- 重点关注 Time 字段大的会话,以及 State 字段是否为 Sending data / Sorting result

看到长时间 Sending data 的查询,基本可以判断它在做大量的行读取和过滤,下一步就是抓出来做 EXPLAIN。

MySQL 8.0 及以上版本,可以直接用 EXPLAIN ANALYZE 拿到一条 SQL 每个阶段实际消耗的时间,这比传统 EXPLAIN 只看预估 rows 要直观得多。团队里很多人第一次用的时候都不太适应,因为传统 EXPLAIN 只给估算值,而 ANALYZE 会真实执行一遍并把每一步的具体时间打出来。注意它是会真实执行 SQL 的,所以在大表上要小心使用,可以在测试环境或者事务里配合回滚来用。

2.2 三种最常见的 SQL 性能事故:索引失效、深分页、隐式转换

在实际业务里,能打垮数据服务的 SQL 事故基本就那几类,这里展开说一下:

索引失效,最典型的场景是查询条件里对索引列做了函数运算。举个例子,某订单表的 createdAt 列建了索引,但查询写的是:

SELECT * FROM orders WHERE DATE(created_at) = '2025-01-15';

DATE() 函数套在索引列上,优化器就没法走索引,只能全表扫描。正确写法是改成范围查询:

SELECT * FROM orders WHERE created_at >= '2025-01-15 00:00:00' AND created_at < '2025-01-16 00:00:00';

这个改动的原理就是保持索引列本身干净,让 B+ 树的二分查找能够生效。

深分页,是 LIMIT 写法里最坑的一种。业务端做分页经常用 LIMIT 1000000, 20,这句话在 MySQL 里要先扫描出前 100 万行,再把它们全部丢进临时结果集,最后只拿第 1000001 到 1000020 行返回。越往后翻页,扫描量越大。更合理的做法是用延迟关联,先走覆盖索引取最小主键集合,再回表拿完整数据:

SELECT t.id, b.title, a.content FROM ( SELECT id FROM orders WHERE user_id = ? AND status = 1 ORDER BY create_time DESC LIMIT 1000000, 20 ) t JOIN orders a ON a.id = t.id JOIN products b ON b.id = a.product_id;

这个写法的关键在于子查询里只查主键 id,排序也只需要用索引,彻底避开回表和大字段的传输。如果业务上允许游标分页,也就是记录上一页最后一条数据的 id,再用 WHERE id > last_id 取下一页,性能还会更好,只是不能随意跳页码了。

隐式转换是很多慢 SQL 的隐形推手。比如手机号字段在库里是 varchar 类型,查询条件却传了数字:

SELECT * FROM users WHERE phone = 13812345678;

MySQL 会在比较时把字符串和数字都转成浮点数,导致索引列被隐式函数包裹,查询走不了索引。这种问题往往靠肉眼很难发现,需要盯执行计划里的 type 是不是从 ref 变成了 ALL。

我在实际业务里还会遇到一类去重查询的问题。热搜词里"SQL 语句去重"出现频率非常高,很多人习惯用 SELECT DISTINCT,但如果去重的字段本身没有索引,MySQL 会产生临时表。假设你有一张 800 万行的用户标签表,想统计所有出现过的标签:

SELECT DISTINCT tag_name FROM user_tags;

如果 tag_name 没有索引,这条 SQL 会把全表数据捞进内存做排序去重,内存不够还要落盘临时表,堪称性能杀手。可以先给 tag_name 建一个二级索引,让索引本身就保证有序,DISTINCT 就能顺着索引顺序直接取一遍,避免临时表排序。或者把单列 DISTINCT 改成 GROUP BY,在慢日志里的表现往往也好一些,因为优化器对 GROUP BY 的分组下推处理更成熟。

2.3 一次 SQL 改写的前后对比:关注执行计划里的 type 和 Extra

很多刚入门的朋友会把 SQL 优化当成背模板,什么"不要用 SELECT *"、"WHERE 条件放最左侧"这类口诀背了一堆,但一遇到线上问题还是不会分析。我提供一个标准动作:写任何一条 SQL 都养成执行 EXPLAIN 的习惯,并且只看几个关键列。

列名重点关注含义
typeconst / eq_ref / ref / range / index / ALL访问类型,ALL 是全表扫描,range 及以下基本需要优化
key实际选中的索引NULL 说明没用到索引
rows预估扫描行数越大越危险,结合真实行数判断
ExtraUsing filesort / Using temporary / Using indexfilesort 表示排序没用上索引,temporary 表示用了临时表,Using index 是覆盖索引的加分项

我之前处理过一个真实案例。业务需求是根据用户 ID 查最近 20 条带商品信息的订单列表,初始 SQL 长这样:

SELECT o.id, o.order_no, p.title, o.pay_amount FROM orders o JOIN products p ON o.product_id = p.id WHERE o.user_id = 10086 AND o.status = 1 ORDER BY o.create_time DESC LIMIT 20;

这条 SQL 看着挺正常,但 EXPLAIN 的结果显示 orders 表走的是索引扫描 type=ref,预估 rows 有 3 万多,Extra 里还有 Using filesort。原因在于 order by create_time 这个排序字段不在 user_id 和 status 组合成的联合索引里,MySQL 需要先取到 3 万多条数据再额外做文件排序。

后来我把联合索引改成 (user_id, status, create_time),同一个 EXPLAIN 的 Extra 里不再出现 Using filesort,rows 降到 6000 多,接口从 890ms 掉到了 55ms。整个优化过程中没有改任何业务代码,只是让索引的结构刚好覆盖了 where 过滤和 order by 排序两个需求。

这类"组合索引设计"的思维,比单纯背一句"给 WHERE 字段加索引"价值大得多。因为一条 SQL 的执行链路是:先通过索引定位到满足条件的行,再把需要排序的字段按索引顺序取出,最后回表拿完整数据。你的索引字段顺序设计得越贴近查询模式,存储引擎的每一步就越省力。

3. 缓存层的问题比SQL更隐蔽

3.1 缓存命中率上不去,先怀疑 key 设计和数据粒度

优化完 SQL 之后,很多数据服务的性能瓶劲会转移到缓存层。缓存问题隐蔽在,它不是直接报错的,而是让你感觉接口变慢但又找不到原因。最常见的一个坑是缓存命中率长期低于预期。

怎么量化命中率?用 Redis 自带的指标就可以:

redis-cli INFO stats | grep keyspace # keyspace_hits: 89123 # keyspace_misses: 8827

命中率 = keyspace_hits / (keyspace_hits + keyspace_misses)。读多写少、访问相对均衡的业务,命中率长期低于 90% 就要警惕了。我从实际项目里复盘过,命中率上不去的原因通常不是缓存容量不够,而是 key 设计不合理。

一种典型问题是缓存粒度太粗。比如把整个首页接口返回的 JSON 包当作一个 key 来缓存,但业务里这个 JSON 中只有顶部轮播图部分频繁变化,导致运营每次改内容都要失效整个缓存,用户请求把整包数据重新回源一次数据库。这种场景应该把动态区块和静态区块拆成两个缓存 key,动态部分短过期时间,静态部分长过期时间,命中率立刻能提上来。

另一种典型问题是缓存 value 太大。有些同学为了方便,把数据库一行所有的列都塞进 Redis,里面可能有个大字段存的是几千字的描述文本。结果就是每次取缓存都发生大流量传输,Redis 的带宽先被打满,延迟自然就上去了。合理做法是只缓存业务真正高频使用的字段,大字段仍然走数据库,或者单独做压缩存储。

如果访问热度非常集中,也就是少数几个 key 占据绝大多数流量,光有 Redis 还不够。我会在应用进程内再放一层本地缓存,用 Caffeine 或者 Guava Cache 做 L1,Redis 做 L2。这样热点数据在大部分情况下直接命中进程内存,连网络 IO 都省了。但要注意,本地缓存和 Redis 之间的过期时间必须错开,不然会变成所有节点同时回源 Redis 甚至数据库。另外也要提醒一句:像 MyBatis 这种 ORM 框架自带的二级缓存,如果业务里已经接入了 Redis 作为统一缓存,一般不建议再开启 ORM 二级缓存。因为两级缓存的失效时机不同,数据一致性验证的成本远大于它带来的那点性能收益。

3.2 缓存穿透、击穿、雪崩:三个完全不同的故障场景

这三兄弟经常被混在一起说,但它们的成因和应对策略完全不一样,我用实际场景逐个拆开:

缓存穿透,指的是请求查询的数据压根不存在于任何一层。比如用户 A 假装用一个不存在的订单 ID 疯狂请求,每次缓存都查不到,请求直接打到数据库。这就是缓存没拦住流量。应对穿透最稳妥的办法是两层方案:一层是参数合法性校验,把明显不存在的 ID 挡在入口处;另一层是缓存空值,也就是虽然数据库查不到结果,也把"空结果"以短过期时间(比如 60 秒)存进缓存,让后续同样请求命中缓存。如果接口对外的 key 空间很大,还可以用布隆过滤器在缓存之前做一次快速判断,会省下大量 Redis 访问。

缓存击穿,指的是某一个热点 key 在过期的瞬间发生高并发访问,所有请求同时回源数据库。比如某个爆款商品的详情数据,平时几万 QPS 都在读缓存,凌晨缓存过期的那一刻,请求像洪水一样涌向数据库。处理思路有两个:第一是逻辑过期,即缓存的 value 里存一个过期时间,应用层发现逻辑过期后,只有一个线程能拿到分布式锁去数据库刷新缓存,其他线程先返回旧值。第二是互斥锁,用 Redis 的 SETNX 保证同一时刻只有一个请求回源数据库。

我给你一个互斥锁的伪代码,业务里可以直接参考:

String data = redis.get(key); if (data == null) { boolean locked = redis.setIfAbsent("lock:" + key, "1", Duration.ofMillis(500)); if (locked) { try { data = db.query(...); redis.set(key, data, Duration.ofMinutes(30)); } finally { redis.del("lock:" + key); } } else { // 其他请求先休眠一小会儿,再读一次缓存 Thread.sleep(50); data = redis.get(key); } }

这段代码的精髓在于:拿到锁的线程负责回源更新缓存,没拿到锁的线程不直接压到数据库,而是等锁释放后再读缓存。50ms 的休眠时间是经验值,太短会导致大量线程立刻重试,太长会增加接口耗时,可以根据实际业务压测调整。

缓存雪崩,是指大量 key 在同一时间段内集中过期,导致大部分缓存同时失效,请求全部打到数据库。这个问题的经典解法是在设置 TTL 时加随机抖动,比如本来都是 30 分钟过期,改成 30 分钟加一个 0 到 300 秒的随机值,避免所有 key 手拉手一起消失。另外也可以让缓存分为多套过期周期,或者做多级缓存降级。雪崩一旦发生,单靠 TTL 调整往往来不及,所以一定要有预案:数据库连接池限流、接口降级开关、优先保证核心链路可用。

这里我要特别强调一点:缓存这三个问题和前面说的慢 SQL 不是孤立的两件事。很多时候线上事故是慢 SQL 优化做到一半,你发现有缓存兜底,就降低了警惕,结果缓存一出问题,所有 SQL 问题加倍放大。缓存治理的本质是把访问压力在中间件层面做缓冲,但绝不能替不健康的数据访问辩护。

3.3 缓存一致性:没有银弹,只有最终一致

缓存和数据库的双写一致性问题,是数据服务性能调优里绕不过去的坎。先说一个现实结论:没有一套方案既能保证强一致又有高性能。如果你的业务真的要强一致,最稳妥的办法就是不读缓存,直接查数据库。而绝大多数读多写少场景,我们追求的是最终一致,也就是允许在极短时间窗口内读到旧数据。

主流的方案是 Cache Aside 模式,也就是先更新数据库,再删除缓存。更新数据库后,缓存被删掉,下一次读请求就会回源数据库并重新加载缓存。这个方案之所以比"先更新缓存"强,是因为它避免了并发写时缓存里留下旧值的问题。删除缓存后即使有并发读,读到的也只是短暂的空窗期,数据最终还会被重新加载成最新值,最终一致性能保证。

在并发要求更高的场景下,可以在删除缓存前做一次延迟双删。也就是更新数据库后先删除缓存,睡眠几百毫秒,再删一次缓存。这多出来的一次删除,是为了清掉那些在第一次删除前读到了旧值、并且正在回写缓存的并发请求。但这套方案有个显而易见的缺点:睡眠等待是耗时操作,不适合放在同步调用链路里,实际项目里我一般会用消息队列异步做第二次删除。

现在很多团队会引入基于 binlog 的异步刷新方案。也就是让应用程序把更新的动作同步到消息队列,后台消费者解析数据变更后再去刷新缓存。这样做的好处是业务代码完全不用关心缓存操作,可以做到应用和缓存解耦。但要注意它的成本:需要搭建 binlog 监听组件,同时刷新缓存是异步的,会有更明显的时间窗口。比较各家方案时,我会参考几个关键维度:

方案一致性强度实现复杂度适用场景
先更 DB 再删缓存最终一致,窗口短低大多数业务
延迟双删最终一致,窗口更短中并发写较多,需要缩短不一致窗口
binlog 订阅刷新最终一致,窗口较长高团队有中间件能力,希望应用无感知
强一致读 DB强一致低对一致性要求极高、读并发不高的场景

一致性调优这件事,我踩过的坑是:一开始总想做到极致,为了一秒内可能出现的一次不一致投入了大量复杂度。后来我把关注点改成了"让旧数据在页面上的存续时间控制在可接受的范围内",比如运营后台秒杀库存信息允许有 500ms 的延迟可见,但支付状态绝对不能有延迟。不同数据对一致性的敏感度不一样,缓存策略不能一刀切。

4. 把性能调优落进平常的迭代里

4.1 连接池、批量操作和应用侧浪费

SQL 和缓存都正常的情况下,数据服务性能还有一个容易被忽略的缺口:应用侧的资源浪费。其中最典型的就是数据库连接池配置不合理。很多项目总想着把连接池调大,好像连接数越多性能越好。其实每个连接都对应数据库端的一个线程,连接数过大时线程切换开销会拖垮数据库,连接排队反而严重。

如果你用的是 HikariCP,可以按这个思路设置初始参数:

spring.datasource.hikari.maximumPoolSize=20 spring.datasource.hikari.minimumIdle=5 spring.datasource.hikari.connectionTimeout=3000 spring.datasource.hikari.maxLifetime=1800000

maximumPoolSize 的经验公式是:核心并发数 ×(单连接处理一个请求的耗时 + 网络等待时间)再除以单请求目标 RT。比如你预期峰值并发 200,单次数据库操作平均 10ms,一个连接每秒能处理 100 个请求,那 20 个连接就够用了。连接不是越多越好,够用且留有余量才是健康状态。

还有一个很典型的浪费是 N+1 查询。用 ORM 时,很多人会写循环里逐条查数据库:

for (Product product : productList) { ProductDetail detail = productDetailMapper.selectByProductId(product.getId()); }

假设 productList 有 50 个元素,这就是 50 次数据库往返。正确做法是先查出所有商品 id,再用 IN 查询一次性取回:

List<ProductDetail> details = productDetailMapper.selectByProductIds(idList);

数据库的网络往返被极大压缩,性能提升是非常明显的。还有一层隐藏损耗在事务边界。长事务会长期持有数据库锁,导致其他普通查询全部排队等待。我在项目里见过一个接口把远程调用也放在事务里执行,远程服务慢两秒,数据库事务就开两秒,后面所有这个表的写操作全部堵住。事务的范围只应该包住真正需要原子性的写操作,查询、远程调用尽量放在事务外。

4.2 监控和告警:把调优成果固定成防线

一个数据服务的性能调优做到最后,如果只停留在某次上线,那后续很快会退化。我把长期稳定运行的秘诀总结成四个字:持续观测。团队里应该建立起一套基础监控和告警体系,维度不需要多,但每条都要真实有效。

我常用的监控指标和阈值大致如下:

监控目标推荐阈值或动作说明
接口 TP99超过 500ms 告警数据服务常见目标,根据业务调整
慢 SQL 平均时长超过 200ms 触发记录超过阈值的 SQL 自动进慢日志分析
缓存命中率低于 90% 关注、低于 85% 告警命中率骤降往往意味着缓存策略变化或热 key 失效
连接池活跃连接数超过最大值的 80% 告警避免连接耗尽后才被动处理
数据库 CPU连续 5 分钟超过 80% 告警和慢 SQL、缓存穿透联动分析

这些阈值不是拍脑袋定的,我是结合常见业务压测结果和数据库经验值整理的。实际项目里要根据你服务的 QPS 基线和可用性目标调整,建好后也不要一劳永逸,每次大版本迭代后回看一轮。

告警配置好之后,调优经验的沉淀也很重要。我习惯每次线上性能事故后写一份简短复盘,格式包括:故障时间、触发的监控项、定位链路、根因、改动点、验证方式。下次再遇到类似问题,直接翻历史复盘比重新排障省太多时间。

4.3 一道SQL面试题:怎么答才能体现实战能力

热搜词里"SQL 面试题"出现频率很高,但大多数人准备面试时喜欢背八股,比如"索引有哪些结构"、"SQL 优化有哪些手段"。真到了面试官问一句"线上数据库 CPU 突然 100%,你怎么排查",很多人就只会说加索引、开慢查询日志,回答得非常空。

我建议把整条调优链路串成一个标准问答。面试官问数据库 CPU 100% 怎么办,你的回答至少要包含四层:第一步观察监控,确认 CPU 升高和接口 QPS 变化有没有相关性;第二步抓 SHOW FULL PROCESSLIST 和慢查询日志,定位具体是哪几条查询占用了大量执行时间;第三步用 EXPLAIN 分析执行计划,看是索引失效、深分页还是大量小查询堆积;第四步检查缓存命中率曲线,自主判断是不是缓存穿透或雪崩导致请求打到数据库。这样回答,面试官能从中看到你有全局观,而且每一步都有真实操作支撑。

如果面试官进一步问"SQL 优化有哪些手段",你可以分两个层次来说。业务层是缓存、读写分离、连表粒度控制、数据库表结构设计;单条 SQL 层面才是索引设计、执行计划分析、深分页改写、避免隐式转换。不用把每一个细节都背出来,只要让面试官看到你已经形成了一套从宏观到微观的方法论,这个回答就已经赢过大多数背模板的候选人了。

数据服务性能调优这条路,我实践下来的核心体会只有两个:第一,调优前先建立基线,调优中一次只改一个变量;第二,SQL 和缓存永远是一体的,缓存方案设计得再好,SQL 底子不健康,也只是延迟了问题爆发的时间。最后分享一个工作习惯:我每周会固定抽出半小时,把线上慢 SQL 日志和缓存命中率拉出来过一遍,不用等到故障发生才去救火。这种持续的巡检带来的收益,比临时抱佛脚的调优大得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询