[InnoDB 学习6] 事务

 

事务的ACID特性

事务在提交工作时,可以确保要么所有修改都保存了,要么所有修改都不保持。

事务具有的4个特性。

原子性(atomicity)

1个事务要么全部提交成功,要么全部失败回滚,不能只执行其中一部分操作。

一致性(consistency)

事务将数据库从一种状态转变为一种一致的状态。

如果数据库运行中发生故障,有些事务被迫中断,未完成的事务对数据库所做的修改有一部分已经写入数据库,这时数据库就处于不一致的状态。

完整性约束也不能被破坏。

隔离性(isolation)

是指在并发环境中,事务是相互隔离的,1个事务的执行不能被其它事务干扰。

不同事务操作的数据对其它并发的事务是隔离的。

持久性(durability)

事务一旦提交,那么它对该数据的变更就会永久保存在数据库中。

即使发生故障,数据库也能保证恢复后提交的数据都不会丢失。

若不是数据库本身发生的故障,而是外部原因导致存储介质遭到破坏,那么数据可能会丢失。

事务的隔离级别

查看当前会话事务隔离级别,系统隔离级别:

mysql> select @@tx_isolation, @@global.tx_isolation;
+-----------------+-----------------------+
| @@tx_isolation  | @@global.tx_isolation |
+-----------------+-----------------------+
| REPEATABLE-READ | REPEATABLE-READ       |
+-----------------+-----------------------+

隔离级别越低,事务请求的锁越少或保持锁的时间就越短。

SQL标准定义了4个事务隔离级别。

未授权读,读未提交(read uncommited)

如果一个事务已经开始写数据,则另外一个事务则不允许同时进行写操作,但允许其他事务读此行数据。该隔离级别可以通过排他写锁实现。这样就避免了更新丢失,却可能出现脏读。也就是说事务B读取到了事务A未提交的数据。

授权读取,读提交(read commited) (实际采用)

读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行。该隔离级别避免了脏读,但是却可能出现不可重复读。事务A事先读取了数据,事务B紧接了更新了数据,并提交了事务,而事务A再次读取该数据时,数据已经发生了改变。

可重复读(repeatable read)

可重复读是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。那么,在第一个事务中的两次读数据之间,即使第二个事务对数据进行修改,第一个事务两次读到的的数据是一样的。这样就发生了在一个事务内两次读到的数据是一样的,因此称为是可重复读。

有事务进行写时,允许其它事务读旧版的数据,但不允许其它事务写。这样避免了不可重复读取和脏读,但是有时可能出现幻象读

读取数据的事务,可以通过共享读锁排他写锁实现。

InnoDB存储引擎通过Next-Key Lock避免幻读问题

序列化(serializable)

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

InnoDB会对每个SELECT语句后自动加上LOCK IN SHARE MODE,即为每个读取操作加一个共享锁。读占用了锁,对非一致性读不再给予支持。

SERIALIABLE事务隔离级别主要用于InnoDB存储引擎的分布式事务

事务的分类

  • 扁平事务:最简单(使用也是最频繁),所有操作都在同一层次,其间的操作都是原子的,要么都执行,要么都回滚。
  • 带有保存点的扁平事务:除了支持扁平事务的操作,还允许在事物执行过程中回滚到同一事务中较早的一个状态。并不会因为过程中的错误导致所有操作无效,这样开销大。

    保存点使用SAVEPOINT p1函数建立,使用ROLLBACK TO SAVEPOINT p1回滚。

  • 链事务:保存点的变种。在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式传给下一个要开始的事务。

    链事务的回滚仅限于当前事务。对锁的处理不同,链事务执行commit后释放当前事务所持有的锁,而带保存点的扁平事务不影响所持有的锁。

  • 嵌套事务:是层次结构,由顶层控制各层的事务。嵌套的事务称为子事务。

    InnoDB不原生支持。

  • 分布式事务:在分布式环境中运行的扁平事务。需要根据数据所在的位置访问网络中的不同节点。

对于扁平事务,其隐式设置了一个保存点,只有初始的保存点。只能回滚到事务开始时的状态。

事务的实现

