高性能MYSQL(四):查询性能优化

查询优化、索引优化、库表结构优化需要齐头并进,一个不落。

查询性能优化的核心在于缩短响应时间 。要理解这一点,首先需将查询视为由多个按序或并行执行的子任务 组成的整体过程。优化查询的本质,就是通过消除某些子任务、减少其执行次数,或提升其执行效率来降低总耗时。

一个查询完整的生命周期通常遵循以下顺序:

  1. 连接与通信:客户端与服务器建立连接、发送查询语句、接收返回结果。
  2. 语法解析与预处理:服务器进行SQL语法解析、对象权限验证。
  3. 查询优化与执行计划生成 :优化器分析SQL,考虑表结构、索引、统计信息等,生成一个它认为成本最低的执行计划
  4. 执行 :根据执行计划,调用存储引擎接口,进行数据的检索、排序、分组、聚合等操作,并将最终结果返回给客户端。

其中,"执行"阶段是整个生命周期中最关键、也最耗时的部分。它涉及大量对存储引擎的调用,以及在服务器层进行的数据处理。

时间主要消耗在何处?

查询时间可能大量耗费在以下环节:

  • 网络传输:客户端与服务器间的数据传输,尤其是在结果集很大或网络延迟高时。
  • CPU计算 :执行计划中的排序(ORDER BY)、分组(GROUP BY)、连接(JOIN)计算、表达式求值等。
  • 生成执行计划:优化器分析SQL、选择索引、确定连接顺序等计算过程。
  • 锁等待:查询所需访问的数据行或表可能被其他事务锁定,导致查询必须等待。
  • 存储引擎的物理I/O操作
    • 内存操作:在缓冲池(Buffer Pool)中查找数据。
    • CPU操作:解码数据页、比较索引键值等。
    • 磁盘I/O操作:当所需数据不在内存中时,需从磁盘读取,这是最耗时的操作之一。
  • 上下文切换与系统调用:数据库服务器与操作系统间的频繁交互。

一、慢查询基础:优化数据访问

一条查询,如果性能很差,最常见的原因是访问的数据太多。

某些查询可能不可避免地需要筛选大量数据,但这并不常见。大部分性能低下的查询都可以通过减少访问的数据量的方式进行优化。

对于低效的查询,我们发现通过下面两个步骤来分析总是很有效:

1.确认应用程序是否在检索大量且不必要的数据。这通常意味着访问了太多的行,但有时候也可能是访问了太多的列。

2.确认MySQL服务器层是否在分析大量不需要的数据行。

(一)是否向数据库请求了不需要的数据?

1.查询了多余的数据

(1)查询过多条数

例如,在新闻网站中取出100条记录,但是只是在页面上显示前面10条​。

他们认为MySQL会执行查询,并只返回他们需要的10条数据,然后停止查询。

实际情况是,MySQL会查询出全部的结果集,客户端的应用程序会接收全部的结果集数据,然后抛弃其中大部分数据。

最简单有效的解决方法就是在这样的查询后面加上LIMIT子句。

sql 复制代码
select * from post limit 10;
(2)查询了过多的列

查询使用了 SELECT *,但应用程序只用到其中部分列。这会导致传输更多数据,并可能迫使查询去访问实际不需要的列(例如,当有些列不属于索引覆盖时)。

2.多表联接时返回全部列

如果你想查询所有在电影Academy Dinosaur中出现的演员,千万不要按下面的写法编写查询:

sql 复制代码
SELECT * FROM sakila.actor 
INNER JOIN sakila.film_actor USING(actor_id) 
INNER JOIN sakila.film USING(film id)
WHERE sakila.film.title ='Academy Dinosaur';

这将返回这三个表的全部数据列。正确的方式应该是像下面这样只取需要的列:

sql 复制代码
SELECT sakila.actor.* FROM sakila.actor 
INNER JOIN sakila.film_actor USING(actor_id) 
INNER JOIN sakila.film USING(film id)
WHERE sakila.film.title ='Academy Dinosaur';

每次看到SELECT * 的时候都需要用怀疑的眼光审视,是不是真的需要返回全部的列,很可能不是必需的。取出全部列,会让优化器无法完成索引覆盖扫描这类优化,还会为服务器带来额外的I/O、内存和CPU的消耗。

因此,一些DBA严格禁止SELECT *的写法,这样做有时候还能避免某些列被修改而带来的问题。

当然,查询返回超过需要的数据也不总是坏事。在我们研究过的许多案例中,人们会告诉我们,这种有点浪费数据库资源的方式可以简化开发,因为能提高相同代码片段的复用性,如果清楚这样做对性能的影响,那么这种做法也是值得考虑的。

如果应用程序使用了某种缓存机制,或者有其他考虑,获取超过需要的数据也可能有其好处,但不要忘记这样做的代价是什么。获取并缓存所有的列的查询,相比多个独立的只获取部分列的查询可能更有好处。

3.重复查询相同的数据

如果你不够小心,很容易出现这样的错误------不断地重复执行相同的查询,然后每次都返回完全相同的数据。

例如,在用户评论的地方需要查询用户头像的URL,那么在用户多次评论的时候,可能就会反复查询这个数据。

比较好的方案是,当初次查询的时候将这个数据缓存起来,需要的时候从缓存中取出,这样性能显然会更好。

或者在循环或应用程序的迭代中,重复执行相同的查询。例如,在for循环中根据ID逐条获取用户信息,而不是通过一个 IN (...) 查询批量获取。

总结而言,优化查询就是一场"时间削减战" 。我们需要借助数据库提供的工具(如 EXPLAIN、慢查询日志、性能监控 Profile)深入查询生命周期的内部,准确识别出时间主要被消耗在哪些子任务上,并针对性地进行优化。理解上述原理,是进行一切有效查询优化的基础。

(二)MySQL是否在扫描额外的记录

在确定查询只返回需要的数据以后,接下来应该看看查询为了返回结果是否扫描了过多的数据。

对于MySQL,最简单的衡量查询开销的三个指标如下:

  • 响应时间
  • 扫描的行数
  • 返回的行数

没有哪个指标能够完美地衡量查询的开销,但它们大致反映了MySQL在内部执行查询时需要访问多少数据,并可以大概推算出查询运行的时间。

这三个指标都会被记录到MySQL的慢日志中,所以检查慢日志记录是找出扫描行数过多的查询的好办法。

1.响应时间

要记住,响应时间只是一个表面上的值。这样说可能看起来和前面关于响应时间的说法有矛盾,其实并不矛盾,响应时间仍然是最重要的指标,这有一点复杂,后面细细道来。

(1)响应时间 = 服务时间 + 排队时间

响应时间是两部分之和:服务时间和排队时间。这是最根本的公式。

  • 服务时间:数据库引擎"纯干活"的时间,是指数据库处理这个查询真正花了多长时间。。可以想象成厨师专心炒一道菜的时间。
  • 排队时间:数据库引擎在"等待"的时间,是指服务器因为等待某些资源而没有真正执行查询的时间------可能是等I/O操作完成,也可能是等待行锁,等等。。可以想象成厨师在等食材送到、等灶台空出来、等前一道菜出锅的时间。

例子:一个简单的查询 SELECT * FROM orders WHERE user_id = 123;

  • 理想情况(几乎全是服务时间)user_id字段有完美的索引,数据都在内存中。数据库直接通过索引找到数据,返回结果。耗时 5毫秒 。这5毫秒主要是服务时间(CPU计算、内存访问)。
  • 糟糕情况(排队时间占大头)
    • 情况A(I/O等待)user_id没有索引,需要做全表扫描,而表数据很大,大部分在磁盘上。数据库发出大量I/O请求去读取磁盘数据。虽然每个I/O服务时间很短(比如0.1毫秒),但大部分时间花在了排队等待 磁盘磁头转动和寻道上。最终耗时 500毫秒 ,其中480毫秒是排队时间(I/O等待)
    • 情况B(锁等待) :同时,另一个事务正在对orders表进行大批量更新并持有锁。你的查询需要排队等待 那个锁释放。等待了 2秒 后,锁释放,你的查询只用了10毫秒就完成了。总响应时间 2.01秒 ,其中2秒是排队时间(锁等待)

同样是"响应时间"这个指标,在情况A和B中,问题的根源(磁盘慢、无索引 vs. 并发锁竞争)完全不同。只看"2.01秒"这个表面值,你无法判断问题出在哪。

所以在不同类型的应用压力下,响应时间并没有一致的规律或者公式。诸如存储引擎的锁(表锁、行锁)​、高并发资源竞争、硬件响应等诸多因素都会影响响应时间。

(2)响应时间难以细分

遗憾的是,我们无法把响应时间细分到上面这些部分,除非有什么办法能够逐个测量这些消耗,这很难做到。

因为数据库内部像一个极其复杂的黑箱。它不会在每条查询结束后都出具一份详细报告:"CPU花了X毫秒,等磁盘Y毫秒,等锁Z毫秒"。

