数据库性能优化,在很多人眼里是门"玄学"——慢了就加个索引,还慢就加内存,再慢就分库分表。这套凭感觉的打法,偶尔管用,经常把系统搞得更糟。
真正的性能优化是有方法论的:先定位瓶颈在哪,再看执行计划为什么慢,然后针对性地改索引 / 改 SQL / 改结构,最后验证。 跳过前两步直接动手,是优化最大的原罪。
本文以 Oracle 为主线(它的 AWR / ASH / DBMS_XPLAN 工具链最完整,是 DB 性能方法论的"参考实现"),在每个分叉点给出 MySQL / PostgreSQL / SQL Server 的命令对照。原理通用,命令分库——方法论可以平移到任何一个关系数据库。
这是数据库系列的实战补充:系列(三)讲了索引是什么、(九)讲了优化器怎么工作,这篇回答的是手上拿到一个慢查询,怎么把它调快。
一、方法论:先定位,再优化
把优化分成三个层次,自顶向下,先确定"病在哪一层"再开方:
| |
最大的反模式:跳过定位,直接"加索引"。 索引不是越多越好——它加速读,却拖慢写、占空间、还可能因为统计信息不准而根本不被使用。正确的姿势永远是:先用工具看清"慢在哪、为什么慢",再做最小且对症的改动。
二、第一步 · 定位慢查询
优化的第一步不是改 SQL,而是找出哪条 SQL 值得改。两个维度:累计耗时最长的和执行次数最多 × 单次代价的。
Oracle 主线:AWR + ASH + V$SQL
许可证提醒:AWR、ASH 以及相关历史视图属于 Oracle Diagnostics Pack 的能力。使用前先核对数据库版本、版本授权和
CONTROL_MANAGEMENT_PACK_ACCESS配置;没有相应许可时,改用动态性能视图、Statspack 等已获许可的工具,不能因为命令可执行就默认有权使用。
- AWR(Automatic Workload Repository):周期性快照,记录系统统计。两个快照之间生成报告,直接看 “Top SQL by Elapsed Time / by Buffer Gets”。
1sqlplus / as sysdba @?/rdbms/admin/awrrpt.sql # 选快照范围,生成 HTML 报告 - ASH(Active Session History):通常每秒采样一次活跃会话,用于定位"某个时间点系统在等什么"。内存中的样本可从
V$ACTIVE_SESSION_HISTORY查看,部分样本会进入 AWR 历史数据;两者都受 Diagnostics Pack 许可约束。 - V$SQL 找 Top SQL:
1 2 3 4 5-- 平均单次耗时最长的 SQL SELECT sql_id, executions, elapsed_time/1e6 AS elapsed_s, ROUND(elapsed_time/GREATEST(executions,1)/1e6, 3) AS avg_s FROM v$sql ORDER BY avg_s DESC FETCH FIRST 20 ROWS ONLY; - 等待事件(wait events)判断瓶颈类型,这是 Oracle 方法论的精华:
db file sequential read→ 在做索引单块读(正常,但过多说明扫描次数多)db file scattered read→ 在做多块读,常见于全表扫描或快速全索引扫描;是否异常要结合对象、访问路径和累计等待判断log file sync→ 前台会话在等待 commit redo 刷盘;可能与提交过频、存储延迟或日志路径负载有关,需继续分解enq: TX - row lock contention→ 行锁等待,并发写冲突buffer busy waits/latch: ...→ 热块 / 内存结构竞争
四库对照:慢查询定位工具
| 维度 | Oracle(主线) | MySQL | PostgreSQL | SQL Server |
|---|---|---|---|---|
| 历史 Top SQL | AWR 报告 / DBA_HIST_SQLSTAT | performance_schema / sys schema | pg_stat_statements | Query Store / sys.dm_exec_query_stats |
| 实时活跃会话 | ASH / V$SESSION | sys.processlist / performance_schema.threads | pg_stat_activity | sys.dm_exec_requests / sys.dm_os_waiting_tasks |
| 等待事件 | V$SESSION_WAIT / V$SYSTEM_EVENT | performance_schema.events_waits_* | pg_stat_activity.wait_event | sys.dm_os_wait_stats(等待统计的标杆) |
| 慢日志 | 可选,通常用 AWR | slow_query_log + long_query_time | log_min_duration_statement | 扩展事件 / 跟踪标志 |
通用法则:先看等待事件回答"在等什么"(CPU / IO / 锁 / 日志),再用 Top SQL 回答"谁在等"。两者一交叉,病根就出来了。
三、第二步 · 读懂执行计划
这是性能优化最核心的技能。看不懂执行计划,等于在黑暗里改 SQL。
Oracle 主线:DBMS_XPLAN
- 估算计划(不实际执行):
1 2EXPLAIN PLAN FOR SELECT ... ; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY()); - 真实计划(执行后看真实行数 / 时间,这才是生产排障用的):
1 2 3 4-- 先让本次会话收集执行统计 ALTER SESSION SET STATISTICS_LEVEL = ALL; -- 执行目标 SQL,然后取其游标的真实计划 SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));ALLSTATS LAST会显示A-Rows(真实行数)、Starts(执行次数)、A-Time(真实耗时)、Buffers(逻辑读)。
看计划时盯住这几样(通用)
- 真实行数 vs 估算行数:差一个数量级以上 → 统计信息过期,优化器基于错误估算选了烂计划。这是性能问题头号元凶。
- 访问方式:
TABLE ACCESS FULL(全表扫)、INDEX RANGE SCAN(索引范围扫)、INDEX UNIQUE SCAN(索引唯一扫)。全表扫不一定坏(小表、或返回大比例数据时全扫反而更快),但大表全扫 + 高频 = 红灯。 - JOIN 方式:
NESTED LOOPS(适合驱动表小、内表走索引)、HASH JOIN(适合大表等值连接)、SORT MERGE JOIN。选错驱动表或 JOIN 方式是常见瓶颈。 - 有没有落盘排序/临时:
SORT但内存不够 → 写 temp,代价陡增。
四库对照:执行计划
| 维度 | Oracle(主线) | MySQL | PostgreSQL | SQL Server |
|---|---|---|---|---|
| 估算计划 | EXPLAIN PLAN FOR + DBMS_XPLAN | EXPLAIN | EXPLAIN | SET SHOWPLAN_XML/TEXT ON |
| 真实计划 | DBMS_XPLAN.DISPLAY_CURSOR('ALLSTATS LAST') | EXPLAIN ANALYZE(8.0+) | EXPLAIN (ANALYZE, BUFFERS) | 实际执行计划(图形)/STATISTICS IO |
| 关键看点 | A-Rows vs E-Rows、Buffers | type(ALL/range/ref/eq_ref)、Extra(Using index/Using filesort) | rows(估) vs actual rows(真)、Buffers: shared hit/read | 估计行数 vs 实际行数、逻辑读 |
| 强制统计收集 | DBMS_STATS.GATHER_* | ANALYZE TABLE | ANALYZE / autovacuum | 自动(可手动 UPDATE STATISTICS) |
通用判断:统计信息要准(这是地基);警惕"估算行数远小于真实行数"导致的错误 JOIN 顺序;任何"全表扫大表 + 高频"或"落盘排序"都值得深究。
四、第三步 · 索引调优
这是性价比最高、最通用的优化手段。系列(三)讲了索引的数据结构,这里讲怎么用索引提速。
通用法则
- 从
WHERE/JOIN/ORDER BY/GROUP BY的真实访问模式中寻找候选索引,再结合选择性、扫描行数、排序需求和写入成本验证;不是出现过的每一列都应该建索引。 - 复合索引与前导列:
(a, b, c)通常能高效服务以a开头的条件;只查b时一般不能做常规的前导列范围定位,但某些数据库和计划也可能选择跳跃扫描或扫描整个索引。列顺序要同时考虑等值/范围条件、选择性、排序、覆盖和真实查询模式,不能只套“等值在前、范围在后”的口诀。 - 覆盖索引避免回表:查询列都在索引里,就不用回表取数据。
SELECT别写*,只取需要的列,才有机会用覆盖索引。 - 警惕索引失效的写法(让优化器放弃索引、退回全表扫):
- 函数包裹列:
WHERE YEAR(create_time)=2024→ 改WHERE create_time >= ... AND create_time < ... - 隐式类型转换:
WHERE phone = 13800000000(列是字符,值是数字)→ 统一类型 - 前导通配:
WHERE name LIKE '%张'→ 走不了普通 B 树索引 OR两边不全有索引 → 考虑拆UNION ALL或补索引IS NULL的处理各库不同(PG 会索引 NULL,部分库默认不索引)
- 函数包裹列:
Oracle 索引类型补充
- B-tree(默认)、位图索引(低基数列、数据仓库,OLTP 慎用)、反键索引(单调递增键消除热块)、函数索引(
CREATE INDEX i ON t(UPPER(name)))、索引组织表(IOT)。
四库对照:索引能力
| 维度 | Oracle | MySQL(InnoDB) | PostgreSQL | SQL Server |
|---|---|---|---|---|
| 常见表模型 | 堆表 | 聚簇组织(主键叶节点存行数据) | 堆表 | 堆表或聚簇索引表(取决于是否建立聚簇索引) |
| 特色索引 | 位图 / 反键 / 函数 / IOT | 全文 / 空间 / 函数(8.0+) | 部分索引 / 表达式索引 / GIN | 列存索引 / 包含列(INCLUDE) |
| NULL 是否入普通 B-tree 索引 | 全部键列都为 NULL 的行不入索引 | 入 | 入 | 入 |
| 收集统计 | DBMS_STATS | ANALYZE | ANALYZE / autovacuum | 自动 / UPDATE STATISTICS |
通用铁律:加了索引要验证它真被用了(看执行计划),并保持统计信息新鲜。索引建了却没被用,是最常见的"优化了个寂寞"。
五、第四步 · SQL 改写
写法不对,再好的索引也救不了。下面这些反模式几乎全库通用。
通用反模式
SELECT *:多取列 → 无法用覆盖索引、增加网络与内存开销。永远只取需要的列。- N+1 查询:循环里逐条查(通常是 ORM 的锅)。→ 改成一次
IN/ 批量查。 - 深分页:
OFFSET 1000000 LIMIT 20要先扫过前 100 万行。→ keyset(游标)分页:WHERE id > :last_id ORDER BY id LIMIT 20。 IN子查询:现代优化器往往能把它改写为半连接,但复杂相关子查询或估算失准时仍可能产生差计划。先看执行计划,再考虑等价地改写为JOIN或EXISTS;改写时注意重复行和 NULL 语义。COUNT(*)全表:大表上很贵。→ 业务能接受就用近似值,或维护计数表。- 笛卡尔积 /
DISTINCT滥用:JOIN 条件缺失或多余的去重,常常是中间结果集爆炸的根源。 - 相关子查询:每行都执行一次。→ 改成
JOIN或窗口函数。
四库对照:分页与批量写语法
| 操作 | Oracle | MySQL / PostgreSQL | SQL Server |
|---|---|---|---|
| 分页 | OFFSET n ROWS FETCH NEXT m ROWS ONLY(12c+)或 ROWNUM | LIMIT m OFFSET n | OFFSET n ROWS FETCH NEXT m ROWS ONLY |
| keyset 分页 | WHERE id > :last ORDER BY id FETCH NEXT 20 ROWS ONLY | WHERE id > :last ORDER BY id LIMIT 20 | 同 Oracle |
| 批量插入 | 多行 INSERT / INSERT ALL / PL/SQL FORALL / SQL*Loader | 多行 INSERT / LOAD DATA INFILE(MySQL)、COPY(PG) | BULK INSERT / 表值参数 |
改写的核心思想:让优化器能走索引、减少中间结果集、减少排序与聚合代价。每改一处,用执行计划前后对比验证。
六、第五步 · 写优化与批量
读优化讲得多,但写往往是系统瓶颈所在。
- 批量 > 循环单条:Oracle 用
FORALL批量绑定、多行INSERT、SQL*Loader/ 外部表;MySQLLOAD DATA INFILE;PGCOPY;SQL ServerBULK INSERT/SqlBulkCopy。逐条 INSERT + 逐条 commit 是性能杀手。 - 合理的事务粒度:
log file sync(Oracle)/ binlog 同步代价来自 commit。太小(commit 太频繁)→ 日志成为瓶颈;太大 → 锁持有久、回滚贵。按业务折中。 - 大批量写入前先减负:在维护窗口内,可评估先移除非必要的非唯一索引、装载后再重建。Oracle 的 direct-path insert 可配合
APPEND,NOLOGGING仅在满足条件的操作上减少 redo,并会影响介质恢复和 Data Guard 可恢复性;使用后必须按备份与容灾策略处理,不能把它当成通用开关。 - 顺序键通常减少随机页访问,但可能形成热点:自增主键 / 序列有利于局部性,却可能在高并发下让最右侧叶块或页成为竞争点;应以目标数据库和负载实测为准。
七、第六步 · 结构与连接层
SQL 和索引调到头了,就上结构层:
- 连接池:短连接每次建连 + 认证开销巨大。务必用连接池(Oracle 用 UCP / 应用侧 HikariCP,PG 配 pgbouncer,MySQL/SQL Server 用应用侧池),池大小不是越大越好(数据库的进程/线程和内存有上限)。
- 读写分离:读多写少的场景,主写从读,把读压力分摊到副本。
- 分区(Partitioning):大表按范围 / 列表 / 哈希分区,配合分区裁剪(
partition pruning),让查询只扫相关分区。 - 反范式 / 冗余:为了少 JOIN,适度冗余高 frequency 读取的字段(用空间换时间,要权衡一致性)。
- 缓存层:热点数据放 Redis,挡在数据库前面。
四库对照:分区
| 维度 | Oracle | MySQL | PostgreSQL | SQL Server |
|---|---|---|---|---|
| 分区类型 | 范围 / 列表 / 哈希 / 组合 | 范围 / 列表 / 哈希(RANGE/LIST/HASH) | 范围 / 列表 / 哈希 | 基于分区函数边界的范围分区 |
| 分区裁剪 | 自动 | 自动 | 自动 | 自动 |
| 快速装入/移出分区 | EXCHANGE PARTITION | ALTER TABLE ... EXCHANGE PARTITION | ATTACH PARTITION / DETACH PARTITION | ALTER TABLE ... SWITCH PARTITION |
八、避坑清单 + 结语
常见坑:
- 凭感觉加索引,不验证是否真被用、不收集统计信息。
- 不看执行计划就改 SQL,改完不对比。
- 只盯着单条 SQL,忽略等待事件暴露的系统性问题。
OFFSET深分页、N+1、SELECT *这类反模式。- 忽略写入代价:逐条 commit、大批量写入时不减负。
- 过度优化:为了 1% 的场景把系统搞复杂,得不偿失。
结语:
数据库性能优化不是"加索引"三个字,而是一套先定位、再诊断、后动手的工程方法论。它的通用内核是:用等待事件回答"系统在等什么",用执行计划回答"这条 SQL 为什么慢",用索引 / SQL / 结构三层武器对症下药,每一步都用数据(执行计划、统计)验证,而不是靠手感。
这套方法论在 Oracle 上最成熟(AWR / ASH / DBMS_XPLAN 是标杆),但思想完全平移到 MySQL、PostgreSQL、SQL Server——工具名换了,逻辑不变。掌握了它,你面对任何一个"慢"字,都不再是玄学,而是可拆解、可验证的工程问题。