在数据库原理之事务控制中, 我们从理论层面讨论了数据库的事务应该具备的一些特性. 但在实践层面, 数据库软件往往需要提供比理论要求更细致的能力, 才能满足实际业务开发的需求. 本文将以MySQL作为基础, 讨论MySQL中的数据库事务特性.
由于实现了ACID特性的数据库比没有实现ACID特性的数据库需要消耗更多CPU性能, 内存空间和磁盘空间, 因此MySQL提供了不同的存储引擎, 可以自由的选择是否需要事务相关的特性, 从而更灵活的适应需求. 关于存储引擎的实现可参考MySQL笔记之存储结构.
MYSQL与事务
MySQL采取默认提交(AUTOCOMMIT)模式, 即除非显式的开始一个事务, 则每个语句都视为一个事务, 在执行后立即提交. 可以使用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 | BEGIN; |
MVCC机制
MySQL的大多数支持事务的存储引擎都采用多版本并发控制机制来实现行级锁. MVCC机制的核心是创建一个快照视图, 从而从某个时刻开始, 事务根据快照读取数据时就好像数据不在发生变化, 从而实现可重复读.
MySQL中InnoDB的MVCC机制的实现方法的核心在于每行数据后有两个隐藏字段, 一个表示创建时间, 一个表示删除时间, 两个字段都使用系统版本号进行标识. 开启一个事务时, 系统版本号会自动递增, 并且事务以此时的系统版本号作为此事务的版本号. 并且在执行不同操作时, 有如下的约束
SELECT操作只读取创建时间的版本号小于等于当前版本号的数据和删除时间为未定义或大于当前事务版本号的数据. 这样可以保证读取的数据要么在事务开始前已经创建, 要么在事务开始后才被删除.
INSERT操作将当前事务的版本号作为新插入数据的创建时间版本号. DELETE操作将当前事务的版本号作为删除数据的删除时间版本号. UPDATE操作先插入一条数据, 然后将原来的数据删除.
注意: 为了避免读取到未提交事务的记录, 还需要维护一个活跃事务列表(ReadView), 其中包含了创建快照时所有的正在进行事务的ID. 在读取数据时需要判断该记录是否是活跃事务创建的, 如果是则应该对当前事务不可见.
按照如上的约定, 即可实现可重复读(因为快照包含不会发生变化). 如果放宽SELECT操作对创建时间的限制(每次读取最新版的记录), 那么就可以将隔离级别降低为读已提交.
其他的数据库系统也都实现了MVCC机制, 但具体的实现可能不尽相同, 因为MVCC并没有规定实现标准.
只读事务与读写锁
基于MVCC机制, 所有事务均按照以上约定进行操作, 可以使得一个只读的事务完全不需要加锁即可满足事务要求. 但对于非只读的事务, 还是需要适当的加锁, 以避免事务特性被破坏.
例如, 对于SELECT ... FOR UPDATE
操作, 由于该操作要求读取的是当前数据, 因此可能与直接SELECT
时的数据不一致(普通SELECT
读取事务开始时快照的版本). 但由于FOR UPDATE
声明了写操作, 因此会对数据加写锁, 保证了其他事务无法修改此数据, 保证了当前事务的可重复读.
由于快照不变, 因此也可以保证不出现幻读的问题. 但对于当前读, 由于读取的是实时数据, 因此还是有可能出现幻读. 对于幻读问题, MySQL采用间隙锁来解决.
相比于MVCC机制的读写互不干扰, 读写锁相互影响导致性能更低.
锁机制
记录锁与间隙锁
记录锁就是锁定某一条记录的锁, 可分为读锁和写锁. 为了解决幻读问题, 即事务执行期间有其他事务插入新数据, MySQL引入了间隙锁. 间隙锁本质上可以连接为对一段不存在数据的区间的锁定, 使得该区间内不可插入新数据, 从而避免幻读.
典型的场景是以FOR UPDATE
模式查询了一条不存在的记录, 为了避免后续事务执行过程中插入了数据, 因此会锁定不存在记录的区间.
在MySQL中既锁定某条记录, 同时又锁定一段区间的锁称为
Next-Key
锁
两段锁协议
InnoDB采用两段锁协议(第一阶段只能申请锁不能释放锁, 第二阶段只能释放锁不能申请锁), 在事务过程中随时可能加锁, 并且在事务结束时统一释放所有的锁. InnoDB会根据隔离级别, 在需要的时候自动加锁.
由于引入了锁机制, 因此事务之间可能存在死锁. InnoDB等存储引擎实现了死锁检测和死锁超时机制. 当出现死锁时, InnoDB会回滚持有最少行级排它锁的事务. 锁的行为与顺序和存储引擎有关, 同样的执行语句, 在不同的存储引擎上可能有不同的执行情况. 语句本身和存储引擎的实现都可能导致死锁.
加锁时机
当一个事务开始对数据进行修改操作(如INSERT、UPDATE、DELETE)时, InnoDB会自动为涉及的行或相关资源加锁, 防止其他事务同时对同一数据进行修改, 避免数据冲突和不一致.
在可重复读隔离级别下, 普通的SELECT语句(快照读)一般不会加锁, 它会读取事务开始时的快照数据. 当执行SELECT... FOR UPDATE
或SELECT... LOCK IN SHARE MODE
语句时,InnoDB会进行当前读, 即读取最新的数据, 并对读取的行加锁.
在插入, 删除和更新数据时, 如果会导致索引结构变更, 则会同时对索引加锁. 针对索引的变动范围大小会使用不同粒度的锁. 例如仅新增一条索引记录时, 仅对该记录加锁. 但如果涉及索引页分裂或需要更新索引节点, 则会对更大范围的记录加锁, 以保证索引结构的一致性.
redo日志
由于硬盘的IO速度比较慢, 因此MySQL并不会每次修改操作都将修改写入硬盘, 而是会将结果先缓存到内存之中, 在某个时机再整页的写入数据. 由于数据写入操作的滞后性, 存在数据修改后还未写入之前, 数据库发生崩溃的可能性. MySQL使用redo日志来解决这一问题.
当事务提交时, MySQL并不会将数据的修改立刻写入硬盘, 但会将相应的redo日志立即写入硬盘. 由于写日志操作是顺序IO, 因此相比于更随机的写入页面, 写入日志的性能更高. 当数据库发生崩溃时, 数据库可以根据redo日志恢复已经提交的事务, 从而保证事务的一致性和持久性.
这里可以想到, 事务提交后, 由于修改了数据但未写入磁盘, 因此在内存中存在一个缓冲池(Buffer Pool). 其他事务在请求数据时, 会优先检查内存中是否存在对应的页面, 从而避免读取到硬盘上尚未更新的数据.
redo日志格式
redo日志是一种物理机制, 其中并不是记录相关的SQL语句, 而是记录了页面修改情况的详细信息. redo日志的行格式中包含很多类型, 有的类型指示简单的记录那个页面的那个偏移位置有修改, 有的类型就会记录一些更复杂的信息.
一条插入语句可能只需要在某个位置加入一个记录, 因此只需要一条redo日志即可. 但在另外一些情况下, 插入数据可能导致页面分裂, 进而引发一系列的修改, 就可能产生数十条redo日志. 因此实际写入redo日志的过程也需要保证原子性, 例如上述的数十条redo日志, 要保证要么不写入, 要么全部写入, 否则就可能导致数据恢复到不正确的中间位置.
MySQL中将底层的一次原子操作称为一个Mini-Transaction(MTR), 例如前面提到了插入数据后导致页面分裂的一系列操作就是一个MTR. redo日志的写入过程以MTR为基本单位.
redo日志写入过程
redo日志也存在缓冲区, 其中按照512字节分割为不同的block. 在事务的执行过程中, 会不断的产生redo日志, 这些redo日志先按照组存在在某个地方, 等一组任务完成以后再写入到redo日志的缓冲区中.
缓冲区内的redo日志在如下的一些时机会被刷入硬盘: 缓冲区空间不足, 事务提交, 后台专门刷新的线程, 关闭服务器, 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 | |-----------------------| |-------------| |
UPDATE操作的undo日志
UPDATE操作的undo日志可以分为两个大类,不更新主键的UPDATE和更新主键的UPDATE。对于不更新主键的UPDATE操作又可以分为两种,可以原地更新的操作和不可原地更新的操作。
对于不修改主键的UPDATE操作,如果需要修改的数据占用的空间没有超过原本的记录的空间,则此数据可以原地更新。否则需要先删除原本的记录,然后插入一条新的记录。此时的删除操作是立即生效的,原本的位置立刻可以用于写入新的数据。
对于需要修改主键的UPDATE操作,由于修改主键会导致数据存储位置的变化,进而导致数据可能跨页移动,因此先原地标记数据删除,等事务提交后再删除数据。
最后更新: 2025年06月14日 13:08
版权声明:本文为原创文章,转载请注明出处
原始链接: 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/