一条 SQL 从敲下回车到返回结果,中间经历了语法解析、查询重写、代价优化、执行引擎调度多个阶段。当它变慢时,你需要知道慢在哪一环:是优化器选错了执行计划,是索引没建对,是连接池耗尽,还是缓冲池命中率太低。
本文把查询处理的原理和慢查询排查的工程实践放在一篇里讲透。前半部分是原理:查询处理流水线、代价模型、统计信息、EXPLAIN 各列含义。后半部分是排查 SOP:慢查询日志、mysqldumpslow 聚合、EXPLAIN 定位、连接池配置、参数调优、监控体系。原理和实战对照着看,你才能读懂 EXPLAIN 输出背后的优化器决策,也能在排查时有章可循。
前置知识
MySQL 索引原理与失效分析:优化器选择执行计划时,索引是核心考量因素,本文索引相关内容回链此文
MySQL InnoDB 架构与实现:理解 I/O 代价模型需要知道 InnoDB 的 Buffer Pool 和页结构
MySQL 事务原理与隔离级别:参数调优中的持久性权衡涉及 redo log 和 binlog
一、查询处理流水线
数据库处理一条 SQL 的过程是一条精密的流水线,每一阶段都有明确的输入和输出,上一阶段的输出就是下一阶段的输入。
这条流水线分为四个阶段:
| 阶段 | 输入 | 输出 | 核心任务 |
|---|---|---|---|
| 解析 | SQL 文本 | 语法树 | 词法分析 + 语法分析,验证 SQL 是否合法 |
| 语义分析与重写 | 语法树 | 查询树 | 名称解析、类型检查、权限验证、视图展开 |
| 优化 | 查询树 | 执行计划 | 逻辑优化 + 物理优化,找到最优执行路径 |
| 执行 | 执行计划 | 结果集 | 按计划访问数据、计算结果 |
优化器是整条流水线中最复杂的组件。一个查询的可能执行计划数量随表的数量呈指数增长,3 张表的 Join 就有 12 种排列,5 张表有 1680 种。优化器的核心挑战就是在有限时间内从海量候选中选出足够好的计划。
二、解析与重写
2.1 语法解析
语法解析分两步:词法分析(Lexer)和语法分析(Parser)。词法分析将 SQL 文本拆成一个个 Token(词法单元),例如:
SELECT name, age FROM users WHERE age > 18;被拆分为 SELECT、name、,、age、FROM、users、WHERE、age、>、18、;。语法分析根据语法规则将 Token 序列组织成一棵抽象语法树(AST),树的结构反映 SQL 的语义层次。如果 SQL 存在语法错误(比如 SELEC name FORM users),解析器会在这一步报错,后续阶段不会执行。
2.2 语义分析
语法树只验证了 SQL 的语法正确性,但不知道 users 表是否存在、name 列是什么类型、当前用户有没有查询权限。语义分析负责这些检查:
- 名称解析:将表名、列名绑定到数据库元数据中的实际对象
- 类型检查:验证表达式类型是否兼容(如不能把字符串和整数相加)
- 权限验证:检查当前用户是否拥有对相关对象的访问权限
语义分析完成后,语法树被转换为查询树(Query Tree),其中每个节点都绑定到了具体的数据库对象。
2.3 查询重写
查询重写阶段对查询树进行等价变换,使其更利于后续优化。常见的重写规则包括:
视图展开,将视图引用替换为视图定义的子查询:
-- 假设 active_users 视图定义为:-- SELECT * FROM users WHERE status = 'active'
-- 原始查询SELECT name FROM active_users WHERE age > 18;
-- 重写后SELECT name FROM users WHERE status = 'active' AND age > 18;子查询提升,将相关子查询改写为 Semi Join,避免逐行执行子查询:
-- 原始:相关子查询(每行都执行一次子查询)SELECT * FROM orders oWHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.city = '北京');
-- 重写后:Semi Join(一次扫描完成)SELECT o.* FROM orders oSEMI JOIN users u ON u.id = o.user_id AND u.city = '北京';常量折叠与表达式简化:
-- 原始SELECT * FROM users WHERE age > 10 + 8 AND 1 = 1;
-- 重写后SELECT * FROM users WHERE age > 18;PostgreSQL 的规则系统(Rule System)也在此阶段工作,用户定义的 RULE 可以在重写阶段修改查询树,实现行级安全策略等高级功能。
三、查询优化
查询优化是整条流水线的核心。优化器的目标是在有限时间内找到代价最低的执行计划。优化分为两个层次:逻辑优化(基于规则)和物理优化(基于代价)。
3.1 逻辑优化
逻辑优化基于启发式规则(Heuristic Rules),对查询树进行等价变换,无需了解数据分布。这些规则通常能减少中间结果集的大小。
谓词下推(Predicate Pushdown),将过滤条件尽早执行,减少上游的数据量:
-- 优化前:先 Join 再过滤(中间结果大)SELECT o.* FROM orders o JOIN users u ON o.user_id = u.idWHERE u.city = '北京';
-- 优化后:先过滤再 Join(中间结果小)SELECT o.* FROM orders o JOIN (SELECT * FROM users WHERE city = '北京') uON o.user_id = u.id;列裁剪(Column Pruning),只读取查询需要的列,减少 I/O 和内存占用。在索引原理与失效分析中讨论的覆盖索引,正是列裁剪在索引层面的体现。
Join 重排序,调整 Join 的顺序,将小表或过滤后行数少的表放在前面。这是优化器搜索空间最大的来源,N 张表的 Join 有 N! 种排列顺序。
3.2 物理优化
逻辑优化决定了”做什么”,物理优化决定”怎么做”。同一个逻辑算子有多种物理实现,代价各不相同:
3.3 Join 算法选择
Join 是最复杂也最关键的算子。四种经典 Join 算法各有适用场景:
| Join 算法 | 时间复杂度 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|---|
| Nested Loop | O(M×N) | 小表驱动大表、有索引 | 简单、支持非等值 Join | 大表全扫描极慢 |
| Hash Join | O(M+N) | 等值 Join、两表都大 | 等值 Join 最快 | 只支持等值、需内存 |
| Sort Merge | O(MlogM+NlogN) | 数据已排序、非等值 | 支持非等值、可利用索引序 | 需要排序开销 |
| Grace Hash | O(M+N) I/O | 等值 Join、内存不足 | 突破内存限制 | 多轮 I/O |
MySQL 8.0 之前只支持 Nested Loop Join(含 Block Nested Loop 变体),8.0 引入了 Hash Join,大幅提升了无索引等值 Join 的性能。PostgreSQL 则长期支持 Nested Loop、Hash Join、Merge Join 三种 Join 节点,其中 Hash Join 在内存不足时会分批溢写磁盘(hybrid hash),不单独区分 Grace Hash 算法。
四、代价模型
优化器如何从众多候选计划中选出最优?答案是代价模型(Cost Model),为每个候选计划估算一个代价分数,选择代价最低的。
4.1 代价的三个维度
代价模型通常考虑三个维度:
| 维度 | 含义 | 典型权重 |
|---|---|---|
| I/O 代价 | 读写磁盘页面的次数 | 最高(磁盘比内存慢 10^5 倍) |
| CPU 代价 | 计算表达式、比较元组的开销 | 中等 |
| 网络代价 | 分布式查询的数据传输量 | 分布式场景下最高 |
总代价 = seq_page_cost × I/O 页数 + cpu_tuple_cost × 元组数 + cpu_index_cost × 索引扫描次数 + …
4.2 MySQL 代价估算
MySQL 的代价模型在 8.0 版本经历了重大重构,从硬编码改为可配置的代价常量:
-- 查看 MySQL 代价常量SELECT * FROM mysql.server_cost;SELECT * FROM mysql.engine_cost;server_cost 表的示例输出:
+------------------------------+------------+---------------------+---------+| cost_name | cost_value | default_value | comment |+------------------------------+------------+---------------------+---------+| disk_temptable_create_cost | NULL | 20.0 | || disk_temptable_row_cost | NULL | 0.5 | || key_compare_cost | NULL | 0.05 | || memory_temptable_create_cost | NULL | 1.0 | || memory_temptable_row_cost | NULL | 0.1 | || row_evaluate_cost | NULL | 0.1 | |+------------------------------+------------+---------------------+---------+其中 row_evaluate_cost(每行评估代价 0.1)和 key_compare_cost(每次索引比较 0.05)是优化器估算扫描行数后计算 CPU 代价的基础。disk_temptable_create_cost(20.0)远高于 memory_temptable_create_cost(1.0),这解释了为什么 Using temporary 涉及磁盘临时表时代价飙升。
PostgreSQL 的代价模型更透明。Seq Scan 代价 = seq_page_cost(默认 1.0)× 总页数 + cpu_tuple_cost(默认 0.01)× 总行数。Index Scan 还要加上 random_page_cost(默认 4.0)× 随机 I/O 页数。
PostgreSQL 的 random_page_cost 默认值 4.0 基于传统机械硬盘的假设。在 SSD 上,随机 I/O 和顺序 I/O 的差距远没有 4 倍。生产环境使用 SSD 时,建议将 random_page_cost 设为 1.1 到 1.5,否则优化器会过度偏好顺序扫描。MySQL 8.0 同样可以通过 mysql.engine_cost 表调整不同存储引擎的随机读代价。
4.3 统计信息:代价估算的基石
代价估算的准确性完全依赖于统计信息。没有准确的统计信息,再精妙的代价模型也是空中楼阁。
核心统计指标:
| 统计指标 | 含义 | 用途 |
|---|---|---|
| NDV(Number of Distinct Values) | 列的唯一值数量 | 估算等值条件的选择率 = 1/NDV |
| 直方图(Histogram) | 列值分布的频率统计 | 估算范围条件的选择率 |
| MCV(Most Common Values) | 高频值及其频率 | 估算偏斜分布的选择率 |
| 相关性(Correlation) | 列值物理排序与逻辑排序的相关度 | 决定 Index Scan 的额外随机 I/O |
选择率(Selectivity)是代价估算的核心概念,它决定了过滤后剩余多少行:
# 选择率估算示例(简化版)def estimate_selectivity(column, predicate, stats): if predicate.type == "equality": # 等值条件:1/NDV(均匀分布假设) return 1.0 / stats.ndv[column] elif predicate.type == "range": # 范围条件:用直方图估算 return stats.histogram[column].fraction_in_range( predicate.lower, predicate.upper ) elif predicate.type == "in_list": # IN 列表:每个值的频率之和 return sum(stats.mcv[column].get(v, 1.0/stats.ndv[column]) for v in predicate.values)统计信息失真是执行计划劣化的头号原因。一张百万行的表,如果统计信息显示只有 1000 行,优化器可能选择 Index Scan;但实际扫描百万行的 Index Scan 比 Seq Scan 慢得多,因为随机 I/O 的代价远高于顺序 I/O。这就是为什么定期收集统计信息是必要的。
统计信息不会自动保持最新,大量 INSERT/UPDATE/DELETE 后会逐渐失真。MySQL 手动收集:
-- MySQL 收集统计信息ANALYZE TABLE users;
-- 查看索引的 CardinalitySHOW INDEX FROM users;
-- 查看统计信息详情SELECT * FROM information_schema.STATISTICSWHERE TABLE_NAME = 'users';PostgreSQL 还支持扩展统计信息,用于捕获多列相关性:
-- PostgreSQL:创建多列统计信息(捕获 city 和 age 的相关性)CREATE STATISTICS s1 (ndistinct, dependencies, mcv) ON city, age FROM users;ANALYZE users;
-- 查看统计信息SELECT * FROM pg_stats WHERE tablename = 'users';五、执行引擎
优化器生成执行计划后,执行引擎负责按计划执行。执行引擎的架构模型决定了查询的执行方式,对性能影响深远。
5.1 三种执行模型
| 模型 | 执行方式 | 虚函数调用 | CPU 缓存 | 适用场景 |
|---|---|---|---|---|
| Volcano/Iterator | 每次拉取一行 | 每行 N 次 | 差(逐行跳转) | 通用、易实现 |
| Vectorized | 每次拉取一批(如 1024 行) | 每批 N 次 | 好(批处理) | OLAP 分析查询 |
| Compiled | 编译为机器码 | 无虚函数调用 | 最好(紧凑循环) | 重复执行的查询 |
Volcano 模型(又称 Iterator 模型)是最经典的执行模型,几乎所有数据库都支持。每个算子实现三个接口:open() 初始化(如 Hash Join 建立哈希表),next() 返回下一行,close() 释放资源。
# Volcano 模型的 Hash Join 伪代码class HashJoin(Operator): def open(self): self.hash_table = {} self.build_child.open() while (row := self.build_child.next()) is not None: self.hash_table[self.key(row)] = row self.build_child.close() self.probe_child.open()
def next(self): while (row := self.probe_child.next()) is not None: if self.key(row) in self.hash_table: return self.emit(self.hash_table[self.key(row)], row) return NoneVolcano 模型的优势是简洁和通用,任何算子只需实现 open/next/close 即可自由组合。劣势是虚函数调用开销,每行数据都要经过多次虚函数调用,对 CPU 流水线不友好。
向量化执行(Vectorized Execution)是对 Volcano 模型的改进:每次 next() 返回一批行(通常 1024 行),而不是一行。这大幅减少了虚函数调用次数,并且批处理模式让 CPU 的 SIMD 指令和缓存预取得以发挥作用。DuckDB、ClickHouse 等分析型数据库广泛采用向量化执行,在 OLAP 场景下比 Volcano 快 5 到 10 倍并不罕见。
编译执行(Compiled Execution)将整个查询计划编译成一段机器码,消除了所有虚函数调用。Hyper 系统率先提出这一思路,后来被 Apache Spark 的 Whole-Stage Code Generation 和 SQL Server 采用。编译执行在查询重复执行时性能最优,但编译本身有开销,对于只执行一次的查询(如 ad-hoc 查询)可能得不偿失。
六、EXPLAIN 各列详解
EXPLAIN 是与优化器对话的窗口。读懂它的每一列,你就能判断优化器选的计划好不好、为什么不好、怎么改。
6.1 EXPLAIN 示例
EXPLAIN SELECT u.name, COUNT(*) AS order_countFROM users uJOIN orders o ON u.id = o.user_idWHERE u.city = '北京' AND o.status = 'completed'GROUP BY u.name;+----+-------------+-------+------------+------+-------------------+-------------------+---------+----------------+------+----------+-------------------------------------------+| id | select_type | table | type | key | key_len | ref | rows | filtered | Extra |+----+-------------+-------+------------+------+-------------------+-------------------+---------+----------+-------------------------------------------+| 1 | SIMPLE | u | ref | idx_city | 152 | const | 500 | 100.00 | Using index condition || 1 | SIMPLE | o | ref | idx_user_status | 12 | test.u.id | 10 | 33.33 | Using where; Using index; Using temporary |+----+-------------+-------+------------+------+-------------------+-------------------+---------+----------+-------------------------------------------+6.2 各列含义
| 列 | 含义 | 关注点 |
|---|---|---|
| id | 查询标识符,相同 id 表示同一层 Join | id 越大越先执行 |
| select_type | 查询类型(SIMPLE/PRIMARY/SUBQUERY 等) | 出现 SUBQUERY/DERIVED 需留意是否可优化 |
| table | 表名 | 衍生表显示为 <derivedN> |
| type | 访问类型,衡量扫描效率的核心列 | 从好到差见下表 |
| key | 实际使用的索引 | NULL 表示未使用索引(全表扫描) |
| key_len | 使用的索引长度(字节) | 判断联合索引用了几个列 |
| ref | 索引比较的来源(const/func/列名) | 联合索引匹配情况 |
| rows | 预估扫描行数 | 越小越好,依赖统计信息 |
| filtered | 过滤后剩余比例 | 100% 最好,10% 表示 90% 的行被丢弃 |
| Extra | 额外信息 | Using filesort / Using temporary 是危险信号 |
type 列的值从好到差排列:
| type 值 | 含义 | 扫描效率 |
|---|---|---|
| system | 表只有一行 | 最优 |
| const | 通过主键或唯一索引匹配单行 | 极快 |
| eq_ref | 使用主键或唯一索引的等值连接 | Join 中最优 |
| ref | 使用非唯一索引的等值匹配 | 较好 |
| range | 索引范围扫描 | 较好 |
| index | 扫描整个索引树 | 中等(不回表时可用) |
| ALL | 全表扫描 | 最差,需要优化 |
Extra 列常见值:
| Extra 值 | 含义 | 是否需关注 |
|---|---|---|
| Using index | 覆盖索引,不回表 | 好信号 |
| Using where | 服务层做过滤 | 正常 |
| Using index condition | 索引下推(ICP) | 好信号 |
| Using filesort | 额外排序操作 | 需关注,可能需加索引 |
| Using temporary | 使用临时表 | 需关注,GROUP BY/DISTINCT 常见 |
| Using join buffer | 使用 Join 缓冲区 | 需关注,可能缺索引 |
| Impossible WHERE | WHERE 条件恒假 | 检查条件逻辑 |
排查慢查询时,优先看 type 是否为 ALL(全表扫描),再看 Extra 是否出现 Using filesort 或 Using temporary,最后看 rows 是否远大于实际返回行数。三者组合起来,基本能定位大多数执行计划问题。
6.3 PostgreSQL EXPLAIN ANALYZE
PostgreSQL 的 EXPLAIN ANALYZE 不仅显示预估代价,还显示实际执行时间,是诊断计划偏差的利器:
EXPLAIN ANALYZESELECT u.name, COUNT(*) AS order_countFROM users uJOIN orders o ON u.id = o.user_idWHERE u.city = '北京' AND o.status = 'completed'GROUP BY u.name;HashAggregate (cost=1250.00..1260.00 rows=200 width=20) (actual time=8.234..8.456 rows=150 loops=1) Group Key: u.name Batches: 1 Memory Usage: 40kB -> Hash Join (cost=450.00..1200.00 rows=5000 width=20) (actual time=3.123..7.890 rows=4800 loops=1) Hash Cond: (o.user_id = u.id) -> Seq Scan on orders o (cost=0.00..350.00 rows=5000 width=12) (actual time=0.012..2.345 rows=4800 loops=1) Filter: (status = 'completed'::text) Rows Removed by Filter: 5200 -> Hash (cost=200.00..200.00 rows=500 width=12) (actual time=0.789..0.789 rows=500 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 28kB -> Seq Scan on users u (cost=0.00..200.00 rows=500 width=12) (actual time=0.008..0.567 rows=500 loops=1) Filter: (city = '北京'::text)Planning Time: 0.234 msExecution Time: 8.567 ms注意 cost(预估)和 actual(实际)的对比。如果两者差距很大,说明统计信息失真,这是执行计划劣化的最常见原因。上例中预估 5000 行、实际 4800 行,偏差在可接受范围内。MySQL 8.0.18+ 也支持 EXPLAIN ANALYZE,输出格式类似。
七、慢查询排查 SOP
原理讲完,现在进入实战。当线上接口变慢,你需要一套标准流程来定位和解决问题。
7.1 性能优化分层
性能优化不是随机尝试,而是有层次的系统性工程。越底层的优化收益越大、成本越低,越上层的优化越精细、越需要领域知识:
| 层级 | 优化方向 | 典型收益 | 实施成本 |
|---|---|---|---|
| 架构层 | 读写分离、分库分表 | 10x~100x | 高(涉及架构变更) |
| 配置层 | 参数调优、连接池、缓冲池 | 2x~5x | 低(改配置即可) |
| SQL 层 | 慢查询优化、索引设计 | 5x~50x | 中(需理解业务) |
| 应用层 | 缓存、批量、异步 | 3x~20x | 中(需改代码) |
优化顺序应该是自底向上:先确保架构合理,再调配置,再优化 SQL,最后在应用层做精细化。一个架构不合理的系统,SQL 再怎么优化也无法突破天花板。
7.2 排查标准流程
慢查询排查有一条清晰的标准流程,从发现问题到定位根因再到修复验证:
7.3 第一步:开启慢查询日志
MySQL 提供了内置的慢查询日志功能,记录执行时间超过阈值的 SQL:
-- 启用慢查询日志SET GLOBAL slow_query_log = ON;SET GLOBAL long_query_time = 1; -- 超过 1 秒的查询SET GLOBAL log_queries_not_using_indexes = ON; -- 记录未使用索引的查询SET GLOBAL min_examined_row_limit = 100; -- 至少扫描 100 行才记录
-- 查看慢查询日志位置SHOW VARIABLES LIKE 'slow_query_log_file';慢查询日志的一条输出示例:
# User@Host: appuser[appuser] @ web-server [10.0.1.5]# Query_time: 3.521400 Lock_time: 0.000120 Rows_sent: 1 Rows_examined: 2847293SELECT * FROM orders WHERE status = 'pending' AND created_at > '2026-04-01' ORDER BY amount DESC LIMIT 10;关键指标解读:
| 指标 | 含义 | 优化方向 |
|---|---|---|
| Query_time | 查询总耗时 | 关注是否可减少扫描行数 |
| Lock_time | 等锁耗时 | 关注是否存在锁竞争,详见锁机制与死锁 |
| Rows_examined | 扫描行数 | 与 Rows_sent 的比值越大,优化空间越大 |
| Rows_sent | 返回行数 | 是否返回了过多不需要的数据 |
Rows_examined / Rows_sent 的比值是衡量查询效率的关键指标。比值越大,说明扫描了大量行才返回少量结果,索引设计或查询写法有问题。上例中扫描了 284 万行只返回 10 行,比值 28 万倍,优化空间巨大。
7.4 第二步:聚合分析
慢查询日志可能记录成千上万条 SQL,逐条看效率太低。用 mysqldumpslow 聚合分析,把相似查询归为一类:
# 按总耗时排序,取 Top 10mysqldumpslow -s t -t 10 /var/lib/mysql/mysql-slow.log
# 按次数排序(找出频繁执行的查询)mysqldumpslow -s c -t 10 /var/lib/mysql/mysql-slow.log
# 按返回行数排序mysqldumpslow -s r -t 10 /var/lib/mysql/mysql-slow.log-s 指定排序方式(t=总耗时、c=次数、r=返回行数),-t 指定取前 N 条。输出会将具体的参数值替换为 S(字符串)和 N(数字),形成查询指纹,便于聚合。
Percona Toolkit 的 pt-query-digest 功能更强大,输出更详细:
# 基本用法:分析慢查询日志pt-query-digest /var/lib/mysql/mysql-slow.log
# 输出按总耗时排序的查询指纹# Rank Query ID Response time Calls R/Call V/M# ==== ================== ============== ====== ======= ====# 1 0x3F8E1A2B4C5D6E7F 1200.1234 62.5% 3421 0.3508 0.01# 2 0x7A8B9C0D1E2F3A4B 450.5678 23.5% 1205 0.3736 0.02
# 只分析最近 1 小时的慢查询pt-query-digest --since '1h' /var/lib/mysql/mysql-slow.logPostgreSQL 则用 pg_stat_statements 扩展提供结构化的查询统计:
-- 启用扩展CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 查找最耗时的 Top 10 查询SELECT query, calls, round(total_exec_time::numeric, 2) AS total_ms, round(mean_exec_time::numeric, 2) AS avg_ms, round((100 * total_exec_time / SUM(total_exec_time) OVER ())::numeric, 2) AS pct_totalFROM pg_stat_statementsORDER BY total_exec_time DESCLIMIT 10;7.5 第三步:EXPLAIN 定位
拿到慢查询指纹后,对具体 SQL 执行 EXPLAIN,看 type、key、rows、Extra 四列定位问题。常见问题模式:
| 现象 | 根因 | 修复方向 |
|---|---|---|
| type=ALL,key=NULL | 无索引或索引未生效 | 加索引,检查隐式转换等失效场景 |
| type=ALL,key 有值 | 索引存在但优化器没用 | 检查选择率,或用 FORCE INDEX |
| Using filesort | ORDER BY 列无索引 | 加 (过滤列, 排序列) 联合索引 |
| Using temporary | GROUP BY/DISTINCT 产生临时表 | 调整 GROUP BY 列顺序或加覆盖索引 |
| rows 远大于返回行数 | 索引选择性差 | 调整索引列顺序,高选择性列在前 |
7.6 第四步:修复与验证
定位问题后,加索引或改写 SQL,然后用 EXPLAIN 和实际执行时间对比验证。
案例 1:缺少索引导致全表扫描
-- 问题:扫描 280 万行,返回 10 行SELECT * FROM ordersWHERE status = 'pending' AND created_at > '2026-04-01'ORDER BY amount DESC LIMIT 10;-- Query_time: 3.52s Rows_examined: 2847293 Rows_sent: 10
-- 分析:EXPLAIN 显示 type=ALL(全表扫描)EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND created_at > '2026-04-01';
-- 优化:添加联合索引(参考[索引原理与失效分析](./mysql-索引原理与失效分析.md)的最左前缀原则)CREATE INDEX idx_status_created_amount ON orders(status, created_at, amount);
-- 优化后:索引范围扫描 + 覆盖索引-- Query_time: 0.003s Rows_examined: 156 Rows_sent: 10案例 2:隐式类型转换导致索引失效
-- 慢:phone 是 VARCHAR,传入整数导致隐式转换,索引失效EXPLAIN SELECT * FROM users WHERE phone = 13800138000;-- type: ALL, rows: 1000000, Extra: Using where
-- 快:传入字符串,走索引EXPLAIN SELECT * FROM users WHERE phone = '13800138000';-- type: ref, key: idx_phone, rows: 1案例 3:ORDER BY 导致 filesort
-- 慢:索引在 (status),但 ORDER BY created_at 无索引EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 10;-- Extra: Using where; Using filesort
-- 快:建立覆盖排序的联合索引CREATE INDEX idx_status_created ON orders(status, created_at);EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 10;-- type: ref, key: idx_status_created, Extra: Using index condition案例 4:小表驱动大表
-- 慢:大表做外表,扫描 100 万行EXPLAIN SELECT * FROM large_table l JOIN small_table s ON l.key = s.key;-- Nested Loop, 驱动表 large_table
-- 快:小表做外表,只扫描 1000 行EXPLAIN SELECT /*+ QB_NAME(main) */ * FROM small_table sJOIN large_table l ON l.key = s.key;-- Hash Join, build 侧 small_table索引失效的原因远不止隐式类型转换。在索引原理与失效分析中详细分析了函数调用、OR 条件、LIKE 前缀通配符、最左前缀违反等场景,此处不再赘述。
7.7 N+1 查询问题
N+1 是应用层最常见的查询性能问题:循环中执行单条查询,1000 条数据产生 1001 次查询。这不是单条 SQL 慢,而是查询次数多导致总耗时高。
# 问题:循环中执行单条查询,1000 次查询# 应用层伪代码:# for order in orders: # 1000 条# SELECT * FROM users WHERE id = order.user_id;
# 优化一:批量查询SELECT * FROM users WHERE id IN (1, 2, 3, ..., 1000);
# 优化二:JOIN 查询SELECT o.*, u.name, u.emailFROM orders oJOIN users u ON o.user_id = u.idWHERE o.status = 'pending';7.8 计划缓存与 Hint
当优化器选错计划时,可以通过 Hint 强制指定执行路径:
-- MySQL:USE INDEX / FORCE INDEXSELECT * FROM users USE INDEX(idx_city) WHERE city = '北京';SELECT * FROM users FORCE INDEX(idx_city) WHERE city = '北京';
-- USE INDEX:建议使用,优化器仍可忽略-- FORCE INDEX:强制使用,除非无法使用(如索引不包含所需列)PostgreSQL 通过 pg_hint_plan 扩展实现类似功能:
-- PostgreSQL:通过 pg_hint_plan 扩展/*+ SeqScan(users) HashJoin(users orders) */SELECT u.name, COUNT(*)FROM users u JOIN orders o ON u.id = o.user_idWHERE u.city = '北京' GROUP BY u.name;比 Hint 更可靠的方式是绑定执行计划,将特定 SQL 模式与固定的执行计划关联。MySQL 没有原生的计划绑定机制,控制执行计划只能靠 Optimizer Hint(/*+ ... */ 注释与 SET_VAR(optimizer_switch='...'));Oracle 的 SQL Profile 和 SQL Server 的 Plan Guide 才是成熟的计划绑定机制,PostgreSQL 有 pg_plan_filter 扩展。
绑定执行计划是双刃剑,它绕过了优化器,数据分布变化后可能适得其反。只在确认优化器反复选错计划时才使用,并定期审查绑定的计划是否仍然最优。
数据库会缓存执行计划,避免重复优化。但参数化查询的计划缓存有一个经典陷阱,参数嗅探(Parameter Sniffing):
-- 第一次执行:city = '北京' 返回 50 万行,优化器选择 Seq ScanPREPARE get_users(VARCHAR) AS SELECT * FROM users WHERE city = $1;EXECUTE get_users('北京');
-- 第二次执行:city = '拉萨' 返回 100 行,但复用了 Seq Scan 的计划EXECUTE get_users('拉萨');PostgreSQL 使用通用计划(Generic Plan)和自定义计划(Custom Plan)的自动切换来缓解此问题。MySQL 8.0 没有等价的计划缓存开关,optimizer_switch 控制的是 hash_join、index_merge、semijoin 等优化器特性的开关,不影响预定义语句的计划复用(8.0 已移除 query cache,prepared statement 的计划复用是自动行为)。
八、连接池配置
慢查询之外,连接管理是另一个常见的性能瓶颈点。
8.1 为什么需要连接池
每次建立数据库连接都需要 TCP 三次握手、SSL 协商、身份认证、会话初始化,整个过程可能耗时 10 到 50ms。如果每个请求都新建连接,高并发下连接建立本身就会成为瓶颈。
| 维度 | 无连接池 | 有连接池 |
|---|---|---|
| 连接建立开销 | 每次请求 10~50ms | 首次建立,后续小于 1ms |
| 并发连接数 | 不可控,可能打满 | 可控,由池大小限制 |
| 连接生命周期 | 短连接,频繁创建/销毁 | 长连接,复用 |
| 数据库压力 | 高(频繁认证) | 低(连接复用) |
8.2 HikariCP 配置
HikariCP 是 Java 生态中性能最高的连接池,Spring Boot 2.x+ 默认使用:
spring: datasource: hikari: # 核心配置 maximum-pool-size: 20 # 最大连接数 minimum-idle: 5 # 最小空闲连接数 connection-timeout: 30000 # 获取连接超时(ms) idle-timeout: 600000 # 空闲连接超时(ms) max-lifetime: 1800000 # 连接最大存活时间(ms)
# 泄漏检测 leak-detection-threshold: 60000 # 连接泄漏检测阈值(ms)
# 连接验证 connection-test-query: SELECT 1 # 连接有效性检查(MySQL) validation-timeout: 5000 # 验证超时(ms)8.3 Druid 连接池
Druid 是阿里巴巴开源的数据库连接池,在国内 Java 生态中使用广泛。相比 HikariCP,Druid 内置了 SQL 防火墙和监控统计功能,适合需要 SQL 审计的场景。
spring: datasource: druid: # 核心配置 initial-size: 5 # 初始化连接数 min-idle: 5 # 最小空闲连接 max-active: 20 # 最大活跃连接 max-wait: 60000 # 获取连接超时(ms)
# 连接保活 validation-query: SELECT 1 test-while-idle: true # 空闲时检测 time-between-eviction-runs-millis: 60000 # 检测间隔 min-evictable-idle-time-millis: 300000 # 最小空闲时间
# 监控与防火墙 filters: stat,wall # 统计 + SQL 防火墙 connection-properties: druid.stat.slowSqlMillis=1000 # 慢 SQL 阈值8.4 PgBouncer 配置
PgBouncer 是 PostgreSQL 的高性能连接池,支持三种池化模式:
; pgbouncer.ini[databases]mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]; 池化模式选择pool_mode = transaction ; session / transaction / statement
; 连接数配置max_client_conn = 1000 ; 最大客户端连接数default_pool_size = 25 ; 每个数据库/用户对的默认池大小min_pool_size = 5 ; 最小池大小reserve_pool_size = 5 ; 预留池大小(突发流量)reserve_pool_timeout = 3 ; 等待预留连接超时(秒)
; 超时配置server_idle_timeout = 600 ; 服务端空闲连接超时(秒)client_idle_timeout = 0 ; 客户端空闲超时(0=不超时)query_timeout = 30 ; 查询超时(秒)query_wait_timeout = 120 ; 等待服务端连接超时(秒)| 模式 | 连接释放时机 | 适用场景 | 事务支持 |
|---|---|---|---|
| session | 客户端断开 | 需要会话状态(SET、PREPARE、临时表) | 完整 |
| transaction | 事务结束 | 大多数 Web 应用(推荐) | 完整 |
| statement | 语句执行完 | 无事务的简单查询 | 不支持 |
8.5 连接数计算
连接数不是越多越好。过多的连接会导致上下文切换开销增大、锁竞争加剧。经验公式:
最优连接数 = CPU 核心数 × (1 + 等待时间 / 计算时间)
举例:8 核 CPU,查询计算时间 5ms,I/O 等待时间 45ms,最优连接数 = 8 × (1 + 45/5) = 80。
上述公式是起点而非终点。实际连接数还需要根据连接池监控数据(活跃连接比、等待线程数)动态调整。如果活跃连接长期接近池大小上限,说明池太小;如果大部分连接空闲,说明池太大。
九、缓存策略
缓存是应用层性能优化的核心手段。当数据库本身已经优化到极限,缓存可以把热点数据的读取从毫秒级降到微秒级。
9.1 缓存模式
| 模式 | 读路径 | 写路径 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 先读缓存,miss 读 DB 后回填 | 先更新 DB,再删缓存 | 最终一致 | 通用场景(最常用) |
| Write-Through | 先读缓存,miss 读 DB 后回填 | 先更新缓存,缓存同步写 DB | 强一致 | 一致性要求高 |
| Write-Behind | 先读缓存,miss 读 DB 后回填 | 先更新缓存,异步批量写 DB | 最终一致 | 写入密集、允许丢失 |
9.2 缓存三大问题
缓存穿透:查询不存在的数据,缓存永远 miss,请求直达数据库。
# 解决方案一:布隆过滤器(推荐)# 在缓存层前加一层布隆过滤器,不存在的 key 直接拦截import pybloom_live
bloom = pybloom_live.ScalableBloomFilter(initial_capacity=1000000)
# 启动时加载所有合法 keyfor user_id in db.query("SELECT id FROM users"): bloom.add(f"user:{user_id}")
def get_user(user_id): key = f"user:{user_id}" if key not in bloom: # 布隆过滤器说不存在,直接返回 return None return cache_aside_get(key) # 可能存在,走正常缓存流程
# 解决方案二:缓存空值(短 TTL)def get_user_with_null_cache(user_id): key = f"user:{user_id}" data = redis.get(key) if data is not None: return None if data == "NULL" else deserialize(data) user = db.query("SELECT * FROM users WHERE id = %s", user_id) redis.setex(key, 300 if user is None else 3600, "NULL" if user is None else serialize(user)) return user缓存击穿:热点 key 过期瞬间,大量并发请求同时穿透到数据库。
# 解决方案:互斥锁(只允许一个请求回源)import redis
r = redis.Redis()
def get_user_with_mutex(user_id): key = f"user:{user_id}" lock_key = f"lock:{key}" data = r.get(key) if data is not None: return deserialize(data) acquired = r.set(lock_key, "1", nx=True, ex=10) # 互斥锁,10 秒超时 if acquired: try: user = db.query("SELECT * FROM users WHERE id = %s", user_id) r.setex(key, 3600, serialize(user)) return user finally: r.delete(lock_key) else: time.sleep(0.1) return get_user_with_mutex(user_id) # 等待并重试缓存雪崩:大量 key 同时过期,或缓存节点宕机,请求全部打到数据库。
# 解决方案一:随机过期时间def set_with_jitter(key, value, base_ttl=3600, jitter_range=300): jitter = random.randint(-jitter_range, jitter_range) r.setex(key, base_ttl + jitter, value)
# 解决方案二:多级缓存(本地缓存 + Redis)from cachetools import TTLCachelocal_cache = TTLCache(maxsize=10000, ttl=60) # 本地缓存 60 秒
def get_user_multi_level(user_id): key = f"user:{user_id}" if key in local_cache: return local_cache[key] # L1 data = r.get(key) if data is not None: result = deserialize(data) local_cache[key] = result return result # L2 user = db.query("SELECT * FROM users WHERE id = %s", user_id) r.setex(key, 3600, serialize(user)) local_cache[key] = user return user # L3| 问题 | 触发条件 | 核心危害 | 解决方案 |
|---|---|---|---|
| 穿透 | 查询不存在的数据 | 恶意请求打垮 DB | 布隆过滤器 / 缓存空值 |
| 击穿 | 热点 key 过期 | 瞬时并发压垮 DB | 互斥锁 / 永不过期+异步刷新 |
| 雪崩 | 大量 key 同时过期 | DB 瞬时负载飙升 | 随机 TTL / 多级缓存 / 熔断降级 |
9.3 Redis 缓存数据结构选择
# String:简单 KV 缓存SET user:1001 '{"name":"张三","age":28}' EX 3600
# Hash:对象缓存(比 String 更节省内存,支持部分读取)HSET user:1001 name "张三" age 28 city "北京"HGET user:1001 name
# ZSet:排行榜缓存(利用跳表的有序特性)ZADD leaderboard 9500 "player:A" 8800 "player:B" 9200 "player:C"ZREVRANGE leaderboard 0 9 WITHSCORES # Top 10
# Bitmap:用户签到(极致节省内存)SETBIT sign:uid:1001:202604 20 1 # 4 月 20 日签到BITCOUNT sign:uid:1001:202604 # 本月签到次数十、参数调优
数据库默认参数是通用场景的保守配置,针对具体负载调优参数可以获得显著性能提升。
10.1 MySQL 关键参数
# my.cnf — MySQL 8.0 性能调优配置
[mysqld]# ===== InnoDB 缓冲池 =====# 核心参数:缓存数据和索引的内存区域# 建议设为物理内存的 60% 到 80%(独占服务器)innodb_buffer_pool_size = 8G
# 缓冲池实例数(减少锁竞争)# 建议:每个实例 1G 以上innodb_buffer_pool_instances = 8
# ===== I/O 能力 =====# 每秒后台刷新的页数(SSD 建议 10000+,HDD 建议 200)innodb_io_capacity = 10000innodb_io_capacity_max = 20000
# 刷新邻接页(SSD 关闭,HDD 开启)innodb_flush_neighbors = 0
# ===== 日志与持久化 =====# binlog 刷盘策略# 0=依赖 OS 刷盘 1=每次提交刷盘(最安全) N=每 N 次提交刷盘sync_binlog = 1
# redo log 刷盘策略# 1=每次提交刷盘(最安全) 2=每次提交写 OS 缓存,每秒刷盘innodb_flush_log_at_trx_commit = 1
# ===== 并发与连接 =====innodb_thread_concurrency = 0 # 0=不限制(推荐,InnoDB 自管理)max_connections = 500thread_cache_size = 100
# ===== 排序与 Join 缓冲 =====# 每个线程的排序缓冲区(按连接分配,注意总内存)sort_buffer_size = 4M# 每个 Join 的缓冲区(无索引 Join 时使用)join_buffer_size = 4M
# ===== 慢查询日志 =====slow_query_log = 1long_query_time = 1log_queries_not_using_indexes = 1MySQL 关键参数速查表:
| 参数 | 默认值 | 推荐值 | 影响 |
|---|---|---|---|
| innodb_buffer_pool_size | 128M | 物理内存 60%~80% | 最重要参数,直接影响缓存命中率 |
| innodb_io_capacity | 200 | SSD: 10000+ | 后台刷新速度,影响脏页刷盘 |
| sync_binlog | 1 | 1(安全)/ 100(性能) | binlog 持久性 vs 性能 |
| innodb_flush_log_at_trx_commit | 1 | 1(安全)/ 2(折中) | redo log 持久性 vs 性能 |
| innodb_flush_neighbors | 1 | SSD: 0 / HDD: 1 | 顺序写优化,SSD 无需 |
| sort_buffer_size | 256K | 4M | 排序内存,过小触发磁盘排序 |
| join_buffer_size | 256K | 4M | 无索引 Join 内存,过小分批扫描 |
innodb_flush_log_at_trx_commit = 2 和 sync_binlog = 100 可以显著提升写入性能,但在操作系统崩溃时可能丢失 1 秒数据。在InnoDB 架构与实现中详细分析了 InnoDB 的 Doublewrite 和 Redo Log 机制,事务原理与隔离级别中讨论了持久性权衡,理解这些机制有助于做出正确的决策。
sort_buffer_size 和 join_buffer_size 是按连接分配的内存。1000 个活跃连接各分配 4M,就是 4G 内存。不要盲目调大,需要根据 max_connections 和物理内存综合计算。
10.2 PostgreSQL 关键参数
# postgresql.conf — PostgreSQL 16 性能调优配置
# ===== 共享缓冲区 =====# 数据库共享内存,缓存数据页# 建议设为物理内存的 25%(不超过 40%)shared_buffers = 4GB
# ===== 查询规划器缓存估计 =====# 规划器假设可用于缓存的内存(不实际分配)# 建议设为物理内存的 50% 到 75%effective_cache_size = 12GB
# ===== 排序与哈希操作 =====# 每个操作的最大内存(按连接分配,注意总内存)work_mem = 64MB
# 维护操作内存(VACUUM、CREATE INDEX)maintenance_work_mem = 1GB
# ===== WAL 配置 =====# WAL 写入策略# fsync = on(安全)/ off(危险但快)fsync = onsynchronous_commit = on # on(安全)/ off(性能)wal_buffers = 64MB
# ===== 检查点与自动清理 =====max_wal_size = 4GB # WAL 最大大小autovacuum = onautovacuum_max_workers = 4PostgreSQL 关键参数速查表:
| 参数 | 默认值 | 推荐值 | 影响 |
|---|---|---|---|
| shared_buffers | 128MB | 物理内存 25% | 数据页缓存,直接影响 I/O |
| effective_cache_size | 4GB | 物理内存 50%~75% | 影响规划器决策(不实际分配) |
| work_mem | 4MB | 32~256MB | 排序/哈希内存,影响磁盘排序 |
| maintenance_work_mem | 64MB | 1GB+ | VACUUM/CREATE INDEX 速度 |
| max_wal_size | 1GB | 2~8GB | WAL 回收阈值,影响检查点频率 |
10.3 Linux 内核参数
数据库性能不仅取决于数据库配置,还受操作系统参数影响:
# ===== 虚拟内存 =====# 降低 swappiness,减少交换(数据库推荐 1~10)vm.swappiness = 1
# 脏页刷新策略vm.dirty_background_ratio = 5 # 后台刷新阈值(%)vm.dirty_ratio = 10 # 强制刷新阈值(%)
# ===== 网络优化 =====# TCP 连接队列net.core.somaxconn = 65535net.core.netdev_max_backlog = 65535
# TCP 优化net.ipv4.tcp_keepalive_time = 600net.ipv4.tcp_tw_reuse = 1
# ===== 文件描述符 =====fs.file-max = 1000000
# 应用生效sysctl -p /etc/sysctl.d/99-database.confMySQL 与 PostgreSQL 参数调优对比:
| 调优维度 | MySQL | PostgreSQL |
|---|---|---|
| 数据缓存 | innodb_buffer_pool_size(独占) | shared_buffers(共享内存) |
| 规划器提示 | 无直接等价 | effective_cache_size |
| 排序内存 | sort_buffer_size(按连接) | work_mem(按操作) |
| WAL 策略 | innodb_flush_log_at_trx_commit | synchronous_commit + fsync |
| 后台清理 | InnoDB 自管理 | autovacuum 系列参数 |
| I/O 能力 | innodb_io_capacity | effective_io_concurrency |
十一、监控体系
没有监控的优化是盲目的。建立完善的监控体系,才能量化问题、验证优化效果、及时发现问题。
11.1 指标分类
数据库监控指标遵循 USE 方法(Utilization / Saturation / Errors)和 RED 方法(Rate / Errors / Duration):
核心监控指标分类:
| 类别 | 指标 | 告警阈值建议 |
|---|---|---|
| 延迟 | 查询 P99 延迟 | 大于 500ms(Warning),大于 2s(Critical) |
| 延迟 | 连接获取延迟 | 大于 100ms |
| 吞吐 | QPS / TPS | 下降 30%(Warning) |
| 吞吐 | 慢查询数量 | 大于 10/min(Warning) |
| 错误 | 查询错误率 | 大于 1%(Warning),大于 5%(Critical) |
| 错误 | 死锁频率 | 大于 5/min |
| 饱和 | 活跃连接数 / 最大连接数 | 大于 80% |
| 饱和 | 缓冲池命中率 | 小于 95%(Warning) |
| 饱和 | 磁盘 I/O 利用率 | 大于 80% |
11.2 Prometheus + Grafana 监控
监控部署(Docker Compose):
# docker-compose.yml — 添加 Exporterservices: mysql-exporter: image: prom/mysqld-exporter environment: DATA_SOURCE_NAME: "exporter:password@(mysql:3306)/" ports: ["9104:9104"] postgres-exporter: image: prometheuscommunity/postgres-exporter environment: DATA_SOURCE_NAME: "postgresql://exporter:password@postgres:5432/postgres?sslmode=disable" ports: ["9187:9187"]关键 Grafana 面板指标(PromQL):
# MySQL 缓冲池命中率rate(mysql_global_status_buffer_pool_read_requests[5m]) / (rate(mysql_global_status_buffer_pool_read_requests[5m]) + rate(mysql_global_status_buffer_pool_reads[5m]))
# MySQL 活跃连接数mysql_global_status_threads_running
# PostgreSQL 缓存命中率rate(pg_stat_database_blks_hit{datname="mydb"}[5m]) / (rate(pg_stat_database_blks_hit{datname="mydb"}[5m]) + rate(pg_stat_database_blks_read{datname="mydb"}[5m]))11.3 告警规则
# alert_rules.yml — 数据库告警规则groups: - name: database_alerts rules: # 慢查询激增 - alert: HighSlowQueryRate expr: rate(mysql_global_status_slow_queries[5m]) > 10 for: 5m labels: severity: warning annotations: summary: "MySQL 慢查询激增" description: "慢查询速率 {{ $value }} 次/秒,持续 5 分钟"
# 连接数接近上限 - alert: ConnectionPoolExhaustion expr: mysql_global_status_threads_running / mysql_global_variables_max_connections > 0.8 for: 3m labels: severity: critical annotations: summary: "MySQL 连接池即将耗尽" description: "活跃连接占比 {{ $value | humanizePercentage }}"
# PostgreSQL 复制延迟 - alert: PostgreSQLReplicationLag expr: pg_replication_lag > 30 for: 5m labels: severity: critical annotations: summary: "PostgreSQL 复制延迟超过 30 秒" description: "当前延迟 {{ $value }} 秒"告警不是越多越好。过多的告警会导致告警疲劳,工程师开始忽略所有告警。每个告警都必须可操作(收到后知道该做什么),优先级明确(Warning vs Critical),避免重复告警。
参考资料
- MySQL 8.0 EXPLAIN Output Format - MySQL 官方文档,EXPLAIN 各列含义权威解释
- MySQL 8.0 Cost Model - MySQL 代价常量与优化器代价模型
- PostgreSQL EXPLAIN Documentation - PostgreSQL EXPLAIN/ANALYZE 语法与输出解读
- Percona pt-query-digest - 慢查询日志分析工具官方文档
- HikariCP Wiki - HikariCP 连接池配置参数与调优建议
- Druid GitHub - 阿里巴巴 Druid 连接池,含 SQL 防火墙与监控
- PgBouncer Documentation - PostgreSQL 连接池,三种池化模式说明
- USE Method - Brendan Gregg 的 USE 方法论,资源监控指标分类
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






