事务

事务所提供的安全保证,通常由众所周知的首字母缩略词 ACID 来描述,ACID 代表 原子性(Atomicity)一致性(Consistency)隔离性(Isolation) 和 **持久性(Durability)**原子性

但实际上,不同数据库的 ACID 实现并不相同。

原子性

在mysql中一般原子性视为在同一个事务中的操作:要么完全成功,要么完全失败。

原子性的结果就是没有中间状态,如果有中间状态则一致性就不会得到满足

MySQL 原子性实现原理

  • 通过 undolog 在失败时回滚保证在结果上是原子性的, 即没有中间状态。
  • 通过隔离性保证了在其他并发事务看来是原子性的,即中间状态对外不可见

innodb的回滚操作是由undolog来的实现其特性的

undo log 属于逻辑日志,它记录的是 sql 执行相关的信息。当发生回滚时,InnoDB 会根据 undo log 的内容做与之前相反的工作:对于每个 insert,回滚时会执行 delete;对于每个 delete,回滚时会执行insert;对于每个 update,回滚时会执行一个相反的 update,把数据改回去。

当没有事务需要undo log时才会被删掉

例如:假设一个值从 1 被按顺序改成了 2、3、4。在回滚日志里面就会有类似下面的记录

特殊情况处理

如果在事务中执行了一些语句,但没有显式地提交或回滚事务,以下是 InnoDB 存储引擎的处理方式:

  1. 会话级别的自动回滚:
    • 如果在 MySQL 会话中启动了一个事务(执行了 BEGIN),但没有显式地提交或回滚事务,那么当会话结束时(例如,关闭连接或断开连接),InnoDB 会自动回滚未提交的事务。这样可以确保未提交的事务不会对其他会话产生影响。
  2. 崩溃恢复:
    • 如果发生数据库崩溃或服务器故障,InnoDB 存储引擎会使用**日志(redo log)**来进行恢复(redo log commit的状态)。
    • 在数据库重新启动时,InnoDB 会检测到存在未提交的事务,并尝试回滚这些未提交的事务,以确保数据库的一致性。

持久性

持久性是指事务一旦提交,它对数据库的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

InnoDB 通过 redo log 与bin log重做日志保证了事务的持久性。

redo log

redo log(重做日志)-引擎层日志,记录的是“在某个数据页上做了什么修改”。

wal(write ahead log)机制:事务开始之后就产生 redo log,redo log 的落盘并不是随着事务的提交才写入的,而是在事务的执行过程中,便开始写入 redo log 文件中。

其结构为一个环状:一个write pos记录其落盘的位置,一个checkpoint记录其要擦除的位置

bin log

bin log(归档日志) - server层日志。是逻辑日志,记录的是这个语句的原始逻辑,用于数据的归档。比如“给 ID=2 这一行的 c 字段加 1“

支持3种模式。

  1. Statement:记录的是SQL本身。
  2. Row: 不记录具体sql,只保存哪行记录被修改成什么值。
  3. Mixed:此格式是Statement和Row格式的结合

二阶段提交

server层与引擎层 是如何协同确定这个事务是否提交?

如上图 左侧颜色深一点的表示server层,浅一点的是innodb引擎层。redo log存在parpare阶段和commit阶段。

当redo log相关tx_id是停留在parpare阶段时,需要更具binlog 来判断redo log是否要回滚。

crash-safe

crash-safe 是指数据库系统在面临崩溃或断电等意外故障时,能够保证数据的一致性和持久性,以及在恢复后能够正确恢复到一致的状态。

在mysql中 innodb通过redo log来实现crash-safe能力

当数据库奔溃了,重启后会存在部分未落盘的脏数据。于是读到的可能会是脏数据。但其可以redo中记录的write pos(落盘的指针),来确定哪些是还未落盘的。只需把后续redo log重新加载一遍即可。

但bin log是追加写的形式提供归档能力,且没有落盘相关记录。crash时不能判定binlog中哪些内容是已经写入到磁盘,哪些还没被写入。

隔离性(isolation)

总所皆知innodb支持的隔离性支持以下几种

  1. 读未提交(READ UNCOMMITTED)
  2. 读提交 (READ COMMITTED)
  3. 可重复读 (REPEATABLE READ)
  4. 串行化 (SERIALIZABLE)

mysql是如何在一个数据库实例中能同时支持不同会话实现不同隔离级别的呢

