一次 170 万行的导出:SQL 优化远没有加索引这么简单
一次真实的生产慢 SQL 排查记录。结论先放这儿:慢 SQL 的根因,不一定在数据库。
一、3176 秒
一个导出接口,单次要处理 170 万行数据,居然跑了 3176 秒------接近 53 分钟。
日均只调用两次,显然不是并发问题。它慢到业务方每次都要问一句:这 SQL 是不是已经出问题了?
按理说,一天只跑两次的接口,慢一点大家也能忍。但 53 分钟这个量级已经超出"忍一忍"的范畴了------业务方每次都要盯着,跑完了才敢做下一步。这已经不单是性能问题,接口本身的可用信度都受影响了。
第一版排查时,我甚至怀疑过跑不完。
二、我的第一反应,和它为什么不对
我的第一反应和大多数人一样:翻日志、查索引、看有没有全表扫描。
日志翻完,定位到那条慢 SQL。然后对着表一个个检查 where 条件------结果每条查询都走了索引。
主键本身有索引,另外为了加速,我们还给几个常用字段、数据差异度大的字段都加了索引。
问题不在索引。
这里的坑在于:"慢 SQL"这个词会把人带偏。 它让人默认问题出在数据库,索引、表设计、执行计划。但慢的原因,完全可以出在数据库之外。
我一度以为这次可以当那个例外------不是索引的问题。结果确实不是,但原因比我想的更离谱。
三、AWR 里那条推理链
于是去找 DBA 要了 AWR 报告。
AWR 是 Oracle 的自动负载仓库,会定期给数据库拍快照,把 Top SQL、等待事件、执行时间这些信息存成历史记录。它比应用日志强的地方在于:应用日志只告诉你「慢」,但不告诉你「为什么慢」。
这次的推理链是这样的:
- 索引都走了 → 排除索引失效、全表扫描
- AWR 显示:这条 SQL 的三千多秒里,接近 90% 是 CPU 时间,等待事件很少 → 排除 IO 等待、锁等待
- CPU 占绝对大头,说明是计算密集,不是取数慢 → 问题在"算",不在"读"
到这一步,方向就清楚了:不是数据库存得慢,是算得慢。
四、根因:一条被反复解析的 SQL
回到代码,看到一个线索:
这段业务封装在一个 Oracle 函数 里,内部是用 EXECUTE IMMEDIATE 动态拼接 SQL 实现的。
sql
-- 改之前:拼接字符串
EXECUTE IMMEDIATE 'select ... where col = ''' || p_col || '''';
数据量小的时候完全没问题。但在 170 万行、且每一行的参数值都不一样的场景下,每条都是全新的 SQL 文本。
Oracle 执行一条 SQL 前要先解析:语法检查、语义检查、生成执行计划。文本不同 = 解析结果不能复用。所以每一行都要做一次完整的硬解析。
当解析次数上到百万级,解析的时间远远超过了执行本身。
这也解释了 AWR 里那个 90% 的 CPU 占比:那不是业务逻辑在算,是数据库在反复解析同样的东西。
五、改法其实很朴素
改了两处,都在那个 Oracle 函数里。
第一处:动态拼接 → 绑定变量
sql
-- 改之后:绑定变量,SQL 文本固定
EXECUTE IMMEDIATE 'select ... where col = :1' USING p_col;
文本固定下来,执行计划就能复用,不用再每次都重新解析。
这里有个容易说反的地方:不是"把 SQL 写死"就解决了,是把"参数"从 SQL 文本里拿出来。 写死 where 条件反而更麻烦------换个参数又是一条新 SQL。真正的解法是绑定变量。
第二处:合并重复查询
改的过程中还发现,select 语句里存在大量重复查询------明明是同一个 where 条件,只因为要取不同字段,被写成了好几个独立的查询。
每取一个字段就扫一次表、查一遍库,等于把同一份结果集算了 N 遍。
改成查一次,把结果拿在内存里,按字段分配。
效果
| 指标 | 改之前 | 改之后 |
|---|---|---|
| 单次平均 CPU 解析耗时 | 15.8 ms | 0.8 ms |
| SQL 总执行时间 | 约 3000 秒 | 300 秒左右 |
改完之后,说实话是很有成就感的。
六、但是,能直接上线吗?
显然不行。
硬解析解决了,重复扫表也解决了。但还有一问:
改完之后,业务逻辑变了吗?
如果没验证过最终结果和改动前是否一致就直接上线,很可能吃一个运行事故。
这个模块的结构
这段取数逻辑分散在多个 Controller 里------各自从不同的表里取数,再层层传值汇总到一个最终类,由它执行那段被改的 SQL。
所以要验证,只需要针对这个类测。
做法
- 写一个 JUnit 测试类,专门测这个最终类
- 改动之前,用 bat 脚本 编译并运行它,跑一次全量导出,把 SQL 算出来的结果落成 csv 归档
- 替换实现之后,再跑一次,拿到第二个 csv
- 计算两个文件的 md5
一致的。
md5 相同意味着字节级完全一致------不只是每一行的值一样,连顺序都没有变。这比逐行 diff 更省事,也更严格。
然后是流程
确认无误后才提 PR:先合到本地开发分支,再合到测试分支,通过 Jenkins 部署,交给测试人员验证业务是否符合预期。
一轮轮测下来,才上了生产。
结果是:单次耗时从 3176 秒降到 357 秒,数据与改动前完全一致。
七、回头看
这次慢 SQL 的根因,不在索引,也不在表设计,而在代码一句话:SQL 是拼出来的。
做对了的地方,其实不是"会改 SQL",是两件事:
- 判断 :从
索引都走了和90% 是 CPU两条排除掉常见可能,才推到"反复解析" - 验证:改完不敢直接信,先固化一份改动前的行为基线,再拿新结果去比
第二点更值得说。这种做法在工程上叫特征测试(characterization test)------先固化旧行为,改完再比对,保证重构不改变语义。
性能优化最怕的不是「没变快」,是**「变快了,但算错了」**。页面上的数字看不出来,但数据已经错了,而且没人发现。
一个前提提醒:md5 相同能成立,是因为导出的 csv 里只有业务数据。如果文件里带了导出时间戳、操作人、随机 ID 这类会变的字段,md5 永远对不上------那就得先把那几列剔除,只比数据部分。
所以现在我遇到慢 SQL,会先问一句:
这条 SQL 每次执行的时候,是不是都长得一样?
如果不一样,那再多的索引也救不了。
附:可复用的排查清单
遇到慢 SQL,按这个顺序排除:
| 步骤 | 看什么 | 结论 |
|---|---|---|
| 1 | where 条件是否走索引、有无全表扫描 | 没走 → 索引失效问题,改索引结束 |
| 2 | 走了索引还慢 → AWR 看 CPU / 等待事件占比 | 等待事件多 → IO 或锁问题 |
| 3 | CPU 占比高、等待很少 | 计算密集,看 SQL 是否被反复解析 |
| 4 | SQL 文本是否每次不同 | 是 → 改绑定变量 |
| 5 | 是否有同一条件重复查库 | 是 → 合并查询 |
| 6 | 改完 | 固旧结果 → md5/diff 对比 → 再上线 |