一篇覆盖索引优化、查询优化、表结构设计、执行计划分析、数据库参数调优、分库分表、缓存策略等全方位内容的 SQL 优化指南,适合后端开发日常查阅和面试复习。


目录

  1. SQL 执行原理与性能瓶颈分析
  2. 索引优化
  3. SQL 语句优化
  4. 表结构设计优化
  5. 执行计划分析(EXPLAIN)
  6. MySQL 参数调优
  7. 连接查询优化
  8. 子查询优化
  9. 排序与分组优化
  10. 分页查询优化
  11. 事务与锁优化
  12. 大批量数据操作优化
  13. 分库分表策略
  14. 读写分离与主从复制
  15. 缓存策略
  16. 慢查询监控与分析
  17. 常见 SQL 反模式
  18. 实战案例分析
  19. 不同数据库的优化差异

一、SQL 执行原理与性能瓶颈分析

1.1 SQL 执行流程

1
客户端 → 连接器 → 查询缓存(8.0已废弃) → 分析器 → 优化器 → 执行器 → 存储引擎
阶段 说明 瓶颈
连接器 建立连接、权限验证 连接数不足、频繁建连
分析器 词法分析、语法分析 复杂 SQL 解析耗 CPU
优化器 选择索引、Join顺序、生成执行计划 统计信息不准导致选错索引
执行器 调用存储引擎接口 I/O 瓶颈、锁等待
存储引擎 数据读写 磁盘 I/O、Buffer Pool 不足

1.2 性能瓶颈快速定位

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 查看当前正在执行的查询(MySQL)
SHOW FULL PROCESSLIST;

-- 查看当前锁等待(MySQL 8.0+)
SELECT * FROM performance_schema.data_lock_waits;

-- 查看事务状态
SELECT * FROM information_schema.innodb_trx;

-- 查看 Buffer Pool 命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read_requests';
SHOW STATUS LIKE 'Innodb_buffer_pool_reads';
-- 命中率 = (read_requests - reads) / read_requests * 100%,应 > 99%

1.3 性能瓶颈来源排序(按影响程度)

  1. 缺少索引或索引失效 —— 最常见,占 80% 以上
  2. 返回大量数据 —— 未加 LIMIT、SELECT *
  3. 锁争用 —— 长事务、行锁升级为表锁
  4. 频繁 I/O —— Buffer Pool 太小、无索引导致全表扫描
  5. 复杂子查询 —— 未使用 JOIN 替代
  6. 配置不当 —— 默认配置不适合生产环境

二、索引优化

2.1 索引类型对比

索引类型 存储结构 适用场景 限制
B+Tree 平衡多路搜索树 等值查询、范围查询、排序 最左前缀原则
Hash 哈希表 精确等值查询 不支持范围查询、排序; Memory/NDB 引擎
Full-Text 倒排索引 全文搜索 分词语言限制
Spatial (R-Tree) R 树 地理位置查询 仅 MyISAM/InnoDB(5.7+)
前缀索引 B+Tree + 前缀 长字符串列 无法用于 ORDER BY
函数索引 B+Tree(虚拟列) 函数/表达式查询 MySQL 8.0.13+, PostgreSQL

2.2 联合索引设计原则 —— 最左前缀法则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 创建联合索引
CREATE INDEX idx_a_b_c ON users(a, b, c);

-- ✅ 走索引 (匹配 a)
SELECT * FROM users WHERE a = 1 AND b = 2 AND c = 3;

-- ✅ 走索引 (匹配 a, b,跳过 c 部分因为范围查询)
SELECT * FROM users WHERE a = 1 AND b > 2 ORDER BY c;

-- ✅ 走索引 (匹配 a)
SELECT * FROM users WHERE a = 1 ORDER BY b;

-- ❌ 不走索引 (没有从 a 开始)
SELECT * FROM users WHERE b = 2 AND c = 3;

-- ❌ 不走索引 (没有 a)
SELECT * FROM users WHERE c = 3;

-- ⚠️ 部分走索引 (a 走索引,c 不走 — 跳过了 b 导致断档)
SELECT * FROM users WHERE a = 1 AND c = 3;

2.3 索引字段顺序设计

高选择性原则:区分度大的列放在最前面

1
2
3
4
5
6
7
8
9
10
-- 计算区分度
SELECT
COUNT(DISTINCT status) / COUNT(*) AS status_selectivity, -- 假设 0.001 (太低)
COUNT(DISTINCT user_id) / COUNT(*) AS user_id_selectivity, -- 假设 0.8 (高)
COUNT(DISTINCT created_at) / COUNT(*) AS time_selectivity -- 假设 0.95 (最高)
FROM orders;

-- 建议顺序:WHERE 条件中频繁出现且区分度高的列放在前面
-- 如果查询模型是:WHERE user_id = ? AND status = ? AND created_at BETWEEN ? AND ?
-- 最佳索引:idx_user_status_time (user_id, status, created_at)

等值查询优先于范围查询

1
2
3
4
5
6
7
-- ❌ 差:范围条件在中间,后面的列用不上
INDEX idx_a_b_c (a, created_at, b, c)

-- ✅ 好:等值条件在前,范围条件在最后
INDEX idx_a_b_c (a, b, created_at, c)
-- WHERE a = 1 AND b = 2 AND created_at > '2024-01-01'
-- → 可以用到 a, b, created_at 三列

2.4 覆盖索引

避免回表查询,将所需列全部包含在索引中。

1
2
3
4
5
6
-- ❌ 需要回表:索引只有 user_id,查 name 和 email 需要回表
SELECT name, email FROM users WHERE user_id = 100;

-- ✅ 覆盖索引:索引包含所有需要的列
CREATE INDEX idx_user_cover ON users(user_id, name, email);
-- Extra: Using index (不需要回表)

建议:高频查询尽量设计覆盖索引,但不要盲目覆盖所有列 —— 维护索引也有代价。

