Skip to content

MySQL 必知必会

使用 Docker 启动 MariaDB

MariaDB 被广泛用于替代 MySQL,主要因其开源更彻底、功能更新更快、限制更少且在性能上更具优势

bash
docker run -d \
  -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=root \
  -v $HOME/mysql-data:/var/lib/mysql \
  --name mariadb \
  mariadb:10

准备训练数据

Sakila 数据集 是 MySQL 官方为练习 join、聚合和索引而打造的 DVD 租赁示例库,包含 actor、film、customer、rental 等完整业务表,非常适合练习实战

打开 DataGrip,创建数据库 create database sakila;,将表结构和数据导入,先执行 sakila-schema.sql 文件,再执行 sakila-data 文件

MySQL 架构

在 MySQL 中,架构可以分为服务器层和存储引擎层两部分。服务器层负责 SQL 的解析、优化和执行,同时管理连接、权限以及缓存。

当执行 SELECT 查询时,服务器层会解析 SQL 并生成执行计划,然后交给存储引擎访问数据。SELECT 查询只读取数据,不修改,因此不会产生 binlog,也不会写 redo 或 undo log,只涉及从存储引擎读取 B+ 树、页和缓存中的数据。

对于 INSERT、UPDATE 和 DELETE 等修改操作,流程会稍微复杂一些。SQL 首先在服务器层解析和优化,之后在提交事务前会将操作记录到 binlog,这样可以支持主从复制和增量恢复。随后存储引擎层会先写 redo log 和 undo log 来保证事务的原子性和可回滚性,再修改数据页。事务提交后,redo log 确保数据持久化,而 undo log 用于回滚或支持 MVCC 快照。

可以理解为,binlog 属于服务器层的逻辑日志,记录的是事务提交后的变更,而 redo 和 undo log 属于存储引擎层的物理日志,用于保证事务安全和崩溃恢复。SELECT 查询主要走读取路径,不产生这些日志,而 INSERT/UPDATE/DELETE 则需要走写路径,通过 binlog、redo 和 undo log 协同保证数据一致性和可恢复性。整个架构将 SQL 执行、事务安全和数据持久性清晰分层,同时为性能优化提供了基础。

2PC(两阶段提交)解决的是:binlog 与 redo log 必须保持一致,否则主从会乱套。

MVCC 解决的是:并发读写时,每个事务看到的“数据版本”要一致。(undo log)

日志涉及:

  • redo log(崩溃恢复)
  • binlog(复制)
  • undo log(MVCC + 回滚)

InnoDB 的索引机制

大纲:

  1. 页机制与磁盘 I/O:数据按页读取到内存,减少小块 I/O,提高访问效率
  2. InnoDB 如何存储数据:B+ 树、聚集索引和非聚集索引
  3. SELECT 查询流程
  4. SQL 查询优化手段:联合索引、最左前缀原则、覆盖索引和 ICP

数据库的数据最终存储在磁盘上,而磁盘的访问速度远比内存慢,数据在两者间来回移动的过程就是我们常说的 I/O(输入输出)操作

InnoDB 读取数据时,并不会一条一条地从磁盘上取,而是以 页(Page) 为单位进行读取。页是数据的最小读写单元,InnoDB 默认每页 16KB。当查询触发磁盘访问时,整页数据会被一次性加载到内存缓存(buffer pool)里,这样可以一次获取多条记录,避免频繁的小 I/O 请求,提高数据访问效率

理解页是关键,因为无论是聚集索引还是非聚集索引,B+ 树都是以页为单位组织的

B+ 树是 InnoDB 索引的核心结构,用于快速定位数据。内节点存储导航信息,叶子节点存储实际数据或主键指针。叶子节点通过链表顺序连接,这使得范围查询可以连续扫描多个页,而不是频繁跳转,从而提高 I/O 效率

