Archived
vault backup: 2026-05-21 22:11:56
This commit is contained in:
@@ -50,11 +50,27 @@ graph BT
|
||||
style NKLock fill:#00B6BC,color:#fff
|
||||
```
|
||||
|
||||
### 场景矩阵 — 查询条件 → 锁类型速查
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q{"查询方式"}
|
||||
|
||||
Q -->|"精确等值(unique index)"| R1["Record Lock<br/>仅锁那条记录"]
|
||||
Q -->|"精确等值(non-unique index)"| R2["Next-Key Lock<br/>记录 + 左侧间隙"]
|
||||
Q -->|"范围查询<br/>WHERE id > 10"| R3["每个匹配记录的<br/>Next-Key Lock + 最大值右侧间隙"]
|
||||
Q -->|"无索引条件"| R4["全表记录的 Next-Key Lock<br/>退化为表锁效果 ⚠️"]
|
||||
Q -->|"DELETE | UPDATE | INSERT"| R5["INSERT: Gap Lock on gap<br/>DELETE/UPDATE: Record Lock"]
|
||||
|
||||
style R1 fill:#00B6BC,color:#fff
|
||||
style R4 fill:#EE5A24,color:#fff
|
||||
style R5 fill:#FF9F43,color:#000
|
||||
```
|
||||
> [!NOTE] 为什么需要三张图?
|
||||
>
|
||||
> - **第一张**展示锁的粒度层次:从全局→表→行
|
||||
> - **第二张**展示行锁的子类关系:三种基础锁如何组合成临键锁
|
||||
> - **第三张**(下方场景矩阵)展示不同查询条件下 InnoDB 实际选择哪种锁
|
||||
> - **第三张**展示不同查询条件下 InnoDB 实际选择哪种锁(场景矩阵)
|
||||
>
|
||||
> 三者互为补充,共同构成完整的锁选型全景。带着这三张图,你可以回答面试中「InnoDB 锁到底有哪些类型」的问题了。
|
||||
|
||||
@@ -185,22 +201,58 @@ SELECT * FROM users WHERE id = 1 FOR UPDATE;
|
||||
|
||||
### 意向锁的作用
|
||||
|
||||
意向锁解决的核心问题是:**当一个事务想加表级锁时,怎么快速知道"表里有没有人在锁行"?**
|
||||
|
||||
没有意向锁 → 必须逐行扫描(百万行代价太高)。
|
||||
有了意向锁 → 看一眼表级标记就知道。
|
||||
|
||||
#### 场景一:行级加锁流程
|
||||
|
||||
事务想对某行加锁时,InnoDB **自动**在表级申请意向锁:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["事务 A 想在行级加 X 锁"] --> B{"检查表级意向锁"}
|
||||
B --> C{"表中是否有其他事务的<br/>意向共享锁 IS?"}
|
||||
C -->|有| D["冲突!不能同时拥有 IX + IS"]
|
||||
B -->|无| E{"表中是否有其他事务的<br/>意向排他锁 IX?"}
|
||||
E -->|有| F["冲突!表级不支持多个 IX"]
|
||||
E -->|无| G["✅ 放行,加行级 X 锁"]
|
||||
A["事务想在行级加 X 锁"] --> IX["🔒 自动在表级加 IX 锁<br/>(声明:我打算锁表里的某些行)"]
|
||||
IX --> B{"表级是否有<br/>S 锁或 X 锁?"}
|
||||
B -->|"有"| C["⏳ 等待表级锁释放"]
|
||||
B -->|"无"| D["✅ IX 加锁成功<br/>→ 继续在行级加 X 锁"]
|
||||
|
||||
style G fill:#00D866,color:#fff
|
||||
style D fill:#EE5A24,color:#fff
|
||||
style F fill:#EE5A24,color:#fff
|
||||
style D fill:#00D866,color:#fff
|
||||
style C fill:#EE5A24,color:#fff
|
||||
style IX fill:#FF9F43,color:#000
|
||||
```
|
||||
|
||||
> [!NOTE] 意向锁不是用户能控制的
|
||||
> 当事务准备对某行加 S 锁时,InnoDB 自动在表级加 IS;加 X 锁时自动加 IX。它的作用是快速判断「这张表有没有人在用」,而不必逐行扫描。
|
||||
> [!NOTE] IS 和 IX 之间、IX 和 IX 之间都**不冲突**
|
||||
> 因为意向锁只是"声明意图",真正的冲突发生在**行级**。两个事务各自改不同行,表级意向锁完全可以共存。
|
||||
>
|
||||
> | 已持有 ↓ \ 请求 → | IS | IX | S | X |
|
||||
> |---|---|---|---|---|
|
||||
> | **IS** | ✅ | ✅ | ✅ | ❌ |
|
||||
> | **IX** | ✅ | ✅ | ❌ | ❌ |
|
||||
> | **S** | ✅ | ❌ | ✅ | ❌ |
|
||||
> | **X** | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
#### 场景二:表级加锁流程(意向锁的真正用途)
|
||||
|
||||
当有人想给整张表加 S 锁或 X 锁时,**只需检查表级意向锁**,不用逐行扫描:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["事务想给表加<br/>S 锁 / X 锁"] --> Q{"表级有没有 IX 锁?<br/>(有人在做行级写入)"}
|
||||
Q -->|有| B["⏳ 等待 IX 释放<br/>(说明有人在改行数据)"]
|
||||
Q -->|无| R{"表级有没有 IS 锁?<br/>(有人在做行级读取)"}
|
||||
R -->|"X 锁请求 + 有 IS"| B2["⏳ 等待 IS 释放"]
|
||||
R -->|"S 锁请求 + 有 IS"| C["✅ IS 兼容 S 锁,放行"]
|
||||
R -->|无| D["✅ 放行,加表级锁"]
|
||||
|
||||
style D fill:#00D866,color:#fff
|
||||
style C fill:#00D866,color:#fff
|
||||
style B fill:#EE5A24,color:#fff
|
||||
style B2 fill:#EE5A24,color:#fff
|
||||
```
|
||||
|
||||
> [!TIP] 一句话记住
|
||||
> 意向锁的真正价值不在于阻塞其他意向锁,而在于**让表级锁的兼容性判断变成 O(1)**——只看标记,不扫行。
|
||||
|
||||
## 记录锁、间隙锁、临键锁
|
||||
|
||||
@@ -231,12 +283,35 @@ SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
|
||||
> [!QUESTION] Gap Lock 和 Record Lock 最大的区别是什么?
|
||||
> Record Lock 锁的是"已经存在的东西",Gap Lock 锁的是"还不存在但可能出现的东西"。一个是保护现在,一个是防止未来。
|
||||
|
||||
> [!QUESTION] 那 InnoDB 怎么知道该锁哪个间隙?
|
||||
> 关键在于 B+ 树索引是**有序**的。当查询值不存在时,InnoDB 在索引上做查找,找到**刚好包围这个值的左右两条记录**——左边是 ≤ 查询值的最大记录,右边是 > 查询值的最小记录。这两条记录之间的区间,就是 InnoDB 要锁的间隙。
|
||||
>
|
||||
> 一句话总结:**"谁包围了这个值,间隙就是谁"**。
|
||||
|
||||
索引上的值 `10, 20, 30, 40, 50` 把整条数轴切成了若干个间隙,目标值落在哪个间隙,就锁哪个:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Gaps["索引记录切分出的间隙"]
|
||||
direction LR
|
||||
G0["(-∞, 10)"] --- R1["10"] --- G1["(10, 20)"] --- R2["20"] --- G2["(20, 30)"] --- R3["30"] --- G3["(30, 40)"] --- R4["40"] --- G4["(40, 50)"] --- R5["50"] --- G5["(50, +∞)"]
|
||||
end
|
||||
|
||||
TARGET["查询 id=25"] -.->|25 落入此区间| G2
|
||||
|
||||
style G2 fill:#C44569,color:#fff,stroke-width:3px
|
||||
style TARGET fill:#FF9F43,color:#000
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 假设索引上有以下值: 10, 20, 30, 40, 50
|
||||
|
||||
-- 锁定 (20, 30) 这个开区间
|
||||
SELECT * FROM t WHERE id = 25 FOR UPDATE;
|
||||
-- id=25 不存在 → 不锁记录,锁住包含 25 的间隙 (20, 30)
|
||||
-- InnoDB 在 B+ 树上查找 25:
|
||||
-- → 左边最近的记录: 20(≤ 25 的最大值)
|
||||
-- → 右边最近的记录: 30(> 25 的最小值)
|
||||
-- → 间隙 = (20, 30),锁住它!
|
||||
-- 其他事务不能在 (20, 30) 区间内插入任何值
|
||||
```
|
||||
|
||||
@@ -385,12 +460,62 @@ flowchart TD
|
||||
style R5 fill:#FF9F43,color:#000
|
||||
```
|
||||
|
||||
> [!TIP] 实用记忆法
|
||||
> [!TIP] 🧠 记忆卡片 1 — 锁类型三步判断法
|
||||
>
|
||||
> 看到一条 SQL,依次回答三个问题就能判断加锁类型:
|
||||
> 1. **有索引吗?** → 无索引 = 全表锁(❌)
|
||||
> 2. **是等值查询吗?** → 是 → 再看是否有唯一索引
|
||||
> 3. **是范围查询吗?** → 是 → 每个命中记录都加 Next-Key Lock
|
||||
>
|
||||
> ```mermaid
|
||||
> flowchart TD
|
||||
> Start{"SQL 会加锁吗?<br/>(当前读?)"}
|
||||
> Start -->|"普通 SELECT"| Snap["❌ 一致性读<br/>不加锁(MVCC)"]
|
||||
> Start -->|"SELECT FOR UPDATE/SHARE<br/>UPDATE / DELETE / INSERT"| Q1{"1. 有索引吗?"}
|
||||
>
|
||||
> Q1 -->|"❌ 无索引"| Full["⚠️ 全表 Next-Key Lock<br/>退化为表锁"]
|
||||
> Q1 -->|"✅ 有索引"| Q2{"2. 等值查询?"}
|
||||
>
|
||||
> Q2 -->|"是"| Q3{"3. 唯一索引?"}
|
||||
> Q3 -->|"唯一 + 命中"| RL["📌 Record Lock"]
|
||||
> Q3 -->|"唯一 + 未命中"| GL["🔒 Gap Lock"]
|
||||
> Q3 -->|"非唯一"| NKL["🔑 Next-Key Lock"]
|
||||
>
|
||||
> Q2 -->|"范围查询"| NKL2["🔑 每条命中记录<br/>Next-Key Lock<br/>+ 最大值右侧 Gap"]
|
||||
>
|
||||
> style RL fill:#00B6BC,color:#fff
|
||||
> style GL fill:#FF9F43,color:#000
|
||||
> style NKL fill:#C44569,color:#fff
|
||||
> style NKL2 fill:#C44569,color:#fff
|
||||
> style Full fill:#EE5A24,color:#fff
|
||||
> style Snap fill:#636e72,color:#fff
|
||||
> ```
|
||||
>
|
||||
> **口诀:无索引全表炸,唯一命中只锁行,其余都带间隙锁。**
|
||||
|
||||
> [!TIP] 🧠 记忆卡片 2 — 锁兼容性速记
|
||||
>
|
||||
> | 已有 ↓ \ 请求 → | **S** | **X** |
|
||||
> |---|---|---|
|
||||
> | **S** | ✅ 共享读没问题 | ❌ 别人要写我得让 |
|
||||
> | **X** | ❌ 我在写你别读 | ❌ 我在写你别碰 |
|
||||
>
|
||||
> **口诀:读读兼容,读写互斥,写写互斥。** 意向锁(IS/IX)之间永远不冲突——它们只是"挂牌子声明意图"。
|
||||
|
||||
> [!TIP] 🧠 记忆卡片 3 — RC vs RR 锁行为对比
|
||||
>
|
||||
> | | **RR(默认)** | **RC** |
|
||||
> |---|---|---|
|
||||
> | Gap Lock | ✅ 有(防幻读) | ❌ 无 |
|
||||
> | Next-Key Lock | ✅ 默认算法 | 退化为 Record Lock |
|
||||
> | 并发度 | 较低 | 较高 |
|
||||
> | 死锁概率 | 较高(Gap Lock 多) | 较低 |
|
||||
>
|
||||
> **口诀:RC 没有间隙锁,所以不会防幻读,但并发更高、死锁更少。**
|
||||
|
||||
> [!TIP] 🧠 记忆卡片 4 — 死锁四板斧
|
||||
>
|
||||
> 1. **固定顺序** — 所有事务按同一顺序(如主键升序)访问资源
|
||||
> 2. **一次锁完** — 减少持锁期间的等待窗口
|
||||
> 3. **短事务** — 锁持有时间越短,冲突越少
|
||||
> 4. **降级到 RC** — 砍掉 Gap Lock,死锁概率大降
|
||||
|
||||
## 死锁与排查
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ create time: 2026-05-16 00:00
|
||||
|
||||
## 概述
|
||||
|
||||
死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。MySQL InnoDB 内置了死锁检测机制,会自动选择其中一个事务回滚来解除死锁。理解死锁的成因和排查方法是后端工程师的必备技能。
|
||||
死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。打个比方:两个人各拿一把钥匙,但都需要对方手里的钥匙才能开门——谁也不松手,就永远卡住了。
|
||||
|
||||
MySQL InnoDB 内置了死锁检测机制,发现死锁后会自动选择一个"牺牲者"回滚来解除死锁。但自动回滚不代表可以不管——**频繁死锁会严重影响吞吐量**,理解死锁的成因和排查方法是后端工程师的必备技能。
|
||||
|
||||
> [!QUESTION] 在往下读之前,先想一个问题
|
||||
> 如果你的线上服务突然出现大量 `Deadlock found when trying to get lock` 报错,你的第一步应该做什么?
|
||||
> 带着这个问题往下读,你会在「排查工作流」部分找到答案。
|
||||
|
||||
## 常见死锁场景
|
||||
|
||||
@@ -15,19 +21,32 @@ create time: 2026-05-16 00:00
|
||||
> 所有死锁都可以归结为同一个模式:**每个事务都持有对方需要的资源,同时又在等待对方持有的资源**。
|
||||
> 想象两个人从桥的两端相向而行,桥只能容一人通过——谁也不退让,就卡住了。数据库里的"退让"就是回滚一个事务。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
T1["事务 A<br/>持有 user_id=1 的锁"] -->|"等待 user_id=2 的锁"| T2["事务 B<br/>持有 user_id=2 的锁"]
|
||||
T2 -->|"等待 user_id=1 的锁"| T1
|
||||
|
||||
style T1 fill:#FF6B6B,color:#fff
|
||||
style T2 fill:#4ECDC4,color:#fff
|
||||
```
|
||||
|
||||
### 场景一:交叉顺序加锁
|
||||
|
||||
**最经典、最容易复现的场景。** 核心原因是两个事务对相同资源的访问顺序不一致。
|
||||
|
||||
```sql
|
||||
-- 事务 A -- 事务 B
|
||||
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 加 X 锁 user_id=1
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 尝试加 X 锁 user_id=2 → 被 T2 阻塞
|
||||
-- Step 1: 两个事务各拿一把锁(关键:加锁顺序相反)
|
||||
-- 事务 A
|
||||
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 拿到 user_id=1 的 X 锁
|
||||
-- 事务 B
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 拿到 user_id=2 的 X 锁
|
||||
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1; -- 尝试加 X 锁 user_id=1 → 被 T1 阻塞
|
||||
UPDATE accounts SET balance = balance - 100 WHERE user_id = 2; -- 永远不会执行
|
||||
|
||||
-- 结果:T1 等 T2 的 user_id=2,T2 等 T1 的 user_id=1 → 死锁
|
||||
-- Step 2: 各自请求对方持有的锁 → 死锁
|
||||
-- 事务 A
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 等 user_id=2 → 被事务 B 挡住
|
||||
-- 事务 B
|
||||
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1; -- 等 user_id=1 → 被事务 A 挡住
|
||||
-- → 事务 A 等事务 B,事务 B 等事务 A → 环形等待 → 死锁
|
||||
```
|
||||
|
||||
> [!QUESTION] ❓ 思考一下
|
||||
@@ -42,33 +61,51 @@ UPDATE accounts SET balance = balance - 100 WHERE user_id = 2; -- 永远不会
|
||||
|
||||
### 场景二:范围查询 + 间隙锁
|
||||
|
||||
这个场景比场景一"隐蔽"得多——表面上只是普通的查询和插入,却因为 **Gap Lock(间隙锁)** 撞车了。
|
||||
|
||||
> [!NOTE] 先搞懂:什么是 Gap Lock?
|
||||
> 想象一张表的 id 列有记录 `1, 5, 10`,它们之间存在两个"空隙":`(1,5)` 和 `(5,10)`。
|
||||
>
|
||||
> 在 RR 隔离级别下,当你执行 `SELECT ... FOR UPDATE WHERE id BETWEEN 2 AND 9` 时,InnoDB 不仅锁住 id=5 这条记录本身,还会**锁住它两边的空隙**——这就是 Gap Lock。它的目的是:**防止你在这些空隙里插入新记录(防幻读)**。
|
||||
>
|
||||
> 就像你在图书馆书架上取下第 5 号书,顺手在 4 号和 6 号之间放了块"占位牌"——别人不能在这个位置插入新书。
|
||||
|
||||
> [!WARNING] RC vs RR 的关键差异
|
||||
> 在 **REPEATABLE READ(RR)** 隔离级别下,InnoDB 会启用 Gap Lock。这意味着范围查询"锁定了一段区间"——不仅锁了存在的记录,还锁了它们之间的"空隙"。
|
||||
> 如果你不需要可重复读保证,**考虑降级到 READ COMMITTED(RC)**,Gap Lock 将不复存在。
|
||||
|
||||
下面用一个具体例子演示死锁是如何发生的。假设表中有 id = 1, 5, 10 三条记录:
|
||||
|
||||
```sql
|
||||
-- 假设表中有 id = 1, 5, 10 三条记录
|
||||
|
||||
-- 事务 A -- 事务 B
|
||||
BEGIN; BEGIN;
|
||||
SELECT * FROM items INSERT INTO items (id, name)
|
||||
WHERE id BETWEEN 2 AND 9 VALUES (5, 'item5');
|
||||
FOR UPDATE; -- 拿到 X 锁(id=5) + Gap(1,5) + Gap(5,10)
|
||||
-- Gap(5,10) 阻塞了下面的插入
|
||||
|
||||
INSERT INTO items (id, name) INSERT INTO items (id, name)
|
||||
VALUES (5, 'test'); VALUES (7, 'new_item');
|
||||
-- 尝试插入 id=5 → -- 尝试插入 id=7 → 落入 Gap(5,10)
|
||||
-- 已被 T_A 的 NX 锁 -- 被 T_A 的 Gap Lock 阻塞!
|
||||
-- T_A 试图插入 id=7 → 被 T_B 阻塞
|
||||
-- → 死锁!
|
||||
-- Step 1: 事务 A 先执行范围查询
|
||||
BEGIN;
|
||||
SELECT * FROM items WHERE id BETWEEN 2 AND 9 FOR UPDATE;
|
||||
-- 结果:查到 id=5 一条记录
|
||||
-- 隐含动作:对 id=5 加 X 锁 + Gap(1,5) + Gap(5,10)
|
||||
-- 这两个"空隙"被锁住,别人不能往里面插数据
|
||||
```
|
||||
|
||||
> [!NOTE] 为什么间隙锁会导致死锁?
|
||||
> 很多人不理解:`SELECT ... FOR UPDATE` 只是查数据,为什么要锁"不存在的记录"?
|
||||
> InnoDB 的设计哲学是:**防止幻读**。如果 T_A 查到 2~9 之间没有 id=7 的记录,等它稍后想插入时却被告知"已有人占了",这就是幻读。所以它在 5 和 10 之间也加了一把"虚拟钥匙"。
|
||||
```sql
|
||||
-- Step 2: 事务 B 尝试往空隙里插入
|
||||
BEGIN;
|
||||
INSERT INTO items (id, name) VALUES (7, 'new_item');
|
||||
-- id=7 落在 Gap(5,10) 范围内 → 被事务 A 的 Gap Lock 阻塞
|
||||
-- 事务 B 拿到一个"插入意向锁"(Insert Intention Lock),在 Gap Lock 前排队
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Step 3: 事务 A 也要往同一个空隙里插
|
||||
INSERT INTO items (id, name) VALUES (5, 'test');
|
||||
-- id=5 已经存在,但 A 自己持有该记录的 X 锁,似乎可以直接插入?
|
||||
-- 问题出在这里:B 的"插入意向锁"和 A 的 Gap Lock 互斥
|
||||
-- A 要等 B 释放意向锁,B 在等 A 释放 Gap Lock → 双方互相等待 → 死锁!
|
||||
```
|
||||
|
||||
> [!QUESTION] ❓ 灵魂拷问
|
||||
> 一个 `SELECT ... FOR UPDATE` 查数据的操作,为什么会和 `INSERT` 冲突?
|
||||
> 答案在于 InnoDB 的设计哲学:**防止幻读**。如果 A 查到 2~9 之间没有 id=7 的记录,等它稍后想插入时却被别人抢了先,这就是幻读。所以 Gap Lock 相当于给整个空隙加了"预约"。
|
||||
>
|
||||
> 这个机制在大多数 OLTP 业务中是过度保护——换成 RC 就能彻底消除这类死锁。
|
||||
> 但在大多数 OLTP 业务中,这种保护是过度的——**换成 RC 隔离级别**就能彻底消除 Gap Lock,从根本上解决这类死锁。
|
||||
|
||||
### 场景三:批量操作的非确定性顺序
|
||||
|
||||
@@ -103,41 +140,54 @@ DELETE FROM orders WHERE id IN (
|
||||
SHOW ENGINE INNODB STATUS\G
|
||||
```
|
||||
|
||||
切换到 `trx` 段(向下滚动),定位到 `LATEST DETECTED DEADLOCK` 区域:
|
||||
切换到 `trx` 段(向下滚动),定位到 `LATEST DETECTED DEADLOCK` 区域。下面逐行拆解一份真实的死锁日志,关键信息已用注释标注:
|
||||
|
||||
```
|
||||
------------------------
|
||||
LATEST DETECTED DEADLOCK
|
||||
------------------------
|
||||
2026-05-16 10:30:45.123
|
||||
*** (1) TRANSACTION:
|
||||
2026-05-16 10:30:45.123 -- ① 死锁发生的时间点
|
||||
|
||||
*** (1) TRANSACTION: -- ② 事务 1 的基本信息
|
||||
TRANSACTION 123456, ACTIVE 5 sec starting index read
|
||||
mysql tables in use 1, locked 1
|
||||
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
|
||||
MySQL thread id 7890, OS thread handle 140123, query id id 12345
|
||||
-- "ACTIVE 5 sec":事务已经运行了 5 秒
|
||||
-- "LOCK WAIT":正在等待锁
|
||||
|
||||
*** (1) WAITING FOR THIS LOCK to be granted:
|
||||
*** (1) WAITING FOR THIS LOCK to be granted: -- ③ 事务 1 在等什么锁?
|
||||
RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
|
||||
trx id 123456 lock_mode X locks rec but not gap waiting
|
||||
Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
|
||||
-- "index PRIMARY":锁在主键索引上
|
||||
-- "lock_mode X":要拿排他锁(X锁 = 写锁)
|
||||
-- "rec but not gap":只锁记录本身,不锁间隙
|
||||
-- "heap no 5":物理位置第 5 行,对应记录值 1001
|
||||
|
||||
*** (2) TRANSACTION:
|
||||
*** (2) TRANSACTION: -- ④ 事务 2 的基本信息
|
||||
TRANSACTION 123457, ACTIVE 3 sec inserting
|
||||
mysql tables in use 1, locked 1
|
||||
|
||||
*** (2) HOLDING THE LOCK(S):
|
||||
*** (2) HOLDING THE LOCK(S): -- ⑤ 事务 2 持有哪些锁?
|
||||
RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
|
||||
trx id 123457 lock mode S locks rec but not gap
|
||||
Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
|
||||
-- 事务 2 持有 id=1001 的共享锁(S锁 = 读锁)
|
||||
|
||||
*** (2) WAITING FOR THIS LOCK to be granted:
|
||||
*** (2) WAITING FOR THIS LOCK to be granted: -- ⑥ 事务 2 在等什么锁?
|
||||
RECORD LOCKS space id 42 page no 13 n bits 72 index idx_status of table `app`.`orders`
|
||||
trx id 123457 lock_mode X locks rec but not gap waiting
|
||||
Record lock, heap no 3 PHYSICAL RECORD: p8: 0x44 length 6: 2001
|
||||
-- 事务 2 在等 idx_status 索引上 id=2001 的排他锁
|
||||
-- 但这个锁被事务 1 持有(日志中省略了这部分)
|
||||
|
||||
*** WE ROLL BACK TRANSACTION (2)
|
||||
*** WE ROLL BACK TRANSACTION (2) -- ⑦ InnoDB 选择回滚事务 2
|
||||
```
|
||||
|
||||
> [!TIP] 快速读懂日志的口诀
|
||||
> **"谁在等什么,谁拿着什么,谁被杀了"** —— 三句话就能说清一次死锁。
|
||||
|
||||
### 解读模板
|
||||
|
||||
> [!CHECKLIST] 逐行拆解法
|
||||
@@ -168,15 +218,17 @@ Transaction 2: 持有的锁 → 等待的锁(将被回滚的那个)
|
||||
```sql
|
||||
-- 查看当前阻塞链(谁在等谁)
|
||||
SELECT
|
||||
r.trx_id AS waiting_trx_id,
|
||||
r.trx_mysql_thread_id AS waiting_thread,
|
||||
r.req_query AS waiting_query,
|
||||
b.trx_id AS blocking_trx_id,
|
||||
r.trx_id AS waiting_trx_id, -- 被阻塞的事务 ID
|
||||
r.trx_mysql_thread_id AS waiting_thread, -- 被阻塞的线程(对应 SHOW PROCESSLIST 中的 Id)
|
||||
r.trx_query AS waiting_query, -- 被阻塞的 SQL(可能为 NULL,如果事务还没发新 SQL)
|
||||
b.trx_id AS blocking_trx_id, -- 持有锁的事务 ID("罪魁祸首")
|
||||
b.trx_mysql_thread_id AS blocking_thread,
|
||||
b.trx_query AS blocking_query
|
||||
FROM performance_schema.data_lock_waits AS w
|
||||
INNER JOIN information_schema.innodb_trx AS b ON w.requesting_engine_tx_id = b.trx_id
|
||||
INNER JOIN information_schema.innodb_trx AS r ON w.blocking_engine_tx_id = r.trx_id;
|
||||
b.trx_query AS blocking_query -- 持有锁的事务正在执行的 SQL
|
||||
FROM performance_schema.data_lock_waits AS w -- 锁等待关系表:记录"谁在等谁"
|
||||
INNER JOIN information_schema.innodb_trx AS b
|
||||
ON w.blocking_engine_transaction_id = b.trx_id -- 通过阻塞关系找到"持有锁的事务"
|
||||
INNER JOIN information_schema.innodb_trx AS r
|
||||
ON w.requesting_engine_transaction_id = r.trx_id; -- 找到"等待锁的事务"
|
||||
```
|
||||
|
||||
> [!NOTE] performance_schema vs SHOW ENGINE
|
||||
@@ -185,21 +237,24 @@ INNER JOIN information_schema.innodb_trx AS r ON w.blocking_engine_tx_id = r
|
||||
>
|
||||
> 生产环境建议两者配合:`innodb_print_all_deadlocks` 兜底 + 监控脚本轮询 `data_lock_waits`。
|
||||
|
||||
> [!QUESTION] ❓ 实际使用技巧
|
||||
> 如果 `waiting_query` 显示为 NULL,说明该事务还在等上一条 SQL 的锁。此时可以结合 `blocking_query` 定位持有锁的 SQL——**找到"持锁者"比找"等待者"更重要**,因为优化"持锁者"才能从根本上释放锁。
|
||||
|
||||
### 乐观锁替代方案
|
||||
|
||||
并非所有场景都需要排他锁。考虑使用乐观锁(Optimistic Locking)来从根本上消除冲突:
|
||||
并非所有场景都需要排他锁。乐观锁的核心思想是:**不提前加锁,而是在提交时检查数据有没有被别人改过**——如果被改了,就重试。
|
||||
|
||||
```sql
|
||||
-- 悲观锁:先加锁再判断
|
||||
-- 悲观锁:先加锁再操作(别人只能排队等)
|
||||
BEGIN;
|
||||
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE; -- 直接锁住
|
||||
SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE; -- 直接锁住,别人进不来
|
||||
UPDATE accounts SET balance = 900 WHERE user_id = 1;
|
||||
COMMIT;
|
||||
|
||||
-- ✅ 乐观锁:先改再校验(基于版本号)
|
||||
-- ✅ 乐观锁:先改再校验(基于版本号,不阻塞任何人)
|
||||
UPDATE accounts SET balance = 900, version = version + 1
|
||||
WHERE user_id = 1 AND version = 5; -- 只有版本仍为 5 时才更新
|
||||
-- affected_rows = 1 → 成功;affected_rows = 0 → 重试
|
||||
-- affected_rows = 1 → 成功;affected_rows = 0 → 说明别人先改了,重试
|
||||
```
|
||||
|
||||
> [!QUESTION] ❓ 什么时候该用乐观锁?
|
||||
@@ -225,7 +280,7 @@ flowchart TD
|
||||
I --> J
|
||||
|
||||
J --> K{"死锁消失?"}
|
||||
K -->|是| L["🎉 上线监控"]
|
||||
K -->|是| L["上线监控"]
|
||||
K -->|否| D
|
||||
|
||||
style L fill:#00D866,color:#fff
|
||||
@@ -233,7 +288,7 @@ flowchart TD
|
||||
|
||||
## Go 中的死锁处理
|
||||
|
||||
在 Go 应用层应对死锁的核心思路是:**捕获错误码 1213 → 回滚事务 → 重试**。
|
||||
在 Go 应用层应对死锁的核心思路是:**捕获错误码 1213 → 回滚事务 → 指数退避 → 重试**。
|
||||
|
||||
```go
|
||||
import (
|
||||
|
||||
@@ -84,6 +84,32 @@ flowchart LR
|
||||
2. **在新行上记录前一个版本的回滚指针** (`DB_ROLL_PTR`)
|
||||
3. 更新 `DB_TRX_ID` 为当前事务 ID
|
||||
|
||||
### 具体示例:一行数据经历三次 UPDATE
|
||||
|
||||
```sql
|
||||
-- 初始: INSERT INTO product(id, name, price) VALUES(1, '键盘', 200);
|
||||
-- 行记录: {id=1, name='键盘', price=200, DB_TRX_ID=50, DB_ROLL_PTR=NULL}
|
||||
```
|
||||
|
||||
随后三个事务依次修改 `price`:
|
||||
|
||||
| 操作 | 聚簇索引中的行 | undo log 新增节点 |
|
||||
|------|---------------|-----------------|
|
||||
| 初始 INSERT | `{price=200, trx=50, roll_ptr=NULL}` | — |
|
||||
| TXN 60: `SET price=180` | `{price=180, trx=60, roll_ptr→undo_1}` | undo_1: `{price=200, trx=50}` |
|
||||
| TXN 70: `SET price=150` | `{price=150, trx=70, roll_ptr→undo_2}` | undo_2: `{price=180, trx=60}` |
|
||||
| TXN 80: `SET price=99` | `{price=99, trx=80, roll_ptr→undo_3}` | undo_3: `{price=150, trx=70}` |
|
||||
|
||||
此时 version chain 为:
|
||||
|
||||
```
|
||||
聚簇索引行(price=99) ──roll_ptr──→ undo_3(price=150) ──→ undo_2(price=180) ──→ undo_1(price=200)
|
||||
trx=80 trx=70 trx=60 trx=50
|
||||
```
|
||||
|
||||
> [!TIP] 类比理解
|
||||
> 把 version chain 想象成「编辑历史」——每次修改都不会覆盖原文,而是把旧版本存进回收站(undo log),新版本放回桌面(聚簇索引)。一致性读就是根据你打开文档的时间点,去回收站翻找对应的版本。
|
||||
|
||||
> [!QUESTION] 为什么是链表而不是数组?
|
||||
> 因为版本数量在写入时无法预知。链表允许 O(1) 追加新版本,且通过 `DB_ROLL_PTR` 可以高效遍历——只沿着真正需要回溯的路径走。
|
||||
|
||||
@@ -109,43 +135,43 @@ flowchart TD
|
||||
style I fill:#FF6B6B,color:#fff
|
||||
```
|
||||
|
||||
> [!NOTE] 可见性判断规则
|
||||
> - 若 `trx_id == ReadView.m_ids` 中的某个值 → **当前事务自己改的,可见**
|
||||
> - 若 `trx_id < min_trx_id` → **提交于快照之前的,可见**
|
||||
> - 若 `trx_id >= max_trx_id` → **提交于快照之后的,不可见**
|
||||
> - 若在 `[min_trx_id, max_trx_id)` 之间且在 `m_ids` 列表中 → **可见(该事务在 ReadView 创建时未提交)**
|
||||
> - 若在 `[min_trx_id, max_trx_id)` 之间但**不在** `m_ids` 列表中 → **不可见(该事务已提交)**
|
||||
> [!NOTE] 可见性判断规则(5 条,从上到下依次判断)
|
||||
> 1. `trx_id == 当前事务 ID` → ✅ **自己改的,可见**
|
||||
> 2. `trx_id < min_trx_id` → ✅ **快照前已提交,可见**
|
||||
> 3. `trx_id >= max_trx_id` → ❌ **快照后才启动,不可见**
|
||||
> 4. `trx_id ∈ [min_trx_id, max_trx_id)` 且**在 `m_ids` 中** → ❌ **快照时尚未提交,不可见**
|
||||
> 5. `trx_id ∈ [min_trx_id, max_trx_id)` 且**不在 `m_ids` 中** → ✅ **快照前已提交,可见**
|
||||
>
|
||||
> > 核心直觉:`m_ids` 就是「快照瞬间还在跑的事务名单」,名单上的事务其修改对当前快照**不可见**。
|
||||
|
||||
### 一致性读的完整流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["普通 SELECT"] --> B{"目标行是否有锁?"}
|
||||
A["普通 SELECT"] --> B["读取当前行最新版本"]
|
||||
B --> C{"用 ReadView 判断<br/>DB_TRX_ID 是否可见?"}
|
||||
|
||||
B -->|无锁| C["直接读取当前行"]
|
||||
C -->|可见| D["✅ 返回此版本"]
|
||||
C -->|不可见| E{"DB_ROLL_PTR 是否存在?"}
|
||||
|
||||
B -->|有锁| D["跳过当前行"]
|
||||
D --> E{"DB_ROLL_PTR 是否存在?"}
|
||||
|
||||
E -->|否| F["无可视版本, 返回空"]
|
||||
E -->|是| G["跟随指针读取 undo log 旧版本"]
|
||||
E -->|否| F["❌ 无可视版本, 返回空"]
|
||||
E -->|是| G["沿 DB_ROLL_PTR 读取 undo log 旧版本"]
|
||||
G --> H{"旧版本 trx_id 可见?"}
|
||||
|
||||
H -->|是| I["✅ 返回可见版本"]
|
||||
H -->|否| J{"还有更早版本?"}
|
||||
H -->|可见| D
|
||||
H -->|不可见| I{"还有更早版本?"}
|
||||
|
||||
J -->|是| G
|
||||
J -->|否| F
|
||||
I -->|是| G
|
||||
I -->|否| F
|
||||
|
||||
C --> K["返回结果"]
|
||||
I --> K
|
||||
F --> K
|
||||
|
||||
style B fill:#FF9F43,color:#000
|
||||
style I fill:#00D866,color:#fff
|
||||
style C fill:#FF9F43,color:#000
|
||||
style D fill:#00D866,color:#fff
|
||||
style F fill:#FF6B6B,color:#fff
|
||||
```
|
||||
|
||||
> [!NOTE] 与之前版本的区别
|
||||
> 一致性读**不关心行上有没有锁**——它总是从最新版本开始,通过 ReadView 的可见性规则决定该版本是否对当前事务可见。不可见就沿 undo chain 往回找,直到找到第一个可见版本(或链尾)。
|
||||
|
||||
> [!TIP] 一致性读的性能优势
|
||||
> 由于不需要加锁、不需要等待锁释放,一致性读在大量只读场景下几乎不产生并发开销。这就是为什么报表查询、数据导出都应该用普通 `SELECT`——既不影响业务写入,也避免了自己被阻塞。
|
||||
|
||||
@@ -154,6 +180,66 @@ flowchart TD
|
||||
> [!NOTE] 为什么叫「快照读」?
|
||||
> 因为它读的不是磁盘上的最新数据,而是某个时间点的数据「快照」。在 RR 模式下,这个快照在事务第一次 SELECT 时就冻结了;在 RC 模式下,每次 SELECT 都刷新快照。
|
||||
|
||||
### 端到端示例:跟着 ReadView 走一遍
|
||||
|
||||
> [!QUESTION] 先想想:如果三个事务交替修改同一行,第四个事务来做一致性读,它到底看到谁的版本?
|
||||
|
||||
假设有一行 `account(id=1, balance=1000)`,当前 `max_trx_id = 104`:
|
||||
|
||||
| 时间 | 事务 | 操作 | 状态 |
|
||||
|------|------|------|------|
|
||||
| T1 | TXN 101 | `UPDATE balance=900` | 已提交 ✅ |
|
||||
| T2 | TXN 102 | `UPDATE balance=800` | 未提交 🔒 |
|
||||
| T3 | TXN 103 | `UPDATE balance=700` | 已提交 ✅ |
|
||||
| T4 | **TXN 104(我们)** | `BEGIN; SELECT balance` | 开始一致性读 |
|
||||
|
||||
此时 InnoDB 构建 ReadView:
|
||||
|
||||
```
|
||||
m_ids = [102] ← 快照时仍在运行的事务(只有 TXN 102 未提交)
|
||||
min_trx_id = 102
|
||||
max_trx_id = 105 ← 下一个待分配的事务 ID
|
||||
```
|
||||
|
||||
行的 version chain(从新到旧):
|
||||
|
||||
```
|
||||
balance=700 (trx=103) ← balance=800 (trx=102) ← balance=900 (trx=101) ← balance=1000 (trx=旧)
|
||||
```
|
||||
|
||||
一致性读沿 version chain 逐版本判断:
|
||||
|
||||
1. **trx=103**:在 `[102,105)` 内且**不在** m_ids 中 → 已提交 → ✅ 可见
|
||||
2. 直接返回 `balance=700`,不再往后找
|
||||
|
||||
> [!TIP] 关键洞察
|
||||
> 虽然最新版本是 `balance=700`(trx=103),但如果你把 TXN 103 提交撤销、让 trx=102 先提交,那我们就会跳过 trx=102 的版本(它在 m_ids 中不可见),最终看到 trx=101 的 `balance=900`。可见性判断完全取决于「谁在快照瞬间已经提交」。
|
||||
|
||||
### RR vs RC 的快照时机差异
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as TXN A (RR 模式)
|
||||
participant T2 as TXN B
|
||||
participant DB as InnoDB
|
||||
|
||||
Note over T: BEGIN
|
||||
T->>DB: SELECT balance FROM account WHERE id=1
|
||||
Note over T,DB: 快照创建 (ReadView #1)
|
||||
|
||||
T2->>DB: UPDATE balance=500 WHERE id=1
|
||||
T2->>DB: COMMIT
|
||||
|
||||
T->>DB: SELECT balance FROM account WHERE id=1
|
||||
Note over T,DB: 复用 ReadView #1 → 看不到 500
|
||||
|
||||
T->>DB: SELECT balance FROM account WHERE id=1
|
||||
Note over T,DB: 仍然复用 ReadView #1 → 还是看不到 500
|
||||
Note over T: 整个事务期间看到的值始终一致
|
||||
```
|
||||
|
||||
如果 TXN A 用的是 **RC 模式**,每次 SELECT 都会创建新的 ReadView,第二次 SELECT 就能看到 `balance=500`。
|
||||
|
||||
## 深入理解当前读
|
||||
|
||||
```sql
|
||||
|
||||
@@ -9,21 +9,32 @@ create time: 2026-05-16 10:30
|
||||
|
||||
MySQL 主从复制是通过 Binlog 将主库的数据变更传播到从库的过程。它是高可用架构的基础组件,也是读写分离的前提。
|
||||
|
||||
> [!tip] 生活中的类比
|
||||
> 想象主库是一位「主播」,从库是「观众」。主播做了一个操作(比如画画),观众通过直播画面(Binlog)看到这个操作,然后在自己的画布上**照着做一遍**。
|
||||
>
|
||||
> 这个"看到 → 照做"的过程,就是主从复制的本质。
|
||||
|
||||
> [!question] 为什么需要主从复制?
|
||||
> 一台 MySQL 能撑多久?当读请求暴增时,单机性能到顶了怎么办?
|
||||
> 主从复制给出了两个关键答案:**读写分离**(主库写、从库读)和**高可用**(主库挂了,从库顶上)。
|
||||
|
||||
## 复制架构
|
||||
|
||||
复制的过程可以简化为三个步骤:**主库记录变更** → **网络传输** → **从库重放变更**。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Master"
|
||||
M1["业务写入 → DML"] --> M2["Server 层生成 Binlog"]
|
||||
M1["业务写入 DML"] --> M2["Server 层生成 Binlog"]
|
||||
M2 --> M3["Binlog Dump Thread"]
|
||||
end
|
||||
|
||||
subgraph "Network"
|
||||
M3 -.Binlog Stream.-> I1["IO Thread"]
|
||||
M3 -. "Binlog Stream" .-> I1["IO Thread"]
|
||||
end
|
||||
|
||||
subgraph "Slave"
|
||||
I1 --> S1["Relay Log<br/>中继日志"]
|
||||
I1 --> S1["Relay Log 中继日志"]
|
||||
S1 --> S2["SQL Thread"]
|
||||
S2 --> S3["数据文件更新"]
|
||||
end
|
||||
@@ -40,24 +51,37 @@ flowchart LR
|
||||
| **IO Thread** | Slave | 连接 Master,拉取 binlog 写入本地 Relay Log |
|
||||
| **SQL Thread** | Slave | 读取 Relay Log 重放应用到本地数据库 |
|
||||
|
||||
> [!question] 为什么要多一个 Relay Log,而不是直接在从库上执行?
|
||||
> 其实这和消息队列的「削峰填谷」是一个道理。Relay Log 起到了**缓冲区**的作用:即使从库暂时卡住了(比如正在做慢查询),主库发来的 Binlog 也不会丢失,先暂存在 Relay Log 里,等从库空闲了再慢慢回放。
|
||||
|
||||
## 同步模式
|
||||
|
||||
复制的「同步」和「异步」,核心问题只有一个:**主库写完后,要不要等从库确认?**
|
||||
|
||||
| 模式 | 等不等从库? | 数据安全 | 性能 |
|
||||
|------|------------|---------|------|
|
||||
| **异步** | 不等 | 低(可能丢数据) | 最高 |
|
||||
| **半同步** | 至少等 1 个 | 中(大幅降低丢数据概率) | 中 |
|
||||
| **组复制** | 等多数派共识 | 高 | 较低 |
|
||||
|
||||
下面的流程图展示了三种模式的完整过程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "异步复制 Async"
|
||||
subgraph "Async 异步复制"
|
||||
A1["Client 写入 Master"] --> A2["Master 刷盘返回 OK"]
|
||||
A2 -->|"后续"| A3["异步发给 Slave"]
|
||||
end
|
||||
|
||||
subgraph "半同步复制 Semi-Sync"
|
||||
subgraph "Semi-Sync 半同步复制"
|
||||
S1["Client 写入 Master"] --> S2["Master 刷盘"]
|
||||
S2 --> S3["等待 ≥1 个 Slave ACK"]
|
||||
S3 -->|"超时 10s"| S4["降级为异步 ⚠️"]
|
||||
S2 --> S3["等待至少1个 Slave ACK"]
|
||||
S3 -->|"超时10s"| S4["降级为异步"]
|
||||
S3 -->|"收到 ACK"| S5["返回 OK"]
|
||||
S5 --> S6["异步发给其他 Slave"]
|
||||
end
|
||||
|
||||
subgraph "组复制 Group Replication"
|
||||
subgraph "Group Replication 组复制"
|
||||
G1["Client 写入"] --> G2["Paxos 共识"]
|
||||
G2 --> G3["多数派确认"]
|
||||
G3 --> G4["并行回放"]
|
||||
@@ -72,6 +96,8 @@ flowchart TD
|
||||
|
||||
最简单的模式——Master 写完就不管了,异步发给 Slave。
|
||||
|
||||
**在从库上执行以下命令**,告诉它"去连接哪个主库、用什么账号、从哪里开始复制":
|
||||
|
||||
```sql
|
||||
-- MySQL 5.5 (已弃用)
|
||||
-- CHANGE MASTER TO
|
||||
@@ -79,37 +105,42 @@ flowchart TD
|
||||
|
||||
-- ✅ MySQL 8.0+ 标准语法
|
||||
CHANGE REPLICATION SOURCE TO
|
||||
SOURCE_HOST='10.0.1.10',
|
||||
SOURCE_USER='repl_user',
|
||||
SOURCE_HOST='10.0.1.10', -- 主库 IP
|
||||
SOURCE_USER='repl_user', -- 专用的复制账号
|
||||
SOURCE_PASSWORD='password',
|
||||
SOURCE_PORT=3306,
|
||||
SOURCE_AUTO_POSITION = 1; -- GTID 模式自动定位
|
||||
SOURCE_AUTO_POSITION = 1; -- GTID 模式:自动找到正确的起始位置
|
||||
|
||||
START REPLICA;
|
||||
START REPLICA; -- 启动复制!
|
||||
```
|
||||
|
||||
> [!WARNING] 异步复制的风险
|
||||
> [!tip] 关键参数 `SOURCE_AUTO_POSITION`
|
||||
> 设为 `1` 表示开启 GTID 自动定位——从库会自动告诉主库"我已经有哪些事务了",主库从缺失的部分开始发送。再也不用手动查 Binlog 文件和偏移量了。
|
||||
|
||||
> [!warning] 异步复制的风险
|
||||
> Master 宕机后,尚未传输到 Slave 的数据会丢失。对于金融系统这是不可接受的。
|
||||
|
||||
### 半同步复制(Semi-Sync)
|
||||
|
||||
至少一个从库确认收到 Binlog 后,Master 才返回写成功。
|
||||
|
||||
半同步需要在**主库和从库两端都开启**插件:
|
||||
|
||||
```ini
|
||||
# Master 端配置
|
||||
plugin_load_add = semisync_master
|
||||
rpl_semi_sync_master_enabled = 1
|
||||
rpl_semi_sync_master_timeout = 10000 # 10 秒超时后降级为异步
|
||||
rpl_semi_sync_master_wait_point = AFTER_SYNC # 刷盘后再等待
|
||||
# === Master 端配置 ===
|
||||
plugin_load_add = semisync_master # 加载半同步主库插件
|
||||
rpl_semi_sync_master_enabled = 1 # 开启
|
||||
rpl_semi_sync_master_timeout = 10000 # 10 秒收不到 ACK 就降级为异步(保护性能)
|
||||
rpl_semi_sync_master_wait_point = AFTER_SYNC # 先刷盘,再等从库确认(避免幻读)
|
||||
```
|
||||
|
||||
```ini
|
||||
# Slave 端配置
|
||||
plugin_load_add = semisync_slave
|
||||
rpl_semi_sync_slave_enabled = 1
|
||||
# === Slave 端配置 ===
|
||||
plugin_load_add = semisync_slave # 加载半同步从库插件
|
||||
rpl_semi_sync_slave_enabled = 1 # 开启
|
||||
```
|
||||
|
||||
> [!NOTE] 半同步不是银弹
|
||||
> [!note] 半同步不是银弹
|
||||
> - 从库收到 Binlog ≠ 已从磁盘持久化
|
||||
> - 如果 Master 和从库同时断电,仍可能丢数据
|
||||
> - 网络分区时 Master 会超时降级为异步
|
||||
@@ -120,6 +151,12 @@ rpl_semi_sync_slave_enabled = 1
|
||||
|
||||
GTID(Global Transaction Identifier)给每个事务分配全局唯一的 ID,替代传统的 position-based 复制。
|
||||
|
||||
> [!question] 为什么传统 Position 复制让人抓狂?
|
||||
> 传统模式下,从库靠 `mysql-bin.000003:154`(文件名 + 偏移量)来记录复制进度。
|
||||
> 想象一下:主库挂了,你要把从库切到新主库上,但新旧主库的 Binlog 文件名和偏移量完全不同——你得**手动计算**该从哪个位置继续复制。
|
||||
>
|
||||
> GTID 的思路就简单多了:给每个事务贴一个「身份证号」。无论主库怎么切换,从库只要说"我已经执行到 23 号事务了",新主库就知道该从 24 号开始发。
|
||||
|
||||
```
|
||||
GTID 格式: source_id:transaction_id
|
||||
例: 3E11FA47-71CA-11E1-9E33-C80AA9429562:23
|
||||
@@ -130,13 +167,13 @@ GTID 格式: source_id:transaction_id
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Client 事务写入 Master"] --> B["Server 层分配 GTID<br/>source_id:next_seq"]
|
||||
B --> C["写入 Binlog 事件<br/>带 GTID 标记"]
|
||||
C --> D["Binlog Dump 线程发送<br/>GTID + 事件到 Slave"]
|
||||
D --> E["Slave IO Thread 写入<br/>Relay Log (含 GTID)"]
|
||||
E --> F{"GTID Set<br/>是否已包含此 GTID?"}
|
||||
F -->|是| G["跳过重复事件 ✅"]
|
||||
F -->|否| H["SQL Worker 重放事务"]
|
||||
A["Client 事务写入 Master"] --> B["Server 层分配 GTID"]
|
||||
B --> C["写入 Binlog 事件, 带 GTID 标记"]
|
||||
C --> D["Binlog Dump 线程发送 GTID 和事件到 Slave"]
|
||||
D --> E["Slave IO Thread 写入 Relay Log"]
|
||||
E --> F{"GTID Set 已包含此 GTID?"}
|
||||
F -->|"是"| G["跳过重复事件"]
|
||||
F -->|"否"| H["SQL Worker 重放事务"]
|
||||
H --> I["更新 Slave gtid_executed"]
|
||||
G --> J["继续下一个事件"]
|
||||
I --> J
|
||||
@@ -146,7 +183,7 @@ flowchart TD
|
||||
style H fill:#00B6BC,color:#fff
|
||||
```
|
||||
|
||||
> [!NOTE] GTID 的一致性约束
|
||||
> [!note] GTID 的一致性约束
|
||||
> 启用 `enforce_gtid_consistency = ON` 后,以下操作被**禁止**:
|
||||
> - 对非事务引擎(如 MyISAM)的 DML
|
||||
> - 创建/删除临时表
|
||||
@@ -155,41 +192,62 @@ flowchart TD
|
||||
|
||||
### GTID vs 传统 Position
|
||||
|
||||
一句话总结:**Position 靠「页码」定位,GTID 靠「身份证号」定位。**
|
||||
|
||||
| 特性 | Position-based | GTID-based |
|
||||
|------|---------------|------------|
|
||||
| **定位方式** | 文件名 + position 号 | GTID set |
|
||||
| **定位方式** | 文件名 + position 号(像书签) | GTID set(像身份证号集合) |
|
||||
| **跨库切换** | 需要手动查 position | 自动定位 |
|
||||
| **跳过事务** | 困难 | `SET gtid_next = '...'; BEGIN; COMMIT; SET gtid_next = AUTOMATIC;` |
|
||||
| **一致性检查** | 手动比对 | `pt-table-checksum` + GTID 验证 |
|
||||
|
||||
> [!tip] 实际建议
|
||||
> MySQL 8.0 中 GTID 已经非常成熟,**新项目强烈建议默认开启**。传统 Position 模式仅在兼容老版本时使用。
|
||||
|
||||
在主库和从库的 `my.cnf` 中添加以下配置即可启用 GTID:
|
||||
|
||||
```ini
|
||||
# 启用 GTID
|
||||
gtid_mode = ON
|
||||
enforce_gtid_consistency = ON
|
||||
master_info_repository = TABLE # 复制信息存在表中而非文件
|
||||
gtid_mode = ON # 开启 GTID
|
||||
enforce_gtid_consistency = ON # 强制一致性:禁止不安全的 SQL 写法
|
||||
master_info_repository = TABLE # 复制元数据存到系统表,比文件更可靠
|
||||
relay_log_info_repository = TABLE
|
||||
```
|
||||
|
||||
> [!tip] 热知识
|
||||
> MySQL 8.0 中 `master_info_repository` 和 `relay_log_info_repository` 默认就是 TABLE,无需手动配置。
|
||||
|
||||
## 并行复制(Multithreaded Slave, MTS)
|
||||
|
||||
前面提到 SQL Thread 负责重放 Relay Log。问题来了——默认只有一个 SQL Thread,所有事务都**排队串行**执行。如果主库很忙,从库就很容易「越落越远」。
|
||||
|
||||
> [!tip] 用快递仓库来理解并行复制
|
||||
> 串行回放 = 一个分拣员按顺序处理所有包裹。
|
||||
> 并行回放 = 多个分拣员**同时**处理,只要包裹之间没有依赖关系(比如不会同时操作同一行),就可以并行。
|
||||
|
||||
MySQL 5.6+ 引入从库并行回放,大幅提升复制延迟处理能力。
|
||||
|
||||
在从库配置中开启并行复制:
|
||||
|
||||
```ini
|
||||
# MySQL 5.x(已弃用)
|
||||
# slave_parallel_type = LOGICAL_CLOCK
|
||||
# slave_parallel_workers = 8
|
||||
|
||||
# ✅ MySQL 8.0+ 标准命名
|
||||
replica_parallel_type = LOGICAL_CLOCK # 基于 GTID 的并发回放
|
||||
replica_parallel_workers = 8 # 并发 worker 数量
|
||||
replica_parallel_type = LOGICAL_CLOCK # 按事务依赖关系决定哪些可以并行
|
||||
replica_parallel_workers = 8 # 并发 worker 数量(建议 CPU 核数的一半)
|
||||
```
|
||||
|
||||
> [!TIP] worker 数量设多少合适?
|
||||
> 不是越多越好。每个 worker 都占内存和 CPU,一般建议 **CPU 核数的一半**,上限不超过 16。设太多反而会因为锁竞争导致更慢。
|
||||
|
||||
### MTS 并行模式对比
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "DATABASE(默认)"
|
||||
D1["Relay Log 事件"] --> D2{"按 database\n分桶"}
|
||||
subgraph "DATABASE 默认"
|
||||
D1["Relay Log 事件"] --> D2{"按 database 分桶"}
|
||||
D2 -->|"db_a"| W1["Worker 1"]
|
||||
D2 -->|"db_b"| W2["Worker 2"]
|
||||
D2 -->|"db_c"| W3["Worker 3"]
|
||||
@@ -197,9 +255,9 @@ flowchart LR
|
||||
end
|
||||
|
||||
subgraph "GROUP_TRANSACTIONS"
|
||||
G1["Relay Log 事件"] --> G2{"判断事务\n依赖关系"}
|
||||
G2 -->|"无冲突"| A1["组 1 → 并行执行"]
|
||||
G2 -->|"有冲突"| A2["组 2 → 串行等待"]
|
||||
G1["Relay Log 事件"] --> G2{"判断事务依赖关系"}
|
||||
G2 -->|"无冲突"| A1["组1 并行执行"]
|
||||
G2 -->|"有冲突"| A2["组2 串行等待"]
|
||||
G1 -->|"无冲突"| A1
|
||||
A1 --> J2["写入数据"]
|
||||
A2 --> J2
|
||||
@@ -217,22 +275,22 @@ flowchart LR
|
||||
| **适用场景** | schema 设计良好的系统 | 多库共享、跨库事务较多的场景 |
|
||||
| **引入版本** | MySQL 5.6 | MySQL 5.7+ |
|
||||
|
||||
> [!TIP] 如何选择
|
||||
> [!tip] 如何选择
|
||||
> - 如果业务按 database 做数据隔离(每个 tenant 一个 DB),选 `DATABASE`
|
||||
> - 如果多个业务共享少数几个 database,选 `GROUP_TRANSACTIONS`
|
||||
|
||||
### LOGICAL_CLOCK 原理
|
||||
|
||||
> [!INFO] `LOGICAL_CLOCK` = GTID 驱动的并行回放
|
||||
> [!info] `LOGICAL_CLOCK` = GTID 驱动的并行回放
|
||||
> 无论选择 `DATABASE` 还是 `GROUP_TRANSACTIONS`,底层引擎都是 LOGICAL_CLOCK。
|
||||
> 区别仅在于如何把连续事件流切分成可并行的逻辑组。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Relay Log 事件流"] --> B{"按 database 分组"}
|
||||
B --> C["DB1 的事件队列"]
|
||||
B --> D["DB2 的事件队列"]
|
||||
B --> E["DBn 的事件队列"]
|
||||
B --> C["DB1 事件队列"]
|
||||
B --> D["DB2 事件队列"]
|
||||
B --> E["DBn 事件队列"]
|
||||
|
||||
C --> W1["Worker 1 执行 DB1 事件"]
|
||||
D --> W2["Worker 2 执行 DB2 事件"]
|
||||
@@ -243,13 +301,15 @@ flowchart TD
|
||||
style Wn fill:#FF9F43,color:#000
|
||||
```
|
||||
|
||||
> [!TIP] Parallel Replication 的限制
|
||||
> [!tip] Parallel Replication 的限制
|
||||
> - 基于 database 的并行要求同一 database 内没有冲突事务
|
||||
> - 跨 database 的操作无法并行
|
||||
> - innodb_table_locks=OFF 且 autocommit=1 时才能并行(避免表锁冲突)
|
||||
|
||||
## 监控复制状态
|
||||
|
||||
复制搭好了,日常巡检同样重要。最常见的关注点:**IO 线程和 SQL 线程是否在正常运行?从库落后主库多少秒?**
|
||||
|
||||
```sql
|
||||
-- MySQL 8.0+ 已弃用 SHOW SLAVE STATUS
|
||||
-- SHOW SLAVE STATUS\G
|
||||
@@ -276,16 +336,18 @@ FROM (
|
||||
|
||||
## 常见问题与排查
|
||||
|
||||
生产环境中,主从复制最容易出问题的三个地方:**IO 线程断连**、**SQL 线程卡住**、**延迟越来越大**。下面逐一分析。
|
||||
|
||||
### IO 线程断开(Slave_IO_Running: No)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["IO Thread 断连"] --> B{"原因定位"}
|
||||
B -->|网络不通| C["检查防火墙 / 安全组端口"]
|
||||
B -->|Binlog 已过期| D["purge binary logs / 调整 expire_logs_days"]
|
||||
B -->|认证失败| E["核对 user 权限 & password"]
|
||||
B -->|Server UUID 冲突| F["重置 slave server_uuid"]
|
||||
C --> G["CHANGE REPLICATION SOURCE TO<br/>SOURCE_LOG_FILE = '...',<br/>SOURCE_LOG_POS = ..."]
|
||||
B -->|"网络不通"| C["检查防火墙和安全组端口"]
|
||||
B -->|"Binlog 已过期"| D["purge binary logs"]
|
||||
B -->|"认证失败"| E["核对 user 权限和 password"]
|
||||
B -->|"Server UUID 冲突"| F["重置 slave server_uuid"]
|
||||
C --> G["CHANGE REPLICATION SOURCE TO"]
|
||||
D --> G
|
||||
E --> G
|
||||
F --> G
|
||||
@@ -313,7 +375,7 @@ SHOW MASTER LOGS;
|
||||
|
||||
这通常是由于数据不一致导致的——例如从库被手动修改过。
|
||||
|
||||
> [!WARNING] 不要直接跳过!
|
||||
> [!warning] 不要直接跳过!
|
||||
> 盲目 `sql_slave_skip_counter` 可能导致数据静默丢失。必须先确认事务内容。
|
||||
|
||||
```sql
|
||||
@@ -322,13 +384,14 @@ SHOW MASTER LOGS;
|
||||
-- STOP SLAVE; START SLAVE;
|
||||
|
||||
-- ✅ MySQL 8.0+ GTID 模式:跳过指定事务
|
||||
SET GTID_NEXT='3E11FA47-71CA-11E1-9E33-C80AA9429562:100';
|
||||
BEGIN; COMMIT;
|
||||
SET GTID_NEXT='AUTOMATIC';
|
||||
-- 核心思路:假装已经执行过这个事务,让从库跳过它
|
||||
SET GTID_NEXT='3E11FA47-71CA-11E1-9E33-C80AA9429562:100'; -- 指定要跳过的 GTID
|
||||
BEGIN; COMMIT; -- 提交一个空事务, 标记该 GTID 为已执行
|
||||
SET GTID_NEXT='AUTOMATIC'; -- 恢复自动分配 GTID
|
||||
|
||||
-- 重新加入复制组
|
||||
-- 如果需要从头重建从库的 GTID 记录(慎用!)
|
||||
STOP REPLICA;
|
||||
SET GLOBAL gtid_purged = '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100';
|
||||
SET GLOBAL gtid_purged = '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100'; -- 声明从库已拥有这些 GTID
|
||||
START REPLICA;
|
||||
```
|
||||
|
||||
@@ -336,10 +399,10 @@ START REPLICA;
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Seconds_Behind_Master 大"] --> B{"IO 延迟还是 SQL 延迟?"}
|
||||
B -->|"Relay_Log_Space 持续增长"| C["SQL Worker 回放太慢<br/>→ 增加 replica_parallel_workers<br/>→ 考虑 GROUP_TRANSACTIONS"]
|
||||
B -->|"Read_Master_Log_Pos 跟丢"| D["网络带宽不足 / Master<br/>写入量太大<br/>→ 检查网络 / 限制 binlog 传输大小"]
|
||||
B -->|"DDL 执行中"| E["大表 ALTER TABLE 阻塞<br/>→ 使用 pt-online-schema-change"]
|
||||
A["Seconds_Behind_Master 大"] --> B{"IO 延迟还是 SQL 延迟"}
|
||||
B -->|"Relay_Log_Space 持续增长"| C["SQL Worker 回放太慢, 增加 replica_parallel_workers"]
|
||||
B -->|"Read_Master_Log_Pos 跟丢"| D["网络带宽不足或 Master 写入量太大"]
|
||||
B -->|"DDL 执行中"| E["大表 ALTER TABLE 阻塞, 使用 pt-online-schema-change"]
|
||||
|
||||
style C fill:#00B6BC,color:#fff
|
||||
style D fill:#FF9F43,color:#000
|
||||
|
||||
+202
-94
@@ -7,7 +7,9 @@ create time: 2026-05-16 00:00
|
||||
|
||||
## 概述
|
||||
|
||||
当单库单表的百万级数据无法满足性能需求时,分库分表(Sharding)成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题。
|
||||
想象你开了一家超市,所有商品都放在一个仓库里。当商品只有几千种时,一个仓库绰绰有余;但当商品增长到几十万种,一个仓库就变得拥挤不堪——找货慢、入库堵、仓库还会塌。解决方案很简单:**建多个仓库,按规则分配商品**。这就是分库分表(Sharding)的核心思想。
|
||||
|
||||
当单库单表的数据量超出 MySQL 的舒适区时,分库分表成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题,每一个都是对团队工程能力的考验。
|
||||
|
||||
> [!QUESTION] 你真的需要分库分表吗?
|
||||
>
|
||||
@@ -36,12 +38,12 @@ flowchart TD
|
||||
style V1 fill:#00B6BC,color:#fff
|
||||
```
|
||||
|
||||
| 维度 | 适用场景 | 优点 | 缺点 |
|
||||
|------|---------|------|------|
|
||||
| **Hash 分片** | 均匀写入分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
|
||||
| **Range 分片** | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(双 11) |
|
||||
| **List 分片** | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需预规划 |
|
||||
| **垂直拆分** | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
|
||||
| 维度 | 类比 | 适用场景 | 优点 | 缺点 |
|
||||
|------|------|---------|------|------|
|
||||
| **Hash 分片** | 按姓名首字母分班 | 均匀写入、分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
|
||||
| **Range 分片** | 按年份归档文件 | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(如双 11) |
|
||||
| **List 分片** | 按部门分配办公区 | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需提前规划 |
|
||||
| **垂直拆分** | 超市按品类分区 | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
|
||||
|
||||
> [!TIP] 最佳实践:先垂直再水平
|
||||
> 如果不同业务的表耦合在同一库里,先做**垂直拆分**(按业务域拆成多个库),再对单个大表做**水平拆分**。两步都做能最大化收益。
|
||||
@@ -68,42 +70,104 @@ func GetShardTableName(orderID int64, shardCount int) string {
|
||||
|
||||
### Range 分片
|
||||
|
||||
```sql
|
||||
-- 按月分表(适合审计日志、交易流水等时间序列数据)
|
||||
-- logs_202601, logs_202602, ..., logs_202612
|
||||
-- 优势:天然支持范围查询 WHERE created_at BETWEEN ...
|
||||
-- 劣势:最近月份写入压力大,历史月份只有读取
|
||||
```
|
||||
Range 分片的核心思想是**按连续区间划分**,最典型的场景就是按时间分表——每个月一张表,就像把文件按年份放进不同的文件夹。
|
||||
|
||||
```sql
|
||||
-- 利用 range 分片的特性做数据归档
|
||||
-- 每月 1 号自动将上月数据迁移到 archive 库
|
||||
ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
|
||||
CREATE TABLE logs_202602 LIKE logs_202601;
|
||||
-- 按月分表:审计日志、交易流水等时间序列数据
|
||||
-- 表名:logs_202601, logs_202602, ..., logs_202612
|
||||
|
||||
-- 查询某月数据 → 只需访问一张表
|
||||
SELECT * FROM logs_202603 WHERE level = 'ERROR' ORDER BY created_at DESC LIMIT 100;
|
||||
|
||||
-- 跨月范围查询 → 路由层自动合并多张表
|
||||
SELECT * FROM logs_202603
|
||||
UNION ALL
|
||||
SELECT * FROM logs_202604
|
||||
WHERE created_at BETWEEN '2026-03-01' AND '2026-04-30';
|
||||
```
|
||||
|
||||
```go
|
||||
// 路由层:根据时间范围计算需要查询哪些表
|
||||
func GetLogTableNames(start, end time.Time) []string {
|
||||
var tables []string
|
||||
for t := start; !t.After(end); t = t.AddDate(0, 1, 0) {
|
||||
tables = append(tables, fmt.Sprintf("logs_%s", t.Format("200601")))
|
||||
}
|
||||
return tables
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP] Range 分片天然适合做数据归档
|
||||
> 历史数据可以直接迁移到低成本存储,操作极其简单:
|
||||
> ```sql
|
||||
> -- 将上月数据归档到 archive 库,腾出 SSD 空间
|
||||
> ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
|
||||
> -- 为下月预创建空表
|
||||
> CREATE TABLE logs_202602 LIKE logs_202601;
|
||||
> ```
|
||||
|
||||
> [!QUESTION] Range 分片最大的风险是什么?
|
||||
> 答:**数据倾斜**。如果你按月分表,双 11 当天的写入量可能是平时的 100 倍——所有的写入压力集中在一张表上。解决方案:对热点时段做二次 Hash 分片(见下方"联合分片")。
|
||||
|
||||
### List 分片
|
||||
|
||||
List 分片是**按枚举值分组**,最典型的场景是 SaaS 多租户——每个租户的数据天然隔离,互不干扰。就像办公楼按公司分配楼层,A 公司在 3 楼,B 公司在 5 楼。
|
||||
|
||||
```sql
|
||||
-- 按 tenant_id 列表拆分(SaaS 多租户场景)
|
||||
-- tenant_A 的所有表在一个库,tenant_B 在另一个库
|
||||
-- 优势:租户级别的数据隔离,方便合规审查
|
||||
-- tenant_A: db_tenant_0.tenant_A_users, db_tenant_0.tenant_A_orders
|
||||
-- tenant_B: db_tenant_1.tenant_B_users, db_tenant_1.tenant_B_orders
|
||||
-- SaaS 多租户场景:每个租户的数据在独立的库中
|
||||
-- tenant_A: db_tenant_0.orders, db_tenant_0.users
|
||||
-- tenant_B: db_tenant_1.orders, db_tenant_1.users
|
||||
|
||||
-- 查询时自动路由到对应库
|
||||
-- 应用层根据 tenant_id 选择数据库连接
|
||||
SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC;
|
||||
-- → tenant_A 走 db_tenant_0,tenant_B 走 db_tenant_1
|
||||
```
|
||||
|
||||
```go
|
||||
// 路由层:根据租户 ID 选择目标数据库
|
||||
func GetTenantDB(tenantID string) *sql.DB {
|
||||
// 租户 → 库的映射关系(可存配置中心或 Redis)
|
||||
dbMap := map[string]*sql.DB{
|
||||
"tenant_A": dbTenant0,
|
||||
"tenant_B": dbTenant1,
|
||||
}
|
||||
return dbMap[tenantID]
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE] List 分片的额外好处:合规审计
|
||||
> 某些行业(金融、医疗)要求租户数据物理隔离。List 分片天然满足这一需求——不同租户的数据在不同物理库中,审计时只需检查对应库即可。
|
||||
|
||||
### 联合分片
|
||||
|
||||
```sql
|
||||
-- 双维度分片:shard_id = (user_id % 16) * 4 + (month % 4)
|
||||
-- 第一层 user_id 哈希:分散写入压力,同一用户的记录集中
|
||||
-- 第二层 month 取模:便于按时间范围和归档
|
||||
-- 共 16 × 4 = 64 张子表
|
||||
单一维度的分片总有局限:Hash 分片不支持范围查询,Range 分片会数据倾斜。**联合分片是两种策略的组合**——用两个(或更多)维度共同决定数据归属。
|
||||
|
||||
```go
|
||||
// 联合分片公式:shard_id = (user_id % 16) * 4 + (month % 4)
|
||||
// 第一层 user_id % 16 → 分散写入压力,同一用户的数据集中
|
||||
// 第二层 month % 4 → 按时间归档,热点月份自动分散到 4 张表
|
||||
// 共 16 × 4 = 64 张子表
|
||||
|
||||
func GetShardTableName(userID int64, t time.Time) string {
|
||||
userShard := userID % 16 // 用户维度:16 个桶
|
||||
timeShard := int(t.Month()-1) % 4 // 时间维度:4 个桶
|
||||
shardID := userShard*4 + int64(timeShard)
|
||||
return fmt.Sprintf("orders_%d", shardID)
|
||||
}
|
||||
|
||||
// 举例:
|
||||
// user_id=1001, 2026年3月 → orders_38 (1001%16=9, month=2, 9*4+2=38)
|
||||
// user_id=1001, 2026年5月 → orders_36 (1001%16=9, month=4, 9*4+0=36)
|
||||
// user_id=2002, 2026年3月 → orders_10 (2002%16=2, month=2, 2*4+2=10)
|
||||
// 同一用户不同月份的数据落在相邻分片,方便按用户维度聚合
|
||||
```
|
||||
|
||||
## 分片键选型指南
|
||||
|
||||
分片键是选择最关键的决策——**一旦选定,后期几乎无法更换**。
|
||||
分片键(Sharding Key)是分库分表中最关键的决策——**一旦选定,后期几乎无法更换**。选错了分片键,就像把图书馆的书按"书名首字"分区,找"数据库"相关的书要跑遍 20 个书架。
|
||||
|
||||
分片键的核心要求只有一个:**让最常用的查询能精确定位到一个分片**,而不是扫描所有分片。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -151,7 +215,7 @@ flowchart TD
|
||||
|
||||
## 分布式 ID 与分片的关系
|
||||
|
||||
分库分表后,**全局唯一 ID** 是第一道门槛。每个分片的自增 ID 只在本地有意义,合并时必然冲突。
|
||||
分库分表后,**全局唯一 ID** 是第一道门槛。想象有两个班级各自编号:A 班有 1 号、2 号、3 号……B 班也有 1 号、2 号、3 号……当两个班级合并参加运动会时,"1 号"到底是谁?这就是分库后自增 ID 冲突的问题。
|
||||
|
||||
> [!IMPORTANT] 主键策略必须适配分片
|
||||
>
|
||||
@@ -169,15 +233,18 @@ flowchart TD
|
||||
|
||||
## 跨分片查询
|
||||
|
||||
这是分库分表最大的痛点。
|
||||
这是分库分表最大的痛点。分片前,一个 `JOIN` 就能搞定的查询,分片后可能需要遍历所有分片再手动合并。
|
||||
|
||||
### 不可行方案
|
||||
|
||||
```sql
|
||||
-- ❌ JOIN 跨分片:MySQL 原生不支持分布式 JOIN
|
||||
-- 一个表在 sharded_db.orders_0..3,另一个在 user_db.users
|
||||
-- ❌ 跨分片 JOIN:MySQL 的 JOIN 只在同一实例内有效
|
||||
-- orders 表在 sharded_db,users 表在 user_db,两个不同的实例
|
||||
-- 这条 SQL 会直接报错:Table 'user_db.users' doesn't exist
|
||||
SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id;
|
||||
|
||||
-- ❌ UNION ALL 拼全部分片(除非你知道具体分片号)
|
||||
-- ❌ 暴力 UNION ALL:除非你能精确计算分片号,否则必须扫描全部分片
|
||||
-- 4 个分片还好,如果有 64 个分片,每次都扫全量就太浪费了
|
||||
SELECT * FROM orders_0 WHERE user_id = 100
|
||||
UNION ALL SELECT * FROM orders_1 WHERE user_id = 100
|
||||
UNION ALL SELECT * FROM orders_2 WHERE user_id = 100
|
||||
@@ -243,78 +310,99 @@ flowchart LR
|
||||
```
|
||||
|
||||
```go
|
||||
// 游标分页 + 多路归并排序
|
||||
// 类似 Git rebase 的多分支合并逻辑
|
||||
// 核心思路:游标分页 + 多路归并
|
||||
// 第一步:每个分片多取一些数据(扩大缓冲区)
|
||||
// 第二步:应用层合并所有结果,排序后截取需要的条数
|
||||
|
||||
type PageRequest struct {
|
||||
UserID int64
|
||||
Limit int // 期望返回数量
|
||||
AfterTs int64 // 游标:上次最后一条的时间戳
|
||||
Limit int // 期望返回条数,比如 20
|
||||
AfterTs int64 // 游标:上一页最后一条的时间戳,首次请求传 0
|
||||
}
|
||||
|
||||
func QueryShardedOrders(req PageRequest) ([]Order, error) {
|
||||
shards := []string{fmt.Sprintf("orders_%d", req.UserID%4)}
|
||||
// 1. 计算目标分片(这里只查 1 个分片,因为 user_id % 4 路由精确)
|
||||
shard := fmt.Sprintf("orders_%d", req.UserID%4)
|
||||
|
||||
// Step 1: 各分片拉取 (Limit + Depth) 条数据
|
||||
type RawPage struct {
|
||||
Shard string
|
||||
Orders []Order
|
||||
}
|
||||
|
||||
var allPages []RawPage
|
||||
for _, shard := range shards {
|
||||
q := fmt.Sprintf(
|
||||
// 2. 扩大窗口:请求 20 条,但从每个分片取 40 条作为缓冲
|
||||
// 为什么要多取?因为游标边界附近的数据可能分布在不同分片
|
||||
bufferSize := req.Limit * 2
|
||||
query := fmt.Sprintf(
|
||||
"SELECT * FROM %s WHERE user_id = %d AND created_at < %d ORDER BY created_at DESC LIMIT %d",
|
||||
shard, req.UserID, req.AfterTs, req.Limit*2, // 扩大 2 倍缓冲
|
||||
shard, req.UserID, req.AfterTs, bufferSize,
|
||||
)
|
||||
rows, _ := db.Query(shardDBConn(shard), q)
|
||||
rows, _ := db.Query(getConn(shard), query)
|
||||
orders := scanOrders(rows)
|
||||
allPages = append(allPages, RawPage{shard, orders})
|
||||
}
|
||||
|
||||
// Step 2: 多路归并(k-way merge)
|
||||
merged := mergeSort(allPages, req.Limit)
|
||||
return merged, nil
|
||||
// 3. 截取需要的数量
|
||||
if len(orders) > req.Limit {
|
||||
orders = orders[:req.Limit]
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// k-way merge:类似归并排序的 merge 步骤
|
||||
func mergeSort(pages []RawPage, limit int) []Order {
|
||||
all := make([]Order, 0, len(pages)*limit)
|
||||
for _, p := range pages {
|
||||
all = append(all, p.Orders...)
|
||||
// 当分片键不是查询条件时(比如按时间全局排序),需要扫描所有分片:
|
||||
func QueryAllShards(req PageRequest) ([]Order, error) {
|
||||
var allOrders []Order
|
||||
for i := 0; i < 4; i++ {
|
||||
shard := fmt.Sprintf("orders_%d", i)
|
||||
query := fmt.Sprintf(
|
||||
"SELECT * FROM %s WHERE created_at < %d ORDER BY created_at DESC LIMIT %d",
|
||||
shard, req.AfterTs, req.Limit*2,
|
||||
)
|
||||
rows, _ := db.Query(getConn(shard), query)
|
||||
allOrders = append(allOrders, scanOrders(rows)...)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].CreatedAt.After(all[j].CreatedAt)
|
||||
|
||||
// 全局排序 + 截取
|
||||
sort.Slice(allOrders, func(i, j int) bool {
|
||||
return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt)
|
||||
})
|
||||
if len(all) > limit {
|
||||
return all[:limit]
|
||||
if len(allOrders) > req.Limit {
|
||||
allOrders = allOrders[:req.Limit]
|
||||
}
|
||||
return all
|
||||
return allOrders, nil
|
||||
}
|
||||
```
|
||||
|
||||
> [!QUESTION] 为什么不在数据库层做多分片聚合?
|
||||
> 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。想象你有 4 个桶,每个桶倒出前 20 颗珠子,合起来 80 颗里取前 20——但如果你要的是「全局第 1000~1020 颗」,每个桶自己取前 20 完全不够。**解决方案是加大每片采集窗口,然后在应用层做全局排序截断。** 代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
|
||||
> 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。举个具体例子:你有 4 个分片,想查全局第 100~120 条数据。如果每个分片执行 `LIMIT 100, 20`,得到的是"每个分片自己排第 100~120 条"——合起来 80 条数据,但它们和全局第 100~120 条完全不同。
|
||||
>
|
||||
> **正确做法**:从每个分片多取一些数据(扩大窗口),在应用层做全局排序后截取。代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
|
||||
|
||||
### 跨分片聚合(COUNT / SUM / AVG)
|
||||
|
||||
```sql
|
||||
-- ❌ COUNT(*) 在单个分片运行,汇总时直接相加即可
|
||||
-- ✅ 聚合类查询:SUM(xxx) → 各分片算完再 SUM
|
||||
简单聚合(COUNT、SUM、AVG)的思路很直接:**每个分片各自算一遍,应用层再合并**。比如总订单数 = 分片 0 的 COUNT + 分片 1 的 COUNT + ……
|
||||
|
||||
-- 但复杂聚合(GROUP BY + JOIN)会很贵
|
||||
-- 方案:预计算 + 定时汇总表
|
||||
-- cron 每小时执行一次:
|
||||
但 `GROUP BY + 聚合` 就麻烦了——你需要合并各分片的分组结果,逻辑复杂且性能差。**更好的方案是预计算**:
|
||||
|
||||
```sql
|
||||
-- 方案:定时汇总表(cron 每小时执行一次)
|
||||
-- 每个分片各自聚合,结果写入同一张汇总表
|
||||
INSERT INTO summary_daily (date, total_orders, total_amount)
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_0 GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_1 GROUP BY DATE(created_at);
|
||||
FROM orders_1 GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_2 GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_3 GROUP BY DATE(created_at);
|
||||
|
||||
-- 查询时直接读汇总表,响应速度从"秒级"降到"毫秒级"
|
||||
SELECT date, SUM(total_orders), SUM(total_amount)
|
||||
FROM summary_daily
|
||||
WHERE date BETWEEN '2026-05-01' AND '2026-05-31'
|
||||
GROUP BY date;
|
||||
```
|
||||
|
||||
## 中间件方案
|
||||
|
||||
前面的代码示例都是手动计算分片路由——在生产环境中,我们更常用成熟的中间件来处理分片逻辑。中间件就像一个"智能路由器":应用发一条普通 SQL,中间件自动帮你拆分、路由、合并结果。
|
||||
|
||||
| 中间件 | 开发者 | 部署模式 | 核心特点 |
|
||||
|--------|-------|---------|---------|
|
||||
| **Vitess** | YouTube / Google | Proxy | 透明分片、在线 Re-sharding、K8s 原生 |
|
||||
@@ -363,6 +451,8 @@ flowchart TB
|
||||
|
||||
### 双写迁移法(推荐)
|
||||
|
||||
双写迁移的思路很直观:**新旧两套库同时写入,逐步切换读流量**。就像搬新家时,先把旧家具搬到新房子,同时新旧两个地址都收快递——等新家收拾好了,再把快递地址改成新家。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["旧库 single_db"] --> B["新集群 sharded_db_0..N"]
|
||||
@@ -408,21 +498,37 @@ func CreateOrder(order *Order) error {
|
||||
|
||||
### 渐进式迁移(适合超大表)
|
||||
|
||||
渐进式迁移的核心思想是**分批切换**——先迁移一部分流量到新集群,验证无误后再迁移下一批,最终全部切完。就像搬办公室:先把一个部门搬过去,安顿好了再搬下一个,而不是一次性把整层楼的人都搬走。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Init["初始状态"]
|
||||
AllOld["全部流量"] --> OldDB["老库"]
|
||||
end
|
||||
|
||||
subgraph P1["Phase 1: 50% 流量"]
|
||||
P1Even["hash(user_id) % 2 == 0"] --> NewS01["新分片 0,1"]
|
||||
P1Odd["hash(user_id) % 2 == 1"] --> OldDB2["老库"]
|
||||
end
|
||||
|
||||
subgraph P2["Phase 2: 100% 流量"]
|
||||
P2A["hash(user_id) % 4 == 0,1"] --> NewS01B["新分片 0,1"]
|
||||
P2B["hash(user_id) % 4 == 2,3"] --> NewS23["新分片 2,3"]
|
||||
end
|
||||
|
||||
Init -->|"Phase 1: 先切一半"| P1
|
||||
P1 -->|"Phase 2: 再切剩余"| P2
|
||||
|
||||
style P1 fill:#FFEAA7,color:#000
|
||||
style P2 fill:#00D866,color:#fff
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 渐进式迁移:按分片逐步切换 │
|
||||
│ │
|
||||
│ 初始: 全部流量 → 老库 │
|
||||
│ Phase 1: hash(user_id) % 2 == 0 → 新分片 0,1 │
|
||||
│ hash(user_id) % 2 == 1 → 老库 │
|
||||
│ Phase 2: hash(user_id) % 4 = 0,1 → 新分片 0,1 │
|
||||
│ hash(user_id) % 4 = 2,3 → 新分片 2,3 │
|
||||
│ Final: 全部流量 → 新集群 │
|
||||
│ │
|
||||
│ 优势:每次切换只影响部分用户,风险可控 │
|
||||
│ 劣势:需要在应用层维护两套路由规则 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> [!TIP] 渐进式迁移的核心优势
|
||||
> - **风险可控**:每次只影响部分用户,出问题时只需回滚那部分流量
|
||||
> - **可灰度验证**:先选一小批内部用户试跑,确认无误后再扩大范围
|
||||
> - **无需停机**:整个过程对用户透明,业务零感知
|
||||
>
|
||||
> 劣势:需要在应用层维护两套路由规则,增加了代码复杂度。
|
||||
|
||||
> [!WARNING] 迁移 checklist
|
||||
>
|
||||
@@ -434,14 +540,16 @@ func CreateOrder(order *Order) error {
|
||||
|
||||
## 分片的挑战与反模式
|
||||
|
||||
| 问题 | 描述 | 应对策略 |
|
||||
|------|------|---------|
|
||||
| **数据倾斜** | 某些分片数据量远大于其他 | 检查 hash 分布,必要时换分片键或加 salt |
|
||||
| **跨分片事务** | InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿 |
|
||||
| **Rebalance** | 新增节点后数据如何迁移 | 渐进式迁移,Vitess 内置 Scatter-Gather |
|
||||
| **复杂聚合** | COUNT / SUM 跨分片很贵 | 预计算 + 定时汇总到汇总表 |
|
||||
| **分页困难** | LIMIT 深分页在各分片各自生效 | 游标分页 + 应用层合并(见上方详解) |
|
||||
| **全局唯一约束** | UNIQUE KEY 在多分片下失效 | 改为业务层面去重,或用 Redis SET |
|
||||
分库分表不是银弹,它解决了一个问题,却引入了更多问题。下表列出了最常见的"坑"和应对策略:
|
||||
|
||||
| 问题 | 具体表现 | 应对策略 |
|
||||
|------|---------|---------|
|
||||
| **数据倾斜** | 某些分片数据量远大于其他(比如大卖家的订单集中在某个分片) | 检查 hash 分布,必要时换分片键或加 salt 打散 |
|
||||
| **跨分片事务** | 一笔业务涉及多个分片,InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿(见 [[hhs/MySQL/08-工程实践/36-分布式事务]]) |
|
||||
| **Rebalance** | 新增节点后,老数据如何迁移到新分片? | 渐进式迁移,Vitess 内置 Scatter-Gather |
|
||||
| **复杂聚合** | COUNT / SUM 跨分片很贵,GROUP BY 更是噩梦 | 预计算 + 定时汇总到汇总表(见上方详解) |
|
||||
| **分页困难** | LIMIT 深分页在各分片各自生效,合并后结果不对 | 游标分页 + 应用层合并(见上方详解) |
|
||||
| **全局唯一约束** | UNIQUE KEY 在多分片下失效(两个分片可能各插入同一条) | 改为业务层面去重,或用 Redis SET |
|
||||
|
||||
> [!WARNING] 别过早分片
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user