mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
7123 字
20 分钟
MySQL 查询优化与慢排查
2024-06-25

一条 SQL 从敲下回车到返回结果,中间经历了语法解析、查询重写、代价优化、执行引擎调度多个阶段。当它变慢时,你需要知道慢在哪一环:是优化器选错了执行计划,是索引没建对,是连接池耗尽,还是缓冲池命中率太低。

本文把查询处理的原理和慢查询排查的工程实践放在一篇里讲透。前半部分是原理:查询处理流水线、代价模型、统计信息、EXPLAIN 各列含义。后半部分是排查 SOP:慢查询日志、mysqldumpslow 聚合、EXPLAIN 定位、连接池配置、参数调优、监控体系。原理和实战对照着看,你才能读懂 EXPLAIN 输出背后的优化器决策,也能在排查时有章可循。

前置知识#

Important

一、查询处理流水线#

数据库处理一条 SQL 的过程是一条精密的流水线,每一阶段都有明确的输入和输出,上一阶段的输出就是下一阶段的输入。

flowchart LR SQL["SQL 文本"] --> Parser["Parser<br/>词法+语法分析"] Parser --> AST["语法树 AST"] AST --> Semantic["语义分析<br/>类型检查/权限/名称解析"] Semantic --> QueryTree["查询树 Query Tree"] QueryTree --> Rewriter["查询重写<br/>视图展开/规则系统"] Rewriter --> RewrittenTree["重写后的查询树"] RewrittenTree --> Optimizer["查询优化器<br/>逻辑优化+物理优化"] Optimizer --> Plan["执行计划 Execution Plan"] Plan --> Executor["执行引擎"] Executor --> Result["结果集"] style SQL fill:#e3f2fd,stroke:#1565c0 style Parser fill:#e8f5e9,stroke:#2e7d32 style Optimizer fill:#fff3e0,stroke:#e65100 style Executor fill:#fce4ec,stroke:#c62828 style Result fill:#f3e5f5,stroke:#6a1b9a

这条流水线分为四个阶段:

阶段输入输出核心任务
解析SQL 文本语法树词法分析 + 语法分析,验证 SQL 是否合法
语义分析与重写语法树查询树名称解析、类型检查、权限验证、视图展开
优化查询树执行计划逻辑优化 + 物理优化,找到最优执行路径
执行执行计划结果集按计划访问数据、计算结果
Note

优化器是整条流水线中最复杂的组件。一个查询的可能执行计划数量随表的数量呈指数增长,3 张表的 Join 就有 12 种排列,5 张表有 1680 种。优化器的核心挑战就是在有限时间内从海量候选中选出足够好的计划。

二、解析与重写#

2.1 语法解析#

语法解析分两步:词法分析(Lexer)和语法分析(Parser)。词法分析将 SQL 文本拆成一个个 Token(词法单元),例如:

SELECT name, age FROM users WHERE age > 18;

被拆分为 SELECTname,ageFROMusersWHEREage>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 o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.city = '北京');
-- 重写后:Semi Join(一次扫描完成)
SELECT o.* FROM orders o
SEMI 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.id
WHERE u.city = '北京';
-- 优化后:先过滤再 Join(中间结果小)
SELECT o.* FROM orders o JOIN (SELECT * FROM users WHERE city = '北京') u
ON o.user_id = u.id;

列裁剪(Column Pruning),只读取查询需要的列,减少 I/O 和内存占用。在索引原理与失效分析中讨论的覆盖索引,正是列裁剪在索引层面的体现。

Join 重排序,调整 Join 的顺序,将小表或过滤后行数少的表放在前面。这是优化器搜索空间最大的来源,N 张表的 Join 有 N! 种排列顺序。

3.2 物理优化#

逻辑优化决定了”做什么”,物理优化决定”怎么做”。同一个逻辑算子有多种物理实现,代价各不相同:

flowchart TD subgraph 逻辑算子["逻辑算子"] SCAN["TableScan"] FILTER["Filter"] JOIN["Join"] SORT["Sort"] AGG["Aggregate"] end subgraph 物理实现["物理实现选择"] SEQ["顺序扫描 Seq Scan"] IDX["索引扫描 Index Scan"] IDXONLY["索引只读扫描 Index Only Scan"] NL["Nested Loop Join"] HJ["Hash Join"] SMJ["Sort Merge Join"] INMEM["内存排序"] EXTSORT["外部排序"] HASHAGG["Hash Aggregate"] SORTAGG["Sort Aggregate"] end SCAN --> SEQ SCAN --> IDX SCAN --> IDXONLY JOIN --> NL JOIN --> HJ JOIN --> SMJ SORT --> INMEM SORT --> EXTSORT AGG --> HASHAGG AGG --> SORTAGG style 逻辑算子 fill:#e8f5e9,stroke:#2e7d32 style 物理实现 fill:#fff3e0,stroke:#e65100

3.3 Join 算法选择#

Join 是最复杂也最关键的算子。四种经典 Join 算法各有适用场景:

flowchart TD START["Join 操作"] --> SIZE{"两表大小关系"} SIZE -->|"一表很小 可放内存"| NL["Nested Loop Join<br/>小表做外表 O(M*N)"] SIZE -->|"两表都大 无序"| HJ["Hash Join<br/>小表建 Hash 表 O(M+N)"] SIZE -->|"两表都大 已排序"| SMJ["Sort Merge Join<br/>双指针扫描 O(M+N)"] SIZE -->|"两表都大 内存不足"| GHJ["Grace Hash Join<br/>分区+逐区 Hash Join"] NL --> NL_NOTE["适合:有索引可用 或小表驱动大表"] HJ --> HJ_NOTE["适合:等值 Join 无排序要求"] SMJ --> SMJ_NOTE["适合:非等值 Join 或数据已有序"] GHJ --> GHJ_NOTE["适合:等值 Join 内存不足场景"] style START fill:#e3f2fd,stroke:#1565c0 style NL fill:#e8f5e9,stroke:#2e7d32 style HJ fill:#fff3e0,stroke:#e65100 style SMJ fill:#fce4ec,stroke:#c62828 style GHJ fill:#f3e5f5,stroke:#6a1b9a
Join 算法时间复杂度适用场景优势劣势
Nested LoopO(M×N)小表驱动大表、有索引简单、支持非等值 Join大表全扫描极慢
Hash JoinO(M+N)等值 Join、两表都大等值 Join 最快只支持等值、需内存
Sort MergeO(MlogM+NlogN)数据已排序、非等值支持非等值、可利用索引序需要排序开销
Grace HashO(M+N) I/O等值 Join、内存不足突破内存限制多轮 I/O
Tip

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 页数。

Warning

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)
Note

统计信息失真是执行计划劣化的头号原因。一张百万行的表,如果统计信息显示只有 1000 行,优化器可能选择 Index Scan;但实际扫描百万行的 Index Scan 比 Seq Scan 慢得多,因为随机 I/O 的代价远高于顺序 I/O。这就是为什么定期收集统计信息是必要的。

统计信息不会自动保持最新,大量 INSERT/UPDATE/DELETE 后会逐渐失真。MySQL 手动收集:

-- MySQL 收集统计信息
ANALYZE TABLE users;
-- 查看索引的 Cardinality
SHOW INDEX FROM users;
-- 查看统计信息详情
SELECT * FROM information_schema.STATISTICS
WHERE 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 None

Volcano 模型的优势是简洁和通用,任何算子只需实现 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_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE 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 表示同一层 Joinid 越大越先执行
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 WHEREWHERE 条件恒假检查条件逻辑
Tip

排查慢查询时,优先看 type 是否为 ALL(全表扫描),再看 Extra 是否出现 Using filesortUsing temporary,最后看 rows 是否远大于实际返回行数。三者组合起来,基本能定位大多数执行计划问题。

6.3 PostgreSQL EXPLAIN ANALYZE#

PostgreSQL 的 EXPLAIN ANALYZE 不仅显示预估代价,还显示实际执行时间,是诊断计划偏差的利器:

EXPLAIN ANALYZE
SELECT u.name, COUNT(*) AS order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE 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 ms
Execution Time: 8.567 ms
Important

