跳转至

MySQL

索引

索引类似于字典的目录,是空间换时间的思想。

  • 聚簇索引(InnoDB 默认):叶子节点直接存储索引值与完整行数据,找到索引即拿到数据。每张表只能有一个聚簇索引,通常就是主键索引。
  • 非聚簇索引(二级索引):叶子节点存储索引值与对应的主键值(而非行数据),如普通索引、唯一索引、前缀索引。查询时需先从二级索引找到主键,再回表查聚簇索引获取完整数据。若查询所需字段恰好都包含在索引中(覆盖索引),则可避免回表。

最左匹配失效的情况:

  • 使用 !=, or等
  • like '%...
  • 字符串不加引号
  • join 字段类型不同,造成隐式转换

常见用于搜索的数据结构:哈希表、平衡二叉树、红黑树、B 树、B+ 树,MySQL InnoDB 采用 B+ 树作为索引,是因为:

  1. 磁盘 IO 友好:B+ 树的非叶子节点只存储索引和指针(叶子节点存放索引与行数据),单个节点能容纳更多索引项,树的高度更低(通常 3~4 层即可覆盖千万级数据),每次查询所需的磁盘 IO 次数更少
  2. 天然支持范围查询:B+ 树的叶子节点通过双向链表连接,范围查询(BETWEEN、>、ORDER BY 等)只需定位到起始叶子节点后顺序遍历,效率远高于 B 树需要中序遍历整棵树
  3. 查询性能稳定:所有数据都存储在叶子节点,任何查询都要走从根到叶子的完整路径,时间复杂度稳定为 O(log n);而 B 树的数据分布在所有节点,不同记录的查询深度不一致
  4. 相比哈希索引:哈希索引虽然等值查询 O(1),但不支持范围查询和排序,也无法利用最左前缀匹配
  5. 相比跳表:跳表在内存中表现优异(如 Redis),但其指针结构不利于磁盘顺序读取,无法充分利用操作系统的预读机制
  6. 相比红黑树:每个节点只能有两个子节点,存储大量数据时树的高度会很高,这会导致多次的 I/O,红黑树适合存储少量数据的内存操作
  7. 相比 B 树:B 树的非叶子节点也存储数据,且叶子节点没有指针,范围查询效率低于 B+ 树

页分裂(Page Split)

InnoDB 以**页(Page,默认 16KB)**为最小 IO 单位,每个 B+ 树叶子节点对应一个数据页,页内记录按主键顺序排列。

当向一个已满的数据页中插入新记录时,InnoDB 会将该页的一部分数据搬移到新分配的页中,并调整父节点指针,这个过程就是页分裂。页分裂会导致:

  • 额外的磁盘 IO(分配新页、写入、更新父节点)
  • 页空间利用率下降(分裂后每个页约半满)
  • 大量分裂时产生碎片,影响范围查询的顺序读性能

如何减少页分裂:使用自增主键(AUTO_INCREMENT)作为聚簇索引,保证数据总是追加到最后一个数据页,避免中间插入导致的分裂。这也是不推荐使用 UUID 作为主键的主要原因——UUID 的随机性会导致频繁的随机插入和页分裂。

锁

  • 记录锁:锁定指定的数据
  • 间隙锁:锁定指定数据的范围区间
  • 临键锁(默认):记录锁 + 间隙锁

MySQL 默认的隔离级别是 RR(可重复读),通过临键锁解决了 RR 下的幻读问题。

日志

  • undo log:记录事务的反向 SQL,支持事务回滚、MVCC 版本链,事务的原子性、一致性
  • redo log(WAL):避免崩溃导致数据丢失,事务的持久性
  • bin log:主从同步,有 statement、mixed、row 三种模式

MVCC 支持了事务的隔离性

并发操作金额

在并发场景下对同一账户进行余额扣减、充值等操作时,核心问题是更新覆盖(Lost Update):线程 A 和 B 分别读取到相同余额后各自计算并写回,导致其中一次扣款被另一次覆盖。以下介绍三种常见的解决方案。