虽然现代数据库有一些工具(如PERFORMANCE_SCHEMAsys视图、pg_stat_statements等)可以提供部分线索,但要精确、无开销地测量每个微小操作的耗时,确实非常困难。

sql 复制代码
-- 查看不同类型的等待事件时间
SELECT event_name,
       COUNT_STAR as total_events,
       SUM_TIMER_WAIT/1000000000 as total_wait_seconds,
       AVG_TIMER_WAIT/1000000000 as avg_wait_seconds
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE event_name NOT LIKE 'idle'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
-- 查看当前所有连接的状态和等待
SELECT 
    psth.THREAD_ID,
    psth.EVENT_NAME,
    psth.SOURCE,
    psth.TIMER_WAIT/1000000000 as wait_seconds,
    psth.OBJECT_SCHEMA,
    psth.OBJECT_NAME,
    psth.INDEX_NAME,
    psth.OPERATION,
    t.PROCESSLIST_INFO as current_sql
FROM performance_schema.events_waits_current psth
JOIN performance_schema.threads t 
    ON psth.THREAD_ID = t.THREAD_ID
WHERE t.PROCESSLIST_ID IS NOT NULL
ORDER BY psth.TIMER_WAIT DESC;

-- 查看哪些文件有最多的I/O等待
SELECT * 
FROM sys.io_global_by_wait_by_latency
LIMIT 10;
-- 查看哪些表有最多的I/O等待
SELECT 
    object_schema,
    object_name,
    count_read,
    count_write,
    sum_timer_wait/1000000000 as total_io_wait_seconds
FROM performance_schema.table_io_waits_summary_by_table
ORDER BY sum_timer_wait DESC
LIMIT 10;
(3)"既是原因也是结果"

所以,响应时间既可能是一个问题的结果也可能是一个问题的原因,不同案例情况不同。

在实践中很常见:

  • 作为问题的结果 :当你的查询因为没有索引 (原因)而进行全表扫描时,导致了长的响应时间(结果)。这里响应时间是表面现象。
  • 作为问题的原因 :当大量慢查询 (每个响应时间都很长)并发执行时,它们会堆积起来,占满数据库连接、耗尽CPU和I/O资源。这会导致后续本该很快的查询也开始排队等待资源 ,从而也变慢了。此时,最初的"长响应时间"成为了导致系统雪崩的原因
(4)如何判断响应时间是否合理?------"快速上限估计"

当你看到一个查询的响应时间时,首先需要问问自己,这个响应时间是否是一个合理的值。

实际上可以使用"快速上限估计"法来估算查询的响应时间,这是高级DBA和开发者使用的一种思维模型。你不是去精确测量,而是基于对系统和查询的理解进行"估算"。

步骤举例:

查询:SELECT * FROM products WHERE category = 'electronics' AND price > 1000 ORDER BY create_date DESC LIMIT 20;

  1. 分析查询与索引 :假设你在(category, price)上有一个联合索引。
  2. 估算访问路径 :优化器会使用这个索引找到所有category='electronics' AND price>1000的行(假设有1000行)。但这些行在索引中是按(category, price)排序的,而你要按create_date排序。
  3. 估算I/O
    • 随机I/O :数据库可能需要回表(通过主键)去读取这1000行数据中的create_date字段(假设有20次随机I/O因为数据不在内存)。
    • 顺序I/O :然后在内存中对这1000行根据create_date排序,最后取前20行。
  4. 套用硬件能力
    • 你的硬盘随机I/O能力大约是 100次/秒,即每次约10毫秒。
    • 那么20次随机I/O大概需要 200毫秒
    • 加上网络传输、CPU排序等时间(假设50毫秒)。
  5. 得出估算值 :你估算出这个查询的"合理"响应时间大约在 250毫秒 左右。

现在,当你看到实际响应时间是 2秒时,你立刻就能判断:"这太不正常了,比我的估算值高了一个数量级!一定发生了我没有预料到的事情,比如锁等待、内存不足导致大量随机I/O、或者有触发器在执行额外工作。" 这样,你的调查就有了明确的起点。

2.扫描的行数和返回的行数

分析查询时,查看该查询扫描的行数是非常有帮助的。这在一定程度上能够说明该查询找到需要的数据的效率高不高。

对于找出那些"糟糕"的查询,这个指标可能还不够完美,因为并不是所有行的访问代价都是相同的。较短的行的访问速度更快,内存中的行比磁盘中的行的访问速度要快得多。

理想情况下扫描的行数和返回的行数应该是相同的,但实际中这种"美事"并不多。

例如,在做一个联接查询时,服务器必须要扫描多行才能生成结果集中的一行。扫描的行数与返回的行数的比率通常很低,一般在1:1到10:1之间,不过有时候这个值也可能非常非常大。

sql 复制代码
SELECT * FROM users WHERE email LIKE '%gmail.com%';
-- 假设email字段有索引,但LIKE以通配符开头无法使用索引
-- 扫描行数:1,000,000(全表扫描)
-- 返回行数:300,000(假设30%用户使用gmail)
-- 比率:3.33:1(扫描3.33行才返回1行)

3.扫描的行数和访问类型

在评估查询开销的时候,需要考虑从表中找到某一行数据的成本。

MySQL有好几种访问方式可以查找并返回一行结果。有些访问方式可能需要扫描很多行才能返回一行结果,也有些访问方式可能无须扫描就能返回结果。EXPLAIN语句中的type列反映了访问类型。

访问类型指MySQL从表中查找数据行的具体方法。它决定了查询需要扫描多少数据才能找到结果,是影响查询性能最关键的因素之一。

通过EXPLAIN语句可以查看查询计划,其中的type列显示了使用的访问类型:

访问类型 扫描行数 速度 典型场景
ALL 全表行数 最慢 无索引查询
index 索引全行数 较慢 覆盖索引扫描
range 范围行数 中等 范围查询
ref 匹配行数 非唯一索引等值查询
eq_ref 0-1行 很快 主键/唯一索引连接
const 1行 最快 主键/唯一索引等值查询

一般地,MySQL能够使用如下三种方式应用WHERE条件,从好到坏依次为:

  • 在索引中使用WHERE条件来过滤不匹配的记录。这是在存储引擎层完成的。
  • 使用索引覆盖扫描(在Extra列中出现了Using index)来返回记录,直接从索引中过滤不需要的记录并返回命中的结果。这是在MySQL服务器层完成的,但无须再回表查询记录。
  • 从数据表中返回数据,然后过滤不满足条件的记录(在Extra列中出现Using where)。这在MySQL服务器层完成,MySQL需要先从数据表中读出记录然后过滤。

如果发现查询需要扫描大量的数据但只返回少数行,那么通常可以尝试下面的技巧去优化它:

  • 使用索引覆盖扫描,把所有需要用的列都放到索引中,这样存储引擎无须回表获取对应行就可以返回结果了。
  • 改变库表结构。例如,使用单独的汇总表。
  • 重写这个复杂的查询,让MySQL优化器能够以更优化的方式执行这个查询。

三、重构查询的方式

在优化有问题的查询时,目标应该是找到获得实际需要的结果的替代方法------但这并不一定意味着从MySQL返回完全相同的结果集。

有时候,可以将查询转换为返回相同结果的等价形式,以获得更好的性能。

但是,如果可以获得更好的效率,还应该考虑重写查询以检索不同的结果。通过修改应用代码和查询,最终达到一样的目的。

(一)一个复杂查询还是多个简单查询

在数据库查询设计时,一个重要决策点是是否将一个复杂查询拆分为多个简单查询。传统数据库优化理念强调"让数据库做尽可能多的工作",但这在现代MySQL环境中需要重新思考。

(1)传统观点:数据库层完成所有工作

理论基础

  1. 网络通信成本高:早期网络带宽有限,每次查询都有显著的网络延迟
  2. 连接建立开销大:创建和关闭数据库连接需要大量资源
  3. 减少往返次数:尽可能在一次查询中完成所有工作

典型做法

sql 复制代码
-- 传统的复杂查询:一次完成所有操作
SELECT 
    u.id, u.name, 
    COUNT(o.id) as order_count,
    SUM(o.amount) as total_amount,
    GROUP_CONCAT(p.name) as product_names
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN products p ON oi.product_id = p.id
WHERE u.signup_date >= '2023-01-01'
GROUP BY u.id
HAVING COUNT(o.id) > 5
ORDER BY total_amount DESC
LIMIT 100;
(2)现代现实:小查询不再是问题

技术变化

  1. MySQL连接轻量化:MySQL的连接和断开成本很低
  2. 网络速度飞跃:千兆网卡成为标配,延迟大幅降低
  3. 查询解析优化:简单查询的解析和优化成本极低
  4. 性能基准
    • MySQL可处理 每秒超过10万次 简单查询
    • 千兆网卡轻松支持 每秒2000+查询
    • 内存扫描速度:每秒数百万行

关键洞察

"MySQL响应数据给客户端的速度,远慢于在内部扫描内存数据的速度。这意味着有时多个小查询比一个大查询更快。"

