MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

MySQL 查询优化:避免全表扫描的实用技巧

2022-08-113.9k 阅读

理解全表扫描

在深入探讨避免全表扫描的技巧之前,我们首先需要明确什么是全表扫描。简单来说,全表扫描是指数据库在执行查询时,需要遍历表中的每一行数据,以找到符合查询条件的记录。想象一下,假如你有一本厚厚的电话簿,而你要找一个特定名字的人的电话号码,但你没有索引(比如按姓氏首字母分类查找的方式),你就只能从第一页开始,逐页逐行地查看,直到找到目标。这就是全表扫描在数据库中的工作方式。

全表扫描在一些情况下是合理且必要的,例如当表数据量非常小,或者查询条件无法有效利用索引时。然而,当表数据量较大时,全表扫描的性能问题就会凸显出来。它会消耗大量的系统资源,包括磁盘 I/O、CPU 等,导致查询响应时间变长,严重影响应用程序的性能。

全表扫描产生的原因

  1. 缺乏索引:这是导致全表扫描最常见的原因之一。如果查询条件涉及的列上没有合适的索引,MySQL 就无法快速定位到符合条件的数据行,只能选择全表扫描。例如,在一个包含用户信息的表 users 中,有 idnameemail 等列,如果执行查询 SELECT * FROM users WHERE name = 'John';,而 name 列上没有索引,MySQL 就会对 users 表进行全表扫描。
  2. 索引失效:即使表上存在索引,某些情况下索引也可能无法被有效利用。比如,当使用函数操作索引列时,索引往往会失效。例如 SELECT * FROM orders WHERE YEAR(order_date) = 2023;,这里对 order_date 列使用了 YEAR 函数,MySQL 可能无法使用 order_date 列上的索引,从而引发全表扫描。
  3. 数据分布问题:如果数据在表中的分布不均匀,导致索引无法有效地过滤数据,MySQL 可能会放弃使用索引而选择全表扫描。例如,在一个存储性别信息的列中,90% 的记录都是 female,当查询 SELECT * FROM users WHERE gender = 'female'; 时,MySQL 可能认为全表扫描比使用索引更高效。

优化查询语句

合理使用索引

  1. 创建合适的索引
    • 单列索引:对于经常在 WHERE 子句中作为条件的单个列,应考虑创建单列索引。例如,在 products 表中,经常根据 product_name 进行查询:
CREATE INDEX idx_product_name ON products(product_name);
  • 复合索引:当多个列经常一起出现在 WHERE 子句中作为条件时,复合索引可以提高查询性能。假设在 orders 表中,经常根据 customer_idorder_date 进行查询:
CREATE INDEX idx_customer_date ON orders(customer_id, order_date);

需要注意的是,复合索引的顺序很重要,遵循最左前缀原则。即查询条件要从复合索引的最左边开始使用,才能有效利用索引。例如,上述 idx_customer_date 索引,查询 SELECT * FROM orders WHERE customer_id = 1 AND order_date > '2023 - 01 - 01'; 可以利用索引,但查询 SELECT * FROM orders WHERE order_date > '2023 - 01 - 01'; 则无法利用该索引,因为它没有从最左边的 customer_id 开始。 2. 避免索引覆盖问题

  • 索引覆盖:当查询的列都包含在索引中时,MySQL 可以直接从索引中获取数据,而不需要回表操作,这就是索引覆盖。例如,在 employees 表中有 idnamesalary 等列,创建索引 CREATE INDEX idx_name_salary ON employees(name, salary);,如果执行查询 SELECT name, salary FROM employees WHERE name LIKE 'A%';,MySQL 可以通过该索引直接获取所需数据,避免了回表操作,提高了查询性能。
  • 回表操作:如果查询的列不都在索引中,MySQL 在通过索引找到数据行的主键后,还需要根据主键再去聚簇索引(通常是主键索引)中获取完整的数据行,这就是回表操作。例如,上述 employees 表,若执行查询 SELECT id, name, salary FROM employees WHERE name LIKE 'A%';,由于 id 不在 idx_name_salary 索引中,就会发生回表操作。为了避免不必要的回表,在设计索引时应尽量考虑让查询的列都能被索引覆盖。

