数据库事务、事务特性、事务的实现原理、数据恢复


事务

事务在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。

主要用于处理操作量大,复杂度高的数据。比如要执行一批增删改查操作的sql。它的存在包含有以下两个目的:

  1. 为数据库操作提供了一个从失败中恢复到正常状态的方法,同时提供了数据库即使在异常状态下仍能保持一致性的方法。
  2. 当多个应用程序在并发访问数据库时,可以在这些应用程序之间提供一个隔离方法,以防止彼此的操作互相干扰。

事务控制语句

  • BEGINSTART TRANSACTION 显式地开启一个事务;
  • COMMIT 也可以使用 COMMIT WORK,不过二者是等价的。COMMIT 会提交事务,并使已对数据库进行的所有修改成为永久性的;
  • COMMIT WORK AND CHAIN 提交事务并自动启动下一个事务;
  • ROLLBACK 也可以使用 ROLLBACK WORK,不过二者是等价的。回滚会结束用户的事务,并撤销正在进行的所有未提交的修改;
  • SAVEPOINT savepoint_nameSAVEPOINT 允许在事务中创建一个保存点,是在数据库事务处理中实现“子事务”。一个事务中可以有多个 SAVEPOINT;
  • RELEASE SAVEPOINT savepoint_name 删除一个事务的保存点,当没有指定的保存点时,执行该语句会抛出一个异常;
  • ROLLBACK TO savepoint_name 把事务回滚到标记点;
  • SET TRANSACTION 用来设置事务的隔离级别;

在 MySQL 命令行的默认设置下,事务都是自动提交的,即执行 SQL 语句后就会马上执行 COMMIT 操作。
因此要显式地开启一个事务务须使用命令
BEGINSTART TRANSACTION
或者执行命令
SET AUTOCOMMIT=0
用来禁止使用当前会话的自动提交。

注意:

  1. 在 MySQL 中只有使用了 Innodb 数据库引擎的数据库或表才支持事务。

ACID

事务管理包括原子性、一致性、隔离性和持久性四个方面,即ACID。

所有数据库专著都会给出这个四个特性的定义,为避免翻译引入的歧义,这里我们直接引用事务大师Jim Gray的原文。

Atomicity: Either all the changes from the transaction occur (writes, and messages sent), or none occur.
Consistency: The transaction preserves the integrity of stored information.
Isolation: Concurrently executing transactions see the stored information as if they were running serially (one after another).
Durability: Once a transaction commits, the changes it made (writes and messages sent) survive any system failures.

原子性(Atomicity)

又称为不可分割性。
即事务中的所有操作作为一个整体像原子一样不可分割,要么全部成功,要么全部失败。

不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。

一致性(Consistency)

确保数据库的状态从一个一致状态转变为另一个一致状态。

即写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。

数据库中的数据应满足完整性约束(主码,参照完整性,check约束等)。

隔离性(Isolation)

数据库允许多个并发事务同时对其数据进行读写和修改的能力。多个事务并发执行时,一个事务的执行不应影响其他事务的执行。

隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

事务隔离分为不同级别,包括

  • 读未提交(Read uncommitted)
  • 读已提交(read committed)
  • 可重复读(repeatable read)
  • 可串行化(Serializable)

详细下面再说,点击右手目录跳转→

持久性(Durability)

事务处理结束后,一旦提交,对数据的修改就是永久的,任何事务或系统故障都不会导致数据丢失。


常见事务异常和事务隔离级别

在事务的ACID特性中, C(一致性) 是事务的根本追求,而对数据一致性的破坏主要来自两个方面

  1. 事务的并发执行
  2. 事务故障或系统故障

一旦出现出现这些问题,就是所谓的事务异常。

其中常常出现的是并发问题,因为数据库往往是多个线程并发执行多个事务的,可能这么多个事务还会同时更新和查询同一条数据,所以这里会有一些问题需要数据库来解决

事务异常

脏写

脏写是指事务回滚了或者提交了其他事务对数据项的已提交修改,比如下面这种情况

事务1 事务2
read(A)=10 -
  • | read(A)=10
  • | write(A)=30
  • | Commit
    write(A)=20 | -
    Rollback(A=10) | -

