网上流传的那篇《MySQL Hint 功能介绍》还在讲 FORCE INDEX、SQL_NO_CACHE、INSERT DELAYED,一套一套的。那是 5.x 时代的玩法:查询缓存 8.0 整个删了,INSERT DELAYED 被静默忽略,老式索引 hint 官方已声明将来弃用。这篇不验尸,只讲 8.0 真正该用的
/*+ ... */优化器 hint——跟 Oracle 长得一样,但脾气不一样。老式写法一张表带过,正文全是 8.0 的生产用法。
你手里那份 5.x 清单,现在啥状态
老式关键字 hint 是 SQL 语法的一部分,8.0 还认,但官方已经表态要逐步弃用,新代码直接写新式 /*+ */:
| 老式关键字 | 8.0 里的下场 | 新式等价物 |
|---|---|---|
FORCE INDEX / USE INDEX / IGNORE INDEX |
还能用,仅限 SELECT / UPDATE / 多表 DELETE(单表 DELETE 报 1064) | INDEX() / NO_INDEX(),8.0.20+,单表/多表 DELETE 都支持 |
FORCE INDEX FOR JOIN |
还能用 | JOIN_INDEX() / NO_JOIN_INDEX() |
FORCE INDEX FOR ORDER BY |
还能用 | ORDER_INDEX() / NO_ORDER_INDEX() |
FORCE INDEX FOR GROUP BY |
还能用 | GROUP_INDEX() / NO_GROUP_INDEX() |
STRAIGHT_JOIN |
还能用,只锁 FROM 顺序 | JOIN_FIXED_ORDER() / JOIN_ORDER() / JOIN_PREFIX() / JOIN_SUFFIX() |
SQL_CACHE |
语法错误(查询缓存已移除) | 应用层缓存(Redis) |
SQL_NO_CACHE |
不报错但无效果(已弃用) | 不需要写 |
HIGH_PRIORITY / LOW_PRIORITY |
语法还在,只对 MyISAM 有效 | 应用层排队 / 限流 |
INSERT DELAYED |
接受但忽略,等同普通 INSERT | 应用层消息队列 |
SQL_BUFFER_RESULT / SQL_BIG_RESULT / SQL_SMALL_RESULT |
还能用,InnoDB 下意义不大 | 基本不用 |
两条老式写法特有的坑,新式没有:
- 老式索引 hint 写错索引名直接报错 1176(
ERROR 1176 (42000): Key 'idx_wrong' doesn't exist in table 'orders'),Oracle 是拼错默默忽略,MySQL 相反; - 老式索引 hint 在单表 DELETE 上报 1064,只有多表 DELETE 语法(
DELETE t.* FROM t USE INDEX(...) ...)才支持;新式/*+ */索引 hint 8.0.20+ 单表/多表 DELETE 都支持。
下面的正文全部用新式 /*+ */。
大前提
加 hint 之前先确认两件事,跟 Oracle 一样:
- 统计信息是新的吗? MySQL 不会自动收集,数据量变化大的表要
ANALYZE TABLE dept, emp, orders;。统计信息过期导致的烂计划,加 hint 是治标不治本。数据倾斜严重时,8.0 的直方图(ANALYZE TABLE ... UPDATE HISTOGRAM ON ...)常常就能纠正优化器误判——优先直方图,再考虑 hint。 - 计划确实有问题吗? 用
EXPLAIN ANALYZE真跑一遍看实际行数,别只看EXPLAIN的预估。注意 ANALYZE 会真实完整执行语句,生产大表慎用,尤其别对 DML 批量跑。
还有一条 MySQL 特有的:/*+ */ 写错不报错,优化器当没看见。所以写完必须验证(见第五节),加完就收工等于没加。
场景速查卡:
| 你遇到的问题 | 用哪个 hint | 看执行计划关注什么 |
|---|---|---|
| 优化器不选你要的索引 | /*+ INDEX(o idx) */ |
key 列是否变成 idx |
| 某个索引是坑,想排除掉 | /*+ NO_INDEX(o idx) */ |
key 列是否换成别的索引 |
| 想强制全表扫描 | /*+ NO_INDEX(o) */(不带索引名) |
type 列变成 ALL |
| 复合索引最左列缺失,想用上它 | /*+ SKIP_SCAN(e idx) */ |
Extra 出现 Using index for skip scan |
| 连接顺序不对(大表被拉去当驱动表) | /*+ JOIN_PREFIX(t) */ / /*+ JOIN_ORDER(...) */ |
EXPLAIN 里表的先后顺序 |
| 想强制/禁止嵌套循环 | /*+ NLJ(t1,t2) */ / /*+ NO_NLJ(t1,t2) */ |
TREE 里 Nested loop 出现/消失 |
| 大表 JOIN 大表、想关掉 hash join 对比 | /*+ NO_BNL(t1,t2) */ |
TREE 里 Hash join 消失、回 NL |
| IN/EXISTS 子查询计划跑偏 | /*+ NO_SEMIJOIN(t) */ |
半连接消失,回普通子查询 |
| 派生表被 merge 后计划变差 | /*+ NO_MERGE(t) */ |
TREE 里出现 Materialize |
| 慢 SQL 想限时止损 | /*+ MAX_EXECUTION_TIME(1000) */ |
超时直接报错 3024 杀语句 |
| 只想给这条 SQL 临时调参数 | /*+ SET_VAR(join_buffer_size=16M) */ |
变量临时生效 |
| 报表 SQL 想限 CPU | /*+ RESOURCE_GROUP(rg_slow) */ |
线程被划到低优先级资源组 |
| 超大 IN 列表让 range 优化器抽风 | /*+ NO_RANGE_OPTIMIZATION(t) */ |
不再走 range 路径 |
测试环境
跟 Oracle 那篇保持同构方便对照,MySQL 8.0,数据用递归 CTE 造:
CREATE TABLE dept (
dept_no VARCHAR(10) PRIMARY KEY,
dept_name VARCHAR(50) NOT NULL,
location VARCHAR(30)
) ENGINE=InnoDB;
CREATE TABLE emp (
emp_no INT PRIMARY KEY,
emp_name VARCHAR(50) NOT NULL,
dept_no VARCHAR(10),
salary DECIMAL(10,2),
hire_date DATE,
status VARCHAR(10) DEFAULT 'ACTIVE',
KEY idx_emp_dept_sal (dept_no, salary),
KEY idx_emp_status (status), -- 选择性差的索引,NO_INDEX 案例用
KEY idx_emp_dept (dept_no)
) ENGINE=InnoDB;
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NOT NULL,
order_date DATE NOT NULL,
amount DECIMAL(12,2),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(10) DEFAULT 'COMPLETED',
KEY idx_order_ctime (create_time),
KEY idx_order_cust (customer_id, order_date),
KEY idx_order_status (status)
) ENGINE=InnoDB;
CREATE TABLE customer (
cust_id INT PRIMARY KEY,
cust_name VARCHAR(50) NOT NULL,
region VARCHAR(30)
) ENGINE=InnoDB;
CREATE TABLE order_detail (
detail_id INT PRIMARY KEY,
order_id INT NOT NULL,
product_name VARCHAR(100),
quantity INT,
KEY idx_detail_order (order_id)
) ENGINE=InnoDB;
-- 造数据:dept 50 行
INSERT INTO dept
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 50)
SELECT CONCAT('D', LPAD(n,2,'0')), CONCAT('Dept_', n),
ELT(MOD(n,3)+1, 'BEIJING', 'SHANGHAI', 'GUANGZHOU') FROM seq;
-- emp 1 万行,90% ACTIVE
INSERT INTO emp
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 10000)
SELECT n, CONCAT('Emp_', n), CONCAT('D', LPAD(MOD(n,50)+1,2,'0')),
5000 + MOD(n,50)*200,
DATE_SUB(CURDATE(), INTERVAL MOD(n,120) DAY),
IF(MOD(n,100)=0, 'LEAVE', 'ACTIVE') FROM seq;
-- orders 5 万行,90% COMPLETED
INSERT INTO orders
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 50000)
SELECT n, MOD(n,2000)+1,
DATE_SUB(CURDATE(), INTERVAL MOD(n,24) MONTH),
100 + MOD(n,500)*10,
TIMESTAMPADD(SECOND, -MOD(n,10000)*864, NOW()),
IF(MOD(n,10)=0, 'CANCELLED', 'COMPLETED') FROM seq;
-- customer 2000 行、order_detail 15 万行,写法同上,不贴了
ANALYZE TABLE dept, emp, customer, orders, order_detail;
看计划用哪招: EXPLAIN 看访问路径;EXPLAIN FORMAT=TREE(8.0.16+)看连接结构和子查询形状;EXPLAIN ANALYZE(8.0.18+)真跑一遍,输出实际耗时、实际行数、hash 表是否溢出磁盘——验证 hint 生效没它最靠谱。
一、访问路径:索引怎么走
1. /*+ INDEX(t idx) */ / /*+ NO_INDEX(t idx) */ — 索引的遥控器
新式索引 hint 是 8.0.20 加的,等价老式的 FORCE/IGNORE 家族,单表/多表 DELETE 都能用(老式单表 DELETE 不行),官方主推。先说两个最常用的。
场景一:组合索引没被选中。 按部门+薪水下限查员工,emp 上有 idx_emp_dept(单列)和 idx_emp_dept_sal(组合),优化器选了单列索引,还要回表过滤 salary:
-- 优化器可能选 idx_emp_dept(单列),回表再过滤 salary
EXPLAIN SELECT e.emp_no, e.emp_name FROM emp e
WHERE e.dept_no = 'D05' AND e.salary > 8000\G
-- 强制走组合索引,一次索引扫描搞定两个条件
EXPLAIN SELECT /*+ INDEX(e idx_emp_dept_sal) */ e.emp_no, e.emp_name FROM emp e
WHERE e.dept_no = 'D05' AND e.salary > 8000\G
id: 1
select_type: SIMPLE
table: e
type: range
possible_keys: idx_emp_dept,idx_emp_dept_sal
key: idx_emp_dept_sal <- hint 生效
key_len: 18
ref: NULL
rows: 200
Extra: Using index condition
场景二:某个索引是坑,排除它。 idx_emp_status 上 90% 是 ACTIVE,选择性极差。WHERE status='ACTIVE' AND dept_no='D05' 时优化器可能选 idx_emp_status 走 range 扫 9000 行再回表——明明用 dept_no 的索引只要 200 行:
-- 排除烂索引,让优化器在剩下里挑
EXPLAIN SELECT /*+ NO_INDEX(e idx_emp_status) */ e.emp_no, e.emp_name FROM emp e
WHERE e.status = 'ACTIVE' AND e.dept_no = 'D05'\G
id: 1
select_type: SIMPLE
table: e
type: ref
possible_keys: idx_emp_dept,idx_emp_dept_sal
key: idx_emp_dept <- 烂索引被排除,换了
ref: const
rows: 200
什么时候加: 优化器没选最优索引、或明知某个索引是坑想排除但不指定替代路径。
什么时候别加: 统计信息过期导致的误判,先 ANALYZE TABLE 再说;索引选择性真的差时,排除它不如改索引本身。
DELETE 场景: 官方文档写得很清楚:老式索引 hint 只适用于 SELECT / UPDATE / 多表 DELETE,单表 DELETE 直接报 1064——很多人以为 8.0 放开了,其实没有:
-- 老式写法:单表 DELETE 语法(DELETE FROM t ...)不支持索引 hint,报 1064
DELETE FROM orders IGNORE INDEX (idx_order_status) WHERE status = 'CANCELLED'; -- ERROR 1064
-- 老式写法:换成多表 DELETE 语法(DELETE t FROM t ...)就能用,哪怕只有一张表
DELETE o FROM orders o IGNORE INDEX (idx_order_status) WHERE o.status = 'CANCELLED';
-- 新式写法:8.0.20+ 单表/多表 DELETE 都支持
DELETE /*+ NO_INDEX(o idx_order_status) */ FROM orders o WHERE o.status = 'CANCELLED';
之前同事在单表 DELETE 上照抄老写法直接 1064,查了半天才明白:不是写法位置的问题,是老式索引 hint 压根不支持单表 DELETE——新式没这个限制。
变体: JOIN_INDEX / NO_JOIN_INDEX(只约束 JOIN 环节)、ORDER_INDEX / NO_ORDER_INDEX(只约束 ORDER BY)、GROUP_INDEX / NO_GROUP_INDEX(只约束 GROUP BY)。需要"排序走这个索引、但别影响 JOIN 走法"时分开写,作用域跟老式 FORCE INDEX FOR ORDER BY 一样。
2. 没有 FULL!MySQL 想强制全表扫怎么办
Oracle 转过来的第一个不适:MySQL 没有 FULL hint。想强制全表扫描,新式写法是 NO_INDEX(t) 不带索引名——禁止该表所有索引,等于全表扫:
-- NO_INDEX 不带索引名 = 忽略所有索引 = 全表扫
EXPLAIN SELECT /*+ NO_INDEX(o) */ COUNT(*), SUM(amount) FROM orders o
WHERE o.status = 'COMPLETED'\G
id: 1
select_type: SIMPLE
table: o
type: ALL
possible_keys: NULL
key: NULL
rows: 49500
两个坑:
NO_INDEX(t)不带索引名会禁掉所有索引,包括覆盖索引扫描:查询本来只读索引列(type: index、Extra: Using index),一加同样禁掉,type 被拉到 ALL。想排除一个烂索引却忘写索引名,等于把别的索引一起禁了——检查 type 列,发现变成 ALL 而你没打算全扫,就是写漏了。INDEX()/NO_INDEX()跟老式 FORCE/IGNORE 一样,不是绝对强制。对索引列用了函数(WHERE DATE(create_time)=...)、隐式类型转换、复合索引最左列不在条件里,hint 用不上,照样全表扫。加完必看 key 列。
反向也容易误解:INDEX(t) 不带索引名表示"允许使用该表的任意索引"(所有 scope),不是强制走索引扫描——Oracle 用户容易当成"强制全索引扫描"。
3. /*+ SKIP_SCAN(t idx) */ — 复合索引跳列(8.0.13+)
复合索引 (dept_no, salary),但 WHERE 里只有 salary 条件——最左列缺失,正常用不上这个索引。8.0.13 起优化器支持跳跃扫描:把 dept_no 的所有不同值各扫一遍 salary 区间,相当于"跳列"。
-- 不加:idx_emp_dept_sal 用不上(最左列缺失),全扫或走别的
EXPLAIN SELECT e.emp_no FROM emp e WHERE e.salary > 8000\G
-- 强制跳跃扫描
EXPLAIN SELECT /*+ SKIP_SCAN(e idx_emp_dept_sal) */ e.emp_no FROM emp e
WHERE e.salary > 8000\G
id: 1
select_type: SIMPLE
table: e
type: index
possible_keys: idx_emp_dept_sal
key: idx_emp_dept_sal
key_len: 13
ref: NULL
rows: 3333
Extra: Using index for skip scan <- 关键标记
什么时候加: 复合索引跳列场景((a,b) 索引只查 b)、且列选择性还行时。默认 skip_scan=on 优化器会自己判断,hint 用于强制或做 A/B 对比。
什么时候别加: 索引前缀列不同值太多(几千个),跳扫等于反复扫几千个区间,反而比全表慢。
SKIP_SCAN 是强制语义(文档原话 forces),但如果索引对语句不适用(比如前缀基数巨大),优化器照样忽略——加完看 Extra 里是否真的出现 Using index for skip scan。
二、连接顺序与方法:多表 JOIN 怎么连
4. /*+ JOIN_PREFIX(t) */ / /*+ JOIN_ORDER(t1,t2,t3) */ — 连接顺序
对应 Oracle 的 LEADING,但更精细:JOIN_PREFIX 只钉驱动表、JOIN_SUFFIX 只钉最后一张、JOIN_ORDER 指定完整顺序、JOIN_FIXED_ORDER 等价 STRAIGHT_JOIN(全部按 FROM 顺序)。
场景: 三表关联,统计信息过期时优化器把 orders 拉去当驱动表,全表扫 5 万行再一路过滤:
-- 不加:优化器拿 orders 当驱动表,先全表扫 5 万行
EXPLAIN SELECT e.emp_name, d.dept_name, o.amount
FROM emp e
JOIN dept d ON e.dept_no = d.dept_no
JOIN orders o ON o.customer_id = e.emp_no
WHERE e.dept_no = 'D05'\G
-- 加 JOIN_PREFIX:钉住 emp 做驱动表,小结果集驱动
EXPLAIN SELECT /*+ JOIN_PREFIX(e) */ e.emp_name, d.dept_name, o.amount
FROM emp e
JOIN dept d ON e.dept_no = d.dept_no
JOIN orders o ON o.customer_id = e.emp_no
WHERE e.dept_no = 'D05'\G
(示例用显式 JOIN 语法,比逗号连接更容易稳定复现连接顺序 hint 的效果。)
不加时第一行是 table: o, type: ALL, rows: 49500;加上后第一行变成 table: e, type: ref, key: idx_emp_dept, rows: 200,d 走主键 eq_ref,o 走 idx_order_cust 的 ref——顺序整个换过来了。
什么时候加: 你清楚哪张表过滤后行数最少、最优顺序是什么,优化器选反了。
什么时候别加: 不确定各表实际行数时。加错顺序比不加更慢——先用 EXPLAIN ANALYZE 看各表实际行数再钉。
⚠️ 线上高危: hint 是死的,数据是活的。之前接过一个库,同事统计信息半年没更新,STRAIGHT_JOIN 一把梭把 SQL 焊死,后来 orders 涨到千万级,当年指定的顺序反而让驱动表变大,比原来还慢。连接顺序 hint 适合线上救急,长期方案还是统计信息 + 索引本身。
5. /*+ NLJ(t1,t2) */ / /*+ NO_NLJ(t1,t2) */ — 嵌套循环开关
对应 Oracle 的 USE_NL / NO_USE_NL。注意它不是全局开关:只控制括号里指定的表对之间是否走嵌套循环,不指定连接顺序——通常要和 JOIN_PREFIX 搭配用。NL 适合"驱动表过滤后行数少 + 被驱动表有索引"的场景,比如小部门查员工:
-- 强制 NL:dept 过滤后约 17 行,emp 走 idx_emp_dept 逐行 probe
EXPLAIN FORMAT=TREE
SELECT /*+ JOIN_PREFIX(d) NLJ(d, e) */ d.dept_name, e.emp_name
FROM dept d JOIN emp e ON d.dept_no = e.dept_no
WHERE d.location = 'BEIJING';
-> Nested loop inner join (cost=19.5 rows=17)
-> Filter: (d.location = 'BEIJING') (cost=5.25 rows=17)
-> Table scan on d (cost=5.25 rows=50)
-> Index lookup on e using idx_emp_dept (dept_no=d.dept_no) (cost=0.85 rows=200)
什么时候加: 驱动表过滤后几十到几百行、被驱动表索引命中率高;交互查询要快速返回第一行。
什么时候别加: 驱动表行数多、或没有可用索引。每行 probe 一次被驱动表,10 万 × 单次 probe = 慢到窒息,这时候该走 hash join(见下)。
6. /*+ NO_BNL(t1,t2) */ — hash join 的开关(8.0.18+)
官方文档:8.0.18-8.0.19,BNL / NO_BNL 同时影响 BNL 和 hash join;8.0.20 正式移除 BNL 实现后,这两个 hint 名字保留,专门用于开启/禁用 hash join(文档原话:enabling and disabling hash joins)——NO_BNL 就是 hash join 的 off 开关,为升级兼容保留,别当成 bug。8.0.19 起 HASH_JOIN / NO_HASH_JOIN 这两个 hint 已不再生效,别用。
大表 JOIN 大表没索引,8.0.18+ 自动 hash join,一般不用管。生产常见操作是 NO_BNL 关掉它做对比实验:
-- orders 5 万 × order_detail 15 万全量聚合,等值连接,优化器自动 hash join
EXPLAIN FORMAT=TREE
SELECT o.order_id, SUM(d.quantity)
FROM orders o JOIN order_detail d ON o.order_id = d.order_id
GROUP BY o.order_id;
-- 加 NO_BNL:关掉 hash join,看回退 NL 是快是慢
EXPLAIN FORMAT=TREE
SELECT /*+ NO_BNL(o, d) */ o.order_id, SUM(d.quantity)
FROM orders o JOIN order_detail d ON o.order_id = d.order_id
GROUP BY o.order_id;
-- 不加:Hash join
-> Group aggregate: sum(d.quantity) (cost=30550 rows=50000)
-> Inner hash join (o.order_id = d.order_id) (cost=30550 rows=150000)
-> Table scan on o (cost=5050 rows=50000)
-> Table scan on d (cost=15050 rows=150000)
-- 加 NO_BNL:Nested loop
-> Group aggregate: sum(d.quantity) (cost=...)
-> Nested loop inner join (cost=...)
-> Table scan on d (cost=... rows=150000)
-> Single-row index lookup on o using PRIMARY (order_id=d.order_id)
注意 NO_BNL 后的 NL 是 order_detail 全扫 15 万行、逐行去 orders 主键 probe。如果 hash 表没溢出内存,通常 hash join 赢;但反过来,hash 表溢出到磁盘(EXPLAIN ANALYZE 显示 “rows spilled to disk”)时,NL 反而稳。这就是要做对比实验的原因。
什么时候加 NO_BNL: 大表 hash join 溢出磁盘、或怀疑 hash join 选错了(比如驱动表其实很小、索引命中很好,NL 更优)。
什么时候别加: 结果集要排序/聚合的大表连接,hash join 通常是正解;也别用它当"禁用新特性"的挡箭牌,先看 EXPLAIN ANALYZE 的实际耗时。
7. 没有 USE_MERGE
MySQL 没有排序合并连接(sort-merge join)hint。等值连接的合并排序场景优化器自己会处理,你只能选 NL 还是 hash join——比 Oracle 少一个维度,也算省心。
三、子查询与派生表:MySQL 优化器最爱搞事的地方
Oracle 篇没这块,但这是 MySQL 生产踩坑的重灾区:IN/EXISTS 子查询会被优化器"改写"成半连接(semi join),派生表默认被 merge 展开——改写后计划变差是日常。
8. /*+ NO_SEMIJOIN(t) */ — 把 IN 子查询打回原形
WHERE customer_id IN (SELECT ...) 默认被优化成半连接(semi join),策略有 Duplicate weedout / FirstMatch / LooseScan / Materialize 四种,优化器挑一种。挑错了,计划就烂——比如物化子查询时临时表没走索引。
场景: 查北方客户的订单,customer 过滤后 400 行,优化器物化子查询再半连接,绕远了:
-- 不加:IN 被改写成半连接(可能是 materialize 策略)
EXPLAIN FORMAT=TREE
SELECT o.order_id, o.amount FROM orders o
WHERE o.customer_id IN (SELECT cust_id FROM customer c WHERE c.region = 'NORTH');
-- 加 NO_SEMIJOIN:禁用半连接,回退成逐行子查询
EXPLAIN FORMAT=TREE
SELECT /*+ NO_SEMIJOIN(o) */ o.order_id, o.amount FROM orders o
WHERE o.customer_id IN (SELECT cust_id FROM customer c WHERE c.region = 'NORTH');
-- 不加:半连接(物化)
-> Nested loop inner join (cost=...)
-> Table scan on <subquery2> <- 物化的 customer 结果
-> Filter: (o.customer_id = <subquery2>.cust_id)
-> Table scan on o
-- 加 NO_SEMIJOIN:普通子查询,逐行执行
-> Filter: <in_optimizer>(o.customer_id,<exists>(<index_lookup>...))
-> Table scan on o (cost=... rows=50000)
什么时候加: 半连接策略选错导致临时表物化没索引、或 FirstMatch 退化成大范围扫描;EXPLAIN ANALYZE 确认半连接环节实际行数爆炸。
什么时候别加: 默认半连接通常比普通子查询好(能去重、能批处理)。别一看到 IN 就加,先看计划。
9. /*+ NO_MATERIALIZATION */ / /*+ SUBQUERY(strategy) */ — 更细的扳手
NO_MATERIALIZATION(t):只禁物化策略,让优化器在 FirstMatch / LooseScan / Duplicate weedout 里挑,比 NO_SEMIJOIN 温和;SUBQUERY(strategy):当子查询没被转成半连接时,指定它用物化还是 IN-to-EXISTS 逐行执行——strategy 只有两个合法值MATERIALIZATION和INTOEXISTS。注意它没有NO_版本,别找NO_SUBQUERY,不存在。- 反向:
MATERIALIZATION强制物化、SEMIJOIN(t1,t2)强制半连接——对照实验用。
-- 只禁物化:半连接还在,但改用 FirstMatch 之类
SELECT /*+ NO_MATERIALIZATION(o) */ ... FROM orders o
WHERE o.customer_id IN (SELECT cust_id FROM customer c WHERE c.region = 'NORTH');
-- 子查询不转半连接时,强制物化:先把 IN 右表算成临时表
SELECT o.order_id FROM orders o
WHERE o.customer_id IN (SELECT /*+ SUBQUERY(MATERIALIZATION) */ cust_id FROM customer c WHERE c.region = 'NORTH');
-- 或者强制逐行 IN-to-EXISTS:右表每行 probe 一次,适合右表极小且索引好
SELECT o.order_id FROM orders o
WHERE o.customer_id IN (SELECT /*+ SUBQUERY(INTOEXISTS) */ cust_id FROM customer c WHERE c.region = 'NORTH');
10. /*+ NO_MERGE(t) */ — 派生表别展开
8.0.22 起,不带聚合、不带 GROUP BY/DISTINCT/LIMIT 的派生表默认会被 merge——把派生表展开、外层条件下推,等价于直接写多表连接。大多数时候这是好事,但展开后优化器可能选错访问路径,物化反而更快(先算完小结果再连)。
场景: 报表里先过滤 emp 再关联 dept,派生表被 merge 后外层直接对 emp 全扫 + hash join:
-- 不加:派生表 t 被 merge 展开,等价于 emp JOIN dept 直接连
EXPLAIN FORMAT=TREE
SELECT d.dept_name, t.salary
FROM (SELECT emp_no, dept_no, salary FROM emp WHERE status = 'ACTIVE') t
JOIN dept d ON t.dept_no = d.dept_no
WHERE d.location = 'BEIJING';
-- 加 NO_MERGE:强制物化派生表,先算出 t 再连
EXPLAIN FORMAT=TREE
SELECT /*+ NO_MERGE(t) */ d.dept_name, t.salary
FROM (SELECT emp_no, dept_no, salary FROM emp WHERE status = 'ACTIVE') t
JOIN dept d ON t.dept_no = d.dept_no
WHERE d.location = 'BEIJING';
-- 不加:merge 展开,全扫 emp 再 hash join
-> Inner hash join (t.dept_no = d.dept_no) (cost=...)
-> Table scan on emp (cost=... rows=10000)
-> Hash
-> Filter: (d.location = 'BEIJING')
-> Table scan on d
-- 加 NO_MERGE:物化,TREE 里出现 Materialize
-> Nested loop inner join (cost=...)
-> Filter: (d.location = 'BEIJING')
-> Table scan on d
-> Index lookup on <materialized t> (dept_no=d.dept_no) <- 物化表带索引
注意参数: NO_MERGE(t) 的 t 必须是派生表 / 视图 / CTE 的别名,写基表名(如 NO_MERGE(emp))没有意义、静默失效——"合并"这个概念只对这类查询对象存在。
什么时候加: merge 后外层过滤无法下推、或 merge 导致索引选择变化(派生表内部明明能先缩小);怀疑点集中在派生表上时用它做 A/B。
什么时候别加: 派生表物化要额外写临时表,大多数 merge 场景是赚的。8.0.22 之前版本(默认物化)的存量 SQL 升级后计划变化,也先看 EXPLAIN ANALYZE 再决定要不要补 NO_MERGE。
四、资源与语句级:限时、改参、控 CPU
11. /*+ MAX_EXECUTION_TIME(1000) */ — 慢 SQL 保险丝
超过 1000ms 直接杀语句,报错 3024。适合给"不知道哪天会抽风"的报表 SQL 兜底。
SELECT /*+ MAX_EXECUTION_TIME(1000) */ * FROM orders WHERE amount > 10000;
⚠️ 线上高危: 它只能用在 SELECT 上,UPDATE / DELETE 不支持。而且
max_execution_time这个变量本身也只对 SELECT 生效,网上流传的"用SET_VAR(max_execution_time=...)给 DML 限时"同样无效——MySQL 的 DML 没有语句级执行超时,只能靠应用层驱动超时,或 pt-kill 之类轮询杀慢语句。
12. /*+ SET_VAR(...) */ — 单条 SQL 临时改参数
不改全局、不影响别的连接,生产上做 A/B 对比最常用:
-- 调连接缓冲
SELECT /*+ SET_VAR(join_buffer_size = 16M) */ * FROM orders o JOIN order_detail d ON o.order_id = d.order_id;
-- 临时关掉某个优化器特性做对比(比改全局 optimizer_switch 安全得多)
SELECT /*+ SET_VAR(optimizer_switch = 'hash_join=off') */ * FROM orders o JOIN order_detail d ON o.order_id = d.order_id;
能改的变量有白名单,不是啥都能塞进去(官方文档列了支持 SET_VAR 的变量清单),写错不报错但也不生效。一个 SET_VAR 只设一个变量,要设多个就写多个:SELECT /*+ SET_VAR(join_buffer_size=16M) SET_VAR(sort_buffer_size=8M) */ ...——不是在一个 SET_VAR 里用逗号分隔变量,那种写法不生效。
13. /*+ RESOURCE_GROUP(rg_name) */ — SQL 级限 CPU(8.0+)
资源组是 8.0 的管理员特性:建一个低优先级资源组,把大报表 SQL 划进去,白天跑报表不挤占核心交易的 CPU:
-- 建资源组(管理员操作,一次即可)
CREATE RESOURCE GROUP rg_report TYPE = USER VCPU = 0-1 THREAD_PRIORITY = 0;
-- 报表 SQL 指定进资源组
SELECT /*+ RESOURCE_GROUP(rg_report) */ c.region, COUNT(*), SUM(o.amount)
FROM orders o JOIN customer c ON o.customer_id = c.cust_id
GROUP BY c.region;
注意权限: 使用 RESOURCE_GROUP hint 需要 RESOURCE_GROUP_ADMIN 或 RESOURCE_GROUP_USER 权限(二选一)。普通账号没权限时加这个 hint 会被静默忽略、不报错——加完用 SHOW RESOURCE GROUP 看线程是不是真的进了目标资源组。
什么时候加: 大报表 / 批处理跑在 OLTP 实例上,想限 CPU 又不想动全局;比 Oracle 的 PARALLEL 维度不同——MySQL 没有并行 hint,资源组是它限资源的正道。
14. /*+ QB_NAME(...) */ — 给查询块起名(8.0.14+)
SQL 里多个子查询时,hint 默认只作用于自己所在的查询块。用 QB_NAME 给块命名,别的 hint 就能用 @块名 精确制导:
-- 主查询叫 main,子查询叫 sub
SELECT /*+ QB_NAME(main) */ o.order_id
FROM orders o
WHERE o.customer_id IN (
SELECT /*+ QB_NAME(sub) NO_MATERIALIZATION */ cust_id
FROM customer c WHERE c.region = 'NORTH'
);
-- 或者在外面引用子查询块:对 sub 块禁用半连接
SELECT /*+ QB_NAME(main) NO_SEMIJOIN(@sub) */ o.order_id
FROM orders o
WHERE o.customer_id IN (
SELECT /*+ QB_NAME(sub) */ cust_id FROM customer c WHERE c.region = 'NORTH'
);
调试多层子查询时配合 OPTIMIZER_TRACE 用(见第五节),能一眼看出 hint 打在哪个块上。
15. /*+ NO_RANGE_OPTIMIZATION(t) */ — 超大 IN 列表的逃生门
WHERE 里挂了几千个值的大 IN 列表(IN (1,2,3,...)),range 优化器逐个值评估区间,代价评估和内存开销都可能爆炸,甚至生成糟糕的 range 计划。8.0 起可以用 NO_RANGE_OPTIMIZATION 禁掉 range 优化,让它走别的路径:
SELECT /*+ NO_RANGE_OPTIMIZATION(o) */ * FROM orders o
WHERE o.order_id IN (1,2,3, ...几千个值...);
什么时候加: 大 IN 列表 + 走了 range 但 EXPLAIN ANALYZE 显示慢得反常;应用层生成的动态 IN 列表长度失控时。
什么时候别加: 正常量级的 IN 列表(几十到几百),range 就是最优解,别乱禁。根治方案是把大 IN 改造成临时表 join 或分批。
⚠️ 注意: 它只对指定表生效,其他表的 range 照走;禁掉 range 的同时也会禁用 Index Merge 和 Loose Index Scan,但不影响等值 ref 访问——优化器可能退化到 index scan 甚至全表扫,大 IN 列表场景下这可能更慢。别把它当通用解药,加完必看 EXPLAIN ANALYZE 的实际耗时。
五、验证:hint 到底生效没有
EXPLAIN ANALYZE — 真跑一遍(对应 Oracle 的 GATHER_PLAN_STATISTICS)
Oracle 用 GATHER_PLAN_STATISTICS 看 E-Rows vs A-Rows,MySQL 的对应物是 EXPLAIN ANALYZE——8.0.18+ 真执行一遍,输出每步实际耗时、实际行数、循环次数:
EXPLAIN ANALYZE
SELECT /*+ NO_INDEX(e idx_emp_status) */ e.emp_no, e.emp_name FROM emp e
WHERE e.status = 'ACTIVE' AND e.dept_no = 'D05';
-> Index lookup on e using idx_emp_dept (dept_no='D05') (actual time=0.221..0.381 rows=200 loops=1)
-> Filter: (e.status = 'ACTIVE') (actual time=0.222..0.381 rows=200 loops=1)
注意 output 里的 rows= 是实际行数(不是预估)。hint 加了没加、效果好不好,一跑见真章。EXPLAIN 是预估计划,EXPLAIN ANALYZE 是真实执行,生产诊断以 ANALYZE 为准。
OPTIMIZER_TRACE 的 hints_considered — 对应 Oracle 的 Hint Report
MySQL 没有 Hint Report,但 OPTIMIZER_TRACE 里能看到每个 hint 的最终裁决,applied: true/false 就是 Hint Report 的"hint used / hint not used":
SET optimizer_trace = 'enabled=on';
SELECT /*+ INDEX(o idx_order_status) */ order_id FROM orders o WHERE o.status = 'COMPLETED';
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
-- 在 JSON 输出的 hints_considered 里找这一项:
-- "hint": "INDEX", "table": "o", "index": "idx_order_status", "applied": true
-- applied: false 就是没生效,跟 Oracle 的 "hint not used" 一个意思
SET optimizer_trace = 'enabled=off';
两点提醒:开 trace 有轻微性能开销,生产别长开;applied: true 只代表 hint 被优化器接收,不代表最终计划一定遵从——条件不适用(索引不存在、数据分布不符、算法不可行)时,优化器可能接收后仍不采纳,最终还是以 EXPLAIN ANALYZE 的实际计划为准。
💡 进阶: 8.0 还有个不用改 SQL 的招——不可见索引。想验证"删掉这个索引会不会更优",别用 NO_INDEX 一把梭,直接
ALTER TABLE orders ALTER INDEX idx_order_status INVISIBLE;,优化器就当它不存在,跑两天监控看效果,不行再VISIBLE回来。不用发版、不用动 SQL,比任何 hint 都安全。注意:invisible 只是对优化器隐藏——老式FORCE INDEX和新式INDEX()hint 依然可以强制使用不可见索引。排查"删掉索引行不行"时,记得同时看有没有 SQL 用 hint 强指它,否则验证结果会失真。
六、想绑定执行计划?MySQL 没有 Oracle 的 SPM
Oracle 篇的铁律三说"12c 以后用 SPM 替代 HINT"——MySQL 用户别等这个:MySQL 社区版、企业版至今都没有 SPM / SQL Profile / outline 那一套计划管理机制(Percona Server 也没有,只有 MariaDB 有 Plan Outline)。hint 是 SQL 文本的一部分,想"绑计划"= 改 SQL,这是两边的设计哲学差异:Oracle 把计划管理做成数据库侧功能,MySQL 把控制权留给 SQL 本身 + 统计信息。
MySQL 想稳定执行计划,实际是三条路:
- 改 SQL 内嵌 hint——最直接,也是本文讲的。缺点是改 hint 就得发版;对已经跑在库里的 SQL,无法像 Oracle 那样事后"挂"一个 profile 上去。
- 不改 SQL——两条:不可见索引(8.0 最接近"软计划管理"的官方手段,上面提过);中间件 SQL 改写(ProxySQL 的 query rules 按 query digest 匹配、重写注入 hint),这其实是生产上的标准答案,还能按用户/IP 精细路由给不同计划。
- 不绑,把优化器喂饱——索引设计到"别无选择"、直方图、及时 ANALYZE。hint 是死的,数据是活的,长期最优解其实是这条。
顺带一个对比:隔壁 MariaDB 有 outline(mysql.outline 表 + PLAN OUTLINE 语法,按 SQL 指纹绑定执行计划),MySQL 官方一直没给——不是做不了,是没做。写 SQL 时别指望"以后用 SPM 收编",从第一天就把 hint 当临时方案管理好。
四条铁律
1. 新式 /*+ */ 写错 → 静默忽略;老式索引 hint 写错 → 报错 1176。 新式 hint 拼错表名、索引名、变量名都不报错,优化器当没看见。所以新式 hint 写完必须验证(第五节三件套)。
2. /*+ */ 里的表名必须和 SQL 里的别名一致。 给了别名就用别名,写表名直接失效;表没起别名时,就写原始表名:
SELECT /*+ INDEX(emp idx_emp_dept_sal) */ * FROM emp e WHERE e.dept_no='D05'; -- 无效!
SELECT /*+ INDEX(e idx_emp_dept_sal) */ * FROM emp e WHERE e.dept_no='D05'; -- 生效
(老式 FORCE INDEX 没这个烦恼,它贴着表名写。)
3. 没有 FULL,想全表扫用 NO_INDEX(t) 不带索引名;INDEX() 也不是绝对强制。 对索引列用函数、隐式类型转换、复合索引最左列缺失,hint 用不上照样全表扫。加完 EXPLAIN 看 key 列和 type 列。
4. 验证手段: EXPLAIN FORMAT=TREE 看连接结构、EXPLAIN ANALYZE 真跑看实际行数和耗时、OPTIMIZER_TRACE 的 hints_considered 看 applied 与否。三者配合,hint 有没有生效一目了然。
口诀速记
老清单一半进了土,新式
/*+ */才靠谱。
索引走偏用 INDEX,烂索引 NO_INDEX 排。
想全表扫没有 FULL,NO_INDEX 不带名。
驱动表错用 PREFIX,JOIN_ORDER 排完整。
NLJ 只控表对,HASH 靠 NO_BNL 关。
IN 子查询跑偏 NO_SEMIJOIN,派生表变慢 NO_MERGE。
保险丝用 MAX_TIME,DML 超时靠应用。
资源组限 CPU,QB_NAME 精确打。
写完必须验,ANALYZE 见真章。




