🏺 第15章 优化 SQL 语句
📄 一句话概括

「在测试库上跑得挺快」是个幻觉——数据量、参数、并发都不一样。 重演就是把这些差别抹掉:把生产上真实发生过的负载搬过来,再放一遍。 两种重演分工明确:数据库重演看「整体会不会变慢」(升级、换硬件用), SQL 重演看「单条 SQL 换计划会不会变慢」(调优验证用)。

📝 本章和前面几章的关系

15.4 说「计划会变」。这一篇说的是:变了之后怎么证明它变好还是变坏。 答案只有一个:改之前量一遍,改之后量一遍,逐条比。 Oracle 把这件事做成了两个工具,但思路和你在自己机器上跑个脚本是一样的。

一、为什么要重演:测试库上跑得快是个幻觉

同一套程序,换台机器跑,结果可能差十倍。差别通常来自三处: 数据量不同(测试库一万行,生产一亿行,优化器选的路就完全不同); 参数不同(内存大小、并行度、优化器版本);并发不同(单跑一条快,一百条一起跑就排队)。 所以「我在测试库上加了个索引,快了十倍」这句话,几乎不能作为上线依据。 要作为依据,得让测试库的【负载】和生产的【负载】是一回事。 这就是重演要解决的问题。 重演的核心动作就三步:抓下来、搬过去、放一遍。 抓的是「客户端发给数据库的请求」,包括请求的内容、时间点、并发关系、事务边界。 放的时候按原来的节奏发,这样并发争用也能复现。 这一点是它跟「跑一遍脚本」的根本区别:脚本只能复现 SQL 文本,复现不了争用。 而生产上很多慢,慢在等锁、等 IO、等 CPU,不在 SQL 本身。

二、数据库重演(Database Replay)

四步,对应四个包: ① 捕获——DBMS_WORKLOAD_CAPTURE.START_CAPTURE 与 FINISH_CAPTURE。 ② 预处理——把捕获文件转成重放能用的格式。 ③ 重放——DBMS_WORKLOAD_REPLAY。 ④ 出报告——DBMS_WORKLOAD_REPLAY.REPORT。 捕获期间对生产库有开销,官方建议是先做一次短时间试捕,量一下开销再决定窗口。 别在业务高峰直接开一小时。 它有三条硬约束,缺一条结果就不可信: ① 版本要对得上——跨大版本重放,官方只支持特定的升级路径。 ② 硬件要相当——CPU 核数、IO 能力差太多,耗时就没有参考价值。 ③ 数据要是同一份——重放库得是捕获时那个库的副本,否则跑出来的结果都不一样。 三条里最容易被忽略的是第 ②。 很多人拿一台小机器重放,看到「慢了五倍」就慌,其实是机器本身弱五倍。 所以报告里要先看「重放机的负载能不能撑住原负载」,撑不住的话整份报告只能看趋势、 不能看绝对值。 它最适合的场景是「整体性变更」:升级到 19c、换存储、改一堆参数、打一个补丁。 这类变更影响面广,靠单条 SQL 验证根本盖不住。

三、SQL 重演(SPA)

SQL 重演的思路更窄也更实用:只比 SQL 的执行,不管并发。 它要先把 SQL 装进一个容器,叫 SQL 集(SQL Tuning Set,简称 STS)。 装法有三种:从游标缓存抓(CAPTURE_CURSOR_CACHE_SQLSET)、从 AWR 历史里捞、 或者手工 LOAD_SQLSET 一条条加。 然后是三步:建分析任务 DBMS_SQLPA.CREATE_ANALYSIS_TASK、执行 EXECUTE_ANALYSIS_TASK、 出报告 REPORT_ANALYSIS_TASK。 关键在于执行时传的那个参数,它决定「怎么跑」:

执行方式 做什么 什么时候用
EXPLAIN PLAN 只生成计划,不执行 最快,只看计划变没变
TEST EXECUTE 真执行,但丢弃结果 要真实耗时又怕改数据
EXECUTE 真执行并取结果 最准,但会真的动数据
COMPARE PERFORMANCE 把两次结果对比 改前改后各跑一次之后

