目录

《MySQL实战45讲》阅读笔记——基础篇

关于 MySQL 基础架构、日志系统、事务、索引和锁的相关概念


01 | 基础架构:一条 SQL 查询语句是如何执行的?

MySQL 的逻辑架构可分为 Server 层和存储引擎层。

Server 层包括了连接器、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

而存储引擎层负责数据的存储和提取。其架构模式为插件式,支持 InnoDB、MyISAM、Memory 等多个存储引擎。

https://picx-img.pjmcode.top/20240504/image-image.2yy5v9tyli.webp
MySQL架构图

连接器

主要工作负责与客户端连接。通过认证之后根据权限表得到拥有的权限,之后的操作权限判定都会依靠此时获取的权限信息。

对于长连接内存占用太大的问题,原因是 MySQL 在执行过程中临时使用的内存是管理在连接对象里的。这些资源会在连接断开的时候才释放。解决方案:

  1. 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
  2. 如果使用 MySQL 5.7 或以上版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。

分析器

工作是对 SQL 语句做一个“词法分析”,识别字符串。识别完成之后做“语法分析”,根据语法规则判断是否符合 MySQL 语法。

优化器

经过分析器的处理,已经知道了要做什么操作。在执行之前,需要对操作进行优化。优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序

执行器

MySQL 通过分析器知道了你要做什么,通过优化器知道了该怎么做,于是就进入了执行器阶段,开始执行语句。执行步骤如下:

  1. 判断是否拥有权限,若没有,则返回权限错误。
  2. 根据表的引擎去调用引擎提供的接口。

02 | 日志系统:一条 SQL 更新语句是如何执行的?

MySQL 重要的日志模块有 redo log 和 binlog。

redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。

sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。

redo log

该日志模块的关键点在于 WAL(Write Ahead Logging),即先写日志,再写磁盘。避免了更新时找到对应日志记录,修改相关数据,最后再写入磁盘所花费的 I/O 代价。InnoDB 会在合适的时候将 redo log 上的记录更新到磁盘中。

https://picx-img.pjmcode.top/20240504/image-image.2krq4obq86.webp
redo log示意图
write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

write pos 和 checkpoint 之间的是 redo log 上还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示 redo log 满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe

binlog

redo log 是 InnoDB 自带的日志系统,而 Server 层的日志模块是 binlog。

它们之间的区别如下:

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

执行器和 InnoDB 引擎在执行update T set c=c+1 where ID=2;语句时的内部流程。

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

update 语句的执行流程图,图中浅色框表示是在 InnoDB 内部执行的,深色框表示是在执行器中执行的。

https://picx-img.pjmcode.top/20240504/image-image.3d4lmfh9b7.webp
update语句执行流程

两阶段提交

上图可以注意到,在 redo log 中,存在 prepare 和 commit 两个阶段。两阶段提交就是让 redo log 和 binlog 这两个状态保持逻辑上的一致。

03 | 事务隔离:为什么你改了我还看不见?

事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。

隔离性与隔离级别

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

事务隔离的实现

https://picx-img.pjmcode.top/20240504/image-image.2doi9b6ucg.webp
undo log示意图

对于每个事务的视图,想要获取视图的值,就得将当前值依次执行图中所有的回滚操作得到。

当系统里没有比这个 undo log 更早的 read-view 的时候,会将这个 undo log 删除。

事务的启动方式

  1. 显式启动事务语句, beginstart transaction。配套的提交语句是 commit,回滚语句是 rollback
  2. set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commitrollback 语句,或者断开连接。

建议使用 set autocommit=1, 通过显式语句的方式来启动事务。

autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit 则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。

如何避免长事务对业务的影响?

从应用开发端来看:

  • 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
  • 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
  • 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。

从数据库端来看:

  • 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
  • Percona 的 pt-kill 这个工具不错,推荐使用;
  • 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
  • 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

04&05 | 深入浅出索引

索引的作用是用于加快数据的查询操作。

索引的常见模型

  1. 哈希表:只适用于等值查询的场景
  2. 有序数组:等值查询和范围查询场景中的性能都非常优秀
  3. 树:维护和查询的操作都是 log(n)

其中,InnoDB 的存储引擎使用 B+Tree 作为底层数据结构。

覆盖索引

如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。

覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

06 | 全局锁和表锁 :给表加个字段怎么有这么多阻碍?

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。

数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。

全局锁

全局锁主要用在逻辑备份过程中。对于全部是 InnoDB 引擎的库,建议你选择使用 –-single-transaction 参数,对应用会更友好。但 --single-transaction 方法只适用于所有的表使用事务引擎的库。

MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

表级锁

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是 lock tables … read/write。可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。InnoDB 支持行锁,因此表锁的使用场景不多。

另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。

如何安全地给小表加字段?

主要思想是避免长事务,一个方法是在alter table语句中加入等待时间。或者 kill 长事务。

07 | 行锁功过:怎么减少行锁对性能的影响?

如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

死锁与死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。

https://picx-img.pjmcode.top/20240504/image-image.5tqu1hyi08.webp
死锁出现的情况

当出现死锁以后,有两种策略:

  • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
  • 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

怎么解决由这种热点行更新导致的性能问题呢?

一个方法是关闭死锁检测。另一个方法是控制并发度(最好在服务端进行控制)

08 | 事务到底是隔离的还是不隔离的?

“快照”在 MVCC 里是怎么工作的?

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id

https://picx-img.pjmcode.top/20240504/image-image.3k7ti0il9p.webp
记录行多个事务更新状态图

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

https://picx-img.pjmcode.top/20240504/image-image.lvjeidzmw.webp
数据版本可见性规则

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况:
    • row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    • row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

因此,在一个事务视图的数据版本中,有三种情况:

  1. 版本未提交,不可见;
  2. 版本已提交,但是是在视图创建后提交的,不可见;
  3. 版本已提交,而且是在视图创建前提交的,可见。

更新逻辑

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。当然在加锁操作的情况下,select 操作也会用到当前读。

事务的可重复读的能力是怎么实现的?

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。