MySQL常见面试题

泡泡_滑稽泡泡_啊

如何定位慢查询

解决方案

使用成熟的开源工具:

  1. 调试工具:Arthas
  2. 运维工具:Prometheus 、Skywalking

MySQL自带的慢日志(调试阶段开启,生产阶段关闭)

  1. 慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志

  2. 如果要开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置

# 开启MySQL的慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒,SQL语句的执行时间超过2秒,就会视作为慢查询,记录慢查询日志
long_query_time=2

分析SQL

  • 方法:可以采用EXPLAIN 或者 DESC命令获取 MySQL 如何执行 SELECT 语句的信息
  • 信息:
    • possible_key 当前sql可能会使用到的索引
    • key 当前sql实际命中的索引
    • key_len 索引占用的大小
    • Extra 额外的优化建议
    • type 这条sql的连接的类型,性能由好到差为NULL、system、const、eq_ref、ref、range、 index、all
      • system:查询系统中的表
      • const:根据主键查询
      • eq_ref:主键索引查询或唯一索引查询
      • ref:索引查询
      • range:范围查询
      • index:索引树扫描
      • all:全盘扫描

image-20251219203623791

索引

概念:索引(index)是帮助MySQL高效获取数据的数据结构(有序)。在数据之外,数据库系统还维护着满足特定查找算法的数据结构(B+树),这些数据结构以某种方式引用(指向)数据, 这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。

数据结构比较:

在数据库(尤其是 MySQL 的 InnoDB 引擎)的设计中,核心目标是在海量数据中实现极速的查找,并尽量减少磁盘 I/O 操作。注意:磁盘IO次数是首要条件!!

为什么不选二叉树(BST / AVL / 红黑树)?

无论是普通的二叉搜索树(BST),还是自平衡的 AVL 树或红黑树,它们在数据库索引场景中都有一个致命伤:“树太高了”

  • 磁盘 I/O 瓶颈: 磁盘寻道是非常耗时的操作。在数据库索引中,每个节点通常对应一个磁盘页(Page,InnoDB 默认为 16KB)。
  • 高度失控: 二叉树的每个节点只能有两个子节点(阶数为 2)。如果存储 1000 万行数据,二叉树的高度大约为 log_2(10^7) \approx 24。这意味着查找一条数据可能需要进行 24 次磁盘 I/O
  • 逻辑: 这种“瘦高”的结构在内存中运行很快,但在涉及磁盘读写时,过多的 I/O 会导致性能崩塌。

为什么不选 B 树(B-Tree)?

B 树是对二叉树的改良,它变成了“多叉树”,大大降低了树的高度。但相比 B+ 树,它有三个主要劣势:

一、内部节点存储数据导致“扇出”降低

  • B 树: 每个节点(无论是根节点还是叶子节点)都存储完整的 Data
  • B+ 树: 非叶子节点(索引节点)只存储 KeyPointer,不存储具体的 Data
  • 结论: 同样是 16KB 的磁盘页,B+ 树的非叶子节点能存储更多的索引项,这意味着它的**扇出(Fan-out)**更大,树的高度更低(通常 3 到 4 层即可支撑千万级数据)。

二、范围查询(Range Query)性能差

  • B 树: 如果要查 ID 在 10 到 100 之间的数据,B 树需要进行中序遍历,不断在不同层级的父子节点间“反复横跳”。
  • B+ 树: 所有数据都存在叶子节点,并且叶子节点之间用双向链表连接。查找范围时,只需先找到起始点,然后在叶子节点层进行顺序扫描即可。

三、查询稳定性

  • B 树: 查找性能不稳定,运气好在根节点就找到了,运气差要到叶子节点。
  • B+ 树: 任何查找都要最终落到叶子节点,查询路径长度固定,性能非常稳定。

为啥选择了B+树?

B+ 树之所以成为 InnoDB 的首选,主要得益于以下设计:

特性B+ 树的表现给数据库带来的好处
矮胖结构阶数通常几百上千,高度极低即使亿级数据,磁盘 I/O 也在 3-4 次以内
非叶子节点去 Data 化索引页能装更多索引内存缓存效率更高,命中率高
叶子节点双向链表物理存储有序,逻辑链表连接完美支持 ORDER BYGROUP BY 和范围查询
预读性节点大小与操作系统的页对齐充分利用局部性原理,一次读取整页数据

