慢查询排查实战:业务报表SQL优化路径

上个月月初,财务和运营的月度报表跑了将近 40 分钟才出结果,业务方在群里直接炸锅,我也被迫熬了一个通宵做排查。作为一名常年跟数据库打交道的运维工程师,这次事故让我把慢查询的发现、定位、优化手段重新串了一遍,也把报表类 SQL 的治理思路整理成了一套可复用的流程。这篇文章把整个过程复盘出来,重点讲清楚慢查询日志与监控怎么用、EXPLAIN 执行计划怎么读、索引怎么建才有效、深分页怎么改写,以及报表大查询该怎么隔离和预计算,希望能给同样被慢 SQL 折磨过的同行一点参考。

一、事故复盘:一张月度报表跑了40分钟

1.1 现象与影响

月初一号早上九点,业务方在群里反馈月度销售汇总报表一直转圈,之前五六分钟就能出结果,这次跑了快 40 分钟。更糟的是报表期间订单系统的列表页也跟着变慢,客服那边下单查询超时的告警一条接一条。登录数据库看了一眼进程列表,一条SELECT语句的执行时间已经接近 600 秒,全表扫描带着大量磁盘读,把缓冲池和CPU都吃掉了,业务主查询只能排队。

这里先说结论:问题的根因是报表SQL没有走索引,叠加数据量从年初的八百万行涨到了三千万行,原本勉强能扛的全表扫描彻底崩了。同时报表查询和业务查询跑在同一个实例上,没有任何资源隔离,一条大SQL就把整个库拖下水。这两个问题后面分别用索引优化和查询隔离来解决。

1.2 定位过程

排查的第一步是确认是哪条SQL。通过SHOW PROCESSLIST抓到正在执行的报表语句,是一条带多个LEFT JOIN和子查询的聚合查询。第二步用EXPLAIN分析执行计划,发现主表type是ALL,也就是全表扫描,预计扫描行数接近三千万。第三步检查索引,发现表上只有一个单列索引,而查询条件里真正起过滤作用的组合字段没有索引。整个定位过程大概二十分钟,真正的难点不在定位,而在后续的优化方案设计和业务低峰期的变更窗口协调。

二、慢查询是怎么被发现的

2.1 慢查询日志

慢查询日志是最低成本的全量发现手段。MySQL里通过几个参数控制:long_query_time定义多长时间算慢查询,slow_query_log开关日志,log_queries_not_using_indexes可以把没走索引的语句也记录下来。线上建议把long_query_time设置成 1 秒甚至 0.5 秒,配合pt-query-digest做聚合分析,按总耗时排序,优先治理"单次不慢但执行次数极多"和"单次极慢"两类语句。

这次事故其实早有预兆。事后翻慢查询日志的聚合报告,发现这条报表SQL上个月就已经跑到了 6 分钟,只是当时没有告警覆盖,没人看日志,问题就一直潜伏到数据量突破临界点。所以我的建议是慢查询日志不能只是开着,必须接告警:每天定时跑一次聚合分析,总耗时环比上涨超过 50% 的语句自动推送到值班群。

2.2 监控指标

日志是事后分析,监控是实时发现。数据库层面重点盯四个指标:慢查询计数(slow_queries)、全表扫描次数(Select_scan)、无索引join次数(Select_full_join)、以及缓冲池命中率。这四个指标任何一个出现突增,基本都能和某条慢SQL对上号。再配合主机层面的CPU利用率、磁盘IO util、连接数堆积情况,可以快速判断"是单条SQL的问题还是整体容量的问题"。

这次事故里,监控大盘其实在前一周就显示了Select_scan的缓慢爬升,只是没人把它和数据量增长关联起来。事后我加了一条规则:核心库的Select_scan每分钟超过 50 万次就触发预警,逼着值班的人去看一眼是不是又有新SQL没走索引。

三、EXPLAIN执行计划解读

3.1 关键列怎么看