事务的原子性(A)、一致性(C)、持久性(D)是通过数据库中的redo logundo log来完成

undo并不是redo的逆过程。它们都可视为一种恢复操作。

redo恢复提交事务修改的页操作。
undo回滚行记录到某个特定版本。

因此两者记录不同,redo是物理日志,记录的是页的物理修改操作。
undo是逻辑日志,根据每行记录进行记录。

redo log基本是顺序写,运行时不需要对其读取。
undo log需要随机写。

redo

概念

redo log实现持久性(D),由2部分组成:

  • 内存中的重做日志缓冲(redo log buffer),易失的。
  • 重做日志文件(redo log file),持久的。

当事务提交(commit)时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的commit操作完成才算完成。

为了确保日志都写入redo log,每次写入后,都会调用一次fsync
由于fsync的效率取决与磁盘性能,因此磁盘性能决定了事务提交的性能。

控制重做日志刷新到磁盘的策略:

select @@innodb_flush_log_at_trx_commit;

默认为1,表示事务提交必须调用一次fsync。(插入50w行记录,每条都提交1次,默认配置会花费2分钟时间,可以通过设置该参数为0关闭fsync,时间开销降为13s。)

log block

InnoDB中,重做日志都是以512字节进行存储的。这意味着重做日志缓存、重做日志文件都是以块的方式进行保存的。

由于重做日志块的大小和磁盘扇区大小一样,都是512字节,因此重做日志的写入可以保证原子性,不需要doublewrite技术

重做日志块除了日志本身外,还由日志块头(12字节)+日志块尾(8字节)组成。故每个重做日志块实际可存492字节。

InnoDB存储引擎运行过程中,log buffer根据一定的规则将内存中的log block刷新到磁盘:

  • 事务提交时
  • 当log buffer有一半内存已经使用时
  • log checkpoint时

log group

redo log file除了保存log buffer刷新到磁盘的log block,还保存了一些其它的信息,这些信息一共占用2kb大小。只有log group的第一个redo log file中存储,其余的redo log file仅保留2k空间。这些信息对于InnoDB存储引擎的恢复操作来说非常关键和重要。

这2k信息中存有2个检查点(CP),为的是交替写入。

redo log 格式

由于InnoDB存储引擎的存储管理是基于页的,故其重做日志格式也是基于页的

 _________________________________________________
| redo_log_type | space | page_no | redo log body |
|_______________|_______|_________|_______________|
                 重做日志格式

LSN

LSN(Log Sequence Number)日志序列号,占用8字节,单调递增。含义:

  • 重做日志写入的总量。
  • checkpoint的位置。
  • 页的版本。

LSN记录的是重做日志的总量,单位为字节。

例:LSN为1000时,事务T1写入100字节的重做日志,LSN变为1100。事务T2写入200字节的重做日志,LSN变为1300。

LSN不仅记录在重做日志中,还存在于每个页中。在页中,LSN表示该页最后刷新时LSN的大小。因为重做日志记录的是每个页的日志,因此页中的LSN用来判断页是否需要进行恢复操作。

例:页P1的LSN为10000,数据库启动时,InnoDB检测到写入重做日志中的LSN为13000,并且该日志已经提交,那么数据库需要进行恢复操作,将重做日志应用到P1页中。

恢复

InnoDB启动时,不管上次数据库运行时是否正常关闭,都会尝试进行恢复操作。

因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志要快很多。(同时引擎也对恢复进行了一定优化,如顺序读取及并行应用重做日志。)

物理日志记录的是页的物理修改,是幂等的。f(f(x))=f(x)

例:
对于

insert into t select 1, 2;

由于需要对聚集索引页和辅助索引页操作,重做日志大致是

# 聚集索引
page(2,3), offset 32, value 1,2
# 辅助索引
page(2,4), offset 64, value 2

记录的是页的物理修改操作。

undo

概念

undo log用来帮助事务回滚MVCC功能。

与redo log存放日志文件不同,undo存放在数据库内部的undo段,其位于共享表空间内

