好的,我们继续按照原大纲的结构,对数据库(★★★★★)部分进行详细补充。这部分以 MySQL 为核心,涵盖存储引擎、索引、事务隔离、日志体系、锁机制,以及 SQL 优化的完整方法论。
三、数据库(核心能力 20%)
数据库是绝大多数业务系统的最终一致性保障。高级工程师不仅要能写出复杂的 SQL,更要理解数据库内核的工作机制,能够在高并发场景下设计出高性能、高可用的数据存储方案。
3.1 MySQL 体系架构概览
MySQL 的整体架构分为三层:
| 层级 | 组件 | 职责 |
|---|---|---|
| 客户端层 | Connectors(JDBC、ODBC、Python 等) | 建立连接、认证、权限校验 |
| Server 层 | 连接池、SQL 接口、解析器、优化器、缓存(8.0 已移除) | SQL 解析、优化、执行计划生成 |
| 存储引擎层 | InnoDB、MyISAM、Memory 等 | 数据的实际存储和读取、事务支持 |
InnoDB vs MyISAM 核心对比(★★★★★ 必问)
| 特性 | InnoDB | MyISAM |
|---|---|---|
| 事务支持 | ✅ ACID | ❌ |
| 行级锁 | ✅ | ❌(表级锁) |
| 外键约束 | ✅ | ❌ |
| MVCC | ✅ | ❌ |
| 聚簇索引 | ✅ | ❌ |
| 全文索引 | ✅(5.6+) | ✅ |
| 崩溃恢复 | ✅(Redo Log) | ❌ |
| 适用场景 | OLTP 高并发读写 | 读多写少、数据仓库 |
3.2 索引核心原理(★★★★★)
3.2.1 聚簇索引(Clustered Index)与二级索引(Secondary Index)
聚簇索引(又称主键索引):
- InnoDB 中,表数据本身就是索引,数据行存储在 B+Tree 的叶子节点中。
- 如果定义了主键,则主键作为聚簇索引;如果没有主键,则使用第一个唯一非空索引;如果还没有,则 InnoDB 隐式生成 6 字节的
ROW_ID作为聚簇索引。 - 特性:叶子节点存储整行数据(完整记录)。
二级索引(非聚簇索引、辅助索引):
- 叶子节点存储的是索引列值 + 主键值。
- 查询过程:先扫描二级索引获得主键值,再回表到聚簇索引查询完整行数据。
- 如果查询列全部在二级索引中,则无需回表,称为覆盖索引(Covering Index)。
3.2.2 B+Tree 索引结构深度剖析
MySQL 使用 B+Tree 而非 B-Tree 的原因(★★★★★):
- 查询稳定性:所有数据都在叶子节点,每次查询的 IO 次数固定(树高度)。
- 范围查询高效:叶子节点通过链表连接,范围查询只需遍历链表。
- 更高的扇出(Fan-out):非叶子节点只存索引值,不存数据,页大小固定,可存储更多索引键 → 树更矮 → IO 更少。
B+Tree 层数与数据量估算:
- 假设 B+Tree 页大小 16KB,主键为 bigint(8字节)+ 指针(6字节)= 14字节,一个页可存约 1170 个索引条目。
- 高度为 3 时:根节点 1 个页,第二层 1170 个页,叶子层 1170 × 1170 = 约 136 万个页。
- 每个叶子页可存约 16 行数据(按行大小 1KB 估算)→ 总数据量 ≈ 136 万 × 16 ≈2176 万行。
- 结论:3 层 B+Tree 足以支撑千万级数据,索引查询仅需 2~3 次磁盘 IO。
3.2.3 索引失效场景(★★★★★ 高频 SQL 优化考点)
| 场景 | 原因 | 示例 |
|---|---|---|
| 对索引列使用函数 | 计算破坏索引有序性 | WHERE DATE(create_time) = '2024-01-01'→ 应改为create_time BETWEEN ... AND ... |
| 隐式类型转换 | 字符串列与数字比较 | WHERE phone = 13800001111(phone 为 varchar)→ 需要加引号 |
使用!=或<> | 不等值无法利用索引有序性 | 可能导致全表扫描 |
使用LIKE '%xxx' | 通配符在开头无法匹配前缀 | LIKE 'xxx%'可以走索引 |
| OR 条件中左右列索引不一致 | MySQL 难以选择最优执行计划 | 可将 OR 拆分为 UNION |
| 联合索引违反最左前缀原则 | 索引多列有序性依赖左列 | (a, b, c)索引,查询条件只有b或c不走索引 |
| 范围查询后的列 | 范围查询列后的索引列失效 | WHERE a > 1 AND b = 2,只能用到 a 的索引 |
3.2.4 联合索引(Composite Index)最左前缀原则
核心规则:联合索引(a, b, c)实际上等价于三个索引(a)、(a, b)、(a, b, c)。查询条件必须包含最左列a,索引才能生效。
可走索引的查询条件:
WHERE a = 1✅WHERE a = 1 AND b = 2✅WHERE a = 1 AND b = 2 AND c = 3✅WHERE a = 1 AND c = 3✅(仅 a 列走索引,c 列无法利用)WHERE a > 1 AND b = 2✅(a 走范围,b 失效)
不走索引的条件:
WHERE b = 2❌WHERE c = 3❌WHERE b = 2 AND c = 3❌
索引下推(ICP,Index Condition Pushdown):
- MySQL 5.6+ 引入。在没有 ICP 之前,即使索引部分生效,也要回表取完整行再判断其他条件。
- 启用 ICP 后,可以在索引层面直接过滤掉不满足条件的记录,减少回表次数。
- 例如
WHERE a > 1 AND b = 2,使用 ICP 时,在遍历索引(a)时直接判断 b=2,不满足则跳过,无需回表。
3.2.5 MRR(Multi-Range Read)优化
- 针对范围查询 + 回表的场景。传统做法:按索引顺序扫描,每次回表随机读取磁盘(随机 IO)。
- MRR 优化:将查到的行主键先排序(放入 read_rnd_buffer),再按主键顺序回表读取,将随机 IO 转为顺序 IO。
- 生效条件:
mrr=on,mrr_cost_based=off强制开启。
3.3 事务隔离级别与 MVCC(★★★★★)
3.3.1 SQL 标准定义的四种隔离级别
| 隔离级别 | 脏读(Dirty Read) | 不可重复读(Non-Repeatable Read) | 幻读(Phantom Read) |
|---|---|---|---|
| READ UNCOMMITTED | ✅ 可能 | ✅ 可能 | ✅ 可能 |
| READ COMMITTED | ❌ | ✅ 可能 | ✅ 可能 |
| REPEATABLE READ(MySQL 默认) | ❌ | ❌(MVCC 保证) | ❌(MVCC + 间隙锁保证) |
| SERIALIZABLE | ❌ | ❌ | ❌(读加锁) |
3.3.2 MVCC(Multi-Version Concurrency Control)—— 多版本并发控制
MVCC 是 InnoDB 实现 RC 和 RR 隔离级别的核心技术,读写不互斥,极大提升了并发性能。
三个核心组件:
隐藏列(Hidden Columns):
DB_TRX_ID:创建或最后一次修改该行的事务 ID。DB_ROLL_PTR:回滚指针,指向 Undo Log 中该行的旧版本记录。DB_ROW_ID:当表没有主键时,InnoDB 用该列作为聚簇索引。
Undo Log(回滚日志):
- 记录数据行的历史版本(类似于 Git 的提交历史)。
- 通过
DB_ROLL_PTR串联成版本链。
Read View(读视图):
- 是什么:当前事务可见的数据快照。包含四个重要属性:
creator_trx_id:当前事务 ID。up_limit_id:当前活跃的最小事务 ID(即低水位)。low_limit_id:当前未分配的最小事务 ID(即高水位,大于等于它的事务都不可见)。trx_ids:当前活跃事务 ID 列表。
- 可见性规则(RC vs RR 的核心差异):
- RC 级别:每次执行 SELECT 语句时重新生成Read View。
- RR 级别:事务内第一次执行 SELECT 时生成Read View,整个事务期间复用,不会更新。
- 是什么:当前事务可见的数据快照。包含四个重要属性:
3.3.3 RR 级别下如何避免幻读?
- 快照读(Snapshot Read):
SELECT ...查询走 MVCC 版本链,无论其他事务插入了多少新行,当前事务看到的仍然是创建 Read View 时的数据快照,因此幻读被 MVCC 天然屏蔽。 - 当前读(Current Read):
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT、UPDATE、DELETE等操作读取的是最新版本。针对当前读的幻读问题,InnoDB 引入Next-Key Lock(行锁 + 间隙锁的组合)来锁定范围。
3.3.4 为什么 RR 可以避免幻读?(总结)
MVCC(快照读)解决了快照读的幻读,Next-Key Lock(当前读)解决了当前读的幻读。两者结合使得 MySQL InnoDB 在 RR 级别下完美避免了幻读。
3.4 InnoDB 事务日志体系(★★★★★)
3.4.1 Redo Log(重做日志)—— 保证持久性(Durability)
- 作用:数据库崩溃恢复时,重放 Redo Log,确保已提交事务的修改不丢失(WAL 技术——Write-Ahead Logging,先写日志,后写磁盘)。
- 存储:物理日志,循环写入固定大小的
ib_logfile0、ib_logfile1文件(两个文件轮换使用,大小由innodb_log_file_size控制)。 - 刷盘策略:
innodb_flush_log_at_trx_commit参数控制:0:每秒刷盘(最高性能,可能丢失 1 秒数据)1(默认):每次事务提交刷盘(最安全,性能最低)2:每次提交写入 OS Cache,每秒刷盘(性能和安全的折中)
3.4.2 Undo Log(回滚日志)—— 保证原子性(Atomicity)
- 作用:事务回滚时将数据恢复到修改前的状态。
- 存储:逻辑日志,存储在 Undo Tablespace(
undo_001、undo_002),记录的是如何逆操作(INSERT → DELETE,DELETE → INSERT,UPDATE → 旧值)。 - 清理:Purge 线程异步清理不再需要的 Undo Log(当没有事务需要访问旧版本数据时)。
3.4.3 Binlog(归档日志)—— 保证主从复制与数据恢复
- 作用:记录所有逻辑修改操作(DDL + DML),用于主从复制、数据备份恢复、数据审计。
- 格式:
STATEMENT(记录 SQL 原文)、ROW(记录行级别变化,推荐)、MIXED(混合模式)。 - 与 Redo Log 的区别:
| 特性 | Redo Log | Binlog |
|---|---|---|
| 存储引擎 | InnoDB 特有 | MySQL Server 层通用 |
| 日志内容 | 物理日志(页级别修改) | 逻辑日志(SQL 或行变化) |
| 写入时机 | 事务执行过程中不断写入 | 事务提交时写入 |
| 空间管理 | 循环覆盖,固定大小 | 追加写入,可设置过期时间 |
| 主要用途 | 崩溃恢复(Crash-safe) | 主从复制、数据恢复 |
3.4.4 两阶段提交(2PC)—— 保证 Redo Log 和 Binlog 一致性
问题:MySQL 中 Redo Log 和 Binlog 是两套独立的日志系统,若无协调机制,在数据库崩溃时可能出现“Redo Log 有数据但 Binlog 没记录”或相反的情况,导致主从数据不一致。
解决方案:两阶段提交(2PC),由XA RECOVER协调:
- Prepare 阶段:写 Redo Log,状态为
PREPARE,刷盘。 - Commit 阶段:写 Binlog,刷盘。Binlog 写入成功后,将 Redo Log 状态改为
COMMIT,事务正式完成。
崩溃恢复规则:
- 如果 Redo Log 处于
PREPARE状态且 Binlog 完整(已写入),则提交该事务。 - 如果 Redo Log 处于
PREPARE状态但 Binlog 未写入,则回滚该事务。 - 如果 Redo Log 已
COMMIT,则直接提交。
3.5 InnoDB Buffer Pool —— 缓冲池
- 作用:缓存磁盘数据页到内存,减少磁盘 IO,是 MySQL 性能优化的核心之一。
- 结构:LRU 链表 + Flush 链表 + Free 链表。
- LRU 优化:
- 采用冷热分离的 LRU(
old区占 3/8,young区占 5/8)。 - 新数据页先放入
old区头部。如果数据在old区停留超过innodb_old_blocks_time(默认 1000ms),再次访问时才提升到young区。 - 目的:防止全表扫描将热点数据挤出 LRU(预读 + 全表扫描污染问题)。
- 采用冷热分离的 LRU(
- 预读机制(Read-Ahead):
- 线性预读:顺序读取一个区的多个页后,异步预读后续页。
- 随机预读:不连续页访问时预读(5.5 后默认关闭)。
3.6 Change Buffer(写缓冲区,原 Insert Buffer)
- 作用:缓存二级索引的修改操作(INSERT、UPDATE、DELETE),减少随机磁盘 IO。MySQL 5.5 中仅支持 INSERT,5.6+ 扩展为支持 UPDATE/DELETE,因此改名为 Change Buffer。
- 工作原理:当修改二级索引页时,如果该页不在 Buffer Pool 中,不立即读取磁盘页,而是将修改记录在 Change Buffer 中。后续该页被读入 Buffer Pool 时,再合并(Merge)这些修改。
- 适用条件:非唯一二级索引(唯一索引需要立即检查唯一性约束,无法延迟)。
3.7 Double Write(双写)—— 解决页断裂(Partial Write)
- 问题:InnoDB 页大小 16KB,操作系统每次写 4KB,若写入 4KB 时系统崩溃,会导致数据页损坏(Partial Write)。
- 解决方案:
- 先将脏页复制到内存中的Double Write Buffer(2MB)。
- 将 Double Write Buffer 顺序写入磁盘的共享表空间(ibdata1)中(双写区域)。
- 然后再将脏页写入实际数据文件。
- 恢复:如果数据页损坏,从双写区域拷贝备份进行修复。
- 注意:Double Write 对性能有约 5%-10% 的影响,但为了保证数据完整性,建议保持开启(5.6+ 默认开启,不能关闭)。
3.8 InnoDB 锁机制(★★★★★)
3.8.1 锁粒度
| 锁类型 | 说明 |
|---|---|
| 全局锁(Global Lock) | FLUSH TABLES WITH READ LOCK,整个库只读,用于备份 |
| 表级锁(Table Lock) | LOCK TABLES t READ/WRITE,MyISAM 默认,InnoDB 也可用 |
| 行级锁(Row Lock) | InnoDB 默认,锁定单行记录,并发性能最高 |
| 间隙锁(Gap Lock) | 锁定索引记录之间的间隙,防止幻读(RR 级别默认) |
| Next-Key Lock | 行锁 + 间隙锁的组合,锁定记录本身及其前后的间隙 |
| 意向锁(Intention Lock) | 表级锁,表示事务想要在行上加共享锁/排他锁,用于避免 DDL 与行锁冲突 |
3.8.2 共享锁(S) vs 排他锁(X)
- 共享锁(S):
SELECT ... LOCK IN SHARE MODE,允许其他事务读取,但不能修改。 - 排他锁(X):
SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE自动加 X 锁,其他事务不能读不能写。 - 兼容矩阵:
| X | S | |
|---|---|---|
| X | ❌ 冲突 | ❌ 冲突 |
| S | ❌ 冲突 | ✅ 兼容 |
3.8.3 Next-Key Lock 与 Gap Lock 深入分析
- Gap Lock 出现时机(RR 级别 + 当前读):
- 查询条件命中范围或未命中记录时,Gap Lock 锁定不存在记录的间隙。
- 示例:表有 id = 5, 10, 15。
SELECT * FROM t WHERE id > 6 FOR UPDATE会在 (5, 10)、(10, 15)、(15, +∞) 三个区间加 Gap Lock,阻止其他事务插入 id = 7、9、12、20 等记录。
- Next-Key Lock 的锁定区间:
- 对于
id = 10查询,Next-Key Lock 锁定区间为 (5, 10] + (10, 15] 即 (5, 15)。
- 对于
- RC 级别不使用 Gap Lock,这是 RC 级别无法避免幻读的根本原因。
3.8.4 死锁(Deadlock)
死锁的四个必要条件(与 OS 一致):
- 互斥条件(Mutual Exclusion)
- 持有并等待(Hold and Wait)
- 不可抢占(No Preemption)
- 循环等待(Circular Wait)
MySQL 死锁的典型场景:
- 并发加锁顺序不一致:事务 A 先锁 table1 再锁 table2,事务 B 先锁 table2 再锁 table1。
- 唯一键冲突:两个并发插入相同唯一键,先到的持有行锁,后到的检测到重复键,尝试加 S 锁,但 X 锁已被持有 → 死锁。
- 批量更新范围重叠:两个事务使用不同的条件更新同一范围的数据。
线上死锁排查流程(★★★★★ 实战):
SHOW ENGINE INNODB STATUS查看最新的死锁信息(LATEST DETECTED DEADLOCK部分)。- 开启死锁日志(
innodb_print_all_deadlocks=ON),将死锁记录到 MySQL error log。 - 分析死锁图中的事务 SQL 和持有的锁类型(X/S 锁 + 间隙锁)。
- 定位代码:找到对应的 SQL 语句,分析加锁顺序。
- 解决方案:
- 统一加锁顺序(所有事务按相同顺序访问表/索引)。
- 将 RR 隔离级别降为 RC(RC 无 Gap Lock,死锁概率大幅降低,但需确认业务可接受)。
- 使用
INSERT ... ON DUPLICATE KEY UPDATE减少唯一键冲突死锁。 - 缩短事务(减少锁持有时间),拆分大事务。
- 对热点行加队列(排队机制),避免并发冲突。
3.9 SQL 优化与执行计划分析
3.9.1 Explain 输出解读(★★★★★ 必须熟练)
以 MySQL 8.0 的 Explain 输出为例,重点关注的列:
| 列名 | 核心含义 | 重点关注值 |
|---|---|---|
id | 执行顺序,越大越先执行 | 子查询 > 外层 |
select_type | 查询类型 | SIMPLE(最好)、PRIMARY、SUBQUERY、DERIVED(派生表)、UNION |
table | 表名或别名 | - |
type(最重要) | 访问类型,性能从优到差:system>const>eq_ref>ref>range>index>ALL | 目标:至少达到 range 级别,最好是 ref 或 eq_ref。ALL(全表扫描)通常需要优化 |
possible_keys | 可能用到的索引 | - |
key | 实际使用的索引 | 如果为 NULL,说明未走索引 |
key_len | 使用的索引字节长度 | 可用于判断联合索引使用了多少列 |
ref | 索引列与哪个值进行比较 | 常量(const)或关联列 |
rows | 预估扫描行数 | 越小越好 |
filtered | 存储引擎层过滤后剩余百分比 | 越高越好(100% 最优) |
Extra(关键信息) | 额外信息 | 重点关注:Using index(覆盖索引,好)、Using index condition(ICP,好)、Using where(Server 层过滤)、Using filesort(文件排序,性能差)、Using temporary(临时表,性能差)、Using join buffer(没走索引,用了 Join Buffer) |
3.9.2 索引优化策略(实战)
假设有一张 5000 万行的订单表order,查询 SQL:
SELECTorder_id,user_id,amount,status,create_timeFROMorderWHEREuser_id=12345ANDstatus=1ANDcreate_timeBETWEEN'2024-01-01'AND'2024-12-31'ORDERBYcreate_timeDESCLIMIT20;执行 30 秒,需要优化。
优化思路:
分析执行计划(Explain):
- 如果
type=ALL,说明需要加索引。 - 如果
Extra=Using filesort,说明排序未走索引。
- 如果
创建联合索引(遵循最左前缀原则):
- 条件中
user_id是等值查询,status是等值查询,create_time是范围查询 + 排序字段。 - 推荐索引:
(user_id, status, create_time) - 索引排序原因:创建时间同时满足范围过滤和 ORDER BY 排序,可以避免
filesort。
- 条件中
覆盖索引(如果可能,进一步优化):
- 当前查询 SELECT 包含了
order_id(主键)、user_id、amount、status、create_time。 - 如果
amount不在索引中,则回表取amount。如果业务允许,可以把amount也加入索引(但索引列太多会导致写入性能下降)。 - 使用覆盖索引 + 延迟关联技巧:
SELECTo.order_id,o.user_id,o.amount,o.status,o.create_timeFROM(SELECTorder_idFROMorderWHEREuser_id=12345ANDstatus=1ANDcreate_timeBETWEEN'2024-01-01'AND'2024-12-31'ORDERBYcreate_timeDESCLIMIT20)tmpJOINorderoONtmp.order_id=o.order_id;- 子查询中只查主键,可完全用覆盖索引完成(二级索引包含主键)。
- 外层用主键回表取全部列,但只取 20 行,回表成本可控。
- 当前查询 SELECT 包含了
索引下推(ICP)确保开启:
- 确保
optimizer_switch="index_condition_pushdown=on"
- 确保
3.9.3 Join 优化 —— Join Buffer 与 Hash Join
- Nested Loop Join(嵌套循环连接):驱动表每条记录遍历被驱动表。如果被驱动表没有索引,复杂度 O(N × M),性能极差。
- Join Buffer(连接缓冲区):当被驱动表无法走索引时,MySQL 将驱动表的 join 列放入内存(Join Buffer),减少被驱动表的扫描次数。大小由
join_buffer_size控制。 - Block Nested Loop(BNL):使用 Join Buffer 分批加载驱动表,批量匹配被驱动表。
- Hash Join(MySQL 8.0.18+ 引入):
- 将驱动表构建为哈希表(在内存中),然后遍历被驱动表,用哈希探测匹配。
- 适合等值连接且大表无索引的场景。
- 比 BNL 更高效(时间复杂度 O(N+M))。
优化原则:Join 查询中,始终让小表作为驱动表(被 EXPLAIN 中的第一张表),且确保被驱动表的 join 列上有索引。如果无法加索引,尝试升级到 MySQL 8.0 使用 Hash Join。
3.9.4 分页查询优化(★★★★★ 百万级分页)
问题 SQL:
SELECT*FROMordersORDERBYidLIMIT1000000,20;MySQL 需要扫描 1000020 行,丢弃前 1000000 行,性能极差。
三种优化方案:
延迟关联(覆盖索引 + 回表):
SELECTo.*FROMorders oJOIN(SELECTidFROMordersORDERBYidLIMIT1000000,20)tmpONo.id=tmp.id;- 子查询走覆盖索引(二级索引),避免回表扫描 100 万行。
游标分页(记住最后一条 ID):
SELECT*FROMordersWHEREid>{last_id}ORDERBYidLIMIT20;- 适用于顺序翻页场景(如 App 无限滚动),不走大 Offset,性能恒定。
业务场景妥协:如果不可避免需要精确跳转,可考虑将数据导入 Elasticsearch 或使用搜索引擎解决。
本篇内容覆盖了原大纲中数据库(MySQL)的全量核心知识点,包括存储引擎、索引结构、事务隔离与 MVCC、日志体系、Buffer Pool、锁机制以及 SQL 优化方法论。下一篇我们将继续补充 Redis 和 MQ(消息队列)部分。