1. 悲观锁:SELECT ... FOR UPDATE

通过 FOR UPDATE 对目标行加排他锁(X 锁),在事务提交前阻止其他事务对该行的并发修改。

-- 1. 显式开启事务
BEGIN;

-- 2. 查询并加排他锁(锁会持有至 COMMIT / ROLLBACK)
SELECT balance FROM wallet WHERE user_id = 1 FOR UPDATE;

-- 3. 在应用层进行业务校验:
-- if (balance >= deductAmount) { ... } else { ROLLBACK; return; }

-- 4. 执行扣款
UPDATE wallet SET balance = balance - 100 WHERE user_id = 1;

-- 5. 插入交易流水
INSERT INTO wallet_log(user_id, amount, type) VALUES (1, -100, 'DEDUCT');

-- 6. 提交事务(释放排他锁)
COMMIT;

锁的兼容性:FOR UPDATE 不会阻塞普通的 SELECT(快照读),但会阻塞以下操作:

  • SELECT ... FOR UPDATE — 尝试加 X 锁
  • SELECT ... LOCK IN SHARE MODE — 尝试加 S 锁
  • UPDATE / DELETE — 隐式加 X 锁

注意事项:悲观锁的并发性能较差,且容易引发死锁(例如两个账户互相转账时加锁顺序不一致)。对于简单扣款场景,推荐优先使用 WHERE 条件原子更新或 CAS 乐观锁。

SKIP LOCKED

FOR UPDATE SKIP LOCKED 会自动跳过已被其他事务锁定的行,非常适合任务队列等"抢占式消费"场景:

SELECT * FROM task_queue
WHERE status = 'PENDING'
LIMIT 1
FOR UPDATE SKIP LOCKED;

2. WHERE 条件原子更新(推荐)

将余额校验下沉到 WHERE 条件中,利用 MySQL 在执行 UPDATE 时自动加行级排他锁的特性,保证原子性。

BEGIN;

-- 仅当余额充足时才执行扣减,MySQL 自动加行锁保证原子性
UPDATE wallet
SET balance = balance - 100
WHERE user_id = 1 AND balance >= 100;

-- 通过 affected_rows 判断结果:
--   affected_rows == 1 → 扣款成功,继续插入日志并 COMMIT
--   affected_rows == 0 → 余额不足,ROLLBACK 并返回错误

INSERT INTO wallet_log(user_id, amount, type) VALUES (1, -100, 'DEDUCT');

COMMIT;

优势:减少了一次 SELECT 的网络往返(RTT),锁仅在 UPDATE 执行瞬间持有,死锁风险极低。

局限:仅适用于逻辑简单的单表扣减。如果业务涉及跨表查询(风控、优惠券、会员折扣等复杂校验),仍需使用 FOR UPDATE 先读后写。

3. 乐观锁:CAS 版本号

不加锁,而是在更新时校验版本号(Compare-And-Swap),若版本不匹配则说明数据已被其他事务修改,需要重试。

-- 1. 普通 SELECT,不加锁
SELECT balance, version FROM wallet WHERE user_id = 1;

-- 2. 更新时校验 version,防止覆盖
UPDATE wallet
SET balance = balance - 100, version = version + 1
WHERE user_id = 1 AND version = <old_version> AND balance >= 100;

-- affected_rows == 0 时需在应用层重试

方案对比

方案 优点 缺点 适用场景
SELECT ... FOR UPDATE 强一致性;支持复杂业务逻辑(先读后写) 并发性能差;持锁时间长;易死锁 跨表校验、复杂业务流程
WHERE 条件原子更新 实现简单;锁持有时间极短;死锁风险低 无法在扣减前执行复杂校验逻辑 单表余额扣减等简单场景(推荐)
CAS 版本号 读操作不加锁;并发性能好 高竞争时重试频繁;需应用层实现重试机制 读多写少、冲突概率低的场景

分库分表