索引类型

聚集索引(聚簇索引)

将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据。必须有,且只有一个。

聚簇索引并不是一种单独的索引类型,而是一种数据存储方式

  • 核心逻辑: 将数据行与索引 B+ 树的叶子节点存放在一起。也就是说,索引即表,表即索引
  • 物理存储: 数据的物理存储顺序与索引的逻辑顺序完全一致。
  • 唯一性: 因为数据行只能按照一种物理顺序存储,所以一个表只能有一个聚簇索引。

二级索引(非聚集索引)

将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键。可以存在多个。

非聚簇索引也叫辅助索引(Secondary Index)或者二级索引。

  • 核心逻辑: 索引结构与数据行分开存储。B+ 树的叶子节点不存储真实的整行数据。
  • 叶子节点存什么:
    • InnoDB 中:叶子节点存储的是该索引列的值 + 对应的主键值
    • MyISAM 中:叶子节点存储的是该索引列的值 + 数据的磁盘地址(物理指针)
  • 数量: 一个表可以拥有多个非聚簇索引(如对 nameage 分别建索引)。

image-20251219215501385

回表查询

通过二级索引找到对应的主键值,到聚集索引中查找整行数据,这个过程就是回表。再实际开发中尽可能要避免回表查询。

比如我上面的表结构,聚簇索引是主键ID,非聚簇索引是name,那么看如下SQL:

select *
from t_user
where name = 'Kit';

该SQL会先走非聚簇索引,拿到主键ID,然后由于我是select的所有字段,所以,还会拿着这个主键ID去走一遍聚簇索引,拿到剩余字段。

idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEt_usernullreft_user_name_indext_user_name_index82const1100null

注意这里的Extra是null,所以肯定是回表了的。

覆盖索引

是指查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到 。

-- 第一句
select * from tb_user where id = 1; 覆盖索引

-- 第二句
select id, name from tb_user where name = 'Arm'; 覆盖索引

-- 第三句
select id, name, gender from tb_user where name = 'Arm'; 非覆盖索引需要回表

覆盖索引处理MySQL超大分页问题

  • 问题:当在进行分页查询时,如果执行 limit 9000000,10 ,此时需要MySQL排序前9000010 记录,仅仅返回 9000000 - 9000010 的记录,其他记录丢弃,查询排序的代价非常大 。
  • 优化思路: 一般分页查询时,通过创建 覆盖索引 能够比较好地提高性能,可以通过覆盖索引加子查询形式进行优化
select *
from tb_sku t,
    (select id from tb_sku order by id limit 9000000,10) a
where t.id = a.id;

这段代码展示了 MySQL 大分页查询优化的一种常见技巧(延迟关联):

  • 子查询部分(select id from tb_sku order by id limit 9000000,10)。这步只查询 id 字段,可以利用覆盖索引快速定位到第 9,000,000 行开始的 10 个 ID,避免了全表扫描和大量的回表操作。
  • 主查询部分:通过 where t.id = a.id 将这 10 个 ID 与原表关联,从而获取这 10 条记录的所有字段信息。

相比于直接执行 select * from tb_sku limit 9000000, 10,这种方式能显著提升超大偏移量分页的查询性能。

索引创建原则

  • 针对于数据量较大,且查询比较频繁的表建立索引。
  • 针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。
  • 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
  • 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。
  • 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。
  • 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率。
  • 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。

索引失效

假设我们有一张用户表 tb_user,并针对 profession (职业)、age (年龄)、status (状态) 三个字段建立了联合索引CREATE INDEX idx_user_pro_age_sta ON tb_user(profession, age, status);

索引失效的情况:


1. 违反最左前缀法则

规则:查询必须从索引的最左列开始,且不能跳过中间列。

  • 完全失效:跳过了最左列 profession

    -- 失效:直接从 age 开始,没有 profession
    select * from tb_user where age = 31 and status = '0';
    
  • 部分失效:跳过了中间列 age

    -- 只有 profession 走索引,status 不走索引(中间断开了)
    select * from tb_user where profession = 'Software Engineering' and status = '0';
    

2. 范围查询右边的列失效

