## 目录
1. [MySQL索引与数据的存储位置](#1-mysql索引与数据的存储位置)
2. [Buffer Pool:MySQL的内存管理核心](#2-buffer-pool-mysql的内存管理核心)
3. [热点数据识别:改进的LRU算法](#3-热点数据识别改进的lru算法)
4. [全表扫描的工作机制](#4-全表扫描的工作机制)
5. [数据传输:从磁盘到客户端](#5-数据传输从磁盘到客户端)
6. [写操作与Buffer Pool](#6-写操作与buffer-pool)
7. [脏页管理与刷盘机制](#7-脏页管理与刷盘机制)
8. [大批量更新的挑战与解决方案](#8-大批量更新的挑战与解决方案)
9. [Redo Log:崩溃恢复的保障](#9-redo-log崩溃恢复的保障)
10. [Binlog:复制与恢复](#10-binlog复制与恢复)
11. [Undo Log与MVCC机制](#11-undo-log与mvcc机制)
12. [事务隔离级别详解](#12-事务隔离级别详解)
---
## 1. MySQL索引与数据的存储位置
### 1.1 核心结论
**索引和数据最终存储在磁盘,但运行时的热点部分会被缓存在内存的Buffer Pool中。**
```
永久存储:磁盘(.ibd文件)
├─ 数据页(16KB为单位)
├─ 索引页(B+树结构)
└─ 容量大,持久化,但访问慢
运行时缓存:内存(Buffer Pool)
├─ 热点数据页
├─ 热点索引页
└─ 容量小,易失,但访问快(比磁盘快1000-10000倍)
```
### 1.2 工作原理
```sql
-- 执行查询时
SELECT * FROM users WHERE id = 10;
-- 内部流程:
1. 检查Buffer Pool中是否有id=10所在的数据页
├─ 命中 → 直接从内存读取(极快)
└─ 未命中 → 从磁盘加载到Buffer Pool(较慢)
2. 返回数据给客户端
```
### 1.3 关键配置
```sql
-- 查看Buffer Pool大小
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 推荐设置:服务器物理内存的50-80%
-- 例如:32GB内存的服务器
SET GLOBAL innodb_buffer_pool_size = 24G;
```
---
## 2. Buffer Pool:MySQL的内存管理核心
### 2.1 Buffer Pool的组成
```
┌──────────────────────────────────────────┐
│ InnoDB Buffer Pool │
├──────────────────────────────────────────┤
│ │
│ 【数据页缓存】 │
│ - 表数据(16KB/页) │
│ │
│ 【索引页缓存】 │
│ - B+树的索引节点 │
│ │
│ 【Change Buffer】 │
│ - 非唯一二级索引的变更缓冲 │
│ │
│ 【自适应哈希索引】 │
│ - 热点数据的快速访问路径 │
│ │
└──────────────────────────────────────────┘
```
### 2.2 页面的状态
```
Buffer Pool中的页面有三种状态:
1. Free Page(空闲页)
- 未被使用的页面
- 可以直接分配
2. Clean Page(干净页)
- 内存数据 = 磁盘数据
- 可以直接淘汰(丢弃)
3. Dirty Page(脏页)⭐
- 内存数据 ≠ 磁盘数据(已修改但未刷盘)
- 淘汰前必须先刷盘
```
### 2.3 监控Buffer Pool
```sql
-- 查看Buffer Pool状态
SHOW ENGINE INNODB STATUS\G
-- 关键指标:
Buffer pool size -- 总页数
Free buffers -- 空闲页数
Database pages -- 使用中的页数
Modified db pages -- 脏页数量
Buffer pool hit rate -- 命中率(应>99%)
-- 查看命中率
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
命中率 = (read_requests - reads) / read_requests
-- 理想值:>99%
-- <95%需要调优
```
---
## 3. 热点数据识别:改进的LRU算法
### 3.1 为什么需要改进LRU
**经典LRU的问题:**
```
问题1:预读失效
- InnoDB会预读相邻的数据页
- 但这些预读页可能不会被真正使用
- 却占据了LRU链表头部,挤掉真正的热数据
问题2:Buffer Pool污染
- 全表扫描会读取大量一次性使用的数据
- 这些冷数据会把真正的热数据全部淘汰
```
### 3.2 InnoDB的分代LRU
```
┌─────────────────────────────────────────────┐
│ InnoDB Buffer Pool LRU List │
├─────────────────────────┬───────────────────┤
│ Young区域 (热数据) │ Old区域 (冷数据) │
│ 约占 5/8 (63%) │ 约占 3/8 (37%) │
│ │ │
│ 最近频繁访问的页 │ 新加载的页 & │
│ "真正的热数据" │ 很久没访问的页 │
└─────────────────────────┴───────────────────┘
↑ ↑
头部(Hot) Midpoint中点 尾部(Cold)
```
### 3.3 核心规则
```
规则1:新页插入位置
- 新读取的页不放在链表头部
- 而是放在Old区的头部(midpoint位置)
- 需要"考验期"才能晋升
规则2:晋升条件(Old区 → Young区)
- 在Old区停留时间 > innodb_old_blocks_time(默认1秒)
- 并且在此之后被再次访问
- 才能晋升到Young区
规则3:Young区内的移动
- 不是每次访问都移到头部(成本太高)
- 只有在Young区后1/4部分时,才移到头部
- 前3/4部分的访问不移动位置
```
### 3.4 相关配置
```sql
-- Old区占比(默认37%)
SHOW VARIABLES LIKE 'innodb_old_blocks_pct';
innodb_old_blocks_pct = 37
-- Old区停留时间(默认1000ms)
SHOW VARIABLES LIKE 'innodb_old_blocks_time';
innodb_old_blocks_time = 1000
-- 调整示例(减少全表扫描影响)
SET GLOBAL innodb_old_blocks_time = 5000; -- 延长到5秒
```
### 3.5 效果对比
```
场景:100GB大表全表扫描,Buffer Pool 8GB
【简单LRU】:
扫描页进入 → 头部
原有热数据 → 被挤到尾部 → 被淘汰
结果:Buffer Pool被完全污染 ✗
【改进LRU】:
扫描页进入 → Old区头部
扫描页快速流转 → 在Old区就被淘汰
Young区热数据 → 大部分保留
结果:热数据得到一定保护 ✓
```
---
## 4. 全表扫描的工作机制
### 4.1 表大于Buffer Pool的场景
```
场景设定:
- 表大小:100GB(640万个16KB的页)
- Buffer Pool:8GB(50万个页)
- 执行:SELECT * FROM huge_table;
```
### 4.2 滚动读取过程
```
第一批(0-8GB):
磁盘: [====读取中====][........未读........]
内存: [数据页1-50万] ← Buffer Pool填满
第二批(8-16GB):
磁盘: [已读][====读取中====][......未读......]
内存: [数据页50万-100万]
└→ 页1-50万被逐渐淘汰(LRU尾部)
第三批(16-24GB):
磁盘: [已读][已读][====读取中====][...未读...]
内存: [数据页100万-150万]
└→ 页50万-100万被淘汰
... 以此类推,像传送带一样滚动 ...
最后一批(92-100GB):
磁盘: [=======全部读过=======][====最后一批====]
内存: [数据页590万-640万]
└→ 前面的页早已被淘汰
```
### 4.3 性能影响
```
磁盘I/O计算:
- 数据总量:100GB
- 顺序读速度:200 MB/s(机械盘)或 500 MB/s(SSD)
耗时:
- 机械盘:100GB ÷ 200MB/s ≈ 512秒 ≈ 8.5分钟
- SSD:100GB ÷ 500MB/s ≈ 205秒 ≈ 3.4分钟
影响:
1. 扫描期间:大量磁盘I/O,查询极慢
2. 扫描之后:热数据被淘汰,其他查询变慢
3. 资源竞争:占用磁盘带宽,影响其他业务
```
### 4.4 优化措施
```sql
-- ❌ 不推荐
SELECT * FROM huge_table;
-- ✓ 推荐方案
-- 方案1:分批处理
SELECT * FROM huge_table
WHERE id BETWEEN 1 AND 100000;
-- 暂停,让系统恢复
SELECT * FROM huge_table
WHERE id BETWEEN 100001 AND 200000;
-- 方案2:添加索引,避免全表扫描
CREATE INDEX idx_column ON huge_table(column);
SELECT * FROM huge_table WHERE column = 'value';
-- 方案3:只查询需要的列
SELECT id, name FROM huge_table WHERE condition;
-- 方案4:使用从库
-- 在从库执行大查询,不影响主库
```
---
## 5. 数据传输:从磁盘到客户端
### 5.1 完整的数据流
```
┌────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ 磁盘 │→→→│ Buffer │→→→│ Server │→→→│ 网络层 │→→→│ 客户端 │
│ .ibd │ │ Pool │ │ 层 │ │ Socket │ │ 应用 │
└────────┘ └──────────┘ └────────┘ └────────┘ └────────┘
100GB 8GB 临时处理 16KB ???
持久化 滚动窗口 即时发送 流式传输 取决于模式
```
### 5.2 各层的角色
```
【InnoDB层 - Buffer Pool】
- 作用:数据的"源头"和"工作台"
- 大小:GB级别(如8GB)
- 行为:滚动加载数据页
- 不参与:数据打包和网络传输
【Server层】
- 作用:数据处理和格式化
- 流程:
1. 从Buffer Pool读取行数据
2. 应用WHERE条件
3. 投影需要的列
4. 格式化成MySQL协议包
5. 立即发送(不堆积)
【网络层 - net_buffer】
- 大小:16KB(net_buffer_length)
- 作用:临时缓冲,积累一定数据后发送
- 行为:填满16KB → flush → 继续积累
【客户端】⚠️ 关键差异
- 模式1:缓冲模式(默认,危险)
→ 接收所有数据到内存
→ 100GB数据会占满客户端内存!
- 模式2:流式模式(推荐)
→ 边接收边处理
→ 内存占用恒定(只保存当前处理的行)
```
### 5.3 客户端的两种模式
#### 缓冲模式(Store Result)
```python
# Python - 默认模式(危险)
import pymysql
cursor = conn.cursor() # 默认缓冲模式
cursor.execute("SELECT * FROM huge_table")
rows = cursor.fetchall() # ⚠️ 全部加载到内存!
# 如果是100GB数据,客户端内存爆炸!
for row in rows:
process(row)
```
#### 流式模式(Use Result / SSCursor)
```python
# Python - 流式模式(推荐)
import pymysql
cursor = conn.cursor(pymysql.cursors.SSCursor) # ✓ 流式
cursor.execute("SELECT * FROM huge_table")
for row in cursor: # 逐行读取
process(row)
# 处理完这一行,内存被回收
# 然后获取下一行
# 内存占用:恒定,只有当前行的大小
```
#### 不同语言的流式实现
```java
// Java - JDBC
Statement stmt = conn.createStatement(
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY
);
stmt.setFetchSize(Integer.MIN_VALUE); // MySQL特殊值
ResultSet rs = stmt.executeQuery("SELECT * FROM huge_table");
while (rs.next()) {
process(rs);
}
```
```go
// Go - database/sql(默认就是流式)
rows, err := db.Query("SELECT * FROM huge_table")
defer rows.Close()
for rows.Next() {
var data SomeStruct
rows.Scan(&data)
process(data)
}
```
### 5.4 重要总结
```
Buffer Pool的角色澄清:
✓ 参与:数据读取和准备阶段
- 从磁盘加载数据页
- 提供数据给Server层处理
✗ 不参与:数据传输阶段
- 不是网络传输的通道
- 不是客户端接收的缓冲区
数据流向:
磁盘 → Buffer Pool(暂存)
→ Server层(提取并处理行数据)
→ net_buffer(打包)
→ 网络(传输)
→ 客户端(接收)
Buffer Pool是"工作台",不是"传送带"
```
---
## 6. 写操作与Buffer Pool
### 6.1 UPDATE操作的完整流程
```sql
UPDATE users SET age = 31 WHERE id = 10;
```
**执行步骤:**
```
Step 1: 读取数据到Buffer Pool
├─ 查找id=10所在的数据页
├─ 如果不在Buffer Pool → 从磁盘加载
└─ 可能还需加载相关索引页
Step 2: 在Buffer Pool中修改数据
├─ 直接在内存中修改age字段
├─ 此时内存数据 ≠ 磁盘数据
└─ 这个页变成"脏页"(Dirty Page)
Step 3: 写入Undo Log ⭐
├─ 记录旧值:age = 30
├─ 用于事务回滚
└─ 用于MVCC(其他事务读取历史版本)
Step 4: 写入Redo Log ⭐
├─ 记录物理修改:"在Page X的偏移Y写入值Z"
├─ 先写入Redo Log Buffer(内存)
├─ 事务提交时刷到磁盘(ib_logfile)
└─ 顺序I/O,速度快
Step 5: 事务提交
├─ Redo Log持久化到磁盘
├─ 向客户端返回"成功"
└─ 此时.ibd数据文件可能还没更新!
Step 6: 后台异步刷脏
├─ Page Cleaner线程定期工作
├─ 将脏页写入磁盘(.ibd文件)
└─ 推进Checkpoint
```
### 6.2 WAL机制(Write-Ahead Logging)
```
核心思想:先写日志,再写数据
优势:
┌─────────────────────────────────────────┐
│ 1. 内存操作代替磁盘操作 │
│ - 修改在Buffer Pool中完成(快) │
│ │
│ 2. 顺序I/O代替随机I/O │
│ - 写Redo Log是顺序写(快) │
│ - 写数据文件是随机写(慢) │
│ │
│ 3. 批量写入提高效率 │
│ - 多个修改积累后一次性刷盘 │
│ - 减少I/O次数 │
│ │
│ 4. 快速响应客户端 │
│ - Redo Log落盘即可返回成功 │
│ - 用户感知延迟低 │
└─────────────────────────────────────────┘
```
### 6.3 Change Buffer优化
```
针对非唯一二级索引的特殊优化:
场景:
UPDATE users SET city = 'Shanghai' WHERE id = 10;
-- city字段上有非唯一索引
传统流程:
1. 更新数据页(id=10的行)
2. 更新二级索引页(city索引)
└─ 需要加载索引页到Buffer Pool(可能触发磁盘I/O)
Change Buffer优化:
1. 更新数据页
2. 不立即更新索引页
3. 将"索引需要修改"这个信息记录在Change Buffer中
4. 等将来索引页被其他查询加载时,再合并(Merge)
优势:
- 避免了随机读索引页的磁盘I/O
- 将多次索引修改合并成一次操作
- 适合写多读少的场景
```
---
## 7. 脏页管理与刷盘机制
### 7.1 Buffer Pool的两个关键链表
```
┌─────────────────────────────────────────┐
│ InnoDB Buffer Pool │
├─────────────────────────────────────────┤
│ │
│ LRU List(管理页面淘汰) │
│ ├─ Young区 │
│ │ ├─ 页A(干净) │
│ │ ├─ 页B(脏) ←┐ │
│ │ └─ 页C(脏) ←┼─┐ │
│ └─ Old区 │ │ │
│ ├─ 页D(干净) │ │ │
│ └─ 页E(脏) ←┼─┼─┐ │
│ │ │ │ │
│ Flush List(管理脏页刷盘) │
│ ├─ 页B (LSN=1000) ─┘ │ │ │
│ ├─ 页C (LSN=1050) ───┘ │ │
│ └─ 页E (LSN=1100) ─────┘ │
│ │
└─────────────────────────────────────────┘
```
### 7.2 刷脏的触发条件
```
条件1:脏页比例达到阈值
SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct%';
innodb_max_dirty_pages_pct = 75 -- 上限75%
innodb_max_dirty_pages_pct_lwm = 10 -- 低水位10%
行为:
0% ────── 10% ────── 75% ────── 90% ────── 100%
↑ ↑ ↑
开始温和 加速刷脏 疯狂刷脏
刷脏 可能阻塞
条件2:Redo Log空间不足
- Redo Log是循环使用的(如2GB)
- 只有对应的脏页刷盘后,Redo Log空间才能重用
- 如果Redo Log快写满 → 必须刷脏推进Checkpoint
条件3:Buffer Pool空间不足
- 需要淘汰页面但都是脏页
- 必须同步刷脏(阻塞用户操作)
条件4:定期刷新
- 后台线程每秒检查一次
- 温和地刷一部分脏页
```
### 7.3 Page Cleaner线程
```python
# 伪代码:Page Cleaner的工作逻辑
while True:
# 1. 检查脏页比例
dirty_ratio = get_dirty_pages_ratio()
# 2. 检查Redo Log使用情况
redo_usage = get_redo_log_usage()
# 3. 决定刷脏强度
if redo_usage > 90%:
flush_mode = 'AGGRESSIVE' # 激进模式
pages_to_flush = 1000
elif dirty_ratio > 75%:
flush_mode = 'ACTIVE' # 活跃模式
pages_to_flush = 500
elif dirty_ratio > 10%:
flush_mode = 'GENTLE' # 温和模式
pages_to_flush = 100
else:
sleep(1)
continue
# 4. 从Flush List获取最老的脏页
pages = get_oldest_dirty_pages(pages_to_flush)
# 5. 刷盘
for page in pages:
flush_page_to_disk(page)
mark_as_clean(page)
advance_checkpoint()
sleep(interval)
```
### 7.4 脏页与全表扫描
```
问题:全表扫描会淘汰脏页吗?
保护机制:
1. Redo Log是根本保证
- 脏页即使被淘汰,数据也不会丢
- 崩溃后通过Redo Log可以恢复
2. 优先淘汰干净页
- LRU淘汰时,优先选择干净页
- 脏页需要先刷盘才能淘汰
3. 控制脏页比例
- 脏页比例超过阈值 → 加速刷脏
- 确保始终有足够的干净页可淘汰
4. 同步刷脏(最后手段)
- 如果实在没有干净页可淘汰
- 同步刷脏(阻塞查询)
- 性能下降,但数据安全
结论:性能可以牺牲,数据绝不能丢!
```
### 7.5 监控脏页
```sql
-- 查看脏页情况
SHOW ENGINE INNODB STATUS\G
-- 关键指标:
Modified db pages: 45678 -- 当前脏页数
Buffer pool size: 524288 -- 总页数
脏页比例 = 45678 / 524288 ≈ 8.7%
Pages flushed up to: 1234567890 -- Checkpoint LSN
Log sequence number: 1234600000 -- 当前LSN
未刷脏的Redo Log = 1234600000 - 1234567890 = 32110
Pending writes:
LRU: 5 -- LRU淘汰等待刷脏的页
flush list: 10 -- Flush List等待刷脏的页
```
---
## 8. 大批量更新的挑战与解决方案
### 8.1 全表更新的问题
```sql
-- 危险操作
UPDATE huge_table SET status = 1 WHERE category = 'old';
-- 假设匹配80GB数据,Buffer Pool只有8GB
```
**面临的挑战:**
```
挑战1:脏页爆炸
- 修改产生大量脏页
- 脏页比例快速上升 → 触发疯狂刷脏
- 可能导致周期性卡顿
挑战2:Redo Log空间限制 ⭐ 最严重
- Redo Log总大小有限(如2GB)
- 80GB的修改会产生大量Redo Log
- Redo Log空间用完 → 必须等待刷脏 → 阻塞
- 性能严重下降
挑战3:Undo Log膨胀
- 需要保存所有旧值以支持回滚
- 80GB修改可能产生80GB Undo Log
- 占用磁盘空间,影响查询性能
挑战4:锁等待
- 如果是一个大事务,所有修改的行都被锁定
- 其他查询可能被阻塞
- 超时或死锁风险
挑战5:回滚噩梦
- 如果UPDATE执行到一半失败
- 回滚可能比更新本身还慢
- 可能需要几小时
```
### 8.2 分批更新方案
#### 方案1:按主键分批
```sql
-- ✓ 推荐
-- 第一批
UPDATE huge_table
SET status = 1
WHERE category = 'old'
AND id BETWEEN 1 AND 100000;
COMMIT;
-- 暂停1秒
-- 让系统刷脏、清理Redo Log
-- 第二批
UPDATE huge_table
SET status = 1
WHERE category = 'old'
AND id BETWEEN 100001 AND 200000;
COMMIT;
-- 继续...
```
#### 方案2:使用LIMIT
```sql
-- 循环执行,直到affected_rows = 0
UPDATE huge_table
SET status = 1
WHERE category = 'old'
LIMIT 10000; -- 每次只更新1万行
```
#### 方案3:应用层控制
```python
import pymysql
import time
conn = pymysql.connect(host='localhost', user='root', db='test')
cursor = conn.cursor()
batch_size = 100000
start_id = 0
while True:
sql = """
UPDATE huge_table
SET status = 1
WHERE category = 'old'
AND id BETWEEN %s AND %s
"""
cursor.execute(sql, (start_id, start_id + batch_size))
affected = cursor.rowcount
conn.commit() # 每批都提交,变成小事务
print(f"Updated {affected} rows")
if affected == 0:
break
start_id += batch_size + 1
time.sleep(0.5) # 暂停500ms,让系统喘口气
```
### 8.3 优化配置(大批量更新场景)
```sql
-- 增大Redo Log(MySQL 8.0.30+)
SET GLOBAL innodb_redo_log_capacity = 8G;
-- 增大I/O容量(告诉InnoDB磁盘的IOPS能力)
SET GLOBAL innodb_io_capacity = 2000; -- 正常
SET GLOBAL innodb_io_capacity_max = 4000; -- 峰值
-- 增加Page Cleaner线程数
SET GLOBAL innodb_page_cleaners = 8;
-- 降低脏页上限(更积极刷脏)
SET GLOBAL innodb_max_dirty_pages_pct = 50;
```
### 8.4 监控长事务
```sql
-- 查找运行时间过长的事务
SELECT
trx_id,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_modified,
trx_isolation_level,
trx_state
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_started;
-- 如果发现长事务,考虑kill掉
KILL <trx_mysql_thread_id>;
```
---
## 9. Redo Log:崩溃恢复的保障
### 9.1 Redo Log的本质
**Redo Log是物理日志,记录数据页的字节级修改。**
```
Redo Log不记录:
❌ SQL语句:"UPDATE users SET age=31 WHERE id=10"
❌ 逻辑修改:"第10行的age字段从30改成31"
Redo Log记录:
✓ 物理修改:"在表空间5的第1234号数据页的偏移量567处,
写入2字节数据0x001F(十进制31)"
```
### 9.2 一条UPDATE生成多少Redo Log?
```sql
UPDATE users SET age = age + 1 WHERE city = 'Beijing';
-- 假设匹配10万行
```
**生成过程:**
```
对每一行的修改:
┌────────────────────────────────────┐
│ Mini-Transaction (MTR) #1 │
├────────────────────────────────────┤
│ - Redo: 修改Page 100, offset 200 │
│ - Redo: 更新Page 100的页头 │
│ - Redo: 写入Undo Log(也产生Redo)│
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ Mini-Transaction (MTR) #2 │
├────────────────────────────────────┤
│ - Redo: 修改Page 100, offset 300 │
│ - Redo: 更新Page 100的页头 │
│ - Redo: 写入Undo Log │
└────────────────────────────────────┘
... 重复10万次 ...
总Redo Log量估算:
- 10万行 × 30字节/行 ≈ 3MB
- 加上索引、Undo等 ≈ 5-10MB
```
### 9.3 Redo Log的结构
```
┌────────────────────────────────────┐
│ Redo Log Record │
├────────────────────────────────────┤
│ Type: MLOG_WRITE_STRING │
│ Space ID: 5 │
│ Page Number: 1234 │
│ Offset: 567 │
│ Length: 2 │
│ Data: 0x001F │
└────────────────────────────────────┘
```
**常见类型:**
```c
MLOG_1BYTE // 修改1字节
MLOG_2BYTES // 修改2字节
MLOG_4BYTES // 修改4字节
MLOG_WRITE_STRING // 写入字节串
MLOG_REC_INSERT // 插入记录
MLOG_REC_UPDATE_IN_PLACE // 原地更新
MLOG_REC_DELETE // 删除记录
```
### 9.4 Redo Log的循环使用
```
┌──────────────────────────────────────┐
│ Redo Log Files (如2GB) │
├──────────────────────────────────────┤
│ │
│ ib_logfile0 (1GB) │
│ ib_logfile1 (1GB) │
│ │
│ 循环写入: │
│ ┌─────────────────────────────────┐│
│ │[已刷脏可覆盖][新写入][待刷脏] ││
│ │ ↑ ││
│ │ Write Pos ││
│ │ ↑ ││
│ │ Checkpoint(已刷脏的位置) ││
│ └─────────────────────────────────┘│
│ │
│ 关键:只有脏页刷盘后, │
│ Checkpoint才能推进, │
│ Redo Log空间才能重用 │
└──────────────────────────────────────┘
```
### 9.5 Redo Log与崩溃恢复
```
崩溃恢复流程:
1. MySQL启动,检测到上次未正常关闭
2. 读取Redo Log,找到Checkpoint位置
3. 从Checkpoint开始重放(Replay)Redo Log
├─ 读取每条Redo Log记录
├─ 根据Space ID找到表空间文件
├─ 根据Page Number定位数据页
├─ 根据Offset和Data重写数据
└─ 恢复所有已提交事务的修改
4. 通过Undo Log回滚未提交的事务
5. 恢复完成,数据库可用
优势:
- Redo Log是顺序写,重放也很快
- 物理日志,不依赖SQL执行环境
- 保证已提交事务的持久性(D)
```
### 9.6 配置参数
```sql
-- 查看Redo Log配置
SHOW VARIABLES LIKE 'innodb_log%';
-- 关键参数:
innodb_log_file_size = 1G -- 单个文件大小
innodb_log_files_in_group = 2 -- 文件数量
-- 总大小 = 1G × 2 = 2GB
-- MySQL 8.0.30+可动态调整
SET GLOBAL innodb_redo_log_capacity = 8G;
-- 刷盘策略(重要!)
innodb_flush_log_at_trx_commit = 1
-- 0: 每秒刷一次(快,但可能丢失1秒数据)
-- 1: 每次提交都刷(最安全,默认)⭐
-- 2: 每次提交写OS缓存,每秒fsync(折中)
```
---
## 10. Binlog:复制与恢复
### 10.1 Redo Log vs Binlog
| 对比项 | Redo Log | Binlog |
|--------|----------|--------|
| **层面** | InnoDB引擎层 | MySQL Server层 |
| **格式** | 物理日志 | 逻辑日志(可配置) |
| **内容** | 页的物理修改 | SQL语句或行变更 |
| **用途** | 崩溃恢复 | 主从复制、备份恢复 |
| **大小** | 固定,循环使用 | 不断增长,可归档 |
| **刷盘** | 事务提交时 | 事务提交时(可配置) |
### 10.2 Binlog的三种格式
#### STATEMENT格式
```sql
-- Binlog记录SQL语句本身
BEGIN;
UPDATE users SET age = age + 1 WHERE city = 'Beijing';
COMMIT;
优点:
✓ Binlog文件小
✓ 可读性好
缺点:
✗ 非确定性函数(NOW(), RAND(), UUID())可能导致主从不一致
✗ LIMIT无ORDER BY可能导致不一致
✗ 已不推荐使用
```
#### ROW格式(推荐)⭐
```sql
-- Binlog记录每一行的实际变更
BEGIN;
-- 记录的是变更前后的镜像
Table_map: users
Update_rows:
Before: {id:1, name:'Tom', age:30}
After: {id:1, name:'Tom', age:31}
Update_rows:
Before: {id:2, name:'Jerry', age:25}
After: {id:2, name:'Jerry', age:26}
...
COMMIT;
优点:
✓ 最安全,绝对保证主从一致
✓ 不受SQL执行环境影响
✓ MySQL 5.7.7+默认格式
缺点:
✗ Binlog文件较大(尤其大批量更新)
✗ 可读性差
```
#### MIXED格式
```sql
-- 自动选择STATEMENT或ROW
安全的SQL → STATEMENT格式(文件小)
不安全的SQL → ROW格式(数据一致)
例如:
UPDATE users SET name = 'Alice' WHERE id = 1;
→ STATEMENT格式
UPDATE users SET update_time = NOW() WHERE id = 1;
→ ROW格式(NOW()不确定)
```
### 10.3 非幂等操作的处理
```sql
-- 问题:NOW()等函数在不同时间执行结果不同
UPDATE users SET last_update = NOW() WHERE id = 1;
```
**STATEMENT格式的处理:**
```sql
-- Binlog实际记录:
SET TIMESTAMP = 1716343200; -- ⭐ 记录主库的时间戳
UPDATE users SET last_update = NOW() WHERE id = 1;
-- 从库应用时:
-- 1. 先执行SET TIMESTAMP
-- 2. 再执行UPDATE,NOW()使用设定的时间戳
-- 结果:主从一致
```
**ROW格式的处理(更彻底):**
```sql
-- Binlog记录:
Table_map: users
Update_rows:
Before: {id:1, last_update:'2024-05-20 10:00:00'}
After: {id:1, last_update:'2024-05-22 15:30:45'} -- 已计算好的值
-- 从库直接应用After值,无需重新计算NOW()
-- 天然保证一致性
```
### 10.4 配置Binlog
```sql
-- 查看Binlog配置
SHOW VARIABLES LIKE 'log_bin%';
SHOW VARIABLES LIKE 'binlog%';
-- 关键参数:
log_bin = ON -- 启用Binlog
binlog_format = ROW -- 格式(推荐ROW)⭐
sync_binlog = 1 -- 每次提交都刷盘(最安全)
binlog_cache_size = 32K -- 事务Binlog缓存
max_binlog_size = 1G -- 单个Binlog文件最大1GB
-- 查看Binlog文件
SHOW BINARY LOGS;
SHOW MASTER STATUS;
```
### 10.5 主从复制中的应用
```
主库(Master):
1. 执行UPDATE
2. 写入Redo Log(InnoDB)
3. 写入Binlog(Server层)
4. 两阶段提交(2PC)保证一致性
5. 返回客户端成功
从库(Slave):
1. I/O线程从主库拉取Binlog
2. 写入本地Relay Log
3. SQL线程读取Relay Log
4. 重放(Replay)操作
├─ ROW格式:直接应用行变更
└─ STATEMENT格式:重新执行SQL
5. 数据同步完成
```
---
## 11. Undo Log与MVCC机制
### 11.1 为什么没开事务也有Undo Log?
**两大用途:**
```
1. 事务回滚(Transaction Rollback)
- 记录修改前的旧值
- ROLLBACK时恢复数据
2. MVCC(Multi-Version Concurrency Control)⭐ 更重要
- 为其他事务提供数据的历史版本
- 实现一致性读(快照读)
- 支持不同隔离级别
```
### 11.2 隐式事务
```sql
-- 即使没有显式BEGIN,MySQL也会创建事务
-- 查看自动提交
SHOW VARIABLES LIKE 'autocommit';
-- autocommit = ON(默认)
-- 执行UPDATE
UPDATE users SET age = 30 WHERE id = 1;
-- MySQL内部实际执行:
BEGIN; -- 隐式开启
UPDATE users SET age = 30 WHERE id = 1;
├─ 生成Undo Log(旧值age=29)
└─ 生成Redo Log(新值age=30)
COMMIT; -- 隐式提交
-- 所以:"没有开启事务"这个说法不存在
```
### 11.3 MVCC的核心机制
#### 隐藏字段
```
用户看到的数据:
┌────┬──────┬─────┐
│ id │ name │ age │
├────┼──────┼─────┤
│ 1 │ Tom │ 30 │
└────┴──────┴─────┘
实际存储(含隐藏字段):
┌────┬──────┬─────┬───────────┬────────────┬───────────┐
│ id │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR│ DB_ROW_ID │
├────┼──────┼─────┼───────────┼────────────┼───────────┤
│ 1 │ Tom │ 30 │ 100 │ 0x12AB... │ (自增) │
└────┴──────┴─────┴───────────┴────────────┴───────────┘
↑ ↑
最后修改的事务ID 指向Undo Log的指针
```
#### 版本链
```
当前数据(最新版本):
┌────────────────────────────────┐
│ age=30, TRX_ID=100 │
│ ROLL_PTR ─────┐ │
└───────────────┼───────────────┘
↓
Undo Log (版本2)
┌───────────────────────┐
│ age=29, TRX_ID=98 │
│ ROLL_PTR ─────┐ │
└───────────────┼───────┘
↓
Undo Log (版本1)
┌──────────────────┐
│ age=25, TRX_ID=95│
│ ROLL_PTR = NULL │
└──────────────────┘
通过ROLL_PTR形成版本链
```
#### Read View
```
事务开始时(或第一次SELECT),创建Read View:
┌───────────────────────────────────┐
│ Read View │
├───────────────────────────────────┤
│ m_ids: [97, 99, 101] │ 当前活跃事务列表
│ min_trx_id: 97 │ 最小活跃事务ID
│ max_trx_id: 102 │ 下一个要分配的事务ID
│ creator_trx_id: 99 │ 创建此视图的事务ID
└───────────────────────────────────┘
```
#### 可见性判断
```python
def is_visible(data_trx_id, read_view):
# 1. 很早之前提交的
if data_trx_id < read_view.min_trx_id:
return True # 可见
# 2. 未来的事务(在Read View创建之后)
if data_trx_id >= read_view.max_trx_id:
return False # 不可见
# 3. 在活跃事务列表中
if data_trx_id in read_view.m_ids:
if data_trx_id == read_view.creator_trx_id:
return True # 自己的修改可见
else:
return False # 其他活跃事务的修改不可见
# 4. 不在活跃列表,说明已提交
return True
```
#### 读取流程
```
会话B要读取id=1的数据:
1. 获取当前行的最新版本
age=30, TRX_ID=100
2. 用Read View判断可见性
is_visible(100, read_view) → False
3. 顺着ROLL_PTR找Undo Log中的上一版本
age=29, TRX_ID=98
4. 再次判断
is_visible(98, read_view) → True
5. 返回 age=29
```
### 11.4 MVCC实战
```sql
-- 准备数据
CREATE TABLE test (id INT PRIMARY KEY, value INT);
INSERT INTO test VALUES (1, 100);
-- 会话A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT value FROM test WHERE id = 1;
-- 结果:100
-- 此时创建Read View
-- 会话B
UPDATE test SET value = 200 WHERE id = 1;
-- 生成Undo Log:{id:1, value:100}
-- 立即提交(autocommit=1)
-- 回到会话A
SELECT value FROM test WHERE id = 1;
-- 结果:仍然是100!
-- 因为会话B的事务ID对会话A的Read View不可见
-- 从Undo Log读取了旧版本
COMMIT;
-- 新事务
BEGIN;
SELECT value FROM test WHERE id = 1;
-- 结果:200(新Read View能看到已提交的修改)
```
### 11.5 Undo Log的清理(Purge)
```
生命周期:
┌─────────────────────────────────────┐
│ 1. 事务修改数据 → 生成Undo Log │
│ 2. 事务提交 → 标记为"可清理" │
│ 3. Purge线程检查: │
│ - 是否有活跃Read View需要它? │
│ - 检查所有活跃事务的Read View │
│ 4. 所有Read View都不需要 │
│ → 物理删除Undo Log │
└─────────────────────────────────────┘
长事务的危害:
- 占着Read View不释放
- 导致大量Undo Log无法清理
- History List Length不断增长
- 查询需要遍历很长的版本链
- 性能下降
```
**监控Undo:**
```sql
SHOW ENGINE INNODB STATUS\G
-- 关键指标:
History list length: 1234
-- 值很大说明有大量未清理的Undo Log
-- 查找长事务
SELECT
trx_id,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration,
trx_rows_modified
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
```
---
## 12. 事务隔离级别详解
### 12.1 MySQL支持的四个隔离级别
```sql
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- MySQL默认:REPEATABLE-READ
```
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 |
|---------|------|-----------|------|------|
| READ UNCOMMITTED | ✗ | ✗ | ✗ | ⭐⭐⭐⭐⭐ |
| READ COMMITTED | ✓ | ✗ | ✗ | ⭐⭐⭐⭐ |
| REPEATABLE READ | ✓ | ✓ | ✓* | ⭐⭐⭐ |
| SERIALIZABLE | ✓ | ✓ | ✓ | ⭐ |
*注:MySQL的REPEATABLE READ通过MVCC和Next-Key Lock基本解决了幻读。
### 12.2 READ UNCOMMITTED(读未提交)
**问题:脏读**
```sql
-- 会话A
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 未提交
-- 会话B
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- 读到900(脏读!)
-- 会话A回滚
ROLLBACK;
-- 会话B读到的数据是错误的
```
**使用场景:几乎不用**
### 12.3 READ COMMITTED(读已提交)
**特点:**
- 只能读到已提交的数据(解决脏读)
- 每次SELECT创建新的Read View
**问题:不可重复读**
```sql
-- 会话A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- 第一次:1000
-- 会话B
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;
-- 会话A(同一事务内)
SELECT balance FROM accounts WHERE id = 1;
-- 第二次:900(不可重复读)
```
**使用场景:**
- 高并发互联网应用
- 阿里巴巴部分业务
- 需要读最新数据的场景
### 12.4 REPEATABLE READ(可重复读)⭐
**MySQL默认隔离级别**
**特点:**
- 保证同一事务内多次读取结果一致
- 事务开始时创建Read View(或首次SELECT)
- 通过MVCC实现
**基本解决幻读:**
```sql
-- 快照读(无幻读)
BEGIN;
SELECT * FROM test; -- 3条记录
-- 另一会话插入数据并提交
-- INSERT INTO test VALUES (4, 40);
SELECT * FROM test;
-- 仍然3条记录(通过MVCC,读取快照)
-- 当前读(加锁防幻读)
SELECT * FROM test WHERE id > 0 FOR UPDATE;
-- 使用Next-Key Lock锁定范围
-- 其他会话无法在范围内插入
```
**Next-Key Lock机制:**
```
Next-Key Lock = Record Lock + Gap Lock
例如索引值:1, 5, 10
SELECT * FROM test WHERE id > 5 FOR UPDATE;
锁定范围:
- (5, 10] - Next-Key Lock
- (10, +∞) - Gap Lock
效果:其他事务无法在(5, +∞)范围内插入
```
**使用场景:**
- 大多数OLTP业务(默认)
- 转账、订单处理等
### 12.5 SERIALIZABLE(串行化)
**特点:**
- 最高隔离级别
- 所有SELECT自动变成SELECT ... LOCK IN SHARE MODE
- 完全串行化
```sql
-- 会话A
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM accounts WHERE id = 1;
-- 自动加共享锁(S锁)
-- 会话B
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 被阻塞(等待会话A释放S锁)
```
**锁互斥关系:**
```
S锁 X锁
S锁 ✓ ✗
X锁 ✗ ✗
导致大量阻塞,性能极差
```
**使用场景:**
- 极少使用
- 金融核心账务
- 关键报表生成
### 12.6 隔离级别的设置
```sql
-- 全局设置(新连接生效)
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 会话级设置
SET SESSION transaction_isolation = 'READ-COMMITTED';
-- 单个事务设置
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 只影响这个事务
COMMIT;
-- 配置文件(永久)
[mysqld]
transaction-isolation = READ-COMMITTED
```
### 12.7 不同数据库的默认值
```
MySQL/InnoDB: REPEATABLE READ
Oracle: READ COMMITTED
PostgreSQL: READ COMMITTED
SQL Server: READ COMMITTED
SQLite: SERIALIZABLE
```
**MySQL选择REPEATABLE READ的历史原因:**
```
早期MySQL的Binlog只支持STATEMENT格式
- STATEMENT在READ COMMITTED下可能导致主从不一致
- 例如:UPDATE ... LIMIT无ORDER BY
主库和从库可能选中不同的行
REPEATABLE READ + 间隙锁可避免这个问题
现代MySQL(5.1+):
- Binlog支持ROW格式(推荐)
- ROW格式不受隔离级别影响
- 理论上可改用READ COMMITTED
- 但为兼容性,仍保持REPEATABLE READ为默认
```
---
## 总结:知识体系图
```
MySQL存储与事务机制
│
├─ 存储层
│ ├─ 磁盘存储(.ibd文件)
│ │ └─ 数据页和索引页的永久存储
│ │
│ └─ 内存缓存(Buffer Pool)⭐
│ ├─ 热点数据页缓存
│ ├─ 改进的LRU算法(Young/Old区)
│ ├─ 脏页管理(Flush List)
│ └─ Change Buffer优化
│
├─ 读操作
│ ├─ 全表扫描的滚动读取
│ ├─ Buffer Pool的工作台角色
│ ├─ 数据传输(磁盘→Buffer Pool→Server→网络→客户端)
│ └─ 客户端流式读取的重要性
│
├─ 写操作
│ ├─ WAL机制(Write-Ahead Logging)
│ ├─ Buffer Pool中修改(产生脏页)
│ ├─ Undo Log生成(支持回滚和MVCC)
│ ├─ Redo Log生成(崩溃恢复保障)
│ ├─ Binlog生成(复制和备份)
│ ├─ 后台异步刷脏
│ └─ 大批量更新的分批策略
│
├─ 日志系统
│ ├─ Redo Log(物理日志,InnoDB层)
│ │ ├─ 记录数据页的物理修改
│ │ ├─ 循环使用,固定大小
│ │ ├─ 支持崩溃恢复
│ │ └─ 顺序I/O,性能优秀
│ │
│ ├─ Binlog(逻辑日志,Server层)
│ │ ├─ STATEMENT/ROW/MIXED格式
│ │ ├─ 支持主从复制
│ │ ├─ 支持备份恢复
│ │ └─ 处理非幂等操作
│ │
│ └─ Undo Log(逻辑日志,InnoDB层)
│ ├─ 支持事务回滚
│ ├─ 支持MVCC(更重要)
│ ├─ 形成版本链
│ └─ Purge线程清理
│
└─ 事务机制
├─ MVCC(多版本并发控制)
│ ├─ 隐藏字段(TRX_ID, ROLL_PTR)
│ ├─ Read View
│ ├─ 版本链
│ └─ 可见性判断
│
└─ 隔离级别
├─ READ UNCOMMITTED(几乎不用)
├─ READ COMMITTED(高并发场景)
├─ REPEATABLE READ(MySQL默认)⭐
│ ├─ MVCC实现
│ ├─ Next-Key Lock
│ └─ 基本解决幻读
└─ SERIALIZABLE(极少使用)
```
---
## 快速复习检查清单
### 核心概念
- [ ] Buffer Pool是什么?作用是什么?
- [ ] 索引和数据最终存储在哪里?
- [ ] 热点数据如何识别?(LRU算法)
- [ ] 脏页是什么?如何管理?
### 读操作
- [ ] 全表扫描时Buffer Pool如何工作?
- [ ] 数据如何从磁盘到达客户端?
- [ ] Buffer Pool参与数据传输吗?
- [ ] 客户端的两种读取模式是什么?
### 写操作
- [ ] UPDATE操作的完整流程?
- [ ] WAL机制是什么?有什么优势?
- [ ] Change Buffer的作用?
- [ ] 大批量更新为什么要分批?
### 日志系统
- [ ] Redo Log记录什么?格式是什么?
- [ ] Binlog的三种格式及其区别?
- [ ] 如何处理NOW()等非幂等操作?
- [ ] Undo Log的双重用途?
- [ ] 三种日志的区别和联系?
### 事务机制
- [ ] MVCC的核心机制?
- [ ] 四个隔离级别及其特点?
- [ ] MySQL默认隔离级别是什么?为什么?
- [ ] 如何解决幻读问题?
- [ ] 长事务的危害?
### 性能调优
- [ ] 如何监控Buffer Pool命中率?
- [ ] 如何监控脏页比例?
- [ ] 如何发现长事务?
- [ ] 全表扫描如何优化?
- [ ] 大批量更新的最佳实践?
---
## 关键配置参数速查
```sql
-- Buffer Pool
innodb_buffer_pool_size = 24G -- 核心参数,建议物理内存50-80%
innodb_old_blocks_pct = 37 -- Old区占比
innodb_old_blocks_time = 1000 -- Old区停留时间(ms)
-- 脏页管理
innodb_max_dirty_pages_pct = 75 -- 脏页上限
innodb_max_dirty_pages_pct_lwm = 10 -- 低水位
innodb_page_cleaners = 4 -- Page Cleaner线程数
innodb_io_capacity = 2000 -- I/O能力
innodb_io_capacity_max = 4000 -- 最大I/O能力
-- Redo Log
innodb_redo_log_capacity = 8G -- MySQL 8.0.30+
innodb_log_file_size = 1G -- 旧版本
innodb_log_files_in_group = 2 -- 旧版本
innodb_flush_log_at_trx_commit = 1 -- 刷盘策略(1最安全)
-- Binlog
log_bin = ON
binlog_format = ROW -- 推荐ROW格式
sync_binlog = 1 -- 每次提交刷盘
-- 事务
transaction_isolation = REPEATABLE-READ -- 默认隔离级别
autocommit = ON -- 自动提交
-- 网络
net_buffer_length = 16384 -- 16KB
max_allowed_packet = 64M -- 最大包大小
```
11/17/2025
MySQL存储与事务机制完全指南
数据迁移一般是什么场景需求,有哪些难点
数据迁移(Data Migration)是IT领域中一项高风险、高复杂度的核心工作。简单来说,就是将数据从一个存储系统转移到另一个存储系统。 以下详细解析数据迁移的**典型场景**以及面临的**核心难点**。 --- ### 一、 数据迁移的常见场景需求 数据迁移通常...