掀起你的盖头来之《数据库揭秘》-2-UPDATE操作从开始到完成的完整生命周期

128 阅读14分钟

UPDATE操作从开始到完成的完整生命周期

image.png

数据库连接池、SQL接口、查询解析和优化、二进制日志(binlog)、存储过程、触发器、视图和一些管理服务等功能都是由MySQL服务器层负责的

第一部分:从 UPDATE 到内存修改 —— 事务的执行阶段

这个阶段的核心任务是:找到数据,锁住它,然后在内存里把它改掉,并记录下“我准备怎么改”和“改之前的样子”。

具体的例子:

START TRANSACTION;
UPDATE users SET age = 31 WHERE id = 10;

步骤 0 & 1:开启事务 (START TRANSACTION)

  • 你做了什么: 在Go代码里,你通过database/sql包执行了db.Begin(),或者你的ORM框架帮你做了这件事。

  • InnoDB做了什么:

    1. 创建事务对象: InnoDB在内部会创建一个事务对象,记录事务的ID、状态等信息。
    2. 生成ReadView (读视图) : 这是MVCC的关键。InnoDB会为这个事务拍下一个“快照”,这个快照记录了在当前时间点,哪些事务是活跃的(还未提交)。在整个事务(默认RR隔离级别下)期间,所有SELECT操作都会用这个快照去判断数据的哪个版本对它是可见的,从而实现了“可重复读”。

对Go工程师的意义: 从你执行db.Begin()的那一刻起,你就获得了一个一致性的数据视图。即使别的事务提交了新数据,在你的这个事务里(大部分情况下)也看不到,这保证了你后续操作的逻辑一致性。

步骤 2:SQL解析、查询计划生成

  • 这是谁做的: 这是MySQL Server层的工作,还轮不到InnoDB出场。

  • 它做了什么:

    1. SQL解析: MySQL Server拿到UPDATE users ...这个字符串,会进行词法、语法解析,判断SQL是否合法,并生成一个“解析树”。
    2. 查询优化: 这是MySQL最“聪明”的部分。优化器会根据表的统计信息,决定如何最高效地执行这条UPDATE。对于我们的例子,它会判断WHERE id = 10应该走主键索引,还是需要全表扫描(当然这里肯定走主键索引)。
    3. 生成执行计划: 最终,优化器会给出一个最佳的执行方案,告诉后续的执行器“该怎么干”。

对Go工程师的意义: 这就是为什么有些SQL快,有些SQL慢的根本原因。你可以通过EXPLAIN命令来查看MySQL为你选择的执行计划,这是你做SQL优化的最强武器。

步骤 3:查询要修改的数据

  • 谁来做: MySQL Server的执行器调用InnoDB存储引擎的接口,InnoDB正式开始干活。

  • InnoDB做了什么:

    1. 索引寻址: InnoDB会利用B+树索引,从根节点开始,一层层地找到id = 10这条记录所在的数据页 (Page)
    2. 加载数据页到Buffer Pool: 如果这个数据页恰好在内存的缓冲池 (Buffer Pool) 里,那就直接使用;如果不在,InnoDB会从磁盘把它读取到Buffer Pool中。注意:InnoDB所有操作都是在内存中的Buffer Pool里进行的,而不是直接修改磁盘。

对Go工程师的意义: Buffer Pool是InnoDB性能的命脉。一个配置合理的Buffer Pool可以极大减少磁盘I/O,提升数据库性能。所谓的“热数据”就是指经常被访问,一直待在Buffer Pool里的数据。

步骤 4:校验锁和加锁

  • 为什么需要: 为了保证并发安全。想象一下,如果两个事务同时UPDATE同一行数据,不加锁就会导致数据错乱。

  • InnoDB做了什么:

    1. 加锁: InnoDB会在id = 10这行记录上,加上一个X锁 (排他锁 / Exclusive Lock)
    2. 锁的规则: 一旦加上了X锁,其他任何事务都不能再对这行数据加任何锁(无论是读锁还是写锁),直到当前事务提交或回滚。如果别的事务也想修改这行,就必须等待。

