MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

MySQL 中 ACID 特性的底层实现原理

2024-03-176.4k 阅读

MySQL 的事务与 ACID 特性概述

在数据库系统中,事务(Transaction)是一个不可分割的工作逻辑单元,它包含了一系列对数据库的操作,这些操作要么全部成功执行,要么全部不执行。MySQL 作为一款广泛使用的关系型数据库管理系统,通过实现 ACID 特性来确保事务的可靠性和一致性。ACID 是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)这四个特性的首字母缩写。

  • 原子性(Atomicity):事务中的所有操作要么全部成功提交,要么全部失败回滚,就好像事务是一个原子操作一样,不存在部分成功的情况。例如,在一个银行转账事务中,从账户 A 向账户 B 转账 100 元,这个事务包含两个操作:从账户 A 减去 100 元,向账户 B 加上 100 元。原子性确保这两个操作要么都执行,要么都不执行,不会出现账户 A 减少了 100 元而账户 B 未增加 100 元的情况。
  • 一致性(Consistency):事务执行前后,数据库的完整性约束(如主键约束、外键约束、check 约束等)必须得到保持。也就是说,事务将数据库从一个合法的状态转换到另一个合法的状态。继续以银行转账为例,转账前后,系统中的总金额应该保持不变,这就是一种一致性的体现。
  • 隔离性(Isolation):多个并发事务之间相互隔离,一个事务的执行不应该受到其他并发事务的干扰。每个事务在执行过程中,就好像是在独占数据库一样,不会看到其他并发事务未提交的修改。例如,事务 T1 和事务 T2 同时对账户 A 进行操作,事务 T1 未提交的修改不应该被事务 T2 看到,这样可以避免数据不一致问题,如脏读、不可重复读和幻读等。
  • 持久性(Durability):一旦事务成功提交,其对数据库所做的修改就会永久保存下来,即使系统发生崩溃、断电等故障,这些修改也不会丢失。例如,银行转账事务成功提交后,账户 A 和账户 B 的余额变化就会被永久记录在数据库中,即使数据库服务器随后出现故障重启,转账结果依然有效。

原子性的底层实现原理

MySQL 通过日志系统中的重做日志(Redo Log)来实现事务的原子性。

重做日志(Redo Log)

重做日志是 InnoDB 存储引擎特有的日志,它记录了数据库物理层面的修改操作。当事务执行过程中,InnoDB 会将每个修改操作记录到重做日志中。重做日志采用循环写的方式,空间使用完后会覆盖旧的日志记录。

  1. 记录方式 重做日志记录的是物理层面的修改,例如对某一页数据的修改。假设我们有一个简单的表 user,包含 idname 字段,执行如下 SQL 语句:
UPDATE user SET name = 'new_name' WHERE id = 1;

InnoDB 会将对 user 表中 id 为 1 的那一行数据所在页的修改记录到重做日志中,包括页号、偏移量以及修改后的数据等信息。

  1. 写入时机 重做日志并不是在事务提交时才写入,而是在事务执行过程中逐步写入的。InnoDB 采用了一种叫做 WAL(Write - Ahead Logging)的策略,即先写日志,再写数据。这样做的好处是可以提高事务的执行效率,因为日志的写入通常是顺序写入,而数据的写入可能是随机写入,顺序写入的性能更高。

在事务执行过程中,修改操作会先记录到重做日志缓存(Redo Log Buffer)中,然后根据一定的规则将重做日志缓存中的内容刷新到磁盘上的重做日志文件中。这些规则包括:

  • 每隔一定时间(如 1 秒),将重做日志缓存中的内容刷新到磁盘。
  • 当重做日志缓存使用到一定比例(如 50%)时,将其内容刷新到磁盘。
  • 在事务提交时,将重做日志缓存中的内容刷新到磁盘。
  1. 崩溃恢复(Crash - Recovery) 当数据库发生崩溃时,MySQL 可以利用重做日志进行崩溃恢复。在重启过程中,InnoDB 会从重做日志中读取未完成事务的记录并回滚,同时读取已提交事务的记录并重新应用,从而将数据库恢复到崩溃前的状态。这就确保了已提交事务的修改被永久保存,未提交事务的修改不会对数据库造成影响,实现了事务的原子性。

下面通过一段简单的代码示例来模拟事务操作以及重做日志的作用(这里只是概念性的示例,并非实际的 MySQL 源码实现):

# 模拟数据库页面
class DatabasePage:
    def __init__(self, data):
        self.data = data

    def update(self, new_data):
        self.data = new_data

# 模拟重做日志记录
class RedoLogRecord:
    def __init__(self, page, old_data, new_data):
        self.page = page
        self.old_data = old_data
        self.new_data = new_data

# 模拟重做日志
class RedoLog:
    def __init__(self):
        self.records = []

    def add_record(self, record):
        self.records.append(record)

    def rollback(self):
        for record in reversed(self.records):
            record.page.data = record.old_data

    def recover(self):
        for record in self.records:
            record.page.data = record.new_data