拆分有垂直和水平两种维度:

  • 垂直拆分:按业务或字段维度拆分。垂直分库将不同业务的表拆到独立数据库(如订单库、用户库);垂直分表将同一张表中不常用或大字段(如 TEXT、BLOB)拆到扩展表,减少单行 I/O。
  • 水平拆分:按行维度将同一张表的数据分散到多个库/表中,常见策略如下:
策略 示例 优点 缺点
按范围分 user_id 1–100W 一张表 扩容简单;冷数据易归档(如按时间分的订单表) 写热点集中在最新分片
按哈希取模 user_id % N 数据分布均匀,无热点 扩容需迁移数据;通常配合一致性哈希或预分片缓解
按地理位置 华北库、华南库 就近访问,降低延迟 适用于有明确地域属性的业务(如企业 SaaS 按租户区域分片)

拆分后引发的问题

  1. SQL 路由:应用无法直接感知数据落在哪个分片,需要引入 ShardingSphere、MyCat 等中间件或 SDK 做透明路由。
  2. 分布式主键:自增 ID 在多库下会冲突,常用方案有美团 Leaf、雪花算法(Snowflake)等全局唯一 ID 生成器。
  3. 跨分片 JOIN:
    • 字段冗余:将常用的关联字段直接冗余存到当前表,用空间换时间,避免跨库关联。
    • 应用层组装:先查出 ID 列表,再在业务代码中并发查询各分片并在内存中拼接结果。
  4. 分布式事务:优先在业务设计上将同一事务内的数据划入同一分片;若无法避免跨库事务,可采用本地消息表 + MQ 最终一致性、Seata(AT / TCC 模式)或 Saga 模式。
  5. 多维度查询(非分片键查询):
    • 场景:订单表按 user_id 分片,商家需要按 merchant_id 查询、或仅凭 order_id 查询,怎么办?
    • 基因法(Gene):生成 order_id 时,将 user_id 的低几位哈希值嵌入 order_id 的后缀,这样仅凭 order_id 即可推导出所在分片。
    • 双写与异构索引:通过 Canal / CDC 监听 Binlog,异步同步到 Elasticsearch,复杂的多条件检索、聚合及后台查询全部走 ES,实现读写分离的 CQRS 架构。

生产常见故障

MySQL 的线上故障往往是连锁反应:一条慢 SQL 或一个长事务占住资源,后续请求不断堆积,最终连接耗尽,接口全部超时。因此定位时要先找到堆积的源头,而不是只看最后的报错。

排查工具

现象 可能的故障 排查手段
接口变慢,CPU 或 IO 飙高 慢查询 慢查询日志、EXPLAIN
报 Too many connections 连接数耗尽 SHOW FULL PROCESSLIST
报 Lock wait timeout exceeded 锁等待、长事务 sys.innodb_lock_waits、information_schema.innodb_trx
报 Deadlock found 死锁 SHOW ENGINE INNODB STATUS
大量线程处于 Waiting for table metadata lock DDL 阻塞 sys.schema_table_lock_waits
从库读到旧数据 主从延迟 SHOW REPLICA STATUS
磁盘使用率持续上涨 容量问题 检查 binlog、undo、慢查询日志的大小
-- 查看所有连接正在执行的 SQL
SHOW FULL PROCESSLIST;

-- 查找运行超过 60 秒的事务
SELECT trx_id, trx_mysql_thread_id, trx_started, trx_query
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;

-- 查看谁在阻塞谁
SELECT * FROM sys.innodb_lock_waits;

-- 查看最近一次死锁
SHOW ENGINE INNODB STATUS;

-- 终止指定连接,线程 ID 来自上面的查询
KILL <thread_id>;

慢查询

开启 slow_query_log,并调低 long_query_time(默认 10 秒),再用 pt-query-digest 汇总慢查询日志,找出总耗时最高的 SQL。EXPLAIN 重点关注 type(ALL 表示全表扫描)、rows(预估扫描行数)和 Extra(Using filesort、Using temporary)。