规则:联合索引中,出现范围查询(><)后的列索引失效。

  • 例子

    -- profession 和 age 走索引,但 status 索引失效
    -- 因为 age 使用了范围查询 '>'
    select * from tb_user where profession = 'Software Engineering' and age > 30 and status = '0';
    

    提示:在业务允许的情况下,使用 >=<= 通常可以规避部分失效问题。

3. 在索引列上进行运算操作

规则:对索引字段使用函数或数学计算,MySQL 将无法利用索引。

  • 例子

    -- 失效:对 phone 字段进行了截取操作
    select * from tb_user where substring(phone, 10, 2) = '15';
    
    -- 失效:对 age 进行了数学运算
    select * from tb_user where age + 1 = 30;
    

4. 字符串不加单引号(隐式类型转换)

规则:如果字段类型是字符串,但查询时未加引号,MySQL 会进行隐式转换,导致索引失效。

  • 例子

    -- 假设 phone 是 varchar 类型
    -- 失效:未加单引号,发生了类型转换
    select * from tb_user where phone = 17612345678;
    

5. Like 模糊查询以 % 开头

规则:头部模糊匹配会导致全表扫描。

  • 失效例子(头部模糊)

    -- 失效:百分号在前面
    select * from tb_user where profession like '%Engineering';
    
  • 有效例子(尾部模糊)

    -- 走索引:百分号只在后面
    select * from tb_user where profession like 'Software%';
    

sql优化

  • 表的设计优化

    比如设置合适的数值(tinyint int bigint),要根据实际情况选择

    比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低

  • 索引优化(参考优化创建原则和索引失效)

  • SQL语句优化

    1. SELECT语句务必指明字段名称(避免直接使用select * )
    2. SQL语句要避免造成索引失效的写法
    3. 尽量用union all代替union,union all会展示重复的数据,而union会多一次过滤,效率低
    4. 避免在where子句中对字段进行表达式操作(可能会导致索引失效)
    5. Join优化 能用innerjoin 就不用left join right join,如必须使用 一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序
  • 主从复制、读写分离

    如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响 可以采用读写分离的架构。读写分离解决的是,数据库的写入,影响了查询的效率。

  • 分库分表

事务隔离级别

在数据库的并发控制中,事务隔离级别(Transaction Isolation Levels) 是为了平衡数据一致性系统并发性能而设计的规范。

MySQL 的 InnoDB 存储引擎完全支持 SQL:1992 标准定义的四个隔离级别。

一、 并发事务带来的三大问题

在聊隔离级别之前,我们需要先了解如果不进行隔离,并发事务同时操作同一行数据会发生什么“惨案”:

  1. 脏读 (Dirty Read): 事务 A 读取了事务 B 还没提交的数据。如果事务 B 随后回滚,事务 A 读到的就是非法数据。
  2. 不可重复读 (Non-repeatable Read): 事务 A 在同一个事务内多次读取同一条数据,结果不一致。这是因为在两次读取之间,事务 B 修改并提交了该数据。
  3. 幻读 (Phantom Read): 事务 A 按某个范围查询数据,发现多了或少了几行。这是因为在查询期间,事务 B 插入或删除了符合条件的行并提交了。

img

二、 MySQL 的四大隔离级别

为了解决上述问题,隔离级别从低到高分为四层,级别越高,数据越安全,但性能开销也越大。

1. 读未提交 (Read Uncommitted)

  • 特性: 一个事务可以读取到另一个未提交事务修改的数据。
  • 存在问题: 脏读、不可重复读、幻读。
  • 评价: 性能最高,但几乎没人用,安全性太低。

2. 读已提交 (Read Committed, RC)

  • 特性: 只能读取到已经提交的数据。
  • 解决问题: 解决了脏读
  • 存在问题: 不可重复读、幻读。
  • 应用场景: 许多主流数据库(如 Oracle, SQL Server)的默认隔离级别。

3. 可重复读 (Repeatable Read, RR) — MySQL 默认级别

  • 特性: 在同一个事务中,多次读取同一记录的结果是一致的。
  • 解决问题: 解决了脏读不可重复读
  • 特殊点: MySQL 的 InnoDB 通过 MVCC(多版本并发控制)和 Next-Key Locks 很大程度上解决了幻读问题。 这是它相比于标准 SQL 规范的强大之处。