优化 WHERE 子句

  1. 避免在索引列上使用函数:正如前面提到的,在索引列上使用函数会导致索引失效。例如,有一个 orders 表,包含 order_date 列,若要查询 2023 年的订单,应避免这样写:
SELECT * FROM orders WHERE YEAR(order_date) = 2023;

可以改写为:

SELECT * FROM orders WHERE order_date >= '2023 - 01 - 01' AND order_date < '2024 - 01 - 01';

这样就可以利用 order_date 列上的索引,避免全表扫描。 2. 使用 IN 代替 OR:在某些情况下,OR 操作可能导致索引失效。例如,在 products 表中,有 category 列,若执行查询 SELECT * FROM products WHERE category = 'electronics' OR category = 'clothes';,MySQL 可能无法使用 category 列上的索引。可以将其改写为:

SELECT * FROM products WHERE category IN ('electronics', 'clothes');

这样 MySQL 更有可能使用 category 列上的索引。 3. 避免使用 LIKE '%xxx':以通配符开头的 LIKE 查询(LIKE '%xxx')无法使用索引,因为数据库无法从索引的起始位置开始匹配。例如,在 customers 表中,customer_name 列上有索引,执行查询 SELECT * FROM customers WHERE customer_name LIKE '%Smith'; 会导致全表扫描。如果可能,应尽量改写为 LIKE 'xxx%' 的形式,例如 SELECT * FROM customers WHERE customer_name LIKE 'Smith%';,这样可以利用索引。

优化 JOIN 操作

  1. 使用合适的 JOIN 类型
    • INNER JOIN:当只需要获取两个表中匹配的记录时,应使用 INNER JOIN。例如,有 orders 表和 customers 表,orders 表中有 customer_id 列关联 customers 表的 id 列,要获取每个订单对应的客户信息,可以这样写:
SELECT * FROM orders
INNER JOIN customers ON orders.customer_id = customers.id;
  • LEFT JOINRIGHT JOINLEFT JOIN 返回左表中的所有记录以及右表中匹配的记录,RIGHT JOIN 则相反。使用时要根据实际需求选择。例如,如果要获取所有客户及其订单(包括没有订单的客户),应使用 LEFT JOIN
SELECT * FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id;
  1. 确保 JOIN 条件上有索引:在 JOIN 操作中,JOIN 条件涉及的列上应该有索引。例如,上述 orderscustomers 表的 JOIN,在 orders.customer_idcustomers.id 列上都应该有索引,这样可以加速 JOIN 操作,避免全表扫描。可以通过以下方式创建索引:
CREATE INDEX idx_customer_id ON orders(customer_id);
CREATE INDEX idx_id ON customers(id);
  1. 小表驱动大表:在 JOIN 操作中,应尽量让小表作为驱动表。例如,有一个 departments 表(数据量较小)和 employees 表(数据量较大),employees 表中有 department_id 列关联 departments 表的 id 列。当进行 JOIN 操作时,应将 departments 表放在前面:
SELECT * FROM departments
JOIN employees ON departments.id = employees.department_id;

这样可以减少循环次数,提高查询性能。

数据库结构优化

适当的表设计

  1. 避免大表:大表往往容易导致全表扫描问题。如果一个表的数据量过大,可以考虑进行分表。例如,对于一个存储历史订单数据的 orders_history 表,数据量达到了千万级别,可以按照时间(如按月)进行分表。假设每月的数据量相对较小,这样可以在查询特定月份的数据时,只需要扫描对应的分表,而不是整个大表。
    • 水平分表:按行进行分表,例如上述按时间分表就是水平分表的一种方式。可以使用 PARTITION BY RANGE 来实现,例如:
CREATE TABLE orders_history (
    id INT,
    order_date DATE,
    order_amount DECIMAL(10, 2),
    customer_id INT
)
PARTITION BY RANGE (YEAR(order_date) * 100 + MONTH(order_date)) (
    PARTITION p0 VALUES LESS THAN (202301),
    PARTITION p1 VALUES LESS THAN (202302),
    -- 以此类推
);
  • 垂直分表:按列进行分表,当一个表中有一些不常用的大字段(如大文本、二进制数据等)时,可以将这些字段单独放到一个表中,通过主键关联。例如,在 users 表中有 idnameemailbio(大文本字段)等列,可以将 bio 字段单独放到 users_bio 表中:
CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    email VARCHAR(100)
);

CREATE TABLE users_bio (
    id INT PRIMARY KEY,
    bio TEXT,
    FOREIGN KEY (id) REFERENCES users(id)
);
  1. 减少冗余字段:虽然适当的冗余可以提高查询性能,但过多的冗余会导致数据一致性问题,并且可能影响索引的使用。例如,在 orders 表中有 customer_id,又在 orders 表中冗余存储了 customer_name。如果 customer_name 经常需要更新,就会导致数据一致性问题。而且,这种冗余可能会使索引的维护成本增加,在某些情况下影响查询性能。应尽量通过 JOIN 操作从相关表中获取数据,而不是在多个表中重复存储。

数据类型优化

  1. 选择合适的数据类型:使用合适的数据类型可以减少存储空间,提高查询性能。例如,对于表示性别(只有两种取值)的字段,使用 ENUM 类型比使用 VARCHAR 类型更节省空间。假设在 users 表中有 gender 字段:
CREATE TABLE users (
    id INT,
    gender ENUM('male', 'female')
);
  • 对于数值类型,要根据实际取值范围选择合适的类型。例如,如果一个字段的值范围在 0 - 255 之间,使用 TINYINT 比使用 INT 更节省空间,同时也能提高查询性能,因为在比较和索引操作时,较小的数据类型处理起来更快。
  1. 避免使用 TEXTBLOB 类型TEXTBLOB 类型存储的数据量较大,并且在索引和查询时性能较差。如果必须使用,应尽量将其放在单独的表中,通过主键关联。例如,在 products 表中,如果有产品描述(可能是大文本)字段,应考虑将其单独放到 product_descriptions 表中:
CREATE TABLE products (
    id INT PRIMARY KEY,
    product_name VARCHAR(100)
);

CREATE TABLE product_descriptions (
    id INT PRIMARY KEY,
    description TEXT,
    FOREIGN KEY (id) REFERENCES products(id)
);

这样在查询产品基本信息时,就不会因为大文本字段而影响性能。

分析和监控查询

使用 EXPLAIN 关键字

  1. EXPLAIN 的基本用法EXPLAIN 关键字用于分析查询语句的执行计划,帮助我们了解 MySQL 如何执行查询,是否使用了索引,是否进行了全表扫描等。例如,对于查询 SELECT * FROM products WHERE product_name = 'Widget';,使用 EXPLAIN 可以这样写:
EXPLAIN SELECT * FROM products WHERE product_name = 'Widget';
  • EXPLAIN 输出结果分析
    • id:表示查询中 SELECT 子句的编号,在复杂查询中有多个 SELECT 时,通过 id 可以确定执行顺序。
    • select_type:常见的类型有 SIMPLE(简单查询,不包含子查询或 UNION)、PRIMARY(最外层的 SELECT)、SUBQUERY(子查询中的 SELECT)等。
    • table:表示查询涉及的表。
    • partitions:如果表进行了分区,这里会显示查询涉及的分区。
    • type:表示连接类型,常见的有 ALL(全表扫描)、index(索引扫描)、range(范围扫描)、ref(使用非唯一索引进行连接)、eq_ref(使用唯一索引进行连接)等。ALL 类型表示全表扫描,我们要尽量避免;indexrange 等类型表示使用了索引,性能相对较好。
    • possible_keys:显示可能使用的索引。
    • key:实际使用的索引,如果为 NULL,表示没有使用索引。
    • key_len:表示使用的索引长度,长度越短,性能越好。
    • ref:表示哪些列或常量与索引进行比较。
    • rows:估计需要扫描的行数,行数越少,性能越好。
    • filtered:表示存储引擎返回的数据在经过过滤条件后,满足条件的数据所占的百分比。
  1. 根据 EXPLAIN 结果优化查询:如果 EXPLAIN 结果显示 typeALLkeyNULL,说明进行了全表扫描,需要根据上述优化查询语句和数据库结构的方法进行调整。例如,如果发现某个查询没有使用索引,可以检查是否缺少索引,或者索引是否失效。如果是索引失效问题,如在索引列上使用了函数,就需要改写查询语句,避免在索引列上使用函数,重新使用 EXPLAIN 分析,直到得到满意的执行计划。

