MySQL性能调优

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去聚簇索引里找完整数据。这个过程叫"回表"。
    • 覆盖索引 :如果你只查 idname,二级索引里正好都有,就不需要回表了,速度飞快。

第八章:实战案例大赏

案例一:随机查询的陷阱(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)------ 终极外挂

有些数据(比如首页配置、热点商品)查库太贵,不如存到内存里。

流程

  1. 先查 Redis。
  2. 有就返回(毫秒级)。
  3. 没有再查 MySQL,查到后写入 Redis 并设置过期时间。

注意:缓存和数据库的数据一致性是个坑,记得更新数据库时同步删除缓存(Cache Aside 模式)。


第十章:面试官的"夺命连环问"(新增)

这里整理了面试中最常出现的SQL调优问题,背下来,你就是面试官眼里的"懂王"。

Q1:为什么 MySQL 选择 B+ 树作为索引结构,而不是哈希表或二叉树?

  • 回答思路
    • 对比哈希表 :哈希表虽然等值查询是O(1),但它不支持范围查询(><)和排序,而 B+ 树的叶子节点是链表,天生支持范围查询。
    • 对比二叉树/红黑树:二叉树太"瘦高",树的高度代表磁盘IO次数。B+ 树是"矮胖子",非叶子节点只存索引不存数据,同样高度能存更多索引,大大降低了磁盘IO次数(通常3-4层就能存千万级数据)。

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 到底是怎么跑的。
  • 上科技:数据量大了记得分区、读写分离、加缓存。

记住,优化的终极奥义是:让数据库少干活,干巧活。

相关推荐
IT邦德6 分钟前
OGG 26ai实时同步Oracle
数据库·oracle
星光开发者9 分钟前
基于springboot电动汽车租赁管理系统-计算机毕设 附源码 11217
javascript·spring boot·mysql·django·php·html5·express
苍煜11 分钟前
SpringBoot Spring事务完整版详解:@Transactional注解实操 + 七大事务传播机制用法
spring boot·spring·oracle
Python大数据分析@12 分钟前
有哪些好用又免费的SQL工具?
数据库·sql
哥本哈士奇14 分钟前
SQL Server RAG 笔记1:图数据库构建
数据库
带鱼吃猫16 分钟前
从原子性到串行化:数据库事务全解
数据库·mysql
IT学长17 分钟前
JavaWeb图书管理系统设计与实现(附源码)
mysql·servlet·毕业设计·课程设计·图书管理系统
网络工程小王17 分钟前
[RAG 与文本向量化详解]RAG篇
数据库·人工智能·redis·机器学习
秋918 分钟前
MySQL 8.4.9 LTS 与 MySQL 9.7.0 LTS 全方位深度对比
数据库·mysql
ffqws_28 分钟前
Spring Boot 配置读取全解析:从 application.yml 到 Java 对象的完整链路
java·数据库·spring boot