2.5 索引失效场景(避坑大全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
-- ❌ 1. 对索引列使用函数
SELECT * FROM users WHERE DATE(created_at) = '2024-01-01';
-- ✅ 改成
SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';

-- ❌ 2. 对索引列做运算
SELECT * FROM users WHERE age + 10 = 30;
-- ✅ 改成
SELECT * FROM users WHERE age = 20;

-- ❌ 3. 隐式类型转换(字符串列用数字查)
-- phone 列是 VARCHAR 类型
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 改成
SELECT * FROM users WHERE phone = '13800138000';

-- ❌ 4. LIKE 以 % 开头
SELECT * FROM users WHERE name LIKE '%张三';
-- ✅ 必要时用全文索引,或反向 LIKE 有时可改写为:
SELECT * FROM users WHERE name LIKE '张三%'; -- 走索引

-- ❌ 5. OR 条件不全是索引列
SELECT * FROM users WHERE id = 1 OR name = '张三'; -- name 无索引 → 全表扫描
-- ✅ 改成 UNION ALL
SELECT * FROM users WHERE id = 1
UNION ALL
SELECT * FROM users WHERE name = '张三';

-- ❌ 6. NOT IN / NOT EXISTS / != / <> 在大范围时可能不走索引
-- 优化器认为需要扫描大量数据时,会选择全表扫描
-- ✅ 能改写为 IN / EXISTS 则改写,或用范围查询替代

-- ❌ 7. IS NULL / IS NOT NULL 在稀疏索引上效率低
-- ✅ 设置默认值,避免 NULL

-- ❌ 8. 联合索引不满足最左前缀
INDEX idx_a_b_c (a, b, c)
SELECT * FROM t WHERE b = 2; -- 不走索引

-- ❌ 9. ORDER BY + WHERE 列不是同一索引
INDEX idx_a (a)
SELECT * FROM t WHERE a = 1 ORDER BY b; -- filesort
-- ✅ 创建 (a, b) 联合索引

-- ❌ 10. 统计信息不准 → 优化器误判
-- ✅ 手动更新统计信息
ANALYZE TABLE users;

2.6 索引设计检查清单

检查项 说明
WHERE / JOIN / ORDER BY / GROUP BY 列是否有索引 高频列必须建索引
联合索引是否满足最左前缀 检查 SQL 查询条件顺序
是否存在冗余索引 (a)(a,b) 冗余,只需 (a,b)
是否存在未使用的索引 sys.schema_unused_indexes
索引选择性是否足够 区分度 < 10% 的列建议不单独建索引
每张表索引数量 建议 ≤ 5-6 个,太多影响写入性能
是否使用前缀索引 长 VARCHAR/TEXT 列
1
2
3
4
5
-- 查找冗余索引
SELECT * FROM sys.schema_redundant_indexes;

-- 查找未使用的索引
SELECT * FROM sys.schema_unused_indexes;

三、SQL 语句优化

3.1 SELECT * 问题

1
2
3
4
5
-- ❌ 坏习惯
SELECT * FROM users WHERE id = 1;

-- ✅ 只取需要的列
SELECT id, name, email FROM users WHERE id = 1;

SELECT * 的危害

  • 返回不需要的列 → 网络传输变大
  • 无法使用覆盖索引 → 必须回表
  • 表结构变更可能破坏调用方
  • 大字段(TEXT/BLOB)白白传输

3.2 WHERE 条件优化

1
2
3
4
5
6
7
8
9
10
11
12
13
-- ❌ 复杂 OR 条件
SELECT * FROM orders WHERE (status = 'paid' AND amount > 100)
OR (status = 'pending' AND created_at > '2024-01-01');

-- ✅ 能合并则合并,不能则拆分为 UNION
SELECT * FROM orders WHERE status = 'paid' AND amount > 100
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2024-01-01';

-- ❌ 负向条件
SELECT * FROM orders WHERE status != 'cancelled';
-- ✅ 用 IN(如果状态值有限)
SELECT * FROM orders WHERE status IN ('pending', 'paid', 'shipped');

3.3 COUNT 优化

1
2
3
4
5
6
7
8
9
10
11
12
13
-- ❌ COUNT(column) — 不统计 NULL 
SELECT COUNT(name) FROM users;

-- ✅ COUNT(*) / COUNT(1) — 效率相同,统计所有行
SELECT COUNT(*) FROM users;

-- 近似计数(大数据量,不需要精确值)
-- MySQL: SHOW TABLE STATUS LIKE 'users'; -- 查看 Rows 列,近似值
-- EXPLAIN SELECT COUNT(*) FROM users; -- 查看 rows 列,估算值

-- 精确但高效(利用索引)
SELECT COUNT(*) FROM users WHERE status = 'active';
-- 确保 status 有索引

3.4 IN vs EXISTS 选择

1
2
3
4
5
6
7
8
9
10
11
-- 外表小、子表大 → 用 EXISTS
SELECT * FROM orders o
WHERE EXISTS (
SELECT 1 FROM users u WHERE u.id = o.user_id AND u.vip = 1
);

-- 子表结果集小 → 用 IN
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE vip = 1);

-- MySQL 8.0+ 优化器已能自动转换,但显式写出仍有帮助

3.5 UNION vs UNION ALL

1
2
3
4
5
6
7
8
9
10
-- ❌ UNION — 默认去重,需要额外排序
SELECT name FROM users_2023
UNION
SELECT name FROM users_2024;

-- ✅ UNION ALL — 不去重,性能更高
-- 如果确定无重复(如分区表不同年份),使用 UNION ALL
SELECT name FROM users_2023
UNION ALL
SELECT name FROM users_2024;

四、表结构设计优化

4.1 数据类型选择

场景 ❌ 避免 ✅ 推荐 理由
IP 地址 VARCHAR(15) INT UNSIGNED (4字节) / INET_ATON() 更小更快
日期时间 VARCHAR DATETIME(8字节) / TIMESTAMP(4字节) 原生类型支持函数
枚举状态 VARCHAR(20) TINYINT / ENUM 1字节 vs 20+字节
UUID 主键 VARCHAR(36) BINARY(16) 16字节 vs 36字节
布尔值 CHAR(1) TINYINT(1) 1字节
货币金额 FLOAT/DOUBLE DECIMAL(18,2) / BIGINT(分) 避免浮点精度问题
长文本 所有都用 TEXT VARCHAR 优先 (< 65535),必要时才用 TEXT TEXT 不能有默认值,回表开销大
手机号 BIGINT CHAR(11) 手机号不应做运算

4.2 字段设计规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- ✅ 好的表设计
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
order_no CHAR(20) NOT NULL COMMENT '订单号',
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '金额',
status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消',
source TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0PC 1H5 2小程序 3APP',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_status_time (user_id, status, created_at),
KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表';

规范要点

  • 所有字段 NOT NULL + DEFAULT(避免 NULL 带来的索引问题)
  • 主键用无符号自增 BIGINT(性能好,不担心溢出)
  • 金额用 DECIMAL(精确计算)
  • 状态用 TINYINT + 注释说明枚举值
  • 时间字段有默认值,updated_at 自动更新
  • utf8mb4 字符集(支持 emoji)
  • 必须有 COMMENT

4.3 字段长度最小化原则

1
2
3
4
5
6
7
8
9
-- ❌ 浪费空间
name VARCHAR(500), -- 实际最长 50
description TEXT, -- 实际 200 字以内
age INT, -- 0-150 用 TINYINT 即可