SET TRANSACTION ISOLATION LEVEL <isolation_level>;
# SERIALIZABLE;READ UNCOMMITTED;READ COMMITTED;REPEATABLE READ

mvcc

所谓的 MVCC (Multi-Version Concurrency Control ,多版本并发控制)指的就 是在使用 READ COMMITTD 、 REPEATABLE READ 这两种隔离级别的事务在执行普通的 SEELCT 操作时访问记录的版 本链的过程,这样子可以使不同事务的 读-写 、 写-读 操作并发执行,从而提升系统性能。

快照隔离的一个关键原则是:读不阻塞写,写不阻塞读

版本链

每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:

他的顺序 是把number为1的 的 刘备,改为了 刘备-> 关羽 -> 张飞。其中有2个事务,刘备是事务trx_id = 80改的,关羽 和 张飞是在同一事物 trx_id = 100中改的

  • undologo: 记录的是上个版本的值用于回滚等
  • trx_id: 每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的 事务id 赋值给 trx_id 隐藏列。是依次递增的
  • roll_pointer : 每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo日志 中,然后这个隐藏 列就相当于一个指针,可以通过它来找到该记录修改前的信息。

trx_id

innodb的事务管理器负责为事务分配唯一的 trx_id,跟踪事务的执行过程。

事务管理器会为该事务分配一个唯一的 trx_id。这个 trx_id 是一个递增的数字

实际上 当你begin时不会分配trx_id。如果没有update时,分配的一直是一个虚拟的trx_id。这个虚拟的trx_id会远大于实际的事务id,这样如果可重复读则也不会读到被修改的数据

select trx_id, trx_state, trx_started, trx_mysql_thread_id from information_schema.innodb_trx\G

A transaction ID is only needed for a transaction that might perform write operations or locking reads such as SELECT … FOR UPDATE.

只有在执行写操作或锁定读(SELECT ... FOR UPDATE)语句时,才需要事务id。否则在一个只读事务中的事务id值都默认为0。

ReadView-快照

需要判断一下版本链中的哪个版本是当前事务可见的。为 此,设计 InnoDB 的大叔提出了一个 ReadView 的概念,这个 ReadView 中主要包含4个比较重要的内容:

  • m_ids :表示在生成 ReadView 时当前系统中活跃的读写事务的 事务id 列表。

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

  • max_trx_id :表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。

    注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三 个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。

  • creator_trx_id :表示生成该 ReadView 的事务的 事务id 。

有了这个 ReadView ,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  • 如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己 修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_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 时生成该版本的事务已经被提交,该版本可以被访问。

为了形象化下图是readView的大概样子,

不同隔离级别readView生成时间

READ COMMITTED隔离级别的事务在每次读取数据前都生成一个ReadView。于是在执行过程中如果,在他之前的事务提交了他就能看到

REPEATABLE READ —— 在第一次读取数据时生成一个ReadView,之后的语句都用此视图,于是在执行过程中如果,在他之前的事务提交了他也看不到

再次说明:

READ COMMITTD 、 REPEATABLE READ 这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一 次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作 前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。

大家有没有发现两件事儿:

  • 我们说 insert undo 在事务提交之后就可以被释放掉了,而 update undo 由于还需要支持 MVCC ,不能立即 删除掉。
  • 为了支持 MVCC ,对于 delete mark 操作来说,仅仅是在记录上打一个删除标记,并没有真正将它删除掉。

随着系统的运行,在确定系统中包含最早产生的那个 ReadView 的事务不会再访问某些 update undo日志 以及被 打了删除标记的记录后,有一个后台运行的 purge线程 会把它们真正的删除掉。

当前读-幻读?

定义:当你改变一个数据时,会读取当前值最新提交的数据再进行更新。

目的:防止快照读取过时数据

MySQL 的默认事务隔离级别是可重复读(Repeatable Read),会开启当前读。

想要关闭可调整隔离级别

这样会存在一个情况(不能说是问题)

  1. A事务:读取id=1的记录为,发现number=1
  2. B事务:修改id=1的记录为,name=number+1
  3. B事务:进行提交
  4. A事务:进行数据修改,修改id=1的记录为,name=number+1
  5. A事务:读取id=1的记录为,发现number=3
  6. A事务:提交

此时id=1的记录为,number=3。对于A来说就是幻读。

快照读的情况下可以解决幻读问题,但是在当前读的情况下是不能解决幻读的

Mysql 隔离使用的方案

读操作利用多版本并发控制(MVCC),写操作进行加锁

写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。

小贴士:我们说过普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。