MySQL 的一些动作的执行流程记录。

# 更新

执行:

update T set c=c+1 where ID=2;

其中浅色由 InnoDB 引擎执行,深色由 server 层执行器执行。先写入 redo log,标记为 prepare;再写入 binlog;再提交事物,标记 redo log 为 commit,此为两阶段提交。

为什么需要?
确保数据正确性。binlog 属于 Server 层的日志,而 InnoDB 层为实现 crash-safe ,实现了 redo log 日志。它们有以下不同:

  • redo log 是物理日志,记录了数据页做的修改;binlog 是逻辑日志,记录了一个语句的原始逻辑。
  • redo log 为循环写入;binlog 为追加写,达到一定大小后切换下一个文件。

猜测应该是历史原因产生了这两个日志,而要保证两处一致的情况下,就需要二阶段提交来保证。如果一开始考虑 crash-safe 和数据恢复 / 同步,那应该一个日志也可以。

为什么 binlog 不能实现数据恢复呢?binlog 只记录了数据的逻辑改变,但没有记录数据成功提交的标记。比如执行以下语句:

update t set score=score + 1 where id = 1;
update t set score=score + 1 where id = 2;

只靠 binlog 无法得知哪条数据已经刷入磁盘持久化了,如果重复执行了逻辑,则会导致数据错误。

而为什么要两阶段提交?在必须写入两个日志的情况下,如果不二阶段提交会产生以下情况:

  1. 先写 redo log,再写 binlog
    redo log 写入成功而 binlog 失败,重启后引擎发现 redo log 有记录提交了事务,改变生效;而 binlog 中并没有该记录,如果进行数据恢复 / 主从同步,该 binlog 就会有问题。

  2. 先写 binlog 再写 redo log
    binlog 成功而 redo log 失败,重启后引擎发现 redo log 没有记录,事务回滚,改变并没有生效;而 binlog 却记录了这条记录,同样会导致不一致。

而两阶段提交后就可保证:

  • redo log 成功后 prepare,此时后续失败,恢复时事务无效,binlog 无记录,一致。
  • redo log 成功,binlog 成功,还未 commit,此时恢复发现 binlog 存在记录,redo log 处于 prepare,事务可以提交,一致。

# COUNT

不同的引擎 count 操作有不同,在 MyISAM 中,引擎保存了总数,因此 count(*) 将会很快(如果有条件则需要扫描),而 InnoDB 由于 MVCC 的原因,一个查询应该返回多少行是不确定的,所以得一行行的去扫,然后返回。

show table status

该语句返回的记录行数是不准确的,它仅仅是通过采样估算来的,误差较大。

不同的语句执行

  1. count (主键 id),InnoDB 会遍历整个表,把每一行 id 值取出来返回给 server 层,server 层拿到 id,判断是不可能为空的,就按行累加。
  2. count (1),遍历整个表,但不取值。server 层对于返回的每一行,放一个数字 “1” 进去,判断是不可能为空的,按行累加
  3. count (字段),如果字段定义为 not null,一行行从记录读出字段,判断不能为 null,按行累加。如果是允许为 null,判断到有可能为空还需要把值取出来判断,不为 null 才累加。
  4. count (*),并不会取出全部字段,而是专门做了优化,不取值。count (*) 肯定不是 null,按行累加。

因此其执行效率排序为:count (字段) < count (主键 id) < count (1) ≈ count (*),因此建议使用 count (*)。

由上可知,使用 count 函数会比较影响性能,尤其在数据量大的情况下,这里可以考虑自行计数的方式保存数量。

同样放在 MySQL 中,例如使用单独的表保存,可以通过事务保证插入 / 删除记录与计数更新的一致性。如果使用 redis,则存在更新 redis,写入数据库的步骤,在其间可能出现数据不一致的情况。

# ORDER BY

排序是常用的操作,假设创建如下的表:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `city` varchar(16) NOT NULL,
  `name` varchar(16) NOT NULL,
  `age` int(11) NOT NULL,
  `addr` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `city` (`city`)
) ENGINE=InnoDB;

此时对于查询语句:

select city,name,age from t where city='杭州' order by name limit 1000  ;

将如何工作呢?

通常情况下,这个语句执行流程如下所示 :

  1. 初始化 sort_buffer,确定放入 name、city、age 这三个字段;

  2. 从索引 city 找到第一个满足 city=' 杭州’条件的主键 id,也就是图中的 ID_X;

  3. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,存入 sort_buffer 中;

  4. 从索引 city 取下一个记录的主键 id;

  5. 重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;

  6. 对 sort_buffer 中的数据按照字段 name 做快速排序;

  7. 按照排序结果取前 1000 行返回给客户端。

如图所示:

而对于排序,可能在内存中完成,也可能在外部排序,取决于排序所需的内存和 sort_buffer_size 参数。

该参数就是为排序而开辟的内存,如果要排序的数据量小于它,就在内存中排,否则需要在磁盘的临时文件中辅助排序。

rowid 排序
上面的流程可以看出,如果取出的数据较多,则 sort_buffer 将消耗较大空间,因此当 MySQL 认为排序数据的单行长度超过一定值,就会更换一个算法,该值由 max_length_for_sort_data 指定。

当超过时,MySQL 将只把主键与排序的字段取出,然后进行排序,排序后再根据 id 回表取值:

优化的目的就是减少磁盘访问,尽量能在内存中完成,或是更少的使用临时文件。

总结出来,为什么需要排序?是因为查询的数据是无序的,因此如果设置了索引,刚好就满足了有序,就不再需要排序了。例如上述查询如果添加 (city,name) 索引,根据 city 筛选后,name 已经有序了,可以直接根据主键获取结果返回。如果索引满足覆盖索引则可以直接返回,能够大大提高查询速度。

# JOIN

对于如下的语句,查询的过程是怎样的呢?

select * from t1 join t2 on t1.a = t2.a;

流程如下:

  1. 从表 t1 中读入一行数据 R;

  2. 从数据行 R 中,取出 a 字段到表 t2 里去查找;

  3. 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;

  4. 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。

因此,对于 t1 驱动表来所,它需要扫描全表,如果有条件那就是条件过滤后的所有数据都要遍历,然后根据 join 字段去 t2 上匹配。

可以看出,如果 t2 的 join 字段上有索引的话,就可以避免每次匹配都全表扫描,这称为 Index Nested-Loop Join

实际 MySQL 对此有优化,对于 join 没有索引的字段,驱动表的数据将扫入内存,然后去被驱动表 t2 中一行行读取判断有没有匹配的,虽然比较次数未变,但在内存中进行速度将快一些。如果 t1 数据太多放不下,将会分段放:即放一批进 join_buffer,扫 t2 对比,对比完再放入下一批,再扫 t2 对比。这也叫做 Block Nested-Loop Join,因此 join_buffer 小将导致 t2 被多次全表扫描。

即使有优化仍旧需要扫描大量的数据,因此如果 join 字段没有索引则不建议使用。而对于驱动表的选择,则是选择小表总是最优

# 自增 id

MySQL 支持自增主键,当设置了自增后,插入的 id 字段为 0、null、未指定时,会将当前的 AUTO_INCREMENT 填到自增字段。

AUTO_INCREMENT 是怎样保存和生效的呢?

  • 在 MyISAM 引擎中,自增值保存在数据文件中。
  • InnoDB 保存在内存中,到了 MySQL8.0 才有自增持久化的能力。在 5.7 及之前的版本中,每次重启后,第一次打开表时会去寻找自增值的最大值 max (id),然后将 max (id) + 1 作为这个表当前的自增值。因此如果此时最大 id 为 10,AUTO_INCREMENT = 11,此时删除 id = 10 的行,AUTO_INCREMENT 还是 11,但如果重启,它就会变成 10,即重启可能会改变 AUTO_INCREMENT 的值,在 8.0 版本中将自增值的变更记录在了 redo log 中,重启后可以恢复之前的值。

插入数据时规则如下:

  1. 如果插入数据 id 指定为 0、null、未指定值,则插入 AUTO_INCREMENT。
  2. 如果指定了值则使用指定的值。

指定了值根据其大小,将变更 AUTO_INCREMENT,插入值为 X,自增值为 Y:

  1. X < Y,自增值不变
  2. X ≥ Y,自增值修改为新的自增值。

新的自增值如何得来?从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加到第一个大于 X 的值。这两个参数默认为 1

在多主数据库中可能会设置不同的值,让不同的库的 id 不同,避免冲突。

实际插入时,id 的自增流程如下,例如插入 (null, 1, 1):

  1. 调用 InnoDB 写入接口,传入值为 (0, 1, 1);
  2. 发现没有指定 id,获取当前表的自增值 2;
  3. 传入的行的值改为 (2, 1, 1);
  4. 将表的自增值改成 3;
  5. 继续执行后续插入数据操作。

从上面可以看出,更改自增值是在真正插入数据之前,插入数据时如果出现失败,自增值是不会回滚回去的,所以即使步长都为 1,也可能出现不连续的情况。

自增主键达到最大后会怎样呢?达到最大后将不会继续增加,再插入数据会产生主键冲突的错误。int 型最大在 4294967295,不够可以使用 bigint unsigned 类型。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

HuaLin 微信支付

微信支付

HuaLin 支付宝

支付宝