undo并非将数据库物理地恢复到执行语句或事务之前,因为undo是逻辑日志,因此修改只是被逻辑取消了。会执行相反的SQL操作:insert->delete, delete->insert,update->update。

例:一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中的另几条记录进行修改。

因此,不能将一个页回滚到事务开始的样子,这样会影响其它事务正在进行的工作。

注:undo log会产生redo log,这是因为undo log也需要持久性的保护。

存储管理

事务提交后,并不能马上删除undo logundo log所在的页,这是因为可能还有其它事务需要通过undo log来得到行记录之前的版本。

事务提交时,将undo log放在一个链表中,是否可以删除undo log及undo log所在的页,由purge线程来判断。

不能为每个事务分配一个单独的undo页,会很浪费存储空间。一个页允许多个事务的undo log存在。
通过history list按事务提交的顺序将undo log链接。这种从history list中找undo page,再从undo page中找undo log的设计模式,是为了避免大量的随机读取,提高purge效率。

对undo页需要被重用。

undo log格式

undo log分为:

  • insert undo log:因为insert操作只对本事务可见,对其它事务不可见(隔离性),故可以在事务提交后删除。
  • update undo log:记录的是delete和update操作,该undo log可能要提供MVCC机制,不能在事务提交后就删除,需要放入undo log链表,等待purge线程进行删除。

purge

delete和update操作可能并不直接删除原有的数据。

对记录的delete flag设置为1,记录并没有被删除,即记录还存在于B+树中。
其次,对辅助索引没有做任何处理,甚至没有产生undo log。

purge用于最终完成delete和update操作。这样设计是因为InnoDB支持MVCC,所以记录不能在事务提交时立即进行处理。这时可能有其他事务正在引用这行。
是否可以删除该条记录,通过purge进行判断,若该行记录不被其他事务引用,那么就可以真正进行delete操作。

参数innodb_purge_batch_size用来设置每次purge操作需要清理的undo page数量。
默认为300。该值过大,导致CPU和磁盘过于集中对undo log的处理,使性能下降。

group commit

若事务为非只读事务,则每次事务提交时,需要进行一次fsync操作,以保证重做日志都已经写入磁盘。

为了提高磁盘fsync的效率,提供了group commit,即一次fsync可以将多个事务的重做日志写入磁盘。

事务提交时,会进行2阶段操作:

  • 1.将多个事务的日志写入重做日志缓冲。
  • 2.调用fsync将重做日志缓冲写入磁盘。

分布式事务

InnoDB提供XA事务对分布式事务的实现。允许多个独立的事务资源参与到一个全局的事务中。

全局事务要求在其中的所有参与的事务要么都提交,要么都回滚。

使用分布式事务时,InnoDB的事务隔离级别必须设置为SERIABLIZABLE

分布式事务分2阶段,第一阶段,所有参与全局事务的节点都准备,告诉事务管理器它们准备好了。
第二阶段,事务管理器(连接MySQL的客户端)告诉资源管理器(MySQL)执行ROLLBACK还是COMMIT。若一个节点不能通过提交,则通知全部回滚。

mysql:单节点运行分布式事务没有意义。

xa start 'a';
insert into z select 11;
xa end 'a';
xa prepare 'a';
xa recover;
xa commit 'a';

分布式事务的操作通常是由应用程序控制。

import java.sql.Connection;
import javax.sql.XAConnection;
import javax.transaction.xa.*;
import com.mysql.jdbc.jdbc2.optional.MysqlXADataSource;
import java.sql.*;