在事务1对数据A的回滚,导致事务2对A的已提交修改也被回滚了。

丢失更新

丢失更新是指事务覆盖了其他事务对数据的已提交修改,导致这些修改好像丢失了一样。

事务1 事务2
  • | Read(A)=10
    Read(A)=10 | -
  • | A:=A+10
  • | Commit
    A:=A-10 | -
    Commit(A=0) | -

事务1和事务2读取A的值都为10,事务2先将A加上10并提交修改,之后事务2将A减少10并提交修改,A的值最后为0,导致事务2对A的修改好像丢失了一样。

脏读

脏读是指一个事务读取了另一个事务 未提交 的数据。造成两个事务得到的数据不一致。

事务1 事务2
Read(A)=10 -
A:=A*2 -
write(A)=20 -
  • | read(A)=20
    Rollback(A=10) | -

不可重复读

一个事务处理过程中,读取到另一个 已经提交 上去的事务中的数据。

事务1 事务2
Read(A)=10 -
  • | Read(A)=10
  • | A:=A*2
  • | Write(A)=20
  • | Commit
    Read(A)=20 | -

虽然看似合理,但实际上在事务内是不行的。A还在事务执行中读取某一数据,而其他事务修改了这个数据并且提交给数据库,A还没提交,但再次读取该数据就得到了不同的结果。属于并发问题

虚读(幻读)

幻读和不可重复读都是读取了另一条已经提交的事务,所不同的是不可重复读是针对确定的某一行数据而言,而幻读是针对不确定的多行数据。因而幻读通常出现在带有查询条件的范围查询中,比如下面这种情况:

事务1 事务2
Read(A<5)=(1,2,3) -
  • | Write(A)=4
  • | Commit
    Read(A<5)=(1,2,3,4) | -

简单来说,虚读是由提交的插入和删除操作引起的

事务的隔离级别

SQL标准为事务定义了不同的隔离级别,用来解决上面的读一致性问题。
从高到低依次是

  1. Serializable (可串行化) 【避免脏读、不可重复读、虚读】
  2. Repeatable Read (可重复读) 【可能 虚读【MySQL默认】
  3. Read Committed (读已提交) 【可能 不可重复读、虚读【Oracle、SQLServer等默认】
  4. Read Uncommitted (读未提交) 【可能 脏读、不可重复读、虚读

隔离级别

事务的隔离级别越低,可能出现的并发异常越多,但是通常而言系统能提供的并发能力越强。

完全的隔离性会导致系统并发性能很低,降低对资源的利用率,因而实际上对隔离性的要求会有所放宽,这也会一定程度造成对数据库一致性要求降低。

有一点需要强调,这种对应关系只是理论上的,对于特定的数据库实现不一定准确。
比如mysql的Innodb存储引擎通过Next-Key Locking技术在可重复读级别就消除了幻读的可能。

MySQL中设置隔离级别:

set transaction isolation level 级别;

最后举个栗子:
例如两个事务 A 和 B 按照如下顺序进行更新和读取操作

事务A 事务B
Read(x)=10 -
Write(x)=20 -
  • | Read(x)= ?
    Commit | -
  • | Read(x)= ?
  • | Commit

在事务A提交前后,事务B读取到的x的值是什么呢?

事务 B 在不同的隔离级别下,读取到的值不一样。

  1. 如果事务B的隔离级别是读未提交(RU),那么两次读取均读取到最新值:20 。
  2. 如果事务 B 的隔离级别是读已提交(RC),那么第一次读取到旧值:10,第二次因为事务A已经提交,则读取到新值:20。
  3. 如果事务B的隔离级别是可重复读或者串行(RR,S),则两次均读到旧值:10 ,不论事务A是否已经提交。

事务特性的实现

上面有讲到对数据一致性的破坏主要来自两个方面

  • 事务的并发执行
  • 事务故障或系统故障

而数据库系统通过 并发控制技术日志恢复技术 来避免这种情况发生。

并发控制技术

并发控制技术是实现事务隔离性以及不同隔离级别的关键,它保证了事务的隔离性,使数据库的一致性状态不会因为并发执行的操作被破坏。

