线上慢SQL的排查思路
线上慢SQL的排查思路
面试官您好,关于线上慢 SQL 的排查,我在实际项目中总结了一套 "先止损 - 再定位 - 后根治 - 防复发" 的四步黄金流程,既能快速恢复线上服务,又能彻底解决问题 🚀
🚨 第一步:紧急止损(线上第一优先级)
线上出问题,先保服务可用,再慢慢排查根因!
- 确认影响范围:通过 APM 工具(SkyWalking/Pinpoint)定位慢接口,评估用户影响面
- 快速熔断降级:核心接口被打挂时,先降级非核心功能或开启限流
- Kill 异常慢查询:登录数据库执行
show full processlist;找到耗时 > 10s 的线程,kill [id]; - 流量切分:读压力大时切到从库;写压力大时暂时关闭非核心写操作
🔍 第二步:精准定位问题(核心环节)
2.1 数据库层面排查(80% 的问题在这里)
-- 1. 查看当前运行的所有线程,快速定位慢SQL
show full processlist;
-- 2. 查看慢查询日志(必须提前开启,阈值建议设为1s)
show variables like 'slow_query_log';
show variables like 'long_query_time';
-- 3. 分析执行计划(最关键!)
explain select * from user where name = '张三';
-- 重点关注:type(是否range/ref以上)、key(是否走索引)、rows(扫描行数)、Extra(是否有Using filesort/Using temporary)
-- 4. 查看数据库状态
show status like 'Threads_connected'; -- 连接数
show engine innodb status; -- 查看事务和锁等待情况2.2 应用层面排查(剩下 20% 的坑在这里)
- 有没有N+1 查询:循环调用数据库(如查订单列表后循环查每个订单的用户)
- 有没有大分页查询:
limit 1000000, 10会扫描前 100 万行,性能极差 - 有没有长事务 / 大事务:占用连接资源,导致其他请求排队
- 有没有参数配置问题:连接池最大连接数太小,或
innodb_buffer_pool_size不足
✅ 第三步:针对性根治问题
常见慢 SQL 原因 & 解决方案对照表
| 常见原因 | 典型场景 | 解决方案 |
|---|---|---|
| 全表扫描 | where 条件无索引或索引失效 | 创建合适的联合索引,遵循最左前缀原则 |
| 索引失效 | 索引列用函数 / 运算、隐式类型转换、like '% xxx' | 避免在 where 子句中对索引列做操作,使用覆盖索引 |
| 大分页查询 | limit offset 过大 | 改用游标分页:where id > last_id limit 10 |
| N+1 查询 | 循环查询关联表 | 改用批量查询where id in (...)或关联查询 |
| 大事务 | 一个事务包含多个操作甚至 RPC 调用 | 拆分大事务,将非数据库操作移出事务 |
| 锁等待 | 长事务持有行锁导致其他请求阻塞 | 优化事务粒度,避免在事务中做耗时操作 |
进阶优化方案
- 读写分离:读请求走从库,写请求走主库
- 分库分表:单表数据量超过 1000 万时考虑水平分表
- 缓存热点数据:用 Redis 缓存高频访问数据,减少数据库压力
🛡️ 第四步:防复发(建立长效机制)
- 上线前 SQL 审核:用工具(阿里 Druid、SonarQube)自动检测慢 SQL,禁止不合格 SQL 上线
- 压测验证:上线前做全链路压测,模拟真实流量发现潜在问题
- 完善监控告警:配置慢查询告警(超过 1s 自动报警),监控数据库 CPU、IO、连接数等指标
- 定期巡检:每月做一次数据库性能巡检,清理无用索引,优化表结构
📊 整体排查流程图
面试加分项
- 提到实际踩过的坑:比如 "我之前遇到过隐式类型转换导致的索引失效,varchar 字段传了 int 值导致全表扫描,改成传字符串后性能提升了上百倍"
- 提到具体工具:SkyWalking 链路追踪、Druid 连接池监控、Explain 执行计划分析
- 提到架构层面的解决方案:读写分离、分库分表、多级缓存
- 提到预防措施:说明你不仅会解决问题,还能从流程上避免问题再次发生
🧩 核心代码与技术亮点
1. SpringBoot+Druid 慢 SQL 自动监控与拦截(必配)
技术亮点:无需侵入业务代码,自动采集慢 SQL 并告警,提前发现潜在问题
@Configuration
public class DruidConfig {
@Bean
@ConfigurationProperties("spring.datasource.druid")
public DataSource druidDataSource() {
DruidDataSource dataSource = new DruidDataSource();
// 开启慢SQL统计,阈值1秒
dataSource.setSlowSqlMillis(1000);
dataSource.setLogSlowSql(true);
// 开启WallFilter,拦截全表扫描、恶意SQL
dataSource.setFilters("stat,wall,log4j2");
return dataSource;
}
// 注册Druid监控面板
@Bean
public ServletRegistrationBean<StatViewServlet> statViewServlet() {
ServletRegistrationBean<StatViewServlet> bean = new ServletRegistrationBean<>(
new StatViewServlet(), "/druid/*");
bean.addInitParameter("loginUsername", "admin");
bean.addInitParameter("loginPassword", "123456");
return bean;
}
// 自定义慢SQL告警器(生产级)
@Bean
public SlowSqlListener slowSqlListener() {
return new SlowSqlListener() {
@Override
public void onSlowSql(SlowSqlEvent event) {
String sql = event.getSql();
long timeMillis = event.getTimeMillis();
// 超过3秒直接发钉钉/企业微信告警
if (timeMillis > 3000) {
DingTalkAlertUtil.sendAlert(
"【严重慢SQL告警】\nSQL: " + sql + "\n耗时: " + timeMillis + "ms\n数据源: " + event.getDataSourceName()
);
}
}
};
}
}2. 大分页查询优化(性能提升 1000 倍)
技术亮点:解决limit 1000000, 10全表扫描问题,支持任意深度分页
// ❌ 错误写法:扫描前100万行,性能极差
List<User> badPage(int pageNum, int pageSize) {
int offset = (pageNum - 1) * pageSize;
return userMapper.selectPage(offset, pageSize);
}
// ✅ 正确写法:游标分页(基于自增ID)
List<User> goodPage(long lastId, int pageSize) {
// 只扫描pageSize行,性能恒定
return userMapper.selectByCursor(lastId, pageSize);
}
// 对应的SQL
@Select("select id, name, phone from user where id > #{lastId} order by id asc limit #{pageSize}")
List<User> selectByCursor(@Param("lastId") long lastId, @Param("pageSize") int pageSize);3. N+1 查询问题根治(批量查询替代循环查询)
技术亮点:将 N 次数据库调用合并为 1 次,减少网络 IO 和连接开销
// ❌ 错误写法:N+1查询(1次查订单列表,N次查用户)
List<OrderVO> badGetOrderList() {
List<Order> orders = orderMapper.selectAll();
return orders.stream().map(order -> {
OrderVO vo = new OrderVO();
BeanUtils.copyProperties(order, vo);
// 循环查询用户,N次数据库调用
User user = userMapper.selectById(order.getUserId());
vo.setUserName(user.getName());
return vo;
}).collect(Collectors.toList());
}
// ✅ 正确写法:批量查询(1次查订单,1次查所有用户)
List<OrderVO> goodGetOrderList() {
List<Order> orders = orderMapper.selectAll();
// 提取所有用户ID,批量查询
Set<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toSet());
Map<Long, User> userMap = userMapper.selectByIds(userIds).stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
return orders.stream().map(order -> {
OrderVO vo = new OrderVO();
BeanUtils.copyProperties(order, vo);
vo.setUserName(userMap.get(order.getUserId()).getName());
return vo;
}).collect(Collectors.toList());
}4. 热点行更新优化(秒杀 / 库存扣减场景)
技术亮点:用分段锁解决单条记录行锁竞争问题,QPS 提升 50 倍以上
// ❌ 错误写法:单条记录锁竞争激烈,数据库CPU飙升
@Transactional
public boolean deductStock(Long productId, Integer num) {
int affected = stockMapper.deductStock(productId, num);
return affected > 0;
}
// ✅ 正确写法:分段锁(将1条库存记录拆分为10条)
@Transactional
public boolean deductStockWithSegment(Long productId, Integer num) {
// 随机选择一个分段
int segment = ThreadLocalRandom.current().nextInt(10);
int affected = stockMapper.deductStockBySegment(productId, num, segment);
// 如果当前分段库存不足,尝试其他分段
if (affected == 0) {
for (int i = 0; i < 10; i++) {
if (i == segment) continue;
affected = stockMapper.deductStockBySegment(productId, num, i);
if (affected > 0) return true;
}
return false;
}
return true;
}
// 对应的SQL
@Update("update stock set stock = stock - #{num} where product_id = #{productId} and segment = #{segment} and stock >= #{num}")
int deductStockBySegment(@Param("productId") Long productId, @Param("num") Integer num, @Param("segment") Integer segment);🎯 核心技术难点与解决方案对照表
| 技术难点 | 问题本质 | 典型场景 | 解决方案 | 排查 / 验证命令 |
|---|---|---|---|---|
| 隐式类型转换导致索引失效 | MySQL 对索引列做隐式函数转换,破坏索引有序性 | varchar 类型的手机号传了 int 值;int 类型的 id 传了字符串 | 1. 统一参数类型 2. 避免 where phone = 13800138000这种写法 | explain select * from user where phone = 13800138000;观察key列是否为 null |
| 联合索引最左前缀陷阱 | 范围查询(>、<、between)之后的索引列失效 | 联合索引idx_a_b_c,查询where a=1 and b>2 and c=3,c 列索引失效 | 1. 将等值查询列放在最左 2. 范围查询列放在最后 3. 必要时使用覆盖索引 | explain select * from t where a=1 and b>2 and c=3;观察 key_len列,判断用到了几个索引列 |
| 大事务导致的锁等待雪崩 | 长事务持有行锁时间过长,导致后续请求排队阻塞 | 事务中包含 RPC 调用、文件 IO、循环操作 | 1. 拆分大事务,将非数据库操作移出事务 2. 开启事务超时配置 3. 使用 @Transactional(timeout = 5) | show engine innodb status;查看 TRANSACTIONS部分的事务时长 |
| 死锁排查与解决 | 多个事务互相等待对方持有的锁,形成循环依赖 | 事务 A 先更新表 1 再更新表 2;事务 B 先更新表 2 再更新表 1 | 1. 统一更新顺序 2. 降低事务粒度 3. 开启死锁检测 | show engine innodb status;查看 LATEST DETECTED DEADLOCK部分 |
| MySQL 查询优化器选错索引 | 统计信息不准确,导致优化器选择了错误的索引 | 数据分布不均匀,某列值大部分相同 | 1. 强制指定索引force index(idx_name)2. 更新统计信息 analyze table t;3. 创建更合适的联合索引 | explain analyze select * from t where a=1;对比预估行数和实际行数 |
| Using filesort/Using temporary | 排序或分组操作无法使用索引,需要在内存 / 磁盘临时表中完成 | order by非索引列;group by非索引列 | 1. 为排序 / 分组列创建索引 2. 使用覆盖索引避免回表 3. 限制排序结果集大小 | explain select * from t order by create_time desc;观察 Extra列是否有Using filesort |
💡 面试加分提示
- 结合实际踩坑经历:比如 "我之前在用户中心项目中遇到过隐式类型转换的问题,手机号字段是 varchar 类型,前端传了 int 值,导致全表扫描,接口耗时从 10ms 变成了 3s,改成传字符串后性能直接恢复"
- 量化优化效果:所有优化都要说清楚优化前后的性能对比,比如 "大分页优化后,第 10000 页的查询耗时从 2.5s 降到了 1ms"
- 提到底层原理:比如解释为什么隐式类型转换会导致索引失效,是因为 MySQL 会把索引列转换成参数的类型,相当于对索引列做了函数操作,所以无法使用索引
真实现场模拟面试
真实现场模拟面试
面试官 👨💼:
“假设生产环境数据库突然告警,出现大量慢SQL,老板让你去排查。说说你的整体思路。”
我 💬:
“好的,我一般会按发现→定位→分析→优化→验证这样一个闭环来做。先用一张图概括:”
“接下来每一步都有具体的工具和检查点,我可以展开说说。”
面试官 👨💼:
“好,那第一步快速定位到具体是哪些SQL在慢,你会怎么做?”
我 💬:
“线上讲究无侵入快速定位,我一般四板斧:
- 🐢 慢查询日志 – 已经开启的话,配合
pt-query-digest直接看 TOP N,最省事。 - 🔎 sys 库 / performance_schema – 比如查
sys.statement_analysis,能按总耗时排序,找出那些执行次数多、累积影响大的SQL。 - ☁️ 云监控/APM – 像阿里云DAS、SkyWalking 直接给出慢SQL和调用链路,能知道是哪个接口触发。
- 🚨 紧急情况 – CPU 打满时直接
SHOW FULL PROCESSLIST,看当前正在跑的SQL,重点盯Sending data、Creating tmp table这些状态。
🎯 这一步结束,手里就是一份嫌疑SQL清单,包括耗时、频率、来源。”
面试官 👨💼:
“嗯,拿到一条具体SQL了,接下来怎么分析它为什么慢?”
我 💬:
“核心就是看执行计划。MySQL的话我优先用 EXPLAIN ANALYZE(8.0),因为它显示实际耗时和预估行数的误差。
然后逐字段排查,就像看化验单一样:”
| 关注字段 | 🚩 异常信号 | ⚠️ 实际意味着什么 |
|---|---|---|
| type | ALL | 全表扫描,基本是索引缺失/失效 |
| key | NULL | 完全没用到索引 |
| rows | 巨大 | 扫描行数太多,估算可能不准 |
| Extra | Using filesort / Using temporary | 文件排序或临时表,耗内存、磁盘 |
| filtered | 很小 | 索引选择性差,大量数据回表后被丢弃 |
“同时我还会 SHOW CREATE TABLE 看一眼表结构和现有索引,确认是不是有索引但没用上,比如发生了隐式类型转换。”
面试官 👨💼:
“分析完执行计划,一般能归纳出哪几类根因?”
我 💬:
“根据经验,90%的慢SQL都跑不出这六大根因:
- 🔸 索引缺失/失效(榜首):没建索引、索引列上用函数、类型转换、违反最左前缀。
- 🔸 数据量暴涨:表变大后回表开销大,或优化器统计信息不准选错索引。
- 🔸 锁等待:SQL本身不慢,被其他事务阻塞,
SHOW ENGINE INNODB STATUS能看出来。 - 🔸 返回大量无用数据:
SELECT *拖了大字段,或者深分页LIMIT 100000,20实际扫了很多行。 - 🔸 复杂SQL写法:多表JOIN + 子查询 + OR,优化器很难生成好计划。
- 🔸 硬件/配置瓶颈:磁盘IOPS满、Buffer Pool太小导致刷脏页,得结合
iostat、top一起看。”
面试官 👨💼:
“根因找到了,那你会立刻怎么优化?”
我 💬:
“优化分紧急止血和长远根治。我常用的‘三板斧’:
1. 🔧 索引优化
- 建覆盖索引让查询只扫索引不回表。
- 调整联合索引列顺序,贴合最左前缀。
- 优化器选错索引时,可临时
FORCE INDEX止血,但事后要分析统计信息为啥不准。
2. ✂️ SQL改写
- 深分页改成基于主键的游标式分页,或延迟关联。
OR条件改UNION ALL。- 用
JOIN替代部分子查询。 - 坚决去掉
SELECT *,只查必要字段。
3. ⚙️ 架构调整
- 表太大考虑分区、分库分表,或历史数据归档。
- 统计类查询引入ES等异构数据源,避免直接在OLTP库上跑复杂分析。”
面试官 👨💼:
“优化完了,怎么验证真的有效,而不是按下葫芦浮起瓢?”
我 💬:
“验证我严格走三步:
- 🧪 预发/低峰期测试 – 再次
EXPLAIN甚至EXPLAIN ANALYZE,确认执行计划符合预期,实际耗时下降。 - 📈 线上监控对比 – 观察慢查询数量、CPU使用率、接口RT,确保问题真正消失。
- 📝 闭环复盘 – 把案例沉淀到知识库,团队内同步,同时在代码审查时加强对索引变更的关注,有条件的接入SQL自动审核或慢SQL熔断工具。
如果效果不理想,我会重新回到执行计划分析那一步,调整方案。”
面试官 👨💼:
“不错。最后用一句话总结你的排查思路。”
我 💬:
“监控先发现 → 工具抓SQL → EXPLAIN看计划 → 结合场景挖根因 → 建索引/改写法/调架构 → 验证闭环。永远先观测再动手,线上最怕凭直觉改代码。”
面试官 👨💼:
“思路很清晰。刚才你提到深分页优化、索引失效分析这些,能不能贴一段核心代码示例,展示一下你的具体实现?最好有技术亮点。”
我 💬:
“没问题,我挑两个最有代表性的场景。
1. 深分页优化:延迟关联写法
这是典型的大表 LIMIT 100000,20 慢查询,传统做法数据库实际扫描了100020行。我用延迟关联改写:
-- ❌ 原始慢SQL(扫描大量行)
SELECT id, title, content, create_time
FROM articles
WHERE status = 1
ORDER BY create_time DESC
LIMIT 100000, 20;
-- ✅ 优化后:先取主键,再回表关联
SELECT a.id, a.title, a.content, a.create_time
FROM articles a
INNER JOIN (
SELECT id
FROM articles
WHERE status = 1
ORDER BY create_time DESC
LIMIT 100000, 20
) AS tmp ON a.id = tmp.id;💡 亮点:子查询只扫索引覆盖列(id + 索引列),避免了大量回表,再由主键直接关联取出最终数据,扫描行数从10万+降为20行左右。
2. 快速诊断索引失效:检查隐式类型转换
线上有一个SQL突然从毫秒变秒级,执行计划显示 type=ALL。我怀疑是隐式类型转换导致索引失效,用这个查询验证:
-- 假设 phone 字段是 varchar,但传入的是数字
EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
-- key: NULL, type: ALL 💢 索引失效
-- 验证转换发生
SELECT
COLUMN_NAME,
DATA_TYPE,
COLUMN_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'mydb'
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'phone';
-- 结果:varchar(20) 而传参是数字,MySQL会将字段转为数字比较,导致全表扫描🛠️ 修复:改成 phone = '13800138000',或代码层统一参数类型,执行计划立刻恢复。
3. 用 sys 库揪出 TOP 累积慢查询
当监控曲线抖动,不是单个SQL特别慢,而是高频SQL累积效应,我会用这条语句快速抓凶:
-- 按总延迟排序,找出最 “费时” 的 SQL
SELECT
query,
db,
exec_count,
total_latency,
avg_latency,
rows_sent_avg
FROM sys.statement_analysis
ORDER BY total_latency DESC
LIMIT 10;📊 这条能看到哪些SQL虽然单次不慢,但执行次数极高,拖垮整体性能,是优先治理的对象。”
面试官 👨💼:
“代码很扎实。那站在技术面试角度,你觉得在这个线上慢SQL排查场景里,最大的技术难点有哪些?你是怎么解决它们的?”
我 💬:
“这个场景确实有几个特别棘手的地方,我总结为三大难点:
| 难点 | 具体描述 | ✅ 我的解决方案 |
|---|---|---|
| 🔥 难点1:生产环境无法随意操作 | 不能跑 EXPLAIN ANALYZE 这种耗时诊断,更不能加索引验证,稍不慎会加剧故障 | ① 优先使用慢日志 + pt-query-digest 离线分析,零侵入; ② 在从库或预发环境回放慢SQL,安全地做 EXPLAIN 和压测; ③ 利用 performance_schema 的内存表采集,不锁表。 |
| 🧊 难点2:索引失效原因隐蔽 | 隐式类型转换、字符集不一致、函数包装、统计信息不准等,仅看执行计划不够 | ① 对比 SHOW CREATE TABLE 和实际SQL参数类型,计算 key_len 判断是否用到完整索引;② 打开 optimizer_trace 看优化器决策过程;③ 定期 ANALYZE TABLE 更新统计信息,防止选错索引。 |
| ⏳ 难点3:优化效果需要量化验证,且不能回归 | 优化一个慢SQL可能影响其他查询,或者线上数据分布不同导致预期不符 | ① 在影子表或预发环境用真实数据量压测,对比 EXPLAIN ANALYZE 实际耗时;② 使用数据库审计插件或APM记录优化前后SQL响应时间,形成对比报告; ③ 灰度上线,观察监控曲线,确保CPU/慢查询数量下降,且无新异常。 |
这三大难点背后体现的是线上操作的敬畏心和量化驱动的优化方法论,恰好是一线工程师区别于‘只会写SQL’的核心竞争力。”
面试官 👨💼:
“很好,既有落地代码又有抽象思考。这个问题我们就聊到这儿。”
