数据库系列(九):SQL 方言与查询优化器 — 为什么同样的 SQL 性能差 10 倍

写在前面

前面 8 篇都在讲存储与事务的底层机制,本文跳出"物理层",进入"逻辑层"——SQL 方言与查询优化器

本文要回答:

同样的 SQL,为什么在 PostgreSQL 比 MySQL 快 10 倍?RBO 和 CBO 有什么区别?统计信息为什么这么重要?为什么参数嗅探是个坑?


一、SQL 标准与方言

1.1 SQL 标准的演进

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
SQL-86(SQL-87):第一版,IBM DB2 主导
SQL-89:minor update
SQL-92(SQL2):重大升级,加入 JOIN 语法
SQL:1999(SQL3):触发器、递归、正则
SQL:2003:窗口函数、XML、自动生成 ID
SQL:2006:XML 增强
SQL:2008:INSTEAD OF、TRUNCATE
SQL:2011:时态表
SQL:2016:JSON、行模式识别
SQL:2019:多维数组
1
2
3
4
观察:
  - SQL 标准 ≠ SQL 实现
  - 每家数据库都"接近标准 + 自有扩展"
  - 实际开发中以厂商文档为准

1.2 各家方言的特点

数据库 方言 风格
Oracle PL/SQL 过程化、Ada 风格
SQL Server T-SQL 过程化、C 风格
MySQL SQL(兼容标准) 极简
PostgreSQL PL/pgSQL 类 PL/SQL、Oracle 风格
1
2
3
4
5
关键观察:
  - Oracle 的 PL/SQL 是最"重"的(包、过程、触发器、对象类型)
  - PG 的 PL/pgSQL 模仿 Oracle(让 Oracle 用户过渡平滑)
  - MySQL 没有强"方言",更多是 ANSI SQL
  - SQL Server 的 T-SQL 在微软生态独特

二、数据类型对比

2.1 数值类型

用途 Oracle SQL Server MySQL PostgreSQL
整数(小) NUMBER(7,0) TINYINT / SMALLINT TINYINT / SMALLINT smallint
整数(标准) NUMBER(10,0) INT INT integer
整数(大) NUMBER(19,0) BIGINT BIGINT bigint
自定义精度 NUMBER(p,s) DECIMAL(p,s) DECIMAL(p,s) numeric(p,s)
浮点 BINARY_FLOAT/DOUBLE REAL/FLOAT FLOAT/DOUBLE real/double
自增 SEQUENCE + 触发器 IDENTITY AUTO_INCREMENT SERIAL/BIGSERIAL
1
2
3
4
5
陷阱:
  - Oracle 没有 INT 关键字(其实有别名,但底层都是 NUMBER)
  - Oracle 的 NUMBER(10,0) 占 8~21 字节,比 PG 的 int(4 字节)大
  - MySQL 的 TINYINT(1) 经常被当成 boolean,但存储的还是数字
  - PostgreSQL 的 SERIAL 是 SEQUENCE + DEFAULT 的语法糖

2.2 自增主键

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- Oracle 12c+ 终于有 IDENTITY
CREATE TABLE t (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY);

-- 12c 之前:SEQUENCE + 触发器
CREATE SEQUENCE seq_t START WITH 1;
CREATE TABLE t (id INT PRIMARY KEY);
CREATE TRIGGER t_trg BEFORE INSERT ON t
FOR EACH ROW
BEGIN
  SELECT seq_t.NEXTVAL INTO :new.id FROM dual;
END;

-- SQL Server
CREATE TABLE t (id INT IDENTITY(1,1) PRIMARY KEY);

-- MySQL
CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY);

-- PostgreSQL
CREATE TABLE t (id SERIAL PRIMARY KEY);  -- 9.x 经典
-- 或
CREATE TABLE t (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY);  -- 10+

2.3 字符串类型