(3)为什么考虑拆分查询?

可能有如下理由:

  1. 减少锁竞争
  2. 避免复杂的执行计划
  3. 利用查询缓存
  4. 减少内存使用和临时表
(4)何时拆分 vs 何时合并

应该拆分的情况:

  1. 查询涉及多张大表JOIN
  2. 包含多个子查询或复杂聚合
  3. 需要长时间持有锁
  4. 查询结果各部分缓存策略不同
  5. 应用层需要渐进式加载

应该合并的情况:

  1. 查询结果强相关,需要原子性
  2. 拆分后需要多次重复相同过滤条件
  3. 应用层处理逻辑过于复杂
  4. 网络延迟极高的环境(如跨数据中心)

决策框架:

复制代码
是否需要事务一致性?
├── 是 → 考虑合并或使用事务包装多个查询
└── 否 → 查询是否过于复杂?
    ├── 是 → 是否可以并行执行?
    │   ├── 是 → 拆分并并行执行
    │   └── 否 → 评估拆分后的总成本
    └── 否 → 保持单查询

(二)切分查询

有时候对于一个大查询,我们需要"分而治之"​,将大查询切分成小查询,每个查询的功能完全一样,只完成一小部分,每次只返回一小部分查询结果。

例如,如果使用一个大的DELETE语句一次性删除大量数据(例如几个月前的所有记录),会导致以下问题:

  • 锁住大量行,影响其他查询。
  • 占用大量的事务日志空间,可能导致日志文件增长过快。
  • 消耗大量系统资源,如CPU、内存和磁盘I/O。
  • 阻塞其他小而重要的查询,降低数据库的整体性能。

例如,我们需要每个月运行一次下面的查询:

sql 复制代码
DELETE FROM messages
WHERE Created < DATE_SUB(NOW(),INTERVAL 3 MONTH);

为了缓解这些问题,可以将大DELETE操作拆分成多个较小的操作,每次只删除一定数量的行(如10,000行)。

