暂无图片
暂无图片
5
暂无图片
暂无图片
暂无图片

MySQL 8.0 的 SQL HINT 速查与踩坑

原创 lucky、糯米团子 5天前
109

网上流传的那篇《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 一样:

  1. 统计信息是新的吗? MySQL 不会自动收集,数据量变化大的表要 ANALYZE TABLE dept, emp, orders;。统计信息过期导致的烂计划,加 hint 是治标不治本。数据倾斜严重时,8.0 的直方图(ANALYZE TABLE ... UPDATE HISTOGRAM ON ...)常常就能纠正优化器误判——优先直方图,再考虑 hint。
  2. 计划确实有问题吗?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 只有两个合法值 MATERIALIZATIONINTOEXISTS。注意它没有 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_ADMINRESOURCE_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 想稳定执行计划,实际是三条路:

  1. 改 SQL 内嵌 hint——最直接,也是本文讲的。缺点是改 hint 就得发版;对已经跑在库里的 SQL,无法像 Oracle 那样事后"挂"一个 profile 上去。
  2. 不改 SQL——两条:不可见索引(8.0 最接近"软计划管理"的官方手段,上面提过);中间件 SQL 改写(ProxySQL 的 query rules 按 query digest 匹配、重写注入 hint),这其实是生产上的标准答案,还能按用户/IP 精细路由给不同计划。
  3. 不绑,把优化器喂饱——索引设计到"别无选择"、直方图、及时 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_TRACEhints_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 见真章。


「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论