报告里最该看三列:改前耗时、改后耗时、变化百分比。 再看一行:计划变了没有。 计划没变而耗时变了,说明是数据或环境变了;计划变了耗时也变了,才是这次改动的作用。 SQL 重演的好处是不需要一模一样的环境——它比的是「同一条 SQL 在两个环境下的相对表现」, 所以小机器也能跑。 代价是它看不见并发:一条 SQL 单独跑快了,一百条一起跑可能因为争用变慢。

四、两种重演怎么选

维度 数据库重演 SQL 重演
抓什么 整个负载加并发时序 单条 SQL 的执行
要不要同环境 要,硬件也得相当 不严格,相对比较即可
能不能跨版本 只能走官方支持的路径 同版本内比最稳
能证明什么 整体会不会变慢 这条 SQL 会不会变慢
开销 大,要停机窗口 小,随时可跑

一句话选法:升级评估用数据库重演,调优验证用 SQL 重演。 前者回答「这次升级值不值得做」,后者回答「这个索引该不该建」。 两个都用不上也别放弃验证。 下面这一节讲的就是「没有这两个工具时怎么办」,也就是本文 Demo 做的事。

五、不能重演的环境怎么办:落地版

四步,全部可以用一条 SQL 加一个脚本完成: ① 抓 SQL 文本——V$SQL 按 ELAPSED_TIME 倒排取前几十条, 或者从 AWR 的 DBA_HIST_SQLSTAT 里捞一段时间的; ② 造同构数据——行数、分布、索引都要像,行数差一个数量级结论就会反; ③ 改前跑三遍取最快,改后再跑三遍取最快——取最快是为了避开缓存预热和系统抖动; ④ 逐条比,重点看「变慢」的那几条。 第 ③ 步的「取最快」不是偷懒,是必须的: 第一次跑要把数据从磁盘读进内存,第二次才是纯计算。 只跑一次的话,你量到的多半是磁盘速度,不是 SQL 的速度。 第 ④ 步的分寸:只看总耗时是不够的。 总耗时快了两倍,但有一条关键查询慢了三倍——这条该不该上线,得业务说了算。 所以逐条比,别只看总数。 这套土办法比 SPA 简陋得多,挡不住并发问题,也挡不住跨版本问题。 但它能挡住最常见的那类错误:凭感觉加索引。 而这一类错误,恰恰是生产上索引越加越多、系统越跑越慢的主因。

六、动手 Demo:80 条 SQL 的重演:改前 2.25 秒,改后 1.04 秒

这一篇的 Demo 就是上面那套「落地版」:造 80 条 SQL 当负载,改之前全跑一遍,加一个索引, 再全跑一遍,最后逐条比。 三样东西要盯住:总耗时的倍数、逐条比较的三档计数、以及「有没有变慢的」。 第三个最容易被忽略,也最重要。

# SQL 重演的最小版:改前量一遍,改后量一遍,逐条比
import os, random, sqlite3, tempfile, time
p = os.path.join(tempfile.gettempdir(), "t155.db")
if os.path.exists(p):
    os.remove(p)
d = sqlite3.connect(p)
d.execute("create table big (id int primary key, deptno int, amt int,"
          " name text, flag text)")
rnd = random.Random(3)
d.executemany("insert into big values (?,?,?,?,?)",
              [(i, rnd.randint(1, 12), rnd.randint(1, 1000),
                "n%06d" % (i % 50000), "Y" if i % 2 else "N")
               for i in range(1, 200001)])
d.commit()

def best(fn, n=2):                  # 跑两次取最快,避开首次开销
    b = 9e9
    for _ in range(n):
        a = time.perf_counter()
        fn()
        b = min(b, time.perf_counter() - a)
    return b

load = []                           # 80 条 SQL,当作业务负载
for i in range(1, 41):
    load.append(("select count(*) from big where deptno = %d"
                 % (i % 12 + 1), "deptno"))
