文章目录
- 基础
- MyISAM
- 表锁
- 并发插入
- 锁调度策略
- InnoDB
- 事务
- 并发事务
- 行锁
- 行锁争用情况
- 行锁实现方式
- 恢复和复制对InnoDB锁机制的影响
- 死锁
- MVCC
- 底层实现和原理
- 悲观锁和乐观锁
基础
锁是计算机协调多个进程或线程并发访问某一资源的机制(避免争抢)。在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。
相对其他数据库而言,MySQL的锁机制比较简单,其最显著的特点是不同的存储引擎支持不同的锁机制。从操作数据的角度,可以把锁分为:
- 表级锁:
- 操作数据时,会锁定整个表。
- 开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
- MyISAM和Memory存储引擎使用表级锁。
- 行级锁:
- 操作数据时,会锁定当前数据行。
- 开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
- InnoDB存储引擎默认使用行级锁。
- 页面锁:
- 操作数据是,会锁定当前数据所在的页面。
- 开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
- BDB存储引擎采用页面锁。
不同存储引擎对锁的支持情况如下表所示:
存储引擎 | 表级锁 | 页面锁 | 行级锁 |
---|---|---|---|
MyISAM | 支持 | 不支持 | 不支持 |
InnoDB | 支持 | 不支持 | 支持 |
MEMORY | 支持 | 不支持 | 不支持 |
BDB | 支持 | 支持 | 不支持 |
从锁的角度来说:表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如Web应用;而行级锁则更适合于有大量按索引条件并发更新数据,同时又有查询的应用,如一些在线事务处理系统。
MyISAM
MyISAM存储引擎只支持表锁(Table Lock),可以分为读锁(表读锁,Table Read Lock)和写锁(表写锁,Table Write Lock)。
MyISAM的读锁和写锁的相互兼容性如下表所示:
当前锁模式/请求锁模式 | 读锁 | 写锁 |
---|---|---|
读锁 | 是 | 否 |
写锁 | 否 | 否 |
解释:
- 对MyISAM表的读操作(加读锁),不会阻塞其他用户对同一表的读请求,但会阻塞其他用户对同一表的写请求;
- 对MyISAM表的写操作(加写锁),不会阻塞当前用户对同一表的读和写请求,但会阻塞其他用户对同一表的读和写请求;
简单来说,MyISAM表的读操作和写操作之间,以及写操作之间是串行的。当一个线程获得对一个表的写锁后,只有持有锁的线程可以对表进行更新操作,其他线程的读、写操作都会等待,直到锁被释放为止。
表锁
MyISAM存储引擎在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁;在执行更新操作(UPDATE、DELETE、INSERT 等)前,会自动给涉及的所有表加写锁。加锁的过程对于用户是透明的,不需要用户干预。
当然,用户也可以在操作数据之前显式地加锁:
# 加读锁
lock table table_name read;
# 加写锁
lock table table_name write;
注意:
- 显式加锁一般是为了方便说明问题,比如模拟事务操作,实现对某一时间点的一致性读取。
- 显式加锁时必须同时取得所有涉及表的锁。也就是说,在执行LOCK TABLE后,只能访问显式加锁的这些表,不能访问未加锁的表。在自动加锁的情况下也是如此,MyISAM总是一次获得SQL语句所需要的全部锁,这也正是MyISAM不会出现死锁的原因。
并发插入
MyISAM的读锁和写锁是互斥的,所以MyISAM表的读和写操作是串行的。但是,MyISAM存储引擎也允许设置一个系统变量concurrent_insert,来控制并发插入的行为。
- concurrent_insert=0:不允许并发插入。
- concurrent_insert=1:在MyISAM表没有空洞(表的中间没有被删除的行)的情况下,MyISAM允许在一个进程读取表的同时,另一个进程从表尾部插入记录。这是MySQL的默认设置。
- concurrent_insert=2:无论MyISAM表有没有空洞,都允许在表尾部并发插入记录。
锁调度策略
MyISAM的读写锁调度的策略是写锁优先。如果一个进程请求某个MyISAM表的读锁,同时另一个进程也请求同一个表的写锁,那么写进程会先获得锁。即使读请求先到锁等待队列,写请求后到,写请求也会先获得到锁。
这是因为MySQL认为写请求一般比读请求更重要。这也正是MyISAM不适合做写为主的表的存储引擎的原因。因为如果更新操作较多,会使得查询操作很难获得读锁,从而产生严重的锁等待,导致查询操作被阻塞、查询效率低。
InnoDB
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-transaction-model.html
InnoDB与MyISAM的最大不同有两点:一是支持事务;二是采用了行锁。行锁开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。InnoDB默认使用行锁。
事务
参考InnoDB-事务一节。
多个事务并发运行能大大增加数据库资源的利用率,提高数据库系统的事务的吞吐量,从而可以支持更多的用户。但是,因为并发事务处理可能操作相同的数据,会导致一些问题:
- 修改丢失(Lost to modify):指一个事务修改了一个数据后,还没有进行提交,此时另外一个事务也修改了这个数据,那么第一个事务的修改就丢失了。这种情况称为修改丢失。
- 脏读(Dirty read):指一个事务修改了一个数据后,还没有进行提交,此时另外一个事务使用了这个修改过的数据。因为第一个事务还没有提交,我们可以认为第二个事务读到的数据是“脏数据”。这种情况称为脏读。
- 不可重复读(Unrepeatable read):指在一个事务内多次读取同一个数据的结果不一样。如果在一个事务两次读取一个数据之间,另一个事务对这个数据进行了修改,导致第一个事务两次读取的结果不一样。这种情况称为不可重复读。
- 幻读(Phantom read):幻读与不可重复读类似,是指一个事务以一定的查询条件读取了一些数据,之后按相同的查询条件发现多了一些原本不存在的记录,这是由于其他并发事务插入了满足该查询条件的新数据。这种情况称为幻读。
不可重复读和幻读的区别:不可重复读的重点是数据修改,比如多次读取一条记录,发现其中某些列的值被修改;幻读的重点是数据增加或者删除,比如多次读取某种条件的数据,发现记录增加或减少了。
并发事务
在上面的问题中,“修改丢失”通常是应该完全避免的。但是,防止丢失修改,不能单靠数据库事务控制来解决,更需要应用程序加必要的锁来解决。而“脏读”、“不可重复读”和“幻读”其实都是数据库一致性问题,必须由数据库系统提供一定的事务隔离机制来解决。
数据库系统实现事务隔离的方式,基本上可以分为两种:
- 锁:在读取数据前,对其加锁,阻止其他事务对数据进行修改。
- 多版本并发控制(Multi-Version Concurrency Control,简称MVCC):MVCC不需要对数据加锁,而是通过一定机制在一个数据请求时间点,生成一个一致性数据快照(snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取。从用户的角度来看,好像数据库可以提供同一个数据的多个版本。
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大。事务隔离实质上就是使用事务在一定程度上“串行化” 进行,这显然与“并发” 是矛盾的。
为了解决“隔离”与“并发”的矛盾,ISO/ANSI SQL92标准定义了四种隔离级别,每个级别的隔离程度不同,允许出现的副作用也不同。隔离级别从低到到高分别是:
- 读未提交(Read Uncommitted):最低的隔离级别,允许事务读取其他事没有提交的数据修改,可能会导致脏读、幻读或不可重复读。
- 读已提交(Read Committed):允许事务读取其他事务已经提交的数据修改,可以防止脏读,但是可能会导致不可重复读和幻读。
- 可重复读(Repeatable Read):MySQL默认的隔离级别。在一个事务中,多次读取同一个数据的结果是一致的,除非是自己进行修改。该级别可以防止脏读和不可重复读,但是可能会导致幻读。
- 可串行化(Serializable):最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,事务之间就完全不可能产生干扰。该级别可以防止脏读、不可重复读以及幻读。
不同隔离级别的特性如下:
隔离级别 | 读数据一致性 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
读未提交 | 最低级别 | 是 | 是 | 是 |
读已提交 | 语句级 | 否 | 是 | 是 |
可重复读 | 事务级 | 否 | 否 | 是 |
可串行化 | 最高级别,事务级 | 否 | 否 | 否 |
需要说明的是,不同数据库系统不一定完全实现了上述4个隔离级别。例如,Oracle只提供Read Committed和Serializable两个标准隔离级别,另外还提供自己定义的Read Only隔离级别。MySQL支持全部4个隔离级别,但在具体实现和应用时,有很多需要注意的地方。
比如,InnoDB存储引擎默认的隔离级别是Repeatable Read,可以查看tx_isolation
变量:
mysql> SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
由于使用了间隙锁,还可以避免幻读问题,所以InnoDB默认的隔离级别已经达到了Serializable隔离级别的要求。
行锁
InnoDB存储引擎实现了标准的的行级锁(Row-level Locking),有两种类型:
- 共享锁(Shared Lock,简称S):允许持有锁的事务读取一行。共享锁也可以称为读锁。
- 排他锁(Exclusive Lock,简称X):允许持有锁的事务更新或删除一行。排他锁也可以称为写锁。
如果事务 T1 在行 r 上持有共享锁,则来自某个不同事务 T2 的请求在行 r 上的锁将按如下方式处理:
- 可以立即授予 T2 的共享锁请求。 结果,T1 和 T2 都持有 r 上的 S 锁。
- 不能立即授予 T2 对排他锁的请求。
如果事务 T1 在行 r 上持有排他锁,则无法授予来自某个不同事务 T2 对 r 上任一类型锁的请求。相反,事务 T2 必须等待事务 T1 释放它对行 r 的锁定。
为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks)。这两种意向锁都是表锁:
- 意向共享锁(Intention Shared Lock,简称IS):事务打算给数据加共享锁,在加共享锁之前必须先取得该表的意向共享锁。
- 意向排他锁(Intention Exclusive Lock,简称IX):事务打算在数据加排他锁,在加排他锁之前必须先取得该表的意向排他锁。
上述锁模式的兼容情况如下表所示:
当前锁模式|请求锁模式 | X | IX | S | IS |
---|---|---|---|---|
X | 冲突 | 冲突 | 冲突 | 冲突 |
IX | 冲突 | 兼容 | 冲突 | 兼容 |
S | 冲突 | 冲突 | 兼容 | 兼容 |
IS | 冲突 | 兼容 | 兼容 | 兼容 |
如果一个事务请求的锁模式与当前的锁兼容,InnoDB就把请求的锁授予该事务;反之,如果两者不兼容,那么该事务就要等待锁释放。需要注意,意向锁是InnoDB存储引擎自动加的,不需要程序员干预!所以,我们只需要考虑共享锁和排他锁。
对于更新语句(UPDATE、DELETE和INSERT),InnoDB会自动给涉及的数据加排他锁。加锁的过程对于用户是透明的,不需要用户干预。对于普通查询语句(SELECT),InnoDB不会加任何锁,不过用户可以显式给数据加共享锁或排他锁:
-- 共享锁
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE;
-- 排他锁
SELECT * FROM table_name WHERE ... FOR UPDATE;
行锁升级为表锁:InnoDB的行锁是基于索引的,如果使用不通过索引的查询条件对数据加锁,那么InnoDB将对表中的所有记录加锁,实际效果跟表锁一样。
间隙锁:如果使用范围条件,而不是相等条件的查询条件请求锁,那么InnoDB会给符合查询条件的已有数据进行加锁;对于键值在查询条件范围内但并不存在的记录(可以称为间隙),InnoDB也会对这个间隙加锁。这种锁机制就是所谓的间隙锁,间隙锁只能在Repeatable Read隔离级别下使用。间隙锁的好处是可以避免幻读问题,坏处是会降低并发度。
行锁争用情况
通过检查InnoDB_row_lock状态变量,可以分析系统上的行锁的争用情况:
show status like 'innodb_row_lock%';
解释:
- Innodb_row_lock_current_waits:当前正在等待行锁的数量
- Innodb_row_lock_time:从系统启动到现在,获取行锁花费的总时间(单位:毫秒)
- Innodb_row_lock_time_avg:从系统启动到现在,获取行锁花费的平均时间(单位:毫秒)
- Innodb_row_lock_time_max:从系统启动到现在,获取行锁花费的最大时间(单位:毫秒)
- Innodb_row_lock_waits:从系统启动到现在,等待行锁的总次数
如果发现锁争用比较严重,比如Innodb_row_lock_waits和Innodb_row_lock_time_avg的值比较高,可以通过查询information_schema数据库中相关的表来查看锁情况,或者通过设置InnoDB Monitors来进一步观察发生锁冲突的表、数据等。
行锁实现方式
InnoDB行锁是通过给索引上的索引项加锁来实现的。如果没有索引,InnoDB将通过隐藏的聚簇索引来对数据记录员加锁。
InnoDB行锁分为3种情形:
1)记录锁(Record lock)
记录锁对一个索引记录加锁。比如SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;
阻止任何其他事务对该行的插入、更新和删除操作。
记录锁总是给索引记录加锁,即使一个表没有索引。对于这种情况,InnoDB会创建一个隐藏的聚簇索引,并使用这个索引进行记录锁定。
2)间隙锁(Gap lock)
间隙锁对索引记录之间的“间隙”加锁,比如SELECT c1 FROM t WHERE c1 >= 10 and c1 <= 20 FOR UPDATE;
阻止任何其他事务插入一条t.c1=15
的记录,无论该列是否有等于该值的记录。
一个间隙可能跨越单个索引值、多个索引值,甚至是空的。
间隙锁是性能和并发性之间权衡的一部分,并且用于某些事务隔离级别而不是其他事务隔离级别。
3)Next-Key lock
Next-Key是前两种锁的组合,即索引记录上的记录锁和索引记录之间的间隙上的间隙锁的组合。举例,emp_tab表有101条记录,其emp_id分别是1、2、…、100、101,对于SELECT * from emp_tab where emp_id > 100 FOR UPDATE
来说,InnoDB不仅会对符合查询条件的emp_id等于101的记录加记录锁,也会对emp_id大于101的“间隙“加锁。
InnoDB使用Next-Key锁的目的,一方面是为了防止幻读,满足相关隔离级别的要求;另一方面是为满足其恢复和复制的需要。
很显然,在使用范围条件查询并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待。因此,在实际开发中,尤其是并发插入比较多的应用,应该尽量使用相等条件来访问更新数据,避免使用范围条件。
需要特别说明的是,如果使用相等的查询条件给一个不存在的记录加锁,InnoDB也会使用Next-Key锁,会阻塞其他事务插入数据。
总结,InnoDB行锁的实现特点意味着:
- InnoDB的行锁是基于索引的,如果使用不通过索引的查询条件对数据加锁,那么InnoDB将对表中的所有记录加锁,实际效果跟表锁一样。
- 行锁是针对索引记录加锁。如果不同的数据记录具有相同的索引键,那么不同的事务对这些数据记录加锁会出现锁冲突。
- 当表有多个索引时,不同的事务可以使用不同的索引锁定不同的数据记录,不论是使用主键索引、唯一索引还是普通索引,InnoDB都会使用行锁对数据记录加锁。如果使用不同的索引对同一个数据记录加锁,那么会出现锁冲突。
- 即便在查询条件种使用了索引字段,但是是否使用来检索数据是由MySQL通过判断不同执行计划的代价来决定的。如果MySQL认为全表扫描效率更高,那么就不会使用索引,这种情况下会对所有记录加锁。因此,在分析锁冲突时,必须先检查SQL的执行计划,以确认是否真正使用了索引。
- 避免使用范围条件给数据加锁,应该尽量使用相等条件。
- 避免使用相等条件给不存在的记录加锁。
恢复和复制对InnoDB锁机制的影响
MySQL通过Binlog记录执行成功的INSERT、UPDATE、DELETE等更新数据的SQL语句,并由此实现MySQL数据库的恢复和主从复制。MySQL的恢复机制有以下特点:
- MySQL的恢复是SQL语句级的,也就是在从数据库重新执行BINLOG中的SQL语句。
- MySQL 的Binlog是按照事务提交的先后顺序记录的,恢复也是按这个顺序进行的。
因此,要正确恢复或复制数据,就必须满足:在一个事务未提交前,其他并发事务不能插入满足其锁定条件的任何记录,也就是不允许出现幻读。这已经超过了SQL92标准Repeatable Read隔离级别的要求,实际上是要求事务要串行化。这也是许多情况下,InnoDB要用到Next-Key锁的原因,比如在用范围条件更新记录时,无论是在Read Committed还是Repeatable Read隔离级别下,InnoDB都要使用Next-Key锁,但这并不是隔离级别要求的。
MySQL在处理insert into target_tab select * from source_tab where ...
和create table new_tab ...select ... From source_tab where ...
等语句时给source_tab加共享锁,而没有使用多版本数据一致性技术。加锁的主要原因是为了保证恢复和复制的正确性,具体例子参考《深入浅出MySQL》20.3.6节。但是,加锁可能会阻止对源表的并发更新,如果查询比较复杂,会造成严重的性能问题。所以,在实际中应该尽量避免使用。
死锁
MyISAM存储引擎使用表锁,而且不会发生死锁的情况,这是因为MyISAM总是一次性获得所需的全部锁,要么全部获得,要么等待。但是对于InnoDB存储引擎,锁是逐步获得的,导致可能发生死锁的情况。
死锁是指多个事务都需要获取其他事务持有的排他锁才能继续完成事务,而且事务之间存在循环等待的情况。比如,两个事务都在等待获取对方持有的排他锁,无法继续各自的事务,导致事务失败。
发生死锁后,InnoDB一般都能自动检测到,并使一个事务释放锁并回退,另一个事务获得锁,继续完成事务。但是在涉及外部锁或表锁的情况下,InnoDB并不能完全自动检测到死锁,这需要设置锁等待超时参数innodb_lock_wait_timeout来解决。
通常来说,死锁都是应用设计的问题,通过调整业务流程、数据库对象设计、事务大小以及访问数据库的SQL语句,绝大数思索都可以避免。为了尽可能减少死锁,建议:
- 不同业务流程应该尽量以相同的顺序访问多个表。比如两个业务流程都需要更新表A和B,应该约定使用相同的访问顺序。
- 在程序以批量方式处理数据时,最好先对数据排序,保证每个线程按固定的顺序来处理记录。
- 如果有更新操作,应该直接申请足够级别的锁(排他锁),而不应该先申请共享锁,等到更新时再申请排他锁。
- 不要给不存在的记录加锁。在Repeatable Read隔离级别下,如果两个事务同时对相同条件的记录用
SELECT ... FOR UPDATE
加排他锁,在没有符合该条件记录的情况下,两个事务都会加锁成功。程序发现记录尚不存在并试图插入一条新纪录,就会导致死锁。 - 控制事务的大小。大的事务需要锁定的记录更多,处理时间更长,更容易产生死锁。
- 使用合理的索引。如果查询条件不走索引将会给表的所有记录加锁,死锁的概率大大增大。
- 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从Repeatable Read调整为Read Commited,可以避免一些因为间隙锁造成的死锁。
尽管通过上面介绍的设计和SQL优化等措施,可以大大减少死锁,但死锁仍然很难避免。如果要分析死锁,可以用SHOW INNODB STATUS
命令来查看最后一个死锁的相关信息。
MVCC
mysql的MVCC(多版本并发控制)
https://www.bilibili.com/video/BV1xT411F7TW/?spm_id_from=333.999.0.0&vd_source=e591c11c1982728d43f020ce0b3830b8
MVCC的全称是Multi-Version Concurrency Control,即多版本并发控制。MVCC通过维护同一个数据的多个版本(或快照Snapshot),来解决快照读时的读写冲突。MVCC的优点是可以避免读操作加锁导致的阻塞,减少了开销,提高了并发性能。
当前读:读取记录的最新版本。读取时需要保证其他并发事务不能修改当前记录,因此会对读取的记录进行加锁。比如SELECT LOCK IN SHARE MODE;SELECT FOR UPDATE;UPDATE;INSERT;DELETE。
快照读:非阻塞读,即不加锁的 SELECT 操作。快照读的实现是基于多版本并发控制 MVCC。具体来说,快照读是读取MVCC多版本数据链中的某个快照版本。
MVCC只有InnoDB存储引擎支持(MyISAM不支持),而且只能用在Read Committed和Repeatable Read两种隔离级别。对于其他两种隔离级别,Read Uncommitted可以直接读其他事务未提交的数据,也就是总是读取最新的数据,因此不需要MVCC;而串行级别Serializable根本不允许事务并发,所有事务完全按顺序执行,可以认为快照读在这个级别下会退化成当前读,因此也不需要MVCC。
底层实现和原理
InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然,存储的并不是实际的时间值,而是系统版本号,每开始一个新的事务,系统版本号就会递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。——来自《高性能MySQL》
具体来说,MVCC的实现主要是依赖每行记录中的两个隐藏字段DB_TRX_ID、DB_ROLL_PTR以及读视图Read View:
- DB_TRX_ID:创建或修改该条记录的事务 ID。另外,事务ID会随着时间越来越大,后发生的事务ID要大于之前发生的事务。
- DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本。
- Read View:事务进行快照读生成的读视图,记录并维护系统当前活跃事务的ID,包括
- rw_trx_ids:创建Read View时,所有的活跃事务ID。事务活跃意味着事务还没有提交。
- min_trx_id:活跃的事务ID列表rw_trx_ids中的最小事务ID。
- max_trx_id:创建Read View时,将要分配给下一个事务的ID。
- curr_trx_id:创建Read View时的当前事务ID。
举例,在通过下列SQL命令操作一条数据后,
insert test_tab(id, name) values (1, "mysql"); -- DB_TRX_ID = 100
update test_tab set name = "sql" where id = 1; -- DB_TRX_ID = 200
update test_tab set name = "mvcc" where id = 1; -- DB_TRX_ID = 300
这条数据的版本链如下图所示,
解释:新版本数据的事务ID大于旧版本的,并且有指针指向旧版本数据。
多版本非最新版本的数据存储在UNDO日志中,而且该日志也可以用来实现事务回滚。
注意,读视图Read View在Read Committed和Repeatable Read两种隔离级别下的工作机制略有不同:
- 对于Read Committed,每次快照读查询都会创建一个新的读视图Read View,因此读视图中的活跃的事务ID列表可能会变化(因为其他并发的事务可能已经提交)。这意味着,在这个隔离级别下,一个事务可以读取到其他并发事务提交的修改。这也就是为什么这个隔离级别称为读已提交Read Committed。
- 对于Repeatable Read,只在第一次快照读时创建一个读视图Read View,并且在整个事务期间保持不变。因此,读视图Read View中的活跃事务ID应该都小于创建该读视图的事务ID。这意味着,在这个隔离级别下,创建该读视图的事务只能读取DB_TRX_ID小于当前事务ID的那些版本的数据。
根据上面这条数据,举例说明一下快照读的原理。如果有一个快照读事务尝试查询SELECT name FROM test_tab where id = 1
,
1)假设快照读所在的事务ID=301,读视图Read View的当前活跃的事务包括(205,255,300),其中事务205和255可能数据id=1无关,那么该查询会查到name=sql。具体来说,查询从DB_TRX_ID=300版本的数据开始,发现对应的事务未提交(因为300在活跃事务中),然后根据DB_ROLL_PTR向后查找历史版本,最后发现DB_TRX_ID=200版本的数据已经提交(因为200不在活跃事务中),返回这个版本的数据。
2)假设快照读所在的事务ID=150,读视图Read View的当前活跃的事务包括(205,255,300),那么该查询在不同的隔离级别会查到不同的结果:
- 对于Read Committed,会查到name=sql,因为该隔离级别允许一个事务(事务ID=150)读取其他事务(事务ID=200)提交的更新。
- 对于Repeatable Read,会查到name=mysql,因为该隔离级别只能读取DB_TRX_ID小于当前操作事务的ID的数据。
3)假设快照读所在的事务ID=50,读视图Read View的当前活跃的事务包括(205,255,300),那么该查询在不同的隔离级别会查到不同的结果:对于Read Committed,会查到name=sql;对于Repeatable Read,会查不到任何结果。
4)假设快照读所在的事务ID=300,读视图Read View的当前活跃的事务包括(205,255,300),那么该查询所处的事务是活跃的事务(还没有提交),所以可以观察到当前事务的更新,返回name=mvcc。
5)假设快照读所在的事务ID=299,读视图Read View的当前活跃的事务包括(205,255,300),
- 第一次查询,对于Read Committed和Repeatable Read,都会查到name=sql。
- 假设事务ID=300提交。
- 第二次查询,对于Read Committed,会创建一个新的读视图,活跃的事务包括(205,255),因此可以查到name=mvcc;对于Repeatable Read,读视图不会变化,因此仍然查到name=sql。这也就是为什么这个隔离级别称为可重复读Repeatable Read。
由于旧数据并不真正的删除,所以必须对这些数据进行清理,Innodb会开启一个后台线程执行清理工作,具体的规则是将删除版本号小于当前系统版本的行删除,这个过程叫做purge。
悲观锁和乐观锁
一般来说,并发事务有三种数据冲突:
- 读读冲突:并发读之间互不影响。
- 读写冲突:加锁或使用MVCC机制。
- 写写冲突:必须加锁,包括悲观锁(Pessimistic lock)和乐观锁(Optimistic lock)。
解决写写冲突的方法有:
1)使用Serializable隔离级别,事务串行执行,那么就没有任何冲突。为了事务并发,大多数情况都不会使用这种方法。
2)使用悲观锁。悲观锁是在数据库层面加锁,但是等待锁会导致阻塞。
举例:SELECT * FROM tab FOR UPDATE
,在SELECT
语句后边加了FOR UPDATE
相当于加写锁(排他锁)。加了写锁以后,其他事务不能进行修改,需要等待当前事务执行完。
3)使用乐观锁。
乐观锁不是数据库层面上的锁,而是一种思想,具体实现是在表中添加一个版本字段version。当更新某个数据时,检查该数据的版本是否和预期相同。如果相同,才可以更新,否则不可以更新。
举例:UPDATE tab SET name=xxx,version=version+1 WHERE ID={id} AND version={version}
,该语句会判断version字段与当前的version字段是否相等,相等则更新成功,否则更新失败。所以,如果两个事务同时更新同一行数据,一个事务更新成功,其他事务就会更新失败。