MySQL服务器
│
▼
InnoDB存储引擎
├── 内存结构 (In-Memory Structures)
│ ├── Buffer Pool (缓冲池) ← 最重要!
│ ├── Change Buffer (变更缓冲)
│ ├── Log Buffer (日志缓冲)
│ └── Adaptive Hash Index (自适应哈希)
│
└── 磁盘结构 (On-Disk Structures)
├── 系统表空间 (ibdata1)
├── 独立表空间 (.ibd文件)
├── 重做日志 (ib_logfile0/1)
└── 撤销日志 (undo logs)
🎯 数据库增删改查时,InnoDB内部做了什么?(全流程详解)
一、准备工作:先了解核心组件
InnoDB核心组件(记住它们!):
1. Buffer Pool - 数据缓存(内存)
2. Redo Log - 重做日志(保证持久性,在数据库宕机,恢复时使用)
3. Undo Log - 撤销日志(回滚/MVCC,在写错数据时,恢复数据使用)
4. 锁机制 - 行锁/表锁
5. MVCC - 多版本并发控制
6. 索引 - B+树索引结构
二、SELECT查询(读操作)
场景:SELECT * FROM users WHERE id = 100
执行流程:
// 伪代码演示内部过程
public 数据 执行SELECT(int id) {
// 1. 开启事务(如果是可重复读隔离级别)
if (!自动提交模式) {
开始事务();
}
// 2. 检查Buffer Pool(缓存)
if (数据页 在 BufferPool 中) {
// 缓存命中 ✓
数据 = BufferPool.获取数据(id);
} else {
// 3. 缓存未命中,从磁盘加载
// 通过B+树索引找到数据页
数据页 = 磁盘.读取页(通过B+树找到页号);
// 4. 放入Buffer Pool
BufferPool.缓存页(数据页);
数据 = 数据页.获取行(id);
}
// 5. MVCC版本检查
if (隔离级别 >= REPEATABLE_READ) {
// 检查数据版本是否对本事务可见
if (!数据.对本事务可见()) {
数据 = 从Undo Log读历史版本();
}
}
// 6. 返回数据
return 数据;
}
详细步骤分解:
步骤1:解析SQL
↓
步骤2:查询优化器选择执行计划
↓
步骤3:通过索引查找(假设id是主键)
├── 走聚簇索引B+树
├── 从根节点→中间节点→叶子节点
└── 找到对应数据页
↓
步骤4:检查Buffer Pool
├── 命中:直接从内存读 ✓
└── 未命中:从.ibd文件加载到Buffer Pool
↓
步骤5:在页内查找具体行
├── 用页目录二分查找
└── 找到id=100的行
↓
步骤6:MVCC可见性判断
├── 比较行的事务ID
├── 如果对本事务不可见
└── 从Undo Log读历史版本
↓
步骤7:返回数据
三、INSERT插入
场景:INSERT INTO users (id, name) VALUES (101, '张三')
执行流程:
-- 开启事务查看内部过程
START TRANSACTION;
INSERT INTO users VALUES (101, '张三');
-- 先不提交,看看InnoDB内部状态
内部操作:
public void 执行INSERT(行数据) {
// 1. 申请事务ID
int 事务ID = 分配事务ID();
// 2. 写Undo Log(用于回滚)
撤销日志.记录("插入前id=101不存在");
// 3. 写Redo Log Buffer
重做日志缓冲.记录("要插入id=101,name=张三");
// 4. 在Buffer Pool中插入
// 找到要插入的数据页
数据页 = BufferPool.获取页(对应页号);
// 5. 检查唯一约束
if (违反唯一约束) {
抛出异常();
}
// 6. 插入行
数据页.插入行(行数据);
// 7. 标记为脏页
数据页.标记为脏();
// 8. 更新索引
// 如果是自增主键,更新自增计数器
更新自增ID();
// 9. 处理二级索引
if (有二级索引) {
// 如果索引页在内存 → 直接更新
// 如果索引页不在内存 → 记到Change Buffer
ChangeBuffer.记录索引变更();
}
}
详细步骤分解:
步骤1:检查约束
├── 主键唯一性
├── 外键约束
└── NOT NULL约束
↓
步骤2:写Undo Log
├── 记录"插入前状态"
└── 用于事务回滚
↓
步骤3:写Redo Log Buffer
├── 记录"要做什么"
└── 保证崩溃恢复
↓
步骤4:Buffer Pool操作
├── 找到要插入的页
├── 如果页不在内存,从磁盘加载
├── 插入新行
└── 标记为脏页
↓
步骤5:更新索引
├── 主键索引:更新聚簇索引
├── 二级索引:可能用Change Buffer
└── 如果是自增主键,更新内存计数器
↓
步骤6:事务控制
├── 获取行锁(防止其他事务修改)
└── 生成事务ID
↓
步骤7:返回结果
四、UPDATE更新
场景:UPDATE users SET name='李四' WHERE id = 100
特别注意 :UPDATE是DELETE + INSERT的组合
执行流程:
public void 执行UPDATE(int id, 新数据) {
// 1. 定位要更新的行
旧行 = 执行SELECT(id); // 走上面SELECT的流程
// 2. 检查锁冲突
if (旧行.被其他事务锁定()) {
等待锁释放();
}
// 3. 获取行锁
获取行锁(id);
// 4. 写Undo Log
撤销日志.记录("更新前: id=100,name=张三");
// 5. 写Redo Log Buffer
重做日志缓冲.记录("要更新id=100,name=李四");
// 6. 在Buffer Pool中修改
数据页 = BufferPool.获取页(旧行所在页);
// 7. 删除旧版本(逻辑删除)
// 实际是:在行记录头标记"已删除"
数据页.标记行删除(旧行);
// 8. 插入新版本
新行 = 复制旧行并修改();
数据页.插入行(新行);
// 9. 更新索引
if (name有索引) {
删除旧索引项();
插入新索引项();
}
// 10. 更新统计信息
更新表统计信息();
}
详细步骤分解:
步骤1:定位数据
├── 通过索引找到要更新的行
└── 检查是否在Buffer Pool
↓
步骤2:加锁
├── 获取行锁(排他锁)
├── 如果被锁,等待
└── 检查间隙锁(防止幻读)
↓
步骤3:版本控制
├── 记录旧版本到Undo Log
└── 生成新版本的事务ID
↓
步骤4:写Redo Log
├── 记录变更到Redo Log Buffer
└── 保证持久性
↓
步骤5:修改Buffer Pool
├── 逻辑删除旧行
├── 插入新行
└── 标记页为脏
↓
步骤6:更新索引
├── 更新聚簇索引
├── 更新二级索引(可能用Change Buffer)
└── 如果是大字段,可能用溢出页
↓
步骤7:清理旧版本
├── 旧版本在Undo Log中
└── Purge线程异步清理
五、DELETE删除
场景:DELETE FROM users WHERE id = 100
重要 :DELETE是逻辑删除,不是物理删除
执行流程:
public void 执行DELETE(int id) {
// 1. 定位要删除的行
要删除的行 = 执行SELECT(id);
// 2. 获取锁
获取行锁(id);
// 3. 写Undo Log
撤销日志.记录("删除前: id=100的所有数据");
// 4. 写Redo Log Buffer
重做日志缓冲.记录("要删除id=100");
// 5. 逻辑删除
数据页 = BufferPool.获取页(要删除的行所在页);
// 逻辑删除 = 在行记录头标记删除标志
数据页.标记行删除(要删除的行);
// 6. 更新索引
// 从所有索引中删除对应项
删除所有索引项(要删除的行);
// 7. 更新统计信息
更新表统计信息();
// 注意:数据还在磁盘上!
// 只是标记为删除,Purge线程后续清理
}
详细步骤分解:
步骤1:查找数据
↓
步骤2:加锁
├── 获取行锁
└── 检查引用约束(外键)
↓
步骤3:记录Undo Log
├── 保存完整行数据
└── 用于回滚
↓
步骤4:逻辑删除
├── 在行记录头设置删除标志
└── 不立即释放空间
↓
步骤5:更新索引
├── 删除主键索引项
├── 删除二级索引项
└── 索引页可能标记删除
↓
步骤6:Purge处理
└── 后台Purge线程真正清理
六、事务提交时
场景:执行COMMIT
提交的核心 :WAL(Write-Ahead Logging)先写日志
public void 执行COMMIT() {
// 1. 生成提交标记
生成提交记录();
// 2. Redo Log Buffer刷盘(最重要!)
// 保证:即使Buffer Pool数据没刷盘,也能恢复
RedoLogBuffer.刷盘();
// 3. 释放锁
// 行锁、间隙锁等都释放
释放所有锁();
// 4. 清理Undo Log
// 如果事务已提交,Undo Log可以清理
// 但还要保留一段时间(用于MVCC)
// 5. 更新内存状态
事务管理器.标记事务完成();
// 6. 响应客户端
返回"提交成功";
}
七、实际例子串联
示例:银行转账
-- 转账:A给B转100元
START TRANSACTION;
-- 1. 查A余额
SELECT balance FROM accounts WHERE id = 1;
-- InnoDB: 通过索引查找,Buffer Pool缓存,MVCC判断可见性
-- 2. 扣A的钱
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- InnoDB: 定位行,加锁,写Undo Log,写Redo Log,修改Buffer Pool
-- 3. 加B的钱
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- InnoDB: 同样流程
COMMIT;
-- InnoDB: Redo Log刷盘,释放锁,事务完成
八、面试官常问的问题
问题1:UPDATE到底是怎样执行的?
回答:
"UPDATE是就地更新 还是删除+插入,取决于:
-
主键没变,行大小没变 → 就地更新
-
主键变了或行大小变了 → 删除旧行 + 插入新行
-
无论哪种,都会产生Undo Log 和Redo Log"
问题2:DELETE后数据真的删除了吗?
回答:
"不是立即物理删除,而是:
-
逻辑删除:标记行头删除标志
-
索引删除:从索引中删除
-
Purge清理:后台线程异步回收空间
-
页合并:如果页太空,会合并相邻页"
问题3:事务提交时发生了什么?
回答:
"关键两步:
-
Redo Log刷盘:保证持久性
-
释放锁:让其他事务能访问
提交不保证数据页刷盘,数据页是后台异步刷的"
问题4:MVCC如何工作的?
-- 行记录结构
[事务ID] [回滚指针] [删除标志] [列1] [列2]...
↓ ↓
当前事务 指向Undo Log
↓
历史版本链
"每次查询:
-
读当前行
-
检查事务ID
-
如果对本事务不可见
-
通过回滚指针找历史版本"
九、性能优化点
1. 写操作优化
-- 批量插入优化
INSERT INTO t VALUES (1),(2),(3); -- 一次事务
-- 比3次单独插入快,因为:1次Redo Log刷盘,1次锁开销
-- 禁用自动提交
SET autocommit = 0; -- 减少事务开销
2. 读操作优化
-- 利用覆盖索引
SELECT id FROM users WHERE name = '张三';
-- 如果name有索引,且只需要id,不用回表
3. 事务优化
-- 小事务更快
START TRANSACTION;
-- 多个操作
COMMIT; -- 尽早提交,释放锁
十、一句话总结
增删改查在InnoDB内部:
SELECT = 找缓存 → 读磁盘 → MVCC判断
INSERT = 写日志 → 加锁 → 写内存 → 更索引
UPDATE = 找行 → 加锁 → 记旧版 → 写新版
DELETE = 找行 → 加锁 → 标记删除 → 等清理
COMMIT = 日志刷盘 → 释放锁
记住核心 :先日志,后数据;先内存,后磁盘;有版本,有锁控