事务

62 阅读10分钟

为什么需要事务

先看一个经典场景:

-- 扣减库存
UPDATE t_inventory SET ......;

-- 增加订单信息和订单详情信息
INSERT INTO t_order VALUES(....);
INSERT INTO t_order_info VALUES(....);

-- 增加物流信息
INSERT INTO `t_logistics` VALUES(....);

上面的 SQL 是由线程一条条执行的,也就是有先后顺序,看起来没有太大的问题,无非就是入库的时间先后而已,但是再想想,如果扣减库存的SQL执行完,但是在执行后续的SQL程序发生了异常。这样的结果就是库中已经在库存表中新增一条库存扣减记录,但是对应的订单和订单信息表中却没有新增订单数据,对应的实际业务就是:客户已下单且已经扣减的库存,但是客户没有收到自己的订单信息和物流信息,这是非常严重的生产事故。

那要怎么解决?

其实很简单,那是不是当发生异常时,让所有已经执行的 SQL 回滚,也就是回到执行前的状态是不是就可以解决这个问题

其实这就是常说的事务。


事务的定义:

保证一组数据库操作(即一条SQL或一串SQL的执行),要么全部成功,要么全部失败


在 Mysql 中,事务支持是在引擎层中实现的。但是不是所有引擎都支持事务。所以这里以 InnoDB 引擎为例子,剖析事务支持方面的特定实现

既然提到事务,那肯定会想到事务的四大特性:

原子性

原子性指组成一个事务的一组 SQL 要么全部执行成功,要么全部执行失败,事务中的一组 SQL 会被看成一个不可分割的整体,当成一个操作看待

一致性

一致性指事务发生的前后,MySQL 中数据变化是一致的,也就是数据库中的数据只允许从一个一致性状态变化为另一个一致性状态。简单来说:一个事务中的所有操作,要么一起改变数据库中的数据,要么都不改变,对于其他事务而言,数据的变化是一致的

就上面的例子而言,当发生库存扣减时,那么订单就会增加相应的数量。即库存数量(旧) = 库存数量(新) + 增加的订单数量

如果库存发生扣减,但是没有增加订单,这意味着事务出现了问题,此时 MySQL 会利用事务回滚机制,将数据回滚,确保数据的一致性

如果还不容易理解,就以转账为例,A 转账给 B,A 的余额会减少,那么 B 的余额会增加。但是对于银行而言,账款的总数是不变的。

隔离性

隔离性多个事务之间是独立的,相互之间不会影响

隔离性在底层是如何实现的呢?

基于MySQL的锁机制和MVCC机制做到的

后续补充

持久性

持久性是指一个事务一旦被提交,它会保持永久性,所更改的数据会被写入到磁盘做持久化处理,就算MySQL宕机也不会导致数据改变(因为宕机后也可以通过日志恢复数据)

MySQL 的事务隔离机制

上面已经提到什么是事务,也说明了事务的四大特性,接下来要介绍 MySQL 的事务隔离机制:

MySQL 的事务隔离机制主要是下面四种,越往后在多线程并发操作的情况下,出现问题的几率越小,但对应的性能也会越差,MySQL 的事务隔离级别,默认为第三级别:Repeatable read

  • Read uncommitted/RU:读未提交
  • Read committed/RC:读已提交
  • Repeatable read/RR:可重复读
  • Serializable:序列化/串行化

如果只是知道哪四种隔离机制,却不知道这四种机制分别解决了并发操作下的哪些问题,这显然是不正确的,所以还是有必要先知道一下并发操作下会存在哪些问题:

  • 脏读问题: 脏读是指一个事务读到了其他事务还未提交的数据,也就是当前事务读到的数据,由于还未提交,因此有可能会回滚 image.png

  • 不可重复读问题: 不可重复读问题是指在一个事务中,多次读取同一数据,先后读取到的数据不一致 image.png

  • 幻读问题: 对于幻读问题的定义,目前来说是有争议的,现在主流的两种说法分别是:

    1. 同一个事务多次查询返回的结果集不一样
    2. 另外一个事务在第一个事务要处理的目标数据范围之内新增了数据,并先于第一个事务提交导致第一个事务查询结果不一致造成的问题

    个人更加倾向第二种说法,因为第一种说法看起来更像是不可重复度问题,第二种说法要更加的清晰准确

对于上面的几种问题,MySQL 的事务隔离机制是怎么解决的呢?

  • 读未提交:最低级别的隔离机制,无法避免脏读、不可重复读、幻读问题

  • 读已提交:处于该隔离级别的数据库,可以解决脏读问题,但是不可重复读、幻读问题依旧存在

  • 可重复读:处于该隔离级别的数据库可以解决脏读、不可重复读问题,但是幻读问题依旧存在

  • 序列化/串行化:最严格的隔离机制,处于该隔离级别的数据库,可以解决脏读、不可重复读、幻读问题

数据库不同的事务隔离级别,是基于不同类型、不同粒度的锁来实现的