这里需要理解随机读取顺序读取的区别:随机读取意味着磁盘需要频繁调整磁头位置(寻道),延迟高;顺序读取意味着连续读取多页,磁头移动少,效率高。在聚集索引中,由于数据按主键顺序存储,范围查询或回表可以连续顺序读取磁盘页,从而充分利用顺序读取优势,减少随机 I/O 的开销

在 InnoDB 里,数据存储方式直接影响查询速度。聚集索引(主键索引)将数据行直接存储在 B+ 树的叶子节点中,所以按主键查询时,数据库可以直接定位到数据所在页,一步获取整行数据,无需额外跳转

非聚集索引则只在叶子节点存储主键,用于快速定位数据行。当查询条件是非主键列时,比如 SELECT id, name FROM user WHERE email='a@b.com',InnoDB 会先在 email 索引里找到这条记录,但它拿到的只是一个主键值。例如找到 email 对应的是主键 id=42,它必须再跳回聚集索引里,把整行数据取出来。这个过程叫回表

理解这些概念后,再看 SELECT 的执行流程就非常清楚:如果按主键查询,直接命中聚集索引,不需要回表;如果按非主键查询,先通过非聚集索引找到主键,再回表获取完整数据

聚集索引保证数据顺序存储,非聚集索引提供快速定位,页机制减少小块读取开销,而顺序读取则让回表 I/O 高效,从而整体提升数据库性能

回表虽然多了一跳,但设计合理时,顺序读取可以显著降低 I/O 消耗,从而保持高性能。这就引出了 SQL 查询优化的手段:

联合索引可以把多列组合在一起加速查询条件,例如 (email, name) 索引可以快速定位满足 email=? AND name=? 的记录,而联合索引只有遵循最左前缀原则才能生效,例如可以用于 WHERE email=?WHERE email=? AND name=?,但单独查询 name=? 则无法利用索引

当查询涉及的列全部包含在索引中时,例如只查询 email 和 name,该索引又成为覆盖索引,数据库无需回表即可直接从索引页返回结果,从而进一步减少 I/O 消耗

在非聚集索引回表的过程中,如果匹配的索引行很多,但最终只有少部分满足查询条件,就会产生大量不必要的 I/O,这时 Index Condition Pushdown(ICP) 就派上用场。ICP 会在索引扫描阶段,把能够在索引列上判断的 WHERE 条件提前过滤,只有真正匹配的行才会回表获取整行数据。这样不仅减少了回表 I/O,也降低了缓冲池压力,同时避免对无效数据的 CPU 计算,从而进一步优化查询性能

SQL 优化

SQL 优化核心目标:少扫行、少回表、少排序、少临时表、少死锁

  1. 优化索引结构:让查询尽量走索引,减少扫描
    • 建立正确字段的 B+Tree 索引(WHERE / JOIN / ORDER BY 要一致)
    • 让条件“最左匹配”,把高区分度字段放前面
    • 用覆盖索引减少回表
  2. 减少范围扫描和 filesort
  3. 避免 * 全表扫描
  4. 减少临时表(尤其 GROUP BY / DISTINCT)
  5. 避免低效子查询(EXISTS/IN 的优化套路)
  6. 合理使用 Limit + 索引分页
  7. 尽量避免 OR(会让索引失效)
  8. 减少大事务(锁太多,死锁概率高)
  9. 字段类型优化(很容易忽略)

如何优化 filesort

TODO

如何优化 temporary

TODO

如何优化 join 查询

  • 小表驱动大表
  • JOIN 字段必须有索引
  • JOIN 字段类型必须完全一致,否则索引会失效
  • 尽量避免 JOIN 过多表
  • 使用覆盖索引减少回表
  • 利用 EXISTS 替代 IN / JOIN
  • 避免 SELECT *,选择必要字段

Explain 语句

1、访问类型

