「在测试库上跑得挺快」是个幻觉——数据量、参数、并发都不一样。 重演就是把这些差别抹掉:把生产上真实发生过的负载搬过来,再放一遍。 两种重演分工明确:数据库重演看「整体会不会变慢」(升级、换硬件用), 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 秒实验
- 把
after那段里的索引换成big(amt),再跑一次。 看总耗时变多少、变快的条数是不是从 50 变成 20 左右——这就是「给谁建索引」的区别。 - 把
best()的n从 2 改成 1,再跑一次。 看总耗时会不会明显变大、波动会不会变大——这就是「为什么必须跑多次取最快」。 - 在
load里加几条where pad = 'p' * 12这种没人用的列,再跑。 看顾问的统计表里会不会多出一行、建索引有用没用。
常见误区
- 「总耗时快了就行」。 总耗时会被几条快查询拉平均。逐条比,尤其是原来就慢的那几条。
- 「测试库跑得快就能上线」。 数据量差一个数量级,优化器选的路完全不同。 要重演,就得让数据量也像。
- 「重演报告里慢了五倍,说明新版本不行」。 先看重放机的硬件。 机器本身弱五倍,报告只能看趋势、不能看绝对值。
- 「SQL 重演能做并发压测」。 不能。它一条条跑,看不见争用。 要测并发得上数据库重演或者压测工具。
自测三问(点开对答案)
1. 数据库重演和 SQL 重演,最根本的区别是什么? —— **数据库重演抓整个负载(含并发时序),要同样的环境;** **SQL 重演只比单条 SQL,不严格要同环境,但看不见并发。** 2. 为什么重放机的硬件必须和原机相当? —— **因为报告里比的是绝对耗时。** 机器弱五倍,耗时自然长五倍, **这个数字反映的是机器,不是变更。** 3. `EXECUTE_ANALYSIS_TASK` 的 `TEST EXECUTE` 是什么意思? —— **真执行 SQL,但把结果丢弃。** 这样能拿到真实耗时,又不会改动数据。 4. 为什么每次测量要跑三遍取最快? —— **第一遍要把数据从磁盘读进内存,量到的多半是磁盘速度。** **取最快才是纯计算的那一次。**下一篇讲 Oracle 自带的两个顾问。 这一篇是我们自己写脚本比,下一篇是让数据库自己算「该建什么索引」。 但结论一样:顾问给建议,你负责验证。
- ☐ (本篇完,下一篇 15.6 Oracle 的性能顾问)
相关笔记
- 优化器的使用
- 合理使用索引
- 常规 SQL 语句优化
-
- 🐬 MySQL必知必会/00-索引
- 🏺 Oracle从入门到精通 · 索引