原因 修复
索引失效:对索引列使用函数、隐式类型转换、like '%...' 改写 SQL,保持条件中的类型一致
深分页:LIMIT 1000000, 20 要扫描并丢弃前面所有行 用游标分页 WHERE id > ? LIMIT 20,或先通过覆盖索引查出 ID 再回表
SELECT * 或大结果集:回表次数多,网络传输量大 只查需要的字段,使用覆盖索引
排序或分组没有走索引,产生文件排序或临时表 为排序字段建立联合索引
优化器选错索引:统计信息不准确 执行 ANALYZE TABLE,必要时用 FORCE INDEX

连接数耗尽

连接数达到 max_connections(默认 151)后,新连接会报 Too many connections。此时 MySQL 为管理员保留了一个额外连接,8.0.14 起也可以通过 admin_port 配置独立的管理端口。

原因 修复
慢查询或锁等待导致连接被长期占用 用 KILL 终止源头 SQL,再治理慢查询
应用实例数 × 连接池大小超过 max_connections 扩容前核算总连接数,必要时引入数据库代理
连接未归还:事务没有提交或回滚,rows 没有关闭 用 defer 等机制确保释放,并监控连接池使用率
业务高峰时大量请求排队 设置查询超时和连接池等待超时,超时后快速失败

连接池的连接最大存活时间要小于 wait_timeout,否则应用可能拿到已被 MySQL 关闭的连接,报 MySQL server has gone away。

长事务与大事务

长事务指运行时间长的事务,最常见的原因是在事务里调用 RPC、HTTP 等外部接口,或者框架关闭了自动提交后事务一直未结束。它的危害有:

  • 持有的行锁直到提交才释放,阻塞其他写入
  • undo log 无法清理,导致 undo 膨胀,其他查询遍历版本链时也会变慢
  • 持有元数据锁,引发 DDL 阻塞

大事务指一次修改大量数据的事务,如一次 DELETE 上百万行。它的危害有:

  • 回滚耗时可能比执行更长
  • binlog 集中写入,引发主从延迟

修复方式:事务内不做外部调用,只包含必要的 SQL;批量操作按主键范围分批执行,每批单独提交;监控 information_schema.innodb_trx,对超过阈值的事务告警。

锁等待与死锁

InnoDB 的行锁是加在索引上的,事务持有的锁要到提交时才释放。等待超过 innodb_lock_wait_timeout(默认 50 秒)时,报 Lock wait timeout exceeded。

InnoDB 会自动检测死锁,并回滚代价最小的事务,被回滚的一方报 Deadlock found。因此业务代码要捕获这个错误并重试。

原因 修复
UPDATE / DELETE 的条件没有走索引:RR 隔离级别下会锁住扫描过的所有行,效果接近锁表 确保条件字段有索引
加锁顺序不一致:如两个账户互相转账(见并发操作金额) 按固定顺序加锁,比如按账户 ID 排序
间隙锁冲突:两个事务先 SELECT ... FOR UPDATE 同一个不存在的记录,再 INSERT。间隙锁互相兼容,但插入时要等待对方的间隙锁 用 INSERT ... ON DUPLICATE KEY UPDATE 或唯一索引代替先查后插;业务允许时使用 RC 隔离级别
事务过长,持锁时间久 缩短事务,把最容易冲突的更新放到最后执行

DDL 阻塞

所有查询和写入都持有元数据锁(MDL)读锁,事务提交后才释放,而 DDL 需要 MDL 写锁。典型的故障过程是:

  1. 一个未提交的长事务持有 MDL 读锁
  2. ALTER TABLE 等待 MDL 写锁
  3. 后续所有查询都排在写锁之后,这张表完全不可读写
  4. 请求堆积,连接耗尽

这个问题即使对小表也会发生,修复方式如下:

  • 变更前检查 information_schema.innodb_trx,确认没有长事务
  • 设置 SET lock_wait_timeout = 5,让 DDL 等待超时后放弃,默认值是一年
  • 能用 ALGORITHM=INSTANT(如 8.0 加列)时优先使用
  • 大表使用 ALGORITHM=INPLACE, LOCK=NONE,或 gh-ost、pt-online-schema-change,并在业务低峰执行

