事务

为什么要有事务?

对于一个数据库系统来说可能会面临各种各样的问题,比如:数据库挂了、客户端挂了、网不通了,多个client同时访问……而出现这些情况都有可能会影响整个系统。为了保证数据库的可靠性,所以就出现了“事务”这种机制。从概念上讲,事务就是将多个操作视为一个操作,而整个事务要么执行成功commit,要么执行失败abort或者rollback,这样如果执行失败,客户端可以回滚整个事务,而不是再去找多个操作中哪个操作出现了问题。

事务的ACID提供了什么能力?

事务所提供的安全保证,可以用ACID来描述:

  1. Atomicity:原子性
    • 数据库的属性
    • 多个操作视为一个操作,只能全部成功或者全部失败
    • 提供了在中间的某个操作中失败时,丢弃之前所有操作的能力
  2. Consistency:一致性
    • 应用程序的属性
    • 一个事务的前后从一个合法的状态变到另一个合法的状态,而这里的“合法”是应用程序自定义的,比如:转账系统的总和应该保持不变,事务开始前A有10元,B有5元,总和为15元,事务中A给B转5元,结果A有5元,B有10元,总和为15元,这样就是从一个合法的状态转移到另一个合法的状态。如果A给B转15元呢?这样A有-5元,B有20元,总和还是15元,这也是合法的状态,这是因为开始定义“合法”时,只要求了总和不变,并没有要求某一个人的余额不能小于0,所以说“合法”完全就是应用程序定义的
    • 说白了事务的ACID中的AID是为了保证C的
  3. Isolation:隔离性
    • 数据库的属性
    • 多个客户端的操作应该是相互“隔离”的,相互不影响的,当然这个“隔离”也是有程度的,引出下面的隔离级别
    • 提供了多个客户端同时操作时相互隔离的能力
  4. Durability:持久性
    • 数据库的属性
    • 提供了事务一旦执行成功,数据就写入磁盘中的能力

MySQL中事务是由存储引擎层实现的,所以支不支持事务取决于使用了什么存储引擎,MySQL原生的引擎MyISAM不支持,而InnoDB是支持的。MySQL5.5.5之后默认的存储引擎为InnoBD

多个客户端同时操作时会有哪些问题?

  1. 脏读
    • 一个事务读到了其他事务还没有提交的数据
    • 为什么脏读会有问题?举个例子:事务B修改了某一行的数据,事务A读到了这一行的数据,事务B由于某些原因失败并回滚了,事务A拿这一行读到的数据进行了某些操作并commit了。结果上来看事务A使用了有问题的行进行了操作。
  2. 不可重复读
    • 一个事务内读都读某一行的数据,前后不一致
    • 为什么不可重复读会有问题?举个例子【2】:
      -- session A
      start transaction;
      insert table(id, value) values(1, 1);
      insert table(id, value) values(2, 2);
      commit;
          -- session B
          begin transation;
          select id, value from table where id = 1;
      -- session A
      start transaction;
      update table set value = 10 where id = 1;
      update table set value = 20 where id = 2;
      commit;
          -- session B
          select id, value from table where id = 2;
          commit;
      -- 数据库中这两行的数据只有:(1, 1)(2, 2)和(1, 10)(2, 20)这两个状态,但是sessionB却读到了(1, 1)(2, 20)
      -- 假如业务代码中写了:
      -- if (value1 * 2 != value2) {
      --     执行某些操作
      -- }
      -- 但是显然某些操作在这里不应该执行的,因为数据库中始终没有这个状态,这就是不可重复读的问题
      
  3. 幻读
    • 一个事务内查询的结果数量前后不一致,比如另一个事务又插入了满足条件的数据。
    • 幻读有什么问题?
  4. 更新丢失

如何解决上面的问题?

在标准的SQL规范中定义了四种事务隔离级别:

  1. 读未提交,可能出现脏读、不可重复读、幻读
  2. 读已提交,可能出现不可重复度、幻读
  3. 可重复读,可能出现幻读
  4. 串行化,没有任何问题