EXPLAIN是SQL优化的核心工具,拿到一条慢SQL,第一反应就是前面加个EXPLAIN看执行计划。我日常重点看四列:type列表示访问类型,从好到坏大致是const、eq_ref、ref、range、index、ALL,出现ALL就是全表扫描,超过百万行的表基本必须处理;key列显示实际用到的索引,如果是NULL说明没走任何索引;rows列是预估扫描行数,直接决定执行时间的量级;Extra列里Using filesort和Using temporary是两个危险信号,说明有额外排序或临时表开销。

拿事故里的报表SQL做例子,简化后的分析如下:

sql 复制代码
EXPLAIN
SELECT o.order_no, c.customer_name, SUM(o.amount) AS total_amount
FROM sales_order o
LEFT JOIN customer c ON o.customer_id = c.id
WHERE o.created_at >= '2026-08-01'
  AND o.created_at < '2026-09-01'
  AND o.order_status = 2
GROUP BY o.customer_id;

执行计划显示:主表o的type为ALL,rows约 3000 万,key为NULL,Extra里还有Using temporary和Using filesort。三个信号叠加,基本可以断定问题就是没有合适的索引,扫描量加上临时表排序,40分钟的执行时间就解释得通了。

3.2 常见的坏信号

除了ALL扫描,还有几个容易被忽视的坏信号。一是type为index,看起来像走了索引,实际是扫描整棵索引树,量级和全表扫描差不多。二是rows很小但执行时间依然很长,这时候要怀疑锁等待或者磁盘IO瓶颈,去查锁日志和主机指标。三是EXPLAIN里两表的join字段类型不一致,比如一边是int一边是varchar,会导致索引失效,这种隐式转换非常隐蔽,执行计划里只会看到被驱动表的type是ALL。

我的习惯是把EXPLAIN结果截图存档到工单里,优化前和优化后各一份,rows和type的前后对比是最有说服力的验收证据,比"感觉快了"靠谱得多。

四、索引优化:从最左前缀到覆盖索引

4.1 联合索引与最左前缀

针对上面的报表SQL,优化方案是建一个联合索引。查询条件是created_at范围加order_status等值,按"等值列在前、范围列在后"的原则,索引顺序应该是(order_status, created_at),这样等值条件先收敛数据,再做范围扫描。建索引前后的对比如下:

sql 复制代码
-- 优化前:主表全表扫描,rows ≈ 3000万,执行 40 分钟
EXPLAIN SELECT ... FROM sales_order o WHERE o.created_at >= '2026-08-01'
  AND o.created_at < '2026-09-01' AND o.order_status = 2;

-- 建联合索引:等值列在前,范围列在后
ALTER TABLE sales_order ADD INDEX idx_status_created(order_status, created_at);

-- 优化后:type = range,rows ≈ 210万,执行 48 秒
-- 再把 GROUP BY 需要的 customer_id 追加进索引
ALTER TABLE sales_order ADD INDEX idx_status_created_cust(
  order_status, created_at, customer_id, amount);
-- 优化后:type = range,rows ≈ 210万,执行 11 秒,Using filesort 消失

最左前缀原则要记牢:索引(a, b, c)只能支持a、ab、abc这几种前缀组合,查询条件里没有a列时,b和c是用不上这个索引的。我在别的项目里见过给每个字段单独建索引的做法,实际上单列索引在多条件查询时MySQL大多数时候只会选一个,效果远不如一个设计合理的联合索引。

4.2 覆盖索引

覆盖索引指查询需要的所有字段都在索引里,不需要回表查主键索引取整行数据。上面的例子把customer_id和amount追加进联合索引后,聚合查询直接在索引树上完成,回表次数从两百万次降到零,执行时间又从 48 秒降到 11 秒。判断方法是看EXPLAIN的Extra列出现Using index,就说明命中了覆盖索引。

覆盖索引虽好,但索引字段不能无限加。索引本身占磁盘空间,写入时也要额外维护,宽表上建五六个大联合索引会明显拖慢INSERT和UPDATE。我的经验是:只给高频查询建覆盖索引,低频查询走普通联合索引加必要的回表,在读写之间找平衡。