主从延迟

主从延迟的表现是写入后立刻读取却读不到新数据。SHOW REPLICA STATUS 的 Seconds_Behind_Source 可以查看延迟。

原因 修复
大事务或大表 DDL:从库要花同样长的时间回放 拆分大事务,用 gh-ost 等工具限速变更
从库回放能力不足 开启基于 WRITESET 的并行复制,从库配置不低于主库
从库读压力过大 增加从库,分散读流量
表没有主键:row 格式下每行变更都可能要全表扫描 所有表都设置主键

对于必须读到最新数据的场景,写入后的关键读要强制走主库。

容量问题

问题 现象 修复
磁盘写满 写入全部挂起 配置 binlog_expire_logs_seconds 自动清理 binlog,治理长事务导致的 undo 膨胀,轮转慢查询日志
DELETE 后空间不释放 表文件大小不变 删除的空间只是标记为可复用,需要用 OPTIMIZE TABLE 重建表
自增主键耗尽 INT 达到 2147483647 后,写入报 Duplicate entry 主键使用 BIGINT,并监控自增 ID 的使用率

误操作恢复

误删数据时,先恢复全量备份到临时实例,再回放 binlog 到误操作之前的位置(PITR),确认无误后把数据导回线上。row 格式的 binlog 也可以用 binlog2sql、MyFlash 等工具生成反向 SQL。

预防措施:定期演练备份恢复;开启 sql_safe_updates,拒绝没有 WHERE 或 LIMIT 的更新;保留延迟从库,为发现问题留出时间。

FAQ

SQL 执行

一条 SQL 执行的过程是怎样的?

MySQL 可以分为 server 层和存储引擎层,server 层的执行顺序:

  1. 连接器:客户端通过 TCP 三次握手与数据库建立连接,并校验权限
  2. 分析器:对 SQL 语句进行词法分析和语法分析,识别关键字、表名、列名等,并检查语法是否合法
  3. 优化器:对 SQL 语句进行优化,根据成本计算出最优的执行计划,选择适合的索引
  4. 执行器:执行 SQL 语句,与存储引擎交互,返回结果

MySQL 8 之前还有查询缓存,但表发生修改时就要使相关缓存失效,维护成本高且命中率低,因此之后将其删除。

索引

讲一下 MySQL 的索引

MySQL的索引就像书籍的目录,当我们查看某个章节的时候,通过目录可以快速找到对应内容,这里的目录就是索引, 索引是空间换时间的思想。MySQL的全表扫描就像一页一页翻书,索引则像通过目录查询,效率会高很多。

让你来设计 MySQL 的索引,你会怎么做?

常见的用于搜索的数据结构有哈希表、红黑树、B 树、B+ 树等:

  • 哈希表:等值查询 \(O(1)\),但不支持范围查询和排序,也无法利用最左前缀匹配;且数据量增大时哈希冲突加剧导致性能退化,扩容时需要 rehash 迁移数据,代价较高。
  • 红黑树:作为二叉树,每个节点最多两个子节点,数据量大时树的高度很高,导致磁盘 I/O 次数过多;且每次插入或删除都可能触发旋转以维持平衡,在磁盘场景下开销较大。更适合内存中的少量数据操作(如 Java 的 TreeMap)。
  • B 树:多叉树,树高较低,但非叶子节点同时存储索引和数据,导致单个节点能容纳的索引项有限,树仍然偏高;且叶子节点之间没有指针,范围查询需要中序遍历整棵树。
  • B+ 树:非叶子节点只存储索引和指针,单节点可容纳更多索引项,树更加矮胖,磁盘 I/O 更少;所有数据都存储在叶子节点,查询性能稳定;叶子节点间通过双向链表连接,天然支持范围查询和顺序遍历。

综合来看,B+ 树在磁盘 I/O、范围查询、查询稳定性上都优于其他结构,因此 MySQL InnoDB 选择 B+ 树作为索引。

