zhenlanghuo's Blog

Mysql学习笔记——事务与隔离级别

2019/09/07

事务并发存在的问题

  • 更新丢失

    • 第一类更新丢失, 回滚覆盖:撤消一个事务时,在该事务内的写操作要回滚,把其它已提交的事务写入的数据覆盖了。
    • 第二类更新丢失, 提交覆盖:提交一个事务时,写操作依赖于事务内读到的数据,读发生在其他事务提交前,写发生在其他事务提交后,把其他已提交的事务写入的数据覆盖了。这是不可重复读的特例。
  • 脏读
    一个事务读取了另一个事务未提交的数据。

  • 不可重复读
    一个事务中,重复读取同一条记录,获取到不一样的数据。

  • 幻读
    一个事务中,重复读取一个范围内的记录,获取到不一样的结果集。

不可重复读和幻读问题的本质都是事务读取了在事务期间其他事务提交的数据,不同的是不可重复读是由其他事务的update操作导致的,幻读是由于其他事务的insert、delete操作导致的。

事务的隔离级别

SQL标准中的隔离级别:

  • 读未提交(Read Uncommitted):可以读取未提交的数据,不会出现回滚覆盖问题,会出现脏读、提交覆盖、不可重复读和幻读问题。
  • 读已提交(Read Committed):事务中只能读取提交的修改,不会出现回滚覆盖和脏读问题,仍然会出现提交覆盖、不可重复读和幻读问题。
  • 可重复读(Repeatable Read):不会出现回滚覆盖、脏读、提交覆盖和不可重复读问题,仍然会出现幻读问题。
  • 序列化(Serializable):最高隔离级别,没有任何的并发问题。

上面只是SQL标准中对隔离级别的定义,但是具体的数据库对标准的实现可能有不同,比如Mysql通过间隙锁在可重复读隔离级别中解决了幻读问题。

隔离级别的实现

基于锁的并发控制(Lock-Based Concurrent Control, LBCC)

传统的隔离级别是基于锁实现的,这种方式叫做 基于锁的并发控制(Lock-Based Concurrent Control,简写 LBCC)。通过对读写操作加不同的锁,以及对释放锁的时机进行不同的控制,就可以实现四种隔离级别。

传统的锁有两种:读操作通常加共享锁(Share locks,S锁,又叫读锁),写操作加排它锁(Exclusive locks,X锁,又叫写锁);加了共享锁的记录,其他事务也可以读,但不能写;加了排它锁的记录,其他事务既不能读,也不能写。

另外,对于锁的粒度,又分为行锁和表锁,行锁只锁某行记录,对其他行的操作不受影响,表锁会锁住整张表,所有对这个表的操作都受影响。

  • 读未提交(Read Uncommitted):事务读不阻塞其他事务读和写,事务写阻塞其他事务写但不阻塞读;通过对写操作加 “持续X锁”,对读操作不加锁 实现;

    对读操作不加锁,因此可以读到未提交的数据;对写操作加“持续X锁”是为了防止回滚覆盖。

时间 事务1 事务2
T1 begin; begin;
T2 update t set count = count + 10; (count: 0=>10) update t set count = count + 10; (count: 0=>10)
T3 commit; (count=10)
T4 rollback; (count=0) 回滚覆盖了事务2的更新

如上所示,如果写操作加“持续X锁”的话,两个事务可以同时写同一行数据,可能会导致出现回滚覆盖。

  • 读已提交(Read Committed):事务读不会阻塞其他事务读和写,事务写会阻塞其他事务读和写;通过对写操作加 “持续X锁”,对读操作加 “临时S锁” 实现;不会出现脏读;

    对写操作加“持续X锁”,对读操作加“临时S锁”,保证了读取到的数据都是已经提交的数据(X锁会阻塞S锁,持续的X锁在事务提交或回滚时才释放);但是由于读操作加的是“临时S锁”,读取完数据后马上释放S锁,因此可能会不可重复读(读取完数据以后释放S锁,另一个事务马上对数据进行了修改并提交,再次去读取会发现数据不一样了)。

  • 可重复读(Repeatable Read):事务读会阻塞其他事务事务写但不阻塞读,事务写会阻塞其他事务读和写;通过对写操作加 “持续X锁”,对读操作加 “持续S锁” 实现;

    对读操作变成加“持续S锁”,保证了事务过程中,所读取到的数据,其他事务无法进行修改,因此不可能出现不可重复读。

  • 序列化(Serializable):为了解决幻读问题,行级锁做不到,需使用表级锁

    对数据加行锁不能阻止其他事务的插入/删除操作,会导致幻读,因此需要用表级锁来解决。

基于多版本的并发控制(Multi-Version Concurrent Control, MVVC)

基于锁的并发控制所实现的隔离级别(RC级别及以上),读写是冲突的,不能并发;而Mysql的InnoDB引擎使用的基于多版本的并发控制实现隔离级别(RC、RR级别),把读操作分为快照读(Snapshot Read)和当前读(Current Read),实现了并发读写(快照读)。

InnoDB的MVVC通过快照读来实现可重复读,在事务开始的时候InnoDB为事务生成一个视图read-view,相当于数据库的一个快照,在事务开始后其他事务所提交修改的数据,在这个视图中是看不见的,在事务中的查询(Select语句)默认都是快照读,从事务开始时生成的视图中去读数据,除非在查询时使用lock in share mode或for update来指定使用当前读。

