【补充】事务的特性和隔离级别

发布时间 2023-08-22 17:37:44作者: Chimengmeng

【一】什么是事务

  • 事务是MySQL的一种机制
    • 每开启一个事务
    • 都可以往里放入一系列的SQL语句。
  • 事务是单个逻辑工作单位执行的一系列操作
    • 要么全执行
    • 要么全不执行
  • 每开一个事务
    • 相当于给数据库拍了一张快照
    • 这意味着在提交之前可以回滚
    • 在事务中可以设置多个保存点
    • 然后可以进行回滚到每个保存点
  • 我们默认为每条sql开启事务
    • 并且会在本条sql执行完毕后自动执行commit提交
    • 直接用 SET 来改变 MySQL 的自动提交模式
    • 手动开启的事务里默认不会自动提交。
  • 结束事务可以使用commit和rollback
    • 结束事务会释放事务中所有的锁

【二】事务四大特性(ACID)

  • 原子性(Atomicity):数据库把“要么全做,要么全部做”的这种规则称为原子性
  • 隔离性(Isolation):事务之间相互隔离,不受影响,这与事务的隔离级别密切相关
  • 一致性(Consistency):事务执行前后的状态要一致,可理解为数据一致性
  • 持久性(Durable):事务完成之后,她对数据的修改是永恒的,即时出现故障也能够正常保持
  • 我们可以拿发起一笔转账作为例子来诠释事务的特性

(A)原子性:

  • 事务内部的sql语句是一个不可分割的整体
    • 这一系列的sql语句如果有一条运行失败
    • 则整体都运行失败
  • 比如转账行为(指转账方金额减少和接收方金额增加)
    • 只可能全执行成功或全执行失败,

(C)一致性:

  • 事务执行前后的状态保持一致
    • 和原子性紧密相关
    • 比如张三和李四都有500元的余额
    • 他们余额的总数是1000元
    • 然后开启了一个转账事务
    • 张三给李四转了100元以后
    • 他们各自的余额发生了变化
    • 但他们的总金额仍然保持不变。
  • 另外,在事务发生的前后,数据类型也应该保持一致。

(I)隔离性:

  • 多个事务并发运行
    • 但彼此之间互不影响
    • 比如转账时如果A和B同时各开一个事务给C转账
    • 事务2的B先转账成功
    • 但此时事务1中的A看不到B转账成功的信息
    • 但A转账完
    • C能同时收到A和B的转账

(D)持久性:

  • ⼀个事务被提交(commit)之后
    • 它对数据库中数据的改变是持久的
    • 即使数据库发⽣故障也不应该对其有任何影响。
  • 程序访问数据库,往往是多个线程并发执行多个事务,数据库要能进行隔离操作,以保证各个线程获取数据的准确性

  • 所以,对于不同的事务,采用不同的隔离级别会有不同的结果。

  • 如果不考虑事务的隔离性,会发生 几种问题

    • 脏写(Dirty Write)

      • 如果一个事务修改了另一个事务提交修改过的数据,就意味着发生了脏写现象。
    • 脏读(Dirty Read)

      • 如果一个事务读到了另一个未提交事务修改过的数据,就意味着发生了脏读现象。
    • 不可重复读

      • 如果一个事务修改了另一个未提交事务读取的数据,就意味着发生了不可重复读现象。
    • 幻读

      • 如果一个事务先根据某些查询条件查询出一些记录,在该事务未提交时,另一个事务写入了一些符合那些收缩条件的记录(这里指INSERT,DELETE,UPDATE 操作),就以为着发生了幻读现象

【三】脏读,不可重复读,幻读

【1】脏读

  • 脏读指的是读到了其他事务未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。
  • 读到了并一定最终存在的数据,这就是脏读

  • 脏读最大的问题就是可能会读到不存在的数据。
    • 比如在上图中,事务B的更新数据被事务A读取,但是事务B回滚了,更新数据全部还原,也就是说事务A刚刚读到的数据并没有存在于数据库中。
  • 从宏观来看,就是事务A读出了一条不存在的数据,这个问题是很严重的

【2】不可重复读

  • 不可重复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据出现不一致的情况

  • 事务 A 多次读取同一数据,但事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致

【3】幻读

(1)幻读错误的理解

  • 幻读是 事务A 执行两次 select 操作得到不同的数据集,即 select 1 得到 10 条记录,select 2 得到 15 条记录。
  • 这其实并不是幻读,既然第一次和第二次读取的不一致,那不还是不可重复读吗,所以这是不可重复读的一种。

