事务
数据库一般都会并发执行多个事务,多个事务可能会并发的对相同的一批数据进行增删改查操作,可能就会导致脏写、脏读、不可重复读、幻读这些问题。这些问题的本质都是数据库的多事务并发问题。
为了解决多事务并发问题,数据库设计了事务隔离机制、锁机制、MVCC多版本并发控制隔离机制,用一整套机制来解决多事务并发问题。
并发事务造成的问题
1. 更新丢失(Lost Update)或脏写
当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题–最后的更新覆盖了由其他事务所做的更新。
2. 脏读(Dirty Reads)
一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此作进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫做“脏读”。
一句话:事务A读取到了事务B已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果B事务回滚,A读取的数据无效,不符合一致性要求。
3. 不可重读(Non-Repeatable Reads)
一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。
一句话:事务A内部的相同查询语句在不同时刻读出的结果不一致,不符合隔离性
4. 幻读(Phantom Reads)
一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。
一句话:事务A读取到了事务B提交的新增数据,不符合隔离性
事务隔离级别
事务隔离级别是为了解决并发事务执行时可能出现的各种问题。
串行化 》 可重复读 》 读已提交 》 读未提交
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | ✔️ | ✔️ | ✔️ |
读已提交 | ✖️ | ✔️ | ✔️ |
可重复读(InnoDB默认的隔离级别) | ✖️ | ✖️ | 大部分避免 |
串行化 | ✖️ | ✖️ | ✖️ |
查看事务隔离级别: show variables like 'tx_isolation';
设置事务隔离级别:set tx_isolation='REPEATABLE-READ';
Mysql 锁
锁机制详解
锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中,除了传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供需要用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。
锁分类
执行性能:
乐观锁: (用版本对比或CAS机制),乐观锁适合读操作较多写少的场景
悲观锁: 适合写操作较多的场景
锁粒度:
按照数据操作的粒度,分为表锁、页锁、行锁
按照数据库操作的类型,分为读锁和写锁(都属于悲观锁),还有意向锁
读锁
读锁(共享锁,S锁(Shared)):针对同一份数据,多个读操作可以同时进行而不会互相影响,比如:
select * from T where id = ? lock in share mode;
写锁
写锁(排它锁,X锁(eXclusive)):当前写操作没有完成前,它会阻断其他写锁和读锁,数据修改操作都会加写锁,查询也可以通过for update加写锁,比如:
select * from T where id = ? for update;
意向锁
意向锁(Intention Lock):又称I锁,针对表锁,主要是为了提高加表锁的效率,是Mysql数据库自己定义的。当有事务给表的数据行加了共享锁或排他锁,同时会给表设置一个标识,代表已经有行锁了,其他事务要想对表加表锁时,就不必逐行判断有没有行锁可能跟表锁冲突了,直接读这个标识就可以确定自己该不该加表锁。特别是表中的记录很多时,逐行判断加表锁的方式效率很低。而这个标识就是意向锁。
意向锁主要分为:
意向共享锁,IS锁,对整个表加共享锁之前,需要先获取到意向共享锁。
意向排他锁,IX锁,对整个表加排他锁之前,需要先获取到意向排他锁。
页锁
只有BDB(Berkeley DB)存储引擎支持页锁,页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
行锁
每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。
注意: InnoDB的行锁实际上是针对索引加的锁(在索引对应的索引项上做标记),不是针对整个行记录加的锁。并且该索引不能失效,否则会从行锁升级为表锁。(RR级别会升级为表锁,RC级别不会升级为表锁)
比如我们在RR级别(可重复读)执行如下sql:
-- where条件里的name字段无索引, 会锁表, 其它Session对该表任意一行记录做修改操作都会被阻塞住
select * from account where name = ? for update;
关于RR级别行锁升级为表锁的原因分析
在RR隔离级别下,为了解决不可重复读和幻读问题,所以在遍历扫描聚集索引记录时,为了防止扫描过的索引被其它事务修改(不可重复读问题) 或 间隙被其它事务插入记录(幻读问题),从而导致数据不一致的情况,所以MySQL的解决方案就是把所有扫描过的索引记录和间隙都锁上,这里要注意,并不是直接将整张表加表锁,因为不一定能加上表锁,可能会有其它事务锁住了表里的其它行记录。
间隙锁
间隙锁(Gap Lock) 要用于防止在索引记录之间的间隙中插入新的数据,确保事务的隔离性,避免幻读现象。它锁定的是索引记录之间的间隙,而不是具体的记录,间隙锁是在可重复读隔离级别下才会生效。
Mysql默认级别是repeatable-read,有幻读问题,间隙锁是可以解决幻读问题的。
假设user表里数据如下:
那么间隙就有 id 为 (3,10),(10,20),(20,正无穷) 这三个区间,
在Session_1下面执行如下sql , 则其他Session没法在这个(10,20)这个间隙范围里插入任何数据。
select * from user where id = 18 for update;
如果执行下面这条sql, 则其他Session没法在这个(20,正无穷)这个间隙范围里插入任何数据。
select * from user where id = 25 for update;
也就是说,只要在间隙范围内锁了一条不存在的记录会锁住整个间隙范围,不锁边界记录,这样就能防止其它Session在这个间隙范围内插入数据,就解决了可重复读隔离级别的幻读问题。
临键锁
临键锁(Next-Key Locks):临键锁是查询时InnoDB根据查询的条件而锁定的一个范围,这个范围中包含有间隙锁和记录锁;临键锁=间隙锁+记录锁。
Tips:临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC(读已提交),临键锁则也会失效。
临键锁锁住的区间为:记录+区间(左开右闭)
MySQL 中的事务如何实现的?
事务的实现机制主要依赖日志系统、锁机制和并发控制技术,通过多组件协同保障ACID特性。MySQL 通过 InnoDB 存储引擎来实现事务,主要依赖:
事务的 ACID 特性
特性 | 实现 |
原子性 | 过 Redo Log(重做日志)和Undo Log(撤销日志)实现的 |
隔离性 | 通过锁机制和 MVCC(多版本并发控制)来实现的 |
持久性 | 通过 Redo Log(重做日志)和 双写缓冲 机制来实现的 |
一致性 | 一致性是通过上面的三个特性和锁机制实现的 |
事务的日志机制
日志机制 | 核心作用 | 实现机制 |
Redo Log(重做日志) | 确保事务的持久性(Durability),通过日志恢复数据库崩溃导致未写入磁盘修改数据修。 | 记录物理层面的数据页修改,采用两阶段写入:先写入内存中的Log Buffer,再按策略刷盘。 |
Undo Log(回滚日志) |
|
|
事务的隔离级别
隔离级别 | 实现 |
读未提交 | 不加任何锁,直接读取最新数据 |
读已提交 |
|
可重复读(默认) |
|
串行化 |
|
锁机制
前面以及介绍了Mysql 的各种锁,
MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行update、insert、delete操作会自动给涉及的表加写锁。
InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。InnoDB 读锁会阻塞写,但是不会阻塞读。而写锁则会把读和写都阻塞。
MVCC(多版本并发控制)
Mysql在可重复读隔离级别下如何保证事务较高的隔离性,同样的sql查询语句在一个事务里多次执行查询结果相同,就算其它事务对数据有修改也不会影响当前事务sql语句的查询结果。这个隔离性就是靠MVCC(Multi-Version Concurrency Control)机制来保证的,对一行数据的读和写两个操作默认是不会通过加锁互斥来保证隔离性,避免了频繁加锁互斥,而在串行化隔离级别为了保证较高的隔离性是通过将所有操作加锁互斥来实现的。
MVCC机制的实现就是通过read-view机制与undo版本链比对机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据。
Undo日志版本链 与Read view机制详解
undo日志版本链是指一行数据被多个事务依次修改过后,在每个事务修改完后,Mysql会保留修改前的数据undo回滚日志,并且用两个隐藏字段trx_id和roll_pointer把这些undo日志串联起来形成一个历史记录版本链(见下图)
在可重复读隔离级别,当事务开启,执行任何查询sql时会生成当前事务的一致性视图read-view,该视图在事务结束之前永远都不会变化(如果是读已提交隔离级别在每次执行查询sql时都会重新生成read-view),这个视图由执行查询时所有未提交事务id数组(数组里最小的id为min_id)和已创建的最大事务id(max_id)组成,事务里的任何sql查询结果需要从对应版本链里的最新数据开始逐条跟read-view做比对从而得到最终的快照结果。
特别说明:
1. 事务的实际启动时机:执行 BEGIN/START TRANSACTION 命令并不会立即启动事务, 事务的真正启动是在执行第一个修改操作或加排它锁操作时, 此时才会向 MySQL 申请事务 ID
2. 事务 ID 分配规则:MySQL 严格按照事务的实际启动顺序分配事务 ID,这个顺序决定了事务的可见性判断
版本链比对规则
1. 如果 row 的 trx_id 落在绿色部分( trx_id<min_id ),表示这个版本是已提交的事务生成的,这个数据是可见的;
2. 如果 row 的 trx_id 落在红色部分( trx_id>max_id ),表示这个版本是由将来启动的事务生成的,是不可见的(若 row 的 trx_id 就是当前自己的事务是可见的);
3. 如果 row 的 trx_id 落在黄色部分(min_id <=trx_id<= max_id),那就包括两种情况
a. 若 row 的 trx_id 在视图数组中,表示这个版本是由还没提交的事务生成的,不可见(若 row 的 trx_id 就是当前自己的事务是可见的);
b. 若 row 的 trx_id 不在视图数组中,表示这个版本是已经提交了的事务生成的,可见。
Readview和可见性算法的原理解释
MySQL 事务的实现主要依赖于 ReadView 和可见性算法机制。这个机制的核心是记录 SQL 查询执行时刻数据库内所有事务的提交状态。
在可重复读(RR)隔离级别下:
- 事务中的每次查询操作都会使用第一次查询时生成的 ReadView
- 该 ReadView 记录了事务首次查询时数据库内所有事务的状态
- 通过这个固定的 ReadView 来判断数据的可见性
- 从而确保整个事务期间看到的数据视图是一致的
在读已提交(RC)隔离级别下:
- 事务中的每次查询都会重新生成 ReadView
- 每次查询都基于当前数据库的最新事务状态
- 因此可以读取到其他事务已提交的最新数据