监控数据库性能

  1. 使用 SHOW STATUSSHOW STATUS 命令可以获取 MySQL 服务器的各种状态信息,包括查询相关的统计信息。例如,可以使用以下命令查看当前数据库的查询次数、全表扫描次数等:
SHOW STATUS LIKE 'Com_select'; -- 查询执行的 `SELECT` 语句次数
SHOW STATUS LIKE 'Handler_read%'; -- 各种 `Handler` 相关的读取状态,如 `Handler_read_rnd` 表示全表扫描次数

通过监控这些指标,可以了解数据库的整体查询性能以及全表扫描发生的频率。如果发现全表扫描次数过高,就需要进一步分析并优化查询。 2. 使用 SHOW PROFILESHOW PROFILE 可以提供查询执行的详细性能分析,包括查询执行过程中每个阶段的时间消耗等。首先需要开启 profiling

SET profiling = 1;

然后执行查询,例如:

SELECT * FROM orders WHERE order_amount > 1000;

最后使用 SHOW PROFILE 查看性能分析:

SHOW PROFILE FOR QUERY <query_id>;

其中 <query_id> 可以通过 SHOW PROFILES 命令获取。SHOW PROFILE 的输出结果会显示查询执行过程中的各个阶段,如 Sending data(表示从存储引擎获取数据并发送给客户端的阶段)等,以及每个阶段的时间消耗。通过分析这些信息,可以定位查询性能瓶颈,进一步优化查询。

配置和环境优化

MySQL 配置参数调整

  1. 调整缓冲池大小:缓冲池(InnoDB Buffer Pool)是 InnoDB 存储引擎用于缓存数据和索引的内存区域。适当增大缓冲池大小可以提高数据和索引的读取性能,减少磁盘 I/O。可以通过修改 my.cnf 文件中的 innodb_buffer_pool_size 参数来调整缓冲池大小。例如,如果服务器有足够的内存,可以将其设置为物理内存的 60% - 80%:
[mysqld]
innodb_buffer_pool_size = 8G
  • 调整后需要重启 MySQL 服务使配置生效。合适的缓冲池大小可以使更多的数据和索引驻留在内存中,从而减少全表扫描时的磁盘 I/O 操作,提高查询性能。
  1. 调整查询缓存:查询缓存(query_cache_typequery_cache_size)可以缓存查询结果,当相同的查询再次执行时,直接从缓存中获取结果,而不需要重新执行查询。可以通过修改 my.cnf 文件中的相关参数来配置查询缓存:
[mysqld]
query_cache_type = 1
query_cache_size = 64M
  • 然而,需要注意的是,查询缓存有一定的局限性。如果表经常更新,查询缓存可能会频繁失效,反而影响性能。因此,在使用查询缓存时,要根据数据库的读写特性进行合理配置。
  1. 优化 sort_buffer_sizesort_buffer_size 是 MySQL 用于排序操作的缓冲区大小。当查询需要进行排序时,如 ORDER BY 操作,如果排序数据量较大,适当增大 sort_buffer_size 可以提高排序性能。可以在 my.cnf 文件中进行如下设置:
[mysqld]
sort_buffer_size = 2M
  • 但也不要盲目增大该参数,因为它会消耗更多的内存。要根据实际查询中排序数据量的大小进行合理调整。