B+ 树怎么存储数据的?聚簇索引、非聚簇索引、覆盖索引、联合索引

MySQL的索引分为聚簇索引和非聚簇索引,每张表只能有一个聚簇索引,主键索引就是聚簇索引,普通索引、唯一索引、前缀索引则是非聚簇索引。它们的区别主要在于索引与数据是否一起存储,聚簇索引的叶子节点存放索引与数据行,而非聚簇索引则在叶子节点存放索引与主键,所以非聚簇索引在查询时需要通过主键ID再查一次聚簇索引,也就是回表。这也是为什么非聚簇索引也叫二级索引。而SQL优化的一个重要方向就是减少回表,因此使用覆盖索引,即查询所需的字段就在索引中。联合索引中的多个字段可以大大增加覆盖索引的命中率,减少回表次数。

联合索引的最左前缀匹配原则

假设现在的联合索引是(a,b,c),则命中情况如下:

  • where a = 1 → 命中
  • where a = 1 and b = 2 → 命中
  • where b = 2 and a = 1 → 命中,MySQL 优化器会自动重排索引顺序
  • where a = 1 and b = 2 and c = 3 → 命中
  • where a = 1 and c = 3 → 部分命中,a 走索引,c 不走索引,因为 c 的有序建立在 b 的基础上,c 前面的 b 有缺失,所以 c 无法走索引
  • where b = 2 → 不命中
  • where b = 2 and c = 3 → 不命中
  • where a != 1 → 通常不命中,优化器根据数据分布决定是否走索引,多数情况下选择全表扫描
  • where a = 1 or a = 2 等价于 where a in (1,2) → 命中
  • where a = 1 or b = 2 → 不命中
  • where a like '%... → 不命中
  • where a like '...% → 命中
  • where a like '%...% → 不命中
  • where substring(a, 1, 1) = 'a' → 不命中,因为对索引列使用函数后,破坏了 B+ 树的有序性,无法使用二分查找,自然无法走索引

什么情况下应该加索引?

索引不是越多越好,索引占用的空间会随着数据量的增长而增大,同时写入数据时也要更新索引,拖慢写入性能。应该针对经常查询、排序、区分度高的列添加索引。尽量多使用联合索引而非单个索引,同时减少回表,对于较长的字符串可以使用前缀索引。

为什么要使用自增 ID 作为主键?以及什么是页分裂?

InnoDB中每个叶子节点都对应一个数据页,页内的记录按照主键顺序排列。如果向一个已满的数据页插入数据时,InnoDB会将该页的一部分数据重新分配到新的数据页中,这个过程叫做页分裂。页分裂导致了额外的磁盘I/O。要避免页分裂,可以使用自增ID作为主键,让数据总是追加到最后一个数据页,避免中间插入导致分裂。

事务

什么是事务?

以A向B转账100元为例,A扣减100,B增加100,这种执行多个SQL的操作需要保证原子性,成功则提交,失败则回滚,因此需要事务来实现。

数据库并发产生的问题:

  • 脏读(Dirty Read):一个事务读取到了另一个并发事务未提交的数据。
  • 不可重复读(Non-repeatable Read):一个事务内两次读取同一行数据,由于另一个并发事务在此期间执行了 UPDATE 并提交,导致两次读取的值不一致。
  • 幻读(Phantom Read):一个事务内两次执行同条件的范围查询,由于另一个并发事务在此期间执行了 INSERT 或 DELETE 并提交,导致两次相同的查询返回的行数不一致。

事务的隔离分为 4 个级别,从宽松到严格如下表格:

隔离级别 脏读 不可重复读 幻读 改进
读未提交 ✓ ✓ ✓
读已提交 ✗ ✓ ✓ 解决脏读
可重复读 ✗ ✗ ✗ (快照读靠 MVCC,当前读靠临键锁) 解决不可重复读
串行化 ✗ ✗ ✗ 解决幻读

MVCC

