vault backup: 2026-05-20 00:00:48
This commit is contained in:
@@ -186,11 +186,6 @@ SHOW CREATE TABLE users\G
|
||||
SHOW SESSION VARIABLES LIKE 'character_set%';
|
||||
```
|
||||
|
||||
## Binlog 中的字符集注意
|
||||
|
||||
当启用 `binlog_format = ROW` 时,主从复制会在 binlog 中携带原始字节,不受 collation 影响。
|
||||
|
||||
但如果是 `STATEMENT` 模式,SQL 文本中的字符比较可能在主库和从库得出不同结果(如果两边的 collation 不一致)。这也是为什么生产推荐 `ROW` 格式的原因之一。
|
||||
|
||||
## Go 应用层字符集配置
|
||||
|
||||
@@ -274,3 +269,4 @@ ALTER DATABASE app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
- [[hhs/GORM/02-模型定义]] — GORM 建模时的字符串长度设置要点
|
||||
- [[hhs/MySQL/10-DDL 建表与结构变更]] — 大表 DDL 的在线变更策略
|
||||
- [[hhs/MySQL/40-常见踩坑]] — 隐式转换等更多常见问题
|
||||
- [[hhs/MySQL/29-Binary Log]] — Binlog Format(ROW vs STATEMENT)对字符集的影响
|
||||
|
||||
+208
-12
@@ -46,6 +46,67 @@ graph TB
|
||||
RL1 --> RL2
|
||||
```
|
||||
|
||||
## 写入路径全景
|
||||
|
||||
了解完顶层架构后,让我们跟一次完整的 **写入请求** 走一遍流程。这能帮你理解各个组件如何在同一笔事务中协同工作。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S["SQL: INSERT/UPDATE/DELETE"] --> C{页在 Buffer Pool?}
|
||||
C -->|命中| D["直接修改内存页"]
|
||||
C -->|未命中| E["从磁盘加载页到 Buffer Pool<br/>Page Cache Miss"]
|
||||
E --> D
|
||||
|
||||
D --> F["标记页为 Dirty"]
|
||||
F --> G["写入 Redo Log Buffer"]
|
||||
G --> H{是否提交 COMMIT?}
|
||||
H -->|否| P["Undo Log 记录回滚信息"]
|
||||
H -->|是| I["两阶段提交 2PC"]
|
||||
|
||||
I --> J["先写 Redo Log → fsync 落盘"]
|
||||
J --> K["再写 Binlog → fsync 落盘"]
|
||||
K --> L["事务完成 ✅"]
|
||||
|
||||
P --> M["后台线程: Page Cleaner<br/>脏页刷入磁盘 .ibd"]
|
||||
L --> N["后台线程: Checkpoint<br/>推进 Watermark 允许覆盖 Redo Log"]
|
||||
|
||||
O["Change Buffer<br/>二级索引变更缓存"] -.-> M
|
||||
|
||||
style D fill:#00D866,color:#fff
|
||||
style G fill:#FF9F43,color:#000
|
||||
style J fill:#FFD166,color:#000
|
||||
style K fill:#FFD166,color:#000
|
||||
style L fill:#00B6BC,color:#fff
|
||||
```
|
||||
|
||||
### 分步拆解
|
||||
|
||||
| 步骤 | 操作 | 涉及组件 | 同步/异步 |
|
||||
|------|------|----------|-----------|
|
||||
| ① | 检查 Buffer Pool 中是否有目标页 | Buffer Pool | 同步 |
|
||||
| ② | 若无,从磁盘加载(随机读 I/O) | Buffer Pool + 磁盘 | 同步 |
|
||||
| ③ | 修改内存页 | Buffer Pool | 同步 |
|
||||
| ④ | 记录 Redo Log(相同页面偏移的修改字节) | Redo Log | 同步 |
|
||||
| ⑤ | 若涉及二级索引且页面不在 Buffer Pool 中 | Change Buffer | 同步(只改内存) |
|
||||
| ⑥ | `COMMIT` → 写 Redo Log Buffer → flush | Redo Log | 同步 |
|
||||
| ⑦ | `COMMIT` → 写 Binlog → flush | Binary Log | 同步 |
|
||||
| ⑧ | 后台 Clean Thread 将脏页刷入 .ibd | Buffer Pool + 磁盘 | 异步 |
|
||||
|
||||
> [!WARNING] 真正的磁盘 I/O 只有两步
|
||||
>
|
||||
> - **步骤 ⑥**: Redo Log fsync(保证 crash safe)
|
||||
> - **步骤 ⑧**: 脏页刷入 .ibd(后台异步,与事务无关)
|
||||
>
|
||||
> 其余步骤全部在内存中完成。**这就是为什么 InnoDB 性能远优于 MyISAM**——MyISAM 每次写都要立即落盘,而 InnoDB 可以把大部分写操作延迟到后台执行。
|
||||
|
||||
> [!TIP] 顺序写的秘密
|
||||
>
|
||||
> 注意到 **Redo Log 本身是顺序追加写**的!固定大小循环复用,永远往尾部写。这意味着即使磁盘是机械硬盘,Redo Log 的写入也保持极高的吞吐——因为没有任何随机 seek。
|
||||
>
|
||||
> 对比之下,数据文件 `.ibd` 的写入是随机的(根据 B+Tree 结构分布),这是 InnoDB 最慢的部分,也是 Buffer Pool 和 Change Buffer 要极力减少它的根本原因。
|
||||
|
||||
---
|
||||
|
||||
## Clustered Index(聚簇索引)
|
||||
|
||||
InnoDB 的**数据行就存储在聚簇索引的叶子节点中**。这是 InnoDB 最关键的概念——没有聚簇索引就没有数据。
|
||||
@@ -61,9 +122,12 @@ graph BT
|
||||
E --> H["叶子节点, row_id=3, name=Charlie"]
|
||||
E --> I["叶子节点, row_id=4, name=David"]
|
||||
|
||||
F -.->"链表串联" G
|
||||
G -.->"链表串联" H
|
||||
H -.->"链表排序" I
|
||||
F -.-> G
|
||||
G -.-> H
|
||||
H -.-> I
|
||||
|
||||
note["注意: 叶子节点之间通过双向链表串联,维持主键有序"]
|
||||
note ~~~ F
|
||||
|
||||
style F fill:#00B6BC,color:#fff
|
||||
style I fill:#00B6BC,color:#fff
|
||||
@@ -82,7 +146,7 @@ graph BT
|
||||
所有非主键索引都是二级索引。关键点:**二级索引的叶子节点存储的是主键值**,而不是行数据。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
flowchart LR
|
||||
subgraph "聚簇索引, PK=id"
|
||||
CI1["id=1 → full row data"]
|
||||
CI2["id=2 → full row data"]
|
||||
@@ -95,12 +159,16 @@ graph LR
|
||||
SI3["email='c@x.com' → pk=3"]
|
||||
end
|
||||
|
||||
SI1 -.回表.-> CI1
|
||||
SI2 -.回表.-> CI2
|
||||
SI3 -.回表.-> CI3
|
||||
SI1 -.-> CI1
|
||||
SI2 -.-> CI2
|
||||
SI3 -.-> CI3
|
||||
|
||||
style SI1 fill:#FF9F43,color:#000
|
||||
style CI1 fill:#00D866,color:#fff
|
||||
|
||||
note["注意: 虚线表示二级索引查找后需通过主键回表获取完整行数据"]
|
||||
note ~~~ SI1
|
||||
note ~~~ CI1
|
||||
```
|
||||
|
||||
### 覆盖索引(Covering Index)
|
||||
@@ -203,6 +271,46 @@ flowchart LR
|
||||
>
|
||||
> 生产环境强烈推荐 `1`。设为 `2` 能在大部分场景接近相同性能,但在 OS 崩溃时会丢数据。
|
||||
|
||||
### 两阶段提交(Two-Phase Commit)
|
||||
|
||||
为什么 InnoDB 需要协调 Redo Log 和 Binlog?先想一个问题:
|
||||
|
||||
> [!QUESTION] 如果只写一种日志,会出现什么后果?
|
||||
>
|
||||
> - **只写 Redo Log**:crash 恢复没问题,但无法做主从复制(Binlog 是复制的唯一数据源)
|
||||
> - **只写 Binlog**:crash 后 Binlog 可能包含了一个实际没有落盘的数据页的事务 → 主从不一致
|
||||
>
|
||||
> 答案:**两种情况都会导致 crash 恢复后,Redo Log 和 Binlog 之间的数据不一致。**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "正常事务流程"
|
||||
A1["步骤1: 修改 Buffer Pool<br/>标记页为 Dirty"] --> A2["步骤2: 写 redo log prepare"]
|
||||
A2 --> A3["步骤3: fsync redo log"]
|
||||
A3 --> A4["步骤4: 写 binlog"]
|
||||
A4 --> A5["步骤5: fsync binlog"]
|
||||
A5 --> A6["步骤6: 写 redo log commit"]
|
||||
A6 --> A7["步骤7: fsync redo log"]
|
||||
A7 --> T1["事务成功 ✅"]
|
||||
end
|
||||
|
||||
style A3 fill:#FFD166,color:#000
|
||||
style A5 fill:#FF9F43,color:#000
|
||||
style A7 fill:#FFD166,color:#000
|
||||
```
|
||||
|
||||
> [!TIP] 核心思想:prepare = "我准备好了但不确定",commit = "确认执行"
|
||||
>
|
||||
> | Crash 时间点 | Redo Log 状态 | Binlog 状态 | 恢复行为 |
|
||||
> |---|---|---|---|
|
||||
> | A2 ~ A3 之间 | prepare(未刷盘) | 未写 | Redo Log 回滚 → **Binlog 也不会有这条记录** ✅ |
|
||||
> | A3 ~ A4 之间 | prepare(已刷盘) | 未写 | Redo Log 回滚到 prepare 之前 → **事务被丢弃** ✅ |
|
||||
> | A5 之后 | commit(已刷盘) | 已写 | Redo Log 完整重放 → **事务完整恢复** ✅ |
|
||||
>
|
||||
> **关键结论**:两阶段提交的本质是让 Redo Log 充当 "投票机制"——只有当事务真正安全地存在于两种日志中时才算完成。这保证了主从数据的一致性。
|
||||
|
||||
---
|
||||
|
||||
## Undo Log(回滚日志)
|
||||
|
||||
与 Redo Log(向前恢复)不同,Undo Log 用于将数据**回退到修改前的状态**。它支持两个关键功能:
|
||||
@@ -217,8 +325,8 @@ flowchart LR
|
||||
B1["等待锁 / MVCC 隔离"] --> B2["读取历史版本"]
|
||||
end
|
||||
|
||||
A1 -.提供回退能力.-> ROLLBACK["ROLLBACK 操作"]
|
||||
B2 -.快照读隔离.-> SNAPSHOT["SNAPSHOT READ"]
|
||||
A1 -.-> ROLLBACK["ROLLBACK 操作"]
|
||||
B2 -.-> SNAPSHOT["SNAPSHOT READ"]
|
||||
|
||||
style A1 fill:#FF9F43,color:#000
|
||||
style ROLLBACK fill:#FF6B6B,color:#fff
|
||||
@@ -264,20 +372,108 @@ flowchart TD
|
||||
> - 对 UPDATE/DELETE 同样有效
|
||||
> - 可以通过 `innodb_change_buffer_max_size` 控制最大占比(默认 25%)
|
||||
|
||||
## Checkpoint & Page Clean / Flush
|
||||
|
||||
有了写入路径全景后,我们来看最后一个关键问题:**内存里的脏页什么时候被写回磁盘?**
|
||||
|
||||
### LRU List ↔ Flush List 的双链表协作
|
||||
|
||||
Buffer Pool 维护着两条核心链表,它们各自有不同的职责:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "LRU List, 访问顺序"
|
||||
Old["Old Sub-list (最近经常访问)"] --> New["New Sub-list (新加载的页)"]
|
||||
end
|
||||
|
||||
subgraph "Flush List, 脏污程度"
|
||||
FL1["Clean Pages (已刷盘)"] --> FL2["Dirty Pages (未刷盘)"]
|
||||
end
|
||||
|
||||
DirtyInLRU["LRU 中的脏页<br/>同时存在于 Flush List"] --> FL2
|
||||
CleanedInLRU["已被刷盘的页<br/>仍在 LRU 中等待淘汰"] --> FL1
|
||||
|
||||
style DirtyInLRU fill:#FF6B6B,color:#fff
|
||||
style CleanedInLRU fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
| 链表 | 排序依据 | 主要用途 |
|
||||
|------|----------|----------|
|
||||
| **LRU List** | 访问时间(从旧到新) | 决定哪些页该被淘汰出 Buffer Pool |
|
||||
| **Flush List** | 是否脏污 + LSN 递增 | 决定哪些页需要刷入磁盘 |
|
||||
|
||||
> [!QUESTION] 为什么需要两条独立的链表?
|
||||
>
|
||||
> 想象一个场景:Buffer Pool 满了,需要回收一页空间。
|
||||
> - **只查 LRU**:可能取到 dirty page → 必须先刷盘再回收 → 慢
|
||||
> - **只查 Flush**:可能取到 clean page 但很久没访问过 → 浪费了干净页的空间
|
||||
>
|
||||
> 实际实现中,InnoDB 会从 LRU 的 Old 端找一个 dirty page 刷盘;如果一时找不到,也会刷干净页来救急。**这两条链表的配合是 Buffer Pool 高效运转的关键。**
|
||||
|
||||
### Checkpoint: Redo Log 循环复用的水坝
|
||||
|
||||
既然 Redo Log 是固定大小、循环重写的,那么问题来了:**旧日志什么时候能被安全覆盖?**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Redo Log 写满尾部"] --> B{"能从头覆盖吗?"}
|
||||
|
||||
B -->|"否"| C["Page Cleaner: 刷脏页到磁盘"]
|
||||
C --> D["Flush List 中的脏页减少"]
|
||||
|
||||
D --> E["Checkpoint Thread: 推进 Checkpoint LSN"]
|
||||
E --> F["Watermark 前进 → 旧日志可覆盖 ✅"]
|
||||
|
||||
F --> G["头部开始循环写入"]
|
||||
B -->|"是"| G
|
||||
|
||||
style F fill:#00D866,color:#fff
|
||||
style G fill:#FFD166,color:#000
|
||||
```
|
||||
|
||||
| 概念 | 含义 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| **LSN (Log Sequence Number)** | 单调递增的日志序号,每个页也有当前 LSN | — |
|
||||
| **Checkpoint LSN** | 到此位置之前的所有页保证已落盘 | — |
|
||||
| **Redo Log 可覆盖分界** | `Checkpoint LSN < X < Current LSN` 之间的日志不能删 | — |
|
||||
| **innodb_max_dirty_pages_pct** | 脏页占 Buffer Pool 的上限百分比 | 默认 75% |
|
||||
|
||||
> [!WARNING] 脏页过多的危险信号
|
||||
>
|
||||
> 当脏页比例超过 `innodb_max_dirty_pages_pct` 时,InnoDB 会触发 **流量控制(Throttling)**:
|
||||
> 1. 停止接受新的写请求
|
||||
> 2. 优先执行 Page Clean → 刷脏页
|
||||
> 3. 等比例回落后才恢复写操作
|
||||
>
|
||||
> 这会导致 **突发性写入延迟飙升**。监控 `Innodb_dblwr_pages_written` 和 `Innodb_buffer_pool_dirty_pages` 可以提前发现这个问题。
|
||||
>
|
||||
> **常见原因**: Buffer Pool 太小、大量批量更新、事务提交过于密集导致 redo flush 跟不上。
|
||||
|
||||
---
|
||||
|
||||
## 关联笔记
|
||||
|
||||
### 索引相关
|
||||
- [[hhs/MySQL/16-B+Tree 索引原理]] — 聚簇索引 / 二级索引的数据结构基础
|
||||
- [[hhs/MySQL/16-B+Tree 索引原理]] — B+Tree 数据结构基础
|
||||
- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引与二级索引的深度对比
|
||||
- [[hhs/MySQL/18-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
|
||||
- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证是否命中覆盖索引(Using index)
|
||||
|
||||
### 事务与并发控制
|
||||
- [[hhs/MySQL/23-ACID 与原子性实现]] — Undo Log 保证原子性的核心机制
|
||||
- [[hhs/MySQL/24-隔离级别与可见性]] — MVCC 配合二级索引的可见性判断
|
||||
- [[hhs/MySQL/24-隔离级别与可见性]] — 四种隔离级别的实际区别
|
||||
- [[hhs/MySQL/25-MVCC 原理]] — Read View + Undo Version Chain 的完整实现
|
||||
- [[hhs/MySQL/26-锁机制总览]] — InnoDB 行锁与 Buffer Pool 的协同
|
||||
- [[hhs/MySQL/28-一致性读与当前读]] — 二次读与快照读的触发条件
|
||||
|
||||
### 日志与高可用
|
||||
### 日志与恢复
|
||||
- [[hhs/MySQL/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
|
||||
- [[hhs/MySQL/34-备份与恢复]] — 全量备份 + Redo Log 恢复策略
|
||||
|
||||
### 性能调优
|
||||
- [[hhs/MySQL/38-监控指标]] — 生产环境关键监控项(Buffer Pool命中率、脏页比例等)
|
||||
- [[hhs/MySQL/40-常见踩坑]] — InnoDB 常见性能陷阱及排查方法
|
||||
|
||||
### 整体架构
|
||||
- [[hhs/MySQL/01-MySQL 架构与进程模型]] — Server 层与 InnoDB 引擎的分层协作
|
||||
- [[hhs/MySQL/07-其他存储引擎概览]] — MyISAM / Memory / Archive 的特点对比
|
||||
|
||||
@@ -40,16 +40,21 @@ CREATE TABLE employees (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100),
|
||||
phone VARCHAR(20), -- 单一手机号
|
||||
skills JSON -- 数组存在 JSON 字段内
|
||||
skills JSON -- 数组存在 JSON 字段内(如 ["Go","Python"])
|
||||
);
|
||||
|
||||
-- ❌ 违反 1NF:同一列存多个值(用逗号分隔)
|
||||
CREATE TABLE bad_employees (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100),
|
||||
skills VARCHAR(255) -- 'Go,Python,Docker' — 不行!
|
||||
skills VARCHAR(255) -- 'Go,Python,Docker' — 不行!无法单独查询某个技能
|
||||
);
|
||||
```
|
||||
|
||||
> [!TIP] 现代变通方案
|
||||
>
|
||||
> MySQL 5.7+ 支持 JSON 类型。虽然从严格范式角度看 JSON 列内部仍有复合结构,但数据库引擎提供了高效的索引和查询能力(如 `JSON_EXTRACT`),所以在工程实践中被广泛接受。**核心原则不变:不要把非结构化数据当字符串拼接处理。**
|
||||
|
||||
1NF 是最基本的要求——每一列都是原子值,不能再拆分。现代关系型数据库默认强制执行 1NF。
|
||||
|
||||
### 第二范式(2NF)—— 消除部分函数依赖
|
||||
@@ -133,6 +138,23 @@ CREATE TABLE student_course_teachers (
|
||||
);
|
||||
```
|
||||
|
||||
```sql
|
||||
-- ✅ 修正为 BCNF:拆成两张表,让每个决定因素都是超键
|
||||
CREATE TABLE courses_teachers (
|
||||
course_id BIGINT PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_course (course_id) -- 一门课只有一位老师
|
||||
);
|
||||
|
||||
CREATE TABLE student_grades (
|
||||
student_id BIGINT,
|
||||
course_id BIGINT,
|
||||
grade DECIMAL(5, 2),
|
||||
PRIMARY KEY (student_id, course_id),
|
||||
FOREIGN KEY (course_id) REFERENCES courses_teachers(course_id)
|
||||
);
|
||||
```
|
||||
|
||||
> [!QUESTION] 3NF 已经够了,为什么还需要 BCNF?
|
||||
>
|
||||
> 大多数工程场景 3NF 完全够用。BCNF 解决的问题通常涉及**多重候选键重叠**的复杂建模。如果你遇到 "一门课只能由一位老师教" 这样的约束,且该约束与你的自然主键冲突,才需要考虑 BCNF。实践中建议先做到 3NF,遇到更新异常再往上走。
|
||||
@@ -142,6 +164,19 @@ CREATE TABLE student_course_teachers (
|
||||
>
|
||||
> 但这恰恰是现代工程实践的真相:我们用自增代理主键保证查询性能,用外键语义保证设计合理。**范式检查应该在业务层(逻辑模型)上做,而不是在物理表结构上硬抠。**
|
||||
|
||||
## 三范式速查总结
|
||||
|
||||
读完上面的详细内容,回头用这张表做最后的对比和记忆强化:
|
||||
|
||||
| 范式 | 核心问题 | 违规症状 | 一句话修复 |
|
||||
|------|---------|---------|-----------|
|
||||
| **1NF** | 列是不是原子值? | 一格里塞了多个值 | 拆成多行或多列 |
|
||||
| **2NF** | 这列依赖主键的**全部**吗?(仅复合 PK 场景) | 某些列只依赖主键的一部分 | 把只依赖部分的列拆到新表 |
|
||||
| **3NF** | 这列有没有**绕道**经过其他非主键列? | B 列的值由 C 列决定,而不是直接由主键决定 | 把 C 列及它决定的所有列拆出去 |
|
||||
| **BCNF** | 每个决定因素都是超键吗? | 候选键之间有重叠,约束冲突 | 拆到没有交叉候选键为止 |
|
||||
|
||||
---
|
||||
|
||||
## 表设计实操流程
|
||||
|
||||
知道范式定义是一回事,拿到需求画出一张合理的 ER 图是另一回事。这里给一个**四步工作流**:
|
||||
|
||||
@@ -137,6 +137,18 @@ binlog_format = MIXED # 折中方案
|
||||
>
|
||||
> 答案:**ROW**。理由很简单——数据一致性永远排在体积和可读性之前。虽然 ROW 模式的 binlog 体积更大,但它消除了所有不确定性,而节省空间带来的好处远不及一次主从不一致造成的损失。
|
||||
|
||||
### Row vs Statement 对特殊场景的影响
|
||||
|
||||
除了非确定性函数(`NOW()`、`RAND()`)之外,还有两类场景在 STATEMENT 模式下容易引发主从不一致:
|
||||
|
||||
| 场景 | 问题原因 |
|
||||
|------|---------|
|
||||
| **字符集 / Collation 不一致** | 主库和从库 collation 不同时,STATEMENT 模式下的字符串比较可能得出不同结果;ROW 模式传输原始字节,不受影响 |
|
||||
| **自增 ID(auto_increment)** | 主从并发写入时,STATEMENT 模式下从库的自增值可能与主库不同步,导致重复或跳跃 |
|
||||
| **INSERT_DELAYED** | 5.6+ 已移除,执行时机不确定 |
|
||||
|
||||
**结论**:生产环境一律使用 `binlog_format = ROW`,避免上述所有隐患。
|
||||
|
||||
## Binlog 文件结构
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user