# 模拟事务
class Transaction:
    def __init__(self, redo_log):
        self.redo_log = redo_log
        self.in_progress = False

    def start(self):
        self.in_progress = True

    def commit(self):
        if self.in_progress:
            self.redo_log.recover()
            self.in_progress = False
        else:
            print("Transaction not in progress.")

    def rollback(self):
        if self.in_progress:
            self.redo_log.rollback()
            self.in_progress = False
        else:
            print("Transaction not in progress.")

    def update_page(self, page, new_data):
        if self.in_progress:
            old_data = page.data
            page.update(new_data)
            record = RedoLogRecord(page, old_data, new_data)
            self.redo_log.add_record(record)
        else:
            print("Transaction not in progress.")


# 示例使用
page = DatabasePage("initial data")
redo_log = RedoLog()
transaction = Transaction(redo_log)

transaction.start()
transaction.update_page(page, "new data")
# 模拟系统崩溃
# 这里不进行任何操作,假设系统崩溃
# 重启后恢复
transaction.commit()
print(page.data)

一致性的底层实现原理

一致性的实现依赖于多个方面,包括数据库的完整性约束、事务的原子性、隔离性以及持久性等。

完整性约束检查

  1. 主键约束 MySQL 在创建表时可以定义主键,主键的值必须唯一且不能为空。当执行插入或更新操作时,MySQL 会检查新的值是否违反主键约束。例如:
CREATE TABLE user (
    id INT PRIMARY KEY,
    name VARCHAR(50)
);

当执行 INSERT INTO user (id, name) VALUES (1, 'user1'); 语句时,MySQL 会检查 id 为 1 的记录是否已经存在,如果存在则会抛出主键冲突错误,从而保证了数据的一致性。

  1. 外键约束 外键用于建立两个表之间的关联关系,确保引用的完整性。例如,我们有两个表 orderscustomersorders 表中的 customer_id 字段是 customers 表中 id 字段的外键:
CREATE TABLE customers (
    id INT PRIMARY KEY,
    name VARCHAR(50)
);

