运维群那天下午有点闹。电站运维专责连着发了三条消息,说某光伏电站的发电量报表导出一直转圈,等了五六分钟还没反应。一开始我以为是他们内网带宽问题,让他换个浏览器再试一次。结果半小时后其他几个电站也陆陆续续反馈同样的情况——凡是按这个电站选三个月以上时间范围导出,必定卡死。我登录后台看了一眼,接口日志里那条查询 SQL 的执行时间已经飙到了 280 多秒,整个应用的内存曲线也明显异常。
这类“某个电站导出慢”的问题,在电站监控平台、物联网数据平台里其实非常典型。它不是普通的查询性能问题,而是数据量、索引设计、SQL 执行计划、导出链路设计等多个因素叠加之后暴露出来的综合故障。这篇文章把我们这次从现象、排查、根因到修复的完整过程记录下来,包括每一步为什么这样做、踩了哪些容易忽略的坑,希望能给做电站监控系统、数据平台或者类似报表导出功能的朋友一些参考。
1. 故障现场:一个电站的导出请求卡成了“黑洞”
1.1 三个典型症状,指向同一个方向
导出的问题往往不是“突然不能用”,而是“有一个点特别慢,其他点看起来还能用”。那天我们遇到的故障有三个典型症状,任何一个单独出现都可能被当成偶发问题,但三个同时出现,基本就把排查方向圈定了。
第一个症状,Web 端导出任务长时间无响应。正常状态下,选择一个电站、三个月时间范围、导出运行数据 CSV,后端接口 2 到 3 秒就能返回下载链接。出问题的时候,前端页面一直显示“生成中”,超过 30 秒之后浏览器请求直接超时,用户换浏览器、换网络、清缓存都不管用,说明问题大概率不在前端。
第二个症状,后端日志显示该电站的查询 SQL 耗时异常。我们的监控平台在 ORM 层做了慢 SQL 日志拦截,凡是执行超过 2 秒的 SQL 都会记录到独立的日志文件。那天从日志里翻出来的 SQL,单单查询阶段就跑了 286 秒,而且这条 SQL 的入参里 station_id 稳定指向同一个电站。
第三个症状,导出期间应用服务器频繁 Full GC。导出不是瞬时动作,几百秒的查询意味着大量结果集堆积在内存里,JVM 老年代持续上涨,垃圾回收线程一直在做无用功,最终导致同服务上的其他接口也出现明显的响应延迟。这个症状很容易被忽略,但它其实是“导出的数据量已经超出预期”的直接信号。
1.2 为什么单独这个电站成了“异常电站”
我们平台接入了几百座电站,有大有小。这个出问题的电站,刚开始并没有被特别关注,直到导出慢的反馈集中爆发,我们才去翻了它的元数据:接入的逆变器、汇流箱、气象站等设备数量接近 3200 台,是同体量其他电站的三倍以上;而且它的数据采集频率从标准的 5 分钟一次调整到了 1 分钟一次,等于单位时间内的数据量又翻了五倍。两件事一叠加,这个电站单月产生的历史数据量就能达到其他电站一整年的量级。
这里想说明一个概念上的问题。“异常电站”不一定是设备故障,更多时候是数据分布上的极端个体。我们平时做性能测试,通常会按“平均水位”来设计——取几十个电站的数据量平均值,推导出查询耗时、内存占用的合理范围。但这个电站直接是“极端水位”,它的数据量远超平均值,之前没有专门针对这种规模做过导出场景的压测,所以问题只在它身上暴露出来,其他电站一切正常。
1.3 影响面评估:先止血还是先深挖
评估影响面的时候,我们分了两条线。一条是“故障边界”,确认它只影响导出报表,不影响实时采集、阈值告警、设备控制这些核心链路,也就是说电站本身的安全生产没有受到威胁,可以按常规故障流程处理。另一条线是“蔓延风险”,排查是否还有其他电站的数据量正处于快速膨胀阶段,如果存在同样的趋势而不干预,未来几个月会陆续暴雷。这条线直接决定了我们不能只做一次性修复,必须沉淀出可复用的优化手段和监控指标。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 导出链路拆解:在动手排查前先画清楚数据走向
2.1 导出的整体技术架构
这个平台的导出功能,走的是一条非常典型的 Web 报表链路:前端基于 Vue,用户选择电站、数据类型、时间范围后,向后端发起导出请求;后端是 Spring Boot 服务,提供 /api/export/stationData 接口;数据访问层用的 MyBatis-Plus,SQL 写在 XML 文件里;存储是 MySQL 8.0 InnoDB 引擎,历史采集数据按月分表,表名类似 device_data_202403;文件生成用的 EasyExcel,按流式方式写 Excel 或 CSV。
链路本身不复杂,但有一个关键点:它是一个同步导出接口。也就是说,用户发起请求后,HTTP 连接一直挂着,后端线程要等到 SQL 全部查完、结果集全部写入文件、文件上传到临时存储之后,才能把下载地址返回给前端。任何一个环节耗时过长,都会表现为“页面一直转圈”。这个设计缺陷在数据量小的时候无关痛痒,但遇到极端数据量时就是灾难。
2.2 一次导出请求走过了哪些环节
一次导出请求,从发起到用户看到文件,中间要经历这些步骤:
前端提交导出参数 → Nginx 反向代理 → Controller 接收请求 → Service 组装查询条件 → Mapper 执行 SQL → JDBC 驱动返回结果集 → EasyExcel 遍历结果集写入文件 → 文件落盘并生成临时下载地址 → 前端拿到地址并下载。
这次故障的排查思路,就是把这十个环节拦腰砍成两段:前半段是从请求进入到 SQL 执行完,耗时主要在数据库查询和结果集传输;后半段是结果集到文件生成,耗时主要在内存处理和磁盘写入。我们需要先定位瓶颈到底落在哪一段,再针对性深挖,而不是凭感觉去调 SQL 或者加机器。
2.3 排查工具的清单与配置
复盘之前,先把我们当时用的工具列出来,方便后面复现排查过程:
- MySQL 慢查询日志:确认 long_query_time 设置为 2 秒,确保慢 SQL 全部落盘。
- SHOW PROCESSLIST:实时查看当前数据库会话在执行什么 SQL、状态是什么。
- EXPLAIN:分析慢 SQL 的执行计划,确认优化器的选择是否合理。
- 后端耗时日志:在 Service 层打点记录各个方法的调用耗时,定位耗时集中段。
- 监控大盘:查看应用 JVM 内存曲线、GC 频率、数据库线程数等基础指标。
这些工具都不复杂,但注意要在故障发生后尽快收集现场数据,尤其是 PROCESSLIST 和慢查询日志,一旦 SQL 执行完毕,现场就消失了。我们因为监控配置到位,所以能精确还原当时的执行状态。
3. 排查链路:从 Nginx 到 MySQL 的一步步收窄
3.1 第一步:排除前端和网络因素
排查工作最忌讳的就是跳步。我们首先确认了请求是否真的到达后端。检查 Nginx access log,发现请求正常进入,且每一次请求都返回了 HTTP 状态码(虽然响应时间很长),说明前端、网络、反向代理这条链路是通的。
接着用同一组参数绕过浏览器直接调后端接口,用 curl 和 Postman 分别试了一次。Postman 发出的请求等了约 284 秒才拿到响应,这个数据比前端超时时间更长,进一步说明瓶颈不在浏览器也不在 Nginx,而是后端处理本身已经慢到无法接受。通过这一步,排查范围从前端加后端整个链路,缩小到了后端服务到数据库这一段。
3.2 第二步:后端耗时分析,定位到 Mapper 查询
后端服务我们接了 SkyWalking,虽然平时主要用它的调用链追踪功能,但在这次排查里,反而是 Service 层自己打的一行耗时日志发挥了关键作用。日志显示,整个接口耗时 284 秒,其中 Mapper 查询占 282 秒,占绝对大头;EasyExcel 写文件和临时文件上传合起来只占约 2 秒。
这个分水岭很重要。如果慢在写文件,那是内存和磁盘 IO 的问题;如果慢在查询,那就是数据库的问题。结论非常明确:查询阶段是罪魁祸首,后面的文件生成环节反而是正常的。同时我们也注意到,因为查询本身耗时长,JDBC 结果集在 TCP 层持续传输,应用层不停地接收数据并往内存里堆积,这才导致 GC 压力暴增。所以 GC 异常只是结果,不是原因。
3.3 第三步:慢查询日志里的那条 SQL
打开 MySQL 慢查询日志,很快就找到了肇事 SQL。脱敏后大致长这样:
sql复制SELECT
t.device_id,
t.collect_time,
t.inverter_power,
t.irradiance,
t.module_temp
FROM device_data_202403 t
WHERE t.device_id IN (
SELECT device_id
FROM station_device
WHERE station_id = 1001
)
AND t.collect_time BETWEEN '2024-01-01 00:00:00' AND '2024-03-31 23:59:59'
ORDER BY t.collect_time;
这条 SQL 的逻辑很直观:先根据站号查出该电站下的所有设备 ID,再用这些设备 ID 去按月分表里捞数据,按时间排序。从业务角度没毛病,但执行这么久,数据库端肯定发生了什么反常的事。
与此同时,用 SHOW PROCESSLIST 查看,这个会话一直处于 “Sending data” 状态。“Sending data” 在 MySQL 里是一个大状态,不单指网络发送,还包括执行查询、读取数据、生成结果集等过程。一旦一条 SQL 长时间卡在这个状态,基本可以断定它在存储引擎层面做了大量读取。
3.4 一个容易迷惑人的细节:缓存命中率并不低
这一节想特别强调一个坑。当时我们第一时间看了一眼 InnoDB 的 buffer pool 命中率,发现 read miss 比例并不高,差不多 99% 的读取都命中了内存缓冲池。如果经验不够,很容易得出“数据库缓存没问题,那慢的原因应该不在数据库读取”的错误结论。
但回头看,这个想法是错的。缓存命中率高,不代表扫描的数据量少。MySQL 可以在内存里扫一千万行,耗时照样很长。真正决定查询耗时的是“扫描了多少行”以及“怎么扫描的”,而不是“这个行是不是从磁盘读的”。所以接下来的重点就落到了执行计划上——只有 EXPLAIN 能告诉我们数据库到底是怎么把这条 SQL 跑完的。
4. 根因定位:执行计划失控与数据倾斜
4.1 EXPLAIN 对比:正常电站与异常电站的差距
拿同样的 SQL,换一个正常电站的站号去执行,再和异常电站的执行计划放在一起对比,差距非常直观:
| 维度 | 正常电站 station_id=2041 | 异常电站 station_id=1001 |
|---|---|---|
| 关联设备数 | 47 台 | 3126 台 |
| 近三个月数据量 | 约 120 万行 | 约 2100 万行 |
| 执行计划 type | ref | ALL |
| 扫描行数 rows | 约 3 万行 | 约 2100 万行 |
| Extra | Using index condition | Using where; Using temporary; Using filesort |
| 单次查询耗时 | 0.8 秒 | 286 秒 |
注意看异常电站的执行计划,type 是 ALL,也就是全表扫描。这意味着 MySQL 没有走到任何二级索引,而是把 device_data_202403 这张按月分表从头到尾扫了一遍,扫完 2100 万行之后再做一次时间范围过滤和排序。Extra 里的 Using temporary 和 Using filesort 说明它把中间结果放进临时表,再进行排序。这三样加起来,基本就是慢查询的“顶配套餐”。
4.2 为什么优化器选错了执行路径
这里要解释一个核心问题:MySQL 的优化器为什么不选择更优的索引路径?我们表中有一条非常关键的索引 idx_device_time(device_id, collect_time),查询条件里也有 device_id 和 collect_time,理论上完全可以让优化器走这个索引范围扫描。
问题出在 IN 子查询上。SQL 里“device_id IN (SELECT device_id FROM station_device WHERE station_id = 1001)” 这个写法,MySQL 优化器在做子查询优化时,会对 station_device 返回的 device_id 数量进行估算。它给出的预测值远远低于真实值——估算只有几百个设备 ID,实际是 3126 个。基于这个低估的预估,优化器算了一笔账:如果走 idx_device_time 索引,需要做 3000 多次索引查找,每次再回表,代价太高;而全表扫描虽然要扫 2000 多万行,但按照它估算的过滤比例,只需要读出少量行,整体代价反而“更低”。
结果就是优化器拍板走了全表扫描。这种“估算偏差 + 极端数据分布”的组合,正是很多导出慢问题的幕后黑手。单看 SQL,执行计划似乎合理;单看数据量,也确实到了一个临界点;但两者叠加,产生了远超预期的开销。
4.3 物理结构缺陷:表上缺少站维度的索引
继续往深处挖,我们发现最根本的问题其实还在表结构设计上。device_data_202403 这张表的主索引是 id,二级索引只有 idx_device_time(device_id, collect_time)。这个索引设计针对的是“按设备查时间范围”的场景,但我们的导出功能是按“按电站查所有设备在某个时间范围的数据”,这两个查询入口完全不同。
更麻烦的是,device_data_202403 表里根本没有 station_id 字段,站和设备的关系放在另一张 station_device 表里。这意味着即使我们想给查询加一个站维度的复合索引,直接建在 device_data 上也没有对应的列可用。要彻底解决问题,要么改 SQL 让优化器走已有索引,要么改表结构冗余一个 station_id 字段并建联合索引。前者能快速止血,后者根治但不适合在故障当天直接动刀。
4.4 统计信息陈旧与 ANALYZE TABLE 的试探
排查到这里,我们还做了一个小实验:对 device_data_202403 执行 ANALYZE TABLE 命令,更新表的统计信息,然后重新 EXPLAIN,看优化器会不会改选执行计划。实验结果很有意思,统计信息刷新之后,优化器对 device_id 数量的预估确实更接近真实值了,但执行计划依然选择全表扫描。原因在于,3126 个设备 ID 走索引每次范围查找再回表,累计的回表代价依然高于优化器对全表扫描的估算,两者差距并不像小数据量时那么夸张。
这个实验的结论很明确:统计信息陈旧只是压垮骆驼的最后一根稻草,真正的根子是 SQL 里 IN 子查询的模式不适合这种“一对多”的大规模数据查询,以及表结构本身就缺了站维度的支撑。想靠一条 ANALYZE TABLE 就解决问题,是不可能的。
5. 解决方案落地:SQL 改写、索引重建与异步化改造
5.1 快速止血:把 IN 子查询改成 JOIN
查清根因后,我们的第一个动作是改 SQL。核心思路很简单:绕过优化器的错误判断,把必须经过 station_device 再做 IN 过滤的路子,改写为显式 JOIN,让优化器更容易选择以 device_id + collect_time 索引为基础的驱动路径。
改造后的 SQL:
sql复制SELECT
t.device_id,
t.collect_time,
t.inverter_power,
t.irradiance,
t.module_temp
FROM device_data_202403 t
INNER JOIN station_device s
ON t.device_id = s.device_id
AND s.station_id = 1001
WHERE t.collect_time BETWEEN '2024-01-01 00:00:00' AND '2024-03-31 23:59:59'
ORDER BY t.collect_time;
这个改法在测试环境验证后,执行计划从全表扫描变成了嵌套循环连接,以 station_device 上 station_id=1001 的设备列表为驱动,对 devic资料表的 idx_device_time 索引做范围查找。实际执行时间从 286 秒降到了 37 秒左右。虽然 37 秒依然不算快,但对于快速止血来说已经足够让用户“能用”了,而且为后续优化赢得了时间。
这里也提醒一句,JOIN 改写并不是所有场景都稳赢。如果两张表的关联字段统计信息不准,优化器同样可能选错驱动表和连接算法。当时我们为了保险,在测试环境对比了改写前后的执行计划,确认不是更差才上线的。遇到类似问题,建议你也先把两条 SQL 的执行计划打出来对比一下。
5.2 中长期方案一:冗余 station_id 并重新设计索引
37 秒依然不符合“用户友好”的标准。我们决定在月表上冗余一个 station_id 字段,并建立联合索引 idx_station_time(station_id, collect_time)。这样做之后,SQL 里就不再需要先查设备表再关联,可以直接按站号和时间范围做索引范围扫描:
sql复制SELECT
station_id,
device_id,
collect_time,
inverter_power,
irradiance,
module_temp
FROM device_data_202403
WHERE station_id = 1001
AND collect_time BETWEEN '2024-01-01 00:00:00' AND '2024-03-31 23:59:59'
ORDER BY collect_time;
由于查询需要的字段都包含在二级索引里,还可以考虑建立覆盖索引 idx_station_time_cover(station_id, collect_time, device_id, inverter_power, irradiance, module_temp),让查询完全跳过回表步骤。这个操作我们是在业务低峰期分表执行的,用了在线 DDL 工具,每张表执行完先验证再继续下一张,避免一次性操作把数据库线程池打满。
最终效果:SQL 执行时间稳定在 1.8 秒左右,加上 EasyExcel 写文件的时间,整个导出任务大约 8 到 10 秒完成。这个结果已经远低于前端超时阈值,用户体感从“完全不可用”变成了“可以接受”。
5.3 中长期方案二:同步导出改异步任务
SQL 优化解决的是单次查询的性能问题,但我们判断,仅靠优化 SQL 不够。因为谁也无法保证几年后某个电站的数据量不会继续膨胀,或者业务上会不会出现按整站导出一年数据的需求。重新把一次几千万行的大查询塞进一个同步 HTTP 请求里,风险依然存在。
所以第二步改造是把整个导出链路改成异步任务模式。前端提交导出请求时,后端不再直接执行查询,而是把导出参数写进一张 export_task 表,记录任务状态为“等待中”,然后立刻返回一个任务 ID。后端定时任务或者消息队列消费这些任务,真正去查询数据、写文件,完成后把生成的文件地址更新到任务记录里。前端通过轮询接口获取任务状态,任务完成后显示下载按钮。
这个改造带来的最直接好处是:不管导出任务执行多少秒,用户的 HTTP 请求都不会长时间挂起,应用服务器也不会因为导出任务堆积而拖垮其他接口。任务失败时,还可以在任务表里留下错误原因,支持重试,而不是让用户对着转圈页面干等。
5.4 上线验证与回滚预案
上线过程我们分了两个阶段。先灰度一个电站跑了两天,确认导出任务队列正常、文件生成正确、下载链接有效,再全量开放给所有电站。同时保留了旧接口作为回滚通道,通过配置中心的一个开关,随时可以把导出形式切回同步模式,以防异步链路出现未预料的故障。
阶段验证结果:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| SQL 查询耗时 | 286 秒 | 1.8 秒 |
| 导出总体耗时 | 超过 5 分钟(频繁超时) | 8-10 秒 |
| 用户 HTTP 请求占用 | 全程占用 | 立即返回任务 ID |
| 应用 GC 压力 | 高 | 低 |
6. 从一次复盘到导出性能规范:踩过的坑总结
6.1 导出功能设计的三条红线
这次问题解决后,我们在团队内部立了几条导出功能的硬性规范,现在每次新需求评审都会先对照一遍。
第一,导出查询不允许 SELECT *。导出结果必须显式列出需要的字段,因为返回列越多,回表概率越大,内存占用也越高。这次问题里应用 GC 暴增,一部分原因是查询把整行几 KB 的内容都拉出来了,而实际导出只用 5 个字段。减少返回列是最廉价的内存优化手段。
第二,任何面向用户的导出功能必须走异步任务。同步导出不仅会让用户长时间等待,还会把 Web 容器的线程池占满,影响同服务上的其他业务。即便优化后的 SQL 已经很快,我们也坚持保留异步通道,因为数据量增长是必然趋势,异步架构能兜住未来的极端场景。
第三,报表导出上线前必须做极端数据量压测。压测不只是用几个电站的平均数据量测一遍,还要构造“最大电站、最大时间范围、最大返回列”的组合场景,验证接口性能是否还能接受。这次故障如果当时做过这种压测,大概率能在上线前就暴露出来。
6.2 监控告警需要覆盖的内容
故障发生后,我们补充了几个监控点,目的是在下次出现类似苗头时尽早捕获。慢查询日志的告警阈值从 5 秒收紧到 2 秒,一旦出现类似的全表扫描 + filesort 的慢 SQL,监控系统会自动推送告警到值班群。导出任务层面加了耗时 P95 和 P99 指标,任务失败率和失败原因单独统计,方便快速定位是网络问题、SQL 问题还是文件存储问题。
数据倾斜预警也是这次补上的一块。针对每个电站,我们统计其每日数据写入量,和历史基线做对比。如果某个电站的数据量偏离均值超过 3 倍,后台会生成一条预警,提示运维和开发人员关注这个站的性能风险。这次出问题的电站就是典型的偏离值,提前预警完全有机会防住。
6.3 这次复盘最值得记住的两个经验
第一个经验是:排查慢导出时,不要急着调 SQL 细节,先看执行计划和数据分布。大多数慢导出都不是“SQL 写错了”,而是优化器在极端数据分布下做出了错误的选择,或者表结构设计根本没考虑这种查询入口。执行计划是一切结论的基础,拿到 EXPLAIN 之前做的所有猜测都只是猜测。
第二个经验是:数据平台上的“异常实体”往往是长期累积的极端数据实例。不是代码突然坏了,而是当初的设计基于“平均水位”,没有区分平均个体和极端个体之间的量级差异。这个电站的设备数量是平均值的几十倍,采集频率又比标准高,两个因素相乘之后,任何按平均值设计的导出接口都会瞬间崩溃。所以在做性能设计时,永远要找那条最差的路来评估,而不是取中间值。
现在每次有人跟我反馈“某个电站导出特别慢”,我的第一反应已经不再是盯着 SQL 看,而是先让人把电站的设备数量、时间范围、预估数据量三件套发过来,对照执行计划一起看。大部分同类问题,本质上都不是某一条 SQL 写错了,而是数据分布在时间和空间上的极端程度击穿了当初的设计假设。一次修复解决不了所有问题,把这次复盘沉淀成规范和监控指标,才是长期有效的解法。
