文章目录
- 关于事务
- ACID 特性
- undo log
- redo log
- redo log 的写入过程
- 事务的执行过程
- binlog
- 细节总结
- 数据迁移
- 数据备份工具
- innodb_autoinc_lock_mode
关于事务
事务(transaction)是作为一个单元的一组有序的数据库操作。如果组中的所有操作都成功,则认为事务成功,即使只有一个操作失败,事务也不成功。如果所有操作完成,事务则提交,其修改将作用于所有其他数据库进程。如果一个操作失败,则事务将回滚,该事务所有操作的影响都将取消。
ACID 特性
事务的 ACID 特性是指:原子性(Atomicity)、一致性 (Consistency)、隔离性(Isolation)、持久性(Durability)。
- 原子性:事务是一个不可分割的整体,它在执行过程中不能被中断或推迟,它的所有操作都必须一次性执行,要么都成功,要么都失败。
- 一致性:事务执行的结果必须满足数据约束条件,不会出现矛盾的结果。注意这里的一致性和我们讨论的分布式环境下的一致性语义有所差别,后者强调的是不同数据源之间数据一致。
- 隔离性:事务在执行的时候可以隔离其他事务的干扰,也就是不同事务之间不会相互影响。
- 持久性:事务执行的结果必须保证在数据库里永久保存,即使系统出现故障或者数据库被删除,事务的结果也不会丢失。
undo log
undo log 是指回滚日志,它记录着事务执行过程中被修改的数据。当事务回滚的时候,InnoDB 会根据 undo log 里的数据撤销事务的更改,把数据库恢复到原来的状态。
既然 undo log 是用来回滚的,那么不同的语句对应的 undo log 形态会不一样。
- 对于 INSERT 来说,对应的 undo log 应该是 DELETE。undo log 记录了该行的主键,后续只需要根据 undo log 里面的主键去原本的聚簇索引里面删掉记录,就可以实现回滚效果。
- 对于 DELETE 来说,对应的 undo log 应该是 INSERT。undo log 记录了该行的主键,因为在事务执行 DELETE 的时候,实际上并没有真的把记录删除,只是把原记录的删除标记位设置成了 true。所以这里 undo log 记录了主键之后,在回滚的时候就可以根据 undo log 找到原本的记录,然后再把删除标记位设置成 false。
- 对于 UPDATE 来说,对应的 undo log 也应该是 UPDATE。比如说有一个数据的值原本是 3,要把它更新成5。那么对应的 undo log 就是把数据更新回 3。实际情况稍微复杂一些,基本可以分为以下两种情况:
- 如果没有更新主键,那么 undo log 里面就记录原记录的主键和被修改的列的原值。
- 如果更新了主键,那么可以看作是删除了原本的行,然后插入了一个新行。因此 undo log 可以看作是一个 DELETE 原数据的 undo log 再加上插入一个新行的 undo log。
因此,可以看出来:索引、数据、版本链和 undo log有着密切的关系,示意图如下:
redo log
MySQL的 InnoDB 引擎在数据库发生更改的时候,把更改操作记录在 redo log 里,以便在数据库发生崩溃或出现其他问题的时候,能够通过 redo log 来重做。
你可能会觉得奇怪,InnoDB 引擎不是直接修改了数据吗?为什么需要 redo log?
InnoDB 引擎读写数据的时候,并不是直接操作磁盘的,分为两个步骤:先是读写内存里的 buffer pool,后面再把buffer pool里面修改过的数据刷新到磁盘里面。因此,就可能会出现 buffer pool中的数据修改了,然后突然数据库崩溃了,但是此时数据还没来得及刷新到磁盘。
为了解决这个问题,InnoDB 引擎就引入了 redo log。从流程上来看,相当于InnoDB 先把 buffer pool 里面的数据更新了,再写一份 redo log;等到事务结束之后,就把buffer pool的数据刷新到磁盘里面;万一事务提交了,但是 buffer pool 的数据没写回去,就可以用 redo log 来恢复。
那么问题又来了:redo log 不需要写磁盘吗?如果 redo log 也要写磁盘,干嘛不直接修改数据呢?
redo log 是需要写磁盘的,但是 redo log 是 顺序写 的,也就是 WAL(write-ahead-log) 的一种。不管你要修改的数据在哪个位置以及是否连续,redo log 在磁盘上都是紧挨在一起的。示意图如下:
由于 顺序写的性能比随机写要好很多,这也是中间件设计里常用的技巧——顺序写取代随机写。
redo log 的写入过程
再考虑一个问题:redo log 是直接一步到位写到磁盘的吗?
答案是:并不是。redo log 写入磁盘也需要两步:
- (1) 先写进 redo log buffer;
- (2) 后面再刷新到操作系统的 page cache,或者刷新到磁盘。
InnoDB 引擎本身提供了参数 innodb_flush_log_at_trx_commit
来控制写到磁盘的时机,里面有三个不同的值:
- 0:每秒刷新到磁盘,是从 redo log buffer 到磁盘。
- 1:每次提交的时候刷新到磁盘上,也就是最安全的选项,这个是 InnoDB 的 默认值。
- 2:每次提交的时候刷新到 page cache 里,依赖于操作系统后续刷新到磁盘。
所以,除非把 innodb_flush_log_at_trx_commit
设置成 1,否则其他两个都有丢失的风险。
- 0:数据提交之后,InnoDB 还没把 redo log buffer 中的数据刷新到磁盘,就宕机了。
- 2:数据提交之后,InnoDB 把 redo log 刷新到了 page cache 里面,紧接着宕机了。
在这两个场景下, 看上去事务是提交成功了,但是数据库实际上丢失了这个事务。但是并不是说 InnoDB 引擎会严格遵循参数说明的那样来刷新磁盘,还有两种例外情况:
- 如果 redo log buffer 快要满了,也会触发把redo log刷新到磁盘里这个动作。一般来说,默认放一半了就会刷新。
- 如果某个事务提交的时候触发了刷新到磁盘的动作,那么当下所有事务的 redo log 也会一并刷新。毕竟大家的 redo log 都是放在 redo log buffer里,有人需要刷新了,就顺手一起刷新了。(类似于你去买咖啡,顺手帮你同事也带一杯。)
以上就是 undo log 和 redo log 的用法和机制,那么,InnoDB 是如何通过这样的机制来实现事务的?
事务的执行过程
为了更加便于理解,这里我用一个 UPDATE 语句执行的例子来向你介绍MySQL中 InnoDB 引擎的事务执行过程。假如说原本 a = 3,现在要执行 UPDATE table_name SET a = 5 WHERE id = 1
,这个事务的执行过程大致可以分为下面的步骤:
-
事务开始,在执行 UPDATE 语句之前会先查找到目标行,加上锁,然后写入到 buffer pool 里面。
-
写入 undo log。
-
更新buffer pool:InnoDB 引擎在内存中更新数据值,实际上就是把 buffer pool 的值更新为目标值 5。
-
写入 redo log。
-
提交事务,根据
innodb_flush_log_at_trx_commit
的参数设置,决定是否刷新 redo log。如果是,则此时a=5,否则此时a=3。
-
刷新 buffer pool 到磁盘:此时 a=5。
上面是正常情况下的流程,还有一些特殊情况:
- 如果在 redo log 已经刷新到磁盘,然后数据库宕机了,buffer pool 丢失了修改,那么在 MySQL 重启之后就会回放这个 redo log,从而纠正数据库里的数据。
- 如果都没有提交,中途回滚,就可以利用 undo log 去修复 buffer pool 和磁盘上的数据。因为有时候 buffer pool 的脏页会在事务提交前刷新磁盘,所以 undo log 也可以用来修复磁盘数据。
实际上,事务执行过程有很多细节,也要比这里描述得复杂很多。这里描述的也只是一个便于理解的简化版的流程。
binlog
binlog 和 redo log、undo log 看起来有点像,但实际上它们之间的差异确很大。binlog 是用于存储 MySQL 中二进制日志(Binary Log)的操作日志文件,它是 MySQL Server 级别的日志,也就是说所有引擎都有。 它记录了MySQL 中数据库的增删改操作,因此 binlog 主要有两个用途:一是在数据库出现故障时恢复数据;二是用于主从同步。
在事务执行过程中,写入 binlog 的时机 和 redo log 的提交过程结合在一起,被称为「MySQL的两阶段提交」。
- 第一阶段:redo log Prepare(准备),然后写入 binlog;
- 第二阶段:redo log Commit(提交)。
如果 redo log Prepare 执行完毕了,并且 binlog 已经写入成功了,如果此时 redo log 提交失败,MySQL 也会认为事务已经提交了。如果 binlog 写入失败,那么 MySQL 就认为提交失败了。所以,这里的关键信息在于看 binlog 是否写入成功。
由于 binlog 本身有一些完整性校验的规则,所以在 MySQL 看来,binlog 要么写入成功,要么写入失败,不存在中间状态。
binlog 也有刷新磁盘的时机设定,可以通过 sync_binlog
参数值来控制:
- 0:默认值,由操作系统决定,写入 page cache 就认为成功了。这个时候数据库的性能最好。
- N:每 N 次提交就刷新到磁盘,N 越小性能越差。如果 N = 1,那么就是每次事务提交都把 binlog 刷新到磁盘。
细节总结
由于事务的机制比较复杂,涉及 redo log 和 undo log 的各种配合,所以需要考虑到事务执行过程的各种异常情况。当中途某个操作执行成功了,万一数据库宕机,数据库恢复过来之后会怎么处理这个事务。可以简单总结如下:
- 在 redo log 刷新到磁盘之前,都是回滚。
- 如果 redo log 刷新到了磁盘,那么就是重放 redo log。
- 如果 binlog 都已经提交成功了,那么就重放,否则就是回滚。
- 如果回滚,用 undo log 来恢复数据。
- 如果没有 undo log 就没有后悔药,没有办法回滚。
- 如果没有 redo log,写到 buffer pool后,如果宕机了就会丢失数据。
- 为什么非得引入 redo log,是否可以直接修改数据?—— 直接修改数据就是随机写,性能很差。
- 如果是要求数据不丢失、一致性要求高的业务,可以考虑调整
sync_binlog 的值为 1
。但是代价就是数据库的性能比较差,因为每次提交都需要刷新 binlog 到磁盘上。当然这时候innodb_flush_log_at_trx_commit
也要使用默认值 1。 - 如果是对性能要求很高,但是可以容忍一部分数据丢失的业务,可以尝试将
innodb_flush_log_at_trx_commit
设置为 2,让操作系统来决定什么时候刷新 redo log。同时还可以把sync_binlog
的值调整为 100,进一步提高数据库的性能。
更多参考:《深入理解MySQL中的事务和锁》
数据迁移
如果公司有一个历史悠久的核心系统,可能已经无法维护了,但不管是重构还是重新写一个类似的系统,已有的数据都是不能丢的。这个时候就需要重新设计表结构,并且完成数据迁移。或者,针对原有业务中的单库单表拆分多库多表,也需要设计一个合理的数据迁移方案,把数据从单库迁移到分库分表上。
因此,一个高效、稳定的数据迁移方案是很重要的。
一般情况下,数据迁移的基本步骤大概类似下面这些:
- 创建目标数据表(新表)。
- 用旧数据表的数据初始化新表。
- 执行一次校验,并且修复数据,此时用旧表数据修复目标表数据。
- 业务代码开启双写,先写旧表,再写新表,数据以旧表为准。读取旧表。
- 开启增量校验和数据修复,保持一段时间。
- 切换双写顺序,此时读新表,并且修改业务逻辑为:先写新表,再写旧表,数据以新表为准。
- 继续保持增量校验和数据修复。
- 切换业务逻辑为新表单写,读写都只操作新表。
- 根据自己业务需要考虑旧表是否继续保留,或者删除旧表。
上面提到的「初始化新表」,常规操作思路就是使用旧表的历史备份或者导出旧表的数据,比如使用 mysqldump 可以导出旧表的数据字段结构和数据值。
mysqldump 是一个开源的逻辑备份工具,优点是使用简单,能够直接导出整个数据库。缺点则是导出和导入的速度都比较慢,尤其是在数据量非常大的情况下。针对 mysqldump 可以做一些优化来提高导出和导入的性能,比如:
- 开启 extended-insert 选项,将多行合并为一个 INSERT 语句。
- 关闭唯一性检查和外键检查,源表已经保证了这两项,所以目标表并不需要检查。
- 关闭 binlog,毕竟导入数据用不着 binlog。
- 调整 redo log 的刷盘时机,把 innodb_flush_log_at_trx_commit 设置为 0。
数据备份工具
MySQL中 常用的两款数据备份工具:mysqldump 和 XtraBackup。
- mysqldump:用于备份和恢复 MySQL 数据库的命令行工具,它允许用户导出 MySQL 数据库的结构、数据以及表之间的关系,以便在数据库发生问题时进行恢复。它是一个逻辑备份工具,导出的内容是一条一条的 SQL语句。
- XtraBackup:使用 InnoDB 存储引擎的数据备份技术,支持增量备份和恢复,并且支持多主机备份和恢复。它是一个物理备份工具,相当于直接复制 InnoDB 的底层存储文件。
innodb_autoinc_lock_mode
在数据迁移的时候需要考虑如何处理主键问题,innodb_autoinc_lock_mode
参数的值会影响主键的生成策略。
innodb_autoinc_lock_mode
是 InnoDB 引擎里面控制自增主键生成策略的参数,它有三个取值。
- 0:使用表自增锁,但是锁在 INSERT 语句结束之后就释放了。
- 1:使用表自增锁,如果是普通的
INSERT INTO VALUE
或者INSERT INTO VALUES
语句申请了主键就释放锁,而不是整个INSERT
语句执行完毕才释放。如果是INSERT SELECT
等语句,因为无法确定究竟要插入多少行,所以都是整个 INSERT 语句执行完毕才释放。在执行INSERT INTO VALUES
的时候,不管插入多少行,都只申请一次主键,一次申请够,这些主键 必然是连续的。所以你可以从返回的最后一个 ID 推测出全部 ID。 - 2:使用表自增锁,所有的语句都是申请了主键就立刻释放。
在处理批量插入的时候要注意,如果批量插入用的是 INSERT INTO table_name VALUES (xxx),(xxx),(xxx)
语法,那么生成的主键是连续的,可以从返回的最后一个主键推测出前面其他行的主键。即便此时的 innodb_autoinc_lock_mode
取值是 2 也能保证这一点。但是如果用的是多个 INSERT INTO VALUE
语句,或者 INSERT SELECT
语句,那么这些语句生成的主键就有可能不连续。在双写之前,就要先改造这一类的业务。