用途 Oracle SQL Server MySQL PostgreSQL
定长 CHAR CHAR CHAR char
变长 VARCHAR2 VARCHAR VARCHAR varchar
大文本 CLOB VARCHAR(MAX) / TEXT TEXT/LONGTEXT text
二进制 BLOB VARBINARY(MAX) BLOB/LONGBLOB bytea
1
2
3
4
5
坑:
  - Oracle  VARCHAR2 vs VARCHAR:官方推荐 VARCHAR2
  - MySQL  VARCHAR(255) 之内只用 1 字节存长度,256+  2 字节
  - PostgreSQL 没有 VARCHAR2,统一 varchar
  - SQL Server  NVARCHAR  UTF-16VARCHAR 是单字节

2.4 时间类型

用途 Oracle SQL Server MySQL PostgreSQL
日期 DATE(含时间) DATE DATE date
时间 TIMESTAMP TIME TIME time
时间戳 TIMESTAMP DATETIME2 / DATETIME DATETIME / TIMESTAMP timestamp
时区感知 TIMESTAMP WITH TIME ZONE DATETIMEOFFSET TIMESTAMP(无) timestamptz
区间 INTERVAL DAY TO SECOND —— —— interval
1
2
3
4
5
MySQL 的 TIMESTAMP 坑:
  - 范围 1970~2038(4 字节)
  - 自动 UTC 转换
  - 5.6.4+ 支持小数秒
  - DATETIME 不做时区转换,8 字节

2.5 JSON 与高级类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- MySQL 5.7+
CREATE TABLE t (data JSON);
SELECT data->'$.name' FROM t;

-- PostgreSQL 9.2+(JSON)/ 9.4+(JSONB)
CREATE TABLE t (data JSONB);
CREATE INDEX idx_data ON t USING GIN(data);
SELECT * FROM t WHERE data @> '{"name":"Alice"}';

-- SQL Server 2016+
CREATE TABLE t (data NVARCHAR(MAX) CHECK (ISJSON(data) > 0));

-- Oracle 12c+
CREATE TABLE t (data CLOB CHECK (data IS JSON));
1
2
3
4
观察:
  - PG 的 JSONB 最强(二进制存储、GIN 索引、丰富的操作符)
  - MySQL 的 JSON 是文本 + 解析(功能完整但性能不如 JSONB)
  - SQL Server / Oracle 都是"存文本 + 验证"

三、CTE 与窗口函数

3.1 CTE(Common Table Expression)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 标准 SQL,四家都支持
WITH active_users AS (
  SELECT id, name FROM users WHERE status = 'active'
),
recent_orders AS (
  SELECT user_id, COUNT(*) AS cnt FROM orders
  WHERE created_at > NOW() - INTERVAL '7 days'
  GROUP BY user_id
)
SELECT u.name, COALESCE(r.cnt, 0) AS order_count
FROM active_users u
LEFT JOIN recent_orders r ON u.id = r.user_id;

3.2 递归 CTE

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- 经典场景:组织架构、菜单树、路径搜索
WITH RECURSIVE org_tree AS (
  -- 基础查询:CEO
  SELECT id, name, parent_id, 1 AS level
  FROM employees
  WHERE id = 1

  UNION ALL

  -- 递归:下属
  SELECT e.id, e.name, e.parent_id, ot.level + 1
  FROM employees e
  JOIN org_tree ot ON e.parent_id = ot.id
)
SELECT level, name FROM org_tree ORDER BY level;
1
2
3
4
5
四家支持情况:
  - PostgreSQL:WITH RECURSIVE
  - SQL Server:WITH(不需 RECURSIVE 关键字)
  - Oracle:WITH(11g R2+)
  - MySQL:WITH RECURSIVE(8.0+)

3.3 窗口函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 排名:每个部门薪水前 3
SELECT dept, name, salary,
       ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn,
       RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rk,
       DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS dense_rk
FROM employees
ORDER BY dept, rn;

-- 累计求和
SELECT date, sales,
       SUM(sales) OVER (ORDER BY date) AS cum_sales
FROM daily_sales;

-- 移动平均
SELECT date, sales,
       AVG(sales) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_sales;

-- LAG / LEAD(前后行比较)
SELECT date, sales,
       LAG(sales) OVER (ORDER BY date) AS prev_sales,
       sales - LAG(sales) OVER (ORDER BY date) AS diff
