一次170万行的导出:sql优化远没有加索引这么简单

一次 170 万行的导出:SQL 优化远没有加索引这么简单

一次真实的生产慢 SQL 排查记录。结论先放这儿:慢 SQL 的根因,不一定在数据库。


一、3176 秒

一个导出接口,单次要处理 170 万行数据,居然跑了 3176 秒------接近 53 分钟。

日均只调用两次,显然不是并发问题。它慢到业务方每次都要问一句:这 SQL 是不是已经出问题了?

按理说,一天只跑两次的接口,慢一点大家也能忍。但 53 分钟这个量级已经超出"忍一忍"的范畴了------业务方每次都要盯着,跑完了才敢做下一步。这已经不单是性能问题,接口本身的可用信度都受影响了。

第一版排查时,我甚至怀疑过跑不完。


二、我的第一反应,和它为什么不对

我的第一反应和大多数人一样:翻日志、查索引、看有没有全表扫描。

日志翻完,定位到那条慢 SQL。然后对着表一个个检查 where 条件------结果每条查询都走了索引。

主键本身有索引,另外为了加速,我们还给几个常用字段、数据差异度大的字段都加了索引。

问题不在索引。

这里的坑在于:"慢 SQL"这个词会把人带偏。 它让人默认问题出在数据库,索引、表设计、执行计划。但慢的原因,完全可以出在数据库之外。

我一度以为这次可以当那个例外------不是索引的问题。结果确实不是,但原因比我想的更离谱。


三、AWR 里那条推理链

于是去找 DBA 要了 AWR 报告。

AWR 是 Oracle 的自动负载仓库,会定期给数据库拍快照,把 Top SQL、等待事件、执行时间这些信息存成历史记录。它比应用日志强的地方在于:应用日志只告诉你「慢」,但不告诉你「为什么慢」。

这次的推理链是这样的:

  1. 索引都走了 → 排除索引失效、全表扫描
  2. AWR 显示:这条 SQL 的三千多秒里,接近 90% 是 CPU 时间,等待事件很少 → 排除 IO 等待、锁等待
  3. 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。

所以要验证,只需要针对这个类测。

做法

  1. 写一个 JUnit 测试类,专门测这个最终类
  2. 改动之前,用 bat 脚本 编译并运行它,跑一次全量导出,把 SQL 算出来的结果落成 csv 归档
  3. 替换实现之后,再跑一次,拿到第二个 csv
  4. 计算两个文件的 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 对比 → 再上线
相关推荐
xjz18421 小时前
Rust 注释:从 // 到 rustdoc
后端
syyjhchou3 小时前
自己搭企业内部中转站,怎么让请求更容易命中缓存
人工智能·后端·语言模型·aigc
特立独行的猫A3 小时前
Ionic + 仓颉语言的 cj-tauri 开发框架实战:开发精美移动端 APP
java·后端·python
SomeOtherTime4 小时前
Rust集合(AI回答)
开发语言·后端·rust
我不会起名字3224 小时前
req.Seqs[len-1]:空切片防御缺口与 RPC 二次校验
后端·mongodb·go·即时通讯·im·故障排查
脚本自动化开发4 小时前
京东PLUS开卡红包刷50元的方法
后端
程序员Query5 小时前
day01-认识Go语言 + golang环境的安装
开发语言·后端·golang
码事漫谈13 小时前
黄仁勋被裁第一天,就收到了橄榄枝
后端
金銀銅鐵13 小时前
带您看懂字节码指令(二):空循环里有什么
java·后端·ai编程