更新丢失问题...

但是规范始终只是规范,并没有说明四种隔离级别该如何实现,因此不同的数据库具体的实现方式可能也不相同。而SQL隔离级别的标准是依据基于锁的实现方式来制定的,首先看看只通过锁如何实现这四种隔离级别。【3】

如何通过锁实现四种隔离界别?

前置知识

  1. 锁分为共享锁(S锁)和排它锁(X锁),至于为什么要分为两种锁,可以类比Java的读写锁,允许多个线程同时读,但只允许一个线程写,既支持并发提高性能,又保证了并发安全。【4】
  2. 锁可能在commit的时候才会释放

具体实现:

  1. 读未提交:读操作不加锁,读读,读写,写读并行;写操作加X锁且直到事务提交后才释放。
  2. 读已提交:读操作加S锁,写操作加X锁且直到事务提交后才释放;读操作不会阻塞其他事务读或写,写操作会阻塞其他事务写和读,因此可以防止脏读问题。
  3. 可重复读:读操作加S锁且直到事务提交后才释放,写操作加X锁且直到事务提交后才释放;读操作不会阻塞其他事务读但会阻塞其他事务写,写操作会阻塞其他事务读和写,因此可以防止脏读、不可重复读。
  4. 串行化:读操作和写操作都加X锁且直到事务提交后才释放,粒度为表锁,也就是严格串行。

既然通过锁就可以实现四种隔离级别,为什么又会出现多版本并发控制?

讨论MVCC的前提:存储引擎使用的InnoDB

可以看到通过加锁的方式来实现读已提交和可重复读时,当一个session写某一行时,另个一个session读是会block的,影响了整个系统的并发性。所以向InnoDB这种存储引擎实现了MVCC这种无锁的方案,用以解决事务读-写并发的问题,能够极大提升读-写并发操作的性能。

InnoDB的MVCC是如何实现的?

讨论MVCC的前提:隔离级别是RC或者RR

MVCC的实现依赖于undo log和read view,一个一个解释。

当插入、修改、删除一条记录时,在修改对记录修改前,会将修改前的数据加入到undo log日志中,而对于每一条行记录都有两个隐藏列,一个是修改当前行的事务id(DB_TRX_ID),一个是回滚指针(DB_ROLL_PTR),通过回滚指针可以将一个一个版本连接起来。但undo log存的并不是当前的记录,这样说只是为了方便理解,实际上让我们执行insert操作时,undo log中存的是delete相关操作,也就是逆运算,delete和update同理(例如某一个事务的操作是将name从3改到4,当前值就为4,而undo log中记录的是将4改为3,这样就能知道上一个版本中name是3了)。总之从结果上来看是可以通过undo log和当前的记录回滚到之前的状态。前面提到事务有个特性是原子性,而实现这一机制就是undo log(InnoDB)

那undo log和MVCC又有什么关系呢?他和read view是怎么配合的?

由上面的解释我们可以知道undo log存的是一行记录的多个版本,而通过限定我们能读到的版本范围就可以实现RC和RR隔离级别,read view就提供了这样的能力,read view将所有的事务分为了三类:

  1. 已经提交的事务
  2. 正在活跃的事务,即还没有commit的
  3. 未来的事务

RR隔离界别下read view生成的时间点是事务开启的时候,也就是说这个时间已经确定了已经提交的事务,说白了就是我能读到的事务,因此在我这事务执行期间“我能读到的事务”是不会改变的,这样就实现了“可重复读”。
而RC隔离级别下每一次快照读(我们一直使用的普通select)都会生成新的read view,也就是说“我能读到的事务”是会改变的,如果别事务在我快照读前commit了,“我能读到的事务”就会新增,即在我这个事务中前后读到的数据不一致,即“不可重复读”。

那undo log什么时候删除呢?