4. 串行化 (Serializable)

  • 特性: 所有的事务都按顺序执行。它会对读取的每一行数据都加锁(读写冲突)。
  • 解决问题: 解决所有并发问题。
  • 评价: 性能最差,通常只在对数据绝对敏感且并发量极低的场景下使用。
隔离级别脏读不可重复读幻读备注
Read Uncommitted❌ (存在)❌ (存在)❌ (存在)极少使用
Read Committed✅ (解决)❌ (存在)❌ (存在)Oracle/PostgreSQL 默认
Repeatable Read✅ (解决)✅ (解决)⚠️ (InnoDB 优化)MySQL 默认
Serializable✅ (解决)✅ (解决)✅ (解决)性能极低

虽然 MySQL InnoDB 在 可重复读 (RR) 级别下通过 MVCC (多版本并发控制)Next-Key Locks (临键锁) 已经解决了绝大多数场景下的幻读,但在一些特殊的并发操作序列下,幻读依然会“现身”。

我们可以把 InnoDB 解决幻读的方案分成两套“拳法”:

  1. 快照读 (Snapshot Read): 普通的 SELECT 语句。靠 MVCC 解决,它让你看到的是事务开始时的快照,别人新插的数据你看不到。
  2. 当前读 (Current Read): SELECT ... FOR UPDATEUPDATEDELETE 等。靠 Gap Lock (间隙锁) 解决,它会在范围内加锁,不准别人插新数据。

但当这两套拳法“混用”或者顺序不对时,幻读就钻了空子。

幻读依然发生的两个典型场景:

一、“后发制人”的更新操作 (最经典)

假设表 user 中原本没有 id=5 的记录。

  • 事务 A: 先执行 SELECT * FROM user WHERE id=5。由于是快照读,结果是
  • 事务 B: 插入一条 id=5 的数据并 提交
  • 事务 A: 此时执行 UPDATE user SET name='new' WHERE id=5
    • 关键点: UPDATE当前读。它会去磁盘读最新的数据,结果它竟然发现并成功更新了事务 B 刚插进去的那行!
  • 事务 A: 再次执行 SELECT * FROM user WHERE id=5
    • 炸裂点: 根据 MVCC 的规则,一个事务能看到自己修改过的数据。因为事务 A 刚才更新了这行,它现在的版本号属于事务 A 了。于是,事务 A 的第二次查询竟然查出了这行数据!

结论: 在事务 A 看来,就像凭空变出来的一样,这就是幻读。

二、快照读切换到当前读

  • 事务 A: 执行普通 SELECT 查询某个范围(快照读),没发现新行。
  • 事务 B: 插入新行并 提交
  • 事务 A: 突然想给这些行加锁,执行了 SELECT ... FOR UPDATE(当前读)。
    • 结果: 由于当前读会读取数据库中最新的记录,事务 A 这一次查询会立刻看到事务 B 插入的数据。

结论: 同一个事务内,查询结果集变了。

什么是快照读和当前读?

简单来说,快照读(Snapshot Read)让你看到的是“过去”,而当前读(Current Read)让你看到的是“现在”。

在 MySQL InnoDB 引擎中,为了同时兼顾高并发性能数据一致性,设计了这两套不同的读取机制。

一、 快照读 (Snapshot Read)

快照读就是普通的 SELECT 查询。它不需要加锁,而是通过 MVCC(多版本并发控制) 来实现。

  • 核心逻辑: 它读取的是数据的“快照版本”。即便当前有其他事务在修改这些数据,快照读也会去 Undo Log(回滚日志) 里找之前的老版本,从而实现非阻塞的并发读。

  • 适用场景: 普通的、对实时性要求不是极高的查询。

  • 代表语句:

    SELECT * FROM table WHERE id = 1;
    
  • 特点:

    • 不加锁: 性能极高,不会被其他事务的写操作阻塞。
    • 可见性受隔离级别影响:
      • RC(读已提交): 每次执行 SELECT 都会生成一个新的快照,所以能读到别人刚提交的修改。
      • RR(可重复读): 整个事务只在第一次 SELECT 时生成快照,后面再查都用同一个,所以能保证“可重复读”。