-- ✅ 精准控制
name VARCHAR(60), -- 留 20% buffer
description VARCHAR(500), -- 够用即可
age TINYINT UNSIGNED, -- 0-255

原因:InnoDB 一页 16KB,字段越小 → 一页能放越多行 → B+Tree 层级越低 → 查询越快。同时内存中 Buffer Pool 能缓存更多数据。

4.4 主键设计

1
2
3
4
5
6
7
8
9
10
11
12
13
-- ✅ 推荐:自增 BIGINT
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT

-- ✅ 分布式场景:Snowflake 算法生成有序 ID
id BIGINT UNSIGNED NOT NULL

-- ⚠️ 谨慎:UUID 字符串
-- 问题:无序 → 页分裂严重 → 大量磁盘碎片
-- 替代方案:
id BINARY(16) NOT NULL -- UUID_TO_BIN(UUID(), true) 重新排序避免页分裂

-- ❌ 避免:业务字段做主键(如手机号、身份证)
-- 问题:数据变更需级联更新、占用过多空间、可能不是全局唯一

4.5 垂直拆分与大字段处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 原则:高频访问小字段放主表,低频访问大字段放扩展表

-- 主表(频繁访问)
CREATE TABLE articles (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
summary VARCHAR(500),
status TINYINT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_author (author_id)
) ENGINE=InnoDB;

-- 扩展表(仅查看详情时 JOIN)
CREATE TABLE article_contents (
article_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
content MEDIUMTEXT NOT NULL, -- 大字段
images JSON, -- MySQL 5.7+
attachments TEXT
) ENGINE=InnoDB;

4.6 适当冗余减少 JOIN

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 高频查询:订单列表页需要显示用户昵称
-- 策略:orders 表中冗余 username 字段

-- ❌ 每次查询 JOIN
SELECT o.*, u.username
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > '2024-01-01';

-- ✅ 冗余存储
SELECT * FROM orders WHERE created_at > '2024-01-01';
-- username 写入 orders 表时同步赋值即可
-- 注意:需要定时对账,处理用户改名场景

五、执行计划分析(EXPLAIN)

5.1 EXPLAIN 输出解读

1
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 1 ORDER BY created_at DESC LIMIT 10;
列名 含义 关注点
id 查询标识 越大越先执行,相同则从上到下
select_type 查询类型 SIMPLE > PRIMARY > SUBQUERY > DERIVED > UNION
table 访问的表 关注 <derivedN> 派生表
type 访问方式(关键) 见下方 type 优先级
possible_keys 可用的索引 有但未用 → 检查原因
key 实际使用的索引 NULL → 未用索引
key_len 使用的索引长度 越长 → 用的列越多
ref 索引比较的列或常量 const / 字段名
rows 预估扫描行数 越小越好
filtered 条件过滤后占比 100 最好
Extra 额外信息(关键) Using index / filesort / temporary

5.2 type 访问类型优先级(从优到差)

1
2
system > const > eq_ref > ref > fulltext > ref_or_null > index_merge 
> unique_subquery > index_subquery > range > index > ALL
type 说明 示例
system 表只有一行(系统表) 极少见
const 主键或唯一索引等值查询,最多一行 WHERE id = 1
eq_ref 关联查询,驱动表每行在被驱动表中恰好匹配一行 JOIN ON a.id = b.id
ref 使用非唯一索引等值查询 WHERE user_id = 100
range 索引范围扫描 BETWEEN / > / < / IN
index 全索引扫描(扫整个索引树) ORDER BY col 无 WHERE
ALL 全表扫描(最差) 无索引 / 索引失效

目标:至少达到 range 级别,理想是 ref 或 const。

5.3 Extra 关键信息

Extra 值 含义 处理
Using index 覆盖索引,不需要回表 ✅ 最优
Using where 使用 WHERE 过滤 正常
Using index condition ICP 下推过滤 正常
Using temporary 使用临时表 ⚠️ 需优化(加索引 / 改写 SQL)
Using filesort 文件排序 ⚠️ 需优化(加适合排序的索引)
Using join buffer Join 时用了内存缓冲 ⚠️ 被驱动表加索引
Impossible WHERE WHERE 条件恒假 检查 SQL 逻辑
Select tables optimized away 最优:无需读表 聚合函数可直接从索引获取

5.4 EXPLAIN ANALYZE(MySQL 8.0.18+)

比 EXPLAIN 更强,显示实际执行时间和行数:

1
2
3
4
5
6
7
8
9
10
EXPLAIN ANALYZE 
SELECT * FROM orders WHERE user_id = 100 AND status = 1;

-- 输出示例:
-- -> Filter: (orders.status = 1)
-- (cost=12.35 rows=10)
-- (actual time=0.050..0.120 rows=8 loops=1)
-- -> Index lookup on orders using idx_user_status (user_id=100)
-- (cost=2.15 rows=15)
-- (actual time=0.030..0.100 rows=12 loops=1)

六、MySQL 参数调优

6.1 核心参数配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[mysqld]
# ==================== 连接相关 ====================
max_connections = 500 # 最大连接数(根据业务调整)
wait_timeout = 300 # 非交互连接超时(秒)
interactive_timeout = 600 # 交互式连接超时

# ==================== InnoDB Buffer Pool ====================
innodb_buffer_pool_size = 8G # 物理内存 50-70%(最重要参数)
innodb_buffer_pool_instances = 8 # Buffer Pool 实例数(≥ 1GB 时建议 4-8)

# ==================== 日志相关 ====================
innodb_log_file_size = 2G # 重做日志文件大小
innodb_log_buffer_size = 64M # 日志缓冲区
innodb_flush_log_at_trx_commit = 1 # 1=最安全 2=高性能(可能丢1s数据)
sync_binlog = 1 # 1=最安全 0/N=高性能

# ==================== I/O 相关 ====================
innodb_io_capacity = 2000 # SSD 建议 2000+,HDD 建议 200
innodb_io_capacity_max = 4000 # 最大值
innodb_flush_method = O_DIRECT # 绕过 OS 缓存(Linux)
innodb_read_io_threads = 8 # 读 I/O 线程
innodb_write_io_threads = 8 # 写 I/O 线程

# ==================== 并发相关 ====================
innodb_thread_concurrency = 0 # 0=不限制(推荐),或 CPU 核数 * 2
innodb_lock_wait_timeout = 10 # 锁等待超时(秒)

# ==================== 临时表 ====================
tmp_table_size = 128M # 内存临时表最大值
max_heap_table_size = 128M # MEMORY 引擎表最大值

# ==================== 排序/Join 缓冲 ====================
sort_buffer_size = 8M # 排序缓冲区(每个会话)
join_buffer_size = 8M # Join 缓冲区(每个会话)
read_buffer_size = 4M # 顺序读缓冲区