其实就是没有任何read view需要某一个版本的时候就可以删除了。分别以RC和RR隔离级别来举两个例子:
RC:假设当前有两个事务A和B,trx_id分别是9和10,事务A对name字段做了两次更新,原值为1,分别更新成了2和3,那么undo log中就是这样的:1 <-- 2 <-- 3,此时如果事务B进行读操作,对于该事务来说只有1是可见的,那么read view就是需要这三个版本,也就是都不能删除。而接下来事务A提交了,那么事务B再去读的话,3也是可见的,那么undo log中1和2的版本对于任何事务中的read view来说都是不需要的,那么也就是可以删除了。
RR:例子和RC一样,在事务A提交后,对于B来说再select还是只有1可见的,也就是说undo log还是不能删。这也是MySQL中使用InnoDB存储引擎的前提下RR相对于RC不好的地方,就是undo log可能会很大。而且需要尽量避免一个长事务,长事务可能需要的undo log就越长,比如一个事务A进行select,而其他的事务一直在更新并提交。

事务的持久性是如何实现的?

在前面中大量的讨论了事务隔离性的问题,且在MVCC中也说明了事务原子性是通过undo log来实现的,那么事务的持久性是如何实现的呢?【5】【6】

讨论持久化之前,首先先看一下buffer pool(InnoDB实现的),照例还是先看一下为什么要有buffer pool,假如没有buffer pool的话,那么每一次的增删改查都是需要与磁盘交互,这样性能就会有影响。

buffer pool中存储的是什么?
我们知道在InnoDb中最小的存储单元是page,以page作为磁盘和内存存储的基本单位,因此buffer pool中也是保存的一个个page。同时每一个page都有一个描述数据块来指向page,因此会看到buffer pool占用的内存是比page总和大的。疑问:下一次的查询怎么知道我需要的page已经在buffer pool中?

  1. 数据页
  2. 索引页
  3. 插入缓存页
  4. undo页
  5. 自适应哈希索引
  6. 锁的信息

