跳转到内容
唯一赫兹
返回

MySQL 架构

MySQL 架构

MySQL 存储结构

文件存储结构

表空间文件的结构

自底向上的结构为:

InnoDB 的 Compact 行格式

关于「变长字段长度列表」和「NULL值列表」的存储
  • 变长字段长度列表和 NULL 值列表是逆序存放的(从右往左读才是对应列的顺序)
  • 因为记录头信息中的 next_record 指向的是下一条数据的「记录头信息」和其「真实数据」之间的位置
  • 这样往左读就是额外信息,往右读就是真实数据
  • 同时位置靠前的真实数据和其长度信息可以在同一个 CPU Cache Line 中,可以提高 CPU Cache 的命中率

事务隔离级别

事务并发执行遇到的错误严重程度从高到底:

MVCC 工作机制

MVCC 的工作依靠两个数据:事务开启时创建的 ReadView、索引记录中隐藏列的 record_trx_idroll_pointer

ReadView

聚簇索引记录中的隐藏列

事务访问数据的流程

对于 ReadView 和一个事务的 trx_id,需要确定的前提:

  1. trx_id < min_trx_id 这个事务一定是已提交事务
  2. min_trx_id <= trx_id < max_trx_id 这个事务有不一定是已提交事务,如果在 m_ids 中则是活跃事务,反之则是已提交事务
  3. trx_id >= max_trx_id 这个事务是 ReadView 创建后才启动的

当一个事务创建 ReadView 并访问记录的 record_trx_id 时,经历以下流程:

  1. record_trx_id < min_trx_id || (min_trx_id <= record_trx_id < max_trx_id && record_trx_id not in m_ids) 说明修改这条记录的事务已提交,该记录对当前事务可见,读取数据并结束访问
  2. min_trx_id <= record_trx_id < max_trx_id && record_trx_id is in m_ids 说明修改这条记录的事务未提交,该记录对当前事务不可见,查询该记录的 roll_pointer 指向的旧版本,并回到步骤 1
  3. record_trx_id >= max_trx_id 说明修改这条记录的事务是在 ReadView 创建后才启动的,该记录对当前事务不可见,查询该记录的 roll_pointer 指向的旧版本,并回到步骤 1
    注意

    如果顺着 roll_pointer 一直找到尽头,所有历史版本都不可见, 则应该返回空,仿佛这条记录不存在一样

不同隔离级别中 ReadView

临键锁

「临键锁」的作用等效于「间隙锁 + 记录锁」,作用的区间是左开右闭的, 例如多列的数据为 [1,2,5,10][1, 2, 5, 10],想要将区间 (2,10](2, 10] 锁住,就会在 (2,5](2, 5](5,10](5, 10] 上分别加临键锁 临键锁并不代表两个单独的锁,而是一个完整的锁单元

「当前读」会发生幻读的场景
  1. 事务 A 开启,然后执行 select * from stu where id = 5 得到结果为空
  2. 事务 B 开启,然后执行 insert into stu values(5, "v1hz") 并提交
  3. 事务 A 执行 update stu set name = "wxy" where id = 5(这种情况确实很少见)
  4. 事务 A 执行 select * from stu where id = 5 查到 id 为 5 的记录(发生幻读)

为什么? 因为 update 是「当前读」,会读到事务 B 插入的记录,所以能够更新成功,此时 id 为 5 的记录的 record_trx_id 就是事务 A 的 trx_id 导致第二次查询时记录对事务 A 是可见的,于是第二次查询能查到第一次查不到的数据

第二种情况:

  1. 事务 A 开启,然后执行 select * from stu where id = 5 得到结果为空
  2. 事务 B 开启,然后执行 insert into stu values(5, "v1hz") 并提交
  3. 事务 A 执行 select * from stu where id = 5 for update 查到 id 为 5 的记录(发生幻读)

这种情况则是因为新的查询是当前读,肯定能读到新插入的数据 避免的方法就是尽量在开启事务后立即执行 select ... for update 语句,这样就会立即加上临键锁,防止增删操作造成幻读

索引

按物理存储分类

按逻辑功能分类

索引失效

count() 中的索引行为

count(主键) count(*) count(1) 有什么区别?哪个性能更好?

获取记录数量的优化方法
  • explain select count(*) from <table>show table status 不会真的去查询,rows 字段是对表记录数的估算值
  • 单独维护一个计数字段
MyISAM 的 rows 字段

MyISAM 引擎在表头维护了总行数,所以它的 rows 字段是精确值

LIMIT 分页中的索引行为

SELECT * FROM page ORDER BY id LIMIT <offset>, <row>
SELECT * FROM page ORDER BY id LIMIT <row> OFFSET <offset>

