事务

事务特性

MySQL采取默认提交(AUTOCOMMIT)模式, 即除非显式的开始一个事务, 则每个语句都是为一个事务在执行后立即提交. 事务的基本特征是ACID, 即原子性, 一致性, 隔离性和持久性.

原子性表明一个事务必须视为一个不可分割的最小执行单位, 其中的操作要么同时成功, 要么同时失败.

一致性表明数据库总是一致的从一个状态转移到另一个状态, 而不会导致数据出现不一致.

隔离性表明一个事务的操作在提交之前应该对其他事务不可见. 虽然事务要求有隔离性, 但MySQL对隔离性提供了不同的等级, 因此对隔离性也有不同的表现. MySQL默认采取的隔离级别为可重复读.

持久性表明一个事务的操作一旦提交到数据库, 则即使数据库崩溃, 数据也不会丢失. 但实际上并不存在可以保证绝对不丢失数据的数据库, 因而持久性也存在不同的等级.

SQL标准规定了四种隔离级别, 每种级别都规定了一个事务的修改, 在其他事务内的可见性. 低级别的隔离通常具有更高的并发性并且系统开销也更低. 关于事务的基本特性和隔离级别, 可以参考之前的博客数据库原理之事务并发控制.

实现了ACID特性的数据库比没有实现ACID特性的数据库需要消耗更多CPU性能, 内存空间和磁盘空间. MySQL由于可以选择不同的存储引擎, 因此可以自由的选择是否需要事务相关的特性, 从而更灵活的适应需求.

开启事务

可以使用BEGIN或者START TRANSACTION语句开启一个事务。如果使用START TRANSACTION开启事务,还可以跟随几个关键词来指定具体的事务类型

修饰词 含义
READ ONLY 开启一个只读的事务
READ WRITE 开启一个可读可写的事务
WITH CONSISTENT SNAPSHOT 开启一致性读的事务

例如START TRANSACTION READ ONLY或者START TRANSACTION READ ONLY, WITH CONSISTENT SNAPSHOT

隐式提交

默认情况下, MySQL是自动提交的, 即如果没有明确表示开始一个事务, 则每个语句都视为一个单独的事务. 但即使手动开启了事务, 当执行某些特定操作时, 此操作之前的操作还是会被提交, 这一特性称为隐式提交.

这类操作包括定义或修改数据库结构, 在事务中开启新的事务, 手动锁定表, 加载数据, 复制数据等.

保存点

在事务中执行多步操作时, 可以指定一些保存点, 从而在回滚的时候, 不回滚全部操作, 而是回滚到指定的保存点. 例如

1
2
3
4
5
6
7
8
9
10
BEGIN;

SELECT ...;
UPDATE ...;

SAVEPOINT A;

UPDATE ...;

ROLLBACK TO A; #回滚到保存点A

redo日志

由于硬盘的IO速度比较慢, 因此MySQL并不会每次修改操作都将修改写入硬盘, 而是会将结果先缓存到内存之中, 在某个时机再整页的写入数据. 由于数据写入操作的滞后性, 存在数据修改后还未写入之前, 数据库发生崩溃的可能性. MySQL使用redo日志来解决这一问题.

当事务提交时, MySQL并不会将数据的修改立刻写入硬盘, 但会将相应的redo日志立即写入硬盘. 由于写日志操作是顺序IO, 因此相比于更随机的写入页面, 写入日志的性能更高. 当数据库发生崩溃时, 数据库可以根据redo日志恢复已经提交的事务, 从而保证事务的一致性和持久性.

redo日志格式

redo日志是一种物理机制, 其中并不是记录相关的SQL语句, 而是记录了页面修改情况的详细信息. redo日志的行格式中包含很多类型, 有的类型指示简单的记录那个页面的那个偏移位置有修改, 有的类型就会记录一些更复杂的信息.