二、 当前读 (Current Read)

当前读是指读取数据库中最新、最真实的数据,并且为了防止在读取过程中数据被别人改掉,它会对读取的行进行加锁

  • 核心逻辑: 必须读取到磁盘上已经提交的最新的那一行记录,不能读回滚日志里的老版本。
  • 适用场景: 涉及修改操作(更新、删除、插入)或者需要保证绝对数据实时的查询。
  • 代表语句:
    1. SELECT ... FOR UPDATE (加排他锁/X锁)
    2. SELECT ... LOCK IN SHARE MODE (加共享锁/S锁)
    3. INSERT
    4. UPDATE
    5. DELETE
  • 特点:
    • 加锁: 会阻塞其他事务对这些行的修改(或获取锁)。
    • 保证一致性: 强制读最新,不会因为版本快照而产生数据延迟。

三大log文件

在 MySQL(尤其是使用 InnoDB 存储引擎时)中,redologundologbinlog 是保证数据库事务 ACID 特性、崩溃恢复和主备复制的核心机制。

三大日志概览

特性Redo Log (重做日志)Undo Log (回滚日志)Binlog (归档日志)
所属层级InnoDB 存储引擎层InnoDB 存储引擎层MySQL Server 层
文件格式物理日志(记录数据页的变化)逻辑日志(记录相反的操作)逻辑日志(记录 SQL 或数据行)
记录内容“在某个数据页上做了什么修改”“这条记录修改前的值是多少”“更新了哪一行” 或 “执行了某条 SQL”
主要作用崩溃恢复(保证持久性)事务回滚 & MVCC(原子性、隔离性)主从复制 & 数据恢复
写入方式循环写(空间固定,会覆盖)逻辑写入(存储在回滚段中)追加写(文件满了切下一个)

Redo Log:保证“不丢数据”的硬汉

作用: Redo Log 是为了实现事务的持久性(Durability)。当数据库宕机时,内存中尚未刷到磁盘的数据(脏页)会丢失,重启后通过 Redo Log 重新执行操作,确保数据不丢失。这就是所谓的 WAL(Write-Ahead Logging,预写日志) 技术。

  • 产生时机: 只要有数据修改,就会先写到 redo log buffer,然后根据策略(如 innodb_flush_log_at_trx_commit)刷到磁盘文件。
  • 清除时机: Redo Log 的大小是固定的(通常是几个文件组成的环形结构)。当日志对应的“脏页”已经被真正刷新到磁盘数据文件(.ibd)后,这部分日志就失效了,空间可以被覆盖重用

Undo Log:后悔药与多版本管理

作用: Undo Log 主要负责原子性(Atomicity)和 MVCC(多版本并发控制)

  1. 回滚: 如果事务失败或执行了 ROLLBACK,MySQL 利用 Undo Log 将数据改回原来的样子(记录相反的操作:比如你执行了 INSERT,它记录一个 DELETE)。
  2. MVCC: 当一个事务在读取数据时,如果有另一个事务正在修改,它可以通过 Undo Log 读取到之前的版本。
  • 产生时机: 在事务开始修改数据之前,会先记录该行的原始状态到 Undo Log 中。
  • 清除时机: 事务提交后,Undo Log 不会立刻删除。它需要等待 purge 线程 确认没有其他事务再需要这个版本(用于 MVCC)时,才会异步地进行清理。

Binlog:集群复制与数据溯源

作用: Binlog 记录了所有修改数据库结构的 DDL 和 DML 语句,属于 MySQL Server 层,与引擎无关。

  1. 主从复制: Master 将 Binlog 发送给 Slave,Slave 重放实现数据同步。
  2. 数据恢复: 如果数据库被误删,可以结合全备和 Binlog 进行“增量恢复”到指定时间点。
  • 产生时机: 事务提交时,一次性将整个事务的日志写入 Binlog 文件。
  • 清除时机: 通过配置项 expire_logs_days(或新版本的 binlog_expire_logs_seconds)设置保存天数。超过时间后,系统会自动删除旧文件。

MVCC

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 MySQL InnoDB 存储引擎实现隔离级别(主要是 RC 读已提交RR 可重复读)的核心机制。

