1 简介
MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
2 事务启动
- 显式启动事务语句,
begin或start transaction。配套的提交语句是commit,回滚语句是rollback。 set autocommit=0关闭自动提交 (容易出现因为长链接导致的长事务)- 在 autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit 则提交事务。如果执行
commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。
3 事务特性/隔离级别
3.1 事务特性
事务特性ACID
- Atomicity 原子性
- Consistency 一致性
- Isolation 隔离性
- Durability 持久性
3.2 并发问题
3.2.1 脏读(dirty read)
我们有两个事务,一个是 Transaction A,一个是 Transaction B,在第一个事务里面,它首先通过一个 where id=1 的条件查询一条数据,返回 name=Ada,age=16 的这条数据。然后第二个事务呢,它同样地是去操作 id=1 的这行数据,它通过一个 update 的语句,把这行 id=1 的数据的 age 改成了 18。但是大家注意,它没有提交。这个时候,在第一个事务里面,它再次去执行相同的查询操作,发现数据发生了变化,获取到的数据 age 变成了 18。
那么,这种在一个事务里面,由于其他的时候修改了数据并且没有提交,而导致了前后两次读取数据不一致的情况,这种事务并发的问题,我们把它定义成脏读。
3.3.2 不可重复读(non-repeatable read)
同样是两个事务,第一个事务通过 id=1 查询到了一条数据。然后在第二个事务里面执行了一个 update 操作,这里大家注意一下,执行了 update 以后它通过一个 commit提交了修改。然后第一个事务读取到了其他事务已提交的数据导致前后两次读取数据不一致的情况,就像这里,age 到底是等于 16 还是 18,那么这种事务并发带来的问题,我们把它叫做不可重复读。
3.3.3 幻读(phantom read)
案例一
在第一个事务里面我们执行了一个范围查询,这个时候满足条件的数据只有一条。在第二个事务里面,它插入了一行数据,并且提交了。重点:插入了一行数据。在第一个事务里面再去查询的时候,它发现多了一行数据。一个事务前后两次读取数据数据不一致,是由于其他事务插入数据造成的,这种情况我们把它叫做幻读。
案例二
select * from t where d=5 for update。这个语句的意思你应该很清楚了,查所有 d=5 的行,而且使用的是当前读,并且加上写锁。
- Q1 只返回 id=5 这一行;
- 在 T2 时刻,session B 把 id=0 这一行的 d 值改成了 5,因此 T3 时刻 Q2 查出来的是 id=0 和 id=5 这两行;
- 在 T4 时刻,session C 又插入一行(1,1,5),因此 T5 时刻 Q3 查出来的是 id=0、id=1 和 id=5 的这三行。
其中,Q3 读到 id=1 这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。这里,我需要对“幻读”做一个说明:
- 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,
幻读在“当前读”下才会出现。 - 上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。
幻读仅专指“新插入的行。
3.3 事务隔离级别
为了解决这些问题,就有了“隔离级别”的概念。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。
- 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的(InnoDB 的默认事务隔离级别)。
- 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
4 事务隔离的实现(MVCC)
4.1 简介
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读
4.2 MVCC能解决什么问题?
数据库并发场景有三种,分别为:
- 读-读:不存在任何问题,也不需要并发控制
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
- 写-写:有线程安全问题,可能会存在更新丢失问题
MVCC带来的好处是?
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以MVCC可以为数据库解决以下问题
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
4.3 实现原理
实现原理主要是依赖记录中的 隐式字段、undo日志 以及Read View 来实现的。
4.3.1 隐式字段
每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段。
- DB_TRX_ID 6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
- DB_ROLL_PTR 7byte,回滚指针,指向这条记录的上一个版本(用于配合undo日志,指向上一个旧版本)
- DB_ROW_ID 6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
- 实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了
4.3.2 undo日志
undo log主要分为两种:
-
insert undo log
代表事务在insert新记录时产生的undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃
-
update undo log
事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除 为了实现InnoDB的MVCC机制,更新或者删除操作都只是设置一下老记录的
deleted_bit,并不真正将过时的记录删除。
为了实现InnoDB的MVCC机制,更新或者删除操作都只是设置一下老记录的deleted_bit,并不真正将过时的记录删除。
4.3.3 Read View
Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)。
在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
4.4 可见性算法
主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本
FAQ
1 如何解决幻读问题?
1.1 间隙锁
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。顾名思义,间隙锁,锁的就是两个值之间的空隙。
案例分析
这样,当你执行 select * from t where d= for update 的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。
1.1.2 next-key lock
间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。
间隙锁和 next-key lock 的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。例如:死锁、减少并发。
注意
- 间隙锁是开区间
- 间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了
2 为什么幻读会导致死锁?
你看到了,其实都不需要用到后面的 update 语句,就已经形成死锁了。我们按语句执行顺序来分析一下:
- session A 执行 select … for update 语句,由于 id=9 这一行并不存在,因此会加上间隙锁 (5,10);
- session B 执行 select … for update 语句,同样会加上间隙锁 (5,10),
间隙锁之间不会冲突,因此这个语句可以执行成功; - session B 试图插入一行 (9,9,9),被 session A 的间隙锁挡住了,只好进入等待;session A 试图插入一行 (9,9,9),被 session B 的间隙锁挡住了。
- 至此,两个 session 进入互相等待状态,形成死锁。当然,InnoDB 的死锁检测马上就发现了这对死锁关系,让 session A 的 insert 语句报错返回了。
3 为什么幻读只会在可重复读隔离级别下生效?
间隙锁是在可重复读隔离级别下才会生效,是因为在可重复读隔离级别下,MySQL会对查询的范围加上间隙锁,以防止其他事务在这个范围内插入新的数据。
在可重复读隔离级别下,MySQL会对读取的每一行数据都加上共享锁,以保证读取到的数据是一致的。同时,MySQL还会对查询的范围加上间隙锁,以防止其他事务在这个范围内插入新的数据。这样可以避免幻读的问题,即一个事务在读取数据时,发现有新的数据插入,导致读取的数据不一致。
在其他隔离级别下,间隙锁不会生效,因为MySQL不会对查询的范围加上间隙锁。例如,在读提交隔离级别下,MySQL只会对读取的每一行数据加上共享锁,而不会对查询的范围加上间隙锁。这样就可能会出现幻读的问题。
因此,间隙锁只在可重复读隔离级别下生效,是为了保证数据的一致性和避免幻读的问题。但是,间隙锁也会增加锁冲突的可能性,因此在使用时需要注意避免锁冲突的情况。
4 当前读是什么?
它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。当前读的规则,就是要能读到所有已经提交的记录的最新值。
select lock in share mode(共享锁)select for update(排它锁)updateinsertdelete(排他锁)
这些操作都是一种当前读。
5 快照读是什么?
不加锁的select操作就是快照读,即不加锁的非阻塞读;
快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;快照读的实现是基于多版本并发控制,即MVCC,既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
6 purge线程
为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
7 MVCC可见性算法详解
可见性算法详解:DB_TRX_ID去跟Read View某些属性进行怎么样的比较?
我们可以把Read View简单的理解成有三个全局属性
trx_list一个数值列表,用来维护Read View生成时刻系统正活跃的事务IDup_limit_id记录trx_list列表中事务ID最小的IDlow_limit_idReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
- 首先比较DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到DB_TRX_ID 所在的记录;
- 如果大于等于进入下一个判断,接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见
- 如果小于则进入下一个判断,判断DB_TRX_ID 是否在活跃事务之中trx_list.contains(DB_TRX_ID)如果在,则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的。