个人主页: > 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 * 也有潜在问题:
- 读取和传输不需要的
note等宽列; - 更难形成覆盖索引,增加回表;
- 表新增列后,接口数据量会无感增长;
- 依赖列顺序的映射代码更脆弱。
推荐明确列清单:
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 语义问题 |
十三、上线前检查清单
- 返回列是否都是接口真正需要的?
- 条件是否保持列的原始可搜索形式?
- 参数类型是否与列类型一致?
- 联合索引是否匹配等值、范围、排序的组合?
- 估算扫描行数与实际行数是否接近?
- 是否发生大量回表、排序或临时表?
LIMIT前是否仍处理了大量数据?- 新索引会给写入和 Buffer Pool 带来多少成本?
- 不同租户、状态、日期参数是否产生明显不同计划?
- 是否在接近生产的数据量与分布上压测?
十四、小结
- SELECT/WHERE 优化的核心是减少读取行数、回表、排序、计算与传输,不是强行走索引。
- 明确列清单能降低宽行成本,也更容易形成覆盖查询。
- 对索引列套函数或做计算时,优先改写为原列上的等值或范围条件。
- 时间查询推荐左闭右开,既利于索引,又避免微秒边界错误。
- 联合索引要同时考虑等值、范围、排序、覆盖和业务隔离,不能只看单列选择性。
- 范围后的索引列不一定完全无效,它仍可能参与索引条件下推或覆盖。
IN、OR、IS NULL、!=都不是语法层面的索引失效开关。- 字符串列与数字参数混用、连接列类型不一致,是常见的隐式转换风险。
- 覆盖索引有价值,但不能为了零回表无限扩宽索引。
- 最终结论必须通过
EXPLAIN ANALYZE、真实数据分布和稳定压测验证。