服务器硬件优化

  1. 增加内存:足够的内存可以使更多的数据和索引缓存到内存中,减少磁盘 I/O。如前面提到的缓冲池,如果服务器内存充足,可以增大缓冲池大小,从而提高查询性能。对于经常进行全表扫描的数据库,增加内存可以有效减少磁盘 I/O 带来的性能瓶颈。例如,将服务器内存从 16GB 升级到 32GB,可能会显著提高数据库的整体性能。
  2. 使用高速存储设备:传统的机械硬盘在 I/O 性能上相对较低,使用固态硬盘(SSD)可以大幅提高磁盘 I/O 速度。特别是在进行全表扫描时,SSD 的随机读写性能优势明显,可以加快数据的读取速度。如果条件允许,将数据库存储设备更换为 SSD,能够有效提升查询性能。此外,使用 RAID 阵列(如 RAID 10)可以在保证数据安全性的同时,提高 I/O 性能。
  3. 优化网络配置:如果数据库服务器与应用服务器不在同一台机器上,网络性能也会影响查询响应时间。确保网络带宽充足,减少网络延迟。可以通过优化网络拓扑结构、使用高速网络设备等方式来提高网络性能。例如,将网络从千兆升级到万兆,能够有效减少数据传输时间,特别是在处理大量数据查询时,对整体性能有积极影响。

存储引擎选择

了解不同存储引擎特点

  1. InnoDB:InnoDB 是 MySQL 默认的存储引擎,支持事务、行级锁和外键。它在处理大量并发写入和读取操作时表现良好,适用于大多数应用场景。InnoDB 将数据和索引存储在一个表空间中,其缓冲池机制可以有效缓存数据和索引,提高查询性能。例如,在一个电商订单系统中,需要保证订单数据的事务完整性,并且有大量的并发读写操作,InnoDB 就是一个合适的选择。
  2. MyISAM:MyISAM 存储引擎不支持事务和行级锁,只支持表级锁。它在读取性能上表现较好,但写入性能相对较差,特别是在高并发写入场景下。MyISAM 将数据和索引分别存储在不同的文件中。例如,在一些日志记录系统中,主要以读取操作为主,对事务和并发写入要求不高,MyISAM 可能是一个可选的存储引擎。
  3. Memory:Memory 存储引擎将数据存储在内存中,读写速度非常快,但数据在服务器重启后会丢失。它适用于一些临时数据存储或对数据持久性要求不高,且需要快速读写的场景。例如,在缓存一些短期的统计数据时,可以使用 Memory 存储引擎。

根据业务需求选择存储引擎

  1. 事务性应用:如果应用对事务要求严格,如银行转账、电商订单处理等场景,InnoDB 是首选存储引擎。因为它能够保证数据的一致性和完整性,通过事务机制可以确保一系列操作要么全部成功,要么全部失败。例如,在银行转账操作中,从一个账户扣款和向另一个账户加款必须作为一个原子操作,InnoDB 可以很好地满足这种需求。
  2. 读密集型应用:对于读密集型应用,如新闻网站、博客平台等,如果对事务要求不高,可以考虑 MyISAM 或 InnoDB。MyISAM 在简单查询和读取性能上有一定优势,但如果存在并发写入操作,InnoDB 可能更合适,因为它的行级锁可以减少锁争用,提高并发性能。例如,在一个新闻网站中,大量用户同时访问新闻内容,以读取操作为主,偶尔有管理员发布新文章(写入操作),此时 InnoDB 可以在保证读取性能的同时,较好地处理并发写入。
  3. 临时数据存储:当需要存储临时数据,如缓存短期统计信息、临时计算结果等,可以使用 Memory 存储引擎。它的快速读写特性可以满足临时数据处理的需求。例如,在一个实时统计系统中,需要快速获取当前在线用户数量等临时数据,Memory 存储引擎可以快速响应查询。但要注意,由于数据存储在内存中,服务器重启后数据会丢失,所以要根据实际情况进行数据备份或持久化处理。

通过以上从查询语句优化、数据库结构优化、分析监控查询、配置和环境优化以及存储引擎选择等多个方面的深入探讨和实践,可以有效地避免 MySQL 查询中的全表扫描,提高数据库的查询性能,从而提升整个应用系统的性能和用户体验。在实际应用中,需要根据具体的业务需求和数据库特点,灵活运用这些技巧和方法,不断优化数据库的性能。