# ==================== 慢查询 ====================
slow_query_log = 1 # 开启慢查询日志
long_query_time = 1 # 超过 1 秒记录
log_queries_not_using_indexes = 0 # 是否记录未用索引的查询

6.2 Buffer Pool 命中率监控

1
2
3
4
5
6
7
8
9
10
11
-- Buffer Pool 命中率(应 ≥ 99%)
SELECT
(1 - (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads')
/ (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests')
) * 100 AS buffer_pool_hit_rate;

-- Buffer Pool 使用率
SELECT
(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_data')
/ (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_total')
* 100 AS buffer_pool_usage;

七、连接查询优化

7.1 JOIN 算法

算法 说明 适用场景
Nested-Loop Join (NLJ) 驱动表每行去被驱动表扫描 被驱动表有索引 → 效率高
Block Nested-Loop Join (BNL) 驱动表批量放入 Join Buffer 被驱动表无索引(8.0.20 后废弃)
Hash Join 构建哈希表 MySQL 8.0.18+,替代 BNL

7.2 JOIN 优化原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 原则 1:小表驱动大表
-- 优化器一般自动选择,但复杂查询可能失效

-- ❌ 大表驱动小表
SELECT * FROM big_table b JOIN small_table s ON b.id = s.big_id;

-- ✅ 小表驱动大表(或使用 STRAIGHT_JOIN 强制)
SELECT * FROM small_table s
STRAIGHT_JOIN big_table b ON s.big_id = b.id;

-- 原则 2:被驱动表的 JOIN 列必须有索引
-- idx_orders_user_id ON orders(user_id)
SELECT * FROM users u
JOIN orders o ON u.id = o.user_id -- o.user_id 必须有索引
WHERE u.status = 1;

-- 原则 3:避免笛卡尔积
-- ❌ 少了 ON 条件或条件错误
SELECT * FROM a JOIN b; -- 笛卡尔积!

-- 原则 4:JOIN 字段类型一致(避免隐式转换)
-- ❌ users.id 是 BIGINT,orders.user_id 是 VARCHAR
SELECT * FROM users u JOIN orders o ON u.id = o.user_id;
-- ✅ 统一类型
ALTER TABLE orders MODIFY user_id BIGINT UNSIGNED NOT NULL;

7.3 多表 JOIN 优化

1
2
3
4
5
6
7
8
9
10
11
12
-- 最多 JOIN 3-5 张表,超出建议拆分

-- ❌ 过多 JOIN
SELECT * FROM a
JOIN b ON ... JOIN c ON ... JOIN d ON ... JOIN e ON ... JOIN f ON ...;

-- ✅ 拆分:先把核心数据查出,再查关联表
-- Step 1: 查主表
SELECT id, name FROM a WHERE ... LIMIT 100;
-- Step 2: 应用程序拿到 id 列表,再 IN 查询 b, c
SELECT * FROM b WHERE a_id IN (1,2,3,...);
SELECT * FROM c WHERE b_id IN (1,2,3,...);

7.4 LEFT JOIN 注意事项

1
2
3
4
5
6
7
8
9
10
11
12
-- LEFT JOIN 时,右表的过滤条件放在 ON 中 vs WHERE 中效果不同

-- 写法 1:条件放在 WHERE(可能把 LEFT JOIN 变成 INNER JOIN)
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 1; -- ❌ 会过滤掉没有订单的用户

-- 写法 2:条件放在 ON(保留左表所有行)
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 1; -- ✅ 正确

八、子查询优化

8.1 子查询改写为 JOIN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- ❌ 子查询(可能产生派生表)
SELECT * FROM orders
WHERE user_id IN (
SELECT id FROM users WHERE status = 1
);

-- ✅ 改写为 JOIN
SELECT o.* FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 1;

-- ✅ 或用 EXISTS
SELECT * FROM orders o
WHERE EXISTS (
SELECT 1 FROM users u WHERE u.id = o.user_id AND u.status = 1
);

8.2 避免在 SELECT 中使用子查询

1
2
3
4
5
6
7
8
9
10
11
-- ❌ 每行执行一次子查询
SELECT
u.id, u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;

-- ✅ 改用 JOIN + GROUP BY
SELECT u.id, u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

8.3 NOT IN vs NOT EXISTS 的 NULL 陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- ⚠️ NOT IN + 子查询返回 NULL → 整个条件为 UNKNOWN,返回空!
SELECT * FROM orders
WHERE user_id NOT IN (
SELECT id FROM users WHERE deleted_at IS NOT NULL
-- 如果 id 中有 NULL,NOT IN 永远返回空!
);

-- ✅ 改用 NOT EXISTS(不受 NULL 影响)
SELECT * FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM users u WHERE u.id = o.user_id AND u.deleted_at IS NOT NULL
);

-- ✅ 或者子查询中显式排除 NULL
SELECT * FROM orders
WHERE user_id NOT IN (
SELECT id FROM users WHERE deleted_at IS NOT NULL AND id IS NOT NULL
);

九、排序与分组优化

9.1 ORDER BY 优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 核心原则:利用索引有序性避免 filesort

-- 场景 1:完全利用索引排序
INDEX idx_user_time (user_id, created_at)
SELECT * FROM users
WHERE user_id = 1
ORDER BY created_at DESC; -- ✅ 使用索引,Extra: 无 filesort

-- 场景 2:WHERE 等值 + ORDER BY 不同方向
INDEX idx_a_b (a, b)
SELECT * FROM t WHERE a = 1 ORDER BY b ASC, c DESC; -- ❌ 混合 ASC/DESC
-- 索引只能单一方向
-- MySQL 8.0+ 支持降序索引:INDEX idx_a_b_c (a, b ASC, c DESC)

-- 场景 3:ORDER BY 多个字段但不在同一索引
SELECT * FROM users ORDER BY status, created_at;
-- ✅ 建联合索引
CREATE INDEX idx_status_created ON users(status, created_at);

-- 场景 4:排序结果集很大 → 调大 sort_buffer_size
SET SESSION sort_buffer_size = 16 * 1024 * 1024; -- 16M

9.2 GROUP BY 优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- GROUP BY 也会利用索引

-- ✅ 利用索引排序避免临时表
INDEX idx_dept (department_id)
SELECT department_id, COUNT(*) FROM employees GROUP BY department_id;

-- ❌ GROUP BY + ORDER BY 不同列
SELECT department_id, COUNT(*) AS cnt
FROM employees
GROUP BY department_id
ORDER BY cnt DESC; -- filesort + temporary

-- 技巧:如果必须按聚合结果排序,限制数据量
SELECT department_id, COUNT(*) AS cnt
FROM employees
WHERE created_at > '2024-01-01' -- 先缩小范围
GROUP BY department_id
ORDER BY cnt DESC
LIMIT 10;

9.3 DISTINCT 优化

1
2
3
4
5
6
7
8
9
10
-- ❌ DISTINCT 去重(可能产生临时表)
SELECT DISTINCT department_id FROM employees;

-- ✅ 如果 department_id 有索引,用 GROUP BY 有时更高效
SELECT department_id FROM employees GROUP BY department_id;

-- ✅ 或者用覆盖索引
-- 如果有 INDEX idx_dept (department_id)
SELECT department_id FROM employees GROUP BY department_id;
-- Extra: Using index for group-by ← 最优

十、分页查询优化

10.1 深分页问题

1
2
3
-- ❌ 深分页:扫描前 100010 行,丢弃 100000 行,返回 10 行
SELECT * FROM orders ORDER BY id LIMIT 100000, 10;
-- 扫描行:100010,返回行:10,浪费严重!

10.2 延迟关联(Deferred Join)

1
2
3
4
5
6
7
8
9
-- ✅ 方案 1:先取 ID,再 JOIN 取完整数据
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 10
) AS tmp ON o.id = tmp.id;

-- 或分两步:
-- Step 1: SELECT id FROM orders ORDER BY id LIMIT 100000, 10;
-- Step 2: SELECT * FROM orders WHERE id IN (100001, 100002, ...);

10.3 游标分页(推荐)

1
2
3
4
5
6
7
-- ✅ 方案 2:基于游标(上一页最后一条的 ID)
-- 第 1 页
SELECT * FROM orders WHERE id > 0 ORDER BY id LIMIT 10;

-- 第 N 页(拿到上一页最后 id = 100000)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
-- 始终只扫描 10 行!

游标分页的代价:无法跳页,需要业务配合改造。

10.4 分页总数优化

1
2
3
4
5
6
7
8
-- ❌ 每次分页都 COUNT(*)(全量扫描)
SELECT COUNT(*) FROM orders WHERE status = 1; -- 1000W 行

-- ✅ 优化方案:
-- 方案 A:缓存总数(对时效性要求不高的场景)
-- 方案 B:使用 EXPLAIN 的 rows 估算值
-- 方案 C:只显示"下一页",不显示总数(如社交 feeds 流)
-- 方案 D:使用 Redis 维护计数器(配合 binlog 同步)

十一、事务与锁优化

11.1 事务优化原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 原则 1:事务尽量小,不要长事务
-- ❌ 事务中做非 DB 操作
START TRANSACTION;
UPDATE orders SET status = 2 WHERE id = 100;
-- 发送邮件、调用第三方 API... → 锁一直持有!
COMMIT;

-- ✅ 非 DB 操作放到事务外
UPDATE orders SET status = 2 WHERE id = 100; -- 单条语句也是事务
-- 再发送邮件

-- 原则 2:避免在事务中等待用户输入
-- 原则 3:批量操作分小事务
-- ❌ 单事务更新 100W 行 → 锁持有太久
START TRANSACTION;
UPDATE logs SET processed = 1 WHERE created_at < '2024-01-01';
COMMIT;

-- ✅ 分批次
-- 每次更新 5000 行
UPDATE logs SET processed = 1 WHERE created_at < '2024-01-01' LIMIT 5000;
-- 循环直到 affected_rows = 0

11.2 锁类型与避免

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 查看当前锁等待
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id
JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id;

-- MySQL 8.0+ 推荐
SELECT * FROM performance_schema.data_lock_waits\G

-- 避免死锁的原则:
-- 1. 按相同顺序访问资源(如统一按 id 升序更新)
-- 2. 尽量用主键/唯一键精确更新(减少锁范围)
-- 3. 减少事务持锁时间
-- 4. 合理使用 READ COMMITTED 隔离级别(减少间隙锁)

11.3 隔离级别选择

隔离级别 脏读 不可重复读 幻读 性能 适用场景
READ UNCOMMITTED 最高 几乎不用
READ COMMITTED 较高 多数场景(推荐)
REPEATABLE READ (默认) ⚠️* 中等 MySQL 默认
SERIALIZABLE 最低 强一致性要求

*InnoDB 的 REPEATABLE READ 通过 Next-Key Lock 在大多数情况下避免了幻读。

1
2
-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

十二、大批量数据操作优化

12.1 批量 INSERT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- ❌ 逐条插入(每条一个事务,N 次网络往返)
INSERT INTO users (name, email) VALUES ('张三', 'zhangsan@example.com');
INSERT INTO users (name, email) VALUES ('李四', 'lisi@example.com');
INSERT INTO users (name, email) VALUES ('王五', 'wangwu@example.com');

-- ✅ 批量插入(单条 SQL,1 次往返,单事务)
INSERT INTO users (name, email) VALUES
('张三', 'zhangsan@example.com'),
('李四', 'lisi@example.com'),
('王五', 'wangwu@example.com');

-- 批量插入最佳实践:
-- - 每批 500-5000 行(根据行大小调整)
-- - 关闭自动提交,手动控制事务
-- - 大批量可使用 LOAD DATA INFILE(比 INSERT 快 20 倍)

LOAD DATA LOCAL INFILE '/path/to/data.csv'
INTO TABLE users
FIELDS TERMINATED BY ','
LINES TERMINATED BY '\n'
(name, email);

12.2 批量 UPDATE / DELETE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- ❌ 一次性更新/删除大量数据 → 主从延迟、锁持有太久
DELETE FROM logs WHERE created_at < '2023-01-01';

-- ✅ 分批删除
DELIMITER $$
CREATE PROCEDURE batch_delete_logs()
BEGIN
DECLARE affected_rows INT DEFAULT 1;
WHILE affected_rows > 0 DO
DELETE FROM logs
WHERE created_at < '2023-01-01'
LIMIT 5000;
SET affected_rows = ROW_COUNT();
DO SLEEP(1); -- 间隔 1 秒,避免主从压力过大
END WHILE;
END$$
DELIMITER ;

12.3 大表加索引(Online DDL)

1
2
3
4
5
6
-- MySQL 5.6+ InnoDB 支持 Online DDL,不锁表
ALTER TABLE orders ADD INDEX idx_user_time (user_id, created_at), ALGORITHM=INPLACE, LOCK=NONE;

-- 注意:Online DDL 期间仍有性能开销,避开高峰期操作
-- 大表建议使用 pt-online-schema-change(Percona Toolkit)
-- pt-online-schema-change --alter "ADD INDEX idx_xxx (col)" D=db,t=table

12.4 表数据归档

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 历史数据移出热表,减少热表体积

-- 1. 创建归档表(同结构)
CREATE TABLE orders_archive LIKE orders;

-- 2. 分批迁移
INSERT INTO orders_archive
SELECT * FROM orders WHERE created_at < '2023-01-01' LIMIT 10000;

-- 3. 确认数据一致后,分批删除热表数据
DELETE FROM orders WHERE created_at < '2023-01-01' LIMIT 10000;

-- 4. 或使用分区表,直接 DROP PARTITION(见第十三章)

十三、分库分表策略

13.1 分表时机

指标 阈值
单表行数 > 500W(根据行大小调整,核心是 B+Tree 层数)
单表数据大小 > 10GB
单表写入 QPS > 2000
单表查询延时 > 100ms(高频查询)
维护困难 ALTER TABLE 耗时 > 30 分钟

13.2 水平分表方案

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 方案 1:按时间分表(适合日志/流水等时间序列数据)
-- orders_202401, orders_202402, ...

-- 方案 2:按 ID 取模(适合用户/订单等按 ID 均匀分布)
-- 分 64 张表
-- 路由:table_suffix = user_id % 64
-- users_00, users_01, ..., users_63

-- 方案 3:按时间 + 取模(适合热数据集中但总量大的场景)
-- orders_202401_00, orders_202401_01, ...

-- 方案 4:一致性哈希(适合动态扩缩容)
-- 虚拟节点映射到物理表

13.3 分区表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- MySQL 分区表(透明,业务代码无感知)
-- 按年份范围分区
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(12,2) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id, created_at), -- ⚠️ 分区键必须在主键中
KEY idx_user (user_id)
) ENGINE=InnoDB
PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2022 VALUES LESS THAN (2023),
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p_future VALUES LESS THAN MAXVALUE
);