类型说明性能等级
system表只有一行记录(const 的特例)⭐⭐⭐⭐⭐
const主键或唯一索引等值查询,最多返回一行⭐⭐⭐⭐⭐
eq_ref连接时使用主键或唯一索引,每张表只读取一行⭐⭐⭐⭐
ref非唯一索引等值查询⭐⭐⭐
range索引范围扫描⭐⭐⭐
index索引全扫描(索引树扫描)⭐⭐
ALL全表扫描

性能目标:至少达到 range 级别,最好达到 ref 级别。

2、eq_ref 表示 JOIN 条件使用主键或唯一索引,每次匹配一行。

JOIN 时,对被驱动表进行等值连接,如果使用的是主键或完整唯一索引,且一次查找在逻辑上只能返回一行,就是 eq_ref

3、理解常见 Extra 的类型

说明优先级
Using index覆盖索引,无需回表✅ 最好
Using index condition索引条件下推(ICP)✅ 好
Using where使用 WHERE 过滤✅ 正常
Using filesort需要文件排序⚠️ 需优化
Using temporary使用临时表⚠️ 需优化
Using join buffer使用连接缓冲⚠️ 需优化
  • Using filesort -> 排序无法走索引
  • Using index condition -> ICP -> 减少回表行数,节省 I/O

事务隔离级别

隔离级别脏读不可重复读幻读锁机制
READ UNCOMMITTED可能可能可能几乎不加锁
READ COMMITTED避免可能可能每次 SELECT 都创建新 Read View
REPEATABLE READ避免避免可能事务开始时创建 Read View
SERIALIZABLE避免避免避免全表串行化
  • MySQL 默认隔离级别:REPEATABLE READ;Oracle 默认隔离级别:READ COMMITTED
  • Repeatable Read 是事务隔离的协议,MVCC 是 InnoDB 用来实现这套协议的核心机制之一
  • MVCC 主要解决“读一致性”,幻读靠的不是 MVCC,而是锁(Next-Key Lock)

READ UNCOMMITTED - 脏读演示

脏读:读到其他事务未提交的数据

sql
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

BEGIN;

-- 第一次读取
SELECT balance FROM accounts WHERE id = 1;
-- 读到: 1000

-- 保持事务开启,切换到会话 B
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

BEGIN;

-- 修改数据,但不要提交!
UPDATE accounts SET balance = 500 WHERE id = 1;

-- 此时数据已修改,但事务未提交
-- 不要执行 COMMIT,保持事务开启
sql
-- 第二次读取
SELECT balance FROM accounts WHERE id = 1;
-- 读到: 500!
-- 这就是"脏读" —— 读到了会话 B 未提交的修改
sql
-- 回滚事务
ROLLBACK;

-- 此时数据恢复为 1000
sql
-- 第三次读取
SELECT balance FROM accounts WHERE id = 1;
-- 读到: 1000
-- 但如果会话 A 之前基于 500 做了业务决策,就出问题了!

COMMIT;
  1. 为什么会读到未提交的数据?因为 READ UNCOMMITTED 不使用 MVCC,不加锁,直接读取缓冲池中的最新值
  2. 如果会话 B 最终回滚了,会话 A 基于脏数据做的操作可能导致数据不一致、业务决策错误

READ COMMITTED - 不可重复读演示

sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN;

-- 第一次读取
SELECT balance FROM accounts WHERE id = 1;
-- 记录这个值,假设是 1000

-- 切换到会话 B
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

UPDATE accounts SET balance = 500 WHERE id = 1;
sql
-- 第二次读取
SELECT balance FROM accounts WHERE id = 1;
-- 值变成 500 了!这就是"不可重复读"

COMMIT;

REPEATABLE READ - 可重复读演示

sql
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

BEGIN;

-- 第一次读取
SELECT balance FROM accounts WHERE id = 1;
-- 记录这个值

-- 切换到会话 B
sql
UPDATE accounts SET balance = 2000 WHERE id = 1;

-- 验证
SELECT * FROM accounts WHERE id = 1;
sql
-- 第二次读取
SELECT balance FROM accounts WHERE id = 1;
-- 值还是旧的!这就是"可重复读"