实现方式有很多,按照其对可能冲突的操作采取的不同策略可以分为

  • 悲观并发控制
    • 对于并发执行可能冲突的操作,假定其必定发生冲突,通过让事务等待 (锁) 或者中止 (时间戳排序) 的方法,使并行的操作串行执行。
  • 乐观并发控制
    • 对于并发执行可能冲突的操作,假定其不会真的冲突,允许并发执行,直到真正发生冲突时才去解决冲突,例如让事务回滚。

悲观——锁

通过锁使它们互斥执行,同一时刻只有一条线程运行,其余线程等待获取锁

锁通常分为:

  • 共享锁(读锁/S锁):是读取操作创建的锁。其他事务对于同一数据也可以加共享锁,但是不能加排他锁。换而言之,其他事务都能访问到数据,但是只能读不能修改。
  • 排他锁(写锁/X锁):一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。

注意:在排他锁这里有说

一旦获取了排他锁,其他事务就不能再获取该数据的其他锁,包括共享锁(读锁)

但是这里并不等于说连读取数据都不能的意思。

因为大多数数据库中,都是采用MVCC机制(后面说)来优化查询效率, ++普通的select语句一般不会加任何锁类型++ (比如select from),而update、delete、insert语句都会自动给涉及的数据加上排他锁。所以增删改操作加上排他锁的数据在其他事务中是不能再修改的,但是可以直接通过select ...from...查询数据,因为普通查询没有任何锁机制。

如果查询要加排他锁可以使用 select...for update 语句。这样查询就能避免其他线程同一刻修改数据。
而加共享锁可以使用 select ... lock in share mode 语句。

除此之外,锁还有

  • 记录锁(Record):加到索引上的锁,通过索引建立的B+树找到行记录,并使用该索引进行记录锁定。
  • 间隙锁(Gap):对索引记录之间的锁。
  • 插入意向锁:间隙锁的一种类型,保证两个事务插入key不同的数据的时候不冲突,提升并发性。
  • 临键锁(Next-Key):是行锁加上记录前的间隙锁的结合,InnoDB用该锁配合MVCC防止了幻读的问题。
  • 自增锁(auto-inc):一种特殊的表级锁,用来避免连续主键生成时的并发问题。

这里三言两语说不全,可以自行找资料补齐知识点

基于锁的并发控制流程:

  1. 事务根据自己对数据项进行的操作类型申请相应的锁(读申请共享锁,写申请排他锁)。
  2. 申请锁的请求被发送给锁管理器。锁管理器根据当前数据项是否已经有锁以及申请的和持有的锁是否冲突决定是否为该请求授予锁。
  3. 若锁被授予,则申请锁的事务可以继续执行;若被拒绝,则申请锁的事务将进行等待,直到锁被其他事务释放。

由此可知,对于可能发生冲突的并发操作,锁使它们由并行变为串行执行,是一种悲观的并发控制。

引申:读和写锁不仅限于数据库系统。传统上,进入Java synchronized块允许获取排他锁,从1.5版开始,Java允许通过ReentrantReadWriteLock对象进行读写锁。不过与本文无关,这里不多阐述。

其中可能出现的问题:

  • 死锁:多个事务持有锁并互相循环等待其他事务的锁导致所有事务都无法继续执行。
  • 饥饿:数据项一直被加共享锁,导致事务一直无法获取数据的排他锁。

只有锁这个概念是不足以防止冲突,并发控制策略必须定义如何获取和释放锁,因为这也会影响事务交织,引发上面说的死锁等问题。

为此,2PL协议定义了一种锁定管理策略,以确保严格的可序列化性。

2PL(两段锁)

2PL协议指所有的事务必须分两个阶段对数据项加锁和解锁:

  • 扩展阶段(获取锁,并且不允许释放锁)
  • 收缩阶段(释放所有锁,并且无法进一步获取其他锁)。

扩展阶段可以获得任何数据项上的任何类型的锁,读操作前申请S锁,写操作前申请X锁。但是不能释放。如果加锁不成功,事务就会进入等待状态,直到加锁成功才能继续执行。