MySQL 的锁机制

读未提交

读未提交是基于写互斥锁实现的,当两个事务操作同一数据时,只有先获得锁资源的事务才可以对该数据进行写操作,且获取到锁的事务具备排他性/互斥性,也就是其他线程无法再操作这个数据

在这个事务隔离级别中,写同一数据时会互斥,但读操作并不是互斥的,即当一个事务在写某个数据时,就算没有提交事务,其他事务来读取该数据时,也可以读到未提交的数据,因此就会出现脏读、不可重复读、幻读一系列问题

读已提交级别

读已提交对于写操作同样会使用 写互斥锁,即两个事务操作同一数据时,会出现排他性,而对于读操作则基于 MVCC 多版本并发控制的技术处理,也就是当存在另一个事务中的需要读取当前事务正在操作的数据时,MVCC 机制不会让另一个事务读取正在修改的数据,而是读取上一次提交的数据(原本的老数据)

MVCC 多版本并发控制详解

在该隔离级别中,对于写操作会具备排他性(针对同一数据),对于读操作则只能读已提交事务的数据,不会读取正在操作但还未提交的事务数据

示例: 事务A负责更新ID=100001的这条数据,事务B中则是读取ID=100001的这条数据。 当事务A正在更新该条数据但还未提交时,此时事务B开始读取数据,那么MVCC机制则会基于表数据的快照创建一个ReadView,然后读取原本表中上一次提交的老数据。然后等事务A提交之后,事务B再次读取数据,此时MVCC机制又会创建一个新的ReadView,然后读取到最新的已提交的数据,此时事务B中两次读到的数据并不一致,因此出现了不可重复读问题

简单来说就是每次读操作来时,读之前,MVCC都会基于表数据的快照形成一个 ReadView,每次读操作在都会在这个ReadView上进行,由于每次读操作前都会生成一个新的ReadView,如果另一个事务如果存在多次读SQL时,这可能也会导致两次读操作读的数据不一致,即不可重复读问题

可重复读级别

在可重复读的隔离级别中,主要是为了解决上一个级别中遗留的不可重复读问题,但MySQL依旧是利用MVCC机制来解决这个问题的,只不过在这个级别的MVCC机制会稍微有些不同

在该级别中,不会每次的读操作都创建新的ReadView,而是在一个事务中,当第一次执行查询会创建一个ReadView,且在这个事务的生命周期内,事务中后续所有的查询都会从这一个ReadView中读取数据,从而确保了一个事务中多次读取同一数据是一致的,也就是解决了不可重复读问题

序列化/串行化级别

序列化是最高的隔离级别,处于该隔离级别的 MySQL 不会产生任何并发问题。该级别下的数据库会将所有的事务按序排队后串行化处理,也就是操作同一张表的事务只能一个一个按顺序执行,事务在执行前需要先获取表级别的锁资源,拿到锁资源的事务才能执行,其余事务则陷入阻塞,等待当前事务释放锁

MySQL的事务实现原理

在 MySQL 中,任意一条写SQL的执行都会记录三个日志undo-log、redo-log、bin-log

  • undo-log:主要记录SQL的撤销日志,比如目前是insert语句,就记录一条delete日志
  • redo-log:记录当前SQL归属事务的状态,以及记录修改内容和修改页的位置
  • bin-log:记录每条SQL操作日志,主要是用于数据的主从复制与数据恢复/备份

和事务机制有关的是undo-log、redo-log这两个日志,其中最重要的是redo-log这个日志

redo-log是一种WAL(Write-ahead logging)预写式日志,在数据发生变更之前(SQL执行之前)会先往该日志中记录一条prepare状态的日志,然后再执行数据的写操作

需要注意的是 MySQL 是基于磁盘的,而磁盘的写入速度相较内存而言会较慢,因此MySQL-InnoDB引擎中不会直接将数据写入到磁盘文件中,而是会先写到BufferPool缓冲区中,当SQL被成功写入到缓冲区后,紧接着会将redo-log日志中相应的记录改为commit状态,然后再由MySQL刷盘机制去做具体的落盘操作

上面的 SQL 写入流程可以总结为:


  1. 开启事务:start transaction
  2. redo-log 日志中记录一条状态为 prepare 的日志
  3. 生成对应的撤销日志并记录到 undo-log
  4. 执行 SQL ,将要写入的数据先更新到缓冲区
  5. 再对第二条 SQL 语句做相同处理,如果有更多条 SQL 则逐条依次做相同处理.....
  6. 提交或回滚事务:commit / rollback
  • 如果是commit提交事务的命令,会先将当前事务中,所有的SQLredo-log日志改为commit状态,然后由MySQL后台线程做刷盘,将缓冲区中的数据落入磁盘存储
  • 如果是rollback回滚事务的命令,则在undo-log日志中找到对应的撤销SQL执行,将缓冲区内更新过的数据全部还原,由于缓冲区的数据被还原了,因此后台线程在刷盘时,依旧不会改变磁盘文件中存储的数据