COMMIT;

-- 提交后再读,就是新值了
SELECT balance FROM accounts WHERE id = 1;

幻读演示

幻读:同一事务内,前后两次查询,结果集数量不同

sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN;

-- 查询余额 < 1000 的用户
SELECT id, username, balance FROM accounts WHERE balance < 1000;
-- 假设只有 1 条记录

-- 切换到会话 B
sql
-- 插入一条新记录
INSERT INTO accounts (username, balance) VALUES ('user_new', 500);

COMMIT;
sql
-- 再次查询
SELECT id, username, balance FROM accounts WHERE balance < 1000;
-- 现在有 2 条记录了!这就是"幻读"

COMMIT;

MySQL 的 REPEATABLE READ 通过 Next-Key Lock 解决幻读:

sql
-- ===== 设置隔离级别为 REPEATABLE READ =====
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

重复上述步骤,你会发现幻读被避免了!

锁机制

InnoDB 的 RR 是靠 MVCC + 锁实现的

InnoDB 锁机制 = 行锁(S/X)+ 间隙锁/Gap Lock + Next-Key + 意向锁

当前锁MVCC 快照读S 锁读X 锁读
无锁
S 锁
X 锁

MVCC 快照读

场景:两个事务同时读取同一条记录,一个事务修改数据,观察读到的值

sql
BEGIN;
-- 查看 MySQL 事务 ID
SELECT CONNECTION_ID();

-- 读取用户余额(快照读)
SELECT id, username, balance FROM accounts WHERE id = 1;

-- 此时不要提交,保持事务开启
-- 然后切换到会话 B
sql
-- 更新数据
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

-- 立即提交
COMMIT;

-- 验证更新
SELECT * FROM accounts WHERE id = 1;
sql
-- 再次读取,观察是否读到新数据
SELECT id, username, balance FROM accounts WHERE id = 1;

-- 为什么还是旧值?因为 MVCC 的 Read View
COMMIT;
  1. 会话 A 第二次读取的结果与会话 B 更新后的值一致吗?不一致。会话 A 读到的是旧值(快照)
  2. 为什么会话 A 读不到会话 B 的修改?因为 MVCC 机制。Read View 在事务开始时创建,后续读取的是这个快照,而不是最新数据

当前读和快照读的区别

  • 快照读:普通的 SELECT,读取 Read View 中的历史版本
  • 当前读:读取最新版本,加锁保证读到的最新数据
sql
BEGIN;

-- 这是快照读,不加锁
SELECT balance FROM accounts WHERE id = 1;

-- 这是当前读,加 S 锁
SELECT balance FROM accounts WHERE id = 1 LOCK IN SHARE MODE;

-- 这也是当前读,加 X 锁
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;

锁机制:S 锁(共享锁)

S 锁特性:多个事务可以同时持有 S 锁

sql
BEGIN;

-- 加 S 锁(LOCK IN SHARE MODE)
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;

-- 保持事务开启,切换到会话 B
sql
BEGIN;

-- 也可以加 S 锁,成功!
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;

COMMIT;

-- 尝试加 X 锁(FOR UPDATE),会阻塞!
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 此时应该被阻塞...

会话 B 的 FOR UPDATE 会被阻塞,因为 S 锁和 X 锁不兼容

锁机制:X 锁(排他锁)

X 锁特性:只有持有锁的事务可以修改数据

sql
BEGIN;

-- 加 X 锁
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

-- 或者直接 UPDATE 也会加 X 锁
-- UPDATE accounts SET balance = balance + 100 WHERE id = 1;

-- 保持事务开启
sql
BEGIN;

-- 尝试加 S 锁,被阻塞
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;

-- 尝试加 X 锁,也被阻塞
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

-- 尝试更新,同样被阻塞
UPDATE accounts SET balance = balance + 50 WHERE id = 1;

数据库范式

解决数据冗余、数据不一致

应用层连接池

应用层连接池参数