收缩阶段可以释放任何数据项上的任何锁,当事务释放一个封锁后,事务进入封锁阶段,在该阶段只能进行解锁而不能再进行加锁操作。

借此保证加锁阶段与解锁阶段不相交。若并发执行的所有事务均遵守两段锁协议,则对这些事务的任何并发调度策略都是可 串行化 的。

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。 这个也是两阶段锁协议内容。

这方面知识也比较多,不在这写,等啥时候有空整一个。

总之,锁在多线程多事务方面占重要位置就是了。想更详细深入了解指路 MySQL详解——锁

悲观——时间戳排序

对于并发可能冲突的操作,基于时间戳排序规则选定某事务继续执行,其他事务回滚。

系统会在每个事务开始时赋予其一个时间戳,这个时间戳可以是系统时钟也可以是一个不断累加的计数器值,当事务回滚时会为其赋予一个新的时间戳,先开始的事务时间戳小于后开始事务的时间戳。

每一个数据项Q有两个时间戳相关的字段:

  • W-timestamp(Q):成功执行write(Q)的所有事务的最大时间戳
  • R-timestamp(Q):成功执行read(Q)的所有事务的最大时间戳

时间戳排序规则如下:

  1. 假设事务T发出read(Q),T的时间戳为TS
    1. 若TS(T)<W-timestamp(Q),则T需要读入的Q已被覆盖。此read操作将被拒绝,T回滚。
    2. 若TS(T)>=W-timestamp(Q),则执行read操作,同时把R-timestamp(Q)设置为TS(T)与R-timestamp(Q)中的最大值。
  2. 假设事务T发出write(Q)
    1. 若TS(T)<R-timestamp(Q),write操作被拒绝,T回滚。
    2. 若TS(T)<W-timestamp(Q),则write操作被拒绝,T回滚。
    3. 其他情况:系统执行write操作,将W-timestamp(Q)设置为TS(T)。

基于时间戳排序和基于锁实现的本质一样:对于可能冲突的并发操作,以串行的方式取代并发执行,因而它也是一种悲观并发控制。

它们的区别主要有两点:

  • 基于锁是让冲突的事务进行等待,而基于时间戳排序是让冲突的事务回滚。
  • 基于锁冲突事务的执行次序是根据它们申请锁的顺序,先申请的先执行;而基于时间戳排序是根据特定的时间戳排序规则。

乐观——有效性检查

事务对数据的更新首先在自己的工作空间进行,等到要写回数据库时才进行有效性检查,对不符合要求的事务进行回滚。

基于有效性检查的事务执行过程会被分为三个阶段:

  1. 读阶段:数据项被读入并保存在事务的局部变量中。所有write操作都是对局部变量进行,并不对数据库进行真正的更新。
  2. 有效性检查阶段:对事务进行有效性检查,判断是否可以执行write操作而不违反可串行性。如果失败,则回滚该事务。
  3. 写阶段:事务已通过有效性检查,则将临时变量中的结果更新到数据库中。

有效性检查通常也是通过对事务的时间戳进行比较完成的,不过和基于时间戳排序的规则不一样。

该方法允许可能冲突的操作并发执行,因为每个事务操作的都是自己工作空间的局部变量,直到有效性检查阶段发现了冲突才回滚。因而这是一种乐观的并发策略。

乐观——版本号机制、MVCC

版本号机制核心思想是:添加一个version字段,修改时比对该版本字段是否合理,版本不对则回滚操作。

MVCC

MVCC是在版本号机制的基础上的新的扩展思想,中文全称叫多版本并发控制,是现代数据库(包括MySQL、Oracle、PostgreSQL等)引擎实现中常用的处理读写冲突的手段,目的在于提高数据库高并发场景下的吞吐性能。

如此一来不同的事务在并发过程中, SELECT 操作可以不加锁而是通过 MVCC 机制读取指定的版本历史记录,并通过一些手段保证保证读取的记录值符合事务所处的隔离级别,从而解决并发场景下的读写冲突。

各个数据库引擎实现的MVCC方式都不一样,这里用InnoDB说明MVCC的实现原理:

为了实现mvcc,每行数据会多两列

  1. DATA_TRX_ID(当前事务版本ID)
  2. DATA_ROLL_PTR(回滚指针)

(有可能还有一列DB_ROW_ID,当没有默认主键时会自动加上这列)

有很多人把回滚指针当成删除版本号,只能说理解方向不对,如果不能正确理解回滚指针就更难理解回滚段是什么了。

mvcc版本链

事务 A 对值 x 进行更新之后,该行即产生一个新版本和旧版本。假设之前插入该行的事务ID为100,事务A的ID为200。操作过程如下:

  1. 对 ID = 1 的记录加排他锁,毕竟要修改了,总不能加共享锁。
  2. 把该行原本的值拷贝到 undo log 中。
  3. 修改该行的值这时产生一个新版本,更新 DATA_TRX_ID 为修改记录的事务ID,将 DATA_ROLL_PTR 指向刚刚拷贝到 undo log 链中的旧版本记录,这样就能通过DB_ROLL_PTR找到这条记录的历史版本。如果对同一行记录执行连续的UPDATE,Undo Log会组成一个链表,遍历这个链表可以看到这条记录的变更记录。
  4. 记录redo log,包括 undo log 中的修改。

如果是INSERT 和 DELETE 会更简单:

  • INSERT 会产生一条新纪录,它的 DATA_TRX_ID 为当前插入记录的事务ID。
  • DELETE 某条记录时可看成是一种特殊的 UPDATE ,其实是软删除,真正执行删除操作会在 commit 时, DATA_TRX_ID 则记录下删除该记录的事务ID。

至于SELECT,核心问题就是当前事务读取数据的时候如何判断应该读取哪个版本。
因此,InnoDB中引入了一个 可读试图(ReadView) 的概念。

SELECT语句开始时,会重新将当前系统中的所有的活跃事务拷贝到一个列表生成ReadView,但是隔离级别不同,生成的时机也不同。RC级别里,每次读取数据前都会生成一个ReadView。RR级别里,只在第一次读取数据前生成ReadView。

一个ReadView主要包含如下属性:

  1. m_ids:生成ReadView时,当前活跃所有的事务ID,活跃的意思就是事务开启了还没提交,这里可以提一点,事务开启事务ID会自增,实际上事务ID就是一个全局自增的数字。
  2. min_trx_id:当前活跃的mIds中最小的事务ID。
  3. max_trx_id:生成ReadView时,最大的事务ID,这里一定不要理解成mIds中最大的ID,这是一个相当错误的理解,后面再解释。
  4. creator_trx_id:该ReadView在那个事务里创建的

ReadView有了上面4个属性后,那么应该以什么样的规则,判断当前事务到底可以读取哪个版本的数据呢?(划重点)

  1. 如果被访问版本的 data_trx_id 小于 m_ids 中的最小值,说明生成该版本的事务在 ReadView 生成前就已经提交了,那么该版本可以被当前事务访问。
  2. 如果被访问版本的 data_trx_id 大于++当前事务的最大值++,说明生成该版本数据的事务在生成 ReadView 后才生成,那么该版本不可以被当前事务访问。
    • 为什么这里的最大值不是m_ids的最大值,因为事务ID虽然是全局递增的,但是并不代表事务ID大的一定要在事务ID小的后面提交,也就是事务开启有先后,但是事务结束的先后和开启的先后并不是完全一致的,毕竟事务有长有短。如果此时数据的事务版本是200,而m_ids中没有200,那么m_ids最大值就可能小于200,那么以规则2判断就可能让本该可以访问到的数据因为这个规则,而访问不到了,归根结底就是因为没有正确找到生成ReadView时的最大事务ID,所以不能肯定的说生成该版本数据的事务在生成 ReadView 后才生成。
  3. 如果被访问版本的 data_trx_id 属性值在最大值和最小值之间(包含),那就需要判断一下 trx_id 的值是不是在 m_ids 列表中。
    • 如果在,说明创建 ReadView 时生成该版本所属事务还是活跃的,因此该版本不可以被访问;
    • 如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

