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

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

相关推荐
QH139292318802 小时前
是德科技KEYSIGHT N5183B 9 kHz~40 GHz微波模拟信号发生器
网络·数据库·科技·嵌入式硬件·集成测试
暗暗别做白日梦2 小时前
Redisson 延迟队列实现订单支付超时自动取消(源码 + 原理全解)
数据库·redis
数厘2 小时前
2.13 sql数据更新(UPDATE)
数据库·sql·oracle
一江寒逸2 小时前
零基础从入门到精通MongoDB(附加篇):面试八股文全集
数据库·mongodb·面试
星晨雪海2 小时前
Redis 分布式 ID 生成器
数据库·redis·分布式
有味道的男人2 小时前
抖音关键词搜索,视频详情api
linux·数据库·音视频
丁丁点灯o2 小时前
Oracle中金额数字转换为大写汉字
数据库·oracle
fly spider2 小时前
MySQL之Buffer Pool
数据库·mysql
程序员老邢2 小时前
【技术底稿 13】内网 Milvus 2.3.0 向量数据库全流程部署(商助慧 AI 底座,Attu 可视化)
java·数据库·人工智能·ai·语言模型·milvus