FROM daily_sales;
1
2
3
4
5
6
支持情况:
  - 窗口函数:SQL:2003 标准
  - PostgreSQL 8.4+
  - SQL Server 2005+
  - Oracle 8i+
  - MySQL 8.0+(最晚加入的)

四、查询优化器

4.1 RBO vs CBO

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
RBO(Rule-Based Optimizer):
  - 基于规则
  - 比如:"有索引就走索引"
  - 不考虑数据分布
  - 简单但僵化
  - Oracle 早期用,现代基本淘汰

CBO(Cost-Based Optimizer):
  - 基于代价
  - 估算每种执行计划的代价(CPU + I/O)
  - 选最优
  - 需要"统计信息"支撑
  - 现代主流

4.2 CBO 的工作流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
1. 解析 SQL → AST
2. 查询重写(Query Rewrite)
   - 视图展开
   - 谓词下推
   - 子查询展开
   - 常量折叠

3. 计划枚举
   - 单表访问路径(全表扫 / 索引扫 / 索引合并)
   - JOIN 顺序枚举(A JOIN B vs B JOIN A)
   - JOIN 方法(Nested Loop / Hash / Merge)

4. 代价估算
   - 单表代价 = 行数 × 选择率 × 每行代价
   - JOIN 代价 = 左代价 + 右代价 + 连接代价
   - 综合考虑 CPU + I/O

5. 选择最小代价的计划

6. 计划执行

4.3 各家优化器

数据库 优化器 特点
Oracle CBO 业界标杆,统计信息丰富,Hint 完善
SQL Server CBO 基于 Cascades 框架
MySQL CBO(5.7+) 相对简单,Hint 不如 Oracle
PostgreSQL CBO + GEQO 大表 JOIN 用遗传算法

4.4 PostgreSQL 的 GEQO

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
问题:多表 JOIN 的顺序枚举复杂度爆炸
  - N 张表 JOIN,组合数 ~ N!
  - 12 表 JOIN:~4.79 亿种顺序

PG 的解法:
  - 表数 ≥ geqo_threshold(默认 12)
  - 切换到 GEQO(Genetic Query Optimization)
  - 用遗传算法找近似最优

代价:
  - 不保证最优
  - 但避免了组合爆炸

五、统计信息

5.1 CBO 的"输入"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
CBO 基于统计信息估算:
  - 表行数
  - 列的基数(distinct value 数)
  - 列的最值
  - 数据分布(直方图)
  - 索引高度、叶子数
  - 索引聚簇因子(Cluster Factor)

没有统计信息:
  - CBO 只能猜
  - 经常选错索引
  - 经常全表扫

5.2 直方图

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
直方图:解决"数据分布不均"

例:status 字段 99% 是 'A',1% 是 'B'
  WHERE status = 'A' → 99% 行匹配
  WHERE status = 'B' → 1% 行匹配

没直方图:CBO 估算都是 50%(默认假设均匀分布)
有直方图:CBO 知道 'A' 99%,'B' 1%,做精确选择

类型:
  - 频率直方图(Frequency Histogram)
  - 等高直方图(Height-Balanced Histogram)
  - 顶级频率(Top-Frequency)
  - 混合(Hybrid,Oracle 12c+)

5.3 收集统计信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- Oracle
EXEC DBMS_STATS.GATHER_TABLE_STATS('SCHEMA', 'TABLE', cascade => TRUE);
EXEC DBMS_STATS.GATHER_SCHEMA_STATS('SCHEMA');

-- SQL Server
UPDATE STATISTICS t;
-- 自动:默认开启(auto_update_statistics)

-- MySQL
ANALYZE TABLE t;
-- 自动:8.0+ 自动采样

-- PostgreSQL
ANALYZE t;
-- 自动:autovacuum 自动分析
1
2
3
4
5
关键配置:
  - 目标行数(采样精度)
  - 是否生成直方图
  - 收集频率
  - 收集时机(避开业务高峰)

六、执行计划