这会跳过前 <offset> 条记录,返回接下来的 row 条记录 如果 offset 过大,会导致读取大量无用数据

性能优化策略
  1. 游标分页:基于上一页最后一条记录的 id 进行下一页的查询 SELECT * FROM page WHERE id > last_seen_id ORDER BY id LIMIT size;
  2. 减少不必要的列查询:只选择必要的列可以减少 I/O 开销
  3. 子查询优化:先找出目标范围的起始 id 再进行查询,减少需要处理的数据量 SELECT * FROM page WHERE id >= (SELECT id FROM page ORDER BY id LIMIT 6000000, 1) ORDER BY id LIMIT 10; 这个语句性能更好是因为子查询只会读取 id 值,大大减少 I/O 开销

锁类型

全局锁

flush tables with read lock
unlock tables

加全局锁会让整个数据库变为只读,常用于全库逻辑备份 对于支持「可重复读」事务隔离级别的存储引擎,可以在备份前开启事务,

表级锁

读锁时「共享锁」(S 锁),读锁是「排他锁」(X 锁)

关于表锁
  1. lock tables <table> read/write 是 MySQL Server 层提供的表锁命令,不是 InnoDB 自己实现的
  2. MDL 也是 MySQL Server 层的锁
  3. 意向锁是 InnoDB 引擎实现的,只是信号锁,不会阻塞普通的 DML
  4. AUTO-INC 是 InnoDB 引擎实现的,不会阻塞普通的 DML

行锁

事务中的锁

事务中的生成的所有锁会在事务提交时全部释放

关于插入意向锁

插入意向锁之间是互相兼容的,例如对于现有记录 [2,5][2, 5],事务 A 想要插入 33,就会在 (2,5)(2, 5) 的插入意向锁,事务 B 想要插入 44 也会加上一个 (2,5)(2, 5) 的插入意向锁,事务 A 和事务 B 中的插入都会执行成功

如果插入位置重复了怎么办? 例如事务 A 和 B 都要插入 33 意向插入锁不会检查是否位置重复(主键或唯一键冲突),默认其他所有的插入意向锁的位置与自己不同,InnoDB 会在唯一性检查阶段进行检测,如果存在冲突,会进入串行化等待:

  1. 如果事务 A 操作成功,事务 B 再次检查,发现 33 已存在,报 Dupulicate Key 错误
  2. 如果事务 A 操作失败回滚,则事务 B 继续插入,成功
当前读的错误操作导致锁全表

当执行 insert delete update select ... for update 这种会加临键锁的语句时,如果 where 筛选中没有索引,或者优化器经过判断后没使用索引,就会导致全表扫描的时候每一行记录都被加锁,造成了锁全表

解决方案

  1. EXPLAIN 确认 UPDATE 走索引(type ≠ ALL)
  2. WHERE 条件字段必须有合适索引
  3. 开启 sql_safe_updates = 1
  4. 大批量更新用 LIMIT 分批处理
  5. 避免在索引列上使用函数、计算、模糊前缀
  6. 必要时用 FORCE INDEX 强制走索引

AI 回答的大厂解决方案

  1. SQL 审核平台:所有 DML 必须通过审核,禁止无索引 UPDATE
  2. 影子库/测试环境验证:上线前在影子库跑 EXPLAIN,确认执行计划
  3. DBProxy 拦截:在数据库中间件层(如 ShardingSphere、Atlas)拦截危险 SQL
  4. 权限控制:生产账号只允许执行带主键/唯一索引的 UPDATE
  5. 监控告警:检测慢 SQL、全表扫描、大事务,实时告警

行锁的加锁规则

如果只用记录锁或间隙锁就能避免幻读,就不会使用临键锁(临键锁退化)

唯一索引的等值查询

  1. 如果记录存在,会加记录锁
  2. 如果不存在,会加间隙锁

唯一索引的范围查询

  1. 如果是大于等于或小于等于, 临界点的记录如果存在则会为记录加记录锁 如果不存在则会加间隙锁 小于等于的时候记录锁会和左侧的间隙锁构成临键锁
  2. 如果是大于或小于, 如果临界点的记录不存在则会在左右两侧加间隙锁 如果存在则会加单侧的间隙锁(根据大于或者小于判断是哪一侧) 大于的时候间隙锁会和右侧的记录锁构成临键锁
临键锁并不代表两个单独的锁

虽然上面说“间隙锁会和记录锁”构成临键锁,但是临键锁并不代表两个单独的锁,临键锁是一个完整的锁单元,作用范围是左开右闭的,“构成临键锁”只是方便理解

