MySQL InnoDB内存结构,增删改查时怎么运行的

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是就地更新 还是删除+插入,取决于:

  1. 主键没变,行大小没变​ → 就地更新

  2. 主键变了或行大小变了​ → 删除旧行 + 插入新行

  3. 无论哪种,都会产生Undo LogRedo Log"

问题2:DELETE后数据真的删除了吗?

回答

"不是立即物理删除,而是:

  1. 逻辑删除:标记行头删除标志

  2. 索引删除:从索引中删除

  3. Purge清理:后台线程异步回收空间

  4. 页合并:如果页太空,会合并相邻页"

问题3:事务提交时发生了什么?

回答

"关键两步:

  1. Redo Log刷盘:保证持久性

  2. 释放锁:让其他事务能访问

    提交不保证数据页刷盘,数据页是后台异步刷的"

问题4:MVCC如何工作的?

复制代码
-- 行记录结构
[事务ID] [回滚指针] [删除标志] [列1] [列2]...
   ↓         ↓
当前事务   指向Undo Log
             ↓
         历史版本链

"每次查询:

  1. 读当前行

  2. 检查事务ID

  3. 如果对本事务不可见

  4. 通过回滚指针找历史版本"

九、性能优化点

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 = 日志刷盘 → 释放锁

记住核心先日志,后数据;先内存,后磁盘;有版本,有锁控

相关推荐
杨了个杨89822 小时前
PostgreSQL(pgSQL)常用操作
数据库·postgresql·oracle
蝈蝈(GuoGuo)2 小时前
SQL Server 中指定范围分页取数详解
数据库
慕白Lee2 小时前
【PostgreSQL】日常总结
数据库·postgresql
sc.溯琛3 小时前
MySQL 视图实战:简化查询与数据安全管控指南
数据库
风月歌3 小时前
小程序项目之校园二手交易平台小程序源代码(源码+文档)
java·数据库·mysql·小程序·毕业设计·源码
西格电力科技3 小时前
绿电直连架构适配技术的发展趋势
大数据·服务器·数据库·架构·能源
计算机毕设VX:Fegn08953 小时前
计算机毕业设计|基于springboot + vue汽车销售系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·汽车·课程设计
@小白向前冲3 小时前
数据库创表(方便自己查看)
数据库·mysql
散一世繁华,颠半世琉璃3 小时前
高并发下的 Redis 优化:如何利用HeavyKeeper快速定位热 key
数据库·redis·缓存