一条插入语句可能只需要在某个位置加入一个记录, 因此只需要一条redo日志即可. 但在另外一些情况下, 插入数据可能导致页面分裂, 进而引发一系列的修改, 就可能产生数十条redo日志. 因此实际写入redo日志的过程也需要保证原子性, 例如上述的数十条redo日志, 要保证要么不写入, 要么全部写入, 否则就可能导致数据恢复到不正确的中间位置.

MySQL中将底层的一次原子操作称为一个Mini-Transaction(MTR), 例如前面提到了插入数据后导致页面分裂的一系列操作就是一个MTR. redo日志的写入过程以MTR为基本单位.

redo日志写入过程

redo日志也存在缓冲区, 其中按照512字节分割为不同的block. 在事务的执行过程中, 会不断的产生redo日志, 这些redo日志先按照组存在在某个地方, 等一组任务完成以后再写入到redo日志的缓冲区中.

缓冲区内的redo日志在如下的一些时机会被刷入硬盘

  1. 缓冲区空间不足
  2. 事务提交
  3. 后台专门刷新的线程
  4. 关闭服务器
  5. checkpoint

redo日志文件组

redo日志在磁盘上对应了几个文件, 一般的命名格式类似ib_logfile0, ib_logfile1. 这些日志文件会被循环写入, 后写入的日志会覆盖以前写入的日志.

redo日志中使用log sequence number(lsn)来记录日志的写入位置, 从而判断哪些位置日志已经失效, 哪些位置的日志还需要保存.

undo日志

事务ID

每个开启的事务都会被分配一个事务ID, 如果这个事务不进行任何修改, 则ID默认为0.

INSERT操作的undo日志

由于INSERT操作是向数据库中插入数据,因此对应的undo操作就是删除数据。为了删除一条已经插入的数据,undo日志中只需要记录数据对应的主键值即可。

如果表定义了INT类型的主键,或者没有定义主键而使用默认生成的主键,则undo日志中记录对应的INT值。如果使用了联合主键,则undo日志中记录联合主键中每个键的值。

DELETE操作的undo日志

由于使用InnoDB引擎删除数据时,处于性能考虑,并不会立即删除数据并移动其他数据覆盖删除数据的位置,因此对于删除操作,只需要在事务提交以前,将对应位置的数据标注为删除状态,但并不添加到垃圾记录链表之中即可。

此时数据从逻辑上来说已经被删除,但因为不在垃圾记录链表中,因此不会被其他新插入的数据覆盖。如果事务回滚,只需要修改标志位即可恢复数据。如果事务提交,也只需要将此记录添加到垃圾链表之中即可。

补充:undo链

在一个事务中可以对一条数据可以执行多次操作,例如可以先插入一条数据,然后再删除这条数据。为了保证一系列的操作都能被正确的undo,InnoDB中使用了undo链。

当插入数据时,数据对应的roll_pointer指向INSERT的undo记录,如果再删除数据,则数据对应的roll_pointer指向DELETE的undo记录,而DELETE的undo记录中添加一个roll_pointer字段指向原本的INSERT的undo记录。

1
2
3
4
5
6
7
8
9
|-----------------------|     |-------------|
| Record | roll_pointer | --> | INSERT undo |
|-----------------------| |-------------|
第一步插入数据时的指针状态

|-----------------------| |----------------------------| |-------------|
| Record | roll_pointer | --> | DELETE undo | roll_pointer | --> | INSERT undo |
|-----------------------| |----------------------------| |-------------|
第二步删除数据时的指针状态

UPDATE操作的undo日志

UPDATE操作的undo日志可以分为两个大类,不更新主键的UPDATE和更新主键的UPDATE。对于不更新主键的UPDATE操作又可以分为两种,可以原地更新的操作和不可原地更新的操作。

对于不修改主键的UPDATE操作,如果需要修改的数据占用的空间没有超过原本的记录的空间,则此数据可以原地更新。否则需要先删除原本的记录,然后插入一条新的记录。此时的删除操作是立即生效的,原本的位置立刻可以用于写入新的数据。