通俗点来说,也就是ReadView中通过最大事务ID,mIds最小事务ID,mIds活跃事务列表,将当前要读的数据的事务ID分成了3种情况,要么小于m_ids的最小事务ID,很明显又在当前活跃的最小事务之前生成,又不在活跃事务中,一定是已提交的事务,这个版本肯定可以访问;要么大于生成ReadView的当前的最大事务ID,很明显在所有活跃事务之后,并且也不可能存在于活跃事务列表中,那么就说明,该版本在当前活跃事务之后才出现,总不能读取到未来的版本吧;要么处于最大最小值之间,这时候就有两种情况,因为并不是说最大最小值之间就一定是活跃的,毕竟先开启的事务并不一定会先结束,事务有大小长短,这时候就很简单,在mIds中就是还没提交的活跃版本,不可被读取,不在就是已经提交的版本,可以被读取。当一个事务要读取一行数据,首先用上面规则判断数据的最新版本也就是那行记录,如果发现可以访问就直接读取了,如果发现不能访问,就通过DATA_ROLL_PTR指针找到undo log,递归往下去找每个版本,直到读取到自己可以读取的版本为止,如果最后还读取不到就返回空。

流程图:
ReadView逻辑流程图

拿回上面用过的例子:

事务A 事务B
Read(x)=10 -
Write(x)=20 -
  • | Read(x)= ?
    Commit | -
  • | Read(x)= ?
  • | Commit

现在都知道RR级别下,两次都是10;RC级别下,第一次是10,第二次是20;那么,怎么用上面MVCC的ReadView原理解释这个结果?

  1. RR级别下,开启事务后的第一个查询会生成一个ReadView,顺着版本链查找事务id,发现修改的事务A的id在自己视图的m_ids里面,代表事务A还没提交,继续往更早的版本查找下去,直到找到小于min_trx_id的事务id,也就是最初的10;第二次查询因为ReadView还是用的同一个,所以依旧会查到是10;
  2. RC级别下,事务B每次查询都会生成一个ReadView,第一次查询生成的视图里,事务A的data_trx_id被记录在该视图的m_ids中,因此不能读到事务A的版本;第二次查询生成了一个新的视图,顺着版本链找到事务A的事务id,发现已经不在m_ids里面了,于是可以读到这个20;

RC和RR两种级别只是控制生成ReadView的时机不同,就可以实现不同的可见性,这算是一个比较常规并且巧妙的设计了。

结合锁和MVCC,需要记录一个很容易混淆的点: 快照读当前读

假如有两个事务:

事务A 事务B
start -
  • | start
    Read(x)=1 | Read(x)=1
  • | Write(x)=x+1
  • | Commit
    Write(x)=x+1 | -
    Read(x)= | -
    Commit | -

提问:在默认级别RR下,问号读到的值是多少?

众所周知,写之前会去读取x的值,可能会有人认为既然是RR,读取的还是自己事务的值1。但是,常识告诉我们,当它要去更新数据的时候,不能再在历史版本上更新,否则就覆盖了事务C的提交,变成丢失更新

用可读视图读取自己事务内应该看到的值的称为快照读
在这种更新值或者加锁(比如 lock in share mode 的s锁,或 for update 的x锁)去读取值的时候,不能用可读视图去读取值,只能读当前的值,称为当前读。这样将两者区分开。

所以最终答案为3。

日志恢复技术

数据库运行过程中可能会出现故障,这些故障包括事务故障和系统故障两大类:

  • 事务故障:比如非法输入,系统出现死锁,导致事务无法继续执行。
  • 系统故障:比如由于软件漏洞或硬件错误导致系统崩溃或中止。

这些故障可能会对事务和数据库状态造成破坏,因而必须提供一种技术来对各种故障进行恢复,保证数据库一致性,事务的原子性以及持久性。

数据库通常以日志的方式记录数据库的操作从而在故障时进行恢复,因而可以称之为日志恢复技术

日志恢复技术保证了事务的原子性,使一致性状态不会因事务或系统故障被破坏。同时使已提交的对数据库的修改不会因系统崩溃而丢失,保证了事务的持久性。

为什么会出现这些故障