sql 复制代码
rows affected =0
do {
rows affected = do query(
"DELETE FROM messageS WHERE Created < DATE_SUB(NOW(),INTERVAL 3 MONTH)
LIMIT 10000")
} while rows affected>0

这种方法能够:

  • 减少锁定的时间,从而减少对其他查询的影响。
  • 控制事务日志的增长,避免其过大。
  • 分散系统资源的使用,使得操作更加平滑,不会一次性耗尽资源。

一次删除一万行数据一般来说是一个比较高效而且对服务器影响最小的做法(如果是事务型引擎,很多时候小事务能够更高效)​。

同时,需要注意的是,如果每次删除数据后,都暂停一会儿再做下一次删除,也可以将服务器上原本一次性的压力分散到一个很长的时间段中,可以大大降低对服务器的影响,还可以大大减少删除时锁的持有时间。

(三)分解联接查询

很多高性能的应用都会对联接查询进行分解。简单地说,可以对每一个表进行一次单表查询,然后将结果在应用程序中进行联接。

例如,下面这个查询:

sql 复制代码
SELECT * FROM tag 
JOIN tag_post ON tag_post.tag_id=tag.id
JOIN post ON tag_post.post_id=post.id
WHERE tag.tag='mysql';

可以分解成下面这些查询来代替:

sql 复制代码
SELECT * FROM tag WHERE tag='mysql';
SELECT * FROM tag_post WHERE tag_id=1234;
SELECT * FROM post WHERE post.id in(123,456,567,9098,8904);

到底为什么要这样做?乍一看,这样做并没有什么好处,原本一条查询,这里却变成多条查询,返回的结果又是一模一样的。事实上,用分解联接查询的方式重构查询有如下优势:

(1)提高缓存效率

缓存粒度更细

  • 单表查询的结果更容易缓存:对于每个单独的表,可以更容易地设置缓存策略。例如,tag表的数据可能变化不频繁,因此可以长时间缓存;而post表的数据可能会更频繁更新,但仍然可以在较短时间内缓存。
  • 避免无效化整个缓存:当一个表的数据发生变化时,只需要无效化或更新该表对应的缓存部分,而不必清空所有涉及该表的联接查询结果。
(2) 减少锁竞争

降低锁定范围

  • 单表查询锁定更少的数据行:相比于复杂的联接查询,单表查询通常会锁定更少的数据行,从而减少了并发事务之间的锁竞争。
  • 缩短锁定时间:由于每次查询处理的数据量较小,锁定的时间也会相应缩短,降低了阻塞其他操作的可能性。
(3) 更易于数据库拆分和扩展

支持水平和垂直拆分

  • 水平拆分(Sharding):如果数据量巨大,可以通过将不同表的数据分布到不同的服务器上来实现负载均衡。在这种情况下,单表查询使得跨服务器的数据访问更加简单和高效。
  • 垂直拆分(Vertical Partitioning):根据业务逻辑将相关性较低的字段分离到不同的表中,同样有利于提高查询效率和简化管理。
(4) 提升查询效率

优化索引利用

  • 使用IN()替代联接:在某些情况下,使用IN()语句可以让MySQL按照索引顺序检索数据,而不是随机访问,这可以显著提高查询速度,尤其是在处理大量数据时。
  • 减少冗余记录访问:在应用程序层做联接查询意味着只对必要的记录进行一次查询,而不需要重复访问相同的数据,从而减少了网络传输和内存消耗。
(5) 减少冗余数据访问

避免重复数据传输

  • 一次性加载所需数据:在应用层做联接查询时,可以根据需要精确地获取每条记录,避免了数据库层面联接查询可能导致的冗余数据传输。
  • 节省带宽和资源:通过减少不必要的数据传输,可以有效节省网络带宽和系统资源,特别是在分布式环境中这一点尤为重要。

在有些场景下,在应用程序中执行联接操作会更加有效。比如,当可以缓存和重用之前查询结果中的数据时、当在多台服务器上分发数据时、当能够使用IN()列表替代联接查询大型表时、当一次联接查询中多次引用同一张表时。

四、查询执行的基础

当希望MySQL能够以更高的性能运行查询时,最好的办法就是弄清楚MySQL是如何优化和执行查询的。一旦理解了这一点,很多查询优化工作实际上就是遵循一些原则让优化器能够按照预想的合理的方式运行。

可以看到,当向MySQL发送一个请求的时候,MySQL到底做了些什么:

  • 1.客户端给服务器发送一条SQL查询语句。
  • 2.服务器端进行SQL语句解析、预处理,再由优化器生成对应的执行计划。
  • 3.MySQL根据优化器生成的执行计划,调用存储引擎的API来执行查询。
  • 4.将结果返回给客户端。

我们会看到,在每一个阶段,查询处于何种状态。查询优化器是其中特别复杂也特别难理解的部分。

(一)MySQL的客户端/服务器通信协议

一般来说,不需要去理解MySQL通信协议的内部实现细节,只需要大致理解通信协议是如何工作的。MySQL的客户端和服务器之间的通信协议是"半双工"的,这意味着,在任何时刻,要么是由服务器向客户端发送数据,要么是由客户端向服务器发送数据,这两个动作不能同时发生。所以,我们无法也无须将一个消息切成小块来独立发送。

这种协议让MySQL通信变得简单快速,但是也从很多地方限制了MySQL。一个明显的限制是,这意味着没法进行流量控制。一旦一端开始发送消息,另一端要接收完整个消息才能响应它。这就像来回抛球的游戏:在任何时刻,只有一个人能控制球,而且只有控制球的人才能将球抛回去(发送消息)​。

客户端用一个单独的数据包将查询传给服务器。这也是为什么当查询的语句很长的时候,参数max_allowed_packet就特别重要了。[插图]一旦客户端发送了请求,它能做的事情就只是等待结果了。

然而,一般的服务器响应给用户的数据通常很多,由多个数据包组成。当服务器开始响应客户端请求时,客户端必须完整地接收整个返回结果,而不能简单地只取前面几条结果,然后让服务器停止发送数据。在这种情况下,客户端若接收完整的结果,然后取前面几条需要的结果,或者接收完几条结果后就"粗暴"地断开连接,都不是好主意。这也是在必要的时候一定要在查询中加上LIMIT限制的原因。

换一种方式解释这种行为:当客户端从服务器取数据时,看起来是一个拉数据的过程,但实际上是MySQL在向客户端推送数据的过程。客户端不断地接收从服务器推送的数据,客户端也没法让服务器停下来。客户端像是"从消防水管喝水"​(这是一个术语)​。

多数连接MySQL的库函数都可以获得全部结果集并将结果缓存到内存里,还可以逐行获取需要的数据。默认一般是获得全部结果集并将它们缓存到内存中。MySQL通常需要等所有的数据都已经发送给客户端才能释放这条查询所占用的资源,所以接收全部结果并缓存通常可以减少服务器的压力,让查询能够早点结束、早点释放相应的资源。

当使用多数连接MySQL的库函数从MySQL获取数据时,其结果看起来都像是从MySQL服务器获取数据,而实际上都是从这个库函数的缓存获取数据。多数情况下这没什么问题,但是在需要返回一个很大的结果集的时候,这样做并不好,因为库函数会花很多时间和内存来存储所有的结果集。如果能够尽早开始处理这些结果集,就能大大减少内存的消耗,在这种情况下可以不使用缓存来记录结果而是直接处理。这样做的缺点是,对于服务器来说,需要查询完成后才能释放资源,所以在和客户端交互的整个过程中,服务器的资源都是被这个查询所占用的。

我们看看当使用 PHP 的时候是什么情况。下面是我们连接 MySQL 的通常写法:

php 复制代码
<?php
$link = mysql_connect('localhost', 'user', 'p4ssword');
$result = mysql_query('SELECT * FROM HUGE_TABLE', $link);

while ( $row = mysql_fetch_array($result) ) {
    // Do something with result
}
?>

这段代码看起来像是只有当你需要的时候,才通过循环从服务器端取出数据。而实际上,在上面的代码中,在调用 mysql_query() 的时候,PHP 已经将整个结果集缓存到内存中了。while 循环只是从这个缓存中逐行取出数据。然而,如果使用下面的查询,用 mysql_unbuffered_query() 替代 mysql_query(),PHP 则不会缓存结果:

php 复制代码
<?php
$link = mysql_connect('localhost', 'user', 'p4ssword');
$result = mysql_unbuffered_query('SELECT * FROM HUGE_TABLE', $link);
while ( $row = mysql_fetch_array($result) ) {
    // Do something with result
}
?>

(二)查询状态

对于一个MySQL连接,或者一个线程,任何时刻都有一个状态,该状态表示了MySQL当前正在做什么。

有很多种方式能查看当前的状态,最简单的是使用SHOW FULL PROCESSLIST命令(该命令返回结果中的Command列,其就表示当前的状态)​。

sql 复制代码
show FULL PROCESSLIST;

在一个查询的生命周期中,状态会变化很多次。

MySQL官方手册中对这些状态值的含义有最权威的解释,下面将这些状态列出来,并做一个简单的解释。

Sleep

线程正在等待客户端发送新的请求。

Query

线程正在执行查询或者正在将结果发送给客户端。

Locked

在MySQL服务器层,该线程正在等待表锁。在存储引擎级别实现的锁,例如InnoDB的行锁,并不会体现在线程状态中。

Analyzing and statistics

线程正在检查存储引擎的统计信息,并优化查询。

Copying to tmp table [on disk]

线程正在执行查询,并且将其结果集复制到一个临时表中,这种状态一般要么是在做GROUP BY操作,要么是在进行文件排序操作,或者是在进行UNION操作。如果这个状态后面还有"on disk"标记,那表示MySQL正在将一个内存临时表放到磁盘上。

Sorting result

线程正在对结果集进行排序。了解这些状态的基本含义非常有用,这可以让你很快地了解当前"谁正在持球"​。

在一个繁忙的服务器上,可能会看到大量的不正常的状态,例如,statistics正占用大量的时间。这通常表示,某个地方有异常了。

(三)查询优化处理

查询的生命周期的下一步是将一个SQL查询转换成一个执行计划,MySQL再依照这个执行计划和存储引擎进行交互。

这包括多个子阶段:解析SQL、预处理、优化SQL执行计划。这个过程中产生的任何错误(例如,语法错误)都可能终止查询。

这里不打算详细介绍MySQL的内部实现,而只是选择性地介绍其中几个独立的部分,在实际执行中,这几部分可能一起执行也可能单独执行。我们的目的是帮助大家理解MySQL如何执行查询,以便写出更优秀的查询。

1.语法解析器和预处理

首先,MySQL通过关键字将SQL语句进行解析,并生成一棵对应的"解析树"​。MySQL解析器将使用MySQL语法规则验证和解析查询。

例如,它将验证是否使用了错误的关键字,使用关键字的顺序是否正确,或者它还会验证引号是否能前后正确匹配。

然后,预处理器检查生成的解析树,以查找解析器无法解析的其他语义,例如,这里将检查数据表和数据列是否存在,还会解析名字和别名,看看它们是否有歧义。

下一步预处理器会验证权限。这通常很快,除非服务器上有非常多的权限配置。

2.查询优化器

现在解析树被认为是合法的了,并且由优化器将其转化成查询执行计划。

一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。

MySQL使用基于成本的优化器,它将尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。

最初,成本的最小单位是随机读取一个4KB数据页的成本,后来成本计算公式变得更加复杂,并且引入了一些"因子"来估算某些操作的代价,如执行一次WHERE条件比较的成本。

(1)查询当前会话的查询成本

可以通过查询当前会话的Last_query_cost的值来得知MySQL计算的当前查询的成本:

sql 复制代码
mysql> SELECT SQL_NO_CACHE COUNT(*) FROM sakila.film_actor;
+----------+
| count(*) |
+----------+
|    5462  |
+----------+

mysql> SHOW STATUS LIKE 'Last_query_cost';
+-----------------+-------------+
| Variable_name   | Value       |
+-----------------+-------------+
| Last_query_cost | 1040.599000 |
+-----------------+-------------+

这个结果表示,MySQL的优化器认为大概需要做1040个数据页的随机查找才能完成上面的查询。

这是根据一系列的统计信息计算得来的:每个表或者索引的页面个数、索引的基数(索引中不同值的数量)​、索引和数据行的长度、索引分布情况。优化器在评估成本的时候并不考虑任何层面的缓存带来的影响,它假设读取任何数据都需要一次磁盘I/O。

(2)优化器选择错误的原因

有很多种原因会导致MySQL优化器选择错误的执行计划,如下所示:

  • 统计信息不准确。MySQL服务器依赖存储引擎提供的统计信息来评估成本,但是有的存储引擎提供的信息是准确的,有的偏差可能非常大。例如,InnoDB因为其MVCC的架构,并不能维护一个数据表的行数的精确统计信息。

  • 成本指标并不完全等同于运行查询的实际成本,因此即使统计数据是准确的,查询的成本也可能超过或者低于MySQL估算的近似值。例如,有时候某个执行计划虽然需要读取更多的页面,但是它的成本却更低。因为如果这些页面都是顺序读或者这些页面都已经在内存中的话,那么它的访问成本将很低。MySQL并不知道哪些页面在内存中、哪些在磁盘中,所以查询在实际执行过程中到底需要多少次物理I/O是无法得知的。

  • MySQL的最优可能和你想的最优不一样。你可能希望执行时间尽可能短,但是MySQL只是基于其成本模型选择最优的执行计划,而有些时候这并不是最快的执行方式。所以,这里我们看到的根据执行成本来选择执行计划并不是完美的模型。

  • MySQL从不考虑其他并发执行的查询,这可能会影响到当前查询的速度。

  • MySQL也并不是任何时候都是基于成本的优化。它有时也会基于一些固定的规则,例如,如果存在全文搜索的MATCH()子句,则在存在FULLTEXT索引的时候就使用全文索引。即使有时候使用其他索引和WHERE条件可以远比这种方式要快,MySQL也仍然会使用对应的全文索引。

  • MySQL不会考虑不受其控制的操作的成本,例如,执行存储函数或者用户自定义函数的成本。

  • 后面我们还会看到,优化器有时候无法估算所有可能的执行计划,所以它可能错过实际上最优的执行计划。

MySQL的查询优化器是一个非常复杂的软件,它使用了很多优化策略来生成一个最优的执行计划。优化策略可以简单地分为两种,一种是静态优化,一种是动态优化。静态优化可以直接对解析树进行分析,并完成优化。

例如,优化器可以通过一些简单的代数变换将WHERE条件转换成另一种等价形式。静态优化不依赖于特别的数值,如WHERE条件中带入的一些常数等。静态优化在第一次完成后就一直有效,即使使用不同的参数重复执行查询也不会发生变化,可以认为这是一种"编译时优化"​。

然而,动态优化则和查询的上下文有关,也可能和很多其他因素有关,例如WHERE条件中的取值、索引中条目对应的数据行数等。这需要在每次查询的时候都重新评估,可以认为这是"运行时优化"​。

在执行绑定变量和存储过程的时候,动态优化和静态优化的区别非常重要。MySQL对查询的静态优化只需要做一次,但对查询的动态优化则在每次执行时都需要重新评估。有时候甚至在查询的执行过程中也会重新优化。

(3)能够优化的类型

下面是一些MySQL能够处理的优化类型

重新定义联接表的顺序

数据表的联接并不总是按照在查询中指定的顺序进行。决定联接的顺序是优化器很重要的一个功能。

将外联接转化成内联接

并不是所有的OUTER JOIN语句都必须以外联接的方式执行。诸多因素,例如WHERE条件、库表结构都可能会让外联接等价于一个内联接。MySQL能够识别这一点并重写查询,让其可以调整联接顺序。

使用代数等价变换规则

MySQL可以使用一些代数等价变换规则来简化并规范表达式。它可以合并和减少一些比较,还可以移除一些恒成立和一些恒不成立的判断。例如,(5=5 AND a>5)将被改写为a>5。类似地,如果有(a<b AND b=c)AND a=5则会改写为b>5 AND b=c AND a=5。这些规则对于编写条件语句很有用。

优化COUNT()、MIN()和MAX()

索引和列是否可为空通常可以帮助MySQL优化这类表达式。例如,要找到某一列的最小值,只需要查询对应B-tree索引最左端的记录,MySQL可以直接获取索引的第一行记录。

在优化器生成执行计划的时候就可以利用这一点,在B-tree索引中,优化器会将这个表达式作为一个常数对待。

类似地,如果要查找一个最大值,也只需读取B-tree索引的最后一条记录。如果MySQL使用了这种类型的优化,那么在EXPLAIN中就可以看到"Select tables optimized away"​。从字面意思可以看出,它表示优化器已经从执行计划中移除了该表,并以一个常数代替。

预估并转化为常数表达式

当MySQL检测到一个表达式可以转化为常数的时候,就会一直把该表达式作为常数进行优化处理。例如,一个用户自定义变量在查询中没有发生变化时就可以将其转换为一个常数。数学表达式则是另一种典型的例子。

让人惊讶的是,在优化阶段,有时候一个查询也能够转化为一个常数。一个例子是在索引列上执行MIN()函数。甚至是主键或者唯一键查找语句也可以被转换为常数表达式。如果WHERE子句中使用了该类索引的常数条件,MySQL可以在查询开始阶段就先查找到这些值,这样优化器就能够知道并将其转换为常数表达式。

sql 复制代码
mysql> EXPLAIN SELECT film.film_id, film_actor.actor_id
    -> FROM sakila.film
    -> INNER JOIN sakila.film_actor USING(film_id)
    -> WHERE film.film_id = 1\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: film
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
ref: const
rows: 1
filtered: 100.00
Extra: Using index
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: index
possible_keys: NULL
key: PRIMARY
key_len: 4
ref: const
rows: 5462
filtered: 10.00
Extra: Using where; Using index

MySQL分两步来执行这个查询,也就是上面执行计划的两行输出。第一步先从film表找到需要的行。因为在film_id列上有主键索引,所以MySQL优化器知道这只会返回一行数据,优化器在生成执行计划的时候,就已经通过索引信息知道将返回多少行数据了。

因为查询优化器已经明确知道有多少个值(WHERE条件中的值)需要做索引查询,所以这里的表访问类型是const。

在执行计划的第二步时,MySQL将第一步中返回的film_id列当作一个已知取值的列来处理。因为优化器清楚在第一步执行完成后,该值就会是明确的了。

注意,正如在第一步中一样,使用film_actor字段对表的访问类型也是const。

另一种会看到常数条件的情况是通过等式将常数值从一个表传到另一个表,这可以通过WHERE、USING或者ON语句来限制某列取值为常数。

在上面的例子中,因为使用了USING子句,所以优化器知道这会限制film_id在整个查询过程中始终都是一个常量------因为它必须等于WHERE子句中的那个取值。

覆盖索引扫描

当索引中的列包含所有查询中需要使用的列的时候,MySQL就可以使用索引返回需要的数据,而无须查询对应的数据行,这在前面已经讨论过了。

子查询优化

MySQL在某些情况下可以将子查询转换为一种效率更高的形式,从而减少多个查询多次对数据进行访问。

提前终止查询

发现已经满足查询需求的时候,MySQL总是能够立刻终止查询。一个典型的例子就是当使用了LIMIT子句的时候。除此之外,MySQL在其他几类情况下也会提前终止查询,例如发现了一个不成立的条件,这时MySQL可以立刻返回一个空结果。从下面的例子中可以看到这一点:

sql 复制代码
mysql> EXPLAIN SELECT film.film_id FROM sakila.film WHERE film_id = -1;
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: NULL
partitions: NULL
type: NULL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: NULL
filtered: NULL
Extra: Impossible WHERE

从这个例子中可看到,查询在优化阶段就已经终止。除此之外,MySQL在执行过程中,如果发现某些特殊的条件,则会提前终止查询。当查询执行引擎需要检索"不同取值"或者判断存在性的时候,MySQL都可以使用这类优化。

例如,我们现在需要找到没有演员的所有电影

sql 复制代码
SELECT film.film_id
FROM sakila.film
LEFT OUTER JOIN sakila.film_actor USING(film_id)
WHERE film_actor.film_id IS NULL;

这个查询将会过滤掉所有有演员的电影。每一部电影可能都会有很多的演员,但是上面的查询一旦找到任何一个演员,就会停止并立刻判断下一部电影,因为只要有一名演员,那么WHERE条件就会过滤掉这部电影。

类似这种"不同值/不存在"的优化一般可用于DISTINCT、NOT EXIST()或者LEFT JOIN类型的查询。

等值传播

如果两列的值可通过等式联接,那么MySQL能够把其中一列的WHERE条件传递到另一列上。例如,我们看下面的查询:

sql 复制代码
SELECT film.film_id
FROM sakila.film
INNER JOIN sakila.film_actor USING(film_id)
WHERE film.film_id > 500;

因为这里使用了film_id字段进行等值联接,MySQL知道这里的WHERE子句不仅适用于film表,而且对于film_actor表同样适用。

如果使用的是其他的数据库管理系统,可能还需要手动通过一些条件来告知优化器这个WHERE条件适用于两个表,那么写法就会如下:

sql 复制代码
... WHERE film.film_id> 500 AND film_actor.film_id> 500

在MySQL中这是不必要的,这样写反而会让查询更难维护。

列表IN()的比较

在很多数据库服务器中,IN()完全等同于多个OR条件的子句,因为这两者是完全等价的。

在MySQL中这点是不成立的,MySQL将IN()列表中的数据先进行排序,然后通过二分查找的方式来确定列表中的值是否满足条件,这是一个O(logn)复杂度的操作,等价地转换成OR查询的复杂度为O(n),对于IN()列表中有大量取值的时候,MySQL的处理速度将会更快。

当然,虽然优化器已经很智能了,但是有时候也无法给出最优的结果。有时候你可能比优化器更了解数据,例如,由于应用逻辑使得某些条件总是成立;还有时候,优化器缺少某种功能特性,如哈希索引;再如前面提到的,从优化器的执行成本角度评估出来的最优执行计划,在实际运行中可能比其他的执行计划更慢。

如果能够确认优化器给出的不是最佳选择,并且清楚背后的原理,那么也可以帮助优化器做进一步的优化。例如,可以在查询中添加hint提示,也可以重写查询,或者重新设计更优的库表结构,或者添加更合适的索引。

(4)表和索引的统计信息

MySQL架构由多个层次组成。在服务器层有查询优化器,却没有保存数据和索引的统计信息。统计信息由存储引擎实现,不同的存储引擎可能会存储不同的统计信息(也可以按照不同的方式存储统计信息)​。

因为服务器没有存储任何统计信息,所以MySQL查询优化器在生成查询的执行计划时,需要向存储引擎获取相应的统计信息。

存储引擎则给优化器提供对应的统计信息,包括:每个表或者索引有多少个页面、每个表的每个索引的基数是多少、数据行和索引的长度是多少、索引的分布信息等。

优化器根据这些信息来选择一个最优的执行计划。在后面的小节中我们将看到统计信息是如何影响优化器的。

(5)MySQL如何执行联接

查询MySQL中使用的术语"联接"​(对应英文为Join)的范围可能比你熟悉的更广泛。

总的来说,MySQL认为每一个查询都是联接------不仅是匹配两张表中对应行的查询,而是每一个查询、每一个片段(包括子查询,甚至基于单表的SELECT)都是联接。因此,理解MySQL如何执行联接查询是非常重要的。

所以,理解MySQL如何执行UNION查询至关重要。我们先来看一个UNION查询的例子。

对于UNION查询,MySQL先将一系列的单个查询结果放到一个临时表中,然后再重新读出临时表中的数据来完成UNION查询。在MySQL的概念中,每个查询都是一次联接,所以读取临时表的结果也是一次联接。

当前MySQL的联接执行策略很简单:MySQL对任何联接都执行嵌套循环联接操作,即MySQL先在一个表中循环取出单条数据,然后再嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表中匹配的行为止。

最后根据各个表匹配的行,返回查询中需要的各列。

MySQL会尝试在最后一个联接表中找到所有匹配的行,如果最后一个联接表无法找到更多的行,MySQL返回到上一层次的联接表,看是否能够找到更多的匹配记录,依此类推,迭代执行。

(6)执行计划

和很多其他关系数据库不同,MySQL并不会生成查询字节码来执行查询。MySQL生成查询的一棵指令树,然后通过查询执行引擎执行完成这棵指令树并返回结果。最终的执行计划包含了重构查询的全部信息。

如果你对某个查询执行EXPLAIN EXTENDED后,再执行SHOW WARNINGS,就可以看到重构出的查询。

在计算机科学中,这被称为一棵平衡树。但是,这并不是MySQL执行查询的方式。正如我们在前面介绍的,MySQL总是从一个表开始,一直嵌套循环、回溯完成所有表联接。

所以,MySQL的执行计划总是如图所示,是一棵左侧深度优先的树。

(7)联接查询优化器

MySQL查询优化器最重要的一部分就是联接查询优化器,它决定了多个表联接时的顺序。

通常多表联接的时候,可以有多种不同的联接顺序来获得相同的执行结果。联接查询优化器通过评估不同顺序时的成本来选择一个成本最低的联接顺序。

下面的查询可以通过不同顺序的联接最后获得相同的结果:

sql 复制代码
SELECT film.film_id, film.title, film.release_year, actor.actor_id,
       actor.first_name, actor.last_name
FROM sakila.film
INNER JOIN sakila.film_actor USING(film_id)
INNER JOIN sakila.actor USING(actor_id);

很容易看出,可以通过一些不同的执行计划来完成上面的查询。例如,MySQL可以从film表开始,使用film_actor表的索引film_id来查找对应的actor_id值,然后再根据actor表的主键找到对应的记录。Oracle用户会用下面的术语描述:​"film表作为驱动表先查找file_actor表,然后以此结果为驱动表再查找actor表"​。

sql 复制代码
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: actor
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 200
filtered: 100.00
Extra: NULL

*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: ref
possible_keys: PRIMARY,idx_fk_film_id
key: PRIMARY
key_len: 2
ref: sakila.actor.actor_id
rows: 27
filtered: 100.00
Extra: Using index

*************************** 3. row ***************************
id: 1
select_type: SIMPLE
table: film
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
ref: sakila.film_actor.film_id
rows: 1
filtered: 100.00
Extra: NULL

这和我们前面给出的执行计划完全不同。MySQL从actor表开始(从上面的EXPLAIN结果的第一行输出可以看出这一点)​,然后与我们前面的计划按照相反的顺序进行联接。这样是否效率更高呢?我们来看看。

我们先使用STRAIGHT_JOIN关键字,按照之前的顺序执行,下面是对应的EXPLAIN输出结果:

sql 复制代码
mysql> EXPLAIN SELECT STRAIGHT_JOIN film.film_id...\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: film
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 1000
filtered: 100.00
Extra: NULL

*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: ref
possible_keys: PRIMARY,idx_fk_film_id
key: idx_fk_film_id
key_len: 2
ref: sakila.film.film_id
rows: 5
filtered: 100.00
Extra: Using index

*************************** 3. row ***************************
id: 1
select_type: SIMPLE
table: actor
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
ref: sakila.film_actor.actor_id
rows: 1
filtered: 100.00
Extra: NULL

这说明了MySQL为什么要反转联接顺序:反转后可以使查询在第一个表中检查更少的行。在这两种情况下,都能够在第二个和第三个表中执行快速索引查找,不同的是,需要执行的索引查找次数不一样。将film表作为第一个表需要检查大约1000行记录(参见rows字段)​,每一行都是一个探针,用于针对film_actor和actor表进行索引查找。如果MySQL先扫描actor表,则只需要对后面的表进行200次索引查找。换句话说,反转联接顺序会让查询进行更少的回溯和重读操作。

这个简单的例子主要想说明MySQL是如何选择合适的联接顺序来让查询执行的成本尽可能低的。重新定义联接的顺序是优化器非常重要的一项功能。不过有的时候,优化器给出的并不是最优的联接顺序。这时可以使用STRAIGHT_JOIN关键字重写查询,让优化器按照你认为的最优的联接顺序执行------不过老实说,人的判断很难那么精准。绝大多数时候,优化器做出的选择都比普通人的判断要更准确。

联接优化器会尝试在所有的联接顺序中选择一个成本最低的来生成执行计划树。如果可能,优化器会遍历每一个表,然后逐个做嵌套循环,计算执行每一棵可能的计划树的成本,最后返回一个最优的执行计划。

不过,糟糕的是,n个表的联接可能有n的阶乘种联接顺序,我们称之为所有可能的查询计划的"搜索空间"​。搜索空间的增长速度非常块,例如,若是10个表的联接,那么共有3628800种不同的联接顺序!当搜索空间非常大的时候,优化器不可能逐一评估每一种联接顺序的成本。这时,优化器选择使用"贪婪"搜索的方式查找"最优"的联接顺序。实际上,当需要联接的表超过optimizer_search_depth的限制的时候,就会选择"贪婪"搜索模式了(optimizer_search_depth参数可以根据需要指定大小)​。

在MySQL这些年的发展过程中,优化器积累了很多"启发式"的优化策略来加速执行计划的生成。在绝大多数情况下这都是有效的,但因为不会去计算每一种联接顺序的成本,所以偶尔也会选择不是最优的执行计划。

有时查询不能重新排序,联接优化器可以利用这一点通过消除选择来减小搜索空间。左联接(LEFT JOIN)和相关子查询都是很好的例子(稍后将详细介绍子查询)​。这是因为,一个表的结果依赖于另外一个表中检索的数据,这种依赖关系通常可以帮助联接优化器通过消除选择来减少搜索空间。

(8)排序优化

无论如何排序都是一个成本很高的操作,所以从性能角度考虑,应尽可能避免排序或者尽可能避免对大量数据进行排序。

当不能使用索引生成排序结果的时候,MySQL需要自己进行排序,如果数据量小则在内存中进行,如果数据量大则需要使用磁盘,不过MySQL将这个过程统一称为文件排序(filesort),即使完全是在内存中排序不需要任何磁盘文件时也是如此。

如果需要排序的数据量小于"排序缓冲区"​,MySQL使用内存进行快速排序操作。

如果内存不够排序,那么MySQL会先将数据分块,对每个独立的块使用"快速排序"进行排序,并将各个块的排序结果存放在磁盘上,然后将各个排好序的块进行合并(merge),最后返回排序结果。

MySQL有如下两种排序算法

两次传输排序(旧版本使用)

读取行指针和需要排序的字段,对其进行排序,然后再根据排序结果读取所需要的数据行。

这需要进行两次数据传输,即需要从数据表中读取两次数据,第二次读取数据的时候,因为是读取排序列进行排序后的所有记录,这会产生大量的随机I/O,所以两次传输排序的成本非常高。

单次传输排序(新版本使用)

先读取查询所需要的所有列,然后再根据给定列进行排序,最后直接返回排序结果。

因为不再需要从数据表中读取两次数据,对于I/O密集型的应用来说,这样做的效率高了很多。另外,相比两次传输排序,这个算法只需要一次顺序I/O就可读取所有的数据,而无须任何的随机I/O。

然而,这种方式可能占用更多空间,因为会保存查询中每一行所需要的列,而不仅仅是进行排序操作所需要的列。这意味着更少的元组可以放入排序缓冲区,使得文件排序(filesort)操作必须执行更多的排序合并过程。

MySQL在进行文件排序时需要使用的临时存储空间可能会比想象的要大得多。原因在于MySQL在排序时,对每一个排序记录都会分配一个足够长的定长空间来存放。这个定长空间必须足够长才能容纳其中最长的字符串,例如,如果是VARCHAR列,则需要分配其完整长度;如果使用utf8mb4字符集,那么MySQL将会为每个字符预留4字节。我们曾经在一个库表结构设计不合理的案例中看到,排序消耗的临时空间比磁盘上的原表要大很多倍。

在联接查询的时候如果需要排序,MySQL会分两种情况来处理这样的文件排序。如果ORDER BY子句中的所有列都来自联接的第一个表,那么MySQL在联接处理第一个表的时候就进行文件排序。如果是这样,那么在MySQL的EXPLAIN结果中可以看到Extra字段会有"Using filesort"字样。除此之外的所有情况,MySQL都会先将联接的结果存放到一个临时表中,然后在所有的联接都结束后,再进行文件排序。在这种情况下,在MySQL的EXPLAIN结果的Extra字段可以看到"Using temporary;Using filesort"字样。如果查询中有LIMIT的话,LIMIT也会在文件排序之后应用,所以即使需要返回较少的数据,临时表和需要排序的数据量仍然会非常大。

(四)查询执行引擎

在解析和优化阶段,MySQL将生成查询对应的执行计划,MySQL的查询执行引擎会根据这个执行计划来完成整个查询。这里的执行计划是一个数据结构,而不是和很多其他的关系数据库那样生成对应的可执行的字节码。

相对于查询优化阶段,查询执行阶段不是那么复杂:MySQL只是简单地根据执行计划给出的指令逐步执行。在根据执行计划逐步执行的过程中,有大量的操作需要通过调用存储引擎实现的接口来完成,这些接口也就是我们称为"handler API"的接口。

查询中的每一个表都由一个handler的实例表示。如果一个表在查询中出现了三次,服务器会创建三个handler对象。

前面我们有意忽略了这一点,实际上,MySQL在优化阶段就为每个表创建了一个handler实例,优化器根据这些实例的接口可以获取表的相关信息,包括表的所有列名、索引统计信息,等等。

存储引擎接口有着非常丰富的功能,但是底层接口却只有几十个,这些接口像"搭积木"一样能够完成查询的大部分操作。

例如,有一个查询某个索引的第一行的接口,其还有查询某个索引条目的下一个条目的功能,有了这两个功能就可以完成全索引扫描的操作了。这种简单的接口模式,让MySQL的存储引擎的插件式架构成为可能,但是正如前面的讨论,这也给优化器带来了一定的限制。

并不是所有的操作都由handler完成。例如,当MySQL需要进行表锁的时候。handler可能会实现自己的级别的、更细粒度的锁,如InnoDB就实现了自己的行级基本锁,但这并不能代替服务器层的表锁。

如果是所有存储引擎共有的特性则由服务器层实现,比如时间和日期函数、视图、触发器等。为了执行查询,MySQL只需要重复执行计划中的各个操作,直到完成所有的数据查询。

(五)将结果返回给客户端

执行查询的最后一个阶段是将结果返回给客户端。即使查询不需要给客户端返回结果集,MySQL仍然会返回这个查询的一些信息,如该查询影响到的行数。

MySQL将结果集返回客户端是一个增量且逐步返回的过程。

例如,我们回头看看前面的联接操作,一旦服务器处理完最后一个联接表,开始生成第一条结果时,MySQL就可以开始向客户端逐步返回结果集了。

这样处理有两个好处:服务器端无须存储太多的结果,也就不会因为要返回太多结果而消耗太多内存。

另外,这样的处理也可让MySQL客户端第一时间获得返回的结果。

结果集中的每一行都会以一个满足MySQL客户端/服务器通信协议的封包发送,再通过TCP协议进行传输,在TCP传输的过程中,可能对MySQL的封包进行缓存,然后批量传输。

五、MySQL查询优化器的局限性

MySQL所实现的查询执行方式并不是对每种查询都是最优的。不过还好,MySQL查询优化器只对少部分查询不适用,而且我们往往可以通过改写查询让MySQL高效地完成工作。

(一)UNION的限制

有时,MySQL无法将限制条件从UNION的外层"下推"到内层,这使得原本能够限制部分返回结果的条件无法应用到内层查询的优化上。

如果希望UNION的各个子句能够根据LIMIT只取部分结果集,或者希望能够先排好序再合并结果集的话,就需要在UNION的各个子句中分别使用这些子句。

例如,想将两个子查询结果联合起来,然后再取前20条记录,那么MySQL会将两个表存放到同一个临时表中,然后再取出前20行记录:

sql 复制代码
(SELECT first_name, last_name
 FROM sakila.actor
 ORDER BY last_name)
UNION ALL
(SELECT first_name, last_name
 FROM sakila.customer
 ORDER BY last_name)
LIMIT 20;

这条查询将会把actor表中的200条记录和customer表中的599条记录存放在一个临时表中,然后再从临时表中取出前20条。可以通过在UNION的两个子查询中分别加上一个LIMIT 20来减少临时表中的数据:

sql 复制代码
(SELECT first_name, last_name
 FROM sakila.actor
 ORDER BY last_name
 LIMIT 20)
UNION ALL
(SELECT first_name, last_name
 FROM sakila.customer
 ORDER BY last_name
 LIMIT 20)
LIMIT 20;

现在临时表只包含40条记录了,除了考虑性能之外,在这里还需要注意一点:从临时表中取出数据的顺序并不是一定的,所以如果想获得正确的顺序,还需要在最后的LIMIT操作前加上一个全局的ORDER BY操作。

(二)等值传递

某些时候,等值传递会带来一些意想不到的额外消耗。

例如,考虑一列上的巨大IN()列表,优化器知道它将等于其他表中的一些列,这是由于WHERE、ON或USING子句使列彼此相等。

优化器通过将列表复制到所有相关表中的相应列来"共享"列表。通常,因为各个表新增了过滤条件,所以优化器可以更高效地从存储引擎过滤记录。

但是如果这个列表非常大,则会导致优化和执行都会变慢。除了修改MySQL源代码,目前还没有什么办法能够绕过该问题(不过这个问题很少会碰到)​。

(三)并行执行

MySQL无法利用多核特性来并行执行查询。很多其他的关系数据库能够提供这个特性,但是MySQL做不到。这里特别指出是想告诉读者不要花时间去尝试寻找并行执行查询的方法。

(四)在同一个表中查询和更新

MySQL不允许对一张表同时进行查询和更新。这其实并不是优化器的限制,如果你清楚MySQL是如何执行查询的,就可以避免这种情况。

下面是一段无法运行的SQL语句,尽管这是一段符合标准的SQL语句。这个查询会将表中每一行的c字段值更新为和该行的type字段值相同的行数量:

sql 复制代码
mysql> UPDATE tbl AS outer_tbl
    -> SET c = (
    ->   SELECT count(*) FROM tbl AS inner_tbl
    ->   WHERE inner_tbl.type = outer_tbl.type
    -> );
ERROR 1093 (HY000): You can't specify target table 'outer_tbl' for update in FROM clause

可以使用生成表的形式来绕过上面的限制,因为MySQL只会把这个表当作一个临时表来处理。实际上,这执行了两个查询:一个是子查询中的SELECT语句,另一个是多表UPDATE查询,其中包含原表和子查询的联接结果。子查询会在UPDATE语句打开表之前就完成,所以下面的查询将会正常执行:

sql 复制代码
mysql> UPDATE tbl
    -> INNER JOIN(
    ->   SELECT type, count(*) AS c
    ->   FROM tbl
    ->   GROUP BY type
    -> ) AS der USING(type)
    -> SET tbl.c = der.c;

六、优化特定类型的查询

(一)优化COUNT()

查询COUNT()聚合函数,以及如何优化使用了该函数的查询,很可能是MySQL中最容易被误解的前10个话题之一。你在网上随便搜索一下就能看到很多错误的理解,可能比我们想象中的要多得多。在做优化之前,先来看看COUNT()函数真正的作用是什么。

1.COUNT()的作用

COUNT()是一个特殊的函数,有两种非常不同的作用:它可以统计某列的值的数量,也可以统计行数。

在统计列值时要求列值是非空的(不统计NULL)​。如果在COUNT()的括号中指定了列或者列的表达式,则统计的就是这个表达式有值的结果数。

因为很多人对NULL理解有问题,所以这里很容易产生误解。如果你想了解更多关于SQL语句中NULL的含义,建议阅读一些关于SQL语句基础的书籍。​(关于这个话题,互联网上的一些信息是不够准确的。​)

COUNT()的另一个作用是统计结果集的行数。当MySQL确认括号内的表达式值不可能为空时,实际上就是在统计行数。

最简单的就是当我们使用COUNT(*)的时候,这种情况下通配符*并不会像我们猜想的那样扩展成所有的列,实际上,它会忽略所有的列而直接统计所有的行数。

我们发现最常见的错误之一是,当需要统计行数时,在COUNT()函数的括号内指定了列名。如果想要知道结果中的行数,应该始终使用COUNT(*),这样可以更清晰地传达意图,避免糟糕的性能表现。

2.简单优化

通常会看到这样的问题:如何在一个查询中统计同一列的不同值的数量,以减少查询的语句量。

例如,假设可能需要通过一个查询返回各种不同颜色的商品数量,此时不能使用OR语句(比如,SELECT COUNT(color='blue'OR color='red') FROM items;​)​,因为这样做无法区分不同颜色的商品数量;

也不能在WHERE条件中指定颜色(比如,SELECT COUNT(*)FROM items WHERE color='blue'AND color='RED';​)​,因为颜色的条件是互斥的。

下面的查询可以在一定程度上解决这个问题:

sql 复制代码
SELECT SUM(IF(color = 'blue', 1, 0)) AS blue,
       SUM(IF(color = 'red', 1, 0)) AS red
FROM items;

也可以使用COUNT()而不是SUM()实现同样的目的,只需要将满足条件设置为真,不满足条件设置为NULL即可:

sql 复制代码
SELECT COUNT(color = 'blue' OR NULL) AS blue,
       COUNT(color = 'red' OR NULL) AS red
FROM items;

3.使用近似值

有时候,某些业务场景并不要求完全精确的统计值,此时可以用近似值来代替。

EXPLAIN出来的优化器估算的行数就是一个不错的近似值,执行EXPLAIN并不需要真正地去执行查询,所以成本很低。

很多时候,计算精确值非常复杂,而计算近似值则非常简单。曾经有一个客户希望我们统计他的网站的当前活跃用户数是多少,这个活跃用户数保存在缓存中,过期时间为30分钟,所以每隔30分钟需要重新计算并放入缓存。

这个活跃用户数本身就不是精确值,所以使用近似值代替是可以接受的。

另外,如果要精确统计在线人数,使用WHERE条件会很复杂,一方面需要剔除当前非活跃用户,另一方面还要剔除系统中某些特定ID的"默认"用户,去掉这些约束条件对总数的影响很小,但却可能提升该查询的性能。

更进一步的优化则可以尝试删除DISTINCT这样的约束来避免文件排序。这样重写过的查询比原来精确统计的查询快很多,而返回的结果则几乎相同。

4.更复杂的优化

通常来说,COUNT()查询需要扫描大量的行(意味着要访问大量数据)才能获得精确的结果,因此是很难优化的。除了前面提到的方法,在MySQL层面还能做的就只有索引覆盖扫描了。如果这还不够,那就需要考虑修改应用的架构,可以增加类似Memcached这样的外部缓存系统。不过,可能很快你就会陷入一个熟悉的困境:​"快速、精确和实现简单"​。三者永远只能满足其二,必须舍掉一个。

(二)优化联接查询

这个话题基本上整本书都在讨论,这里需要特别提到以下几点。

  • 确保ON或者USING子句中的列上有索引。在创建索引的时候就要考虑到联接的顺序。当表A和表B用列c联接的时候,如果优化器的联接顺序是B、A,那么就不需要在B表的对应列上建索引。没有用到的索引只会带来额外的负担。一般来说,除非有其他理由,否则只需在联接顺序中的第二个表的相应列上创建索引。
  • 确保任何GROUP BY和ORDER BY中的表达式只涉及一个表中的列,这样MySQL才有可能使用索引来优化这个过程。
  • 当升级MySQL的时候需要注意:联接语法、运算符优先级等其他可能会发生变化的地方。因为以前是普通联接的地方可能会变成笛卡儿积,不同类型的联接可能会生成不同的结果,甚至会产生语法错误。

(三)使用WITH ROLLUP优化GROUP BY

分组查询的一个变种就是要求MySQL对返回的分组结果再做一次超级聚合。可以使用WITH ROLLUP子句来实现这种逻辑,但可能优化得不够。

可以通过EXPLAIN来观察其执行计划,特别要注意分组是否是通过文件排序或者临时表实现的。然后再去掉WITH ROLLUP子句来看执行计划是否相同。

也可以通过本节前面介绍的优化器提示来强制执行计划。很多时候,如果可以,在应用程序中做超级聚合是更好的,虽然这需要给客户端返回更多的结果。

也可以在FROM子句中嵌套使用子查询,或者是通过一个临时表存放中间数据,然后和临时表执行UNION来得到最终结果。最好的办法是尽可能地将WITH ROLLUP功能转移到应用程序中处理。

(四)优化LIMIT和OFFSET子句

在系统中需要进行分页操作的时候,我们通常会使用LIMIT加上偏移量的办法实现,同时加上合适的ORDER BY子句。如果有对应的索引,通常效率会不错,否则,MySQL需要做大量的文件排序操作。

一个非常常见又令人头疼的问题是,在偏移量非常大的时候,例如,可能是LIMIT 1000,20这样的查询,这时MySQL需要查询10020条记录然后只返回最后20条,前面10 000条记录都将被抛弃,这样的代价非常高。

如果所有的页面被访问的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。

化此类分页查询的一个最简单的办法就是尽可能地使用索引覆盖扫描,而不是查询所有的行。然后根据需要做一次联接操作再返回所需的列。在偏移量很大的时候,这样做的效率会有非常大的提升。考虑下面的查询:

sql 复制代码
SELECT film_id, description FRoM sakila.film ORDER BY title LIMIT 50, 5;

如果这个表非常大,那么这个查询最好改写成下面的样子:

sql 复制代码
SELECT film.film_id, film.description
FROM sakila.film
INNER JOIN (
  SELECT film_id FROM sakila.film
  ORDER BY title LIMIT 50, 5
) AS lim USING(film_id);

这种"延迟联接"之所以有效,是因为它允许服务器在不访问行的情况下检查索引中尽可能少的数据,然后,一旦找到所需的行,就将它们与整个表联接,以从该行中检索其他列。类似的技术也适用于带有LIMIT子句的联接。

有时候也可以将LIMIT查询转换为已知位置的查询,让MySQL通过范围扫描获得对应的结果。例如,如果在一个位置列上有索引,并且预先计算出了边界值,上面的查询就可以改写为:

sql 复制代码
SELECT film_id, description 
FROM sakila.film 
WHERE position BETWEEN 50 AND 54 
ORDER BY position;

对数据进行排名的问题也与此类似,但往往还会同时和GROUP BY混合使用。在这种情况下通常需要预先计算并存储排名信息。

LIMIT和OFFSET的问题,其实是OFFSET的问题,它会导致MySQL扫描大量不需要的行然后再抛弃掉。

如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用OFFSET。

例如,若需要按照租借记录做翻页,那么可以根据最新一条租借记录向回追溯,这种做法可行是因为租借记录的主键是单调增长的。首先使用下面的查询获得第一组结果:

sql 复制代码
SELECT * FROM sakila.rental
ORDER BY rental_id DESC LIMIT 2

假设上面的查询返回的是主键为16,049到16,030的租借记录,那么下一页查询就可以从16,030这个点开始:

sql 复制代码
SELECT * FROM sakila.rental
WHERE rental_id < 16030
ORDER BY rental_id DESC LIMIT 20;

该技术的好处是无论翻页到多么靠后,其性能都会很好。其他优化办法还包括使用预先计算的汇总表,或者联接到一个冗余表,冗余表只包含主键列和需要做排序的数据列。

(五)优化SQL CALC FOUND ROWS

分页的时候,另一个常用的技巧是在LIMIT语句中加上SQL_CALC_FOUND_ROWS提示(hint),这样就可以获得去掉LIMIT以后满足条件的行数,因此可以作为分页的总数。

看起来,MySQL做了一些非常"高深"的优化,像是通过某种方法预测了总行数。但实际上,MySQL只有在扫描了所有满足条件的行以后,才会知道行数,所以加上这个提示以后,不管是否需要,MySQL都会扫描所有满足条件的行,然后再抛弃掉不需要的行,而不是在满足LIMIT的行数后就终止扫描。所以该提示的代价可能非常高。

一个更好的设计是将具体的页数换成"下一页"按钮,假设每页显示20条记录,那么我们每次查询时都是用LIMIT返回21条记录并只显示20条,如果第21条存在,那么就显示"下一页"按钮,否则就说明没有更多的数据,也就无须显示"下一页"按钮了。

另一种做法是先获取并缓存较多的数据------例如,缓存1000条------然后每次分页都从这个缓存中获取。这样做可以让应用程序根据结果集的大小采取不同的策略,如果结果集小于1000,就可以在页面上显示所有的分页链接,因为数据都在缓存中,所以这样做不会对性能造成影响。如果结果集大于1000,则可以在页面上设计一个额外的"找到的结果多于1000条"之类的按钮。这两种策略都比每次生成全部结果集再抛弃不需要的数据的效率高很多。

有时候也可以考虑使用EXPLAIN的结果中的rows列的值来作为结果集总数的近似值(实际上,Google的搜索结果总数也是一个近似值)​。当需要精确结果的时候,再单独使用COUNT(*)来满足需求,这时如果能够使用索引覆盖扫描则通常也会比SQL_CALC_FOUND_ROWS快得多。

(六)优化UNION查询

MySQL总是通过创建并填充临时表的方式来执行UNION查询,因此很多优化策略在UNION查询中都没法很好地被使用。经常需要手工地将WHERE、LIMIT、ORDER BY等子句"下推"到UNION的各个子查询中,以便优化器可以充分利用这些条件进行优化(例如,直接将这些子句冗余地写一份到各个子查询)​。

除非你确实需要服务器消除重复的行,否则一定要使用UNION ALL,这一点很重要。如果没有ALL关键字,MySQL会给临时表加上DISTINCT选项,这会导致对整个临时表的数据做唯一性检查。这样做的代价非常高。即使有ALL关键字,MySQL仍然会使用临时表存储结果。事实上,MySQL总是将结果放入临时表,然后再读出,再返回给客户端,虽然很多时候这样做是没有必要的(例如,MySQL可以直接把这些结果返回给客户端)​。

相关推荐
cab52 小时前
MyBatis如何处理数据库中的JSON字段
数据库·json·mybatis
天若有情6732 小时前
用MySQL+BI工具搭建企业级数据可视化看板:从数据准备到动态展示全攻略
数据库·mysql·信息可视化
郝学胜-神的一滴2 小时前
机器学习数据预处理:归一化与sklearn的MinMaxScaler详解
人工智能·python·程序人生·机器学习·性能优化·sklearn
TDengine (老段)2 小时前
TDengine C# 语言连接器进阶指南
大数据·数据库·人工智能·物联网·c#·时序数据库·tdengine
山峰哥2 小时前
SQL调优实战:让查询效率飙升10倍的降本密码
服务器·前端·数据库·sql·编辑器·深度优先
一个响当当的名号2 小时前
lectrue2 高级SQL
数据库·oracle
风叶悠然3 小时前
vue3中数据的pinia的使用
前端·javascript·数据库
JosieBook3 小时前
【数据库】2026国产时序数据库新格局与金仓的多模突围
数据库·时序数据库