MVCC(多版本并发控制)通过 undo log 版本链 保存数据的历史版本,通过 ReadView 判断哪些版本对当前事务可见,从而在不加锁的情况下实现事务隔离,解决脏读和不可重复读问题。同时由于 MVCC 的不同事务看到的数据量不同,因此 InnoDB 无法维护数据表的精确行数。

MVCC 只用在读已提交和可重复读两个隔离级别,两者的区别在于 ReadView 的创建时机:

  • 读已提交:每次执行 SELECT 时都创建新的 ReadView,因此能读到其他事务已提交的最新数据
  • 可重复读:仅在事务中第一次执行 SELECT 时创建 ReadView,后续复用同一个,因此整个事务内读取的数据快照一致

快照读与当前读:

  • 快照读:普通 SELECT,通过 MVCC 读取历史版本,不加锁,并发性能高
  • 当前读:SELECT ... FOR UPDATE、UPDATE、DELETE 等,直接读取数据的最新版本并加锁,阻塞其他事务对同一记录的修改

可重复读级别如何防止幻读:

  • 快照读靠 MVCC,始终读取事务开始时的快照,自然看不到其他事务新插入的行
  • 当前读靠临键锁(Next-Key Lock)锁住查询范围,阻止其他事务在该范围内插入新数据

日志

redo log、undo log、binlog 的作用是什么?

  • undo log(InnoDB 引擎层):记录数据修改前的值,事务失败时用于回滚,同时也是 MVCC 版本链的基础。
  • redo log(InnoDB 引擎层):实现了 WAL(Write-Ahead Logging),修改数据时先将变更记录顺序写入 redo log,再修改 buffer pool 中的数据页。如果 MySQL 崩溃,可通过 redo log 恢复尚未刷盘的脏页数据。同时将随机 I/O 转为顺序 I/O,提升写入性能。
  • binlog(Server 层):记录所有数据变更,用于主从复制和数据恢复(point-in-time recovery)。有三种模式:
    • statement:记录原始 SQL,日志体积小,但 NOW()、RAND() 等非确定性函数可能导致主从不一致
    • row:记录每一行的具体变更,确定性强,但批量操作时日志体积大
    • mixed:默认使用 statement,遇到非确定性操作时自动切换为 row,兼顾两者优点

在一个事务中,redo log、undo log、binlog 是如何协作的?

  1. 准备回滚(Undo log):在对记录进行具体修改前,Undo log 会先记录修改前的数据镜像。这样一旦事务中途失败或执行 ROLLBACK,可以快速恢复到修改前的状态;同时 Undo log 也为 MVCC 提供了历史版本链。
  2. 准备重做(Redo log - Prepare):数据在内存(Buffer Pool)中修改后,会生成对应的 Redo log,此时 Redo log 处于 Prepare(准备)状态,并刷新到磁盘(WAL 预写日志机制),确保哪怕此时宕机,数据改动也有据可查。
  3. 记录变更(Binlog):在 Redo log 处于 Prepare 状态后,服务层会把本次事务的最终修改逻辑写入 Binlog 并刷盘,用于后续的主从同步或数据恢复。
  4. 提交事务(Redo log - Commit):Binlog 写入成功后,Redo log 会将状态修改为 Commit(提交)状态,此时整个事务才算真正成功完成。

在事务中,为了确保 redo log 与 binlog 的一致性,采用两阶段提交(2PC)。

数据页如何工作?

数据页是 InnoDB 的最小 I/O 管理单位,默认 16KB。B+ 树的每个节点就是一个数据页,叶子节点的数据页中存储了实际的数据行。

读取:查询数据时,以数据页为单位加载到 buffer pool。由于空间局部性原理,同一页内的相邻数据会一起被加载,后续访问时直接命中内存,无需再次磁盘 I/O。

修改:根据 WAL 原则,先将变更写入 redo log,再修改 buffer pool 中的数据页。此时内存中的数据页与磁盘不一致,称为脏页。InnoDB 会在合适的时机(如空闲时、内存不足时)将脏页异步刷回磁盘。

评论