(2)正确的理解

  • 幻读,并不是说两次读取获取的结果集不同,幻读侧重的方面是某一次的 select 操作得到的结果所表征的数据状态无法支撑后续的业务操作。
  • 更为具体一些:select 某记录是否存在,不存在,准备插入此记录,但执行 insert 时发现此记录已存在,无法插入,此时就发生了幻读

  • 查的时候明明没有这条记录,但插入的时候 却告诉我 主键冲突,这就好像幻觉一样。
  • 这才是所有的幻读。
  • 不可重复读侧重表达 读-读,幻读则是说 读-写,用写来证实读的是鬼影

【四】事务的隔离级别

  • 上面我们介绍了事务执行过程中可能会遇到的一些现象,这些现象会对事务的一致性产生不同程度的影响。

  • 上面严重性现象排序 : 脏写 > 脏读 > 不可重复读 > 幻读

  • 为了解决上面的问题,数据库就指定了一个隔离级别标准,隔离级别越低,就越可能发生严重的问题。

READ UNCOMMITTED:未提交读(读未提交)
READ COMMITTED:已提交读(读已提交)
REPEATABLE READ:可重复读
SERIALIZABLE:可串行化

【1】Read uncommitted(读未提交)-ru

  • 事务B读取到了事务A未提交的数据
  • A事务在写数据时,不允许B事务进行写操作,但允许B事务进行读操作
  • 于是 B就会读到A事务写入,但没提交的数据,于是出现脏读
  • 解决了更新丢失,但会出现脏读

【2】Read committed(读已提交)-rc

  • 写事务提交之前不允许其他事务的读操作,可以解决脏读问题。
  • 但会出现一个事务范围内两个相同的查询却返回了不同数据
  • 解决了更新丢失和脏读问题

【3】Repeatable read(可重复读取)-rr

  • 在开始读取数据(事务开启)时,不再允许修改操作,这样就可以在同一个事务内两次读到的数据是一样的,因此称为是可重复读隔离级别,但是有时可能会出现幻读
  • 解决了更新丢失、脏读、不可重复读、但是还会出现幻读

【4】Serializable(串行化)

  • 要求事务序列化执行,事务只能一个接着一个地执行,但不能并发执行
    • 如果仅仅通过“行级锁”是无法实现序列化的,必须通过其他机制保证新插入的数据不会被执行查询操作的事务访问到。
  • 序列化是最高的事务隔离级别,同时代价也是最高的,性能很低,一般很少使用,在该级别下,事务顺序执行
  • 可以避免脏读、不可重复读,幻读

【5】总结

  • 针对不同的隔离级别,并发事务执行过程中可以发生不同的现象
隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不可能 可能 可能
REPEATABLE READ 不可能 不可能 可能
SERIALIZABLE 不可能 不可能 不可能
  • 也就是说:

    • 在 READ UNCOMMITTTED 隔离级别下,可能发生脏读,不可重复读和幻读现象;

    • 在 READ COMMITTED 隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;

    • 在 REPEATABLE READ 隔离级别下,可能发生幻读现象,但是不可能发生脏读和不可重复读现象;

    • 在 SERIALIZABLE 隔离级别下,上述各种现象都不可能发生。

  • 脏写: 脏写这个现象对一致性影响太严重了,无论哪种隔离级别,都不允许出现脏写的情况发生

  • Oracle仅支持两种隔离级别:Read Committed与Serializable,默认基本为RC

  • Mysql的InnoDB 引擎才支持事务,默认事务隔离级别为:REPEATABLE READ(可重复读)-RR

  • 在RR这一隔离级别下,只能解决部分幻读问题,不能解决全部的幻读问题

【五】案例演示

  • 首先设置数据库的隔离级别为读未提交
set session transaction isolation level read UNCOMMITTED
  • 据库的隔离级别设置为读已提交
set session transaction isolation level read committed
  • 隔离级别设置为可重读
set session transaction isolation level REPEATABLE READ
  • 用命令查询下设置的级别
SELECT @@tx_isolation
  • 假设我们现在有一张表,表中有 主键 ID 和 年龄 age 两个字段
    • 执行一条 SQL:insert into t value (1, 1)
  • 接下来我们开启两个事务,从上到下时间线
执行时间顺序 事务A 事务B
1 启动事务,查询得到值 1 启动事务
2 - 查询得到值 1
3 - 1 改成 2
4 查询得到值 V1 -
5 - 提交事务 B
6 查询得到值 V2 -
7 提交事务A -
8 查询得到值 V3 -