-- 分区修剪:只扫描需要的分区
SELECT * FROM orders WHERE created_at >= '2024-01-01';
-- 只扫描 p2024 和 p_future 分区

-- 归档:直接删除分区(秒级)
ALTER TABLE orders DROP PARTITION p2022;

-- 分区限制:
-- - 分区键必须在所有 UNIQUE KEY 中
-- - 最大 8192 个分区
-- - 不支持 FOREIGN KEY

13.4 分库分表中间件

中间件 特点 适用场景
ShardingSphere 功能全面,社区活跃 多数场景
Vitess YouTube 开源,成熟 大规模
MyCAT 国内流行 中小规模
自研路由 灵活可控 定制需求
应用层路由 最简单 简单分表

十四、读写分离与主从复制

14.1 主从架构

1
2
3
4
5
6
7
8
        ┌──────────┐
│ Master │ ← 写
└────┬─────┘
┌───────┼───────┐
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│Slave1│ │Slave2│ │Slave3│ ← 读
└──────┘ └──────┘ └──────┘

14.2 读写分离注意事项

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 问题:主从延迟导致读不到刚写入的数据

-- 方案 1:强制走主库(关键业务)
-- 插入/更新后立即查询使用主库

-- 方案 2:延迟阈值
-- 如果从库延迟 > 1s,切换到主库

