SQL性能调优笔记:从"删库跑路"到"丝般顺滑"
前言:为什么你的SQL像树懒一样慢?
想象一下,你让一个图书管理员(数据库)去找一本《Java从入门到入土》。
- 烂SQL的做法:管理员从书架的第一本书开始,一本本翻开看,直到找到为止(全表扫描)。
- 好SQL的做法:管理员直接看门口的索引卡片,直奔目标书架(索引查找)。
我们的目标,就是不让数据库管理员累死在找书的路上。
第一章:SELECT * 是万恶之源
*为什么不能 SELECT ?
这就好比你点外卖,明明只想吃个汉堡,结果你让骑手把整个麦当劳的后厨都搬过来。
错误示范(慢得像拖拉机):
sql
-- 哪怕你只需要 name,你也把 address, phone, id_card 全查出来了
SELECT * FROM users WHERE phone = '13800138000';
正确姿势(快得像高铁):
sql
-- 缺啥补啥,按需取用
SELECT id, name, phone FROM users WHERE phone = '13800138000';
效果:网络传输量瞬间减少90%,数据库笑得合不拢嘴。
第二章:索引(Index)------数据库的"藏宝图"
索引不是越多越好
索引就像书的目录。目录太厚了,找目录都要半天,而且每次往书里加内容(INSERT/UPDATE),都得重新改目录,累不累啊?
黄金法则:
- 最左前缀原则 :复合索引
(name, age)。如果你只查WHERE age = 18,索引就废了。必须先查name才能用到索引。 - 区分度要高:别给"性别"建索引,因为男女比例差不多,数据库还是会全表扫描。
别让索引"失效"
这是新手最容易踩的坑,相当于你给了地图,但数据库偏不看。
场景一:对字段"动手术"
sql
-- 错误:对 create_time 用了 YEAR() 函数,数据库必须把每一行都算一遍,索引直接罢工
SELECT * FROM orders WHERE YEAR(create_time) = 2023;
-- 正确:把计算移到右边,让索引正常工作
SELECT * FROM orders WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31';
场景二:类型不匹配(隐式转换)
sql
-- 错误:phone 是字符串类型,你却用数字去查
SELECT * FROM users WHERE phone = 13800138000;
-- 数据库内心戏:我要把每一行的 phone 转成数字再比?太累了,全表扫描吧。
-- 正确:加个引号,类型一致
SELECT * FROM users WHERE phone = '13800138000';
第三章:JOIN 与 子查询 ------ 拒绝"套娃"
能用 JOIN 就别用 IN/EXISTS
子查询就像俄罗斯套娃,一层层剥开很麻烦。JOIN 则是把两张表摊开,一次性匹配。
错误示范(相关子查询):
sql
-- 对每一笔订单,都要去查一遍用户表,慢得想哭
SELECT name,
(SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) as order_count
FROM users;
正确姿势(JOIN):
sql
-- 一次操作,直接搞定
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
第四章:分页查询 ------ 深度分页的"绝望"
OFFSET 的痛
当你翻到第100万页时,数据库需要扫描前100万行数据然后扔掉,只为了给你最后那20条。
错误示范:
sql
-- 越往后翻越慢,最后直接卡死
SELECT * FROM orders LIMIT 1000000, 20;
正确姿势(游标法) :
记住上一页最后一条数据的ID,直接从那里开始找。
sql
-- 假设上一页最后的 ID 是 123456
SELECT * FROM orders WHERE id > 123456 LIMIT 20;
第五章:事务与锁 ------ 别把大门锁死
大事务 = 堵车
事务就像过马路,你一个人过马路(小事务)很快。如果你把整条马路封了,拖着一车货慢慢过(大事务),后面的车(其他请求)全堵死了。
- 建议:批量删除数据时,别一次删10万条。分批次,一次删1000条,歇一会儿再删。
悲观锁与乐观锁
- 悲观锁 (
FOR UPDATE):我觉得你会改数据,所以我先把行锁住,谁也别想动。适合库存扣减这种敏感操作。 - 乐观锁:我觉得你不会改,等你改的时候我再检查版本号。
库存扣减实战:
sql
START TRANSACTION;
-- 加上行锁,防止超卖
SELECT stock FROM products WHERE id = 1001 FOR UPDATE;
-- 应用层判断库存充足后
UPDATE products SET stock = stock - 1 WHERE id = 1001;
COMMIT;
第六章:实战工具 ------ EXPLAIN
别瞎猜,用 EXPLAIN 照妖镜。
在 SQL 前面加上 EXPLAIN,看看执行计划:
sql
EXPLAIN SELECT * FROM users WHERE name = '张三';
重点关注 type 字段:
- ALL:全表扫描(完蛋,性能极差)。
- index:全索引扫描(勉强能活)。
- range:范围扫描(不错)。
- ref/eq_ref:非唯一/唯一索引查找(很好)。
- const:常量查询(完美,秒杀)。
第七章:底层原理 ------ 为什么是 B+ 树?(新增)
索引的"前世今生"
数据库索引不是凭空变出来的,它背后有一个数据结构在支撑。常见的有哈希表、二叉树、红黑树,但 MySQL(InnoDB引擎)最终选择了 B+ 树。
为什么?因为数据库不仅要考虑快,还要考虑磁盘IO(读写硬盘)的次数。
1. 二叉树/红黑树:太"瘦高"了
想象一棵二叉树,如果数据量有100万,这棵树可能高达20层。
- 缺点:每查找一个数据,就要从树根走到叶子,相当于要读写20次硬盘。机械硬盘的IO是很慢的,20次IO能把人等死。
2. B树:稍微好点,但还不够
B树让每个节点可以存多个数据,树变"矮胖"了,IO次数减少了。
- 缺点:每个节点既存索引值,又存数据指针,导致每个节点能存的索引变少,树还是不够矮。而且范围查询(比如查 1到100)时,需要反复回溯树节点,效率低。
3. B+ 树:天选之子
B+ 树是 MySQL 的绝对核心,它有三个"杀手锏":
-
杀手锏一:只有叶子节点存数据
- 非叶子节点只存索引(目录),不存数据(正文)。
- 好处:同样大小的内存页(比如16KB),能存下更多的索引值。这意味着树更"矮"了,通常3-4层就能存下几千万数据。查一次数据,只需要3-4次磁盘IO!
-
杀手锏二:叶子节点手拉手(链表)
- 所有的叶子节点通过双向链表连在一起。
- 好处 :范围查询(
WHERE id BETWEEN 1 AND 100)简直不要太爽。找到1之后,顺着链表往后走就能拿到100,不需要再回树根去查找。
-
杀手锏三:全表扫描也方便
- 因为叶子节点连成了链表,如果要扫描全表,直接顺着链表走一遍就行,不用遍历整棵树。
聚簇索引 vs 非聚簇索引(二级索引)
- 聚簇索引(主键索引) :
- 叶子节点存的是整行数据。
- 一张表只能有一个聚簇索引(通常就是主键)。
- 数据就是索引,索引就是数据。
- 二级索引(辅助索引) :
- 叶子节点存的是索引列的值 + 主键值。
- 回表 :如果你用二级索引查数据(比如
WHERE name = '张三'),先找到主键ID,然后再拿着主键ID去聚簇索引里找完整数据。这个过程叫"回表"。 - 覆盖索引 :如果你只查
id和name,二级索引里正好都有,就不需要回表了,速度飞快。
第八章:实战案例大赏
案例一:随机查询的陷阱(ORDER BY RAND())
想从100万用户里抽10个幸运儿?
错误示范(让数据库当场去世):
sql
-- 数据库要把100万行数据全部拿出来,扔进一个桶里摇匀,再取前10个
SELECT * FROM users ORDER BY RAND() LIMIT 10;
后果:全表扫描 + 文件排序,100万数据能让你等到天荒地老。
正确姿势(利用主键ID范围):
sql
-- 步骤1:先查出ID的最大值和最小值(应用层做)
SELECT MIN(id), MAX(id) FROM users;
-- 假设 min=1, max=1000000
-- 步骤2:在应用层生成10个随机数,比如 123, 456...
-- 步骤3:直接通过ID查(精准打击)
SELECT * FROM users WHERE id IN (123, 456, 789...);
效果:从10秒变0.01秒,性能提升1000倍。
案例二:大表JOIN小表,谁驱动谁?
你有1万用户的 users 表和100万订单的 orders 表。
错误示范(大表驱动小表):
sql
-- 用100万行的订单表去匹配1万行的用户表,效率低
SELECT u.name, o.amount
FROM orders o
JOIN users u ON o.user_id = u.id;
正确姿势(小表驱动大表):
sql
-- 让优化器先用小的用户表去匹配订单表
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;
原理:小表数据少,遍历成本低,就像拿着小名单去大仓库找货,比拿着大仓库清单去小办公室找人要快。
案例三:模糊查询的"前缀"玄机
老板要看姓"张"的用户,还要看名字里带"三"的用户。
场景A:前缀匹配(能走索引)
sql
-- 索引能帮上忙,因为它知道"张"在哪里
SELECT * FROM users WHERE name LIKE '张%';
场景B:通配符开头(索引失效)
sql
-- 索引直接罢工,因为"三"可能在名字的任何位置,只能全表扫描
SELECT * FROM users WHERE name LIKE '%三%';
优化建议 :如果必须全文搜索,请考虑使用 Elasticsearch 或 MySQL 的全文索引,别硬抗 LIKE。
第九章:架构级优化
分区表(Partitioning)------ 把大象装进冰箱
当表数据量达到亿级,单表查询再快也扛不住。这时候要把表切开。
场景:订单表按年份分区。
sql
CREATE TABLE orders (
id BIGINT NOT NULL,
amount DECIMAL(10,2),
order_date DATE
) PARTITION BY RANGE (YEAR(order_date)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
效果 :查询2024年的订单时,数据库只扫 p2024 分区,直接无视其他年份的数据,速度起飞。
读写分离 ------ 别累死主库
- 主库(Master):负责写(INSERT/UPDATE/DELETE),压力最大,要保护起来。
- 从库(Slave):负责读(SELECT),可以搞很多个,分担流量。
- 策略:报表查询、复杂统计全部扔到从库去跑,别在主库上搞事情。
缓存(Redis)------ 终极外挂
有些数据(比如首页配置、热点商品)查库太贵,不如存到内存里。
流程:
- 先查 Redis。
- 有就返回(毫秒级)。
- 没有再查 MySQL,查到后写入 Redis 并设置过期时间。
注意:缓存和数据库的数据一致性是个坑,记得更新数据库时同步删除缓存(Cache Aside 模式)。
第十章:面试官的"夺命连环问"(新增)
这里整理了面试中最常出现的SQL调优问题,背下来,你就是面试官眼里的"懂王"。
Q1:为什么 MySQL 选择 B+ 树作为索引结构,而不是哈希表或二叉树?
- 回答思路 :
- 对比哈希表 :哈希表虽然等值查询是O(1),但它不支持范围查询(
>、<)和排序,而 B+ 树的叶子节点是链表,天生支持范围查询。 - 对比二叉树/红黑树:二叉树太"瘦高",树的高度代表磁盘IO次数。B+ 树是"矮胖子",非叶子节点只存索引不存数据,同样高度能存更多索引,大大降低了磁盘IO次数(通常3-4层就能存千万级数据)。
- 对比哈希表 :哈希表虽然等值查询是O(1),但它不支持范围查询(
Q2:什么是"回表"?如何避免?
- 回答思路 :
- 定义:在使用二级索引(非主键索引)查询时,如果查询的字段不在二级索引中,数据库需要先在二级索引中找到主键ID,再拿着主键ID去聚簇索引(主键索引)中查找完整的行数据。这个过程叫"回表"。
- 避免方法 :使用覆盖索引 。即你查询的字段(
SELECT后面的列)和过滤条件(WHERE后面的列)正好都在同一个索引树上,数据库直接从索引里就能拿到数据,不需要再回表查行数据。 - 例子 :
SELECT id, name FROM users WHERE name = '张三',如果有name的索引,就不需要回表。
Q3:联合索引 (a, b, c),查询 WHERE a=1 AND c=3 会走索引吗?
- 回答思路 :
- 会走索引,但只用到
a。 - 根据最左前缀法则 ,索引匹配是从最左边开始,遇到范围查询或断档就停止。这里跳过了
b,所以c无法利用索引进行快速查找,只能扫描a=1的所有数据来过滤c。
- 会走索引,但只用到
Q4:COUNT(*)、COUNT(1) 和 COUNT(列名) 有什么区别?哪个快?
- 回答思路 :
COUNT(*):MySQL 官方推荐,InnoDB 引擎做了专门优化,不取具体列值,直接统计行数,速度最快。COUNT(1):和COUNT(*)基本一样,但需要遍历每一行取个常量1,理论上稍微慢一丢丢(几乎可忽略)。COUNT(列名):会统计该列不为 NULL 的行数。如果该列没有索引,需要全表扫描;如果有索引,走索引统计。- 结论 :统计行数无脑用
COUNT(*)。
Q5:千万级大表分页,LIMIT 1000000, 10 很慢,怎么优化?
- 回答思路 :
- 原因:数据库需要扫描前1000010条记录,丢弃前1000000条,IO开销巨大。
- 方案一(游标法/延迟关联) :记录上一页最大的ID,
SELECT * FROM table WHERE id > 上次最大ID LIMIT 10。利用主键索引直接定位,速度极快。 - 方案二(覆盖索引+回表) :先走覆盖索引查出ID,再关联回原表。
SELECT * FROM table t, (SELECT id FROM table LIMIT 1000000, 10) tmp WHERE t.id = tmp.id。
Q6:EXPLAIN 结果中,type 字段有哪些值?哪些是必须优化的?
- 回答思路 :
- 性能从好到坏 :
system>const>eq_ref>ref>range>index>ALL。 - 必须优化 :
ALL(全表扫描)是绝对的红线,必须消灭。 - 尽量优化 :
index(全索引扫描)通常也需要优化,除非数据量很小。 - 及格线 :至少要达到
range(范围扫描)级别。
- 性能从好到坏 :
总结:防脱发指南
- 别偷懒 :别用
SELECT *,只查需要的列。 - 别作死 :别在
WHERE左边对字段做运算或函数转换。 - 别套娃 :尽量用
JOIN代替子查询。 - 别深潜 :避免
LIMIT 1000000, 20这种深度分页。 - 懂底层:理解 B+ 树,明白为什么范围查询快、为什么回表慢。
- 多照镜子 :经常用
EXPLAIN看看自己的 SQL 到底是怎么跑的。 - 上科技:数据量大了记得分区、读写分离、加缓存。
记住,优化的终极奥义是:让数据库少干活,干巧活。