【1】读未提交隔离级别下

  • 两个事务同时开启
  • 事务B 获取值1
  • 事务B 将其1改成2
  • 事务A 得到 V1 的值为 2
    • 原因:此隔离级别下,事务B 虽然还没有提交,但是 事务A 是可以看到被修改的结果的
  • 事务B 提交
  • 事务A 拿到 V2 的值为 2
  • 事务A 提交
  • 事务A 拿到 V3 的值为 2

【2】读已提交隔离级别下

  • 两个事务同时开启
  • 事务B 获取值1
  • 事务B 将其1该为2
  • 事务A 得到 V1 的值为 1
    • 原因:读已提交隔离级别下,事务B 的更新再提交后才能被 A 看到,此刻还没有提交
  • 事务B 提交
  • 事务A 查询 V2 的值为 2
    • 原因:事务B已经提交过了,则事务A 可以看到事务B的改动
  • 事务A 提交
  • 事务A 查询 V3 的值为 2

【3】可重复读

  • 用户在执行当前事务期间看到的数据前后必须是一致的。
  • 即从事务开始之前,没有外部事务进行写操作,则到该事务提交这段时间访问到的数据是一致的,只能看到自己所修改的数据
  • 两个事务同时开启
  • 事务B 获取值1
  • 事务B 将其1该为2
  • 事务A 得到的值为 1
    • 原因:可重复读隔离级别下,参考上面文字
  • 事务B 提交
  • 事务A 查询 V2 得到的值为 1
    • 原因:可重复读隔离级别下,参考上面文字
  • 事务A 提交
  • 事务A 查询 V3 得到的值为 2
    • 原因:当前事务已经提交,可以访问比当前事务提交更早一些提交事务对数据库进行的写操作

【六】MVCC原理

【1】版本链

  • InnoDB 存储引擎中有两个非常重要的隐藏列

    • trx_id:

      • 一个事物每次对某条聚簇索引记录进行改动时,都会把该事务的事务 id 赋值给 trx_id 隐藏列。
    • roll_pointer:

      • 每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中。
      • 这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息。
  • 比如,我们创建一个 hero 表,字段有 numbernamecountry

    • 执行SQL
select * from hero
number name country
1 刘备
  • 假如我们现在有两个事务对该条记录进行改动,分别是 trx_100trx_200
执行时间顺序 事务A 事务B
1 BEGIN
2 BEGIN
3 UPDATE hero SET name=‘关羽’ WHER number =1
4 UPDATE hero SET name=‘张飞’ WHER number =1
5 COMMIT;
6 UPDATE hero SET name=‘赵云’ WHER number =1
7 UPDATE hero SET name=‘诸葛亮’ WHER number =1
8 COMMIT;
  • 上面表格中进行了4次修改的操作,并且对每次进行一次改动,都会形成一个条日志,每条日志中也都有一个 roll_pointer 属性。

    • 通过这个属性,可以将这些日志串成一个链表
  • 新修改的指向原来,这样就形成了一个类似链表的结构。

  • 随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,这个链表称为版本链。

    • 版本链的头节点就是当前记录的 最新值。
    • 我们之后会利用这个记录的版本链来控制并发事务访问相同记录时的行为,我们把这种机制称为 多版本并发控制,即MVCC

【2】ReadView

  • 对于使用读已提交和可重复读隔离级别的事务来说,都必须保证督导已经提交事务修改过的记录。
    • 也就是说假如另一个事务已经修改了记录但尚未提交,则不能直接读取最新版本的记录。
    • 最核心的问题是:需要判断版本里链中那个版本是当前事务可见的。

(1)ReadView 包含的内容介绍

  • m_ids:

    • 再生成 ReadView 时,当前系统中活跃的读写事务的事务 id 列表
  • min_trx_id:

    • 在生成 ReadView 时,当前系统中活跃的读写事务中最小的事务 id;也就是 m_ids中的最小值;
  • max_trx_id:

    • 在生成 ReadView 时,系统应该分配给下一个事务的事务 id 值。
  • creator_trx_id:

    • 生成该 ReadView 的事务的事务 id。
  • 有了这个 ReadView 后,在访问某条记录时,只需要按照下面的步骤来判断记录的某个版本是否可见。

    • 如果被访问本版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味和当前事务正在访问它自己修改过的记录,所以该版本可以被当前事务访问。

    • 如果被访问版本的 trx_id 属性值小于 ReadView 中的 trx_id值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交了,被访问的已经不活跃了,所以该版本可以被当前事务访问。

    • 如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成改本的事务在当前事务生成 ReadView 后开启,即事务还没有开启,所以该版本不能被当前事务所访问。

    • 如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 属性值是否在 m_ids 列表中。如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

  • 如果某个版本的数据对当前事务是不可见的,那就顺着版本链找到下一个版本的数据,并继续执行上面的步骤来判断记录的可见性;

    • 以此类推,知道版本链中最后一个版本。
    • 如果记录的最后一个版本也不可见,就意味着该条记录对当前事务完全不可见,查询结果就不包含该记录。