串行化和读未提交这两个事务级别没有用到MVCC

简单来说,它的目的是:让数据库在处理读写冲突时,不用加锁也能保证数据的安全性,从而大幅提升并发性能。

MVCC 的核心实现原理

MVCC 的实现依赖于三个关键组件:隐藏列Undo Log(回滚日志)ReadView(一致性视图)

隐藏列

InnoDB 在每行数据后面都会默默添加几个隐藏字段:

  • DB_TRX_ID:最近修改这条数据的事务 ID
  • DB_ROLL_PTR回滚指针,指向这条记录的上一个版本(存放在 Undo Log 中)。
  • DB_ROW_ID:如果表没有主键,InnoDB 会自动生成的隐藏主键。
Undo Log(版本链)

当一个事务修改数据时,InnoDB 不会直接覆盖旧数据,而是先把旧数据写入 Undo Log。 通过隐藏列中的 DB_ROLL_PTR 指针,将这些旧版本的数据串联起来,形成一个版本链

ReadView(一致性视图)

ReadView 是事务在进行“快照读”时产生的一个结构,它定义了哪些数据版本对当前事务是可见的。它包含四个核心字段:

  1. m_ids:生成 ReadView 时,系统中当前正在活跃(未提交)的事务 ID 列表。
  2. min_trx_id:活跃事务中最小的 ID。
  3. max_trx_id:系统即将分配给下一个事务的 ID 值。
  4. creator_trx_id:创建这个 ReadView 的事务 ID。

可见性判断规则

当事务想读某行数据时,会拿着该行当前的 trx_id 与 ReadView 进行比对:

  1. trx_id == creator_trx_id:这个版本是我自己改的,可见
  2. trx_id < min_trx_id:说明这个事务在 ReadView 创建前已经提交了,可见
  3. trx_id >= max_trx_id:说明这个事务在 ReadView 创建后才启动,不可见
  4. min_trx_id <= trx_id < max_trx_id
    • 如果 trx_idm_ids 列表中:说明事务还没提交,不可见
    • 如果不在:说明事务已提交,可见

如果当前版本不可见,就顺着版本链(Undo Log)往回找,直到找到可见的版本。

图示:

下面我结合图片来解释这一个过程,图片主要参考的是黑马程序员的MySQL相关课程

假设当前我有五个事务,他们同时开始,但是事务提交的时间不一致,如下图表格所示:

事务2事务3事务4事务5
开始事务开始事务开始事务开始事务
修改id为30记录,age改为3查询id为30的记录
提交事务
修改id为30记录,name改为A3
查询id为30的记录
提交事务
修改id为30记录,age改为10
查询id为30的记录
查询id为30的记录
提交事务

我们先来看一下Undo Log版本链的产生过程:

最开始我们的记录如图所示:

image-20251220145553663

然后事务二开始了,它需要把id为30的数据的age改为3:

image-20251220150025647

此时,InnoDB 会将旧版本数据写入 Undo Log,并让当前记录的 DB_ROLL_PTR 指向该快照,形成版本链

接着事务三来了,它把id为30的数据的name改为A3,同样需要在UndoLog里产生一个快照记录。并调整指针的指向,最终如下图所示:

image-20251220150631638

同样的道理,当事务四也修改了记录,所以也会有一个“快照记录”,我这里就直接给出最后的图:

image-20251220151400080

不同的是事务或相同事务对同一条数据进行刘修改,会导致该记录的undolog生成一条记录版本的链表,链表的头部是最新的旧记录,链表的尾部是最早的旧记录。

到这里我觉得你肯定已经明白这个Undo Log版本链是如何产生的了,那么接下来我们来看看事务五的两个查询究竟会返回哪条记录!

回顾一下前面说的一致性视图(ReadView)有的地方也叫读视图,它是快照读SQL执行的时,MVCC提取数据的依据,记录并维护当前系统活跃的事务(未提交)的id。这个视图和隔离级别有一定的关系,比如在RC(Read Committed)级别下:每次select都会生成一个快照读。而在RR(Repeatable Read)开启事务后的第一个select语句才是产生快照读的地方。

ReadView维护了四个字段:

字段含义
m_ids当前活跃的事务ID集合,只有未提交(Active)的事务才会进入 m_ids
min_trx_id最小活跃事务ID
max_trx_idReadView生成时,系统即将分配给下一个事务的 ID
creator_trx_idReadView创建者的事务ID

这里我来再说一下这个活跃的事务ID是啥意思,以我开始的那个表格为例:当我的事务五的第一个查询语句时刻,那个时刻,活跃的事务ID一共有3,4,5。因为事务一已经commit了。

我现在先以RC隔离级别来说一下这个ReadView是如何生成的:

drawio

在RC模式下,每次select都会有一个属于自己的ReadView。

然后获取记录,需要按照如下的规则,并结合开始版本链来完成,先看规则,规则如下:

序号判断条件结论详细说明
trx_id == creator_trx_id可以访问该版本说明数据是当前这个事务自己更改的。
trx_id < min_trx_id可以访问该版本说明该事务在 ReadView 生成前已经提交了。
trx_id > max_trx_id不可以访问该版本说明该版本的事务在当前 ReadView 生成后才启动,因此不可见。
min_trx_id <= trx_id <= max_trx_id视情况而定如果 trx_id 不在 m_ids 中,可以访问。说明该事务在 ReadView 生成前已提交。

现在我针对第一个读视图来,看一下它获取的是哪一条数据:

image-20251220151400080

目前版本链中有 4 条记录(1 条当前记录,3 条在 Undo Log 中)。

ReadView 状态: m\_ids: \{3, 4, 5\}min\_trx\_id: 3max\_trx\_id: 6creator\_trx\_id: 5