对Go工程师的意义: 这是你遇到“数据库死锁”和“请求超时”的根源。当你的高并发业务逻辑中,多个事务以不同的顺序去锁住多个资源时,就可能产生死锁。理解行锁机制,是解决这类并发问题的基础。

步骤 5:修改数据和生成日志

这是整个执行阶段最核心、最复杂的一步,InnoDB会同时做三件事:

  1. 生成Undo Log (回滚日志) :

    • 作用: 为了实现事务的原子性 (Atomicity)MVCC
    • 内容: InnoDB会把age字段修改前的值(比如是30)记录到一个Undo Log里。这个日志的作用有两个:① 如果事务需要回滚(ROLLBACK),InnoDB可以根据Undo Log把数据恢复到修改前的状态;② 对于其他事务,如果它们需要读取这行数据的旧版本,也可以通过Undo Log找到。
  2. 修改内存中的数据:

    • 动作: InnoDB现在可以放心地修改Buffer Pool中那个数据页上的数据了,把age字段的值从30改为31。
    • 状态: 这个被修改过但尚未刷到磁盘的数据页,现在被称为“脏页 (Dirty Page) ”。
  3. 生成Redo Log (重做日志) :

    • 作用: 为了实现事务的持久性 (Durability)崩溃恢复
    • 内容: InnoDB会记录一条“物理日志”,内容大致是:“在某个表空间的某个数据页的某个偏移量位置,把值从30修改为31”。这条日志被写入内存中的Redo Log Buffer
    • 核心思想 (WAL) : 这是Write-Ahead Logging (预写日志) 思想的体现。修改数据必须先记日志,这样即使在脏页刷盘前数据库崩溃了,重启后也可以通过Redo Log来恢复数据,保证了事务的持久性。

对Go工程师的意义: 你需要明白,你的UPDATE语句执行到这里,其实只是修改了内存。数据的真正持久化,依赖于后续的COMMIT操作中对Redo Log的处理。Redo Log的顺序写特性,是MySQL实现高性能写入的关键。

第二部分:COMMIT 的“一瞬间” —— 事务的提交与持久化保证 (步骤6-8)

这个阶段的核心任务是:以最高效、最安全的方式,让你在内存中的修改“固化”下来,并通知整个集群(如果有的话),确保数据在任何意外情况下都不会丢失。

我们继续之前的例子,你现在执行了 tx.Commit()。

步骤 6:本地提交

这绝不是一个简单的“确认”按钮。在 InnoDB 内部,为了同时保证一致性 (Redo Log 和 Binlog 的数据要一致) 和持久性,它执行了一个精妙的内部“二阶段提交 (Two-Phase Commit, 2PC) ”。

为什么需要二阶段提交?

因为 MySQL 有两个重要的日志:

  • Redo Log: InnoDB 存储引擎层的日志,保证崩溃后能恢复数据页,是物理日志。
  • Binlog (Binary Log) : MySQL Server 层的日志,用于主从复制数据恢复,记录的是SQL语句或行的变化,是逻辑日志。

必须保证这两个日志要么都写入成功,要么都失败。否则,如果 Redo Log 写成功了(主库数据已持久化),但 Binlog 没写成功(从库没收到变更),就会导致主从数据不一致。

二阶段提交的流程:

  1. 阶段一:Redo Log prepare 状态

    • InnoDB 将之前在 Redo Log Buffer 中的日志刷入磁盘的 Redo Log 文件中。
    • 刷盘后,它在 Redo Log 中记录一个“prepare”标记,并带上当前事务的标识信息(XID)。
    • 关键点: 此时,事务在 InnoDB 层面已经“准备好”提交了,即使现在崩溃,重启后看到这个 prepare 状态的日志,InnoDB 也会去检查 Binlog 是否完整,再决定是提交还是回滚,从而保证了一致性。
  2. 阶段二:写入 Binlog 并提交 Redo Log

    • MySQL Server 层接收到 InnoDB 的 prepare成功的信号后,开始将该事务的 Binlog 内容写入磁盘的 Binlog 文件中。
    • Binlog 写入成功后,MySQL Server 会告诉 InnoDB:“可以正式提交了!”
    • InnoDB 收到这个通知后,把 Redo Log 中刚才那条日志的状态从 prepare 改为 commit。这个 commit 标记的写入非常轻量,通常只是修改一个标志位。