-- 方案 3:等待主从同步
-- MySQL 5.7+ 半同步复制 + AFTER_SYNC 模式

-- 方案 4:缓存旁路
-- 写入时更新 Redis,读优先查 Redis

14.3 主从延迟监控

1
2
3
4
5
6
-- 在从库执行
SHOW SLAVE STATUS\G
-- 关注:Seconds_Behind_Master

-- Performance Schema 方式
SELECT * FROM performance_schema.replication_applier_status_by_worker\G

十五、缓存策略

15.1 缓存分层

1
2
3
4
5
6
7
8
9
┌──────────────┐  最快
│ 应用层缓存 │ (本地内存 / 进程内)
├──────────────┤
│ 分布式缓存 │ (Redis / Memcached)
├──────────────┤
│ 数据库缓存 │ (Buffer Pool / Query Cache)
├──────────────┤
│ 磁盘存储 │ 最慢
└──────────────┘

15.2 Redis 缓存策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 伪代码示例

# Cache-Aside 模式(最常用)
def get_user(user_id):
# 1. 先查缓存
user = redis.get(f"user:{user_id}")
if user:
return user

# 2. 缓存未命中,查 DB
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
if user:
# 3. 写入缓存,设置过期时间
redis.setex(f"user:{user_id}", 3600, user)
return user

def update_user(user_id, data):
# 1. 更新 DB
db.execute("UPDATE users SET name = ? WHERE id = ?", data.name, user_id)
# 2. 删除缓存(不是更新缓存!)
redis.delete(f"user:{user_id}")

15.3 缓存穿透 / 击穿 / 雪崩

问题 现象 解决方案
缓存穿透 查不存在的 key → 每次穿透到 DB 布隆过滤器 / 缓存空值
缓存击穿 热点 key 过期 → 瞬间压到 DB 互斥锁 / 永不过期 + 异步更新
缓存雪崩 大量 key 同时过期 → DB 崩溃 过期时间 + 随机值 / 多级缓存 / 限流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 缓存穿透:缓存空值
def get_user_with_null_cache(user_id):
user = redis.get(f"user:{user_id}")
if user is not None:
return user if user != "NULL" else None
user = db.query(...)
if user:
redis.setex(f"user:{user_id}", 3600, user)
else:
redis.setex(f"user:{user_id}", 60, "NULL") # 空值也缓存,短过期
return user

# 缓存击穿:互斥锁
def get_hot_data(key):
data = redis.get(key)
if data is None:
# 加锁,只有一个线程查 DB
if redis.setnx(f"lock:{key}", 1):
try:
data = db.query(...)
redis.setex(key, 3600, data)
finally:
redis.delete(f"lock:{key}")
else:
time.sleep(0.1)
data = redis.get(key)
return data

# 缓存雪崩:过期时间加随机
redis.setex(f"user:{user_id}", 3600 + random.randint(0, 600), user)

十六、慢查询监控与分析

16.1 慢查询日志配置

1
2
3
4
5
6
7
8
9
10
11
-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 临时开启
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1; -- 新连接生效
SET GLOBAL log_queries_not_using_indexes = 1;

-- 查看慢查询日志位置
SHOW VARIABLES LIKE 'slow_query_log_file';

16.2 pt-query-digest 分析慢查询

1
2
3
4
5
6
7
8
9
10
11
12
# 分析慢查询日志
pt-query-digest /var/log/mysql/slow.log > slow_report.txt

# 分析指定时间段
pt-query-digest \
--since '2024-01-01 00:00:00' \
--until '2024-01-02 00:00:00' \
/var/log/mysql/slow.log > slow_report.txt

# 分析 tcpdump 抓包
tcpdump -i eth0 port 3306 -s 65535 -w mysql.pcap
pt-query-digest --type tcpdump mysql.pcap > report.txt