参数含义配置原则 / 说明实战参考值
MaxOpenConns最大连接数(池中最大活跃连接)控制客户端并发不要超过 MySQL max_connections;高并发适当放大小型 20~50 / 中型 200~300 / 大型 800+
MaxIdleConns最大空闲连接数空闲连接池大小,保证突发请求快速拿到连接MaxOpenConns 的 50% 左右
ConnMaxIdleTime / MaxConnectionIdleTime单连接最大空闲时间防止连接被 MySQL 服务端断开或占用资源太久2~5 分钟
ConnMaxLifetime / MaxLifetime单连接最大生存时间定期替换连接,避免死链或累积状态异常略小于 MySQL wait_timeout(30~60 分钟)

MySQL 服务端参数

参数含义调优原则实战参考值
innodb_buffer_pool_sizeInnoDB 缓冲池大小占可用内存 60~75%,热点数据放内存,减少磁盘 IO小型 2~4G / 中型 22~40G / 大型 96G
innodb_buffer_pool_instances缓冲池实例数大缓冲池分多个实例减少锁争用根据 buffer_pool_size 决定:>1G → 4~16 个
max_connections最大允许连接数≥ 客户端总池连接数 + 预留余量小型 200 / 中型 1000 / 大型 3000
wait_timeout / interactive_timeout空闲连接超时> 池中 MaxConnIdleTime300~1200 秒

TODO

  • 树越高,上面的节点数据越少,是不是浪费 16K 页?
    • B+ 树上层节点看似“空间利用率低”,但它们常驻内存、极大降低树高和磁盘 IO,这是数据库里最划算的空间换时间。
  • 不同页之间读取数据,IO 次数怎么算?
    • 数据库计算 IO 的核心单位是“页是否命中内存”,而不是“跨了多少节点”或“走了多少指针”。
    • Buffer Pool/根节点常驻内存 0 IO
    • IO 发生在“页不在内存”时
  • 磁盘 https://linux.vbird.org/linux_basic/centos7/0130designlinux.php#partition_name
  • 页不是磁盘的物理单位,而是数据库为了控制 IO 成本而设计的逻辑抽象;数据库用页思考性能,操作系统用块操作磁盘,磁盘最终按扇区读写。
  • 顺序 IO/随机 IO/寻道
  • B 树和 B+树的区别:B 树的节点存储数据行;B+树的节点只存储索引,只在叶子节点存储数据行
  • 平衡二叉树/自旋慢;红黑树/自旋快
  • 同级节点的左右指针:顺序访问/范围查询/顺序 IO/随机 IO
  • 自旋/spin
  • CRUD 时 B+树如何工作?增删麻烦,查改效率高
  • MySQL 通过 B+ 树索引精确定位页号;是否随机或顺序 IO,取决于访问的页在磁盘上的物理连续性;顺序 IO 极大减少寻轨,是数据库性能的生命线。
  • 缓存机制:MySQL Buffer Pool/OS Page Cache/预读
  • 关联字段/排序字段/分组字段是如何使用索引的
  • optimizer_trace 和 EXPLAIN 的关系
    工具作用
    EXPLAIN我选了谁
    optimizer_trace我为什么这么选
    SHOW PROFILE执行时花在哪
  • optimizer_trace 是用来解释“为什么优化器不用某个索引”的唯一官方证据,它展示了候选执行计划、成本估算和最终决策过程。
  • 联合索引/最左前缀匹配
  • 覆盖索引
  • 唯一索引
    • 唯一索引允许多个 NULL 值
    • 唯一索引影响性能的本质,是写入时必须进行额外的存在性检查和并发一致性保护,这在高并发写场景下会显著放大成本
  • 索引失效的情况
    1. 使用函数
    2. 使用 LIKE 以%开头(除非覆盖索引)
    3. 使用不等于/!=
    4. is not null 有可能不走索引
    5. 违反最左前缀匹配
  • 索引下推/ICP/Using index condition