非唯一索引的等值查询

  1. 如果记录存在,则会在非唯一索引上记录的左侧加临键锁(防止插入重复数据),右侧加间隙锁,并在记录的主键索引上加记录锁
  2. 如果记录不存在,则会在非唯一索引的记录的两侧加间隙锁

非唯一索引的范围查询

会对所有扫描到的记录,在二级索引上加临键锁,在主键索引上加记录锁

可能的优化

其实对于 where age <= 22,右侧只需要加 (22,39)(22, 39) 的间隙锁就能避免幻读,但是 MySQL 还是选择了加 (22,39](22, 39] 的临键锁

没加索引的查询

如果执行没有加索引的查询,会对全表进行扫描,如果语句会进行加锁操作(update/delete/insert/select-for-update),就会导致锁全表,属于严重事故!

死锁

死锁场景

假设对于 order_no 现有数据为 [1000, 1005, 1006]

  1. 事务 A 执行 SELECT id FROM t_order WHERE order_no = 1001 FOR UPDATE;(1000,1005)(1000, 1005) 区间加「间隙锁」(临键锁退化)
  2. 事务 B 执行 SELECT id FROM t_order WHERE order_no = 1002 FOR UPDATE;(1000,1005)(1000, 1005) 区间加「间隙锁」
  3. 事务 A 执行 INSERT INTO t_order(order_no, craeate_date) values(1001, now()) 生成 (1000,1005)(1000, 1005) 区间的「插入意向锁」,被事务 B 的间隙锁阻塞
  4. 事务 B 执行 INSERT INTO t_order(order_no, craeate_date) values(1002, now()) 生成 (1000,1005)(1000, 1005) 区间的「插入意向锁」,被事务 A 的间隙锁阻塞,事务 A、B 构成死锁

避免死锁

在系统层面:

  1. 设置 innodb_lock_wait_timeout = 50 默认为 50 秒
  2. 设置 innodb_deadlock_detect = ON MySQL 默认开启

在业务层面:

  1. 统一加锁顺序
  2. 减小事务粒度:事务越短,持锁时间越短,越不容易死锁
  3. 查询使用索引:防止锁全表
  4. 利用唯一索引避免重复

日志

Undo Log

Undo Log 的两个作用

删除和更新操作的特殊处理
  • DELETE 并不会直接删除,而是会将目标对象打上 delete flag,标记为删除,最终的删除操作是由 purge 线程完成的
  • UPDATE 的列如果是主键则会删除目标行然后插入新行,如果不是则会正常执行

Buffer Pool

在 MySQL 启动时,InnoDB 会申请一段连续的内存作为 BufferPoll,然后会按照 16KB 的大小将空间划分为多个页,还会有一部分空间用于存储自适应哈希索引 当读取数据时,如果数据存在于 BufferPool 中,客户端会直接从 BufferPool 获取数据,如果没有再去磁盘读取,并将其加载到 BufferPool 中 当修改数据时,如果目标数据存在于 BufferPool 中,会直接修改 BufferPool 中的数据,并将目标页标记为脏页,后续再将脏页写入磁盘

BufferPool 和磁盘的 IO 方式

BufferPool 对 .ibd 文件的读写使用 Direct I/O, 对日志文件的读写使用 Buffered I/O

自适应哈希索引

自适应哈希索引不是 16KB 的页,而是 BufferPool 内部的数据结构

Undo 页的刷盘

Undo 页和 Redo Log 不一样,Undo 页是通过「直接 IO」写入磁盘的,不会接触文件缓存(Page Cache)

Redo Log

Redo Log 是什么

Redo Log 是为了实现 ACID 中的持久性(Durability)而设计的,用于防止 MySQL 异常重启导致 BufferPool 中还未刷入磁盘的脏页和 undo 页丢失 每一条记录更新时,InnoDB 先更新 BufferPool 中的页,标记为脏页,然后将本次对页的修改(包括 Undo 页)写到 Redo Log Buffer,后续由后台线程将脏页和 Redo Log Buffer 刷入磁盘,这就是 WAL(Write-Ahead Logging)技术

为什么要写入 RedoLog 而不是直接写入数据

数据的写入是随机写,而 RedoLog 的写入是顺序写,顺序写比随机写高效很多,因此 RedoLog 写入磁盘的开销更小 这也变相地将 MySQL 写操作从「随机写」优化为了「顺序写」,提高了执行性能

Redo Log Buffer 的大小

默认为 16MB,可以通过 innodb_log_buffer_size 调整大小,增加其大小可以让 MySQL 在处理「大事务」时不必写入磁盘,从而提升 I/O 性能

Redo Log 的刷盘机制

