数据库Update慢到怀疑人生使用Index Hint强制走索引让亿级数据秒级完成
你有没有过这种经历?半夜被监控报警炸醒,打开一看,一个UPDATE语句跑了三十多分钟还没完,CPU被干到100%,业务全部卡死。你盯着进度条,心里一万只草泥马奔腾而过。
别急,这种事儿我见过太多了。今天咱们就来聊聊,当一个亿级数据的表被慢UPDATE折磨得快要崩溃的时候,怎么用Index Hint这种”黑科技”让它瞬间起飞。
先搞清楚,UPDATE为什么会慢得离谱
很多人一上来就急着优化,但你不搞清楚原因,优化就是瞎子摸象。
UPDATE慢,本质上是数据库在找”我要改哪些行”这件事上花了太多时间。想象一下,你有一本电话号码簿,里面按姓氏排序,你要找到所有”张伟”的号码。如果你从头翻到尾,那得翻到猴年马月。但如果你直接翻到”Z”区,秒完事。
数据库也是一样的道理。
最常见的原因有三个:
第一个原因是全表扫描。 数据库没找到合适的索引,只能逐行读取每一行数据,看看是否符合WHERE条件。一亿行数据,这得多慢?
第二个原因是索引失效。 你以为你建了索引,数据库就会用,实际上很多情况会让索引白建了。比如对索引列做函数运算、隐式类型转换、或者用!=、NOT IN这些操作符,索引就直接罢工了。
第三个原因是锁竞争和日志开销。 每次UPDATE都要写redo log和undo log,还要加锁。数据量越大,日志量呈指数级增长,磁盘IO就成了瓶颈。
来,看看真实案例
我去年帮一家电商公司救火,他们的场景是这样的:
有一张order_master订单主表,数据量1.2亿行,结构大概是这样的:
CREATE TABLE `order_master` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` varchar(32) NOT NULL COMMENT '订单编号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已支付 2已发货 3已完成',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`amount` decimal(10,2) NOT NULL COMMENT '订单金额',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';
他们当时执行的语句是:
UPDATE order_master
SET status = 3, update_time = NOW()
WHERE user_id = 888888
AND status IN (0, 1)
AND create_time >= '2024-01-01';
执行计划一看,吓人一跳:
+----+-------------+-------------+------------+------+---------------+------+---------+-------+----------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------------+------------+------+---------------+------+---------+-------+----------+----------+-------------+
| 1 | UPDATE | order_master| NULL | ALL | idx_user_id | NULL | NULL | NULL | 120583456| 10.00 | Using where |
+----%-------------+-------------+------------+------+---------------+------+---------+-------+----------+----------+-------------+
type: ALL,全表扫描,扫了1.2亿行!key: NULL,一个索引都没用!
你以为建了idx_user_id索引,数据库就会用它?Too young。因为WHERE条件里有三个字段(user_id、status、create_time),数据库评估了一下,觉得用索引扫完还要回表查其他字段,不如直接全表扫来得快。这是典型的索引选择不当。
Index Hint是个啥玩意儿
Index Hint,说白了就是你对数据库说:”嘿,你别自作主张了,这次就按我说的用这个索引。”
MySQL支持好几种Hint语法:
-- 强制使用索引
SELECT * FROM table_name FORCE INDEX (index_name) WHERE ...;
-- 优先使用某个索引
SELECT * FROM table_name USE INDEX (index_name) WHERE ...;
-- 忽略某个索引
SELECT * FROM table_name IGNORE INDEX (index_name) WHERE ...;
-- 强制使用某个索引做连接(JOIN场景)
SELECT * FROM t1 FORCE INDEX (idx_col) JOIN t2 ON t1.id = t2.id;
UPDATE语句同样支持:
UPDATE order_master FORCE INDEX (idx_user_id)
SET status = 3, update_time = NOW()
WHERE user_id = 888888
AND status IN (0, 1)
AND create_time >= '2024-01-01';
这就相当于你给数据库下了死命令:必须用idx_user_id这个索引,不允许你偷懒走全表扫描。
关键问题来了:为什么FORCE INDEX就能快?
你可能会问,索引不是都建好了吗,数据库自己不用非得我Force,这合理吗?
这里有个关键的认知误区:建了索引 ≠ 一定会用索引。
数据库优化器(Optimizer)是个”贪财”的家伙,它每次执行SQL都会评估各种方案的”成本”,选一个它认为最便宜的。这个成本包括:
- 磁盘IO次数
- CPU计算量
- 内存使用
优化器看到你的WHERE条件有user_id、status、create_time三个过滤条件,它评估:
- 用
idx_user_id:先找到user_id=888888的记录(假设几千条),然后每条都要回表查status和create_time - 全表扫:虽然扫1.2亿行,但顺序读磁盘,比随机回表可能更便宜
优化器选择了全表扫,你觉得冤不冤?
但事实是,优化器的评估经常翻车。尤其是数据分布不均匀的时候。比如user_id=888888这个用户,可能只下了3条订单,但优化器不知道,它以为这个用户有几百万条。
这时候Force Index就是你告诉优化器:“别算了,信我,就用这个索引。”
实战演练:从30分钟到3秒
回到我们那个案例。加上FORCE INDEX之后:
UPDATE order_master FORCE INDEX (idx_user_id)
SET status = 3, update_time = NOW()
WHERE user_id = 888888
AND status IN (0, 1)
AND create_time >= '2024-01-01';
执行计划变成:
+----+-------------+-------------+------------+-------+---------------+------------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------------+------------+-------+---------------+------------+---------+-------+------+----------+-------------+
| 1 | UPDATE | order_master| NULL | ref | idx_user_id | idx_user_id| 8 | const | 342 | 33.33 | Using where |
+----+-------------+-------------+------------+-------+---------------+------------+---------+-------+------+----------+-------------+
type: ref,用上了索引,只扫了342行(这个用户的订单),而不是1.2亿行!
结果怎么样?执行时间从30多分钟降到了3.2秒。
但等等,别高兴太早
Force Index虽然好用,但它不是银弹。用不好会出大问题的。
第一个坑:数据分布变化后,Hint可能帮倒忙。
今天user_id=888888只有342条订单,用索引很快。但一年后这个用户下了100万单,还用这个索引,扫描量反而比全表扫还大。所以Hint不是设完就一劳永逸的。
第二个坑:Hint写死了,表结构一变就废。
如果你改了表结构,索引名字变了,或者索引被删了,FORCE INDEX直接报错。
第三个坑:过度依赖Hint,会掩盖真正的优化机会。
有时候UPDATE慢的根本原因不是索引选择问题,而是:你的索引本身设计就不对。
比如你那个场景,如果用联合索引idx_user_status_time(user_id, status, create_time),可能根本不需要Hint,优化器自己就会选对。
怎么判断该不该用Hint?
给你一个排查流程:
第一步:先看执行计划
EXPLAIN UPDATE order_master
SET status = 3, update_time = NOW()
WHERE user_id = 888888
AND status IN (0, 1)
AND create_time >= '2024-01-01';
看type列:
ALL= 全表扫描,有问题ref= 用了索引等值查询,OKrange= 用了索引范围扫描,OKindex= 全索引扫描,比全表好点但也不是最优
看key列:
NULL= 没用任何索引,有问题- 有索引名 = 用了索引
看rows列:
- 这个数字是优化器预估要扫的行数,越大越差
第二步:看实际执行效果
EXPLAIN ANALYZE UPDATE order_master ...;
EXPLAIN ANALYZE(MySQL 8.0+)会告诉你实际执行了多少行,优化器的预估准不准。
第三步:决定策略
如果执行计划显示全表扫或者用了错误的索引,先用Hint验证一下效果。如果Hint后性能暴涨,说明优化器确实选型错了。
更好的方案:优化索引设计
如果说Force Index是”急救措施”,那优化索引设计就是”根治方案”。
回到我们的例子,如果表结构允许,建一个联合索引:
ALTER TABLE order_master
ADD INDEX idx_user_status_time (user_id, status, create_time);
然后重新执行原来的UPDATE语句(不加Hint),看看执行计划:
+----+-------------+-------------+------------+------+-----------------+-----------------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------------+------------+------+-----------------+-----------------+---------+-------+------+----------+-------------+
| 1 | UPDATE | order_master| NULL | ref | idx_user_status | idx_user_status | 9 | const | 342 | 10.00 | Using where |
+----+-------------+-------------+------------+------+-----------------+---------+-------+------+----------+-------------+
优化器自己就选对了,不需要你Force。而且这样更优雅,不会因为数据分布变化而翻车。
一些进阶技巧
技巧一:批量更新,别一次性搞
即使走了索引,一次UPDATE一百万行也可能锁住表很久。分批更新更安全:
-- 每次更新10000行,循环执行
UPDATE order_master FORCE INDEX (idx_user_id)
SET status = 3, update_time = NOW()
WHERE user_id = 888888
AND status IN (0, 1)
AND create_time >= '2024-01-01'
LIMIT 10000;
技巧二:用pt-archiver做在线大表更新
如果你的表数据量特别大,可以用PerconaToolkit里的pt-archiver工具,边更新边归档,对线上业务影响最小:
pt-archiver \
--source h=localhost,D=db,t=order_master \
--where "user_id=888888 AND status IN (0,1) AND create_time>='2024-01-01'" \
--limit 1000 \
--bulk-insert \
--progress 10000 \
--statistics \
--commit-each
技巧三:分区表+Hint的组合拳
如果表已经按时间分区了,你可以直接指定分区来扫描,比Hint更精准:
UPDATE order_master FORCE INDEX (idx_user_id)
PARTITION (p2024_01, p2024_02)
SET status = 3, update_time = NOW()
WHERE user_id = 888888
AND status IN (0, 1);
这样只扫描两个月分区的数据,不用碰其他几十亿行。
总结一下
遇到UPDATE慢到怀疑人生的情况,别慌:
- 先看执行计划,搞清楚是走全表扫描了还是用了错误的索引
- 用FORCE INDEX强制走正确的索引验证效果
- 如果验证有效,考虑优化索引设计,从根本上解决问题
- 大数据量时注意分批,避免锁表和日志膨胀
- Hint是双刃剑,用好了救命,用不好埋雷
记住,数据库优化这件事,没有放之四海而皆准的方案。每个场景都要具体分析,先诊断再下药,别一上来就加索引或者加Hint,那都是治标不治本。
希望这篇文章能帮你下次遇到这种问题时,心里有底,手上有招。