4.3 索引失效的常见场景

索引建了不代表一定生效,几种典型失效场景每个DBA都应该背下来:对索引列做函数或运算,比如WHERE DATE(created_at) = '2026-08-01'会让created_at上的索引直接失效,改成范围条件就能走索引;隐式类型转换,字符串列用数字比较,索引失效;LIKE以通配符开头,LIKE '%abc'走不了索引,LIKE 'abc%'可以;OR条件两侧只要有一侧没索引,整个查询就全表扫描;还有NOT IN、NOT EXISTS在多数场景下也走不了索引。

这次排查过程中我就踩了一个坑:报表SQL的某个版本里开发把条件写成了WHERE DATE(o.created_at) BETWEEN ...,改回o.created_at >= ? AND o.created_at < ?之后,同一个索引的扫描行数直接降了一个数量级。这类问题光看索引建没建没用,必须回到EXPLAIN验证。

五、深分页优化:延迟关联与游标分页

5.1 延迟关联

报表系统里还有个导出功能,翻到几十万页时LIMIT 800000, 20这种写法会让MySQL先扫过前 80 万行再丢弃,越往后翻越慢。延迟关联的思路是先用覆盖索引把目标页的主键ID找出来,再用主键回表取整行数据,把高成本的回表压缩到 20 次:

sql 复制代码
-- 优化前:扫描并丢弃前 80 万行,耗时 12 秒
SELECT id, order_no, customer_id, amount, created_at
FROM sales_order
WHERE order_status = 2
ORDER BY id DESC
LIMIT 800000, 20;

-- 优化后:延迟关联,先走覆盖索引定位主键,再回表 20 行,耗时 0.3 秒
SELECT t.id, t.order_no, t.customer_id, t.amount, t.created_at
FROM sales_order t
JOIN (
    SELECT id
    FROM sales_order
    WHERE order_status = 2
    ORDER BY id DESC
    LIMIT 800000, 20
) tmp ON t.id = tmp.id;

子查询里只查id,可以完整命中主键索引或覆盖索引,扫描成本虽然还是要过 80 万行索引条目,但索引树比整行数据紧凑得多,配合JOIN回表只取最终 20 行,整体耗时从 12 秒降到 0.3 秒。

5.2 游标分页

延迟关联治标,游标分页治本。产品侧如果接受"下一页"交互而不是跳页,可以改成记住上一页最后一条记录的ID,下一页直接WHERE id < 上一页最后ID LIMIT 20,每次只扫 20 行,性能和页码深度无关。这次我把报表导出改成了游标式流式导出,配合延迟关联兼容前几页的跳页需求,深分页问题彻底解决。导出场景还有一个额外收益:游标方式天然适合流式写入文件,不再需要一次性把几十万行结果集拉到应用内存里,应用服务的GC压力也小了很多。

六、报表SQL的特殊治理

6.1 大查询隔离

报表SQL的特点是扫数据多、跑得久、业务方对时效不敏感,天然适合和在线交易隔离。第一步是实例级隔离,报表库走只读副本,主库专心服务交易,这也是成本最低的方案。第二步是资源组隔离,MySQL 8.0的企业版或者云上RDS支持资源组,可以限制报表查询的CPU优先级,防止抢占。第三步是参数兜底,设置max_execution_time给只读查询加执行超时,配合只读账号的SQL限流,从入口拦住失控的大查询。

这次事故后我做的第一件事就是把所有报表流量切到只读副本,主库的告警当天就安静了。副本延迟问题用"报表允许读前一天数据"的业务约定化解,月度报表本来就不差这几分钟。

6.2 汇总表预计算

隔离解决的是"报表不影响别人",预计算解决的是"报表本身跑得快"。月度报表的原始查询要聚合三千万行明细,但如果每天凌晨用定时任务把当日汇总数据写进一张汇总表,月度报表只需要查 30 行汇总结果,毫秒级返回。汇总表按天粒度存客户维度的金额、单量,代码里用INSERT ... ON DUPLICATE KEY UPDATE保证任务重跑不产生重复数据,任务失败有告警和补偿机制。