16.3 Performance Schema 分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 哪些 SQL 执行次数最多
SELECT
DIGEST_TEXT,
COUNT_STAR AS exec_count,
AVG_TIMER_WAIT / 1000000000 AS avg_ms,
SUM_TIMER_WAIT / 1000000000 AS total_ms,
SUM_ROWS_EXAMINED AS total_rows_examined,
SUM_ROWS_SENT AS total_rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;

-- 哪些 SQL 使用了临时表
SELECT
DIGEST_TEXT,
COUNT_STAR,
SUM_CREATED_TMP_TABLES,
SUM_CREATED_TMP_DISK_TABLES
FROM performance_schema.events_statements_summary_by_digest
WHERE SUM_CREATED_TMP_DISK_TABLES > 0
ORDER BY SUM_CREATED_TMP_DISK_TABLES DESC;

16.4 Sys Schema 分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- Top N 最慢 SQL
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile
ORDER BY avg_latency DESC LIMIT 10;

-- 全表扫描最多的表
SELECT * FROM sys.schema_tables_with_full_table_scans;

-- 冗余索引
SELECT * FROM sys.schema_redundant_indexes;

-- 未使用的索引
SELECT * FROM sys.schema_unused_indexes;

-- 哪些表的 I/O 最多
SELECT * FROM sys.io_global_by_file_by_bytes LIMIT 20;

-- 锁等待
SELECT * FROM sys.innodb_lock_waits;

十七、常见 SQL 反模式

反模式 1:随意使用 SELECT *

1
2
3
4
-- ❌
SELECT * FROM orders WHERE id = 1;
-- ✅
SELECT id, order_no, amount, status FROM orders WHERE id = 1;

反模式 2:在 WHERE 中对列做运算

1
2
3
4
-- ❌ (索引失效)
SELECT * FROM users WHERE YEAR(birthday) = 1990;
-- ✅
SELECT * FROM users WHERE birthday >= '1990-01-01' AND birthday < '1991-01-01';

反模式 3:使用大量 OR 条件

1
2
3
4
-- ❌
SELECT * FROM users WHERE id = 1 OR id = 2 OR id = 3 OR id = 4 ...;
-- ✅
SELECT * FROM users WHERE id IN (1, 2, 3, 4, ...);

反模式 4:无 LIMIT 的查询

1
2
3
4
-- ❌ 可能返回几百万行
SELECT * FROM orders WHERE status = 1;
-- ✅ 加 LIMIT,配合分页
SELECT * FROM orders WHERE status = 1 LIMIT 100;

反模式 5:过度依赖数据库函数处理

1
2
3
4
-- ❌ 大量数据在 DB 层做字符串拼接、格式化
SELECT CONCAT(u.first_name, ' ', u.last_name, ' - ', u.phone) FROM users u;
-- ✅ 在应用层处理格式化,DB 只做数据检索
SELECT u.first_name, u.last_name, u.phone FROM users u;

反模式 6:NULL 值设计问题

1
2
3
4
5
6
7
-- ❌ 到处 NULL 加复杂判断
SELECT * FROM users WHERE deleted_at IS NOT NULL OR status IS NULL;

-- ✅ 合理设默认值
-- deleted_at DATETIME NOT NULL DEFAULT '9999-12-31'
-- status TINYINT NOT NULL DEFAULT 0
SELECT * FROM users WHERE deleted_at = '9999-12-31' AND status = 0;

反模式 7:用 MySQL 做全文搜索

1
2
3
4
5
6
7
-- ❌ LIKE '%keyword%'(全表扫描,极慢)
SELECT * FROM articles WHERE content LIKE '%关键词%';

-- ✅ 方案 A:Elasticsearch / Meilisearch
-- ✅ 方案 B:MySQL 内置全文索引(中文需配合 ngram parser)
ALTER TABLE articles ADD FULLTEXT INDEX ft_content (content) WITH PARSER ngram;
SELECT * FROM articles WHERE MATCH(content) AGAINST('关键词' IN BOOLEAN MODE);

反模式 8:大数据量导出

1
2
3
4
-- ❌ 一次性导出几百万行
SELECT * INTO OUTFILE '/tmp/export.csv' FROM orders;

-- ✅ 分批次导出(使用 LIMIT 游标)

反模式 9:滥用存储过程/函数

1
2
3
4
-- ❌ 复杂业务逻辑写在存储过程中
-- 问题:难以版本管理、调试困难、测试困难、耦合度高

-- ✅ 业务逻辑放应用层,DB 只做 CRUD

反模式 10:JSON 字段代替关联表

1
2
3
4
5
6
7
8
9
10
11
-- ⚠️ JSON 的合理场景:
-- - 结构多变、无强模式的附加属性(如用户扩展信息)
-- - 不需要按 JSON 内部字段频繁查询/排序

-- ❌ 不合理的 JSON 使用
-- 经常需要 WHERE json_col->>'$.status' = 'active' → 无法使用索引

-- ✅ 合理做法
-- 核心业务字段独立建列 + 索引
-- 次要/多变的属性用 JSON(MySQL 5.7+ 虚拟列+索引辅助查询)
ALTER TABLE users ADD status TINYINT NOT NULL DEFAULT 0, ADD INDEX idx_status(status);

十八、实战案例分析

案例 1:订单列表查询优化

原始场景:订单表 500W 行,用户查看自己的订单列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- ❌ 原始 SQL(1.8 秒)
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10, 10;

-- 问题分析:
-- EXPLAIN → type: ref, key: idx_user_id, Extra: Using filesort
-- rows: 35000(该用户有 3.5W 订单)
-- 因为没有 (user_id, created_at) 联合索引,需要 filesort

-- ✅ 优化 1:建联合索引
ALTER TABLE orders ADD INDEX idx_user_time (user_id, created_at);
-- 优化后:0.02 秒(Using index,无 filesort)

-- ✅ 优化 2:只取必要字段 + 覆盖索引
ALTER TABLE orders ADD INDEX idx_user_time_cover (user_id, created_at, id, order_no, amount, status);
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10, 10;
-- 优化后:0.005 秒(Using index,覆盖索引无回表)

案例 2:统计报表优化

原始场景:每日统计过去 30 天的订单量和金额。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-- ❌ 原始 SQL(5.2 秒)
SELECT
DATE(created_at) AS date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(created_at)
ORDER BY date DESC;

-- 问题:DATE() 函数导致索引失效 + 全表扫描

-- ✅ 优化:避免函数 + 覆盖索引
SELECT
DATE(created_at) AS date,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE created_at >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 30 DAY), '%Y-%m-%d 00:00:00')
GROUP BY DATE(created_at)
ORDER BY date DESC;

