文章目录
- 1. 锁机制
- 1.1 InnoDB的锁类型
- 1.1.1 读锁(S锁)
- 1.1.2 写锁(X锁)
- 1.1.3 记录锁(Record Lock)
- 1.1.4 间隙锁(GAP Lock)
- 1.1.5 记录锁和间隙锁的组合(next-key Lock)
- 1.1.6 MDL锁
- 1.1.7 页锁
- 1.1.8 意向锁
- 1.1.9 全局锁
- 1.1.10 死锁问题
- 1.2 表锁
- 1.3 从另一个角度区分锁的分类
- 1.3.1 乐观锁
- 1.3.2 悲观锁
- 2. 日志系统
- 2.1 bin log日志
- 2.1 概述
- 2.2 数据恢复
- 2.3 格式分类
- 2.4 日志格式
- 2.5 binlog刷盘
- 2.6 binlog实现主从同步
- 2.2 其他日志
- 2.2.1 通用查询日志,默认关闭
- 2.2.2 慢查询日志
- 2.2.3 错误日志
- 2.3 redo log日志
- 2.3.1 MTR
- 2.3.2 log buffer
- 2.3.3 checkpoint
- 2.3.4 一个事务的执行流程
- 2.3.5 系统崩溃的影响
- 2.4 undo log日志
- 2.4.1 概述
- 2.4.2 事务id(DB_TRX_ID)
- 2.4.3 DB_ROLL_PTR
- 2.4.4 分类
- 2.4.5 物理存储结构
- 2.4.6 记录流程
- 2.4.7 回滚过程
- 2.5 ACID靠什么保证的?
- 2.6 InnoDB 是如何实现事务的
- 3. 隔离级别和MVCC
- 3.1 Read View(读视图)
- 3.1.1 实现原理
- 3.1.2 readView的结构
- 3.2 快照读原理解析
- 3.3 解决脏读和不可重复读
- 3.3.1 解决脏读
- 3.3.1 解决不可重复读
- 3.3.3 解决幻读
- 4. InnoDB 四大特性
- 4.1 插入缓冲(insert buffer)
- 4.2 二次写(double write)
- 4.3 自适应哈希索引(adaptive hash index)
- 4.4 预读(read ahead)
- 线性预读(linear read-ahead)
- 随机预读(Random read-ahead)
1. 锁机制
锁是为了保证数据库中数据一致性,使各种 共享资源在被访问时变得有序而设计的一种规则。
MySQL
中不同的存储引擎支持不同的锁机制,InnoDB
支持行锁,有时也会升级为表锁,Myisam
只支持表锁。
- 表锁:表锁是指上锁的时候锁住的是整个表,当下一个事务访问该表时,必须等前一个事务释放了锁才能进行对表进行访问。
表锁的特点:
开销小,加锁快,不会出现死锁。锁粒度大,发生锁冲突的概率小,并发度相对低
- 行锁:行锁是指上锁的时候锁住的是表的某一行或多行记录,其他事务访问同一张表时,只要被锁住的记录不能访问,其他的记录可正常访问。
行锁的特点:
开销大,加锁慢,会出现死锁。锁粒度小,发生锁冲突的概率大,并发度高。
今天我们讲锁主要从InnoDB
引擎来讲,因为它既支持行锁、也支持表锁。
1.1 InnoDB的锁类型
基于锁的属性分类:
- 共享锁
- 排他锁
基于锁的粒度分类:
- 行级锁(InnoDB)
- 表级锁(InnoDB和Myisam)
- 页级锁(BDB引擎)
- 记录锁
- 间隙锁
- 临键锁
- MDL锁
- 全局锁
基于锁的状态分类:
- 意向共享锁
- 意向排它锁
1.1.1 读锁(S锁)
读锁(共享锁,shared lock
)简称S
锁,一个事务获取了一个数据行的读锁,其他事务也能获得该行对应的读锁,但不能获得写锁,即一个事务在读取一个数据行时,其他事务也可以读,但是不能对该数据行进行增删改得操作。
注意:
读锁是共享锁,多个事务可以同时持有,当有一个或者多个事务持有共享锁时,被锁得数据就不能修改。
简而言之:就是可以多个事务读,但只能一个事务写。
读锁是通过select.... lock in share mode;
语句给被读取得行记录或行记录得范围加一个读锁,让其他事务可以读,但是要想申请加写锁,那就会被阻塞。
事务一:
begin;
SELECT * FROM demo_info where id=1 lock in share mode;
事务二:
begin;
UPDATE demo_info set key1='aa' where id=1 ;
卡住了,说明程序被阻塞,确实加了锁。
s
锁是可以被多个事务同时获取的,我们在两个不同的事务中分别对同一行数据加上s
锁,结果都可以成功,如下图:
1.1.2 写锁(X锁)
写锁(排它锁或者独占锁,exclusive
)简称X
锁,一个事务获取了一个数据行的写锁,即可以读该行的记录,又可以修改该行的记录。但其他事务就不能在获取该行的其他任何锁。包括S
锁,直到当前事务将锁释放。这保证了其他事务在当前事务释放锁之前不能再修改数据。
注意:
写锁是独占锁,只有一个事务可以持有,当这个事务持有写锁时,被锁的数据就不能被其他事务修改。
- 一些
DML
语句的操作都会对行记录加写锁。
事务一:
begin;
UPDATE demo_info set key1='aa' where id=1 ;
事务二:
begin;
UPDATE demo_info set key1='bb' where id=1 ;
卡住了,说明程序在加锁,确实加了锁,但是我们发现,其他事务还能读,有点不符合逻辑,这是因为MySQL
实现了MVCC
模型,后边会详细介绍。
- 比较特殊的就是
select ... for update;
它会对读取的行记录加一个写锁,那么其他任何事务就不能对被锁定的行上加任何锁,要不然会被阻塞。
事务一:
begin;
SELECT * FROM demo_info where id=1 for update;
事务二:
begin;
UPDATE demo_info set key1='bb' where id=1 ;
卡住了,说明加了锁了。
X
锁是只能被一个事务获取,我们在两个的不同事务中分别对同一行数据加上X
锁,发现后者会被阻塞,如下图。
1.1.3 记录锁(Record Lock)
记录锁也属于行锁的一种,只有InnoDB
才支持,只不过记录锁的范围只是表中的某一条记录,记录锁是说事务在加锁后锁住的只是表的某一条记录。
精准条件命中,并且命中的条件字段是唯一索引。
加了记录锁之后,数据可以避免数据在查询的时候被修改的重复读问题,也避免了在修改的事务未提交前被其他事务读取的脏读问题。
我们使用以下四个案例来验证记录锁的存在:
- 两个事务修改同一行记录,该场景下,
where
条件中的列不加索引。
事务一:
begin;
UPDATE demo_info set key1='aa' where common_field='1' ;
事务二:
begin;
UPDATE demo_info set key1='bb' where common_field='1' ;
发现事务二卡住了,只有事务一提交了,事务二才能继续执行,很明显,这一行数据被锁住了。
- 两个事务修改同表不同行记录,此时where条件也不加索引。
事务一:
begin;
UPDATE demo_info set key1='aa' where common_field='1' ;
事务二:
begin;
UPDATE demo_info set key1='bb' where common_field='2' ;
发现事务二卡住了,只有事务一提交了,事务二才能继续执行,很明显。表被锁住了。
- 两个事务修改同一行记录,
where
条件加上索引
事务一:
begin;
UPDATE demo_info set key1='aa' where common_field='1' ;
事务二:
begin;
UPDATE demo_info set key1='bb' where common_field='1' ;
发现事务二卡住了,只有事务一提交,事务二才能继续执行。很明显,这一行数据被锁住了。
- 两个事务修改同表不同行记录,此时
where
条件加索。
事务一:
begin;
UPDATE demo_info set key1='aa' where common_field='1' ;
事务二:
begin;
UPDATE demo_info set key1='bb' where common_field='2' ;
发现都可以顺利修改,说明锁的的确是行。
综上所述:
- 行锁是加在索引上的,这是标准的行级锁。
- 如果
where
条件没有索引,那么行锁会升级为表锁。
1.1.4 间隙锁(GAP Lock)
间隙锁属于行锁中的一种,间隙锁是在事务加锁后其锁住的是表记录的某一个区间,当表的相邻ID
之间出现空隙,则会形成一个区间,遵循左开有闭原则。
范围查询并且查询未命中记录,查询条件必须命中索引,间隙锁只会出现在REPEATABLE_READ
(重复读)的事务级别中。
间隙锁帮我们解决了mysql
在RR
级别下的一部分幻读问题,间隙锁锁定的是记录范围,不包含记录本身,也就是不允许在某个范围内插入或者删除数据。
间隙锁生成的条件:
-
A
事务使用where
进行范围检索时未提交事务,此时B
事务向A
满足检索条件的范围插入或者删除数据。 -
where
条件必须有索引。
事务一:
begin;
select * from student where id between 7 and 15 lock in share mode;
事务二:
insert into student values (12,'tom',66,'d');
发现卡住了,第一个事务会将id
在7
到15
之间的数据全部锁定,不允许在缝隙间插入。
事务三:
insert into student values (18,'tom',66,'d');
插入一个id
为18
的数据,竟然成功了,因为18
不在事务一的检索的范围
1.1.5 记录锁和间隙锁的组合(next-key Lock)
临键锁也属于行锁的一种,并且它是InnoDB
的行锁默认算法,总结来说它就是记录锁和间隙锁的组合,
临键锁会把查询出来的数据锁住,同时也会把该范围内的所有间隙空间也会锁住,再之它会把相邻的下一个区间也会锁住。
它的封锁范围,既包含索引记录,又包含索引区间。
触发条件:范围查询并命中,查询命中了索引。
结合记录锁和间隙锁的特性,临键锁避免了在范围查询时出现脏读,重复读,幻读问题,加了临键锁之后,在范围区间内数据不允许被修改和插入。
注意:
临键锁的主要目的,也是为了避免幻读(Phantom Read
)。如果把事务的隔离级别降级为RC
,临键锁则也会失效。
1.1.6 MDL锁
MySQL 5.5
引入了meta data lock
,简称MDL
锁,用于保证表中元数据的信息。在会话A
中,表开启了查询事务后,会自动获得一个MDL
锁,会话B
就不可以执行任何DDL
语句,不能执行为表中添加字段的操作,会用MDL
锁来保证数据之间的一致性。
元数据就是描述数据的数据,也就是你的表结构。意识是在你开启了事务之后获得了意向锁,其他事务就不能更改你的表结构。MDL
锁都是为了防止在事务进行中,执行DDL
语句导致数据不一致。
1.1.7 页锁
页级锁是MySQL中锁粒度介于行级锁和表级锁中间的一种锁,表级锁速度快,但冲突多,行级锁冲突少,但是速度慢,所以取了折中的页级锁,一次锁定相邻的一组记录,
页级锁的特点:
开销和加锁时间介于表锁和行锁之间,会出现死锁,锁定粒度介于表锁和行锁之间,并发度一般。
1.1.8 意向锁
如果当事务A
加锁成功之后就设置一个状态告诉后面的人,已经有人对表里的行加了一个排他锁,你们不能对整个表加共享锁或者排他锁了,那么后面需要对整个表加锁的人只需要获取这个状态,就知道自己是不是可以对表加锁,避免了对整个索引树的每个节点扫描是否加锁,而这个状态就是意向锁。
意向共享锁:
当一个事务试图对整个表进行加共享锁之前,首先需要获得这个表的意向共享锁。
意向排他锁:
当一个事务试图对整个表进行加排他锁之前,首先需要获得这个表的意向排他锁。
1.1.9 全局锁
全局锁:Flush tables with read lock
,加锁之后整个数据库实例都处于只读状态,所有的数据变更操作都会被挂起。一般用于全库逻辑备份。
1.1.10 死锁问题
发生死锁的必要条件有4
个,分别为互斥条件、不可剥夺条件、请求与保持条件和循环等待条件:
- 互斥条件,在一段时间内,计算机中的某个资源只能被一个进程占用。此时,如果其他进程请求该资源,则只能等待。
- 不可剥夺条件,某个进程获得的资源在使用完毕之前,不能被其他进程强行夺走,只能由获得资源的进程主动释放。
- 请求与保持条件,进程已经获得了至少一个资源,又要请求其他资源,但请求的资源已经被其他进程占有,此时请求的进程就会被阻塞,并且不会释放自己已获得的资源。
- 循环等待条件,系统中的进程之间相互等待,同时各自占用的资源又会被下一个进程所请求。例如有进程A、进程B和进程C三个进程,进程A请求的资源被进程B占用,进程B请求的资源被进程C占用,进程C请求的资源被进程A占用,于是形成了循环等待条件,如图1-7所示。
我模拟了一个死锁场景,如下:
InnoDB
使用的是行级锁,在某种情况下会产生死锁问题,所以InnoDB
存储引擎采用了一种叫作等待图(wait-for graph
)的方法来自动检测死锁,如果发现死锁,就会自动回滚一个事务。
在MySQL中,通常通过以下几种方式来避免死锁。
- 尽量让数据表中的数据检索都通过索引来完成,避免无效索引导致行锁升级为表锁。
- 合理设计索引,尽量缩小锁的范围。
- 尽量减少查询条件的范围,尽量避免间隙锁或缩小间隙锁的范围。
- 尽量控制事务的大小,减少一次事务锁定的资源数量,缩短锁定资源的时间。如果一条SQL语句涉及事务加锁操作,则尽量将其放在整个事务的最后执行。
如何查看死锁
- 使用命令
show engine innodb status
查看最近的一次死锁。 -
InnoDB Lock Monitor
打开锁监控,每15s
输出一次日志,使用完毕后建议关闭,否则会影响数据库性能。
对待死锁常见的两种策略
- 通过
innodblockwait_timeout
来设置超时时间,一直等待直到超时。 - 发起死锁检测,发现死锁之后,主动回滚死锁中的某一个事务,让其他事务继续执行。
1.2 表锁
对于InnoDB
表,在绝大部分情况下都应该使用行级锁,因为事务和行锁往往是我们之所以选择InnoDB
表的理由。但在个别特殊事务中,也可以考虑使用表级锁。
- 第一种情况是:事务需要更新大部分或者全部数据,表又比较大,如果使用默认的行锁,不仅这个事务执行效率低,而且可能造成其他事务长时间锁等待和锁冲突,这种情况下可以考虑使用表锁来提高该事务的执行速度。
- 第二种情况是:事务涉及多个表,比较复杂,很可能引起死锁,造成大量事务回滚,这种情况也可以考虑一次性锁定事务涉及的表,从而避免死锁,减少数据库因为事务回滚带来的开销。
在InnoDB
下 ,主动上表锁的方式如下:
lock tables teacher write,student read;
select * from teacher;
commit;
unlock table
使用时有几点需要额外注意:
- 使用
lock tables
虽然可以给InnoDB
加表级锁,但必须说明的是,表锁不是由InnoDB
存储引擎层管理的,而是由其上一层MySQL Server
层负责的,仅当autocommit=0
(手动提交事务),innodb_table_lock=1
(默认设置)时,InnoDB
层才能感知MySQL
加的表锁,MySQL Server
才能感知InnoDB
加的行锁,这种情况下,InnoDB
才能自动识别涉及表级锁的死锁,否则,InnoDB
将无法自动检测并处理这种死锁。 - 在使用
lock tables
对InnoDB加锁时要注意,事务结束前,不要用unlock tables释放表锁,因为unlock tables会隐含地提交事务,commit和rollback不能释放用lock tables
加的表级锁,必须用unlock tables释放表锁,正确的方式见如下语句。
SET AUTOCOMMIT=0;
LOCAK TABLES t1 WRITE, t2 READ, ...;
[do something with tables t1 and here];
COMMIT;
UNLOCK TABLES;
- 表锁的力度很大,慎用。
1.3 从另一个角度区分锁的分类
1.3.1 乐观锁
乐观锁大多是基于数据版本记录机制实现,一般是给数据库表增加一个version
字段。
- 读取数据时,将此版本号一同读出。
- 更新时,对此版本号加
1
,此时将提交数据的版本号与数据库表对应记录的当前版本信息进行比对
,如果提交的数据版本号等于数据表当前版本号,则予以更新,否则认为是过期数据。
事务一:
select * from ydl_student where id = 1;
事务二:
select * from ydl_student where id = 1;
update ydl_student set score = 99,version = version + 1 where id = 1 and version = 1;
commit;
事务一:
update ydl_student set score = 100,version = version + 1 where id = 1 and version = 1;
commit;
发现更新失败,应为版本号被事务二、提前修改了,这使用了不加锁的方式,实现了一个事务修改期间,禁止其他事务修改的能力。
1.3.2 悲观锁
总有刁民想害朕!!!
悲观锁依靠数据库提供的锁机制实现,MySQL
中的共享锁和排它锁都是悲观锁,数据库的增删改操作默认都会加排它锁,而查询不会加任何锁,此处不赘述。
2. 日志系统
mysql
给我们提供了很多有用的日志,这是mysql
服务层给我们提供的:
日志类型 | 写入日志的信息 |
二进制日志 | 记录了对MySQL数据库执行更改的所有操作 |
慢查询日志 | 记录了所有执行时间超过long_query_time 秒的所有查询或不使用索引的查询 |
错误日志 | 记录了在启动,运行或停止mysqlId时遇到的问题 |
通用查询日志 | 记录了建立的客户端连接和执行的语句 |
中继日志 | 记录了从复制主服务器接受的数据更改 |
2.1 bin log日志
2.1 概述
二进制日志(binnary log
)是以事件形式记录了对MySQL
数据执行修改的所有操作。
binlog
记录了所有数据库表结构变更(例如CREATE、ALTER TABLE…
)以及表数据修改(例如INSERT、UPDATE、DELETE…
)的二进制日志。不会记录SELECT
和SHOW
这类操作,因为这类操作对数据本身并没有修改,但可以通过查询通用日志来查看MySQL
执行过的
所有语句。
binlog
是MySQL Server
层维护的,跟采用何种存储引擎没有关系,记录的是所有的更新操作的日志记录。binlog
是在事务最终commit前
写入的。我们执行SELECT
等不涉及数据更新的语句是不会记binlog
的,而涉及到数据更新则会记录。需要注意的是,对支持事务的引擎比如InnoDB
来说,必须要提交了事务才会记录binlog
。
binlog
文件写满以后,会自动切换到下一个日志文件继续写,而不会覆盖以前的日志,这个也区别redo log,undo log
是循环写入的,即后面写入的可能会覆盖前面写入的。
binlog
有两个常用的使用场景:
- 主从复制:后面会专门有一个章节代领大家搭建一个主从同步的两台
mysql
服务。 - 数据恢复:通过mysqlbinlog工具来恢复数据。
mysql8
中的binLog
默认是开启的,5.7
默认是关闭的,可以通过参数log_bin
控制:
2.2 数据恢复
- 确认
binlog
是否开启,log_bin
变量的值为ON
代表binlog
日志是开启状态。
show variables like 'log_bin';
如果没有开启,可以在配置文件 [mysqld
] 下写入如下内容并重启mysql
服务:
# 开启 Binlog 并写明存放日志的位置
log_bin = /usr/local/mysql/log/bin-log
- 为了防止干扰,我们
flush
刷新log
日志,自此刻会产生一个新编号的binlog
日志文件:
flush logs;
- 查看所有
binlog
日志列表
show master logs;
- 查看
master
状态,即最后(最新)一个binlog
日志的编号名称,及其最后一个操作事件pos
结束点(Position
)值,这一步可有可无:
show master status;
- 执行sql
先创建表,并插入一些数据:
DROP TABLE IF EXISTS ydl_student;
CREATE TABLE `ydl_student` (
`id` int(11) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`score` int(255) DEFAULT NULL,
`grade` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `ydl_student`(`id`, `name`, `score`, `grade`) VALUES (1, 'lucy', 80, 'a');
INSERT INTO `ydl_student`(`id`, `name`, `score`, `grade`) VALUES (2, 'lily', 90, 'a');
INSERT INTO `ydl_student`(`id`, `name`, `score`, `grade`) VALUES (3, 'jack', 60, 'c');
INSERT INTO `ydl_student`(`id`, `name`, `score`, `grade`) VALUES (4, 'hellen', 40, 'd');
INSERT INTO `ydl_student`(`id`, `name`, `score`, `grade`) VALUES (5, 'tom', 60, 'c');
INSERT INTO `ydl_student`(`id`, `name`, `score`, `grade`) VALUES (6, 'jerry', 10, 'd');
INSERT INTO `ydl_student`(`id`, `name`, `score`, `grade`) VALUES (7, 'sily', 20, 'd');
执行删除操作,假装误删除,直接全部删除也可以,把表删了都行,一样的道理:
delete from ydl_student where id in (3,5);
- 查看
binlog
日志,我们因为刷新了日志,所以本次操作都会在最新的日志文件上:
因为binlog
的日志文件是二进制文件,不能用文本编辑器直接打开,需要用特定的工具来打开,MySQL
提供了mysqlbinlog
来帮助我们查看日志文件内容。
# 查看全部的日志信息
mysqlbinlog -v C:\Users\DXH\Desktop\bin-log.000002
文件内容
真实的情况下,我们的日志文件比较复杂,内容比较多使用时间范围查询后任然可能需要花费时间去排查问题,这里我们找到了误删除的位置:
# 指定位置范围
mysqlbinlog -v C:\Users\DXH\Desktop\bin-log.000002 --start-position=0 --stop-position=986
# 指定时间范围
mysqlbinlog -v C:\Users\DXH\Desktop\bin-log.000002 --start-datetime="2022-07-08 15:18:00" --stop-datetime="2022-07-08 18:18:00"
截取其中的一段进行分析:
# at 2324
#220708 18:00:46 server id 1 end_log_pos 2430 Query thread_id=1 exec_time=0 error_code=0
SET TIMESTAMP=1657274446/*!*/;
delete from ydl_student where id in (3,5)
上面输出包括信息:
-
position
:位于文件中的位置,即第一行的(# at 2324
),说明该事件记录从文件的第2324
个字节开始。 -
timestamp
:事件发生的时间戳,即第二行的(#220708 18:00:46
) -
server id
:服务器标识。 -
end_log_pos
:表示下一个事件的结束位置(即当前事件的结束位置+1)。 -
thread_id
:执行该事件的线程id
(thread_id=1
) -
exec_time
: 事件执行的花费时间。 -
error_code
: 错误码,0
意味着没有发生错误。 -
type
:事件类型Query
- 执行恢复,通过上一步的操作,我们找到了删除的位置
2254
(即第一个红框),执行下面的语句:
mysqlbinlog -v C:\Users\DXH\Desktop\bin-log.000002 --stop-position=2254 -v | mysql -uroot -p密码
- 至此,数据已完全恢复了:
binlog
的数据恢复的本质,就是将之前执行过的sql
,从开始到指定位置全部执行一遍,如果报错【当前表已经存在】,就将数据库的表删除,重新恢复。
2.3 格式分类
binlog
有三种格式,使用变量binlog_format
查看当前使用的是哪一种。
-
Statement
(Statement-Based Replication,SBR
):每一条会修改数据的SQL
都会记录在binlog
中。 -
Row
(Row-Based Replication,RBR
):不记录SQL
语句的上下文信息,仅保存那条记录被修改。 -
Mixed
(Mixed-Based Replication,MBR
):Statement
和Row
的混合体,当前默认的选项,5.7
中默认row
。
我们举一个例子来说明row
和statement
的区别,在下面的插入语句中我们有一个函数uuid()
,如果日志文件仅仅保存sql
语句,下一次执行的结果可能不一致,所以Row
格式的文件,他保存的是具体哪一行,修改成了什么数据,记录的是数据的变化,不是简单的sql
:
insert into ydl_student values (8,UUID(),45,'d');
Statement和row的优劣
-
Statement
模式只记录执行的SQL
,不需要记录每一行数据的变化,因此极大的减少了binlog
的日志量,避免了大量的I/O
操作。提升了系统的性能。 - 由于
Statement
模式只记录SQL
,而如果一些SQL
中 包含了函数,那么可能会出现执行结果不一致的情况。比如说uuid()
函数,每次执行的时候都会生成一个随机字符串,在master
中记录了uuid
,当同步到slave
之后,再次执行,就得到另外一个结果了。所以使用Statement
格式会出现一些数据一致性问题。 - 从
MySQL5.1.5
版本开始,binlog
引入了Row
格式,Row
格式不记录SQL
语句上下文相关信息,仅仅只需要记录某一条记录被修改成什么样子了。 - 不过
Row
格式也有一个很大的问题,那就是日志量太大了,特别是批量update
、整表delete
、alter
表等操作,由于要记录每一行数据的变化,此时会产生大量的日志,大量的日志也会带来IO
性能问题。
2.4 日志格式
binlog
文件以一个值为0Xfe62696e
的魔数开头,这个魔数对应0xfebin
。binlog
由一系列的binlog event
构成。每个binlog event
包含header
和data
两部分。
-
header
部分提供的是event
的公共的类型信息,包括event
的创建时间,服务器等等。 -
data
部分提供的是针对该event
的具体信息,如具体数据的修改。
常见的事件类型有:
- FORMAT_DESCRIPTION_EVENT:该部分位于整个文件的头部,每个
binlog
文件都必定会有唯一一个该event
- WRITE_ROW_EVENT:插入操作。
- DELETE_ROW_EVENT:删除操作。
- UPDATE_ROW_EVENT:更新操作。记载的是一条记录的完整的变化情况,即从
前量
变为后量
的过程。 - ROTATE_EVENT:
Binlog
结束时的事件,用于说明下一个binlog
文件。
一个event
的结构如下,我们在恢复数据的时候已经看到了:
- 每个日志的最后都包含一个
rotate event
用于说明下一个binlog
文件。 binlog
索引文件是一个文本文件,其中内容为当前的binlog
文件列表,比如下面就是一个mysql-bin.index
文件的内容。
2.5 binlog刷盘
二进制日志文件并不是每次写的时候同步到磁盘,因此当数据库所在的操作系统发生宕机时,可能会有最后一部分数据没有写入二进制日志文件中。这给恢复和复制带了问题,参数sync_binlog=[N]
表示每写多少次就同步到磁盘,如果将N
设为1
,即sync_binlog=1
表示采用同步写磁盘的方式来写二进制日志,这是写操作不使用操作系统的缓冲来写二进制日志,备注:该值默认为0
,采用操作系统机制进行缓冲数据同步)。
2.6 binlog实现主从同步
数据库单点部署的问题
- 服务器宕机,会导致业务停顿,影响客户体验。
- 服务器损坏,数据丢失,不能及时备份,造成巨大损失。
- 读写操作都在同一台服务器,在并发量大的情况下性能存在瓶颈。
那么我们就可以使用mysql
的binlog
搭建一个一主多从的mysql
集群服务。这样的服务可以帮助我们异地备份数据、进行读写分离,提高系统的可用性。
(1) 主从复制工作原理剖析
MySQL
的主从复制中主要有三个线程,Master(binlog dump thread)
,Slave(I/O thread,SQL thread)
。Master
一条线程和Salve
中的两条线程。
- 主节点
binlog
,主从复制的基础是主库记录数据库的所有变更记录到binlog
中,binlog
是数据库服务器启动的那一刻起,保存所有修改数据库结构和内容的一个文件。 -
Master
数据库将操作语句记录到binlog
日志中,然后授予Slave
远程连接的权限(Master
一定要开启binlog
二进制日志功能,通常为了数据安全考虑,Slave
也开启binlog
功能)。 -
Slave
数据库开启两个线程:I/O
线程和SQL
线程,其中I/O
线程负责读取Master
的binlog
内容到中继日志relay log
里。SQL
线程负责从Relay log
日志里读出binlog
内容,并更新到Slave
数据库里,这样就能保证Slave
数据和Master
数据保持一致了。最终保证主从数据库的一致性。 -
MySQL
主从复制至少需要两个MySQL
的服务,当然MySQL
服务可以分布在不同的服务器上,也可以在一台服务器上启动多个服务。 -
MySQL
主从复制最好确保master
和slave
服务器上的MySQL
版本相同(如果不能满足版本一致,那么要保证master
主节点的版本低于slave
从节点的版本)。 -
master
和slave
两节点间时间需同步。
注意:
主从节点使用binlog
文件+position
偏移量来定位主从同步的位置,从节点会保存其已接收到的偏移量,如果从节点发生宕机重启,则会自动从position
的位置发起同步。
由于MySQL
默认的复制方式是异步的,主库把日志发送给从库后不关心从库是否已经处理,这样会产生一个问题就是假设主库挂了,从库处理失败了,这时候从库升为主库后,日志就丢失了,由此产生两个概念。
- 全同步复制:主库写入
binlog
后强制同步日志到从库,所有的从库都执行完成后才返回给客户端,但是很显然这个方式的话性能会受到严重影响。 - 半同步复制:和全同步不同的是,半同步复制的逻辑是这样,从库写入日志成功后返回
ACK
确认给主库,主库收到至少一个从库的确认就认为写操作完成。
(2)怎么配置mysql主从复制
环境准备
安装两个mysql
,使用vmvare
安装两个linux
系统就可以:
mysql1(master): 42.192.181.133:3306
mysql2(slave): 124.220.197.17:3306
mysql
配置文件配置
mysql1(master)
: 配置文件设置,开启bin_log
(已经开启的可以忽略)且需要配置一个server-id
#mysql master1 config
[mysqld]
server-id = 1 # 节点ID,确保唯一
# log config
log-bin = master-bin #开启mysql的binlog日志功能
mysql2(slave)
: 需要开启中继日志
[mysqld]
server-id=2
relay-log=mysql-relay-bin
replicate-wild-ignore-table=mysql.%
replicate-wild-ignore-table=sys.%
replicate-wild-ignore-table=information_schema.%
replicate-wild-ignore-table=performance_schema.%
重启两个mysql
,让配置生效。
第三步 在master
数据库创建复制用户并授权
- 进入
master
的数据库,为master
创建复制用户
CREATE USER 'repl'@'124.220.197.17' IDENTIFIED BY 'Root12345_';
- 赋予该用户复制的权利
grant replication slave on *.* to 'repl'@'124.220.197.17'
FLUSH PRIVILEGES;
- 查看master的状态
show master status;
mysql> show master status;
+------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+------------------+----------+--------------+------------------+-------------------+
| mysql-bin.000005 120| | mysql | |
+------------------+----------+--------------+------------------+-------------------+
1 row in set (0.00 sec)
- 配置从库
CHANGE MASTER TO
MASTER_HOST = '42.192.181.133',
MASTER_USER = 'repl',
MASTER_PASSWORD = 'Root12345_',
MASTER_PORT = 3306,
MASTER_LOG_FILE='mysql-bin.000020',
MASTER_LOG_POS=2735,
MASTER_HEARTBEAT_PERIOD = 10000;
# MASTER_LOG_FILE与主库File 保持一致
# MASTER_LOG_POS=120 , #与主库Position 保持一致
解释:MASTER_HEARTBEAT_PERIOD
表示心跳的周期。当MASTER_HEARTBEAT_PERIOD
时间之内,master
没有binlog event
发送给slave
的时候,就会发送心跳数据给slave
。
- 启动从库
slave
进程
mysql> start slave;
Query OK, 0 rows affected (0.04 sec)
- 查看是否配置成功
show slave status \G;
-
Slave_IO_Running
:从库的I/O
线程,用来接受Master
发送的binlog
,并将其写入到中继日志relay log
。 -
Slave_SQL_Running
:从库的SQL
线程,用来执行从relay log
读取并执行binlog
。 -
Slave_IO_Running
、Slave_SQL_Running
:这两个进程的状态需全部为YES
,只要有一个为NO
,则复制就会停止。 -
Master_Log_File
:要同步的主库的binlog
文件名。 -
Read_Master_Log_Pos
:已同步的位置,即同步的binlog
文件内的字节偏移量,该值会随着主从同步的进行而不断地增长。 -
Relay_Log_File
:从库的中继日志文件,对接收到的主库的binlog
进行缓冲。从库的SQL
线程不断地从relay log
中读取binlog
并执行。 -
Relay_Log_Pos
:relay log
中已读取的位置偏移量。 -
Seconds_Behind_Master
: 主从同步延时, 值为0
为正常情况,正值表示已经出现延迟,数字越大从库落后主库越多。
- 在主库创建一个数据库、创建一张表,执行一些
sql
语句进行测试。
(3)可能遇到的问题
在配置mysql
主从复制的时候可能出现一下错误:
Fatal error: The slave I/O thread stops because master and slave have equal MySQL server UUIDs; these UUIDs must be different for replication to work.
原因
如果你使用了两台虚拟机,一主一从,从库的mysql
是直接克隆的。在mysql 5.6
的复制引入了uuid
的概念,各个复制结构中的server_uuid
得保证不一样,但是查看到直接克隆data
文件夹后server_uuid
是相同的。
解决
找到data
文件夹下的auto.cnf
文件,修改里面的server_uuid
值,保证各个db
的server_uuid
不一样,重启db
即可。
cd /www/server/data
修改server_uuid
的值
使用
select uuid();
生成一个uuid
即可,重启数据库。
2.2 其他日志
2.2.1 通用查询日志,默认关闭
MySQL
通用查询日志,它是记录建立的客户端连接和执行的所有DDL
和DML
语句(不管是成功语句还是执行有错误的语句),默认情况下,它是不开启的。请注意,它也是一个文本文件。
可以通过以下的sql
查看查询日志的状态:
show variables like 'general_log';
使用以下命令开启通用查询日志,一般不开启,这是为了测试,当然也可以修改配置文件,重启服务:
# 在全局模式下,开启通用查询日志,1表示开启,0表示关闭
SET global general_log=1;
开启后,我们随便执行sql
语句之后,你会发现data
目录多了以下文件:
使用more命令查看该文件:
more VM-12-17-centos.log
2.2.2 慢查询日志
当前版本慢查询日志默认是开启的,有的版本是关闭的,使用如下命令查看慢查询日志的状态:
show variables like 'slow_query_log';
那么,何为慢?mysql
通过一个变量‘long_query_time
’来确定sql
慢不慢,执行时间大于该值就会被记录在慢查询日志中,默认是10s
:
show variables like 'long_query_time';
以下是【慢查询日志】的记录文本:
详细的分析慢SQL可以查看我的另一篇博文——数据库优化——慢查询MySQL定位优化流程
2.2.3 错误日志
错误日志(Error Log
)主要记录 MySQL
服务器启动和停止过程中的信息、服务器在运行过程中发生的故障和异常情况等。一旦发生mysql
服务无法启动、程序崩溃一定要记得去查询错误日志:
show variables like 'log_error';
我们随便人为一个错误导致他无法启动,重新启动mysql
命令如下:
service mysqld restart
systemctl mysqld restart
我们将inndb
的系统表空间文件重命名,重新启动mysql
服务,发生问题:
查询错误日志,寻找蛛丝马迹:
修改回正确的名字,重新启动成功:
2.3 redo log日志
接下来的两个日志,是InnoDB
为解决不同问题而引出的两类日志文件。
redo log
(重做日志)的设计主要是为了防止因系统崩溃而导致的数据丢失,其实解决因系统崩溃而导致数据丢失的思路如下。
- 每次提交事务之前,必须将所有和当前事务相关的
Buffer Pool
中的脏页 刷入磁盘,但是这个效率比较低,可能会影响主线程的效率,产生用户等待,降低响应速度,因为刷盘是I/O
操作,同时一个事务的读写操作也不是顺序读写。 - 把当前事务中修改的数据内容在日志中记录下来,日志是顺序写,性能很高,其实mysql就是这么做的,这个日志被称为
redo log
。执行事务中,每执行一条语句,就可能有若干redo
日志,并按产生的顺序写入磁盘,redo
日志占用的空间非常小,当redo log
空间满了以后又会从头开始以循环的方式进行覆盖式的写入。
redo log
的格式比较简单,包含一下几个部分:
-
type
:该日志的类型,在5.7
版本中,大概有53
种不同类型的redo log
,占用一个字节。 -
space id
:表空间id
。 -
page number
:页号。 -
data
:日志数据。
2.3.1 MTR
在innodb
执行任务时,有很多操作,必须具有原子性,我们把这一类操作称之为MIni Transaction
,我们以下边的例子为例:
在我们向B+
树中插入一条记录的时候,需要定位这条数据将要插入的数据页,因为插入的位置不同,可能会有以下情况:
- 待插入的页拥有充足的剩余空间,足以容纳这条数据,那就直接插入就好了,这种情况需要记录一条
MLOG_COMP_REC_INSERT
类型 的redo
日志就好了,这种情况成为乐观插入。 - 待插入的页剩余空间不足以容纳该条记录,这样就比较麻烦了,必须进行页分裂了。必须新建一个页面,将原始页面的数据拷贝一部分到新页面,然后插入数据。这其中对应了好几个操作,必须记录多条
rede log
,包括申请新的数据页、修改段、区的信息、修改各种链表信息等操作,需要记录的redo log
可能就有二三十条,但是本次操作必须是一个【原子性操作】,在记录的过程中,要全部记录,要么全部失败,这种情况就被称之为一个MIni Transaction
(最小事务)。
(1)MTR的按组写入
对于一个MTR
操作必须是原子的,为了保证原子性,innodb
使用了组的形式来记录redo
日志,在恢复时,要么这一组的的日志全部恢复,要么一条也不恢复。innodb
使用一条类型为MLO_MULTI_REC_END
类型的redo log
作为组的结尾标志,在系统崩溃恢复时只有解析到该项日志,才认为解析到了一组完整的redo log
,否则直接放弃前边解析的日志。
(2)单条redolog的标识方法
有些操作只会产生一条redo log
,innodb
是通过【类型标识】的第一个字符来判断,这个日志是单一日志还是组日志,如下图:
(3)事务、sql、MTR、redolog的关系如下
- 一个事务包含一条或多条
sql
。 - 一条
sql
包含一个或多个MTR
- 一个
MTR
包含一个或多个redo log
。
2.3.2 log buffer
任何可能产生大量I/O
的操作,一般情况下都会设计【缓冲层】,mysql
启动时也会向操作系统申请一片空间作为redo log
的【缓冲区】,innodb
使用一个变量buf_free
来标记下一条redo log
的插入位置(标记偏移量),log buffer
会在合适的时机进行刷盘:
-
log buffer
空间不足。logbuffer
的容量由innodb_log_buffer_size
指定,当写入log buffer
的日志大于容量的50%
,就会进行刷盘。 - 提交事务时,如果需要实现崩溃恢复,保证数据的持久性,提交事务时必须提交
redo log
,当然你也可以为了效率不去提交,可以通过修改配置文件设置该项目。 - 后台有独立线程大约每隔一秒会刷新盘一次。
- 正常关闭服务器。
- 做
checkpoint
时,后边会讲。
有缓冲就可能存在数据不一致,咱们接着往下看。
2.3.3 checkpoint
redolog
日志文件容量是有限的,需要循环使用,redo log
的作用仅仅是为了在崩溃时恢复脏页数据使用的,如果脏页已经刷到磁盘上,其对应的redo log
也就没用了,他也就可以被重复利用了。checkpoint
的作用就是用来标记哪些旧的redo log
可以被覆盖。
我们已经知道,判断redo log
占用的磁盘空间是否可以被重新利用的标志就是,对应的脏页有没有被刷新到磁盘。为了实现这个目的,我们需要了解一下下边几个记录值的作用:
(1)lsnlsn
(log sequence number
)是一个全局变量。mysql
在运行期间,会不断的产生redo log
,日志的量会不断增加,innodb
使用lsn
来记录当前总计写入的日志量,lsn
的初始值不是0
,而是8704
,原因未知。系统在记录lsn
时是按照【偏移量】不断累加的。lsn
的值越小说明redo log
产生的越早。
每一组redo log
都有一个唯一的lsn
值和他对应,你可以理解为lsn
是redo log
的年龄。
(2)flush_to_disk_lsn
flush_to_disk_lsn
也是一个全局变量,表示已经刷入磁盘的redo log
的量,他小于等于lsn
,举个例子:
- 将
redo log
写入log buffer
,lsn
增加,假如:8704+1024 = 9728
,此时flush_to_disk_lsn
不变。 - 刷如512字节到磁盘,此时
flush_to_disk_lsn=8704+512=9256
。
如果两者数据相同,说明已经全部刷盘。
(3)flush链中的lsn
其实要保证数据不丢失,核心的工作是要将buffer pool
中的脏页进行刷盘,但是刷盘工作比较损耗性能,需要独立的线程在后台静默操作。
回顾一下flush
链,当第一次修改某个已经加载到buffer pool
中的页面时,他会变成【脏页】,会把他放置在flush
链表的头部,flush
链表是按照第一次修改的时间排序的。再第一次修改缓冲页时,会在【缓冲页对应的控制块】中,记录以下两个属性:
-
oldest_modification
:第一次修改缓冲页时,就将【修改该页面的第一组redo log的lsn值】记录在对应的控制块。 -
newest_modification
:每一次修改缓冲页时,就将【修改该页面的最后组redo log
的lsn
值】记录在对应的控制块。
既然flush
链表是按照修改日期排序的,那么也就意味着,oldest_modification
的值也是有序的。
(4)checkpoint过程
执行一个check point
可以分为两个步骤
第一步:计算当前redo log
文件中可以被覆盖的redo
日志对应的lsn
的值是多少:
-
flush
链是按照第一次修改的时间排序的,当然控制块内的【oldest_modification
】记录的lsn
值也是有序的。 - 我们找到
flush
链表的头节点上的【oldest_modification
】所记录的lsn
值,也就找到了一个可以刷盘的最大的lsn
值,小于这个值的脏页,肯定已经刷入磁盘。 - 所有小于这个
lsn
值的redo log
,都可以被覆盖重用。 - 将这个
lsn
值赋值给一个全局变量checkpoint_lsn
,他代表可以被覆盖的量。
第二步:将checkpoint_lsn
与对应的redo log
日志文件组偏移量以及此次checkpoint
的编号(checkpoint_no
也是一个变量,记录了checkpoint
的次数)全部记录在日志文件的管理信息内。
2.3.4 一个事务的执行流程
主线程
- 客户端访问
mysql
服务,在buffer pool
中进行操作(如果目标页不在缓冲区,需要加载进入缓冲区),此时会形成脏页。 - 记录
redo log
,可能产生很多组日志,redo log
优先记录在缓冲区,会在提交事务前刷盘。 - 刷盘时根据
checkpoint
的结果,选择可以使用的日志空间进行记录。 - 成功后即可返回,此时数据不会落盘,这个过程很多操作只在内存进行,只需要记录
redo log
(顺序写),所以速度很快。
线程1:
不断的对flush链表的脏页进行刷盘,对响应时间没有过高要求。
线程2:
不断的进行checkpoin
操作,保证redo log
可以及时写入。
2.3.5 系统崩溃的影响
-
log buffer
中的日志丢失,log buffer
中的日志会在每次事务前进行刷盘,如果在事务进行中崩溃,事务本来就需要回滚。 -
buffer pool
中的脏页丢失,崩溃后可以通过redo log
恢复,通过checkpoint
操作,我们可以确保,内存中脏页对应的记录都会在redo log
日志中存在。
redo log
保证了崩溃后,数据不丢失,但是一个事务进行中,如果一部分redo log
已经刷盘,那么系统会将本应回滚的数据同样恢复,为了解决回滚的问题,innodb
提出了undo log
。
2.4 undo log日志
2.4.1 概述
undo log
(也叫撤销日志或者回滚日志),他的主要作用是为了实现回滚操作。同时,他是MVCC
多版本控制的核心模块。undo log
保存在共享表空间【ibdata1
文件】中。
注意:8.0
以后undolog
有了独立的表空间:
在讲undo log之前需要先了解行数据中的两个隐藏列:
2.4.2 事务id(DB_TRX_ID)
我们已经讲过,在innodb
的行数据中,会自动添加两个隐藏列,一个是【DB_TRX_ID
】,一个是【DB_ROLL_PTR
】,本章节会详细介绍这两列的具体作用,如果该表中没有定义主键,也没有定义【非空唯一】列,则还会生成一个隐藏列【DB_ROW_ID
】,这个我们之间也讲过,是为了生成聚簇索引使用的。
事务id
是一个自增的全局变量,如果一个【事务】对任意表做了【增删改】的操作,那么innodb
就会给他分配一个独一无二的事务id
。
DB_TRX_ID
代表最近修改事务id
,记录创建当前记录或者最后一个修改的事务id
。
冷知识:
- 事务
id
保存在一个全局变量【MAX_TRX_ID
】上,每次事务需要分配事务id
,就会从这个全局变量中获取,然后自增1
。 - 该变量每次自增到
256
的倍数会进行一个落盘(保存在表空间页号为5
的页面中),发生服务停止或者系统崩溃后,再起启动服务,会读取这个数字,然后再加256
。这样做既保证不会有太多I/O
操作,还能保证id
的有序增长。比如:读到256
进行落盘,后来有涨到302
,突然崩溃了,下次启动后,第一个事务的id
就是256+256=512
,保证新的事务id
一定大。
2.4.3 DB_ROLL_PTR
undo log
在记录日志时是这样记录的,每次修改数据,都会将修改的数据标记一个【新的版本】,同时,这个版本的数据的地址会保存在修改之前的数据的roll_pointer
列中,如下:
可以理解为回滚指针,指向这条记录的上一个版本。
2.4.4 分类
当我们对数据库的数据进行一个操作时必须记录之前的信息,将来才能【悔棋】,如下:
- 插入一条数据时,至少要把这条数据的主键记录下来,以后不想要了直接根据主键删除。
- 删除一条数据时,至少要把这个数据所有的内容全部记录下来,以后才能全量恢复。但事实上不需要,每行数据都有一个
delete_flag
,事务中将其置1
,记录id
,如需要回滚根据id
复原即可,提交事务后又purge
线程处理垃圾。 - 修改一条数据时,至少要将修改前后的数据都保存下来。
innodb
将undo log
分为两类:
- 一类日志只记录插入类型的操作(
insert
) - 一类日志只记录修改类型的操作(
delete
,update
)
为什么分为这两类呢?
- 插入型的记录不需要记录版本,事务提交以后这一片空间就可以重复利用了。
- 修改型的必须将每次修改作为一个版本记录下来,即使当前事务已经提交,也不一定能回收空间,应为其他事务可能在用。
2.4.5 物理存储结构
undo
同样是以页的形式进行存储的,多个页是使用链表的形式进行管理,针对【普通表和临时表】,【插入型和修改型】的数据,一个事务可能会产生以下四种链表:
这是物理存储模型,分成四种类型,是为了更好的管理。
2.4.6 记录流程
- 开启事务,执行【增删改】时获得【事务id】。
- 在系统表空间中第
5
号页中,分配一个回滚段,回滚段是轮动分配的,比如,当前事务使用第5
个回滚段,下个事务就使用第6
个。
【回滚段】是一个【数据页】,里边划分了
1024
个undo slot
,用来存储日志链表的头节点地址。
- 在当前回滚段的
cached
链表(回收可复用的)和空闲solt
中,找到一个可用的slot
,找不到就报错。 - 创建或复用一个
undo log
页,作为first undo page
,并把他的地址写入undo solt
中。
2.4.7 回滚过程
- 服务再次启动时,通过表空间
5
号页面定位到128
个回滚段的位置, - 遍历所有的
slot
,找到所有状态不为空闲的slot
,并且通过undolog
的标记为找到现在活跃(未提交)的所有的事务id
。 - 根据
undo log
的记录,将数据全部回滚。
2.5 ACID靠什么保证的?
-
A
原子性由undo log
日志保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的SQL
。 -
C
一致性由其他三大特性保证,程序代码要保证业务上的一致性。 -
I
隔离性由MVCC
和锁来保证。 -
D
持久性由内存和redo log
来保证,mysql
修改数据的同时在内存和redo log
记录这次操作,宕机的时候可以从redo log
恢复。
InnoDB redo log
写盘,InnoDB
事务进入prepare
状态。
如果前面prepare
成功,binlog
写盘,再继续将事务日志持久化到binlog
,如果持久化成功,那么InnoDB
事务则进入commit
状态(在redo log
里面写一个commit
记录)。
redo log
的刷盘会在系统空闲的时候进行。
2.6 InnoDB 是如何实现事务的
InnoDB
通过Buffer Pool
,LogBuffer
,Redo log
,Undo log
来实现事务,以一个update
语句为例:
-
InnoDB
在收到一个update
语句后,会先根据条件找到数据所在的页,并把该页缓存在Buffer Pool
中。 - 执行
update
语句,修改Buffer Pool
中的数据,也就是内存中的数据。 - 针对
update
语句生成一个RedoLog
对象,并存入LogBuffer
中。 - 针对
update
语句生成undo log
日志,用于事务回滚。 - 如果事务提交,那么则把
RedoLog
对象进行持久化,后续还有其他机制将Buffer Pool
中所修改的数据页持久化到磁盘中。 - 如果事务回滚,则利用
undo log
日志进行回滚。
数据更新的流程
执行流程:
- 执行器先从引擎中找到数据,如果在内存中直接返回,如果不在内存中,查询后返回。
- 执行器拿到数据之后会先修改数据,然后调用引擎接口重新写入数据。
- 引擎将数据更新到内存,同时写数据到
redo
中,此时处于prepare
阶段,并通知执行器执行完成,随时可以操作, - 执行器生成这个操作的
binlog
。 - 执行器调用引擎的事务提交接口,引擎把刚刚写完的
redo
改成commit
状态,更新完成。
3. 隔离级别和MVCC
【MVCC
】,全称Multi-Version Concurrency Control
,即【多版本并发控制】。MVCC
在MySQL InnoDB
中的实现主要是为了提高数据库的并发性能,用更好的方式去处理【读-写冲突】,做到即使有【读写冲突】时,也能做到不加锁,非阻塞并发读,学习mvcc之前我们需要学习一些新的概念。
注意:MVCC
只在READ COMMITTED
和REPEATABLE READ
两个隔离级别下工作,其他两个隔离级别和MVCC
不兼容,因为READ UNCOMMITTED
总是读取最新的数据行,而不是符合当前事务版本的数据行,而SERIALIZABLE
则会对所有读取的行都加锁。
3.1 Read View(读视图)
在学习MVCC
多版本并发控制之前,我们必须先了解一下,什么是MySQL InnoDB
下的【当前读】和【快照读】,我们都知道undo log
会记录一个事务对一条数据的所有修改,并形成版本链:
- 当前读:像
select lock in share mode
(共享锁),select for update
,insert
,update
,delete
(排它锁),这些操作都是【当前读】,他读取的是记录的【最新版本】。读取时还要保证其他【并发事务】不能修改当前记录,会对读取的记录进行加锁。 - 快照读:像不加锁的
select
操作就是快照读,即不加锁的【非阻塞读】,快照读的前提是【隔离级别不是串行级别】。串行级别下的快照读会【退化成当前读】。顾名思义,快照读读取的是【快照】,它是通过readView
实现的。
3.1.1 实现原理
Read View
就是事务进行【快照读】的时候产生的【读视图】(Read View
),在该事务【执行快照读】的那一刻,会生成数据库系统当前的一个快照。
注意:
【快照】不是说将数据库复制一份,【Read View】的主要作用是做【可见性判断】。快照的实现逻辑是通过undo
的【版本链】,配合一些【参数】,比如事务id
,来确定当前事务可以读取的版本。
3.1.2 readView的结构
举一个列子,当前有事务id
为12
、13
、14
、16
、20
的五个事务,他们在同时修改一条数据,此时,事务13
发生读取行为,在【事务13】读取之前【事务14】已经提交,当前场景下,将产生一个readview
如下:
一个readView
就是一个【结构体】,你甚至可以理解成为java
里的实例(readview
)和属性,包含属性如下:
-
m_ids
:生成该readView
时,当前系统中【活跃的事务】id
列表,对于当前案例,因为14
已经提交,就不活跃了,所以该变量的值为[12,13,16,20]
。 -
min_trx_id
:当前系统【活跃事务】中最小的【事务id】,它也是m_ids
的最小值,当前案例的值就是12
。 -
max_trx_id
:当前系统中计划分配给下一个事务的id
,他可能是m_ids
的最大值+1
,也可能比他大。当前案例值假设为22
。 -
creator_trx_id
:生成这个readView
的事务id
,当前案例的值为12
。
以上readview
配合undo log
就可以形成一个【快照】,那他是怎么读取的呢?
3.2 快照读原理解析
在一个事务读取数据时,会根据当前数据形成一个readView
,读取时会按照以下逻辑进行读取:
- 如果【被访问数据的事务trx_id】和
readView
中的【creator_trx_id值】相同,意味着自己在访问自己修改过的记录,当然可以访问。 - 如果【被访问数据的事务trx_id】小于
readView
中的【min_trx_id】值,说明生成这个版本数据的事务,在生成readView
前已经提交,这样的数据也可以访问。
通俗一点,这个数据之前被其他的事务修改过,但是事务已经提交,所以这个版本的数据是可以使用的,这样不会产生脏读。
- 如果【被访问数据的事务trx_id】大于
readView
中的【max_trx_id】值,说明生成这个版本数据的事务,是在生成readView
后开启,这样的数据不应该访问。
通俗一点:你读取数据之后,有人修改了当前数据,那人家后边修改的数据,你也不能读。
- 如果【被访问数据的事务trx_id】如果在
min_trx_id
和max_trx_id
范围内,则需要判断是不是在【m_ids】中(目的是判断这个数据是不是已经提交),如果在,说明生成这个版本的事务还是活跃的,没有提交的事务产生的数据当然不能读,如果不在,说明事务已经提交,该数据可以被访问。
通俗一点:这个数据被现在活跃的其他事务正在修改中,读取时要看此时这个事务是不是已经提交,目的也是为了不要读取别人未提交的事务。
我们用下边的案例来看一下这个过程:
3.3 解决脏读和不可重复读
对于RU
(读未提交)隔离级别的事务来说,由于可以读取到未提交的事务,所有直接读取【最新的记录】(当前读)就可以,对于serializable
的事务来说,必须使用加锁的方式来访问。
3.3.1 解决脏读
先思考一个问题,脏读指的是当前事务中读取到了其他事务未提交的数据,那解决的思路是什么:
(1)没有undo+mvcc
一个事务读取了数据之后,立马给这个数据加写锁,不允许其他事务进行修改,这是加锁解决脏读。
(2)使用undo+mvcc
所有事务对数据的修改,记录成版本链,使用readview
进行版本选择,每个事务只能读取满足条件的数据,这个过程不需要加锁。
使用mvcc
很好的解决了【读写操作】的并发执行,而且采用了无锁机制。
3.3.1 解决不可重复读
RC
和RR
两个隔离级别解决不可重复读是通过生成readview
时间不同
(1)RC隔离级别,同一个事务中【每次读取数据时都生成一个新的ReadView】,两次读取时,如果中间有其他事务进行提交,可能会生成两个不同的readview
,导致当前事务中,两次读取的数据不一致,这就是不可重复读。具体的执行流程如下:
(2)RR隔离级别,同一个事务中【只在第一次读取数据时生成一个ReadView】,以后这个事务中一直使用这个readview
,那么同一个事务中就能保证多次读取的数据是一致的,具体的执行流程如下:
3.3.3 解决幻读
他是通过间隙锁实现的,一旦锁定某一个范围的数据,就会对这个范围的数据加锁,间隙锁保证我们不能在这个范围内插入新的数据。
4. InnoDB 四大特性
InnoDB
四大特性分别是:
- 插入缓冲(
insert buffer
) - 二次写(
double write
) - 自适应哈希索引(
adaptive hash index
) - 预读(
read ahead
)
4.1 插入缓冲(insert buffer)
索引是存储在磁盘上的,所以对于索引的操作需要涉及磁盘操作,如果我们使用自增主键,那么在插入主键索引(聚簇索引)时,只需不断追加即可,不需要磁盘的随机IO
,但是如果我们使用的是普通索引,大概率是无序的,此时就涉及到磁盘的随机IO
,而随机I/O
的性能是比较差的(Kafka
官方数据:磁盘顺序I/O
的性能是磁盘随机I/O
的4000~5000
倍)。
因此,InnoDB
存储引擎设计了Insert Buffer
,对应非聚集索引的插入或更新操作,不是每一次直接插入到索引页中,而是先判断插入的非聚集索引页是否在缓冲池(Buffer pool
)中,若在,则直接插入。若不在,则先放入到一个Insert Buffer
对象中,然后再以一定的频率和情况进行Insert Buffer
和辅助索引叶子节点的 merge
(合并)操作,这时通常能将多个插入合并到一个操作中(因为在一个索引页中),这就大大提高了对于非聚集索引插入的性能。
插入缓冲的使用需要满足以下两个条件:
- 索引是辅助索引。
- 索引不是唯一的。
因为在插入缓冲时,数据库不会去查找索引页来判断插入的记录的唯一性,如果去查找肯定又会有随机读取的情况发生,从而导致 Insert Buffer
失去了意义。
4.2 二次写(double write)
脏页刷盘风险:InnoDB
的page size
一般是16kb
,操作系统写文件是以4kb
作为单位,那么每写一个InnoDB
的page
到磁盘,操作系统需要写4
个块,于是可能出现16k
的数据,写入4k
时,发生了系统断电或系统崩溃,只要一部分写是成功的,这就是partial page write
(部分页写入)问题。这时会出现数据不完整的问题。
这时是无法通过redo log
恢复的,因为redo log
记录的是对页的物理修改,如果页本身已经损坏,重做日志也无能为力。
double write
就是用来解决该问题的,double write
由两部分组成,一部分为内存中的double write buffer
,其大小为2MB
,另一部分是磁盘上共享表空间中连续的128
个页,即2
个区(extent
),大小也是2M
。
为了解决partial page write
问题,当MySQL
将脏数据刷新到磁盘的时候,会进行以下操作:
- 先将脏数据复制到内存中的
double write buffer
。 - 之后通过
double write buffer
再分2
次,每次1MB
写入到共享表空间的磁盘上(顺序写,性能很高)。 - 完成第二步之后,马上调用
fsync
函数,将double write buffer
中的脏页数据写入实际的各个表空间文件(离散写)。
如果操作系统在将页写入磁盘的过程中发生崩溃,InnoDB
再次启动后,发现了一个page
数据已经损坏,InnoDB
存储引擎可以从共享表空间的double write
中找到该页的一个最近的副本,用于进行数据恢复了。
4.3 自适应哈希索引(adaptive hash index)
哈希(hash
)是一种非常快的查找方法,一般情况下查找的时间复杂度为 O(1)
。但是由于不支持范围查询等条件的限制,InnoDB
并没有采用 hash
索引,但是如果能在一些特殊场景下使用 hash
索引,则可能是一个不错的补充,而 InnoDB
正是这么做的。
具体的,InnoDB
会监控对表上索引的查找,如果观察到某些索引被频繁访问,索引成为热数据,建立哈希索引可以带来速度的提升,则建立哈希索引,所以称之为自适应(adaptive
)的。自适应哈希索引通过缓冲池的 B+
树构造而来,因此建立的速度很快。而且不需要将整个表都建哈希索引,InnoDB
会自动根据访问的频率和模式来为某些页建立哈希索引。
4.4 预读(read ahead)
InnoDB
在I/O
的优化上有个比较重要的特征为预读,当InnoDB
预计某些page
可能很快就会需要用到时,它会异步地将这些page
提前读取到缓冲池(buffer pool
)中,这其实有点像空间局部性的概念。
空间局部性(spatial locality
):如果一个数据项被访问,那么与他地址相邻的数据项也可能很快被访问。
InnoDB
使用两种预读算法来提高I/O
性能:
- 线性预读(
linear read-ahead
) - 随机预读(
randomread-ahead
)
其中,线性预读以 extent
(块,1
个 extent
等于64
个 page
)为单位,而随机预读放到以 extent
中的 page
为单位。线性预读着眼于将下一个extent
提前读取到 buffer pool
中,而随机预读着眼于将当前 extent
中的剩余的 page
提前读取到 buffer pool
中。
线性预读(linear read-ahead)
线性预读(Linear read-ahead
):线性预读方式有一个很重要的变量 innodb_read_ahead_threshold
,可以控制 Innodb
执行预读操作的触发阈值。如果一个 extent
中的被顺序读取的 page
超过或者等于该参数变量时,Innodb
将会异步的将下一个 extent
读取到 buffer pool
中,innodb_read_ahead_threshold
可以设置为0-64
(一个 extent
上限就是64
页)的任何值,默认值为56
,值越高,访问模式检查越严格。
随机预读(Random read-ahead)
随机预读(Random read-ahead
): 随机预读方式则是表示当同一个 extent
中的一些 page
在 buffer pool
中发现时,Innodb
会将该 extent
中的剩余 page
一并读到 buffer pool
中,由于随机预读方式给 Innodb code
带来了一些不必要的复杂性,同时在性能也存在不稳定性,在5.5
中已经将这种预读方式废弃。要启用此功能,请将配置变量设置 innodb_random_read_ahead
为ON
。