for i in range(1, 21):
    load.append(("select count(*) from big where amt = %d"
                 % (i * 7), "amt"))
for i in range(1, 11):
    load.append(("select count(*) from big where name = 'n%06d'"
                 % (i * 100), "name"))
for i in range(1, 11):
    load.append(("select count(*) from big where flag = 'Y'"
                 " and deptno = %d" % i, "flag+deptno"))

before = [best(lambda s=s[0]: d.execute(s).fetchone())
          for s in load]
d.execute("create index i_w_deptno on big(deptno)")
d.execute("analyze")
after = [best(lambda s=s[0]: d.execute(s).fetchone())
         for s in load]

faster = [i for i in range(len(load)) if after[i] < before[i] * 0.8]
slower = [i for i in range(len(load)) if after[i] > before[i] * 1.2]
print("改前 %.4f 秒,改后 %.4f 秒,快了 %.2f 倍"
      % (sum(before), sum(after), sum(before) / sum(after)))
print("变快 %d 条,不变 %d 条,变慢 %d 条"
      % (len(faster), len(load) - len(faster) - len(slower),
         len(slower)))

实跑输出:

--------------------------------------------------------------------
第六十八课:SQL 重演——改之前先量一遍,改之后再量一遍
--------------------------------------------------------------------

  **「加个索引会不会变快」这个问题,猜是没用的。**
  **SQL 重演的做法是:把真实跑过的 SQL 抓下来,改之前跑一遍、改之后跑一遍,逐条比耗时和计划。**
  **这一课用 80 条 SQL 的负载把这个流程走一遍。**

  一、抓负载:改之前的基线
  --------------------------------------------------------------------
  **80 条 SQL,总共 2.243 秒。**
  **这是「改之前」的基线,后面所有对比都对着它。**

  二、按访问列统计:哪几列最该建索引
  --------------------------------------------------------------------

  访问的列    出现次数
  --------------------
  deptno      40
  amt         20
  name        10
  flag+deptno 10
  已选择 4 行
  **这份统计就是「SQL 访问顾问」的输入。**
  **它的逻辑很朴素:被很多条 SQL 用到的列,建索引收益最大。**

  三、只加一个索引,重演一遍
  --------------------------------------------------------------------
  **加完索引,总耗时 1.033 秒,原来是 2.243 秒,快了 2.17 倍。**

  四、逐条比:谁变快了,谁变慢了
  --------------------------------------------------------------------

  变化     条数
  -------------
  明显变快 50
  基本不变 30
  明显变慢 0
  已选择 3 行
  **这次一条都没变慢,但别指望总是这样。**  **加索引让某些 SQL 变慢,听起来反直觉,但真会发生**——
  **优化器选了一条它以为更便宜的路,实际更贵。**
  **所以「加索引」这个动作必须重演验证,不能拍脑袋。**

  五、Oracle 里这件事怎么做
  --------------------------------------------------------------------

  [Oracle 原码 · 本沙箱不执行]
  | -- 1. 抓 SQL 集
  | BEGIN
  |   DBMS_SQLSET.CREATE_SQLSET('STS_1');
  | END;
  | /
  | -- 2. 从游标缓存里装进去(跑一段时间业务之后)
  | BEGIN
  |   DBMS_SQLSET.CAPTURE_CURSOR_CACHE_SQLSET('STS_1', 300);
  | END;
  | /
  | -- 3. 改之前跑一遍
  | DECLARE v VARCHAR2(30);
  | BEGIN
  |   v := DBMS_SQLPA.CREATE_ANALYSIS_TASK(sqlset_name => 'STS_1');
  |   DBMS_SQLPA.EXECUTE_ANALYSIS_TASK(v, 'TEST EXECUTE');
  | END;
  | /
  | -- 4. 建索引、收集统计信息,再跑一遍
  | -- 5. 出对比报告
  | SELECT DBMS_SQLPA.REPORT_ANALYSIS_TASK('TASK_1') FROM dual;

  **这套流程叫 SQL Performance Analyzer(SPA),是 11g 之后的标准做法。**
  **它的价值在于「用真实负载验证」,而不是拿一条 SQL 在测试库上试试。**
  **上面 Demo 里做的正是它的简化版:同一批 SQL,改前改后各跑一遍。**