buffer pool中页的类型

  1. free page,空闲页,即我们可以使用哪些page,为了避免找free page时遍历所有的page,InnoDB中使用一个free链表来管理所有的free page。(类比操作系统分配内存
  2. dirty page,脏页,已经修改但是没有保存到磁盘的页,同理使用flush链表来管理

驱逐策略

  1. LRU
  2. 空间局部性,预读策略,young和old,污染问题,加时间

脏页刷盘时机

  1. redo log满了
  2. buffer pool空间满了,且驱逐的page是脏页,也会触发刷盘
  3. mysql认为空闲时
  4. mysql正常关闭前

redo log(InnoDB实现的)
buffer pool的出现是因为性能问题,但是毕竟是基于内存的,但内存的缺点是数据可能会丢失,为了解决这个问题就出现了redo log。所以更新page的流程是(这个流程是不完整的,下文中将列出更新一条语句的完成流程):

  1. 更新page
  2. 将修改记录到redo log中,redo log记录的是page中的修改,例:某个页的某个位置做了某某更新。这个记录还包括更新undo页的记录。

崩溃恢复

  1. 如果在事务中发生异常,可以通过undo log回到到事务之前的状态(原子性,事务内的所有操作同时失败或者同时成功)
  2. 如果是事务commit之后发生了异常,可以通过redo log恢复改事务中更新的内容(持久性)

这里需要理解redo log保证的“持久性”到底是什么意思,它的大前提是我们使用buffer pool来减少io的次数,但是buffer pool毕竟是基于内存,是存在丢失的风险的,所以在一次事务commit后,redo log中是已经记录了这次事务中所有的变更操作,假设这个时候MySQL挂了那么buffer pool的脏页还没来得及刷到磁盘,但是由于redo log中已经有记录了,所以数据还是不会丢失,只需要将redo log中read指针和write指针重新写到磁盘即可。所以redo log保证的“持久性”是buffer pool中数据,假如InnoDB中没有buffer pool,且事务中的变更操作最终写入磁盘后才算一次事务commit成功,那么这种方式也是可以保证持久性的,只不过性能不好罢了。

为什么要使用redo log?

顺序写 vs 随机写

redo log刷盘时机
redo log并不是直接写入磁盘,而是先写入到redo log buffer中,刷盘时机:

  1. mysql正常关闭
  2. redo log buffer中的记录占用了一般的空间
  3. 参数innodb_flush_log_at_trx_commit控制:
    • 0:不主动触发写入磁盘的操作,每隔一秒刷盘
    • 1:事务提交时触发,保证数据不丢失,真的写到磁盘
    • 2:写到os的page cache中,每隔一秒刷盘,mysql崩溃时数据并不会丢失,只有os崩溃或者停电等情况才会丢失

redo log满了
默认有两个(可以配置)redo log文件ib_logfile0和ib_logfile1,通过循环写的方式,有一个写指针和读指针。当写指针最少读指针是说明redo log满了,这样mysql的更新操作就会被阻塞,需要等待redo log中的数据刷盘后才可以结束阻塞。

binlog

在上面通过事务引出了undo log和redo log,再来看一下binlog。

首先undo log和redo log都是InnoDB实现的,而binlog是由server实现的,作用是通过全量备份+重放binlog可以找到之前的数据,而且还可以通过binlog主从复制。

既然binlog中记录了所有变更操作(表结构+表数据),那么看起来也可以有crash-safe(即脏页还没有写入磁盘但是MySQL挂了,但还是可以恢复数据)的能力,为什么InnoDB还需要实现一个redo log呢?这是因为MySQL设计binlog的目的就是为了恢复和主从复制,在MySQL挂了的时候,虽然里面记录了所有变更的数据,但是我们是不知道哪些数据是还没有写入到磁盘中的,而redo log中读指针和写指针之间是还没有写入磁盘中的数据,因此重放这段即可。(不过通过全量备份+binlog重放貌似也可以?没查到可靠的说法)

binlog vs redo log

  1. 实现:server vs InnoDB
  2. 文件格式:
  3. 写入方式:追加写 vs 循环写
  4. 用途:恢复

binlog刷盘时机
写入binlog也是一样,并不会直接写入到磁盘中,而是先写到binlog buffer中,刷盘时机由参数sync_binlog控制

  1. 0:表示每次提交事务都只write,不fsync,后续交由操作系统决定何时将数据持久化到磁盘
  2. 1:每次都write和fsync
  3. 2~N:每次write,但是只有到了x次才会fsync

在一个事务中只有redo log和binlog都写入了事务(buffer or 刷盘)才能commit,而这里是采用两阶段提交的方式,如果不采用两阶段提交的话是会有问题的比如:

  1. 先redo log后binlog中间挂了,那么数据是可以恢复的,但是从库中恢复不了的,导致主从不一致
  2. 先binlog后redo log中间挂了,那么从库是可以恢复的,但是数据是恢复不了的,导致主从不一致

两阶段提交过程

  1. redo log prepare状态:写redo log,XID写入redo log(需要注意的是,redo log并不是最后commit时才写的,而是事务开始的过程中就已经开始写了,而binlog是最后commit的时候才写的)
  2. 写binlog,XID写入binlog
  3. redo log commit状态:事务提交

这其中可能存在问题的点:

  1. 1 -> 2挂了,回滚因为XID不在binlog中
  2. 2 -> 3挂了,正常提交

一条更新操作执行的过程









说明

===================================

仅作为校招时的《个人笔记》,详细内容请看【参考】部分

===================================

参考

  1. 前半部分都是DDIA的内容,算是笔记
  2. https://www.zhihu.com/question/481238639/answer/2078715891
  3. https://juejin.cn/post/6844904096378404872
  4. https://tech.youzan.com/seven-questions-about-the-lock-of-mysql/
  5. https://xiaolincoding.com/mysql/ 日志篇
  6. https://www.cnblogs.com/better-farther-world2099/articles/14768929.html
posted @ 2023-08-08 22:52  optimjie  阅读(54)  评论(0)    收藏  举报