事务
事务
事务有哪些特性?
主要是 ACID 四个特性:
原子性:Atomicity
一个事务中的所有操作要么全部完成,要么全部不完成,不会结束在中间某个环节,而且如果事务在执行过程中发生错误,会被回滚到事务开始前的状态,就好像这个事务从来没有执行过一样,原子性主要是通过 undo log,也就是回滚日志,来实现的
一致性:Consistency
事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态,不能出现一方数据变化了但另一方数据没有变化的情况,一致性主要是通过持久性 + 原子性 + 隔离性来保证的
隔离性:Isolation
数据库允许多个并发事务同时对其数据进行读写和修改,但每个事务的操作对其他事务是隔离的,隔离性会防止多个事务并发执行时由于交叉执行而导致数据不一致的问题,每个事务都有一个完整的数据空间,隔离性是通过 MVCC,也就是多版本并发控制,或锁机制来保证的
持久性:Durability
一旦事务提交,对数据的修改就是永久性的,即使系统发生故障也不会丢失,持久性主要是通过 redo log,也就是重做日志,来实现的
并发事务会带来哪些问题?
主要是脏读、不可重复读和幻读三种问题,严重程度是脏读 > 不可重复读 > 幻读
脏读:
如果一个事务读到了另一个未提交事务修改过的数据,就意味着发生了脏读现象
例如一个事务 A 先开始读取了某个数据,然后它对这个数据进行了更新,但它还没有提交事务,这时刚好事务 B 也从数据库中读取了这个数据,事务 B 读到的就是事务 A 修改后的数据,如果事务 A 最终回滚了,那么事务 B 读到的数据就是无效的,这种现象就叫做脏读
不可重复读:
在一个事务内多次读取同一个数据,如果出现了前后两次读到的数据不一样的情况,就意味着发生了不可重复读现象
例如一个事务 A 先从数据库中读取了某个数据,然后它继续执行了其他代码逻辑,还没有提交事务,在这个过程中事务 B 对这个数据进行了更新并提交了事务,接着事务 A 再次读取这个数据时,就会发现前后两次读取到的数据是不一致的
幻读:
在一个事务内多次查询某个范围的数据,如果前后两次查询结果的记录数不一样,就意味着发生了幻读现象
例如一个事务 A 先从数据库中读取到了 5 条记录,另一个事务 B 按照相同的搜索条件也查询出了 5 条记录,接下来事务 A 插入了一条记录并提交了事务,事务 B 再次按照相同的搜索条件查询时,就会发现结果中多出了一条记录,变成了 6 条记录
事务的隔离级别有哪些?
按隔离级别从低到高有读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)四种隔离级别
读未提交(Read Uncommitted):允许一个事务读取另一个未提交事务修改过的数据,因此会发生脏读、不可重复读和幻读现象
读已提交(Read Committed):允许一个事务只能读取另一个已提交事务修改过的数据,因此不会发生脏读现象,但仍然可能发生不可重复读和幻读现象
可重复读(Repeatable Read):确保在一个事务内多次读取同一个数据时,读到的数据跟事务启动时看到的数据是一致的,因此不会发生脏读和不可重复读现象,但仍然可能发生幻读现象,MySQL 的默认隔离级别就是可重复读
串行化(Serializable):会对记录加上读写锁,通过强制事务串行执行来避免并发问题,在多个事务对一条记录进行读写操作时,如果发生了读写冲突,后访问的事务必须等待前一个事务执行完成之后才能继续执行,因此不会发生脏读、不可重复读和幻读现象,但会大大降低系统的并发性能
MySQL 的可重复读很大程度上避免了幻读现象,所以 MySQL 并不会使用串行化隔离级别来避免幻读问题,因为它会严重影响数据库的并发性能
在 MySQL 中,读已提交和可重复读的隔离级别的事务是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同,Read View 相当于是一个数据快照,读已提交是在每个语句执行前都会重新生成一个 Read View,而可重复读是在事务开始时生成一个 Read View,整个事务期间都使用这个 Read View
MySQL 有两种开启事务的命令,分别是 begin/start transaction 和 start transaction with consistent snapshot,前者在执行之后并不代表事务启动,只有执行之后的第一个读写操作才会真正启动事务,而后者在执行之后就会立即启动事务并创建一个一致性快照
Read View 在 MVCC 里是如何工作的?
Read View 有下面四个重要的字段:

在聚簇索引中,还会有 trx_id 和 roll_pointer 两个隐藏字段,分别表示该行数据是由哪个事务创建的,以及用于回滚的指针,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 字段中,并把旧版本的记录写到 undo log 中,然后会把该记录的回滚指针记录在 roll_pointer 字段中
在创建 Read View 后,MVCC(多版本并发控制)会通过版本链的方式来控制并发事务访问同一个记录时的行为,记录中的 trx_id 主要有三种情况:

如果记录的 trx_id 值小于 Read View 中的 min_trx_id,说明这个版本的记录是在创建 Read View 之前就已经提交的事务所创建的,因此这个版本的记录对当前事务是可见的
如果记录的 trx_id 值大于 Read View 中的 max_trx_id,说明这个版本的记录是在创建 Read View 之后才开始的事务所创建的,因此这个版本的记录对当前事务是不可见的
如果记录的 trx_id 值介于 Read View 中的 min_trx_id 和 max_trx_id 之间,则需要判断该 trx_id 是否在 m_ids 列表中,如果在的话,就表示生成该版本记录的活跃事务仍然活跃着(还没提交事务),因此该版本记录对当前事务是不可见的;如果不在 m_ids 列表中,说明生成该版本记录的事务已经提交了,因此该版本记录对当前事务是可见的
可重复读是如何工作的?
可重复读的隔离级别是启动事务时生成一个 Read View,然后在整个事务期间都使用这个 Read View 来判断记录的可见性
假设现在有一个事务 A 启动了,然后紧接着事务 B 启动了,B 先读取流量某一条记录,然后 A 将该记录的值进行了修改,并没有提交事务,接着 B 再次读取该记录,这时 B 读取到的值还是修改前的旧值,因为在 B 创建 Read View 时,A 还没有提交事务,所以 B 看不到 A 修改后的新值
接下来 A 提交了事务,然后 B 再次读取该记录,这时 B 读取到的值还是修改前的旧值,因为 B 的 Read View 仍然是基于事务启动时创建的快照,所以 B 看不到 A 修改后的新值,直到 B 提交事务后,B 才能看到 A 修改后的新值
在 undo log 里,MySQL 会保存每个记录的历史版本,当一个事务对某条记录进行修改时,MySQL 会把该记录的旧版本写到 undo log 中,然后通过 roll_pointer 字段将该记录与其旧版本连接起来,形成一个版本链,这样当一个事务需要读取某条记录时,MySQL 就可以通过版本链找到该事务可见的版本
读已提交是如何工作的?
读已提交的隔离级别是在每个语句执行前都会重新生成一个 Read View,然后使用这个 Read View 来判断记录的可见性,也就是说在一个事务期间多次读取同一条数据,前后两次读的数据可能是不一样的,因为可能这期间有另一个事务对该数据进行了修改并提交了事务
假设现在有一个事务 A 启动了,然后紧接着事务 B 启动了,B 先读取某一条记录,然后 A 将该记录的值进行了修改,并没有提交事务,接着 B 再次读取该记录,发现这个记录的 trx_id 的值在事务 B 的最大和最小的 trx_id 之间,然后发现它也在 m_ids 范围内,说明这条记录是被还没有提交的事务修改的,所以 B 不会读取这个版本的记录,而是沿着 uodo log 的链表往下找旧版本的记录,直到找到的 trx_id 小于事务 B 的 Read View 中的 min_trx_id 值的第一条记录,这时 B 读取到的值还是修改前的旧值,因为在 B 创建 Read View 时,A 还没有提交事务,所以 B 看不到 A 修改后的新值
接下来 A 提交了事务,然后 B 再次读取该记录,这时 B 读取到的值就是修改后的新值,因为 B 在这次读取前重新创建了 Read View,B 在找到这条记录时会发现该记录的 trx_id 小于事务 B 的 Read View 中的 min_trx_id 值,说明修改这条记录的事务早在 B 创建这个 Read View 之前就提交过了,所以 B 能够读取到 A 修改后的新值
SELECT * FROM table WHERE id BETWEEN 1 AND 10 FOR UPDATE;,这条 SQL 语句中的 for update 是干什么的?这还是普通的查询语句吗?
使用了 for update 之后,这条 SQL 语句就不再是普通的查询语句了,而是当前读,所以会加锁,for update 会对查询结果集中的记录加上排它锁(X 锁),这样其他事务就无法对这些记录进行修改或删除操作,直到当前事务提交或回滚
MySQL 的可重复读隔离级别,完全解决幻读的问题了吗?
针对快照读(普通的 SELECT 语句),MySQL 的 MVCC(可重复读隔离级别)完全解决了幻读的问题,因为在可重复读隔离级别下,事务在启动时就创建了一个 Read View,这个 Read View 会确保在整个事务期间,所有的快照读操作都只能看到事务启动时的数据状态,因此不会出现幻读现象
针对当前读(update、insert、delete、select ... for update 等语句),这些语句执行前都会查询最新版本的数据,因为如果要更新一个记录时,如果另一个事务已经把这个记录删除并提交事务了,没有拿到最新数据的话就会产生冲突
MySQL 的 InnoDB 引擎为了解决可重复读隔离级别下使用当前读而造成的幻读问题,引入了间隙锁 + 记录锁,例如当事务 A 执行了 SELECT * FROM table WHERE id BETWEEN 1 AND 10 FOR UPDATE; 语句时,InnoDB 会对 id 在 1 到 10 之间的所有记录以及这些记录之间的间隙加锁,这样其他事务就无法在这个范围内插入新的记录,从而避免了幻读现象,如果事务 B 想要在这个范围内插入一条新记录,它会被阻塞,判断到插入的位置被事务 A 加了 next-key lock,所以 B 会生成一个插入意向锁,然后进入等待状态,直到事务 A 提交或回滚后,事务 B 才能继续执行
但还是有两个场景会发生幻读,也就是说 MySQL 的可重复读隔离级别并不能完全解决幻读问题:
第一个:
事务 A 查询了某一条表中没有的记录,查询结果很自然会是空对吧,然后事务 B 插入了这条记录并提交了事务,接着事务 A 选择更新了这个之前没有查找到 id 的记录,就是即使 A 没有查到这个 id 的这条记录,但它选择去更新了这条记录,然后 A 再次查询这个 id 的记录时,就会发现它能看到 B 插入的这条记录了
这是因为 A 对这个 id 的记录进行了更新操作之后,这条新纪录的 trx_id 隐藏列的值会被设置为事务 A 的 trx_id,之后 A 再使用普通的 select 语句,也就是快照读,去查询这条记录时,自然就可以看到这条记录了
第二个:
事务 A 先执行了快照读语句,也就是普通的 select 语句,查询某个范围内的记录,接着事务 B 在这个范围内插入了一条新记录并提交了事务,之后事务 A 执行了当前读语句,比如 update/delete/select ... for update 等语句,去更新/删除/锁定这个范围内的记录时,就会发现多出了一条记录
因为在开启事务之后,A 先执行的是快照读,并没有对记录加间隙锁,所以 B 能够在这个范围内插入新记录,等到 A 执行当前读语句时,A 就会发现多出了一条记录
解决方式也很简单,就是尽量在开启事务之后马上执行当前读语句,加了间隙锁之后就能避免其他事务插入一条新纪录,这样就能避免这种幻读现象
如何设置事务的隔离级别?
MySQL 的事务隔离级别既可以全局配置,也可以按会话和单次事务来设置
在全局层面,可以通过 set global transaction isolation level ... 或在配置文件里写 transaction-isolation=... 来设置全局默认的事务隔离级别,这个只会影响之后新建的连接,... 里可以写 read uncommitted、read committed、repeatable read 或 serializable
如果只是想影响当前连接的后续所有事务,可以用 set session transaction isolation level ... 来设置会话级别的事务隔离级别,但这个必须在 start transaction 之前执行才有效
也可以只影响下一次事务的设置,比如可以通过 set transaction isolation level serializable; start transaction; 来开启一个串行化隔离级别的事务,这个设置只会影响紧接着的这个事务,之后的事务仍然使用之前的隔离级别
事务如何启动?
可以用 begin/start transaction; 来启动一个事务,用 commit; 来提交事务,用 rollback; 来回滚事务
可以用 set autocommit=0; 来关闭自动提交模式,这样每条 SQL 语句执行后都不会自动提交事务,需要手动执行 commit; 来提交事务,或者执行 rollback; 来回滚事务,相当于接下来的查询都一直在事务中,如果是长连接就会导致长事务,所以一般建议使用 set autocommit=1; 来开启自动提交模式