第一段先把 80 条 SQL 跑一遍,得到基线 2.250 秒。 后面所有对比都对着这个数,所以这一步必须先把索引清干净—— 否则基线里已经含着索引的好处,改完就看不出差别了。 第二段按「访问了哪些列」统计:deptno 40 条、amt 20 条、name 10 条、 flag+deptno 10 条。 这份统计就是访问顾问的输入——它的逻辑很朴素:被很多条 SQL 用到的列,建索引收益最大。 第三段只加一个 deptno 索引,总耗时从 2.250 秒掉到 1.038 秒,快 2.17 倍。 注意只加了一个,不是三个都加。 一次只改一个变量,才知道收益是谁带来的。 第四段逐条比:50 条明显变快、30 条基本不变、0 条变慢。 「30 条不变」是因为它们查的是 amt 和 name,没有索引,还是全扫—— 这恰好说明「加错索引等于没加」。 「0 条变慢」这次是运气好,但别指望总是这样。 加索引让某些 SQL 变慢,听起来反直觉,但真会发生—— 优化器选了一条它以为更便宜的路,实际更贵。 所以这个动作必须重演验证,不能拍脑袋。 第五段是 Oracle 的原码:DBMS_SQLSET 建集合、DBMS_SQLPA 跑分析、 最后 REPORT_ANALYSIS_TASK 出报告。 这段本沙箱不执行,但它的流程和上面 Demo 完全对应—— 抓负载、跑基线、改、再跑、出对比。工具不同,思路一样。

30 秒实验

  1. 把 after 那段里的索引换成 big(amt),再跑一次。 看总耗时变多少、变快的条数是不是从 50 变成 20 左右——这就是「给谁建索引」的区别。
  2. 把 best() 的 n 从 2 改成 1,再跑一次。 看总耗时会不会明显变大、波动会不会变大——这就是「为什么必须跑多次取最快」。
  3. 在 load 里加几条 where pad = 'p' * 12 这种没人用的列,再跑。 看顾问的统计表里会不会多出一行、建索引有用没用。

常见误区

  1. 「总耗时快了就行」。 总耗时会被几条快查询拉平均。逐条比,尤其是原来就慢的那几条。
  2. 「测试库跑得快就能上线」。 数据量差一个数量级,优化器选的路完全不同。 要重演,就得让数据量也像。
  3. 「重演报告里慢了五倍,说明新版本不行」。 先看重放机的硬件。 机器本身弱五倍,报告只能看趋势、不能看绝对值。
  4. 「SQL 重演能做并发压测」。 不能。它一条条跑,看不见争用。 要测并发得上数据库重演或者压测工具。
自测三问(点开对答案) 1. 数据库重演和 SQL 重演,最根本的区别是什么? —— **数据库重演抓整个负载(含并发时序),要同样的环境;** **SQL 重演只比单条 SQL,不严格要同环境,但看不见并发。** 2. 为什么重放机的硬件必须和原机相当? —— **因为报告里比的是绝对耗时。** 机器弱五倍,耗时自然长五倍, **这个数字反映的是机器,不是变更。** 3. `EXECUTE_ANALYSIS_TASK` 的 `TEST EXECUTE` 是什么意思? —— **真执行 SQL,但把结果丢弃。** 这样能拿到真实耗时,又不会改动数据。 4. 为什么每次测量要跑三遍取最快? —— **第一遍要把数据从磁盘读进内存,量到的多半是磁盘速度。** **取最快才是纯计算的那一次。**

下一篇讲 Oracle 自带的两个顾问。 这一篇是我们自己写脚本比,下一篇是让数据库自己算「该建什么索引」。 但结论一样:顾问给建议,你负责验证。

相关笔记