对于需要修改主键的UPDATE操作,由于修改主键会导致数据存储位置的变化,进而导致数据可能跨页移动,因此先原地标记数据删除,等事务提交后再删除数据。

MVCC机制

MySQL的大多数支持事务的存储引擎都采用多版本并发控制机制来实现行级锁. 其他的数据库系统也都实现了MVCC机制, 但具体的实现可能不尽相同, 因为MVCC并没有规定实现标准.

MVCC机制的核心是创建一个快照视图, 从而从某个时刻开始, 事务根据快照读取数据时就好像数据不在发生变化, 从而实现可重复读.

MySQL中InnoDB的MVCC机制的实现方法的核心在于每行数据后有两个隐藏字段, 一个表示创建时间, 一个表示删除时间, 两个字段都使用系统版本号进行标识. 开启一个事务时, 系统版本号会自动递增, 并且事务以此时的系统版本号作为此事务的版本号. 并且在执行不同操作时, 有如下的约束

SELECT操作只读取创建时间的版本号小于等于当前版本号的数据和删除时间为未定义或大于当前事务版本号的数据. 这样可以保证读取的数据要么在事务开始前已经创建, 要么在事务开始后才被删除.

INSERT操作将当前事务的版本号作为新插入数据的创建时间版本号.

DELETE操作将当前事务的版本号作为删除数据的删除时间版本号

UPDATE操作先插入一条数据, 然后将原来的数据删除.

按照如上的约定, 即可实现可重复读. 如果放宽SELECT操作对创建时间的限制, 那么就可以将隔离级别降低为读已提交. 通过MVCC机制, 可以使大部分读操作不加锁的实现.

对于写操作还是需要加锁, 否则可能导致事务特性被破坏. 对于select for update等操作, 由于读取的是当前数据, 因此可能与直接的select读取数据不一致. 但select for update语句本身加了锁, 因此从第一次执行后, 还是能够保证可重复读.

由于快照不变, 因此也可以保证不出现幻读的问题. 但对于当前读, 由于读取的是实时数据, 因此还是有可能出现幻读. 对于幻读问题, MySQL采用间隙锁来解决.

锁机制

MVCC与读写锁

MVCC的快照机制可以保证读取操作不会看到不该看到的数据,因此对于只读的事务,仅使用MVCC机制即可保证与其他可写事务的一致性。但对于两个都需要读写的事务,单独使用MVCC机制就不能保证一致性了。此时还是需要引入读写锁进行同步。

当事务A对数据加读锁后,事务B就不能对此数据再加写锁,则保证了事务A对数据的可重复读特性。如果事务A需要更新数据,也可以直接SELECT FOR UPDATE,则对数据加写锁,事务B就不能加读写锁了,保证了其他事务的可重复读。

相比于MVCC机制的读写互不干扰,读写锁相互影响导致性能更低。

两段锁协议

InnoDB采用两段锁协议, 在事务过程中随时可能加锁, 并且在事务结束时统一释放所有的锁. InnoDB会根据隔离级别, 在需要的时候自动加锁.

由于引入了锁机制, 因此事务之间可能存在死锁. InnoDB等存储引擎实现了死锁检测和死锁超时机制. 当出现死锁时, InnoDB会回滚持有最少行级排它锁的事务. 锁的行为与顺序和存储引擎有关, 同样的执行语句, 在不同的存储引擎上可能有不同的执行情况. 语句本身和存储引擎的实现都可能导致死锁.

间隙锁

最后更新: 2024年03月27日 17:30

版权声明:本文为原创文章,转载请注明出处

原始链接: https://lizec.top/2021/05/10/MySQL%E7%AC%94%E8%AE%B0%E4%B9%8B%E4%BA%8B%E5%8A%A1%E5%8E%9F%E7%90%86/