6.1 四家的查看方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- Oracle
EXPLAIN PLAN FOR SELECT * FROM t WHERE id = 1;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- 或更详细:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));

-- SQL Server
SET SHOWPLAN_TEXT ON;
GO
SELECT * FROM t WHERE id = 1;
GO
-- 或图形:SSMS → 显示估计的执行计划

-- MySQL
EXPLAIN SELECT * FROM t WHERE id = 1;
EXPLAIN FORMAT=JSON SELECT * FROM t WHERE id = 1;
EXPLAIN ANALYZE SELECT * FROM t WHERE id = 1;  -- 8.0+,实际执行

-- PostgreSQL
EXPLAIN SELECT * FROM t WHERE id = 1;
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM t WHERE id = 1;

6.2 读懂执行计划

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
关键字:
  Seq Scan:顺序扫描(全表扫)
  Index Scan:索引扫描(取行)
  Index Only Scan:覆盖索引(不回表)
  Bitmap Heap Scan:先索引位图,再批量取行
  Hash Join:哈希连接(适合大表)
  Nested Loop:嵌套循环(适合小表)
  Merge Join:归并连接(已有序)
  HashAggregate:哈希聚合
  Sort:排序

阅读顺序:
  - 从最里层(缩进最深)开始读
  - 自下而上组合
  - 关注 rows、cost、actual time

判断:
  - "rows 估算 = 实际行数" → 统计信息准确
  - "rows 估算 << 实际行数" → 统计信息过时
  - "Seq Scan 在大表" → 缺索引
  - "Hash Join 内存爆炸" → 内存配置低

6.3 EXPLAIN ANALYZE 的威力

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- PostgreSQL
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT u.name, COUNT(*) AS cnt
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2026-01-01'
GROUP BY u.name
ORDER BY cnt DESC
LIMIT 10;