注意 cost(预估)和 actual(实际)的对比。如果两者差距很大,说明统计信息失真,这是执行计划劣化的最常见原因。上例中预估 5000 行、实际 4800 行,偏差在可接受范围内。MySQL 8.0.18+ 也支持 EXPLAIN ANALYZE,输出格式类似。

七、慢查询排查 SOP#

原理讲完,现在进入实战。当线上接口变慢,你需要一套标准流程来定位和解决问题。

7.1 性能优化分层#

性能优化不是随机尝试,而是有层次的系统性工程。越底层的优化收益越大、成本越低,越上层的优化越精细、越需要领域知识:

graph BT APP["应用层优化<br/>缓存策略 / 批量操作 / 异步化"] APP --> SQL["SQL 与索引优化<br/>慢查询 / 执行计划 / 索引设计"] SQL --> CONFIG["配置与参数调优<br/>连接池 / 缓冲池 / 日志策略"] CONFIG --> ARCH["架构层优化<br/>读写分离 / 分库分表"] style ARCH fill:#e3f2fd,stroke:#1565c0,stroke-width:2px style CONFIG fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px style SQL fill:#fff3e0,stroke:#e65100,stroke-width:2px style APP fill:#fce4ec,stroke:#c62828,stroke-width:2px
层级优化方向典型收益实施成本
架构层读写分离、分库分表10x~100x高(涉及架构变更)
配置层参数调优、连接池、缓冲池2x~5x低(改配置即可)
SQL 层慢查询优化、索引设计5x~50x中(需理解业务)
应用层缓存、批量、异步3x~20x中(需改代码)
Tip

优化顺序应该是自底向上:先确保架构合理,再调配置,再优化 SQL,最后在应用层做精细化。一个架构不合理的系统,SQL 再怎么优化也无法突破天花板。

7.2 排查标准流程#

慢查询排查有一条清晰的标准流程,从发现问题到定位根因再到修复验证:

flowchart TD A["开启慢查询日志<br/>slow_query_log / long_query_time"] --> B["聚合分析<br/>mysqldumpslow / pt-query-digest"] B --> C["EXPLAIN 看执行计划<br/>type / key / rows / Extra"] C --> D{"定位问题"} D -->|"type=ALL"| E["全表扫描:加索引"] D -->|"Using filesort"| F["排序问题:加排序索引"] D -->|"Using temporary"| G["临时表:改写 GROUP BY"] D -->|"rows 远大于返回行数"| H["索引选择性差:调整索引"] E --> I["验证:对比优化前后<br/>Query_time / rows_examined"] F --> I G --> I H --> I style A fill:#e3f2fd,stroke:#1565c0 style B fill:#e8f5e9,stroke:#2e7d32 style C fill:#fff3e0,stroke:#e65100 style I fill:#f3e5f5,stroke:#6a1b9a

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';

慢查询日志的一条输出示例:

22.123456Z
# User@Host: appuser[appuser] @ web-server [10.0.1.5]
# Query_time: 3.521400 Lock_time: 0.000120 Rows_sent: 1 Rows_examined: 2847293
SELECT * 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 10
mysqldumpslow -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.log

PostgreSQL 则用 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_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

7.5 第三步:EXPLAIN 定位#

拿到慢查询指纹后,对具体 SQL 执行 EXPLAIN,看 typekeyrowsExtra 四列定位问题。常见问题模式:

现象根因修复方向
type=ALL,key=NULL无索引或索引未生效加索引,检查隐式转换等失效场景
type=ALL,key 有值索引存在但优化器没用检查选择率,或用 FORCE INDEX
Using filesortORDER BY 列无索引加 (过滤列, 排序列) 联合索引
Using temporaryGROUP BY/DISTINCT 产生临时表调整 GROUP BY 列顺序或加覆盖索引
rows 远大于返回行数索引选择性差调整索引列顺序,高选择性列在前

7.6 第四步:修复与验证#

定位问题后,加索引或改写 SQL,然后用 EXPLAIN 和实际执行时间对比验证。

案例 1:缺少索引导致全表扫描

-- 问题:扫描 280 万行,返回 10 行
SELECT * FROM orders
WHERE 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 s
JOIN large_table l ON l.key = s.key;
-- Hash Join, build 侧 small_table
Note