一定会刷盘的时机

  1. 后台线程每秒自动刷一次(触发 write + fsync
  2. Redo Log Buffer 使用量超过一半时
  3. MySQL 关闭时

通过 innodb_flush_log_at_trx_commit 控制刷盘时机

如果 Redo Log 写满了

当 BufferPool 中的脏页刷到了磁盘中,Redo Log 中的记录就没用了,应该擦除 如图为 Redo Log 结构,由两个文件组成一个循环,write_pos 表示当前记录写到的位置,checkpoint 表示要擦除的位置,蓝色区域表示 Redo Log 中还未落盘的脏页记录(已使用区域),红色区域用来记录新的更新操作 如果 write_pos 追上了 checkpoint,意味着 Redo Log 满了,此时所有新的更新操作会被阻塞,将 BufferPool 中的脏页刷新到磁盘,然后擦除旧的记录后 MySQL 才会恢复正常运行

Bin Log

Bin Log 和 Redo Log 的区别

  1. 适用对象不同: Bin Log 是 MySQL 的 Server 层实现的日志,所有存储引擎都可以用 Redo Log 是 InnoDb 引擎实现的日志
  2. 文件格式不同: Bin Log 是逻辑日志,记录每一条修改数据的 SQL Redo Log 是物理日志,记录某个数据页做了什么修改
  3. 写入方式不同: Bin Log 是追加写,如果写满一个文件,就建一个新文件继续写 Redo Log 是循环写,日志空间大小固定
  4. 用途不同: Bin Log 用于主从复制、备份恢复 Redo Log 用于故障恢复

主从复制的实现

  1. 主库在收到客户端提交事务的请求后,先写入 binlog,然后提交事务
  2. 从库创建一个 I/O 线程,向主库发送复制请求,主库创建 log dump 线程发送 binlog,从库将接收的 binlog 写入 relay log(中继日志)然后返回“复制成功”的响应
  3. 从库创建一个 SQL 线程,读取 relay log 然后回放 binlog 实现更新存储引擎中的数据,最终做到主从的数据一致性
主从复制的用法

完成主从复制后,就可以做到读写分离,写时只写主库,读时只读从库 但是从库不是越多越好,因为主库要创建同样多的 log dump 线程来处理复制请求 实际使用,中一个主库一般跟 2~3 个从库

主从复制模型

主从复制有三种模型:

  • 同步复制:主库提交事务的线程要等待所有从库的复制响应成功才会返回结果给客户端,实际项目中基本没法用
  • 异步复制:主库提交事务的线程不会等待从库的响应,就直接返回结果给客户端,这种模式下,一旦主库宕机就会造成数据丢失
  • 半同步复制:折中策略,只要数据成功复制到任意一个从库上,主库的事务线程就会返回结果给客户端

Bin Log 的刷盘机制

每个线程都有一个单独的 binlog cache,事务执行时 binlog 先写入 binlog cache,事务提交时将完整事务写入 binlog 文件(write 到 Page Cache) MySQL 通过 sync_binlog 控制 fsync 的时机:

两阶段提交

Redo Log 和 Bin Log 必须完全一致,否则会导致主从不一致,由此提出两阶段提交,事务提交被拆为两个阶段:

  1. Prepare 阶段 InnoDB 将 Redo Log 写入磁盘,状态标记为 PREPARE
  2. Commit 阶段 Server 层将 Bin Log 写入磁盘 InnoDB 将 Redo Log 状态改为 COMMIT 事务提交的成功以 Bin Log 的写入成功为标志

崩溃后的恢复

如果发现某事务处于 PREPARE 阶段,就回到 Bin Log 查询该事务:

事务未提交时,Redo Log 也会刷入磁盘吗
  • Redo Log 可以在事务提交之前持久化到磁盘,但是 Bin Log 必须在事务提交之后才可以持久化到磁盘
  • 如果 MySQL 崩溃,还没提交事务的 Redo Log 已被持久化到磁盘,而 Bin Log 还没有,Bin Log 中查不到该事务,就会进行回滚操作

两阶段提交的问题

  1. I/O 次数高: 当 sync_binloginnodb_flush_log_at_trx_commit 都为 1 的时候,每次事务提交都会至少调用 2 次刷盘操作(Redo Log 和 Bin Log 刷盘),这会成为性能瓶颈
  2. 锁竞争激烈: 早期 MySQL 版本中,通过使用 prepare_commit_mutex 锁来保证事务提交顺序,一个事务拿到锁后才嫩刚进入「Prepare 阶段」,直到「Commit 阶段」释放锁,下一个事务才可以继续

优化策略:组提交

TODO

一条 SQL 语句的执行过程

TODO


参考资料:



上一篇
ElasticSearch 架构
下一篇
计算机网络 CS144 - 第二单元 - 传输层