MySQL SELECT 与 WHERE 优化实战:可搜索条件、联合索引与执行计划

个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <

文章目录

  • [MySQL SELECT 与 WHERE 优化实战:可搜索条件、联合索引与执行计划](#MySQL SELECT 与 WHERE 优化实战:可搜索条件、联合索引与执行计划)
    • 一、优化目标不是"强行走索引"
    • 二、准备实验表
    • [三、先测量,再改 SQL](#三、先测量,再改 SQL)
    • [四、SELECT 列表优化:少取列不是洁癖](#四、SELECT 列表优化:少取列不是洁癖)
      • [4.1 SELECT * 的四个实际成本](#4.1 SELECT * 的四个实际成本)
      • [4.2 LIMIT 不是万能加速器](#4.2 LIMIT 不是万能加速器)
    • [五、WHERE 优化核心:让条件可搜索](#五、WHERE 优化核心:让条件可搜索)
      • [5.1 不要对索引列做不可逆计算](#5.1 不要对索引列做不可逆计算)
      • [5.2 时间条件使用左闭右开](#5.2 时间条件使用左闭右开)
    • 六、联合索引:顺序取决于查询形态
      • [6.1 等值列通常在范围列之前](#6.1 等值列通常在范围列之前)
      • [6.2 范围列后的索引列并非完全没用](#6.2 范围列后的索引列并非完全没用)
    • 七、隐式类型转换:经常被忽略的性能坑
    • [八、LIKE、IN、OR、NULL 与不等号的真相](#八、LIKE、IN、OR、NULL 与不等号的真相)
      • [8.1 LIKE 看前缀是否确定](#8.1 LIKE 看前缀是否确定)
      • [8.2 IN 不必机械改成 BETWEEN](#8.2 IN 不必机械改成 BETWEEN)
      • [8.3 OR 可能使用 index merge](#8.3 OR 可能使用 index merge)
      • [8.4 IS NULL 可以使用索引](#8.4 IS NULL 可以使用索引)
      • [8.5 !=、<>、NOT IN 不是语法性失效](#8.5 !=、<>、NOT IN 不是语法性失效)
    • 九、覆盖索引与回表
    • 十、一次完整优化示例
    • [十一、INSERT INTO ... SELECT 的额外注意点](#十一、INSERT INTO ... SELECT 的额外注意点)
    • 十二、常见错误方法
    • 十三、上线前检查清单
    • 十四、小结

MySQL SELECT 与 WHERE 优化实战:可搜索条件、联合索引与执行计划

SQL 优化最危险的方法,是背一份"看到某个关键字就判定索引失效"的清单。

IN、OR、IS NULL、!= 都不必然导致全表扫描;key 不为空也不代表查询一定快。

真正有效的方法是:减少读取、让谓词可搜索、设计匹配查询形态的索引,再用实际执行验证。

一、优化目标不是"强行走索引"

一条查询的总成本,大致来自以下环节:

成本来源 常见表现 优化方向
访问数据页 扫描行数大、随机 I/O 多 缩小扫描范围
回表 二级索引命中很多,再读聚簇索引 覆盖索引或提高选择性
CPU 判断 对大量候选行计算函数、表达式 改写可搜索条件
排序与临时表 Using filesort、Using temporary 匹配排序/分组索引
网络传输 返回宽行、大结果集 精确列清单、合理分页
锁与事务 查询嵌入长事务 缩短事务、分批处理
优化目标不是让 EXPLAIN.key 必须有值,而是降低端到端资源消耗和响应时间。
如果条件会命中表中 60% 的行,顺序全表扫描可能比大量随机回表更便宜。

二、准备实验表

sql 复制代码
CREATE DATABASE IF NOT EXISTS select_opt_demo
  DEFAULT CHARACTER SET utf8mb4;
USE select_opt_demo;
CREATE TABLE orders (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    tenant_id BIGINT UNSIGNED NOT NULL,
    customer_id BIGINT UNSIGNED NOT NULL,
    status TINYINT UNSIGNED NOT NULL,
    order_no VARCHAR(32) NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    note VARCHAR(500) NULL,
    created_at DATETIME(6) NOT NULL,
    updated_at DATETIME(6) NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_tenant_order (tenant_id, order_no),
    KEY idx_tenant_status_time (tenant_id, status, created_at, id),
    KEY idx_customer_time (customer_id, created_at, id)
) ENGINE = InnoDB;

后文围绕一个常见接口展开:

sql 复制代码
SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 1001
  AND status = 1
  AND created_at >= '2026-09-01'
  AND created_at <  '2026-10-01'
ORDER BY created_at DESC, id DESC
LIMIT 20;

这个查询同时包含:多租户隔离、等值条件、范围条件、排序与分页,是联合索引设计的典型场景。

三、先测量,再改 SQL

不要从猜测开始,先拿到基线。

sql 复制代码
EXPLAIN FORMAT=JSON
SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 1001
  AND status = 1
  AND created_at >= '2026-09-01'
  AND created_at <  '2026-10-01'
ORDER BY created_at DESC, id DESC
LIMIT 20;

MySQL 8.0.18+ 可观察真实执行:

sql 复制代码
EXPLAIN ANALYZE
SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 1001
  AND status = 1
  AND created_at >= '2026-09-01'
  AND created_at <  '2026-10-01'
ORDER BY created_at DESC, id DESC
LIMIT 20;
指标 重点看什么
type const/ref/range 通常比 ALL 缩小得更多,但不是绝对排名
key 实际选择的索引
key_len 用到了联合索引的哪些部分
rows 预计读取多少行
filtered 读取后预计保留比例
actual rows 实际产生多少行,检查估算偏差
loops 内层节点被重复执行多少次
Extra 覆盖索引、索引条件下推、排序等线索
EXPLAIN ANALYZE 会真正执行查询,生产环境要先评估扫描量。

四、SELECT 列表优化:少取列不是洁癖

4.1 SELECT * 的四个实际成本

sql 复制代码
SELECT *
FROM orders
WHERE tenant_id = 1001 AND id = 90001;

即使只返回一行,SELECT * 也有潜在问题:

  1. 读取和传输不需要的 note 等宽列;
  2. 更难形成覆盖索引,增加回表;
  3. 表新增列后,接口数据量会无感增长;
  4. 依赖列顺序的映射代码更脆弱。
    推荐明确列清单:
sql 复制代码
SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 1001 AND id = 90001;

但不要走向另一个极端:为了覆盖查询,把十几个大字段全塞进索引。

二级索引越宽,占用页越多,缓存命中率越低,写入维护成本也越高。

4.2 LIMIT 不是万能加速器

sql 复制代码
SELECT id
FROM orders
WHERE note LIKE '%退款%'
ORDER BY amount DESC
LIMIT 20;

如果没有合适访问路径,MySQL 仍可能扫描大量行、计算匹配并排序,最后才取 20 行。

LIMIT 能否提前停止,取决于访问顺序是否与过滤、排序相匹配。

五、WHERE 优化核心:让条件可搜索

SARGable 可理解为"能够转化为索引搜索范围"。

关键不是函数出现在 SQL 的哪一侧,而是能否用索引中的原始有序值定位边界。

5.1 不要对索引列做不可逆计算

sql 复制代码
-- 不推荐
SELECT id
FROM orders
WHERE DATE(created_at) = '2026-09-15';

普通 created_at 索引按完整时间排序,DATE(created_at) 的结果并未直接存于其中。

改写为范围:

sql 复制代码
SELECT id
FROM orders
WHERE created_at >= '2026-09-15'
  AND created_at <  '2026-09-16';

算术条件同理:

sql 复制代码
-- 不推荐
WHERE amount * 100 >= 50000
-- 推荐
WHERE amount >= 500.00

只有在业务长期大量按同一个表达式搜索时,才考虑 MySQL 8.0 函数索引或生成列。

sql 复制代码
-- MySQL 8.0.13+ 函数索引
CREATE INDEX idx_created_date
ON orders ((DATE(created_at)));

MySQL 5.7 可用生成列加普通索引:

sql 复制代码
ALTER TABLE orders
  ADD COLUMN created_date DATE
    GENERATED ALWAYS AS (DATE(created_at)) STORED,
  ADD INDEX idx_created_date (created_date);

函数索引不是"免改 SQL 的魔法",它增加存储与写入维护成本,应由稳定查询模式驱动。

5.2 时间条件使用左闭右开

sql 复制代码
WHERE created_at >= '2026-09-01'
  AND created_at <  '2026-10-01'

不要写:

sql 复制代码
WHERE created_at BETWEEN '2026-09-01 00:00:00'
                     AND '2026-09-30 23:59:59';

DATETIME(6) 可保存微秒,第二种写法会漏掉最后一秒中的微秒数据。

左闭右开还能自然处理月份天数和跨年边界。

六、联合索引:顺序取决于查询形态

示例索引:

sql 复制代码
KEY idx_tenant_status_time (tenant_id, status, created_at, id)

它可以高效支持:

sql 复制代码
WHERE tenant_id = ?
  AND status = ?
  AND created_at >= ?
  AND created_at < ?
ORDER BY created_at DESC, id DESC

6.1 等值列通常在范围列之前

B+Tree 按 (tenant_id, status, created_at, id) 的字典序排列。

前两列等值固定后,同一租户、同一状态的数据会按时间连续排列,便于做范围扫描。

查询条件 可形成的连续索引范围
tenant_id = 1001 第一列范围
再加 status = 1 前两列范围更精确
再加时间区间 在固定前缀内扫描连续时间段
再按时间、id 排序 有机会直接利用索引顺序
"选择性最高的列永远放最左"并不准确。
索引列序还要考虑租户隔离、等值/范围组合、排序、分组、覆盖和复用频率。

6.2 范围列后的索引列并非完全没用

常见口诀说"遇到范围后全部失效",这过于粗糙。

范围后的列通常不能继续缩小 B+Tree 的起止边界,但可能通过 Index Condition Pushdown 在存储引擎层过滤,也可能用于覆盖查询。

sql 复制代码
CREATE INDEX idx_tenant_time_status
ON orders (tenant_id, created_at, status);
sql 复制代码
WHERE tenant_id = 1001
  AND created_at >= '2026-09-01'
  AND status = 1

这里 status 往往不能继续确定时间范围边界,但不等于它毫无作用。

判断依据应是执行计划与实际读取行数,而不是一句"失效"。

七、隐式类型转换:经常被忽略的性能坑

假设 order_no 是 VARCHAR(32):

sql 复制代码
-- 风险写法:数字常量可能触发类型转换
WHERE order_no = 202600001
-- 类型一致
WHERE order_no = '202600001'

字符串列与数字比较时,MySQL 可能把列值转换为数字进行比较,破坏原有字符串索引的搜索方式,还可能产生意外匹配。

连接条件也必须检查类型:

sql 复制代码
-- 两列最好在类型、符号属性、字符集和排序规则上兼容
ON child.tenant_id = parent.tenant_id
不一致项 可能后果
BIGINT 对 VARCHAR 隐式数值/字符串转换
INT UNSIGNED 对 INT 范围语义不同,可能增加转换
不同字符集 字符集转换与索引受限
不同 collation 比较规则变化或转换成本
参数化查询本身不会导致索引失效。
真正需要保证的是绑定参数类型与列类型一致,并基于实际值分布验证计划。

八、LIKE、IN、OR、NULL 与不等号的真相

8.1 LIKE 看前缀是否确定

sql 复制代码
-- 可形成前缀范围,是否选索引仍由成本决定
WHERE order_no LIKE '202609%'
-- 普通 B+Tree 无法从开头定位
WHERE order_no LIKE '%0901%'

包含搜索可考虑全文索引、专用搜索引擎或业务生成的可检索字段,不能简单改成错误的前缀匹配。

8.2 IN 不必机械改成 BETWEEN

sql 复制代码
WHERE status IN (1, 3, 7)

它表示三个离散值;下面语句却包含 2、4、5、6,语义不同:

sql 复制代码
WHERE status BETWEEN 1 AND 7

MySQL 可以把较短的 IN 列表转为多个范围。列表极长时,应考虑临时表或业务表连接,但仍要测量。

8.3 OR 可能使用 index merge

sql 复制代码
WHERE customer_id = 10 OR order_no = 'A202609001'

如果两个分支都有合适索引,优化器可能使用 index_merge,也可能选择其他计划。

只有一个分支完全不可索引时,整体计划更容易退化。

只有在等价且实测更优时才改写 UNION ALL,并注意重复行:

sql 复制代码
SELECT id FROM orders WHERE customer_id = 10
UNION ALL
SELECT id FROM orders
WHERE order_no = 'A202609001' AND customer_id <> 10;

8.4 IS NULL 可以使用索引

sql 复制代码
WHERE note IS NULL

MySQL B+Tree 可以存储并查找 NULL。是否使用索引取决于 NULL 占比、覆盖情况和回表成本。

不要为了所谓"索引失效"把未知值强行改成空字符串或 0,这会污染业务语义。

8.5 !=、<>、NOT IN 不是语法性失效

不等条件通常命中范围较大,优化器可能认为全表扫描更便宜;若排除值覆盖了绝大多数行,索引也可能有价值。

NOT IN 还有 NULL 语义陷阱:

sql 复制代码
SELECT 1 WHERE 1 NOT IN (2, NULL);

结果不是 TRUE,而是 UNKNOWN。反关联通常更适合 NOT EXISTS:

sql 复制代码
SELECT c.id
FROM customer AS c
WHERE NOT EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.id
);

九、覆盖索引与回表

InnoDB 二级索引叶子节点保存索引列和主键值。

查询需要额外列时,通常先命中二级索引,再按主键读取聚簇索引,这就是回表。

sql 复制代码
SELECT id, created_at
FROM orders
WHERE tenant_id = 1001 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;

idx_tenant_status_time 已包含所有所需值,有机会显示 Using index,即覆盖查询。

但如果再取 note:

sql 复制代码
SELECT id, created_at, note
FROM orders
WHERE tenant_id = 1001 AND status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;

通常需要回表。是否把 note 放进索引?大多数情况下不应该,因为它宽、更新成本高。

一个常用策略是先用窄索引定位少量主键,再回表取详情。

十、一次完整优化示例

问题 SQL:

sql 复制代码
SELECT *
FROM orders
WHERE tenant_id = '1001'
  AND DATE(created_at) = '2026-09-15'
  AND status IN (1, 2)
ORDER BY created_at DESC
LIMIT 20;

主要问题:

问题 影响
SELECT * 宽行传输,难以覆盖
DATE(created_at) 普通时间索引难以形成范围
租户参数类型不明确 应与 BIGINT UNSIGNED 一致绑定
排序缺少唯一键 同时间值下顺序不稳定
改写后:
sql 复制代码
SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 1001
  AND status IN (1, 2)
  AND created_at >= '2026-09-15'
  AND created_at <  '2026-09-16'
ORDER BY created_at DESC, id DESC
LIMIT 20;

候选索引不是凭感觉直接上线,而是结合数据验证:

sql 复制代码
CREATE INDEX idx_tenant_status_time
ON orders (tenant_id, status, created_at, id);

这里有一个细节:status IN (1, 2) 相当于多个状态范围,跨状态全局按时间排序时,未必能完全避免排序。

如果接口长期查询单一状态,这个索引更匹配;若总是跨多个状态,则要根据过滤率与排序成本权衡列序。

建议对比:

sql 复制代码
EXPLAIN ANALYZE SELECT ...;

重点比较优化前后的实际扫描行数、回表量、排序行数和总耗时,而不是只看 key 名称。

十一、INSERT INTO ... SELECT 的额外注意点

sql 复制代码
INSERT INTO order_archive (id, tenant_id, amount, created_at)
SELECT id, tenant_id, amount, created_at
FROM orders
WHERE created_at < '2025-01-01';

它既是查询,也是批量写入。优化 SELECT 部分只能解决一半问题,还要关注:

  • 单事务过大导致 undo、redo、binlog 膨胀;
  • 目标表索引维护和唯一键检查;
  • 复制延迟与磁盘写入峰值;
  • 失败回滚成本和锁持有时间;
  • 批次边界是否稳定、是否可重试。
    推荐按主键或时间键分批,并记录水位,而不是使用不断增大的 OFFSET。
    不要仅为减少锁就随意修改全局隔离级别;一致性要求必须先于性能技巧。

十二、常见错误方法

错误方法 为什么不可靠
看到全表扫描就强制索引 全表扫描有时就是最低成本方案
认为 IN/OR/IS NULL/!= 必然失效 是否用索引取决于范围、分布和成本
所有条件列都建单列索引 不一定匹配组合过滤与排序,还增加写成本
把所有返回列塞进覆盖索引 索引过宽,缓存和写入代价上升
只看 possible_keys 它只是候选,非实际使用
只看耗时一次 缓存、并发、参数值都会影响结果
用 FORCE INDEX 当长期方案 数据分布变化后可能固定住坏计划
关闭严格模式换取"性能" 掩盖数据和 SQL 语义问题

十三、上线前检查清单

  1. 返回列是否都是接口真正需要的?
  2. 条件是否保持列的原始可搜索形式?
  3. 参数类型是否与列类型一致?
  4. 联合索引是否匹配等值、范围、排序的组合?
  5. 估算扫描行数与实际行数是否接近?
  6. 是否发生大量回表、排序或临时表?
  7. LIMIT 前是否仍处理了大量数据?
  8. 新索引会给写入和 Buffer Pool 带来多少成本?
  9. 不同租户、状态、日期参数是否产生明显不同计划?
  10. 是否在接近生产的数据量与分布上压测?

十四、小结

  • SELECT/WHERE 优化的核心是减少读取行数、回表、排序、计算与传输,不是强行走索引。
  • 明确列清单能降低宽行成本,也更容易形成覆盖查询。
  • 对索引列套函数或做计算时,优先改写为原列上的等值或范围条件。
  • 时间查询推荐左闭右开,既利于索引,又避免微秒边界错误。
  • 联合索引要同时考虑等值、范围、排序、覆盖和业务隔离,不能只看单列选择性。
  • 范围后的索引列不一定完全无效,它仍可能参与索引条件下推或覆盖。
  • IN、OR、IS NULL、!= 都不是语法层面的索引失效开关。
  • 字符串列与数字参数混用、连接列类型不一致,是常见的隐式转换风险。
  • 覆盖索引有价值,但不能为了零回表无限扩宽索引。
  • 最终结论必须通过 EXPLAIN ANALYZE、真实数据分布和稳定压测验证。
相关推荐
2501_931803751 小时前
MySQL 锁机制详解
数据库·mysql
Nturmoils2 小时前
SQL 编辑器不只是写查询:我用 KStudio 排查一次订单异常
数据库
IvorySQL2 小时前
PostgreSQL 日报|逻辑解码 DDL 子事务缺陷修复(10 月 12 日)
数据库·postgresql
这个DBA有点耶2 小时前
研发规模/投入最大的国产数据库厂商是谁?拆解五个衡量维度
数据库·程序员·产品
葡萄城技术团队3 小时前
数据库里的数据明明是对的,为什么 BI 报表还是错?
数据库
野生数据人3 小时前
日志平台每日巡检我压成了一条命令链:从 SHOW BACKENDS 到扩缩容的完整步骤
大数据·数据库
bmxy小明同学3 小时前
2026-10-09-token与成本
java·前端·数据库
路远的数据库笔记3 小时前
Oracle报ORA-00020连接打满?从专用进程模型到静态参数,一次生产排查全记录
数据库·oracle·国产数据库·ora-00020·dba实战·sharedserver
瀚高PG实验室3 小时前
瀚高数据库序列介绍、使用及值不连续、重复写入等现象处理
数据库·postgresql·瀚高数据库