文章目录
1.一条查询语句是如何执行的
mysql> select * from T where ID=10;
上面这条语句是怎么执行的?先来看看mysql逻辑架构图,MySQL可以分为Server层和存储引擎层。
执行过程:
1.连接器连接
连接器负责跟客户端建立连接、获取权限、维持和管理连接。
连接命令通常是这样写的:
mysql -h$ip -P$port -u$user -p
输完命令后,需要在交互对话里输入密码。
注:ip和port不写默认为本机和3306端口。密码可以写在-p后,但一般不这样做(导致密码泄漏)。
在客户端与服务器完成TCP三次握手后,连接器开始认证你的身份,这个时候用的就是输入的用户名和密码。
- 用户名密码不对,收到"Access denied for user"的错误,客户端程序结束执行。
- 用户名密码通过,连接器会到权限表查出你拥有的权限。之后的所有操作,都将依赖于这个权限。
2.查询缓存(8.0后废弃)
MySQL拿到这个语句后,会先到缓存看看,这个查看是看之前有没有与这条完全一模一样的语句执行过(这是受key-value存储的影响,key是查询语句,value是查询结果),如果查到了则会直接返回到客户端,没查到继续后面的流程,这点与操作系统查询类似。
缺点:
- 命中率低,列之间换个顺序都会导致无法命中。
- 容易失效。只要一个表更新,那么这个表上的所有查询都会被清空。
可以将参数 query_cache_type 设置成 DEMAND关闭查询缓存。
3.分析器(做什么)
如果没有命中缓存,就开始真正执行语句了。MySQL需要知道你要干嘛,因此对SQL语句做解析。
词法分析: MySQL识别你输入的字符串分别是什么,代表什么。比如这里的”T“代表”表名T“,”ID“代表”列ID“。(也就会判断这个列是不是属于这个表)
语法分析: 判断语句是否满足MySQL语法,不满足会收到“You have an error in your SQL syntax”的错误提醒。
4.优化器(怎么做)
优化器是表里有多个索引的时候,决定使用哪个索引,或者一个语句有多表关联的时候,决定各个表的连接顺序。
5.执行器
判断你对这个表T有没有执行查询的权限,如果没有,就会返回没有权限的错误。
如果有权限,就打开表继续执行。打开表的时候,执行器会根据表的引擎定义,去使用这个引擎提供的接口。
这个例子的执行情况如下(假设id字段没有索引,为全局扫描):
- 调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是 10,如果不是则跳过,如果是则将这行存在结果集中;
- 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
- 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。
对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。(执行器并不知道索引情况,只管通过接口拿数据)
2.一条更新语句是如何执行的
表结构如下:
mysql> create table T(ID int primary key, c int);
执行更新:
mysql> update T set c=c+1 where ID=2;
和查询那一套差别不大,同样是先连接数据库,清除表T上的所有缓存,分析器通过词法和语法解析知道这是一条更新语句,优化器决定使用ID这个索引,执行器负责执行,找到这一行,然后更新。
所不同的是,更新用到了两个日志:redo log(重做日志)和binlog(归档日志)。
redo log
原理:类似于掌柜记账,掌柜有一个账本,记录客人赊账的情况,但是每赊账一个客人就去账本里找并更新他的赊账记录,在忙碌的时候肯定来不及,用脑瓜子记呢又怕记错,所以掌柜搞了个黑板,直接把客人这次的赊账记录在黑板上,然后在空闲的时候再去更新账本就好了。当然,黑板大小是有限的,当黑板写满的时候,掌柜只好停下手中的活儿,先更新一部分记录再接着做下面的活。
MySQL把这种原理叫做WAL(Write-Ahead Logging)写前日志技术,关键是先写日志,再写磁盘。
当有一条记录更新的时候,InnoDB引擎会先把记录写到redo log里面,并保存。在适当的时候(系统空闲时),将这个操作更新到磁盘里面。如果redo log写满了,将开头的更新到数据,并用新的记录覆盖,整体像是一个循环队列,write_pos表示当前记录的位置,相当于队列尾,checkpoint是当前要擦除的位置,相当于队列头。
有了redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe。
bin log
MySQL分为server层和引擎层,上述redo log是InnoDB引擎特有的,其他引擎没有,而bin log是server层有的,与用什么引擎无关。
bin log非常好理解,它记录了所有语句原始逻辑,如“给 ID=2 这一行的 c 字段加 1”。
可以发现:
- redo log是物理日志,记录"在某个数据页做了什么修改";bin log是逻辑日志,记录语句的原始逻辑。
- redo log循环写,空间固定会用完;bin log没有这个烦恼,它是追加写的。
了解了这两个日志,我们接着看语句的执行过程
- 执行器先找到ID=2这一行。ID是主键,引擎直接用搜索树找到这一行,如果这一行已经在内存中,直接返回给执行器,否则先从磁盘读入内存,再返回。
- 执行器拿到引擎给的行数据,先把值加一,得到新的行数据,再调用引擎接口写入这行数据。
- 引擎将这行新数据更新到内存中,同时将这个更新操作记录到redo log里,此时redo log处于prepare状态。然后告知执行器执行完成了,随时可以提交事务。
- 执行器生成这个操作的bin log,并把bin log写入磁盘。
- 执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成提交(commit)状态,更新完成。
流程图如下:
最后三步的蜜汁操作把redo log拆成了prepare和commit,这就是两阶段提交。
两阶段提交
两阶提交的目的是为了让两份日志之间的逻辑一致。redo log和bin log它们的功能不同。
redo log保证crash-safe。
bin log记录了所有的操作,所有能让数据库恢复到任意一秒的状态,实现传统意义上的日志功能。系统会定期做整库备份,可以是一天一备,也可以是一周一备。
比如,某天下午两点发现中午十二点有一次误删表,需要找回数据,那你可以这么做:
- 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;
- 然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。
为什么那么麻烦,要用两阶段提交呢?如果不用,会发生什么情况?
- 先写redo log后写bin log。如果写完redo log挂掉了,系统重启仍然可以通过redo log把数据恢复过来,这个时候,二者不一致,当有恢复库的要求时,由于bin log的丢失,就会少这一次更新。
- 先写bin log后写redo log。如果写完bin log挂掉了,恢复库的时候就会多一次莫名其妙的更新,与原库不同。
3.事务隔离
脏读:读取到其他事务未提交的数据
不可重复读:前后读取的记录内容不一致(由更新导致)
幻读:前后读取的记录数量不一致(由插入导致)
四种隔离级别
为了解决上面三个问题,衍生出四个隔离级别
- 读未提交(read uncommitted):一个事务还没提交,它做的变更就能被别的事务看到,造成脏读。
- 读提交(read committed):一个事务提交后,它做的变更才能被其他事务看到,但会造成不可重复读。
- 可重复读(repeatable read):mysql默认的隔离级别,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。会导致幻读,但可以通过多版本并发控制解决。
- 串行化(serializable):对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
这四种隔离级别,依靠视图来实现:
- 读未提交:直接返回记录上的最新值,没有视图概念。
- 读提交:每个SQL语句开始时候创建视图。
- 可重复读:第一次select的时候创建,整个事务期间都用这个视图。
- 串行化:通过加锁来避免访问。
可重复读的具体实现
实际上每条记录在更新的时候都会同时记录一条回滚操作(undo log)。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。
当前值是4,但是在查询这一条记录的时候,不同时候启动的事务会有不同的视图版本(read-view)。在版本A、B、C里,同一个记录的值分别是1、2、4。同一记录在系统存在多个版本,就是数据库的多版本并发控制(MVCC)。对于视图A,要想拿到该值,就必须从当前值回滚到A版本的位置。这样,即使现在有事务正在把4改成5,这个事务和视图A、B、C视图是不会冲突的。
回滚日志要到事务结束(不使用视图)才会删除,这就存在一个问题,假如T1事务创建了视图A,然后一直存在(长事务),那么它用到的回滚数据就会一直保留,导致占用大量存储空间。
所以尽量不要使用长事务,除了对回滚段的影响外,长事务还会占用锁资源,可能拖垮整个库。
4.索引(上)
为什么要有索引?
索引的出现是为了提高数据查询的效率,就像书的目录一样。对于数据库而言,索引就是它的“目录”。
索引的常见模型
哈希表
用一个哈希函数把key换算成一个确定的位置,然后把value放在数组的这个位置。
比如,我们想根据身份证号查到这个人的信息,只需要hash(id_card)算出这个人的哈希地址,然后从这个地址取出user对象即可。
优点:等值查询很快,增加新的索引很快。
缺点:区间查询很慢。
有序数组
优点:等值查询和范围查询都很快。可以借助二分查找进一步加快查找速度。
缺点:更新数据麻烦,成本太高。
所以这种索引只适用于静态存储索引,比如2020年某个城市人口信息,这类不会修改的数据。
二叉搜索(排序)树
搜索速度取决于树高(每查询一个节点就进行一次I/O),因此可以考虑:
- 使用平衡二叉树
- 改为N叉树
这样就能最大化的缩小树高。
但是N并不是越大越好,磁盘每次读取的数据块大小是有限的,尽量保持N的数据量差不多等于数据块大小就好。
InnoDB索引模型
在InnoDB中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表,且使用了B+树作为存储结构。每一个索引在InnoDB里面对应一颗B+树。
假如有如下表,主键列为ID,表中有字段k,并且在k上有索引:
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB;
InnoDB的索引组织结构如下
从表中可以看到,根据叶子节点的内容,索引类型分为聚簇索引和非聚簇索引。
聚簇索引的叶子节点存的是整行数据。
非聚簇索引的叶子节点存的是主键的值。
下面来看看二者的区别:
- 如果语句是select * from T where ID = 500,即聚簇索引方式,则只需要搜索ID这棵B+树。
- 如果语句是select * from T where k = 5,即非聚簇索引方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。这个过程称为回表。
查询的时候尽量使用主键查询,可以避免每次查询需要搜索两棵树。
索引维护
B+树为了维护索引有序性,在插入新值的时候需要做必要的维护。从数据结构课程可知,存在这节点的分裂与合并,所以,主键的选择,就显得非常重要。
一些建表规范要求表里一定要有自增主键,这是为什么呢?
- 从性能上看,自增主键的插入数据模式,为追加操作,不会涉及挪动其他记录,也不会触发叶子节点的分裂。
- 从存储空间来看,自增主键为整型变量,只需占用4个字节。主键长度越小,非主键索引的叶子节点就越小,占用的空间就越小。
由于索引可能因为删除、分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面利用率更高,也就是索引更紧凑、更省空间。
但是这样的重建是不可取的:
//重建非聚簇索引k
alter table T drop index k;
alter table T add index(k);
//重建聚簇索引
alter table T drop primary key;
alter table T add primary key(id);
因为无论是删除还是创建主键,都会导致整个表重建。所以如果连着执行这两个重建,第一个重建就白做了,这里用一条语句即可
alter table T engine=InnoDB
5.索引(下)
如果我们在一张表里根据非聚簇索引去区间查找数据,会执行几次树的操作,扫描多少行?表结构按上图所示。
select * from T where k between 3 and 5
- 在 k 索引树上找到 k=3 的记录,取得 ID = 300;
- 再到 ID 索引树查到 ID=300 对应的 R3;
- 在 k 索引树取下一个值 k=5,取得 ID=500;
- 再回到 ID 索引树查到 ID=500 对应的 R4;
- 在 k 索引树取下一个值 k=6,不满足条件,循环结束。
可以看到,这个过程读了k索引树的3条记录(步骤1、3、5),回表了两次(步骤2、4)。
有没有可能经过索引优化,避免回表呢?
覆盖索引
如果执行的语句是select ID from T where k between 3 and 5,只需要查询ID的值,可以发现,ID已经在k索引树上了,这就省去了回表操作。索引k已经覆盖了我们的查询请求,称为覆盖索引。覆盖索引是一种常用的性能优化手段。
比如在一张市民信息表上,有一个高频请求,需要用身份证号去找这个人的名字。
CREATE TABLE `tuser` (
`id` int(11) NOT NULL,
`id_card` varchar(32) DEFAULT NULL,
`name` varchar(32) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
`ismale` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `id_card` (`id_card`),
KEY `name_age` (`name`,`age`)
) ENGINE=InnoDB
这时,光靠建立身份证号索引会导致大量的回表操作,如果使用 (身份证号,姓名)联合索引,则不需要进行回表,减少执行时间。
但是,覆盖索引也是有代价的,根据(身份证号,姓名)建立索引,这就导致了B+树节点变大,维护的代价也更高。所以建立冗余索引来支持覆盖索引需要权衡考虑。
最左前缀原则
如果每一种查询都设计一个索引,这索引未免太多了吧。
我们得知,前面的(身份证号)索引,是为了应对身份证号去查询这个人的家庭住址需求,而这个需求是极少数的情况,身份证号所耗费的字节数非常大,这样很明显是一种极大浪费。
其实,B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。
用(name, age)联合索引先做分析
可以发现,索引项是按照索引定义里面出现的字段排序的,name在前,就先排序name,对于相同的name,再排序age。也就是说,仅从name来看,它是整体有序的,而age是局部有序。因此,(name, age)的联合索引就可以当作(name)单独索引使用了。
比如:
- 当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到 ID4,然后向后遍历得到所有需要的结果。
- 如果你要查的是所有名字第一个字是“张”的人,你的 SQL 语句的条件是"where name like ‘张 %’"。这时,你也能够用上这个索引,查找到第一个符合条件的记录是 ID3,然后向后遍历,直到不满足条件为止。
只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。
回到身份证号查找地址的问题,由于我们已经有了(身份证号,姓名)联合查询,所以单独的(身份证号)索引就可以去掉了。
既然最左字段可以实现这样的优势,那么建立联合索引的时候,如何去安排字段顺序呢?
- 通过调整顺序,可以少维护一个索引,那么这个顺序就可以优先考虑。
- 当然,如果既有(a, b)联合查询,又有a, b各自的查询,这时候就不得不维护(a, b),(b)两个索引了。a, b谁占空间小,谁就单独拿出来做索引。
索引下推
还是以市民表的(name, age)为例,如果有一个需求:检索出表中“名字第一个字是张,而且年龄是 10 岁的所有男孩”。
mysql> select * from tuser where name like '张%' and age=10 and ismale=1;
按照最左前缀规则,会按照"张"找到第一个满足条件的记录ID3。接下来呢?
在MySQL5.6之前,只能从ID3开始一个一个回表(不会去看联合索引里面age的值),找到主键索引上对应的行,再比对字段值。
在MySQL5.6引入了索引下推优化(index condition pushdown),可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
可以看到,在加入优化前,需要回表4次,加入优化后,只需要回表2次。
6.锁
根据加锁的范围,MySQL里面的锁大致可以分为全局锁、表级锁和行级锁三类。
全局锁
全局锁是对整个数据库实例加锁,MySQL提供了一个加全局读锁的方法,FTWRL。
Flush tables with read lock
之后其他线程的以下语句会被阻塞:数据更新语句(DML)(增删改)、数据定义语句(DDL)(包括建表、修改表结构)和更新类事务的提交语句。
典型使用场景-----做全库逻辑备份,把整个库的每个表都select出来存储成文本,整个备份过程中整个库完全处于只读状态。
缺点:
- 如果在主库备份,备份期间都不能执行更新,业务基本上得停摆。
- 如果在从库备份,备份期间从库不能执行主库同步过来的binlog,而从库用于实现读写分离的话,就会导致主从延迟。
使用全局锁的主要目的是为了确保系统在备份过程中视图逻辑一致,即备份的都是这一时刻的所有数据,InnoDB在此有着天然优势----在可重复读隔离级别下开启一个事务,再加上MVCC的支持,原库可以持续更新,视图也保持独立不受影响。这样就可以不使用全局锁了。
官方自带的逻辑备份工具mysqldump实现了这一功能,通过mysqldump使用参数-single-transaction实现
mysqldump -u root -h 127.0.0.1 --single-transaction -p reservation > C:\Users\young\Desktop\reservation.sql
mysqldump这功能太好啦,全局锁不就没用了是吧。
这种实现有个前提,就是支持事务,像MyISAM连事务都不支持,就不能使用。
其实set global readonly = true也可以让全库进入只读状态,但还是推荐使用FTWRL方式,主要是因为
- readonly会被用来做其他逻辑,比如判断一个库是主库还是备库。
- 如果客户端发生异常断开,FTWRL方式会自动释放全局锁,整个库可以回到正常更新的状态,而readonly方式会一直保持readonly状态,导致整个库长时间不可写。
表级锁
MySQL里面表级别的锁有两种,一种是表锁,一种是元数据锁(meta data lock, MDL)。
表锁
lock tables ... read/write
与FTWRL类似,可以用unlock tables自动释放锁,也可以在客户端断开的时候自动释放。
lock tables除了会限制别的线程读写外,也限制了本线程接下来的操作对象。对表加读锁(lock tables t1 read)后,自己也不能对其进行修改,自己和其他线程只能读取该表;对表加写锁后(lock tables t2 write),自己能读写该表,其他线程对表的读写都被阻塞。
注意,一旦对表加锁,该线程就只能操作该表,无法操作其他表。
没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式(MyISAM)。对于InnoDB这种支持行锁的引擎,一般不使用表锁控制并发,锁整个表的影响还是太大。
元数据锁(MDL)
MDL不需要显示使用,在访问一个表的时候会被自动加上。MDL是为了保证读写的正确性存在的。如果查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做了变更,删了一列,那么查询线程拿到的结构跟表结构就对不上。
因此,MySQL5.5版本引入了MDL,对一个表做增删改查的时候,加MDL读锁;当要对表结构做变更的时候,加MDL写锁。
- 读锁之间不互斥,因此可以多个线程同时对一张表增删改查。
- 读写锁之间、写锁之间互斥,保证变更表结构的安全性。
实战题:给一个小表加字段,导致整个库挂掉了,这是为什么?
- sessionA先启动,这时会对表t加一个MDL读锁。
- sessionB来了,它需要的也是读锁,可以正常执行。
- sessionC来了,它需要MDL写锁,被阻塞。
- sessionD来了,被阻塞。
- 后续的所有查询全被阻塞。
由于客户端有重试机制,也就是超时会再起一个新session请求,这个库的线程会很快爆满。
这是由于写锁优先级高于读锁导致的。申请MDL锁的操作会形成一个队列,队列中写锁优先级高于读锁,一旦出现写锁等待,不但当前操作会被阻塞,同时还会阻塞后续该表的所有操作,事务一旦申请到MDL锁后,直到事务执行完才会释放。
怎么加字段才算安全呢?
- 首先解决长事务,事务不提交,就会一直占着MDL锁。如果你要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。(之前还说过长事务导致MVCC回滚段不能回收长时间占用空间)
- 如果遇到请求频繁的表,kill可能不大好使,因为新的事务又来了。这时在alter table语句里设置等待时间,在这个等待时间里能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,之后再通过重试指令重复这个过程。
行锁
行锁是对数据库中行记录的锁。比如事务A需要更新一行,这时事务B也要更新同一行,则事务B必须等待事务A操作完成后才能更新。
MySQL行锁是在引擎层由各个引擎自己实现的。InnoDB支持行锁,而MyISAM不支持行锁(只能使用表锁),这是MyISAM被InnoDB替代的重要原因之一。
行锁也遵循两阶段锁协议,行锁是在更新的时候加上的(扩展),而在更新完成后,不会立即释放(此时还处在扩展阶段),需要待事务提交的时候才会释放(收缩)。这就意味着,如果把加锁的代码放在最后,每个事务在最后才执行可能会引起阻塞的操作,那么阻塞的时间就会缩短(相比于放在事务开头),可以提高整体性能。
两阶段锁协议(TwoPhase Locking, 2PL)
- 在对任何数据进行读写之前,首先要申请并获得对该数据的封锁
- 在释放一个封锁之后,事务不再申请和获得任何其他封锁
第一阶段获得封锁(扩展阶段),事务可以申请任何数据上的任何锁,但不能释放任何锁。
第二阶段释放封锁(收缩阶段),事务可以释放任何数据的任何锁,但是不能申请任何锁。
死锁和死锁检测
由于锁的存在,类似于共享变量,数据库也会遭遇java多线程,操作系统一样的死锁现象,如下图所示,事务A获得了id1的锁,在等待id2的锁,事务B获得了id2的锁,在等待id1的锁,出现了循环等待,若无外力作用,永远无法执行下去。
数据库中,应对死锁现象,主要有两种策略:
- 设置等待超时。超时自动释放锁,可以通过参数innodb_lock_wait_timeout 来设置。
- 发起死锁检测。发现死锁后,主动回滚死锁链条中的某一个事务,可以通过把参数 innodb_deadlock_detect 设置为on开启。
对于第一种策略,非常尴尬,超时的时间很难把握,如果时间过长,像是在线服务的业务无法接受;如果时间过短,又会出现很多“误伤”。
正常情况还是使用第二种策略,这种策略的负担主要在循环的去检测,每当一个事务被锁的时候,就会看看它所依赖的线程有没有被锁住,如此循环,最后判断是否出现了循环等待(死锁),时间复杂度为O(n),这只是一个线程,如果同时存在多个线程呢?时间复杂度就会上升至O(n2),这是非常恐怖的。如果某一时刻有1000个线程去更新同一行(典型的秒杀活动),那么死锁检测操作就是1000*1000的数量级,虽然最终检测结果是没有死锁,但会消耗大量的CPU资源。
这种问题如何解决呢?
- 如果确保业务一定不会死锁,就把检测死锁关掉,一般不采用。
- 控制并发度。在数据库服务端做并发控制,对于相同的行的更新,在进入引擎之前排队,可以考虑在中间件实现。
- 将一行改为逻辑上的多行。比如多个线程要修改电影院账户的总额,可以把总额分在多个记录上,比如10条记录,总额就等于这10个记录的总和,在给影院账户增加金额的时候,随机选一条记录来加,冲突就变为原来的1/10。
7.事务到底是隔离还是不隔离的
在MySQL里,有两个“视图”概念:
- 一个是view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view …。
- 另一个是InnoDB在实现MVCC时用到的一致性视图,即consistent read view, 用于支持RC(读提交)和RR(可重复读)隔离级别的实现。
一致性视图的实现
前面有说到,RC是在每个语句执行的时候创建一致性视图,RR是在第一个select语句执行的时候创建一致性视图,那么这个一致性视图是怎么保证拿到数据的版本正确呢?(为了讲述方便,这里RR在事务创建就创建一致性视图)
注:一致性视图是基于整库的。
InnoDB里面每个事务都有一个唯一的事务ID,叫做transaction id。它是事务开始时向InnoDB事务系统申请的,按申请顺序严格递增。每行数据也有多个版本,每次事务更新的时候,都会生成一个新的数据版本,并把transaction id赋值给这个数据版本,记为row trx_id。同时旧的数据版本要保留,新的版本能够直接拿到它。
也就是说,表中的一行记录,有多个版本(row),每个版本有自己的row trx_id。
图中的三个虚线箭头,就是之前所说的undo log(回滚日志),而V1,V2,V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。
一个事务启动的时候,它能看到所有已经提交的事务结果,但是之后,在当前事务的执行期间,其他事务的更新对它不可见。如何实现这个功能的呢?
InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,正在“活跃”的所有事务ID,“活跃”指的是,启动了但还没提交。
数组的最小值叫做低水位,最大值加一叫做高水位,低于低水位的事务,看作是已提交事务,高于高水位的事务,看作是此刻未启动的事务。
(图中“未提交事务集合”换成“可能未提交事务集合比较好”)
对于数据库启动瞬间来说,一个数据版本的row trx_id,有以下几种可能
- 落在绿色部分,表示这个版本是已提交的或是当前事务自己生成的,这个数据可见。
- 落在红色部分,表示是未来的事务生成的,不可见。
- 落在黄色部分,分为两种情况
a. 若row trx_id在数组中,表示是由没提交的事务生成的,不可见
b.若row trx_id不在数组中,表示是由已提交的事务生成的,可见
更新逻辑
如果事务在读取一行的时候,更新了这行,再度读取时它拿到的是怎样的数据呢?如果更新前数据被其他事务更新了呢?
更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”。
也就是说,事务B更新数据的时候,不再从它启动时的历史版本1更新了,而是从当前版本更新,k变为3,当前版本变为101。当事务B再去读这个值的时候,发现最新版本是自己,可以直接使用。
当然,前面是假设事务C更新过后立即提交,如果C没有提交呢?前面说过InnoDB会自动加行锁,也就是当前数据的写锁(X锁)还没有被释放,此时事务B是当前读,要加读锁(S锁),被阻塞,直到事务C提交过后才能执行。