【3】不同隔离级别生成 ReadView 的时机

(1)读已提交(READ COMMITTED)

  • 该隔离级别下,每次读取数据前都会生成一个 ReadView

(2)可重复读(REPEATABLE READ)

  • 该隔离级别下,在第一次读取数据时生成一个 ReadView

【七】为什么可重复读只能解决部分幻读问题

【1】前期准备

  • 假设我们有一张表 hero,字段有 numbernamecountry,目前该表中有 3 条数据,如下表
number name country
1 刘备
2 赵云
3 曹操

【2】两个并发事务

(1)第一种情况

执行时间顺序 事务A 事务B
1 BEGIN
2 BEGIN
3 SELECT * FROM hero
4 INSERT INTO hero value(5, ‘孙权’, ‘吴’)
5 COMMIT;
6 SELECT * FROM hero
7 COMMIT;

分析

  • 事务A 开始执行
  • 事务B 开始执行
  • 事务A 插入一条记录
  • 事务A 提交事务
  • 事务B 进行全表查询
  • 事务B 提交事务
  • 此时,在可重复读隔离级别下来,我们看看事务B 所查询到的数据

第一次查询结果

number name country
1 刘备
2 赵云
3 曹操

第二次查询结果

number name country
1 刘备
2 赵云
3 曹操
  • 发现两次查询的结果是一样的,没有读到 事务A 新插入的数据

(2)第二种情况

执行时间顺序 事务A 事务B
1 BEGIN
2 BEGIN
3 SELECT * FROM hero
4 INSERT INTO hero value(5, ‘孙权’, ‘吴’)
5 COMMIT;
6 UPDATE hero SET country=‘吴蜀魏’ WHER number < 10
7 SELECT * FROM hero
8 COMMIT;
  • 我们发现第二种情况是事务B 在执行了一次修改操作之后,再次查询。看两次查询到的数据

第一次查询结果

number name country
1 刘备
2 赵云
3 曹操

第二次查询结果

number name country
1 刘备 吴蜀魏
2 赵云 吴蜀魏
3 曹操 吴蜀魏
5 孙权 吴蜀魏
  • 这个时候我们发现两次查到的结果并不一样,莫名奇妙多了一条记录,出现这样的情况,读者可能也会感到疑惑,这是为什么呢?
  • 原因:
    • 当事务A 插入一条记录之后,事务B 如果不对其进行写的操作,那么事务B 在提交之前再次查询并会出现幻读情况
    • 如果对其他事务做了修改并且再次查询,那么就会将其他事务写的操作归到自己的事务所有
    • 这样自己的事务查询自己所修改的数据当然是可以查出来了。
    • 由于这样的操作,我们就可以明白“在可重复读”隔离级别下只能解决部分幻读问题这句话了

【八】二级索引与MVCC

  • 通过上面的讲解,我们知道,只有在聚簇索引记录中才有 trx_id 和 roll_pointer,如果某个查询语句是使用的二级索引来查询,该如何判断可见性?

  • 二级索引页面的 Page Header 部分有一个名为 PAGE_MAX_TRX_ID 的属性,执行增删改操作时,如果执行该操作的事物的事务 id 大于 PAGE_MAX_TRX_ID 的属性值,则将其值设置为执行操作的事务id,这就意味着 PAGE_MAX_TRX_ID 属性值设置为执行该操作的最大事务 id。

  • 当 SELECT 语句访问某个二级索引记录时,如果 ReadView 的 min_trx_id > PAGE_MAX_TRX_ID 属性值?如果是,则说明该页面中的所有记录对该 ReadView 可见;否则就需要执行回表判断

  • 利用二级索引记录中的主键值进行回表操作,得到对应的聚簇索引记录后在按照聚簇索引的方式,判断该可见性。

  • 补充:
    • 执行写操作会会生成 事务id 和 ReadView
    • 执行读操作只会生成 ReadView