到此,“本地提交”这个动作才算真正完成。

对Go工程师的意义: 你要知道,一次COMMIT至少涉及两次磁盘 I/O(一次刷 Redo Log prepare,一次刷 Binlog)。这就是为什么说“小事务,勤提交”有时并不是最佳实践,因为每次提交的成本并不低。将多个操作合并到一个事务里,可以有效减少这种 I/O 开销。

步骤 7:主备复制

在 Binlog 写入成功后,主从复制的机制就开始工作了。

  • 谁来做: 主库上有一个专门的线程叫 Log Dump 线程

  • 它做了什么:

    1. Log Dump 线程发现 Binlog 文件有了新内容,就会把它读取出来。
    2. 它通过网络,将这些新的 Binlog 事件发送给所有连接到它的从库。
    3. 从库上的 I/O 线程 接收到这些事件,并把它们写入从库自己的一个叫 Relay Log (中继日志) 的文件中。
    4. 从库上的 SQL 线程 会读取 Relay Log,并在从库上重放 (Replay) 这些SQL操作,从而实现与主库的数据同步。
  • 复制模式的影响:

    • 异步复制 (默认) : 主库写入 Binlog 后,不等从库响应,直接就认为自己成功了。这种模式性能最好,但如果主库刚提交完就宕机,数据可能还没来得及传到从库,会造成数据丢失。
    • 半同步复制 (Semi-Sync) : 为了数据安全,通常会配置成半同步。主库写入 Binlog 后,会等待至少一个从库确认“我已收到并写入 Relay Log”,然后才认为自己提交成功。

对Go工程师的意义: 启用半同步复制,会增加你 tx.Commit() 的响应时间,因为这其中包含了一次网络来回的时间。在设计对延迟敏感的业务时,必须考虑到这一点。这是用延迟换取数据高可用的典型权衡。

步骤 8:返回提交成功

这是整个流程对应用层的“交代”。

  • 何时发生:

    • 异步复制模式下,只要步骤6(本地二阶段提交)完成,MySQL 就会向客户端返回“提交成功”。
    • 半同步复制模式下,必须等到步骤7中从库的确认信号回来后,MySQL 才会向客户端返回“提交成功”。

此时,在你的 Go 程序里,tx.Commit() 这个函数调用终于执行完毕,不再阻塞,你可以继续执行后续代码。你收到的这个“成功”信号,是一个非常坚实的保证:你的数据修改已经基于你的配置(单机持久化或主从高可用),被安全地记录下来了

第三部分:幕后工作 —— 数据的最终落盘 (步骤9)

这个阶段的核心任务是:在不影响数据库正常服务的前提下,悄悄地、高效地将内存中被修改过的数据(脏页),同步回磁盘上的物理数据文件中,完成真正意义上的“数据落地”。

步骤 9:脏页刷入磁盘 (Flush)

这个过程不是在 COMMIT 时同步发生的,而是一个异步的后台操作。

为什么是异步的?

这是 InnoDB 一个极其重要的性能优化设计,被称为 “延迟写入”(Deferred Write)

想象一下,如果每次 COMMIT 都必须等待数据页从内存刷到磁盘,会发生什么?

  1. 性能雪崩: 磁盘 I/O,尤其是随机写,是非常慢的。如果每个事务都同步等待这个操作,数据库的吞吐量 (TPS) 会低得无法接受。
  2. 浪费 I/O: 假设你在一秒内,对 id=10 这条记录所在的那个数据页,做了 100 次修改。如果每次都刷盘,你就要做 100 次磁盘 I/O。但如果 InnoDB “懒”一点,等到一秒钟后再把这个页的最终状态一次性刷回磁盘,就只用了 1 次 I/O。