public static void main(String[] args) {
    String connStr1 = "jdbc:mysql://192.168.1.101:3306/bank_shanghai";
    String connStr2 = "jdbc:mysql://192.168.1.102:3306/bank_beijing";

    try {
        // 1.数据源
        MysqlXADataSource ds1 = new MysqlXADataSource();
        ds1.setUrl(connStr1);
        ds1.setUser("tom");
        ds1.setPassword("123");
        MysqlXADataSource ds2 = new MysqlXADataSource();
        ds2.setUrl(connStr2);
        ds2.setUser("jerry");
        ds2.setPassword("321");

        // 2.连接
        XAConnection xaConn1 = ds1.getXAConnection();
        XAResource xaRes1 = xaConn1.getXAResource();
        Connection conn1 = xaConn1.getConnection();
        Statement stmt1 = conn1.createStatement();
        
        XAConnection xaConn2 = ds2.getXAConnection();
        XAResource xaRes2 = xaConn2.getXAResource();
        Connection conn2 = xaConn2.getConnection();
        Statement stmt2 = conn2.createStatement();
        
        // 3.分布式id
        Xid xid1 = new Xid();
        Xid xid2 = new Xid();

        try {
            // 4.事务
            xaRes1.start(xid1, XAResource.TMNOFLAGS);
            stmt1.execute("update account set money=money-10000 where user='tom'");
            xaRes1.end(xid1, XAResource.TMSUCCESS);
            
            xaRes2.start(xid2, XAResource.TMNOFLAGS);
            stmt2.execute("update account set money=money+10000 where user='jerry'");
            xaRes2.end(xid2, XAResource.TMSUCCESS);

            // 5.准备
            int ret1 = xaRes1.prepare(xid1);
            int ret2 = xaRes2.prepare(xid2);

            // 6.提交
            if(ret==XAResource.XA_OK && ret2==XAResource.XA_OK){
                xaRes1.commit(xid1, false);
                xaRes2.commit(xid2, false);
            }
        } catch (Exception e) {
            //TODO: handle exception
        }
    } catch (Exception e) {
        //TODO: handle exception
    }
}

长事务

是指执行时间比较长的事务。

由于数据库回滚所有发生的变化,这个过程可能比产生变化的时间还长。因此,对于长事务,有时可以通过转为小批量的事务来进行处理。

redo log与bin log的不同

二进制日志(binlog),用来进行POINT-IN-TIME(PIT)的恢复及主从复制。表面上看起来和redo log相似,都是记录了对数据库操作的日志,但本质非常不同。

不同 redo log bin log
谁产生? 是InnoDB存储引擎层产生 是MySQL数据库的上层产生。
不仅可用于InnoDB,其它存储引擎对数据库的更改都会产生。
内容形式 物理格式日志,记录的是对于每个页的修改 逻辑日志,记录的是对应的SQL语句
写入磁盘时间点不同 事务提交完成后,一次写入 事务进行中,不断写入

MVCC导致的问题

为了实现MVCC,会导致2个问题:

  • update undo log不会在事务提交后立刻删除:记录的是delete和update操作,该undo log可能要提供MVCC机制,不能在事务提交后就删除,需要放入undo log链表,等待purge线程进行删除。
  • delete和update操作延迟:记录不能在事务提交时立即进行处理。这时可能有其他事务正在引用这行。是否可以删除该条记录,通过purge进行判断,若该行记录不被其他事务引用,那么就可以真正进行delete操作。

为什么要写入二进制日志和事务提交顺序一致?

为什么需要保证MySQL数据库上层二进制日志的写入顺序,和InnoDB层的事务提交顺序一致?

这是为了备份和恢复的需要。

statement和row格式记录的binlog不同

statement记录的是操作的SQL语句。

row记录的是行的变更。

解决binlog和undolog不同

问题:由于复制的需要,需要binlog。若二进制日志先写,而在写入InnoDB存储引擎的undo时发生了宕机,那么slave可能会接收到master传过去的二进制日志并执行,最终导致主从不一致的情况。

解决:为了解决,MySQL数据库在binlog与InnoDB存储引擎之间采用XA事务(内部XA事务)。当事务提交时,InnoDB存储引擎会先做一个PREPARE操作,将事务的xid写入,接着进行二进制日志的写入。如果宕机,MySQL数据库在重启后,会先检查准备的UXID事务是否已经提交,若没有,则在存储引擎层再进行一次提交操作。

不好的事务习惯

  • 在循环中提交。每次提交都要写一次重做日志。 -> 显式开启事务,避免隐式拆分为多个事务。

    过程调用中的循环提交,注意会隐式提交。建议显式开启事务。

  • 使用自动提交
  • 使用自动回滚。在程序中控制事务的好处是,用户可以的到发生错误的原因。