而在事务开始时生成的视图read-view,不是真正的把数据库的数据copy一份,InnoDB通过一些很巧妙的方法来实现。

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id,它是在事务开始的时候向 InnoDB的事务系统申请的,是按申请顺序严格递增的。

而数据库中的每行数据是有多版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id 赋值给这个数据版本的事务ID。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

数据行的隐藏字段

innoDB 向数据库中存储的每行添加三个隐藏字段

  • DB_TRX_ID 事务id
    占6 字节,表示这一行数据最后插入或修改的事务id。此外删除在内部也被当作一次更新,在行的特殊位置添加一个删除标记(记录头信息有一个字节存储是否删除的标记)。
  • DB_ROLL_PTR 回滚指针
    占7字节,回滚指针指向被写在undo log中的记录,在该行数据被更新的时候,该行修改前内容会被记录到undo log。
  • DB_ROW_ID 行ID
    占7字节,它就像自增主键一样随着插入新数据自增。如果表中不存主键 或者 唯一索引,那么数据库 就会采用DB_ROW_ID生成聚簇索引。否则DB_ROW_ID不会出现在索引中。

undo log

undo log是为回滚而用,具体内容就是copy事务前的数据库内容(行)到undo buffer,在适合的时间把undo buffer中的内容刷新到磁盘。

undo buffer与redo buffer一样,也是环形缓冲,但当缓冲满的时候,undo buffer中的内容会也会被刷新到磁盘;与redo log不同的是,磁盘上不存在单独的undo log文件,所有的undo log均存放在主ibd数据文件中(表空间),即使客户端设置了每表一个数据文件也是如此。

undo log 在 Rollback segment中又被细分为 insert 和 update undo log , insert 类型的undo log 仅仅用于事务回滚,当事务一旦提交,insert undo log 就会被丢弃。update的undo log 被用于 一致性的读和事务回滚,update undo log 的清理 是在 没有事务 需要对这部分数据快照进行一致性读的时候 进行清理。

read-view

在事务开始的时候,InnoDB会为事务生成一个视图read-view,这个read-view其实是事务开启时当前所有事务的一个集合,里面存储了以下的数据:

  • m_ids 活跃事务id数组
    保存了这个事务启动瞬间,当前正在“活跃”(启动了但还没提交)的所有事务 ID。
  • m_low_limit_id 最小事务id
    其实就是活跃事务id数组中最小的事务id
  • m_up_limit_id 最大事务id
    当前系统里面已经创建过的事务 ID 的最大值加 1
  • m_creator_trx_id 当前事务id

这些数据是用来判断数据的版本是否对当前事务是可见的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool changes_visible(trx_id_t id,const table_name_t& name) const MY_ATTRIBUTE((warn_unused_result)) {
ut_ad(id > 0);
//如果ID小于Read View中最小的, 则这条记录是可以看到。说明这条记录是在select这个事务开始之前就结束的
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
//如果比Read View中最大的还要大,则说明这条记录是在事务开始之后进行修改的,所以此条记录不应查看到
if (id >= m_low_limit_id) {
return(false);
} else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
//判断是否在Read View中,如果在,说明在创建Read View时 此条记录还处于活跃状态则不应该查询到,
//否则说明创建Read View是此条记录已经是不活跃状态则可以查询到
return(!std::binary_search(p, p + m_ids.size(), id));
}

上面是判断数据版本可见性的源码,可以这样总结其规则:

一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:

  1. 版本未提交,不可见;
  2. 版本已提交,但是是在视图创建后提交的,不可见;
  3. 版本已提交,而且是在视图创建前提交的,可见。

InnoDB的快照读就是读取对当前事务可见的数据版本,每次从数据的最新版本开始,判断当前的数据版本是否对自身可见,如果不可见,就通过回滚指针找到上一版本的数据,再判断可见性,如此循环直到找到第一个对自身可见的数据版本。

不同隔离级别下,事务read-view的创建时机:

  • 可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

快照读与当前读

在InnoDB的事务中(RR和RC级别下),普通的select是快照读,快照读是不需要加锁的,快照读不会出现不可重复读的问题,因为快照读只会读到对事务可见的数据版本,这个在事务开始的时候就已经决定了,就像给数据库拍了个快照一样,而这些就是通过上面说的数据行的隐藏字段、undo log和read-view实现的。

当前读是读取数据最新的版本,是需要加锁的。

在事务中,update是先读后写的,而这个读就是当前读

可以通过在select语句后加上lock in read mode和for update来指定当前读。

参考

MVVC 原理
MySQL InnoDB 的多版本并发控制(MVCC)
Mysql实战45讲:事务到底是隔离的还是不隔离的?

CATALOG
  1. 1. 事务并发存在的问题
  2. 2. 事务的隔离级别
  3. 3. 隔离级别的实现
    1. 3.1. 基于锁的并发控制(Lock-Based Concurrent Control, LBCC)
    2. 3.2. 基于多版本的并发控制(Multi-Version Concurrent Control, MVVC)
      1. 3.2.1. 数据行的隐藏字段
      2. 3.2.2. undo log
      3. 3.2.3. read-view
      4. 3.2.4. 快照读与当前读
  4. 4. 参考