-- 输出包含:
  - 估算行数 vs 实际行数
  - 估算代价 vs 实际时间
  - 缓冲区命中(shared hits vs read
  - 内存使用(Hash Memory
  - IO 时间

七、常见坑与优化

7.1 隐式类型转换

1
2
3
4
5
6
7
8
-- 经典坑:phone 是 VARCHAR,传 INT
SELECT * FROM users WHERE phone = 13800000000;

MySQL:自动转换  CAST(phone AS INT)  不走索引
PG:报错(更严格,避免错误)
Oracle:可能转换  索引失效

 解决:传字符串 '13800000000'

7.2 参数嗅探(Parameter Sniffing)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
SQL Server 的经典问题:

  CREATE PROCEDURE get_user(@age INT) AS
    SELECT * FROM users WHERE age = @age;
  GO

  -- 第一次执行:@age = 18
  -- CBO 看 'age=18' 选择率高 → 走索引
  -- 缓存这个计划

  -- 第二次执行:@age = 30
  -- 实际选择率低,应该全表扫
  -- 但用了缓存的"走索引"计划 → 慢

解决:
  - OPTIMIZE FOR UNKNOWN:用平均选择率
  - OPTION (RECOMPILE):每次重新编译
  - 局部变量绕过嗅探

7.3 绑定变量 vs 字面量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Oracle 经典问题:共享池污染

  不用绑定变量:
    SELECT * FROM t WHERE id = 1;
    SELECT * FROM t WHERE id = 2;
    SELECT * FROM t WHERE id = 3;
    → 每条都是不同 SQL → 都要解析 → 共享池爆炸

  用绑定变量:
    SELECT * FROM t WHERE id = :1;
    → 一次解析多次复用 → 高效

MySQL:
  - 没有"共享池"概念
  - 但 prepared statement 也有类似优化

7.4 谓词下推

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 优化前:先 JOIN 再过滤
SELECT * FROM big_table b JOIN small_table s ON b.id = s.id
WHERE s.status = 'active';

-- 优化后(CBO 自动):先过滤 small_table,再 JOIN
SELECT * FROM big_table b JOIN (
  SELECT * FROM small_table WHERE status = 'active'
) s ON b.id = s.id;

-- 四家 CBO 都会自动做谓词下推
-- 但视图 / 子查询嵌套深时可能失败 → 手动改写

7.5 N+1 查询问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ORM 常见问题:
  users = User.objects.all()[:100]    # 1 次查询
  for u in users:
      print(u.profile.bio)            # 每次循环都查一次

   100 个用户做了 101 次查询(N+1

解决:
  - ORM  prefetch_related / eager loading
  - 手写 JOIN
  - DataLoader 模式

八、Hint(提示)

8.1 强制 CBO 选某个计划

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- Oracle
SELECT /*+ INDEX(t idx_name) */ * FROM t WHERE name = 'Alice';
SELECT /*+ FULL(t) */ * FROM t WHERE id = 1;
SELECT /*+ LEADING(t1 t2) USE_HASH(t2) */ ...
FROM t1 JOIN t2 ON ...;

-- MySQL 8.0+ 也支持
SELECT /*+ NO_RANGE_OPTIMIZATION(t PRIMARY) */ * FROM t WHERE id < 100;
SELECT /*+ JOIN_ORDER(a, b, c) */ ...

-- PostgreSQL:不支持 Hint(设计哲学不同)
-- 替代:调整统计信息、SET enable_xxx = off、改写 SQL
1
2
3
4
PG 反对 Hint 的哲学:
  - Hint 让 SQL 变得"非声明式"
  - 数据变化后 Hint 仍然生效 → 性能反而变差
  - 应该让 CBO 不断改进,而不是手工指定

8.2 Hint 何时用

1
2
3
4
5
6
7
8
适用场景:
  - CBO 选错索引(统计信息不足)
  - 临时强制某种 JOIN 方法
  - 临时强制某种顺序

不适用:
  - 长期解决方案(应该修统计信息)
  - 每条 SQL 都加(说明统计信息严重缺失)

九、查询重写技巧

9.1 IN vs EXISTS

1
2
3
4
5
6
7
8
9
-- 经典问题:IN vs EXISTS 谁快?

-- 没有定论,取决于:
  - 子查询大小
  - 外查询大小
  - CBO 实现

-- 现代优化器都能互相转换,效果相同
-- 经验:小表用 IN,大表用 EXISTS(CBO 时代无所谓了)

9.2 NOT IN vs NOT EXISTS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- NOT IN 的 NULL 陷阱
SELECT * FROM a WHERE id NOT IN (SELECT a_id FROM b);
-- 如果 b.a_id 有 NULL → 整个查询返回空!

-- NOT EXISTS 更安全
SELECT * FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id);

-- 解决方案:IS NOT NULL
SELECT * FROM a WHERE id NOT IN (
  SELECT a_id FROM b WHERE a_id IS NOT NULL
);

9.3 避免 SELECT *

1
2
3
4
5
6
7
8
9
不要 SELECT *:
  - 占用带宽(无用列也传输)
  - 无法用覆盖索引
  - 表结构变化时可能出 bug
  - 阻断某些视图优化

例外:
  - 临时调试
  - COUNT(*)(COUNT(1) 没有差异)

十、小结

本文学习了 SQL 方言与查询优化器:

  • SQL 标准的演进与各家方言差异
  • 数据类型对比(数值 / 字符串 / 时间 / JSON)
  • CTE 与窗口函数(递归、排名、累计、LAG/LEAD)
  • RBO vs CBO 的工作原理
  • 统计信息(行数、基数、直方图)的重要性
  • 执行计划的查看与阅读
  • 常见坑:隐式转换、参数嗅探、绑定变量、N+1
  • Hint 使用与限制
  • 查询重写技巧
1
2
3
4
记住三句话:
  1. 90% 的"数据库慢"问题,根因是统计信息过时
  2. EXPLAIN ANALYZE(或等效命令)是性能调优的第一工具
  3. 优化 SQL 不是优化"语法",是优化"执行计划"

下一篇是系列收官:跳出关系数据库,俯瞰整个数据存储生态——OLTP / OLAP / HTAP / 数仓 / Lakehouse / NoSQL 一图看懂。