所以,InnoDB 的策略是:我先通过 Redo Log 保证了你的数据持久性,拿到了“免死金牌”,然后就可以从容不迫地在后台慢慢把数据文件同步好。

谁来做刷脏页的工作?

InnoDB 有专门的后台线程 (Background Threads) 来负责这个任务,主要是 Master Thread 和 Page Cleaner Threads。它们会根据一系列复杂的规则,决定何时以及如何刷写脏页。

触发脏页刷盘的几种典型场景:

  1. Redo Log 写满了:

    • Redo Log 文件的大小是固定的,并且是循环写入的。当 InnoDB 发现 Redo Log 快要写满,下一个事务的日志没地方放了,它就必须强制停下来,把一部分脏页刷回磁盘。
    • 这个刷盘动作会推进一个叫做 checkpoint 的标记,checkpoint 之前的 Redo Log 对应的脏页都已落盘,这部分空间就可以被覆盖重用了。
    • 这是一个关键的性能平衡点:Redo Log 太小,会导致频繁刷脏页,影响性能;太大,则意味着崩溃恢复时需要扫描和重做的日志更多,恢复时间更长。
  2. Buffer Pool 空间不足:

    • 当有新的数据页需要从磁盘加载到 Buffer Pool,但 Buffer Pool 已经满了,InnoDB 就需要淘汰一些旧的数据页来腾出空间。
    • 如果它准备淘汰的恰好是一个“脏页”,那么在淘汰它之前,必须先把它刷回磁盘,否则上面的修改就丢失了。
  3. MySQL 正常关闭:

    • 当数据库被正常关闭时,InnoDB 会执行一次刷盘操作,确保所有脏页都回写到磁盘,保证数据文件的完整性。
  4. 空闲时自动刷盘:

    • 在数据库不那么繁忙的时候,后台线程也会见缝插针地、平稳地刷写一部分脏页,以减轻未来高峰期刷盘的压力。

对Go工程师的意义: 你不需要直接控制这个过程,但需要理解它的存在。这能帮助你解释一些现象:

  • 为什么有时候数据库看起来不忙,但磁盘 I/O 却很高? -> 很可能就是后台线程在默默地刷脏页。
  • 为什么一次大的 DELETE 或 UPDATE 操作后,即使事务提交了,数据库的 I/O 压力还会持续一段时间? -> 因为大量的数据页变成了脏页,后台线程需要时间去“消化”它们。

完整流程总结:一次 UPDATE 的旅程

现在,我们把这三个部分串起来,完整地回顾一下 id=10 这行记录 age 字段从 30 变为 31 的史诗级旅程:

  1. 执行阶段 (内存操作) : 你的 UPDATE 语句被解析、优化。InnoDB 在 Buffer Pool 中找到(或从磁盘加载)数据页,加上 X 锁,记录 Undo Log,修改内存中的数据页(使其成为脏页),并把这个修改操作写入内存的 Redo Log Buffer。

    • 产物: 内存数据已变,日志已备好。
  2. 提交阶段 (持久化承诺) : 你执行 COMMIT。InnoDB 启动二阶段提交

    • 先把 Redo Log 刷到磁盘并标记为 prepare。
    • 再把 Binlog 刷到磁盘。
    • 最后把 Redo Log 标记为 commit。
    • (如果是高可用架构) Binlog 被发送给从库。
    • 产物: 返回给你的“成功”回执,代表着一个基于日志的、不可丢失的持久化承诺
  3. 落盘阶段 (异步落地) : 在未来的某个时刻,当满足刷盘条件时(如 Redo Log 将满、Buffer Pool 不足等),InnoDB 的后台线程会找到那个被你修改过的脏页,将它的最终内容(age=31)写回到磁盘上的 .ibd 数据文件中。

    • 产物: 磁盘上的物理数据文件,终于和你在内存中所做的修改完全一致

至此,一次更新操作的生命周期才算真正画上了句号。