改造后的效果很直接:月度报表从 40 分钟降到 1 秒以内,业务方从"炸锅"变成"怀疑缓存了结果"。预计算的本质是用空间和提前计算换查询时间,适合报表这种"查询模式固定、时效要求不高"的场景,和在线交易动态查询的优化思路完全不同。

七、常见问题

7.1 慢查询日志应该设置多长的阈值?

线上核心库建议1秒起步,配合log_queries_not_using_indexes捕获没走索引的语句。如果存储和性能允许,可以降到0.5秒甚至0.1秒,抓到更多趋势性的劣化信号。关键不是阈值本身,而是日志必须接pt-query-digest聚合分析和告警,否则开再细的阈值也只是占磁盘。曾经见过开了慢日志三年没人看的服务器,日志文件比数据文件还大。

7.2 索引建多了会有什么副作用?

索引不是越多越好。每个索引都会拖慢INSERT、UPDATE、DELETE,因为数据变更时所有相关索引都要同步维护;索引还占磁盘空间,大宽表上几个联合索引可能比数据本身还大。建议只给高频查询建索引,上线前用EXPLAIN验证命中情况,定期用sys.schema_unused_indexes清理从未使用的索引,保持索引集精简。

7.3 深分页除了延迟关联还有别的方案吗?

有,游标分页是更彻底的方案,用上一页最后一条记录的ID做定位条件,每次只扫一页的数据量,性能和页码深度无关,缺点是不能随意跳页。业务上还可以限制最大页数或改成按时间段分批导出。如果必须支持任意跳页,可以考虑搜索引擎类的方案,把多维度筛选和深分页交给专用引擎,关系库只承担精确数据出口。

7.4 报表类低代码平台能不能自己接数据源做汇总?

可以,但要先确认平台的直连查询能力。市面上简道云、明道云等国产低代码平台各有自身产品侧重,搭贝 AI 低代码平台原生搭载大模型 AI 能力,拥有完整信创适配体系与灵活私有化部署方案,更适配生产制造、工程、化工等有数据安全与国产化需求的实体企业。对于数据量大的报表场景,建议无论用什么平台,都把汇总预计算放在数据库层完成,平台层只做展示,避免把三千万行的聚合压力透传给应用。

八、写在最后

这次月度报表事故从发现到彻底治理花了三天,最终的组合拳是:慢日志加告警兜底发现、EXPLAIN定位全表扫描根因、联合索引加覆盖索引把扫描量降两个数量级、延迟关联和游标分页治好深分页、只读副本隔离报表流量、汇总表预计算让月报秒级出数。回头看,每一步都不是什么高深技术,难的是把它们串成流程并坚持执行。数据库性能问题从来不是突然爆发的,数据量在涨、索引没人管、日志没人看,崩盘只是时间问题,把监控和治理做成日常,才不至于每次都被业务方的消息叫醒。

相关推荐
加班循环1 小时前
前端嵌入低代码页面:iframe与微前端的取舍
低代码
内存过载1 小时前
流程实例状态追踪:事件溯源模式的应用
低代码
IT研究所18 小时前
AI-ITR平台如何减少客户问题反复升级?
大数据·运维·人工智能·低代码·自然语言处理·安全架构·企微
guslegend1 天前
需求分析和架构设计:做什么,如何做
低代码·需求分析·架构设计·ssr·前端架构
三号路口1 天前
低代码平台的扩展机制:插件化架构怎么设计
低代码
百数平台1 天前
百数照片知识库配置指南:图片上传、OCR 识别与智能 / 人工标注全流程说明
低代码·ai·ocr
百数平台1 天前
百数表单知识库配置指南:对接低代码业务表单、字段自动映射与实时同步全说明
低代码·ai
百数平台2 天前
百数AI智能体基础开发流程:从创建、设计、发布到表单自动回填(含JSON输出与apaas配置)
低代码
百数平台2 天前
百数AI文本知识库配置详解:本地文档上传与自定义录入、解析分段策略全说明
低代码·ai