事务的执行过程可以简化如下:

  1. 系统会为每个事务开辟一个私有工作区。
  2. 事务读操作将从磁盘中拷贝数据项到工作区中,在执行写操作前所有的更新都作用于工作区中的拷贝。
  3. 事务的写操作将把数据输出到内存的缓冲区中,等到合适的时间再由缓冲区管理器将数据写入到磁盘。

内存磁盘关系图

由于数据库存在立即修改和延迟修改,所以在事务执行过程中可能存在以下情况:

  • 在事务提交前出现故障,但是事务对数据库的部分修改已经写入磁盘数据库中,导致事务的原子性被破坏。
  • 在系统崩溃前事务已经提交,但数据还在内存缓冲区中,没有写入磁盘。等恢复的时候发现丢失了之前提交过的修改,导致事务的持久性被破坏。

日志的种类和格式

MySQL中有六种日志文件,其中比较重要的有:

  • 回滚日志(undo log)
  • 重做日志(redo log)
  • 二进制日志(binlog)

其余还有:

  • 错误日志(errorlog)
  • 慢查询日志(slow query log)
  • 一般查询日志(general log)
  • 中继日志(relay log)

上面已经提到过undo log,其实就是保存事务版本的日志,方便多事务读取内容和事务回滚。不过回滚日志不能长期保存,当系统判断没有事务再需要用到这些回滚日志时,也就是系统里没有比这个回滚日志更早的ReadView的时候,回滚日志就会被删除。

岔个题,基于上面的说明,来写下为什么不建议不要使用长事务。

长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。严重的时候只能为了清理回滚段而重建整个库。

redo log

上面说到MySql为了提高效率,读写操作都是在内存进行,具体来说,当有一条记录需要更新的时候,InnoDB引擎就会先把记录写到redo log里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。

其实就是 MySQL 里经常说到的 WAL 技术,WAL 的全称是
Write-Ahead Logging,它的关键点就是先写日志,再写磁盘。

与 undo log 不同,InnoDB的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大
小是 1GB,那么总共就可以记录4GB的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。

redoLog

wirte pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。
checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

如果因各种情况,write pos 追上了checkpoint,表示写满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

有了 redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe。

binlog

上面说的 redo log 是InnoDB引擎特有的存储日志(具体MySql结构写在后面的博客上),而binlog是MySql的Server层的存储日志,称为归档日志。

之所以会有两种存储日志,得从盘古开天地时开始说起:

最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入
MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系
统——也就是 redo log 来实现 crash-safe 能力。

这两种日志有以下三点不同:

  • redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指
    binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

有了对这两个日志的概念性理解,我们再来看执行器和 InnoDB 引擎在执行这个简单的update语句时的内部流程。

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2
    这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

这里用林晓斌大佬给出这个 update 语句的执行流程图,图中浅色框表示是在 InnoDB 内部执行的,深色框表示是在执行器中执行的。
更新过程

最后InnoDB写入 redo log 的时候,可以看到状态是prepare,只有写完binlog,提交事务后才算一次commit。这就是“两阶段提交”

两阶段提交(2PC)

由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,要么采用反过来的顺序,那么数据库的状态都有可能和用它的日志恢复出来的库的状态不一致。

只要涉及到需要用binlog的地方都会存在问题,比如备份恢复、数据库扩容做主从库的时候等。

简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。

使用日志恢复技术

因为 binlog 会记录所有的逻辑操作,并且是采用“追加写”的形式,所以一切的恢复取决于这个保存的 binlog 文件。

当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,可以这么做:

  1. 找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;
  2. 从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。

这样临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库去。

一般系统都会定期做整库备份,以作容灾。这里的“定期”取决于系统的重要性,可以是一天一备,也可以是一周一备。

一天一备里,好处是“最长恢复时间”更短,一周一备就要应用一周的binlog了,取决于系统对应指标:RTO(恢复目标时间)。

当然这个是有成本的,因为更频繁全量备份需要消耗更多存储空间,所以这个 RTO 是成本换来的,就需要你根据业务重要性来评估了。


参考资料

posted @ 2021-03-27 12:41  鹿友  阅读(532)  评论(0编辑  收藏  举报