-- ✅ 更进一步:预计算汇总表 + 定时任务
CREATE TABLE order_daily_stats (
stat_date DATE NOT NULL PRIMARY KEY,
order_count INT UNSIGNED NOT NULL DEFAULT 0,
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00
) ENGINE=InnoDB;

-- 每日凌晨执行
INSERT INTO order_daily_stats (stat_date, order_count, total_amount)
SELECT DATE(created_at), COUNT(*), SUM(amount)
FROM orders
WHERE DATE(created_at) = CURDATE() - INTERVAL 1 DAY;

-- 查询改为 O(1)
SELECT * FROM order_daily_stats
WHERE stat_date >= CURDATE() - INTERVAL 30 DAY
ORDER BY stat_date DESC;
-- 优化后:0.001 秒

案例 3:多表关联优化

原始场景:4 表 JOIN 查询活动参与用户列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-- ❌ 原始 SQL(3.8 秒)
SELECT u.*, a.title, p.prize_name, l.created_at AS join_time
FROM activity_log l
LEFT JOIN users u ON l.user_id = u.id
LEFT JOIN activities a ON l.activity_id = a.id
LEFT JOIN prizes p ON l.prize_id = p.id
WHERE l.activity_id = 100
ORDER BY l.created_at DESC
LIMIT 20;

-- 问题分析:
-- EXPLAIN 显示 activity_log 全表扫描(200W 行)
-- 缺少 activity_id 索引

-- ✅ 优化:
-- 1. 添加索引
ALTER TABLE activity_log ADD INDEX idx_activity_time (activity_id, created_at);

-- 2. 减少 JOIN,分批查询
-- Step 1:查日志
SELECT id, user_id, prize_id, created_at
FROM activity_log
WHERE activity_id = 100
ORDER BY created_at DESC
LIMIT 20;

-- Step 2:查关联表(IN 查询)
SELECT * FROM users WHERE id IN (uid1, uid2, ...);
SELECT * FROM prizes WHERE id IN (pid1, pid2, ...);
-- 活动信息可缓存(变化少)

-- 优化后:总体 < 0.1 秒

案例 4:高并发库存扣减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- ❌ 先查再扣(并发下超卖)
-- 线程 A:SELECT stock FROM products WHERE id = 1; -- stock = 10
-- 线程 B:SELECT stock FROM products WHERE id = 1; -- stock = 10
-- 线程 A:UPDATE products SET stock = 9 WHERE id = 1;
-- 线程 B:UPDATE products SET stock = 9 WHERE id = 1; -- 超卖!

-- ✅ 原子操作
UPDATE products SET stock = stock - 1
WHERE id = 1 AND stock >= 1;
-- 检查 affected_rows,为 0 说明库存不足

-- ✅ 或用乐观锁(版本号)
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = @old_version;

-- ✅ 高并发场景:Redis + Lua 脚本原子扣减
-- EVAL "if redis.call('get', KEYS[1]) >= ARGV[1] then
-- return redis.call('decrby', KEYS[1], ARGV[1])
-- else return -1 end" 1 stock:1 1

十九、不同数据库的优化差异

19.1 MySQL vs PostgreSQL

方面 MySQL PostgreSQL
默认隔离级别 REPEATABLE READ READ COMMITTED
MVCC 实现 Undo Log 元组多版本
JOIN 算法 NLJ / Hash(8.0.18+) NLJ / Hash / Merge
查询计划 EXPLAIN EXPLAIN ANALYZE(更详细)
索引类型 B+Tree / FullText / Spatial B-Tree / Hash / GIN / GiST / BRIN / SP-GiST
分区 有限支持 更强大(声明式分区)
并行查询 有限(8.0+) 原生支持(9.6+)
JSON JSON 类型 JSONB(更高效)
分析函数 8.0+ 支持 完善支持

19.2 MySQL 版本差异

特性 5.6 5.7 8.0
Online DDL 部分支持 增强 完善
JSON 类型 ✅(增强)
CTE (WITH)
窗口函数
Hash Join ✅ (8.0.18)
降序索引
原子 DDL
资源组
直方图
EXPLAIN ANALYZE ✅ (8.0.18)

附录 A:优化检查清单(速查)

日常开发检查

  • SELECT 是否避免了 *
  • WHERE 条件列是否有索引?是否违反最左前缀?
  • WHERE 中是否对索引列使用了函数/运算/隐式转换?
  • JOIN 的关联列是否都有索引?类型是否一致?
  • 大表查询是否加了 LIMIT?
  • 子查询是否能改写为 JOIN?
  • ORDER BY / GROUP BY 是否有对应索引?
  • 是否避免了 NOT IN 子查询(NULL 陷阱)?
  • OR 条件是否能改为 IN 或 UNION ALL?
  • 是否使用了合适的数据类型?

上线前检查

  • 对核心 SQL 执行了 EXPLAIN,type 是否达到 ref 或以上?
  • 是否模拟了线上数据量(≥ 预估 3 个月后的量)做压测?
  • 新加的索引是否避免了冗余和未使用?
  • 是否有慢查询监控和告警?
  • 事务是否足够短?是否有长事务风险?
  • 是否有死锁风险(多表更新顺序一致)?

附录 B:常用 SQL 性能排查命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-- 1. 查看当前所有连接
SHOW FULL PROCESSLIST;

-- 2. 查看当前事务
SELECT * FROM information_schema.innodb_trx WHERE trx_state = 'RUNNING';

-- 3. 查看锁等待(MySQL 8.0+)
SELECT * FROM performance_schema.data_lock_waits;

-- 4. 查看 Buffer Pool 使用率
SHOW STATUS LIKE 'Innodb_buffer_pool%';

-- 5. 查看表统计信息
SELECT * FROM mysql.innodb_table_stats WHERE database_name = 'your_db';
SELECT * FROM mysql.innodb_index_stats WHERE database_name = 'your_db';

-- 6. 更新统计信息
ANALYZE TABLE your_table;

-- 7. 查看表大小(Top 10)
SELECT
table_schema, table_name,
ROUND(data_length/1024/1024, 2) AS data_mb,
ROUND(index_length/1024/1024, 2) AS index_mb,
ROUND((data_length + index_length)/1024/1024, 2) AS total_mb,
table_rows
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql','sys','performance_schema','information_schema')
ORDER BY (data_length + index_length) DESC
LIMIT 10;

-- 8. 查看数据库连接数
SHOW STATUS LIKE 'Threads_connected';
SHOW VARIABLES LIKE 'max_connections';

-- 9. 查看临时表使用情况
SHOW STATUS LIKE 'Created_tmp%';

-- 10. 查看排序情况
SHOW STATUS LIKE 'Sort%';

本文整理自日常开发中的 SQL 优化实践经验,涵盖了从索引设计到架构层面的全方位优化策略,建议收藏作为日常开发的速查手册。