索引失效的原因远不止隐式类型转换。在索引原理与失效分析中详细分析了函数调用、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.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending';

7.8 计划缓存与 Hint#

当优化器选错计划时,可以通过 Hint 强制指定执行路径:

-- MySQL:USE INDEX / FORCE INDEX
SELECT * 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_id
WHERE 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 扩展。

Warning

绑定执行计划是双刃剑,它绕过了优化器,数据分布变化后可能适得其反。只在确认优化器反复选错计划时才使用,并定期审查绑定的计划是否仍然最优。

数据库会缓存执行计划,避免重复优化。但参数化查询的计划缓存有一个经典陷阱,参数嗅探(Parameter Sniffing):

-- 第一次执行:city = '北京' 返回 50 万行,优化器选择 Seq Scan
PREPARE 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。如果每个请求都新建连接,高并发下连接建立本身就会成为瓶颈。

sequenceDiagram participant App as 应用程序 participant Pool as 连接池 participant DB as 数据库 Note over App,DB: 无连接池:每次请求新建连接 App->>DB: TCP 连接(10-50ms) App->>DB: 认证与会话初始化 App->>DB: 执行 SQL DB-->>App: 返回结果 App->>DB: 关闭连接 Note over App,DB: 有连接池:复用已有连接 App->>Pool: 获取连接(小于 1ms) Pool-->>App: 返回空闲连接 App->>DB: 执行 SQL(复用连接) DB-->>App: 返回结果 App->>Pool: 归还连接
维度无连接池有连接池
连接建立开销每次请求 10~50ms首次建立,后续小于 1ms
并发连接数不可控,可能打满可控,由池大小限制
连接生命周期短连接,频繁创建/销毁长连接,复用
数据库压力高(频繁认证)低(连接复用)

8.2 HikariCP 配置#

HikariCP 是 Java 生态中性能最高的连接池,Spring Boot 2.x+ 默认使用:

application.yml
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。

Warning

上述公式是起点而非终点。实际连接数还需要根据连接池监控数据(活跃连接比、等待线程数)动态调整。如果活跃连接长期接近池大小上限,说明池太小;如果大部分连接空闲,说明池太大。

九、缓存策略#

缓存是应用层性能优化的核心手段。当数据库本身已经优化到极限,缓存可以把热点数据的读取从毫秒级降到微秒级。

9.1 缓存模式#

graph TB subgraph CacheAside["Cache-Aside 旁路缓存"] CA_R["读请求"] --> CA_C{缓存命中} CA_C -->|是| CA_RET["返回缓存数据"] CA_C -->|否| CA_DB["查询数据库"] CA_DB --> CA_WRITE["写入缓存"] CA_WRITE --> CA_RET2["返回数据"] end subgraph WriteThrough["Write-Through 写穿透"] WT_W["写请求"] --> WT_C["更新缓存"] WT_C --> WT_DB["缓存同步写数据库"] end subgraph WriteBehind["Write-Behind 写回"] WB_W["写请求"] --> WB_C["更新缓存"] WB_C --> WB_ASYNC["异步批量写数据库"] end style CacheAside fill:#e3f2fd,stroke:#1565c0 style WriteThrough fill:#e8f5e9,stroke:#2e7d32 style WriteBehind fill:#fff3e0,stroke:#e65100
模式读路径写路径一致性适用场景
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)
# 启动时加载所有合法 key
for 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 TTLCache
local_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 = 10000
innodb_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 = 500
thread_cache_size = 100
# ===== 排序与 Join 缓冲 =====
# 每个线程的排序缓冲区(按连接分配,注意总内存)
sort_buffer_size = 4M
# 每个 Join 的缓冲区(无索引 Join 时使用)
join_buffer_size = 4M
# ===== 慢查询日志 =====
slow_query_log = 1
long_query_time = 1
log_queries_not_using_indexes = 1

MySQL 关键参数速查表:

