去年我接手过一套从Oracle迁移到达梦的报表系统,迁移完第一周就出了个怪事:一条查订单汇总的SQL,在Oracle里跑1秒不到,到了达梦上直接跑了42秒。全组人一开始都笃定是达梦不行,后来翻执行计划才发现,根本不是引擎的问题——Oracle里那条SQL悄悄用了一个函数索引,还带了并行hint,迁移时这些索引都没建。这件事让我重新梳理了对SQL优化的理解:SQL优化从来不是背一套"万能技巧"就能通吃所有数据库的活儿,它本质上是要搞懂你正在用的这个引擎,在一条SQL进来之后,每一步是怎么拆解、怎么选路、怎么出结果的。这篇文章就围绕这个思路,把不同数据库引擎在SQL优化上的差异拆开讲一遍。
1. 慢SQL定位:不同引擎的排查入口差很远
慢SQL定位是优化的起点,但很多人第一步就走偏了。不同数据库引擎对慢SQL的日志方式、聚合方式、排查工具完全不同,同一个问题在不同引擎里需要从不同入口进去。
1.1 MySQL:慢查询日志和数据字典的组合
MySQL的慢查询日志是最直观的入口,但默认情况下它是关闭的,需要在配置文件里开通。我在生产环境通常这样设置:
code复制slow_query_log = ON
slow_query_log_file = /data/mysql/log/slow.log
long_query_time = 2
log_queries_not_using_indexes = ON
long_query_time=2意味着超过2秒才记录,这个阈值在OLTP系统里已经比较宽松了,核心表建议调到1秒。log_queries_not_using_indexes=ON我会建议先开着,它能把没走索引的查询也记下来,对排查特别有用,但这个开关在慢日志量大的时候会把日志刷得很快,所以观察一段时间后要关掉。
慢日志记录的是"执行完成"的SQL,如果一条SQL一直卡住不返回,慢日志里反而看不到它,这时要查performance_schema的events_statements_summary_by_digest,或者直接查看当前正在执行的语句。MySQL 5.7以上有了聚合好的语句摘要视图,可以按总耗时倒序排列:
sql复制SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT/1000000000 AS total_s
FROM performance_schema.events_statements_summary_by_digest
ORDER BY total_s DESC LIMIT 20;
这个视图的价值在于它能告诉你哪些SQL是"高频慢"的——单条执行只要几百毫秒但一天调用几万次,累计耗时惊人。这一类问题的优化优先级往往比单条大慢SQL更高,因为总资源消耗更大。
1.2 PostgreSQL:默认不开慢日志,但统计视图更强
PostgreSQL的慢查询日志默认也是关着的,在postgresql.conf里改参数:
code复制log_min_duration_statement = 1000
单位是毫秒,配好后需要reload配置。但PostgreSQL真正好用的还是pg_stat_statements扩展,它会自动记录所有SQL的执行次数、总耗时、平均耗时、块读写量。用之前需要先改两处配置,否则容易踩坑:
code复制shared_preload_libraries = 'pg_stat_statements'
这个参数必须写进postgresql.conf并重启数据库,然后才能CREATE EXTENSION pg_stat_statements。生产环境改配置要提前约维护窗口,很多第一次用的人在这里卡住。配置好后,按平均耗时排名的SQL这样查:
sql复制SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 20;
我在实际使用中觉得pg_stat_statements比MySQL的慢日志强在一点:它天然就按语句聚合了,不用自己去parse日志文本,而且能看到每个查询命中多少缓冲区,对判断IO压力特别有帮助。
1.3 SQL Server、Oracle和达梦:从系统视图到ET工具
SQL Server里我一般先查sys.dm_exec_query_stats,它聚合了执行计划缓存里各语句的资源消耗。一个常用查询是按CPU耗时排序,把语句文本带出来:
sql复制SELECT TOP 20 qs.total_worker_time/1000000 AS cpu_s,
SUBSTRING(st.text, (qs.statement_start_offset+2)/2,
(CASE WHEN qs.statement_end_offset = -1
THEN LEN(CONVERT(nvarchar(max), st.text))*2
ELSE qs.statement_end_offset END - qs.statement_start_offset)/2) AS stmt
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY qs.total_worker_time DESC;
SQL Server 2016以后强烈建议开启Query Store(查询存储),它比DMV更稳定,会记录执行计划的历史和走势。遇到"昨天还快今天突然慢"这类问题,Query Store能直接看出执行计划在哪个时间点发生了变化,这是DMV很难做到的。Oracle的慢SQL定位手段里,AWR报告是核心,配合v$sqlarea按elapsed_time就能找到TOP SQL。
达梦数据库和Oracle比较接近,系统视图的命名和用法有很多能对上,比如v$sql_text。达梦还提供了一个ET工具,在会话里开启ET_ON后执行目标SQL,再查询ET结果可以看到语句在节点级别的耗时分布,对定位"瓶颈在优化器还是执行器"非常有帮助。
不管哪个引擎,定位慢SQL的目标都不是单纯找出"最慢"的那一条,而是找出"总资源消耗最高"或"对业务影响最大"的SQL。单次慢但一天只跑一次,往往不如每秒执行一百次、每次100毫秒的SQL值得优化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 执行计划阅读:同一个SQL在不同引擎里的"导航路线"
执行计划是数据库告诉你"我打算怎么查这条SQL"。我遇到过很多人上来就问SQL怎么优化,我第一件事一定是让他把执行计划发出来。但不同引擎的执行计划长得完全不一样,阅读重点也不同。
2.1 MySQL EXPLAIN:重点看type和Extra
MySQL的EXPLAIN一直是预估计划,8.0里虽然加了EXPLAIN ANALYZE能看到真实执行时长,但日常诊断还是从传统EXPLAIN开始:
sql复制EXPLAIN SELECT ...;
我阅读时有几个固定习惯:
- type列:从const、eq_ref、ref、range到index、ALL,从左到右越来越差。看到ref基本放心,看到ALL通常就是问题所在。
- key列:实际用到的索引。如果是NULL但possible_keys里有索引,说明优化器评估后放弃了索引,需要想清楚为什么。
- rows列:预估扫描行数。这个值如果和实际差距很大,说明统计信息可能过期了。
- Extra列:看到Using filesort和Using temporary要警觉,这意味着临时排序或临时表,通常伴随性能瓶颈;看到Using index说明是覆盖索引,这是最优状态。
MySQL 8.0的EXPLAIN ANALYZE会真实执行SQL并打印每步的实际耗时和行数,可以拿来校验rows的预估值。但注意它会真的把数据读一遍,超大规模表选业务低峰再做。
2.2 PostgreSQL EXPLAIN ANALYZE:真实执行时间直接可见
PostgreSQL的EXPLAIN ANALYZE和MySQL有一个本质区别:它会真的把SQL跑一遍,然后把每步的实际耗时、返回行数、循环次数打印出来。这是我最喜欢PostgreSQL的一点,因为真实数据不会骗人。
执行计划文本里有一行类似这样的输出:
code复制Seq Scan on orders (cost=0.00..29608.00 rows=1600000 width=16) (actual time=0.025..295.300 rows=1600000 loops=1)
actual time和rows是实际数字,cost是预估。当actual rows和estimated rows差了一个数量级,多半是统计信息过期,或者字段分布严重倾斜导致优化器估算失真。这时候刷新统计信息通常比改SQL更有效。
PostgreSQL还支持在EXPLAIN里加BUFFERS选项,直接显示每一步的shared hit/read块数。看到某个节点read的块数特别多,就说明IO集中在那个位置,可以判断是CPU算子慢还是物理读瓶颈。
2.3 SQL Server、Oracle和达梦:三者的阅读思路对比
SQL Server里我通常直接用SSMS的图形执行计划,但图形计划有个问题:默认只显示逻辑读和物理读次数,看不到真实IO时间。所以我习惯配合SET STATISTICS IO ON和SET STATISTICS TIME ON执行查询,输出里会有每个表的扫描次数和逻辑读次数。逻辑读一次相当于读一个8KB页,某个表逻辑读几万次,基本就是性能大头了。
Oracle阅读执行计划最专业的方式是用DBMS_XPLAN:
sql复制SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(FORMAT=>'ALLSTATS LAST'));
ALLSTATS LAST能显示每一步的实际返回行数和耗时(A-Rows、A-Time),和PostgreSQL的EXPLAIN ANALYZE思路类似,对排查基数估算错误特别有用。达梦的执行计划结构和Oracle很像,但达梦在中低端环境使用较多,统计信息更新频率往往不足。我见过很多达梦慢SQL其实是统计信息缺失导致的,优化之前先刷新统计信息,往往比改SQL更有效。
执行计划的阅读不单是"看有没有走索引",更关键的是看估算和实际的偏差。优化器也是靠猜的,猜得准不准,直接决定了这条SQL的性能表现。
3. 索引设计的分水岭:聚簇结构、回表代价与覆盖策略
索引是SQL优化里最大的坑,也是不同数据库引擎差异最集中的地方。因为物理存储结构不同,同一个"建索引"动作在不同引擎里带来的代价和收益完全不一样。
3.1 MySQL InnoDB聚簇索引和PostgreSQL堆表:从物理结构开始的差异
MySQL InnoDB是聚簇索引结构:表数据本身按主键组织在B+树里,二级索引的叶子节点存的是主键值。所以二级索引查询时如果数据列不在索引里,需要拿着主键再回聚簇索引里扫一遍,这就是"回表"。
PostgreSQL不一样,它是堆表结构:索引和表数据分开存放,索引叶子节点存的是指向堆表行的指针(CTID)。从存储设计上看,PostgreSQL所有索引都是"二级索引",因为表本身不按任何索引组织。理论上任何索引访问都要有回表动作(除非走index-only scan),但PostgreSQL的bitmap index scan可以把多个索引扫描结果做位图合并后再读表,这个能力MySQL是没有的。
实际经验是:MySQL里多个独立单列索引经常发挥不了作用,要靠复合索引;PostgreSQL里组合单列索引往往能通过bitmap合并工作得很好。两边的加索引策略不能照搬,这是数据库迁移时最需要警惕的地方。
3.2 覆盖索引和回表:代价在不同引擎里完全不同
覆盖索引的意思是"查询的列都包含在索引里,不需要回表读行"。这个优化在InnoDB里收益特别明显,因为回表是随机IO;在PostgreSQL里也可以做,但PostgreSQL的index-only scan依赖可见性映射(visibility map)。如果表刚更新过一大批数据,可见性映射没跟上,它还需要回堆表确认版本可见性,性能提升会打折扣。所以PostgreSQL里要让覆盖索引稳定生效,保持及时的VACUUM非常重要。
SQL Server里覆盖索引有两种做法:把列加进索引键,或用INCLUDE关键字把列挂载在叶子节点但不参与排序。INCLUDE的方式更省空间,排序效率更高,是SQL Server特有好用的功能。
Oracle和达梦没有直接的INCLUDE语法,但Oracle从11g开始可以在索引里记录额外列,实现方式是建索引时把需要的列放在最后。不过Oracle更常用的覆盖手段是索引快速全扫描(INDEX FAST FULL SCAN),它读的是索引块而不是表块,逻辑读代价远低于回表。
3.3 索引失效场景:哪些是通杀,哪些因引擎而异
我把常见的索引失效场景整理成了一个对照表:
| 场景 | MySQL | PostgreSQL | SQL Server | Oracle/达梦 |
|---|---|---|---|---|
| 对列做函数(如YEAR(create_date)) | 不走索引 | 不走索引 | 不走索引 | 可用函数索引 |
| 隐式类型转换 | 不走索引 | 不走索引 | 不走索引 | 不走索引 |
| LIKE '%xxx' 前缀模糊 | 不走索引 | 不走索引 | 不走索引 | 不走索引 |
| OR 条件跨列 | 通常退化全扫 | 可用bitmap合并 | 优化器自动处理 | 自动处理较好 |
| IS NULL 查询 | 可走索引 | 可走索引 | 可走索引 | 普通索引常不走 |
函数索引这一点最值得展开。Oracle和达梦都支持在表达式上建索引,比如CREATE INDEX idx_upper ON t(UPPER(name)),然后查询写成WHERE UPPER(name)='ABC'就能走索引。MySQL 8.0虽然也支持在创建索引时使用表达式语法,但生态和优化器支持都不如Oracle成熟。MySQL里遇到函数条件,多数情况还是要改写SQL,比如把WHERE YEAR(create_date)=2024改成WHERE create_date >= '2024-01-01' AND create_date < '2025-01-01',让索引生效。
3.4 复合索引的左前缀原则和排序方向
复合索引的左前缀原则在所有引擎中基本通用:对(a,b,c)建复合索引,能用到a、a+b、a+b+c三个组合,但跳过a直接用b通常不行。PostgreSQL有skip scan能部分突破,MySQL 8.0的skip scan也有类似功能,但都有限制。
还有一点容易忽略的是排序方向。MySQL 8.0以前,复合索引的DESC排序通过反向扫描来优化,8.0开始支持真正意义上的降序索引;PostgreSQL天然支持索引列排序方向;SQL Server和Oracle对ORDER BY的优化依赖索引键顺序。
如果一条SQL是联合条件加排序,比如WHERE status=? ORDER BY create_time DESC,最理想的复合索引是(status, create_time)。这个顺序既满足等值条件,又让排序直接走索引顺序,避免filesort。这种索引设计思路在所有引擎里是一致的,属于通用优化手段。
4. SQL写法的通用经验与引擎特性对照
执行计划和索引是优化的大头,但SQL写法本身的调整也能带来明显收益。这一部分技巧大多数引擎通用,只是写法形态在不同引擎里会有所变化。
4.1 分页查询:LIMIT深分页、ROWNUM和OFFSET FETCH的取舍
深分页是所有做业务系统的人躲不开的问题。LIMIT 100000,20在MySQL里意味着要先扫100020行再丢掉前10万行,越到后面越慢。比较好的方案有两种。
第一种是延迟关联,先查主键再查明细:
sql复制SELECT o.* FROM orders o
JOIN (SELECT id FROM orders WHERE user_id=123 ORDER BY id LIMIT 100000,20) tmp
ON o.id = tmp.id;
第二种是keyset分页,更适合滚动加载场景:
sql复制SELECT * FROM orders
WHERE user_id=123 AND id > 100000
ORDER BY id LIMIT 20;
SQL Server 2012以前没有OFFSET语法,需要用ROW_NUMBER()包一层;2012以后可以用OFFSET ... FETCH。Oracle 12c以后也支持OFFSET ... FETCH,老版本则必须用ROWNUM子查询。不管哪个引擎,表很大的情况下,keyset分页永远比offset分页稳定。
4.2 去重、空值与NULL:DISTINCT、GROUP BY、NOT IN的陷阱
去重最直接的写法是SELECT DISTINCT,但DISTINCT经常会触发排序或哈希去重,在MySQL里容易出现Using temporary。如果业务只需要按某个维度取最新一条记录,用窗口函数更合适:
sql复制SELECT * FROM (
SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) rn
FROM orders t
) x WHERE x.rn = 1;
这个写法在MySQL 8.0、PostgreSQL、SQL Server 2005+、Oracle 9i+都支持,是跨引擎最通用的去重手段。唯一要注意的是MySQL 5.7及以下版本不支持窗口函数,还在用老版本的只能先升级或绕行。
NULL值是最容易写错的地方。COUNT(*)统计行数,COUNT(column)统计非NULL值个数;NOT IN子查询里只要子查询结果出现一个NULL,整个表达式结果为NULL,外层查询就会返回空集,这个坑在几乎所有引擎里都存在。如果子查询的列允许NULL,应该用NOT EXISTS替代NOT IN,或者先在子查询里过滤掉NULL。
关于BETWEEN ... AND ...,它是闭区间,包含两端。如果字段本身有NULL,NULL永远不在BETWEEN范围内,这个细节很多人容易忽略。
4.3 绑定变量与SQL注入:性能与安全是同一件事
SQL注入是经典安全话题,但从性能角度多讲一句:绑定变量(参数化查询)对数据库执行计划复用非常重要。
Oracle里,独立字面量SQL每次进来都要硬解析,相同结构的SQL如果使用绑定变量就是软解析或软软解析;SQL Server会按文本匹配执行计划缓存,文本稍有一点不一样就缓存不上;MySQL解析代价相对低,但预编译语句也能减少重复解析。
在MyBatis里,这就体现为能用#{}就不要用${}。${}会把变量直接拼进SQL,既有SQL注入风险,又会破坏执行计划缓存。#{}走PreparedStatement绑定,既安全又利于计划复用。热词里看到"sql注入万能密码绕过"这种搜索词,我提醒一句:所有通过字符串拼接构造SQL的方式都是这类问题的根源,绑定变量是治本的手段。
4.4 WITH AS和临时表:什么时候值得用
WITH AS(CTE)让SQL可读性提升一大截,但性能上要小心。在PostgreSQL和Oracle里,CTE默认可能会