我们从最新版本(当前记录)开始按顺序往后找:

  1. 分析当前记录(trx\_id = 4
  • 规则①(是否是自己): trx\_id(4) \neq creator\_trx\_id(5),不是当前事务修改的。
  • 规则②(是否已提交): trx\_id(4) \nless min\_trx\_id(3),说明该版本在 ReadView 生成时可能还未提交。
  • 规则③(是否太新): trx\_id(4) < max\_trx\_id(6),说明该事务不是在 ReadView 生成后才开启的。
  • 规则④(活跃事务判断): 虽然 trx\_id 在 3 和 6 之间,但 4 \in m\_ids结论: 说明 ReadView 生成时,事务 4 还处于“活跃未提交”状态,不可见
  1. 分析 Undo Log 版本链
  • trx\_id = 3 的记录: 同理,虽然 3 < 6,但 3 \in m\_ids,说明事务 3 当时也未提交,不可见
  • trx\_id = 2 的记录: 此时 trx\_id(2) < min\_trx\_id(3)
    • 结论: 根据规则 ②,该版本在 ReadView 生成前就已经提交,可见

最终结果:第一个 ReadView 最终获取的是 事务 2 提交的数据。

其实这里我觉得去套这个公式有点蠢了,你自己看你的事务隔离级别,你事务隔离级别不是读已提交吗,那么ReadView读的肯定就是最新提交的数据呀。一眼就能判断出读取的是哪个数据。RC就是找事务比自己先开启的,并且已经提交的,就可以读。

同理:如果是在RR级别下,由于会复用第一次select产生的ReadView,所以无论你读多少次,都以第一次select到的数据为准。

RC 级别: 本质上就是“取最新已提交的版本”。每次 SELECT 都重新生成 ReadView,所以它能看到别人刚提交的修改。

RR 级别: 本质上是“静态快照”。只在第一次 SELECT 时生成 ReadView,后续无论别人怎么改,它都只认第一次看到的世界。

数据库主从同步

MySQL 的主从同步(Replication)是实现高可用、负载均衡和读写分离的核心机制。其核心原理可以概括为:主库(Master)记录变更日志,从库(Slave)读取并重放这些日志。

日志文件:

  • Binary Log (Binlog):存在于主库,记录了所有修改数据库结构的 DDL 和修改数据的 DML 操作。
  • Relay Log (中继日志):存在于从库,它是从主库拿过来的 Binlog 的临时“中转站”。

线程:

  • Binlog Dump Thread (主库):当从库连接时,主库会创建该线程,负责发送 Binlog 内容给从库。
  • I/O Thread (从库):负责连接主库,请求 Binlog 内容,并将其写入本地的 Relay Log。
  • SQL Thread (从库):负责读取 Relay Log 中的内容,并在从库本地执行,从而保持数据一致。

三种同步方式

模式描述优点缺点
异步复制 (Asynchronous)主库写完 Binlog 后立即返回客户端,不关心从库是否收到。性能最高,延迟最低。如果主库宕机且日志未传到从库,可能丢失数据。
半同步复制 (Semi-Synchronous)主库至少等待一个从库确认收到 Relay Log 后才返回。数据安全性较高。会增加主库写操作的响应时间(RT)。
全同步复制 (Fully Synchronous)必须等所有从库都执行完并反馈后才返回。数据最安全。性能损耗巨大,通常由集群方案(如 MGR)实现。

分库分表

当单表数据量达到千万级,或者单库并发请求过高时,MySQL 的性能会显著下降。这时候就需要考虑分库分表

分库分表的核心思想是:化整为零,分而治之


1. 拆分的两个维度

分库分表通常分为“垂直拆分”和“水平拆分”两种方式:

垂直拆分(Vertical Splitting)

  • 垂直分库:按业务模块将表拆分到不同的数据库中。例如:将用户表、订单表、商品表分别放在 user_dborder_dbproduct_db
  • 垂直分表:将一张表中的字段拆分到多张表中。通常将 高频访问列低频/大字段列(如 text 类型的备注、详情)分开,以减少单行数据的体积。

水平拆分(Horizontal Splitting)

  • 水平分表:表的结构不变,按某种规则将数据行存入不同的表中(如 order_0, order_1)。
  • 水平分库:将不同的数据行存入不同的物理数据库实例中,主要解决单机高并发和磁盘 IO 的瓶颈。

2. 数据的分片策略(Sharding Strategy)

如何决定一条数据该去哪个表?常见的策略有:

  1. 取模(Hash)
    • 原理user_id % 4,将数据均匀分布在 4 个分片。
    • 优点:数据分布均匀,不易产生热点。
    • 缺点:扩容极其麻烦(需要全量数据迁移)。
  2. 范围(Range)
    • 原理:按时间(如每月一张表)或按自增 ID 范围。
    • 优点:扩容简单,无需迁移历史数据。
    • 缺点:容易产生热点问题(比如最近一个月的数据会被疯狂读写,而老的表却很闲)。
  3. 查找表(Map)
    • 原理:在元数据库中记录每条数据对应的分片位置。虽然灵活,但维护成本高且有性能损耗。

3. 分库分表带来的技术挑战

这是面试和实际架构中最难的部分,因为拆分后会破坏原生数据库的特性:

  • 分布式事务问题:原本一个事务就能解决的写操作,现在跨了库。通常需要引入 Seata 这种分布式事务中间件,或者使用 TCC、可靠消息最终一致性 等方案。
  • 跨节点 Join 问题:无法直接在 SQL 里 Join 不同库的表。
    • 对策:字段冗余(空间换时间)、全局表(每个库都存一份小表)、在应用层(Java 代码)多次查询后组装。
  • 分页与排序(Max/Count/Order By)
    • 对策:需要在每个分片执行后,在中间件层面进行归并排序。如果偏移量(Offset)很大,性能会急剧下降。
  • 全局唯一 ID:单表自增 ID 失效。
    • 对策:使用 雪花算法(Snowflake) 或分布式 ID 生成器。

4. 常见的中间件解决方案

目前主流的方案分为两类:

类型代表产品优点缺点
客户端代理 (SDK)ShardingSphere-JDBC性能高,无需独立部署,支持 Spring Boot。对代码有侵入性,升级需重启应用。
服务端代理 (Proxy)MyCat, ShardingSphere-Proxy对代码透明(像连 MySQL 一样),跨语言。增加了一层网络消耗,需要运维中间件。

5. 什么时候该分库分表?

不要为了“高大上”而分。阿里巴巴开发手册建议:单表行数超过 500 万行或者单表容量超过 2GB 时才考虑。

在动手之前,应优先考虑以下顺序:

  1. SQL 优化与索引优化
  2. 读写分离(利用主从架构缓解读压力)。
  3. 缓存(Redis)
  4. 冷热数据分离(将历史旧数据归档)。
  5. 最后才是分库分表