参数默认值推荐值影响
innodb_buffer_pool_size128M物理内存 60%~80%最重要参数,直接影响缓存命中率
innodb_io_capacity200SSD: 10000+后台刷新速度,影响脏页刷盘
sync_binlog11(安全)/ 100(性能)binlog 持久性 vs 性能
innodb_flush_log_at_trx_commit11(安全)/ 2(折中)redo log 持久性 vs 性能
innodb_flush_neighbors1SSD: 0 / HDD: 1顺序写优化,SSD 无需
sort_buffer_size256K4M排序内存,过小触发磁盘排序
join_buffer_size256K4M无索引 Join 内存,过小分批扫描
Note

innodb_flush_log_at_trx_commit = 2sync_binlog = 100 可以显著提升写入性能,但在操作系统崩溃时可能丢失 1 秒数据。在InnoDB 架构与实现中详细分析了 InnoDB 的 Doublewrite 和 Redo Log 机制,事务原理与隔离级别中讨论了持久性权衡,理解这些机制有助于做出正确的决策。

sort_buffer_sizejoin_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 = on
synchronous_commit = on # on(安全)/ off(性能)
wal_buffers = 64MB
# ===== 检查点与自动清理 =====
max_wal_size = 4GB # WAL 最大大小
autovacuum = on
autovacuum_max_workers = 4

PostgreSQL 关键参数速查表:

参数默认值推荐值影响
shared_buffers128MB物理内存 25%数据页缓存,直接影响 I/O
effective_cache_size4GB物理内存 50%~75%影响规划器决策(不实际分配)
work_mem4MB32~256MB排序/哈希内存,影响磁盘排序
maintenance_work_mem64MB1GB+VACUUM/CREATE INDEX 速度
max_wal_size1GB2~8GBWAL 回收阈值,影响检查点频率

10.3 Linux 内核参数#

数据库性能不仅取决于数据库配置,还受操作系统参数影响:

/etc/sysctl.d/99-database.conf
# ===== 虚拟内存 =====
# 降低 swappiness,减少交换(数据库推荐 1~10)
vm.swappiness = 1
# 脏页刷新策略
vm.dirty_background_ratio = 5 # 后台刷新阈值(%)
vm.dirty_ratio = 10 # 强制刷新阈值(%)
# ===== 网络优化 =====
# TCP 连接队列
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
# TCP 优化
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_tw_reuse = 1
# ===== 文件描述符 =====
fs.file-max = 1000000
# 应用生效
sysctl -p /etc/sysctl.d/99-database.conf

MySQL 与 PostgreSQL 参数调优对比:

调优维度MySQLPostgreSQL
数据缓存innodb_buffer_pool_size(独占)shared_buffers(共享内存)
规划器提示无直接等价effective_cache_size
排序内存sort_buffer_size(按连接)work_mem(按操作)
WAL 策略innodb_flush_log_at_trx_commitsynchronous_commit + fsync
后台清理InnoDB 自管理autovacuum 系列参数
I/O 能力innodb_io_capacityeffective_io_concurrency

十一、监控体系#

没有监控的优化是盲目的。建立完善的监控体系,才能量化问题、验证优化效果、及时发现问题。

11.1 指标分类#

数据库监控指标遵循 USE 方法(Utilization / Saturation / Errors)和 RED 方法(Rate / Errors / Duration):

graph LR subgraph USE["USE 方法 资源视角"] U["利用率 Utilization<br/>CPU/内存/磁盘使用率"] S["饱和度 Saturation<br/>连接等待/IO 队列/锁等待"] E1["错误 Errors<br/>连接失败/查询错误/复制中断"] end subgraph RED["RED 方法 请求视角"] R["速率 Rate<br/>QPS/TPS/连接数"] E2["错误 Errors<br/>慢查询/超时/死锁"] D["延迟 Duration<br/>P50/P95/P99 延迟"] end style USE fill:#e3f2fd,stroke:#1565c0 style RED fill:#fce4ec,stroke:#c62828

核心监控指标分类:

类别指标告警阈值建议
延迟查询 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 — 添加 Exporter
services:
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 }} 秒"
Tip

告警不是越多越好。过多的告警会导致告警疲劳,工程师开始忽略所有告警。每个告警都必须可操作(收到后知道该做什么),优先级明确(Warning vs Critical),避免重复告警。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

MySQL 查询优化与慢排查
https://blog.souloss.cn/posts/middleware/db/mysql-query-optimization/
作者
Souloss
发布于
2024-06-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时