CREATE TABLE orders (
    id INT PRIMARY KEY,
    order_number VARCHAR(50),
    customer_id INT,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

当向 orders 表插入一条记录时,MySQL 会检查 customer_id 是否在 customers 表中存在,如果不存在则插入操作失败,以此保证数据的一致性。

  1. Check 约束 Check 约束用于对表中的列进行条件检查。例如:
CREATE TABLE products (
    id INT PRIMARY KEY,
    price DECIMAL(10, 2),
    CHECK (price > 0)
);

当插入或更新 products 表中 price 字段的值时,MySQL 会检查 price 是否大于 0,如果不满足条件则操作失败,确保了数据的一致性。

事务与一致性的关系

事务的原子性、隔离性和持久性为一致性提供了保障。原子性确保事务中的所有操作要么全部成功,要么全部失败,避免了部分操作成功导致数据不一致的情况。隔离性防止并发事务之间的干扰,保证每个事务看到的数据库状态是一致的。持久性使得已提交事务的修改永久保存,确保了数据的一致性在系统故障后依然能够保持。

例如,在一个涉及多个表操作的事务中,假设要插入一条订单记录并更新对应的客户表中的订单数量。如果没有原子性,可能会出现订单插入成功但客户表订单数量未更新的情况,破坏了数据的一致性。如果没有隔离性,并发事务可能会相互干扰,导致数据读取和修改的不一致。如果没有持久性,系统崩溃后已提交事务的修改丢失,同样会破坏数据的一致性。

隔离性的底层实现原理

MySQL 通过锁机制和多版本并发控制(MVCC)来实现事务的隔离性。

锁机制

  1. 共享锁(Shared Lock,S 锁) 共享锁用于读取操作。当一个事务对某一数据对象加共享锁后,其他事务也可以对该数据对象加共享锁,多个事务可以同时读取该数据,但不能对其进行修改。例如,事务 T1 对表 user 中的某一行数据加共享锁进行读取操作:
START TRANSACTION;
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;

此时,其他事务也可以对 id 为 1 的这一行数据加共享锁进行读取,但如果有事务想要对其进行更新操作,必须先等待共享锁释放。

  1. 排他锁(Exclusive Lock,X 锁) 排他锁用于写操作。当一个事务对某一数据对象加排他锁后,其他事务不能再对该数据对象加任何锁,直到排他锁释放。例如,事务 T2 对表 user 中的某一行数据加排他锁进行更新操作:
START TRANSACTION;
UPDATE user SET name = 'new_name' WHERE id = 1 FOR UPDATE;

此时,其他事务无法对 id 为 1 的这一行数据进行任何操作,必须等待排他锁释放。

  1. 锁的粒度 MySQL 中的锁可以分为行级锁、表级锁和页级锁。行级锁的粒度最小,只锁定某一行数据,并发性能高,但开销也较大;表级锁的粒度最大,锁定整个表,并发性能低,但开销较小;页级锁介于两者之间,锁定一页数据。InnoDB 存储引擎默认使用行级锁,MyISAM 存储引擎默认使用表级锁。

多版本并发控制(MVCC)

MVCC 是一种基于多版本的并发控制机制,它通过维护数据的多个版本来实现并发事务之间的隔离。在 InnoDB 中,MVCC 依赖于以下几个关键要素:

  1. 隐藏列 InnoDB 为每一行数据添加了三个隐藏列:DB_TRX_IDDB_ROLL_PTRDB_ROW_IDDB_TRX_ID 记录了最后一次修改该行数据的事务 ID;DB_ROLL_PTR 指向该行数据的回滚段记录,用于在需要时回滚到旧版本;DB_ROW_ID 是行的唯一标识(如果表没有定义主键,InnoDB 会自动生成一个 6 字节的 DB_ROW_ID)。

  2. 回滚段(Rollback Segment) 回滚段用于存储数据的旧版本。当事务对数据进行修改时,InnoDB 会将修改前的数据版本记录到回滚段中,并更新 DB_TRX_IDDB_ROLL_PTR。例如,事务 T 对表 userid 为 1 的记录进行修改,InnoDB 会将修改前的记录保存到回滚段中,并更新 id 为 1 的记录的 DB_TRX_ID 为事务 T 的 ID,DB_ROLL_PTR 指向回滚段中的旧版本记录。

  3. Read View Read View 是 MVCC 实现隔离性的关键。当一个事务开始读取数据时,InnoDB 会生成一个 Read View,该 Read View 记录了当前系统中活跃的事务 ID 列表。在读取数据时,InnoDB 根据 Read View 和数据的 DB_TRX_ID 来判断应该读取哪个版本的数据。如果数据的 DB_TRX_ID 小于 Read View 中最小的活跃事务 ID,说明该数据的修改在当前事务开始之前就已经提交,当前事务可以读取该版本的数据。如果数据的 DB_TRX_ID 大于 Read View 中最大的活跃事务 ID,说明该数据的修改是在当前事务开始之后进行的,当前事务不能读取该版本的数据。如果数据的 DB_TRX_ID 在 Read View 的活跃事务 ID 列表中,说明该数据的修改事务还未提交,当前事务也不能读取该版本的数据,而是需要根据 DB_ROLL_PTR 到回滚段中查找合适的旧版本数据。

通过锁机制和 MVCC,MySQL 可以实现不同的隔离级别,如读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。不同的隔离级别在并发性能和数据一致性之间进行了不同的权衡。

持久性的底层实现原理

MySQL 通过将数据和日志持久化到磁盘来实现事务的持久性。

数据持久化

InnoDB 采用缓冲池(Buffer Pool)机制来管理数据的读写。当需要读取数据时,首先从缓冲池中查找,如果没有找到则从磁盘读取并放入缓冲池。当数据被修改时,先在缓冲池中进行修改,然后根据一定的策略将修改后的数据刷新到磁盘。

  1. Flush 操作 InnoDB 会定期将缓冲池中修改过的数据页(Dirty Page)刷新到磁盘。刷新操作的触发条件包括:
  • 缓冲池中脏页的数量达到一定比例(如 75%)。
  • 后台线程定期执行刷新操作。
  • 数据库关闭时,会将所有脏页刷新到磁盘。
  1. Doublewrite Buffer 为了保证数据的完整性,InnoDB 引入了 Doublewrite Buffer。当将脏页从缓冲池刷新到磁盘时,首先将脏页写入 Doublewrite Buffer,这是一个内存区域,大小通常为 2MB。然后分两次将 Doublewrite Buffer 中的数据写入磁盘的共享表空间(ibdata 文件)的 Doublewrite 区域,每次写入 1MB。只有当 Doublewrite 区域写入成功后,才会将脏页写入实际的数据文件。如果在写入数据文件过程中发生崩溃,可以从 Doublewrite 区域恢复数据,确保数据的完整性。

日志持久化

重做日志的持久化确保了已提交事务的修改不会丢失。如前文所述,重做日志采用 WAL 策略,先写日志,再写数据。在事务提交时,会将重做日志缓存中的内容刷新到磁盘上的重做日志文件中,并且会等待磁盘 I/O 操作完成,以确保日志被持久化。

MySQL 通过将数据和日志持久化到磁盘,以及采用 Doublewrite Buffer 等机制,保证了事务的持久性,即使系统发生崩溃,已提交事务的修改依然可以通过重做日志和 Doublewrite Buffer 进行恢复。

通过对 MySQL 中 ACID 特性底层实现原理的深入分析,我们了解到 MySQL 如何通过重做日志、完整性约束、锁机制、MVCC 以及数据和日志持久化等技术来确保事务的可靠性和一致性。这些技术相互配合,使得 MySQL 能够在高并发环境下有效地处理事务,为用户提供可靠的数据存储和管理服务。