From 622046c52d24483d14e7a79d82510e720fa66567 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 7 Sep 2026 19:57:11 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E3=80=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E3=80=8D=E7=AB=A0=E8=8A=82=EF=BC=88MySQL=20?= =?UTF-8?q?=C3=974=20+=20Redis=20=C3=973=EF=BC=8C=E5=85=B1=207=20=E7=AF=87?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL: - acid-logs.md ACID 与三大日志:undo/redo/binlog 分工、WAL、崩溃恢复三阶段、两阶段提交 - transaction-isolation.md 事务隔离级别:三大异常、四级别取舍、RR 幻读破功场景、RC vs RR 选型 - mvcc.md MVCC 机制:隐藏列与 undo 版本链、Read View 五条可见性规则、快照读 vs 当前读 - btree-index.md B+ 树索引:为何选 B+ 树、聚簇/二级索引与回表、最左前缀、覆盖索引、ICP、索引失效 Redis: - persistence.md RDB / AOF / 混合持久化、AOF 重写本质、与 MySQL binlog/redo log 对比 - sentinel.md 哨兵:主观/客观下线、Leader 选举、故障转移选主、脑裂防护 - cluster.md 集群:16384 哈希槽、MOVED vs ASK、hash tag、去中心化故障转移 - 新增 database/index.md 章节首页与阅读路径 - mkdocs.yml nav 增加「数据库」顶级章节 - cluster.md 中槽位数字经 CRC16-CCITT 实际计算校验 --- docs/database/index.md | 40 + docs/database/mysql/acid-logs.md | 849 ++++++++++++++ docs/database/mysql/btree-index.md | 480 ++++++++ docs/database/mysql/mvcc.md | 893 +++++++++++++++ docs/database/mysql/transaction-isolation.md | 712 ++++++++++++ docs/database/redis/cluster.md | 512 +++++++++ docs/database/redis/persistence.md | 1059 ++++++++++++++++++ docs/database/redis/sentinel.md | 970 ++++++++++++++++ mkdocs.yml | 11 + 9 files changed, 5526 insertions(+) create mode 100644 docs/database/index.md create mode 100644 docs/database/mysql/acid-logs.md create mode 100644 docs/database/mysql/btree-index.md create mode 100644 docs/database/mysql/mvcc.md create mode 100644 docs/database/mysql/transaction-isolation.md create mode 100644 docs/database/redis/cluster.md create mode 100644 docs/database/redis/persistence.md create mode 100644 docs/database/redis/sentinel.md diff --git a/docs/database/index.md b/docs/database/index.md new file mode 100644 index 0000000..e507401 --- /dev/null +++ b/docs/database/index.md @@ -0,0 +1,40 @@ +# 数据库 + +!!! note "MySQL 与 Redis 核心原理:日志、事务、索引、持久化与高可用" + +本章整理数据库相关的底层原理与面试高频知识点,分 MySQL 与 Redis 两部分。 +MySQL 侧重「一条数据是怎么被安全地改掉的」(日志 + 事务 + 索引), +Redis 侧重「内存数据怎么不丢、单机扛不住了怎么办」(持久化 + 哨兵 + 集群)。 + +--- + +## MySQL + +| 文档 | 主题 | 关键词 | +|------|------|--------| +| [ACID 与三大日志](mysql/acid-logs.md) | undo log / redo log / binlog 分工、WAL、崩溃恢复三阶段、两阶段提交 | WAL、脏页、checkpoint、2PC | +| [事务隔离级别](mysql/transaction-isolation.md) | 脏读 / 不可重复读 / 幻读,四种隔离级别取舍,RC vs RR 选型 | READ COMMITTED、REPEATABLE READ | +| [MVCC 机制](mysql/mvcc.md) | 隐藏列与 undo 版本链、Read View 可见性判定、快照读 vs 当前读 | DB_TRX_ID、Read View、next-key lock | +| [B+ 树索引与优化](mysql/btree-index.md) | 为什么是 B+ 树、聚簇索引与回表、最左前缀、覆盖索引、索引失效 | 聚簇索引、回表、ICP、EXPLAIN | + +## Redis + +| 文档 | 主题 | 关键词 | +|------|------|--------| +| [持久化:RDB / AOF / 混合](redis/persistence.md) | BGSAVE 与 COW、appendfsync 三档、AOF 重写本质、混合持久化文件结构 | RDB、AOF、aof-use-rdb-preamble | +| [哨兵机制](redis/sentinel.md) | 主观/客观下线、Leader 选举、故障转移选主规则、脑裂防护 | SDOWN、ODOWN、quorum、min-replicas-to-write | +| [集群 Cluster](redis/cluster.md) | 16384 哈希槽、CRC16、MOVED vs ASK、hash tag、去中心化故障转移 | hash slot、CROSSSLOT、gossip | + +--- + +## 阅读路径 + +- **面试速通**:[事务隔离级别](mysql/transaction-isolation.md) → [MVCC](mysql/mvcc.md) → [B+ 树索引](mysql/btree-index.md) → [Redis 持久化](redis/persistence.md) +- **搞懂「数据为什么不丢」**:[ACID 与三大日志](mysql/acid-logs.md) → [Redis 持久化](redis/persistence.md),两篇都有 MySQL 日志与 Redis AOF 的横向对比 +- **搞懂「单机扛不住了怎么办」**:[哨兵机制](redis/sentinel.md)(解决高可用)→ [集群 Cluster](redis/cluster.md)(解决高可用 + 容量/写扩展) + +## 与其他章节的关联 + +- [架构 · 高并发秒杀系统设计](../architecture/seckill-design.md) — Redis Lua 原子扣减、集群下的 hash tag 用法 +- [架构 · 缓存](../architecture/cache/cache-multilevel-read-write.md) — 多级缓存读写策略、Write-Behind 为什么必须开 AOF +- [算法 · 布隆过滤器](../algorithm/bloom-filter.md) — 缓存穿透的解法之一 diff --git a/docs/database/mysql/acid-logs.md b/docs/database/mysql/acid-logs.md new file mode 100644 index 0000000..e232fad --- /dev/null +++ b/docs/database/mysql/acid-logs.md @@ -0,0 +1,849 @@ +# MySQL ACID 与三大日志 + +!!! note "💡 一句话概述" + **原子性靠 undo log(后悔药),持久性靠 redo log + WAL(备忘录),隔离性靠锁 + MVCC,一致性是前三者加约束共同达成的「结果」而不是一个独立模块。** 而 redo log(引擎内部保命)和 binlog(对外广播的账本)之间必须靠**两阶段提交**对账,否则主从数据会不一致。 + +--- + +## 🔑 核心概念 + +1. **WAL(Write-Ahead Logging,预写日志)** — 先写日志再写数据页。因为日志是顺序追加写、数据页是随机写,一次提交只需要 fsync 一个日志文件,脏页可以延迟批量刷盘。这是 InnoDB 高性能的地基。 +2. **undo log(后悔药)** — **逻辑日志**,记录「怎么把数据改回原样」。两个用途:① 回滚保证原子性;② 撑起 MVCC 的多版本链。 +3. **redo log(备忘录)** — **物理日志**,记录「第几号页、偏移多少、改成什么」。InnoDB 引擎层独有,固定大小的**环形缓冲**循环写,专门用于崩溃恢复。 +4. **binlog(对外广播的账本)** — MySQL **Server 层**的逻辑日志(所有引擎都有),**追加写、写满换新文件、永不覆盖**。用于主从复制、PITR 时间点恢复、审计、CDC。 +5. **两阶段提交(内部 XA)** — redo 写 prepare → 写 binlog → redo 写 commit。崩溃恢复时**以 binlog 是否完整为唯一裁决依据**,保证两份日志记录的事务集合完全一致。 +6. **Buffer Pool / 脏页 / checkpoint / doublewrite** — 内存里改完的页叫脏页;checkpoint 是「这之前的 redo 对应的脏页已刷盘,日志可以被覆盖」的水位线;doublewrite 是防止「16KB 页只写了一半」的物理损坏兜底。 + +--- + +## 📝 详解 + +### 1. 先把总纲钉在脑子里 + +面试问「MySQL 怎么保证 ACID」,90% 的人会背出一堆名词然后卡住。正确的答法是**先给分工,再给机制**: + +| ACID | 靠什么保证 | 关键机制 | 一句话 | +|---|---|---|---| +| **A** 原子性 | **undo log** | 记录反向操作,回滚时逆序执行 | 要么全成,要么当没发生过 | +| **C** 一致性 | **A + I + D + 约束 + 业务代码** | 主键/唯一/非空/外键/CHECK | **不是独立模块,是结果** | +| **I** 隔离性 | **锁 + MVCC** | 行锁/间隙锁/next-key;undo 版本链 + ReadView | 并发事务互不干扰 | +| **D** 持久性 | **redo log + WAL** | 提交时 fsync redo,脏页延迟刷 | 提交了就永远不丢 | + +!!! tip "一致性最容易被答错" + 一致性(C)**没有对应的单一机制**。它是「A、I、D 三个都成立」+「数据库约束(主键、唯一、非空、外键、CHECK)」+「你的业务代码没写错」三者合力得到的**最终状态**。原子性被破坏会留下半截数据,隔离性被破坏会读到中间态,持久性被破坏会让已提交状态回退——任何一个塌了,一致性都塌。所以「一致性由 redo log 保证」这种回答是错的。 + +给 Go 后端同学的类比: + +``` +undo log = 你在内存里做业务时留的 rollback 函数(defer 里执行) +redo log = 你自己进程的 WAL,保证 crash 后能重放(类似 etcd 的 WAL、Kafka 的 log segment) +binlog = 你对外发的变更事件流(类似 Canal 订阅的 CDC、Kafka topic) +``` + +--- + +### 2. 一切的地基:Buffer Pool 与脏页 + +要理解三大日志,先得理解 InnoDB 的写路径**根本不是「改内存 → 立刻写磁盘」**。 + +InnoDB 在内存里维护一块 **Buffer Pool**(默认 128MB,生产通常给到物理内存的 50%~70%),数据以 **16KB 的页(page)** 为单位缓存。你执行 `UPDATE` 时: + +1. 把目标页读进 Buffer Pool(如果不在); +2. **在内存里直接改**; +3. 把这个页标记为**脏页(dirty page)**——「内存里的版本比磁盘上的新」; +4. 至于什么时候把脏页刷回 `.ibd` 数据文件?**不着急,以后再说。** + +``` + ┌───────────────── 内存 ─────────────────┐ + UPDATE ... ──▶ │ Buffer Pool │ + │ ┌────────┐ ┌────────┐ │ + │ │ page 7 │ │ page 9 │ ← 改了没落盘 │ + │ └───┬────┘ └───┬────┘ = 脏页 │ + │ │ │ │ + │ redo log buffer undo log buffer │ + └───────┼────────────┼───────────────────┘ + │ COMMIT 时 fsync(顺序追加,很快) + ┌───────▼────────────▼───────────────────┐ + │ #ib_redo 文件 undo 表空间 │ 磁盘 + │ (redo log) (undo log) │ + │ │ + │ ✗ 脏页【不】在这一步落盘 │ + └────────────────────────────────────────┘ + ⋮ + 后台刷脏线程 / checkpoint 推进时才批量刷 + ⋮ + ┌────────────────────────────────────────┐ + │ 表空间 .ibd 数据文件 │ + └────────────────────────────────────────┘ +``` + +那么问题来了:**脏页还在内存里,机器断电了怎么办?** 这就是 WAL 要解决的事。 + +--- + +### 3. WAL:为什么「先写日志」反而更快 + +WAL 的完整名字是 **Write-Ahead Logging(预写式日志)**,规则只有一句: + +> **任何数据页的修改落盘之前,必须先把它对应的 redo log 记录写入磁盘。** + +直觉上「多写一份日志」应该更慢才对,为什么反而快?三个原因: + +| # | 原因 | 展开说明 | +|---|---|---| +| ① | **随机写 → 顺序写** | 一个事务可能改 20 个分散在不同表、不同位置的页,刷盘就是 20 次**随机 IO**(机械盘寻道 ~10ms,SSD 也远慢于顺序)。而 redo log 是**追加写同一个文件**,纯顺序 IO,快一到两个数量级。 | +| ② | **一次提交只 fsync 一个文件** | `fsync` 是最贵的系统调用(要把 OS page cache 强行推到磁盘介质)。有了 WAL,一次 `COMMIT` 只需要保证**一个 redo log 文件** fsync 成功,不需要碰任何数据文件。 | +| ③ | **脏页延迟 + 批量 + 合并** | 同一个页在内存里被改 100 次,最终只需要刷盘**一次**(写的是最终状态)。而且可以攒一批脏页一起顺序刷、可以放到业务低峰期刷。 | + +配合 MySQL 5.6+ 的 **redo log 组提交(group commit)**:多个并发事务的 redo 可以合并成一次 `fsync`,写入吞吐再上一个台阶。 + +!!! success "一句话总结 WAL" + **用「一次小的顺序写」换掉「N 次大的随机写」,同时把持久性的责任从数据页转移到日志上。** 数据页什么时候落盘已经不影响正确性了——只要 redo 在,页就能重建。 + +--- + +### 4. undo log:后悔药 + +#### 4.1 它是逻辑日志,记录的是「反向操作」 + +undo log 不记录字节,它记录的是**语义上如何撤销**: + +| 你执行的操作 | undo log 记录的内容 | 回滚时执行 | +|---|---|---| +| `INSERT INTO t VALUES (1, 'a')` | 这行的主键 `(1)` | `DELETE FROM t WHERE id = 1` | +| `DELETE FROM t WHERE id = 1` | 被删行的**完整旧值** `(1, 'a')` | `INSERT INTO t VALUES (1, 'a')` | +| `UPDATE t SET name = 'b' WHERE id = 1` | 被改列的**旧值** `name = 'a'` | `UPDATE t SET name = 'a' WHERE id = 1` | + +回滚时**从最新的 undo 记录逆向执行回事务起点**。注意 `UPDATE` 只记被修改的列的旧值,不记整行——所以 undo 的体积通常比 redo 小。 + +#### 4.2 用途一:回滚,保证原子性 + +```sql +BEGIN; +UPDATE account SET balance = balance - 100 WHERE id = 1; -- undo 记下 id=1 的旧余额 +INSERT INTO transfer_log VALUES (NULL, 1, 2, 100); -- undo 记下新插入行的主键 +-- 这里业务代码报错 / 你手动 ROLLBACK / 连接断开 +ROLLBACK; -- InnoDB 逆序执行 undo:删掉 transfer_log 那行 → 把 id=1 的余额加回 100 +``` + +显式 `ROLLBACK`、语句执行出错、客户端连接断开、甚至**崩溃恢复时发现事务未提交**,走的都是同一条 undo 回滚路径。这就是原子性的实现。 + +#### 4.3 用途二:MVCC 版本链(隔离性的另一半) + +这是 undo log 最容易被忽略的价值。InnoDB 的每行数据都有隐藏列: + +- `DB_TRX_ID` — 最后修改这行的事务 ID +- `DB_ROLL_PTR` — 回滚指针,指向 undo log 里这行的**上一个版本** +- `DB_ROW_ID` — 没定义主键时用的隐藏自增 ID + +于是多次更新会把旧版本串成一条**版本链**: + +``` + 当前行(Buffer Pool 里) undo log 中的历史版本 + ┌──────────────────────┐ + │ balance = 700 │ + │ DB_TRX_ID = 300 │ + │ DB_ROLL_PTR ─────────┼──▶ ┌──────────────────────┐ + └──────────────────────┘ │ balance = 800 │ + │ DB_TRX_ID = 200 │ + │ DB_ROLL_PTR ─────────┼──▶ ┌──────────────────────┐ + └──────────────────────┘ │ balance = 900 │ + │ DB_TRX_ID = 100 │ + │ DB_ROLL_PTR = NULL │ + └──────────────────────┘ +``` + +快照读(普通 `SELECT`)时,事务拿自己的 **ReadView**(记录「我看到这个快照时,哪些事务还活跃」)沿版本链**从新往旧找**第一个对自己可见的版本,**全程不加锁**。这就是为什么 InnoDB 能做到「读不阻塞写、写不阻塞读」。 + +- **RR(可重复读,默认)**:事务内第一次快照读时生成 ReadView,之后一直复用 → 可重复读。 +- **RC(读已提交)**:每次快照读都重新生成 ReadView → 能读到别人新提交的数据。 +- **当前读**(`SELECT ... FOR UPDATE` / `UPDATE` / `DELETE` / `INSERT`):读最新版本并加锁,**不走 MVCC**。 + +!!! warning "长事务的真正代价" + 一个跑了 10 分钟不提交的事务,会让它启动时刻之后的**所有 undo 记录都无法被 purge 线程清理**(因为可能还有人需要那些历史版本)。结果是 undo 表空间暴涨、版本链越来越长、别的快照读要遍历一长串版本才能找到可见数据,查询越来越慢。查长事务: + `SELECT * FROM information_schema.INNODB_TRX ORDER BY trx_started LIMIT 10;` + +--- + +### 5. redo log:备忘录 + +#### 5.1 它是物理日志 + +redo log 记录的不是 SQL,而是**对页的物理修改**,概念上长这样: + +``` +(space_id = 34, page_no = 17, offset = 112, len = 8, data = "900") + ↑表空间号 ↑第几号页 ↑页内偏移 ↑改多长 ↑改成什么 +``` + +意思是:「34 号表空间的 17 号页,偏移 112 处,改成 `900`」。崩溃恢复时,InnoDB 只要**照着这条记录把字节写回去**,页就恢复了——这叫**前向重放(redo apply)**,重放是**幂等**的。 + +!!! important "redo 只往前,从不往后" + redo log **只负责重做已提交的修改**,它从不回滚任何东西。回滚是 undo log 的活。这个分工在崩溃恢复流程(第 9 节)里体现得最清楚。 + +#### 5.2 环形缓冲:write pos 与 checkpoint + +redo log 不是无限增长的日志文件,它是一个**固定大小的环形缓冲**: + +``` + write pos(当前写到哪) + ↓ + ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐ + │ │ │ ▓▓ │ ▓▓ │ ▓▓ │ │ │ │ │ ▓▓ │ ▓▓ │ ▓▓ │ ↺ + └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘ + ↑ + checkpoint(此位置之前的日志, + 对应的脏页已刷盘 → 空间可被覆盖) + + ─────────── 顺时针循环推进 ───────────▶ + + ✅ 正常: checkpoint ··········· write pos 中间还有空闲空间 + 🚨 危险: write pos 追上了 checkpoint 必须【阻塞所有写入】, + 强制刷脏页把 checkpoint 推上去 +``` + +- **write pos 到 checkpoint 之间**:空闲,可以写新日志。 +- **checkpoint 到 write pos 之间**:还没被覆盖的 redo,对应的脏页**可能还没刷盘**,所以不能擦。 +- **write pos 追上 checkpoint**:redo 满了,MySQL 会**卡住所有更新**,拼命刷脏页推进 checkpoint。这就是线上偶发的「MySQL 突然全库卡顿几秒」的经典原因之一——**redo log 配得太小**。 + +日志文件位置与大小(注意版本差异,别记错参数名): + +| MySQL 版本 | 文件 | 控制参数 | 默认值 | +|---|---|---|---| +| ≤ 8.0.29 | `ib_logfile0`、`ib_logfile1` | `innodb_log_file_size` × `innodb_log_files_in_group` | 48MB × 2 | +| ≥ 8.0.30 | `#innodb_redo/` 目录下的一组 `#ib_redoN` | `innodb_redo_log_capacity`(前两个参数已废弃) | 100MB | + +生产上一般给到 **1~4GB**(写入量大的实例更大),目标是让 redo 能覆盖住「刷脏页所需的时间窗口」,避免 checkpoint 追不上。 + +#### 5.3 `innodb_flush_log_at_trx_commit`:0 / 1 / 2 的取舍 + +这是面试和生产调优都绕不开的一个参数。先分清三层存储: + +``` +InnoDB redo log buffer → OS page cache → 磁盘 + (mysqld 进程内存) (内核内存) (真正的介质) + ↑ write() 越过这条线 ↑ fsync() 越过这条线 +``` + +| 取值 | COMMIT 时做什么 | 后台每秒做什么 | **mysqld 崩溃** | **OS 崩溃 / 断电** | 性能 | 适用场景 | +|:---:|---|---|:---:|:---:|:---:|---| +| **0** | 什么都不做,redo 留在 InnoDB 用户态 buffer | write + fsync | 🔴 丢约 1 秒 | 🔴 丢约 1 秒 | 最高 | 可重跑的日志表、统计、ETL 临时库 | +| **1**(默认) | write + **fsync** | — | 🟢 不丢 | 🟢 不丢 | 最低 | **金融、订单、账户、库存——必须是 1** | +| **2** | write 到 OS page cache,**不 fsync** | fsync | 🟢 不丢(OS 还活着,cache 里的数据在) | 🔴 丢约 1 秒 | 中 | 一般业务库、从库、可容忍秒级丢失的场景 | + +**0 和 2 的关键区别**在于数据停在哪一层:`0` 停在 **mysqld 进程内存**里,进程一崩就没了;`2` 已经交到了 **OS 内核**手里,进程崩了内核还活着,数据不丢,只有整机断电/内核 panic 才丢。 + +配套还有 binlog 侧的 **`sync_binlog`**:`0` = 由 OS 决定何时刷;`1` = 每次提交都 fsync;`N` = 每 N 个事务 fsync 一次。 + +!!! tip "传说中的「双 1 配置」" + `innodb_flush_log_at_trx_commit = 1` + `sync_binlog = 1`,这是**唯一能保证「提交即绝对不丢」**的组合,也是 MySQL 5.7.7+ 的默认值。任何一项调低,都在拿数据换性能。 + +#### 5.4 doublewrite:redo log 治不了「页写一半」 + +一个 InnoDB 页是 16KB,而文件系统的块通常是 4KB。如果刷一个脏页刷到第 8KB 时断电,这个页就处于**半新半旧的撕裂状态**(partial page write / page corruption)。 + +**为什么 redo log 救不了它?** 因为 redo 是**物理日志**,它记录的是「在偏移 112 处改成 900」——它**假设页的其余部分是正确的**。页本身已经损坏(checksum 校验不过),在这个坏页上重放 redo,得到的还是一坨垃圾。 + +所以 InnoDB 加了 **doublewrite buffer(双写缓冲)**: + +```mermaid +graph LR + A["Buffer Pool
脏页 16KB"] -->|"① 顺序追加写 + fsync
(便宜:顺序 IO)"| B["doublewrite 区域
2MB,.dblwr 文件"] + B -->|"② 再随机写到真实位置
(贵:随机 IO)"| D["表空间 .ibd"] + D -.->|"崩溃重启
checksum 校验失败"| E["③ 从 doublewrite
取出完整页副本"] + E -.->|"覆盖坏页"| D + D -.->|"④ 页已完整
再重放"| F["redo log
前向重放"] +``` + +顺序写 doublewrite 区域很便宜(顺序 IO),然后再随机写到真实位置。**恢复流程是:先用 doublewrite 修复物理损坏的页 → 再用 redo log 重放逻辑修改。** 两层兜底,缺一不可。 + +控制参数是 `innodb_doublewrite`(默认 `ON`)。MySQL 8.0.20 起,doublewrite 数据从系统表空间 `ibdata1` 挪到了独立的 `.dblwr` 文件(由 `innodb_doublewrite_dir` 控制),减少了系统表空间的争用。 + +!!! warning "别为了性能关掉 doublewrite" + 只有在**底层存储自带原子写保证**(如某些企业级 SSD 的 atomic write、FusionIO、ZFS 这类自带 COW 的文件系统)时,关掉 `innodb_doublewrite` 才是安全的。普通 ext4/xfs + 常规 SSD 关掉它,一次断电就可能让整张表损坏到无法启动。 + +#### 5.5 脏页什么时候刷盘 + +| 触发时机 | 说明 | +|---|---| +| **后台刷脏线程** | InnoDB 根据脏页比例、redo 生成速度自适应地持续刷(`innodb_page_cleaners`) | +| **checkpoint 推进** | redo 快写满时,必须刷脏页把 checkpoint 往前推(这时会**阻塞用户写入**) | +| **脏页比例超阈值** | 超过 `innodb_max_dirty_pages_pct`(默认 90)时加速刷 | +| **Buffer Pool 空间不足** | 要读新页但 LRU 上没有干净页可用,得先刷脏页腾位置 | +| **正常关机** | `innodb_fast_shutdown` 默认配置下会把脏页全刷干净 | + +--- + +### 6. binlog:对外广播的账本 + +#### 6.1 Server 层,所有引擎共享 + +binlog(binary log,归档日志)由 **MySQL Server 层**产生,**不管你用 InnoDB、MyISAM 还是 Memory 都有**。它记录的是「这个实例上发生过哪些逻辑变更」,是一份**归档**——这是它和 redo log 最本质的定位差异。 + +redo log 是**引擎的保命机制**(你不主动碰它,InnoDB 自动用),binlog 是**给外面的人看的**(从库、Canal、DBA、审计系统都要主动去读)。 + +#### 6.2 三种格式 + +由 `binlog_format` 控制(MySQL 8.0 默认 `ROW`): + +| 格式 | 记录什么 | 优点 | 缺点 | +|---|---|---|---| +| **STATEMENT** | 原始 SQL 文本 | 日志量小、人眼可读、省空间 | ⚠️ **可能主从不一致**:`NOW()`、`UUID()`、`RAND()`、不带 `ORDER BY` 的 `LIMIT`、触发器,在主从上的执行结果可能不同 | +| **ROW**(默认) | 每一行**改动前后的镜像**(前像 before image + 后像 after image) | 精确、可恢复、CDC 友好(能知道具体哪行从什么变成什么) | 日志量大:一条 `UPDATE ... WHERE 1=1` 影响 100 万行,就记 100 万行的变更 | +| **MIXED** | MySQL 自动判断:可能不一致的语句用 ROW,其余用 STATEMENT | 折中 | 仍有边界情况,且切换逻辑不透明 | + +ROW 格式还有个 `binlog_row_image` 参数:`FULL`(默认,记录整行所有列的前后值)、`MINIMAL`(只记主键 + 变更列)、`NOBLOB`。**做 CDC / 数据同步时必须是 `FULL`**,否则下游拿不到完整行。 + +#### 6.3 追加写,写满换文件 + +``` +binlog.000001 ← 写满 max_binlog_size(默认 1GB) +binlog.000002 +binlog.000003 +... +binlog.000042 ← 当前正在写 +binlog.index ← 索引文件,记录所有 binlog 文件名 +``` + +**binlog 永远不会覆盖旧内容**,写满就切一个新文件(`FLUSH LOGS` 也会强制切)。这跟 redo log 的环形覆盖是根本区别,也正是 binlog 能做 PITR 的前提。 + +空间管理靠过期策略,不靠覆盖: + +```sql +SHOW VARIABLES LIKE 'binlog_expire_logs_seconds'; -- 8.0,默认 2592000(30 天) +SHOW VARIABLES LIKE 'expire_logs_days'; -- 5.7 及之前 +PURGE BINARY LOGS BEFORE '2026-08-01 00:00:00'; -- 手动清理 +SHOW BINARY LOGS; -- 列出所有 binlog 文件 +SHOW MASTER STATUS; -- 当前正在写的文件 + position(8.4 改名 SHOW BINARY LOG STATUS) +``` + +#### 6.4 四大用途 + +| 用途 | 怎么用 | +|---|---| +| **主从复制** | 主库写 binlog → 从库 IO 线程拉过来写进 relay log → SQL 线程(8.0 支持多线程 MTS)重放 | +| **PITR 时间点恢复** | 全量备份 + 重放从备份点到目标时刻之间的 binlog | +| **审计** | `mysqlbinlog` 解析出「谁在几点改了什么」 | +| **CDC** | Canal / Maxwell / Debezium 伪装成从库,解析 binlog 把变更投递到 Kafka、ES、Redis、数仓 | + +--- + +### 7. redo log vs binlog:别再混为一谈 + +这是面试**最高频**的对比题,一张表背下来: + +| 维度 | **redo log** | **binlog** | +|---|---|---| +| **产生层级** | InnoDB **引擎层**(只有 InnoDB 有) | MySQL **Server 层**(所有引擎都有) | +| **日志类型** | **物理日志**(页号 + 偏移 + 字节) | **逻辑日志**(statement / row / mixed) | +| **写入方式** | **环形缓冲,循环覆盖** | **追加写,写满换新文件,不覆盖** | +| **写入时机** | 事务执行过程中**持续写**(每改一个页就写) | 事务**提交时**一次性写 | +| **核心用途** | **崩溃恢复**(crash-safe) | **主从复制、PITR、审计、CDC** | +| **能否恢复到任意时间点** | ❌ **不能**,只保留最近一小段(已被 checkpoint 覆盖的没了) | ✅ **能**(配合全量备份) | +| **是否需要主动读** | 不用,InnoDB 自动重放 | 要,`mysqlbinlog` / 从库 IO 线程 / Canal | +| **空间管理** | 自动循环复用 | `FLUSH LOGS` + `PURGE` / 过期时间自动删 | +| **能不能只用它保 crash-safe** | ✅ 能 | ❌ **不能**(见下方陷阱) | + +**为什么两个都需要,不能只留一个?** + +- 只有 binlog 不行:binlog 是 Server 层的逻辑日志,它**不知道 InnoDB 的页状态**,无法判断「哪些修改已经刷到数据文件、哪些还是内存脏页」,所以做不了崩溃恢复。而且 binlog 是在**提交时**才写的,事务执行中途崩溃的部分修改它根本没有记录能力。 +- 只有 redo log 不行:redo 是环形覆盖的,**没有归档能力**,无法恢复到任意时间点;而且它是 InnoDB 私有的物理日志,别的系统(从库、Canal、异构存储)根本读不懂。 + +一句话:**redo log 是「让 InnoDB 自己活下来」,binlog 是「让别人知道发生了什么」。** + +--- + +### 8. 两阶段提交:redo 和 binlog 必须对得上账 + +#### 8.1 为什么需要它 + +一个事务提交时,**两份日志都要写**。假设没有协调机制,简单粗暴地按顺序写: + +**反例 A:先写 redo,再写 binlog** + +``` +① redo log 写完 ✅ +② ← 此刻崩溃 +③ binlog 没写 ❌ +``` + +重启后:主库靠 redo 恢复出了这笔数据(**主库有**),但 binlog 里没有 → 从库永远同步不到(**从库没有**),用 binlog 做 PITR 恢复出来的库也没有。**主从不一致。** + +**反例 B:先写 binlog,再写 redo** + +``` +① binlog 写完 ✅ +② ← 此刻崩溃 +③ redo log 没写 ❌ +``` + +重启后:主库因为 redo 缺失,这笔事务**等于没发生**(主库没有),但 binlog 已经广播出去了 → 从库重放 binlog 后**多出了这笔数据**。**主从不一致,而且方向相反。** + +**结论:只要「写两份日志」这件事不是原子的,中间任何一个点崩溃都会导致两份日志记录的事务集合不一致。** 这正是分布式事务里经典的原子提交问题,MySQL 的解法就是 **内部 XA / 两阶段提交(2PC)**。 + +#### 8.2 流程 + +```mermaid +sequenceDiagram + autonumber + participant C as 客户端 + participant E as InnoDB 引擎层 + participant R as redo log + participant S as MySQL Server 层 + participant B as binlog + + C->>E: BEGIN; UPDATE ...; COMMIT + Note over E: 执行过程中:改 Buffer Pool 页 + 写 undo log
同时把物理变更写入 redo log buffer + + rect rgb(235, 245, 255) + Note over E,R: 【阶段一 · Prepare】 + E->>R: 写 redo log,事务状态标记为 prepare
(带上本事务的 XID) + R-->>E: redo 已按 flush_log_at_trx_commit 落盘 + end + + rect rgb(240, 255, 240) + Note over S,B: 【阶段二 · 写 binlog】 + E->>S: 通知 Server 层:prepare 完成 + S->>B: 写入本事务的全部 binlog event
最后一条是 XID_event(同一个 XID) + B-->>S: binlog 按 sync_binlog 落盘 + end + + rect rgb(255, 248, 235) + Note over E,R: 【阶段三 · Commit】 + S->>E: binlog 写成功,可以提交 + E->>R: 写 redo log,事务状态标记为 commit + end + + E-->>C: COMMIT 返回 OK +``` + +**XID 是串起两份日志的钥匙**:redo log 的 prepare 记录里带 XID,binlog 事务末尾的 `XID_event` 也带同一个 XID。崩溃恢复时就是靠它对账。 + +#### 8.3 崩溃恢复的裁决规则 + +| 崩溃发生在 | redo log 状态 | binlog 状态 | **裁决** | 理由 | +|---|---|---|:---:|---| +| 阶段一之前 | 无记录 / 未 prepare | 无 | **回滚** | 事务压根没提交 | +| ① 之后、② 之前 | **prepare** | **不完整/缺失** | **回滚** | binlog 没有 → 从库也不会有 → 主库回滚才能保持一致 | +| ② 之后、③ 之前 | **prepare** | **完整(有 XID_event)** | ✅ **提交** | binlog 已完整且可能已被从库消费 → 主库必须提交才能一致 | +| ③ 之后 | **commit** | 完整 | ✅ **已提交** | 正常情况 | + +!!! important "记住这条铁律" + **binlog 是唯一的裁决者(authority)。** 只要 binlog 完整,哪怕 redo 停在 prepare 状态,也判定为提交;只要 binlog 不完整,哪怕 redo 已经 prepare,也判定为回滚。 + 为什么是 binlog 说了算而不是 redo?因为 **binlog 一旦写完就可能已经被从库/Canal 消费掉了,是不可撤销的既成事实**;而 redo 还在本机,怎么处理都行。让本机迁就外部,才能保证全局一致。 + +#### 8.4 顺带一提:组提交 + +MySQL 5.7+ 把两阶段提交做了**组提交(group commit)**优化:把多个并发事务分成一批,合并它们的 `fsync` 调用(redo 一次、binlog 一次),并且拆成 flush / sync / commit 三个队列阶段流水线化。所以「两阶段提交」在高并发下**不是**每个事务两次 fsync 的串行开销,实际代价比想象中小很多。 + +--- + +### 9. 崩溃恢复三阶段 + +实例重启后,InnoDB 自动执行的恢复流程: + +```mermaid +graph TD + A["实例崩溃重启"] --> B["① 扫描 redo log
从 checkpoint LSN 读到日志末尾
确定需要恢复的 LSN 范围"] + B --> C["② 前向重放 redo(REDO APPLY)
把所有物理页修改应用到 Buffer Pool
⚠️ 此阶段【不区分】事务是否已提交"] + C --> D{"③ 逐事务裁决
检查 redo 中的事务状态"} + D -->|"状态 = commit"| E["✅ 保留
(第②步已重做完毕)"] + D -->|"状态 = prepare"| F{"拿 XID 去 binlog 里找
是否有完整的 XID_event?"} + F -->|"找得到 → 已提交"| G["✅ 标记 commit,保留"] + F -->|"找不到 → 未提交"| H["🔄 用 undo log 回滚"] + D -->|"无 commit 记录
(执行中途崩溃)"| H + E --> I["恢复完成
刷脏页,开放服务"] + G --> I + H --> I +``` + +三个阶段各自的职责: + +| 阶段 | 用什么日志 | 做什么 | 方向 | +|:---:|---|---|:---:| +| **① 扫描** | redo log | 从上一次 checkpoint 的 LSN 开始,扫到日志末尾,收集出所有页修改记录 + 事务状态 + XID | — | +| **② 重做** | redo log | **前向重放**所有物理修改(包括未提交事务的修改,先无脑应用,反正后面会撤),把「已提交但脏页没来得及刷盘」的数据找回来 | ⏩ 向前 | +| **③ 回滚** | undo log | 对第 ② 阶段裁决为「未提交」的事务,沿 undo 版本链**逆向执行**,把它们在内存里造成的修改撤销掉 | ⏪ 向后 | + +!!! tip "为什么第 ② 阶段连未提交事务的修改也一起重放?" + 因为 redo 是**页级别**的物理日志,同一个页上可能同时有事务 A(已提交)和事务 B(未提交)的修改,物理上没法只挑 A 的部分应用。所以策略是「**先全部重放,再用 undo 把 B 撤掉**」——简单、正确、可幂等重入。 + +**LSN(Log Sequence Number)** 是贯穿整个恢复过程的坐标:它是单调递增的字节量,标记 redo log 写到哪了。`SHOW ENGINE INNODB STATUS` 的 `LOG` 段能看到一组关键 LSN: + +``` +--- +LOG +--- +Log sequence number 14834495638 ← 当前 redo 写到的 LSN +Log buffer flushed up to 14834495638 ← 已 write 到 OS 的 LSN +Persisted to disk up to 14834495638 ← 已 fsync 落盘的 LSN +Last checkpoint at 14834495629 ← checkpoint 水位(差值 = 待刷脏的量) +``` + +--- + +### 10. 隔离性:锁 + MVCC 的分工 + +把 undo log 那节铺垫好的 MVCC 和锁合起来看: + +| 冲突类型 | 靠什么解决 | 说明 | +|---|---|---| +| **读 vs 写** | **MVCC** | 快照读走 undo 版本链 + ReadView,**不加锁**,读不被写阻塞 | +| **写 vs 写** | **行锁(record lock)** | 同一行的并发更新必须串行 | +| **幻读(RR 级别)** | **间隙锁 gap lock + 临键锁 next-key lock** | next-key = 行锁 + 间隙锁,锁住一个**左开右闭**区间,别人无法在区间内 `INSERT` | + +```sql +-- RR 隔离级别下,这条语句会锁住 (10, 20] 这个区间 +SELECT * FROM orders WHERE id > 10 AND id <= 20 FOR UPDATE; +-- 别的事务此时 INSERT INTO orders VALUES (15, ...) 会阻塞 → 幻读被防住 +``` + +四种隔离级别快速对照: + +| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现方式 | +|---|:---:|:---:|:---:|---| +| READ UNCOMMITTED | ⚠️ 会 | ⚠️ 会 | ⚠️ 会 | 直接读最新页,不走 MVCC | +| READ COMMITTED | ✅ 防 | ⚠️ 会 | ⚠️ 会 | **每次**快照读生成新 ReadView | +| **REPEATABLE READ**(默认) | ✅ 防 | ✅ 防 | ✅ 防* | 事务内**复用**同一个 ReadView + next-key 锁 | +| SERIALIZABLE | ✅ 防 | ✅ 防 | ✅ 防 | 读也加共享锁,全部当前读 | + +\* RR 下快照读能防住大部分幻读,但「先快照读、再当前读/更新」的混合场景仍可能出现幻读现象,需要显式加锁。 + +--- + +### 11. binlog 做 PITR:真刀真枪的操作 + +**PITR(Point-In-Time Recovery,时间点恢复)** 的标准配方是:**全量备份 + binlog 增量重放**。 + +场景:`2026-09-07 14:00` 做了全量备份,`15:23` 有同事手滑执行了 `DELETE FROM orders;`,现在要把数据恢复到 `15:22:59`。 + +```bash +# ── 第 ① 步:定位。先人工翻 binlog,找到备份结束点和误操作点 ──────────── +# ROW 格式的 binlog 是 base64 的,必须加 --base64-output=DECODE-ROWS -v 才可读 +mysqlbinlog --no-defaults --base64-output=DECODE-ROWS -v \ + --start-datetime="2026-09-07 14:00:00" \ + --stop-datetime="2026-09-07 15:30:00" \ + /var/lib/mysql/binlog.000042 /var/lib/mysql/binlog.000043 | less + +# 输出里会长这样,记下关键 position: +# # at 12345 ← 备份结束后的第一条(start-position) +# #260907 14:00:03 server id 1 end_log_pos 12456 GTID ... +# ... +# # at 67890 ← 那条 DELETE 的起点(stop-position) +# #260907 15:23:11 server id 1 end_log_pos 67999 Query DELETE FROM orders + + +# ── 第 ② 步:恢复全量备份 ───────────────────────────────────────────── +mysql -uroot -p < full_backup_20260907_1400.sql + + +# ── 第 ③ 步:用 position 精确重放到误操作【之前】──────────────────────── +mysqlbinlog --no-defaults \ + --start-position=12345 \ + --stop-position=67890 \ + /var/lib/mysql/binlog.000042 /var/lib/mysql/binlog.000043 \ + | mysql -uroot -p --disable-log-bin +# └─ mysql 客户端选项:别把重放的语句再写一遍本实例的 binlog + + +# ── 第 ④ 步:校验 ──────────────────────────────────────────────────── +mysql -uroot -p -e "SELECT COUNT(*) FROM orders;" +``` + +!!! important "position 才是精确的,datetime 只是过滤器" + `--start-datetime` / `--stop-datetime` 的实现只是**按事件时间戳做过滤**,它有几个坑: + - 同一秒内可能有几十上百个事务,你**切不到事务边界中间**; + - 事务的 binlog 是在**提交时刻**一次性写入的,一个长事务的所有 event 时间戳都相同,跨事务切分会不稳定; + - 主从时间戳、`SET TIMESTAMP` 等会干扰判断。 + + **真正精确的边界是 `--start-position` / `--stop-position`**(文件内字节偏移),它能精确停在某个事务的开始/结束。datetime 只适合用来**粗筛出要人工查看的范围**,找到 position 后必须换成 position 来做真正的恢复。 + +备份时如何拿到起始 position?用 `mysqldump --source-data=2`(8.0.26 前叫 `--master-data=2`),它会在导出的 SQL 文件里以注释形式写下 `CHANGE MASTER TO MASTER_LOG_FILE='binlog.000042', MASTER_LOG_POS=12345;`。用 `xtrabackup` 则在 `xtrabackup_binlog_info` 文件里。 + +--- + +## 💻 代码示例 + +### SQL:观察三大日志与关键参数 + +```sql +-- ① 检查持久性配置(生产环境这两项都应该是 1,即「双 1」) +SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit'; +SHOW VARIABLES LIKE 'sync_binlog'; + +-- ② 检查 binlog 配置 +SHOW VARIABLES LIKE 'log_bin'; -- ON 表示开启 +SHOW VARIABLES LIKE 'binlog_format'; -- ROW / STATEMENT / MIXED +SHOW VARIABLES LIKE 'binlog_row_image'; -- CDC 场景需要 FULL + +-- ③ 检查 redo log 容量(按版本选一个) +SHOW VARIABLES LIKE 'innodb_redo_log_capacity'; -- 8.0.30+ +SHOW VARIABLES LIKE 'innodb_log_file_size'; -- 8.0.29 及以下 + +-- ④ 检查 doublewrite +SHOW VARIABLES LIKE 'innodb_doublewrite'; + +-- ⑤ 一个事务在三大日志里都留下了什么 +BEGIN; + UPDATE account SET balance = balance - 100 WHERE id = 1; + -- undo log:记下 id=1 的 balance 旧值(逻辑) + -- redo log buffer:记下「X 号页 Y 偏移改成新值」(物理),尚未 fsync + -- Buffer Pool:该页变成脏页 + + UPDATE account SET balance = balance + 100 WHERE id = 2; +COMMIT; + -- 1) redo log 写 prepare 并 fsync + -- 2) binlog 写入本事务全部 event + XID_event 并 fsync + -- 3) redo log 写 commit + -- ⚠️ 此刻 .ibd 数据文件里可能还是旧值!脏页等着后台刷 + +-- ⑥ 实时观察 redo / checkpoint / 脏页 +SHOW ENGINE INNODB STATUS\G -- 看 LOG 段的 LSN 差值 +SELECT POOL_ID, POOL_SIZE, FREE_BUFFERS, + DATABASE_PAGES, MODIFIED_DATABASE_PAGES -- 脏页数量 +FROM information_schema.INNODB_BUFFER_POOL_STATS; + +-- ⑦ 抓长事务(undo 无法 purge 的元凶) +SELECT trx_id, trx_state, trx_started, + TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, + trx_rows_modified, trx_query +FROM information_schema.INNODB_TRX +ORDER BY trx_started +LIMIT 10; + +-- ⑧ binlog 运维 +SHOW BINARY LOGS; -- 列出所有 binlog 文件及大小 +SHOW MASTER STATUS; -- 当前文件 + position(8.4: SHOW BINARY LOG STATUS) +FLUSH BINARY LOGS; -- 强制切换新文件 +PURGE BINARY LOGS BEFORE '2026-08-01 00:00:00'; -- 清理旧文件 +``` + +### Go:`database/sql` 里正确处理事务提交 + +```go +package account + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "strings" +) + +// ErrCommitUncertain 表示 COMMIT 的结果不确定(网络抖动 / 连接被杀)。 +// 这种情况【绝不能】盲目重试业务,必须靠幂等键或对账兜底。 +var ErrCommitUncertain = errors.New("commit result uncertain") + +// Transfer 转账。演示 COMMIT 失败的语义,以及为什么不能重试 COMMIT。 +func Transfer(ctx context.Context, db *sql.DB, from, to, amount int64) error { + tx, err := db.BeginTx(ctx, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, // InnoDB 默认就是 RR + ReadOnly: false, + }) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + // 关键写法:defer Rollback 兜底。 + // Commit 成功后再调 Rollback 只会返回 sql.ErrTxDone,是无害的。 + // 这样任何一条 return 路径(panic、err、业务失败)都能保证事务被清理, + // 避免连接泄漏和长事务把 undo log 撑爆。 + defer func() { + if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) { + slog.Error("rollback failed", "err", rbErr, "from", from, "to", to) + } + }() + + // 扣款:把「余额是否充足」的判断下推到 SQL,避免先 SELECT 再 UPDATE 的竞态 + res, err := tx.ExecContext(ctx, + `UPDATE account SET balance = balance - ? + WHERE id = ? AND balance >= ?`, amount, from, amount) + if err != nil { + // 返回后 defer 触发 ROLLBACK,InnoDB 用 undo log 逆序撤销 + return fmt.Errorf("debit: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return errors.New("余额不足或账户不存在") // 业务失败,同样靠 ROLLBACK 保证原子性 + } + + if _, err = tx.ExecContext(ctx, + `UPDATE account SET balance = balance + ? WHERE id = ?`, amount, to); err != nil { + return fmt.Errorf("credit: %w", err) + } + + // ── COMMIT 的语义 ──────────────────────────────────────────────── + // 返回 nil 意味着:redo log 已按 innodb_flush_log_at_trx_commit 的策略落盘, + // 且 binlog 也已按 sync_binlog 落盘(两阶段提交完成)。 + // + // ⚠️ 但它【不代表】数据页(.ibd)已经落盘 —— 那些页很可能还是 + // Buffer Pool 里的脏页,等着后台线程慢慢刷。这不影响正确性(WAL 保证了), + // 但如果你有「COMMIT 后立刻去文件系统层面备份 .ibd」的想法,那是错的。 + if err := tx.Commit(); err != nil { + // COMMIT 失败时事务已经结束,【不能】再调 tx.Commit() 重试, + // 也不能假设「一定回滚了」—— 有可能是 redo/binlog 已落盘、 + // 只是返回 OK 的网络包丢了(结果不确定)。 + slog.Error("commit failed", "err", err, "from", from, "to", to, "amount", amount) + return fmt.Errorf("%w: from=%d to=%d amount=%d", ErrCommitUncertain, from, to, amount) + } + return nil +} + +// TransferWithIdempotency 用幂等键兜住「COMMIT 结果不确定」的场景。 +// 上游重试时带同一个 requestID,插入唯一索引冲突就说明上次其实成功了。 +func TransferWithIdempotency(ctx context.Context, db *sql.DB, + requestID string, from, to, amount int64) error { + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // 幂等表:request_id 上有 UNIQUE 索引 + _, err = tx.ExecContext(ctx, + `INSERT INTO transfer_request(request_id, from_id, to_id, amount, status) + VALUES (?, ?, ?, ?, 'SUCCESS')`, requestID, from, to, amount) + if err != nil { + if isDuplicateKey(err) { + return nil // 上一次其实提交成功了,直接当成功返回 + } + return fmt.Errorf("idempotency check: %w", err) + } + + if _, err = tx.ExecContext(ctx, + `UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?`, + amount, from, amount); err != nil { + return fmt.Errorf("debit: %w", err) + } + if _, err = tx.ExecContext(ctx, + `UPDATE account SET balance = balance + ? WHERE id = ?`, amount, to); err != nil { + return fmt.Errorf("credit: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("%w: request_id=%s", ErrCommitUncertain, requestID) + } + return nil +} + +func isDuplicateKey(err error) bool { + // 实际项目里用 go-sql-driver/mysql 的 *mysql.MySQLError,判断 Number == 1062 + return err != nil && strings.Contains(err.Error(), "Error 1062") +} +``` + +!!! tip "Go 侧的三个高频错误" + 1. **忘了 `defer tx.Rollback()`** → 出错路径上事务悬挂,连接不归还池,InnoDB 侧变成长事务,undo 暴涨。 + 2. **COMMIT 失败后重试 COMMIT** → 事务已结束,只会拿到 `sql.ErrTxDone`,真正的数据可能已经提交了。 + 3. **把 `Rollback` 返回的 `sql.ErrTxDone` 当错误打日志告警** → 噪音,应该显式忽略。 + +--- + +## ⚠️ 常见陷阱 + +!!! warning "陷阱一:以为 COMMIT 成功 = 数据页已经落盘" + **错误认知**:「事务提交了,`.ibd` 文件里就一定是新值了,我可以放心去拷贝数据文件做备份。」 + + **真相**:`COMMIT` 只保证 **redo log(和 binlog)落盘**,数据页此刻**极大概率还是 Buffer Pool 里的脏页**。这是 WAL 的设计初衷——用小的顺序写换掉大的随机写。 + + **规避**:物理备份必须用 `xtrabackup`(它会在备份期间持续抓取 redo 并在恢复阶段重放),或者 `FLUSH TABLES ... FOR EXPORT` 配合刷脏。绝不能直接 `cp` 运行中的数据文件。另外,「COMMIT 后立刻查从库能看到吗」也是另一回事——那取决于复制延迟,跟脏页无关。 + +!!! warning "陷阱二:为了性能把 innodb_flush_log_at_trx_commit 调成 0 或 2" + **错误认知**:「压测发现 TPS 上不去,把 `innodb_flush_log_at_trx_commit` 改成 0,性能翻倍,上线!」 + + **真相**:改成 `0` 或 `2` 后,**OS 崩溃 / 整机断电会丢失最近约 1 秒内所有「已向客户端返回成功」的事务**。客户端收到 `COMMIT OK`,你以为数据在,其实它还在 mysqld 进程内存(0)或 OS page cache(2)里。对账时你会发现「用户扣款成功了但订单没生成」这类脏账,极难追查。 + + **规避**:**金融、订单、账户、库存类库必须是 1**(配合 `sync_binlog = 1` 的「双 1」)。真要提性能,方向应该是:升级 SSD/NVMe、开组提交、合并小事务为批量事务、优化 SQL 减少写入量,而不是牺牲持久性。只读从库、可重跑的 ETL 临时库才适合调低。 + +!!! warning "陷阱三:混用 redo log 和 binlog 的作用" + **错误认知**:「binlog 也能重放,所以崩溃恢复靠 binlog 就行」/「redo log 记录了所有修改,主从复制用它就够了」。 + + **真相**:两者职责**完全不重叠**—— + + | 需求 | 该找谁 | + |---|---| + | MySQL 崩溃/断电后数据不丢 | **redo log**(InnoDB 自动,你碰不到) | + | 搭主从复制、读写分离 | **binlog** | + | 误删数据回到某个时间点(PITR) | **binlog** + 全量备份 | + | 审计「几点谁改了什么」 | **binlog**(`mysqlbinlog`) | + | CDC 同步到 Kafka/ES/Redis | **binlog**(Canal/Maxwell/Debezium) | + | redo 空间不够、checkpoint 卡顿 | **redo log**(调 `innodb_redo_log_capacity`) | + + **规避**:记死一句话——**崩溃恢复找 redo,复制和时间点恢复找 binlog**。redo 是环形覆盖的没有归档能力,永远做不了 PITR;binlog 是 Server 层逻辑日志不知道页状态,永远做不了 crash-safe。 + +!!! warning "陷阱四:只开 binlog、关掉/忽视 redo,以为就能保证 InnoDB 崩溃恢复" + **错误认知**:「我把 binlog 开得好好的,`sync_binlog=1`,InnoDB 的 redo log 调小甚至想办法关掉也无所谓吧?」 + + **真相**:**binlog 根本不具备崩溃恢复能力。** 原因有三: + 1. binlog 是 **Server 层逻辑日志**,它不知道 InnoDB 的哪个页刷过盘、哪个页还是脏页,无法判断该重放哪些; + 2. binlog 只在**事务提交时**才写,事务执行**中途**崩溃的那些部分修改,binlog 里一个字都没有; + 3. binlog 描述的是「逻辑变更」,重放它需要重新执行 SQL(涉及索引、约束、自增),而不是「把字节写回页」——这在恢复语义上既慢又不幂等。 + + 另外,redo log 在 InnoDB 里**关不掉**(`innodb_redo_log_capacity` 只能调大小)。真正常见的事故是**把它调得太小**:写入高峰时 write pos 迅速追上 checkpoint,MySQL 被迫**阻塞所有更新**去刷脏页,表现为「整库无规律卡顿几秒」。 + + **规避**:redo log 容量按「实例峰值写入速率 × 期望的刷脏窗口」估算,生产常见 **1~4GB**。同时**不要关 `innodb_doublewrite`**——redo 是逻辑修改的重放,治不了 16KB 页只写了 4KB 的物理撕裂,那一层必须由 doublewrite 兜底。 + +--- + +## 🏋️ 练习题 + +??? question "练习 1:客户端收到 `COMMIT` 返回成功,此时 `.ibd` 数据文件里一定是新值吗?为什么 InnoDB 敢这么设计?" + 提示:想想 WAL 的三条好处,以及脏页是什么时候刷盘的。 + + ??? success "答案" + **不一定,而且大概率不是。** + + `COMMIT` 成功只保证 **redo log 已经按 `innodb_flush_log_at_trx_commit` 的策略落盘**(`=1` 时是 fsync 到磁盘),以及 binlog 按 `sync_binlog` 落盘。数据页此刻通常还是 **Buffer Pool 里的脏页**,等着后台刷脏线程或 checkpoint 推进时才批量写回 `.ibd`。 + + InnoDB 敢这么设计,是因为 **WAL 把持久性的责任从数据页转移到了日志上**: + 1. **随机写变顺序写**——一个事务可能改多个分散的页,刷数据页是多次随机 IO;redo log 是追加写单个文件,纯顺序 IO。 + 2. **一次提交只 fsync 一个文件**——`fsync` 是最贵的系统调用,WAL 让每次 COMMIT 只需要保证一份日志落盘。 + 3. **脏页可以合并 + 批量 + 延迟**——同一页在内存改 100 次只需刷盘 1 次,还能攒到业务低峰期刷。 + + 崩溃时靠**重放 redo log** 就能把脏页的修改重建出来,所以延迟刷盘不破坏持久性,只影响「物理备份不能直接 cp 文件」这类运维约束。 + +??? question "练习 2:一个事务的 redo log 已写成 prepare,binlog 也完整写入并 fsync 了,但还没来得及把 redo 标记成 commit,此时机器断电。重启后这个事务是提交还是回滚?为什么裁决权在 binlog 而不是 redo?" + 提示:想想 binlog 写完的那一刻,可能已经有谁把它拿走了。 + + ??? success "答案" + **判定为已提交。** + + 崩溃恢复时,InnoDB 扫描 redo log 发现该事务处于 **prepare** 状态,就拿它记录的 **XID** 去 binlog 里查找对应的 **XID_event**: + - **找到且完整** → 判定已提交,把 redo 标记为 commit,保留第 ② 阶段前向重放的结果; + - **找不到或不完整** → 判定未提交,用 **undo log** 回滚。 + + 本例中 binlog 完整,所以**提交**。 + + **为什么以 binlog 为准?** 因为 **binlog 一旦写完并 fsync,就可能已经被从库的 IO 线程拉走、被 Canal 消费掉,这是不可撤销的既成事实**。如果主库这时选择回滚,从库重放 binlog 后就会多出这笔数据,主从永久不一致,用 binlog 做 PITR 恢复出的库也会和主库不一样。而 redo log 只在本机,怎么处理都不影响外部——所以让本机迁就外部,才能保证全局一致。 + + 反过来,如果 redo 已 prepare 但 binlog 缺失,主库必须回滚,因为从库那边压根没有这笔数据。 + +??? question "练习 3:既然有了 redo log 就能崩溃恢复,为什么还需要 doublewrite buffer?" + 提示:redo log 是「物理日志」,它记录的粒度是什么?它假设了什么前提? + + ??? success "答案" + 因为 **redo log 治不了「页只写了一半」的物理损坏(partial page write)**。 + + InnoDB 的页是 **16KB**,而文件系统的块通常是 **4KB**。刷一个脏页的过程中如果断电,这个页就可能只写进去了前 8KB——处于**半新半旧的撕裂状态**,checksum 校验直接失败。 + + **redo log 是物理日志**,它记录的是「第 X 号页、偏移 Y 处、改成 Z」,这**默认假设页的其余部分是正确完整的**。在一个已经损坏的页上重放 redo,只能修复那几个字节,整页依然是垃圾,甚至可能让损坏扩散。 + + **doublewrite 的工作方式**:刷脏页时先把页**顺序追加写**到 doublewrite 区域(2MB,8.0.20 起是独立的 `.dblwr` 文件)并 fsync,然后才随机写到表空间的真实位置。恢复时如果检测到某个页损坏,就**先从 doublewrite 里找到那个页的完整副本覆盖回去,再重放 redo log 应用后续修改**。 + + 顺序写 doublewrite 的开销很小(顺序 IO),换来的是页级别的原子性兜底。**所以恢复流程是两层的:doublewrite 修物理损坏 → redo log 重放逻辑修改,缺一不可。** 除非底层存储自带原子写保证(某些企业级 SSD、ZFS 这类 COW 文件系统),否则不要关闭 `innodb_doublewrite`。 + +--- + +## 🔗 相关链接 + +- [MySQL 官方文档 · Redo Log / WAL](https://dev.mysql.com/doc/refman/8.0/en/innodb-redo-log.html) — redo log 与预写日志的权威说明 +- [MySQL 官方文档 · The Binary Log](https://dev.mysql.com/doc/refman/8.0/en/binary-log.html) — binlog 格式、生命周期与复制 +- [MySQL 官方文档 · innodb_flush_log_at_trx_commit](https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit) — 0/1/2 三种取值的官方定义 +- [MySQL 官方文档 · Doublewrite Buffer](https://dev.mysql.com/doc/refman/8.0/en/innodb-doublewrite-buffer.html) — 部分页写入与双写缓冲 +- [MySQL 官方文档 · Undo Logs](https://dev.mysql.com/doc/refman/8.0/en/innodb-undo-logs.html) — undo 表空间与 purge +- [MySQL 官方文档 · mysqlbinlog](https://dev.mysql.com/doc/refman/8.0/en/mysqlbinlog.html) — PITR 用到的全部命令行参数 +- [MySQL 官方文档 · Point-in-Time Recovery](https://dev.mysql.com/doc/refman/8.0/en/point-in-time-recovery.html) — 全量备份 + binlog 增量恢复的标准流程 +- [alibaba/canal](https://github.com/alibaba/canal) — 伪装成 MySQL 从库、解析 binlog 做 CDC 的阿里开源项目 +- [多级缓存与读写策略](../../architecture/cache/cache-multilevel-read-write.md) — 本站文章:缓存层的持久化与一致性取舍,可与本文的 WAL 思路对照阅读 diff --git a/docs/database/mysql/btree-index.md b/docs/database/mysql/btree-index.md new file mode 100644 index 0000000..93f9d7e --- /dev/null +++ b/docs/database/mysql/btree-index.md @@ -0,0 +1,480 @@ +# MySQL B+ 树索引与优化 + +!!! note "💡 一句话概述" + InnoDB 用 B+ 树存索引:非叶子节点只存键不存数据,所以树又矮又胖(3 层约能撑 2000 万行,树高就是磁盘 I/O 次数);叶子节点存数据并用双向链表串起来,范围查询顺着链表扫即可。围绕这棵树派生出聚簇索引/二级索引、回表、覆盖索引、最左前缀、索引下推等一整套面试高频知识点。 + +--- + +## 🔑 核心概念 + +1. **B+ 树** — 多路平衡搜索树,非叶子节点只存键 + 页指针,叶子节点存数据且互相用双向链表连接;树高 ≈ 磁盘 I/O 次数,所以要"矮胖"。 +2. **聚簇索引(主键索引)** — 叶子节点存**整行数据**,"索引即数据",一张 InnoDB 表只有一棵。 +3. **二级索引(辅助索引)** — 叶子节点只存**索引列的值 + 主键值**,按二级索引查非索引列时需要**回表**。 +4. **覆盖索引** — 要查的列全部包含在二级索引里,不用回表,`EXPLAIN` 的 Extra 显示 `Using index`。 +5. **最左前缀原则** — 联合索引 `(a,b,c)` 按 a→b→c 顺序排序,等价于建了 `(a)`、`(a,b)`、`(a,b,c)` 三个索引,查询必须从最左列开始连续命中。 + +--- + +## 📝 详解 + +### 为什么是 B+ 树,而不是 B 树 / 红黑树 / 哈希表 / 跳表 + +先说大白话:**数据库的数据在磁盘上,而磁盘 I/O 比内存访问慢几个数量级**。内存里读一个值大约百纳秒级,机械磁盘随机 I/O 是毫秒级,即使是 SSD 也要几十到几百微秒。所以索引设计的核心目标只有一个——**用最少的磁盘 I/O 次数找到数据**。 + +一次磁盘 I/O 读多少?InnoDB 以**页(page)**为单位读,默认 `innodb_page_size = 16KB`。也就是说,不管你只取 1 行还是 100 行,一次 I/O 都是搬 16KB 进来。那么问题就转化为:**怎样让一次 16KB 的 I/O 承载尽可能多的"寻路信息"?** + +这就是各数据结构的高下之分: + +| 结构 | 问题 | +|------|------| +| **红黑树/AVL 树** | 二叉,每个节点只有 2 个分叉。1000 万数据树高约 23 层(log₂10⁷ ≈ 23.2),最坏要 20 多次磁盘 I/O,而且每个节点很小,16KB 的页严重浪费 | +| **B 树** | 多叉,矮了很多;但**每个节点(包括非叶子节点)都存完整数据**,数据一大,单页能放的键就少,分叉数少 → 树又变高;且叶子节点之间没有链表,范围查询要不断"回到上层再下来" | +| **哈希表** | 等值查询 O(1) 确实快(InnoDB 的自适应哈希索引 AHI 就是它),但**哈希值无序**:不支持范围查询(`WHERE id > 100`)、不支持排序、不支持最左前缀匹配 | +| **跳表** | 本质是"链表 + 多级稀疏索引",范围查询友好、实现简单,这正是 **Redis ZSet 选它**的原因。但跳表是**内存场景**的最优解:Redis 数据全在内存,不怕树高;而跳表每层节点只指向少量后继,一次磁盘 I/O 读进来的 16KB 无法被充分利用,磁盘场景下不如 B+ 树 | +| **B+ 树** | 多叉 + **非叶子节点只存键不存数据** + **叶子节点双向链表**,三个特性完美命中"减少磁盘 I/O + 支持范围查询"两个需求 ✅ | + +#### B+ 树长什么样 + +``` + ┌──────────────────────────────┐ + 非叶子节点 │ [20] [50] │ ← 只存键 + 页指针 + (只存键) └──┬──────────┬──────────┬─────┘ 不存数据行! + ┌─────────┘ │ └─────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │ [5] [12] │ │ [25] [40] │ │ [66] [80] │ + └─┬───┬───┬──┘ └─┬───┬───┬──┘ └─┬───┬───┬──┘ + │ │ │ │ │ │ │ │ │ + ═════════▼═══▼═══▼═══════════▼═══▼═══▼═══════════▼═══▼═══▼═════════ + 叶子节点 ┌──────┐ ⇄ ┌──────┐ ⇄ ┌──────┐ ⇄ ┌──────┐ ⇄ ┌──────┐ ⇄ ... + (存数据) │ 1~4 │ │ 5~11 │ │12~19 │ │20~24 │ │25~39 │ + │ 数据 │ │ 数据 │ │ 数据 │ │ 数据 │ │ 数据 │ + └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ + ══════════════════════════════════════════════════════════════════ + 叶子层是一条按主键有序的双向链表 +``` + +两个关键设计: + +1. **非叶子节点只存键,不存数据** → 一个 16KB 的页能塞下极多的键和指针 → 分叉极多 → 树极矮。 +2. **叶子节点用双向链表串起来,且按主键有序** → 范围查询(`WHERE id BETWEEN 20 AND 40`)先定位到 20 所在叶子页,然后**顺着链表往右扫**就行,不用回到根节点重新 descent;`ORDER BY id` 也天然免排序。 + +#### 三层 B+ 树能存多少行?(估算) + +这是面试经典题,推导过程要会说。以 InnoDB 默认页大小 16KB、主键 `BIGINT`(8 字节)、页指针 6 字节为例: + +``` +非叶子节点每个分叉占用 = 主键 8B + 页指针 6B = 14B +一个 16KB 的页能放的分叉数 ≈ 16 × 1024 / 14 ≈ 1170 个 + +三层树: + 第 1 层(根) :1 个页 → 1170 个分叉 + 第 2 层 :1170 个页 → 1170 × 1170 个分叉 + 第 3 层(叶子层) :1170 × 1170 个页 + 假设每行数据 1KB,一个叶子页放 16 行: + + 总行数 ≈ 1170 × 1170 × 16 ≈ 2190 万行 +``` + +**结论:2000 万行以内的表,按主键查一行,最多 3 次磁盘 I/O**。而且根节点页几乎永远在 Buffer Pool(内存)里,实际往往只有 1~2 次真实磁盘读。注意这些数字都是**估算**——每行 1KB、页利用率 100% 都是理想化假设,实际能存多少取决于行宽和页的填充率,但数量级是对的。这也解释了"单表 2000 万行"这个流传甚广的经验值:超过之后树可能变 4 层,I/O 多一次,性能出现台阶式下降。 + +### 聚簇索引 vs 二级索引:两棵树 + +InnoDB 一张表实际上有**多棵 B+ 树**: + +| | 聚簇索引(主键索引) | 二级索引(辅助索引) | +|---|---|---| +| **叶子节点存什么** | **整行数据** | **索引列的值 + 主键值** | +| **数量** | 一张表只有一棵 | 建几个索引就有几棵 | +| **按什么排序** | 主键 | 索引列(相同时再按主键) | +| **查到索引列之外的字段** | 直接就是整行,无需额外操作 | 需要**回表**:拿主键去聚簇索引再查一次 | + +大白话:**聚簇索引是"数据本身按主键组织成的树"**,二级索引是"一本目录,目录页上写着'去主键 X 那里找'"。 + +为什么二级索引叶子不直接存行数据、只存主键?两个原因:① 省空间——每棵二级索引都复制一份整行的话,磁盘和 Buffer Pool 都撑不住;② 行数据更新(或行迁移)时,只需改聚簇索引一处,不用同步改所有二级索引。 + +没有主键怎么办?InnoDB 会选一个非空唯一索引当聚簇索引;都没有,就偷偷生成一个 6 字节的隐藏列 `ROW_ID` 作为聚簇键(用户不可见)。 + +### 回表:一次查询走两棵树 + +```sql +CREATE TABLE user ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(64) NOT NULL, + phone VARCHAR(20) NOT NULL, + age INT NOT NULL, + PRIMARY KEY (id), + KEY idx_name (name) -- 二级索引 +) ENGINE = InnoDB; + +-- 查询:按 name 查整行 +SELECT * FROM user WHERE name = '张三'; +``` + +执行过程分两步,走两棵树: + +``` + 二级索引树 idx_name 聚簇索引树(主键) +┌─────────────────────┐ ┌─────────────────────┐ +│ [李] [王] │ │ [100] [500] │ +└───┬──────┬──────┬───┘ └───┬──────┬──────┬───┘ + ▼ ▼ ▼ ▼ ▼ ▼ +┌───────┐┌───────┐┌───────┐ ┌───────┐┌───────┐┌───────┐ +│张 │赵 ││李 │… ││王 │… │ │id=1 ││id=100 ││id=500 │ +│(100, ││ ││ │ │整行数据││整行数据││整行数据│ +│ 123) ││ ││ │ └───────┘└───────┘└───────┘ +└───────┘└───────┘└───────┘ ▲ + │ │ + │ ① 在 idx_name 树查到 │ ② 回表:拿着主键 id=100 + │ ('张三', id=100) │ 去聚簇索引树查整行 + └──────────────────────────────────────┘ +``` + +这个"②"就是**回表**——多走一棵树,多几次 I/O。回表次数取决于命中行数:`WHERE name = '张三'` 命中 1 行就回表 1 次;`WHERE name LIKE '张%'` 命中 1000 行就要回表 1000 次,这时优化器甚至可能觉得"还不如直接全表扫"。 + +### 覆盖索引:不回表的捷径 + +如果查询的列**全部**包含在某个二级索引里,就不需要回表了——这叫**覆盖索引**(索引"覆盖"了查询所需的所有列): + +```sql +-- 建联合索引 (name, age) +ALTER TABLE user ADD KEY idx_name_age (name, age); + +-- 只查 name 和 age,两列都在 idx_name_age 里 +EXPLAIN SELECT name, age FROM user WHERE name = '张三'; +``` + +``` ++----+------+-------------+-------+------+---------------+ +| id | type | key | ref | rows | Extra | ++----+------+-------------+-------+------+---------------+ +| 1 | ref | idx_name_age| const | 1 | Using index | ← 覆盖索引! ++----+------+-------------+-------+------+---------------+ +``` + +`Extra = Using index` 就是覆盖索引的标志:**只扫二级索引这一棵树就拿到了全部结果,零回表**。联合索引 `(name, age)` 的叶子节点存的是 `(name, age, id)`(索引列 + 主键),所以 `SELECT id, name, age` 也能被覆盖。 + +这也是"**禁止 `SELECT *`**"的最硬核理由:一旦 `SELECT *`,任何二级索引都无法覆盖整行,必然回表(详见常见陷阱四)。 + +### 最左前缀原则 + +联合索引 `(a, b, c)` 的排序规则是:**先按 a 排,a 相同再按 b 排,b 也相同再按 c 排**。就像字典按"部首→笔画"多级排序——只知道笔画数、不知道部首,字典帮不了你。 + +所以 `(a,b,c)` 相当于免费建了三个索引: + +``` +(a, b, c) ≡ (a) + (a, b) + (a, b, c) +``` + +但它**不包含** `(b)`、`(c)`、`(b,c)` 的能力。哪些 WHERE 能用上索引,逐项判断: + +| WHERE 条件 | 能否用 idx(a,b,c) | 用到几列 | 说明 | +|------------|:---:|:---:|------| +| `a = 1` | ✅ | a | 走 (a) | +| `a = 1 AND b = 2` | ✅ | a,b | 走 (a,b) | +| `a = 1 AND b = 2 AND c = 3` | ✅ | a,b,c | 完整命中 | +| `b = 2` | ❌ | — | 缺最左列 a,b 在全树范围是无序的,用不上 | +| `c = 3` | ❌ | — | 同上 | +| `b = 2 AND c = 3` | ❌ | — | 缺 a,整段用不上 | +| `a = 1 AND c = 3` | ⚠️ | 只有 a | b 断了,c 无法在索引中定位;c=3 只能靠 ICP 在引擎层过滤(见下节) | +| `a > 1 AND b = 2` | ⚠️ | 只有 a | **范围之后全失效**:a 是范围时,b 在 a 的每段内有序、跨段整体无序 | +| `a = 1 AND b > 2 AND c = 3` | ⚠️ | a,b | 同理,b 用了范围,c 用不上 | +| `a = 1 ORDER BY b` | ✅ | a + b 排序 | b 在 a=1 段内有序,免 filesort | +| `a = 1 ORDER BY c` | ❌ | — | c 在 (a=1) 段内无序,需要 filesort | + +**"范围之后全失效"再解释一句**:索引里数据按 `(a,b,c)` 排列,当 `a > 1` 时命中的是多个 a 值,每个 a 值内部 b 各自有序,但拼起来整体无序,所以 b 的条件无法用于索引定位。注意失效的是"**继续用索引定位**",条件本身仍可通过 ICP 在索引层过滤(不是白写)。 + +### 联合索引怎么设计 + +1. **等值查询的列放前面,范围查询的列放后面**。`(status, create_time)` 优于 `(create_time, status)`——`WHERE status = 1 AND create_time > '2024-01-01'` 时前者两列都能用于定位,后者 `create_time` 一用范围,`status` 就废了。 +2. **区分度高的列放前面**。区分度 = `COUNT(DISTINCT col) / COUNT(*)`,越接近 1 越好。`name`(几乎人人不同)放前面,`gender`(就俩值)放前面等于没放——扫一半数据。 +3. **尽量覆盖高频查询**。把高频 SELECT 的列纳入联合索引,做成覆盖索引,直接消灭回表。比如订单列表页高频查 `(user_id, status, create_time)` 且只展示金额,可以建 `(user_id, status, create_time, amount)`。 +4. 顺序可以微调验证:用 `EXPLAIN` 对比 `key_len`(实际用到的索引字节数),越大说明用上的列越多。 + +### 索引下推 ICP(Index Condition Pushdown,MySQL 5.6+) + +看这个查询,`idx(a,b,c)`,条件是 `a = 1 AND c = 3`:按最左前缀,c 断了,索引只能定位到 `a = 1` 的所有记录。**没有 ICP(5.6 之前)**的流程是:存储引擎把所有 `a=1` 的记录**逐条回表**取整行,交给 Server 层,Server 层再用 `c = 3` 过滤——假设 a=1 有 1000 行、其中 c=3 只有 10 行,就白白回表了 990 次。 + +**ICP 的思路**:c 的值其实**就在二级索引里**(联合索引叶子存了 a、b、c、主键),何必回表后再过滤?把 `c = 3` 这个条件**下推到存储引擎层**,在遍历索引时先筛掉不满足的行,只对通过的行回表: + +``` +无 ICP: 索引定位 a=1 ──→ 回表 1000 次 ──→ Server 层过滤 c=3 ──→ 剩 10 行 +有 ICP: 索引定位 a=1 ──→ 引擎层先筛 c=3 ──→ 只回表 10 次 ✅ +``` + +```sql +-- 确认 ICP 开着(默认开) +SHOW VARIABLES LIKE 'optimizer_switch'; -- index_condition_pushdown=on + +EXPLAIN SELECT * FROM user WHERE name LIKE '张%' AND age = 20; +-- 假设有 idx_name_age(name, age) +``` + +``` +Extra: Using index condition ← ICP 生效的标志 +``` + +注意 ICP 的适用范围:只用于二级索引上的 range/ref/eq_ref/ref_or_null 访问,且下推的条件必须是**索引里包含的列**。Extra 里 `Using index condition`(ICP)和 `Using index`(覆盖索引)是两码事,别混。 + +### 索引失效的常见场景 + +| # | 场景 | 反例 SQL | 说明 / 改法 | +|---|------|----------|------------| +| 1 | **对索引列做函数或运算** | `WHERE YEAR(create_time) = 2024`
`WHERE id + 1 = 10` | 索引存的是原始值,不是函数结果。改成 `WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'`、`WHERE id = 9`。原则:**索引列必须"干净"地出现在比较符一侧** | +| 2 | **隐式类型转换** | 列 `phone VARCHAR(20)`,却写 `WHERE phone = 13800000000` | 字符串和数字比较时,MySQL 会把**字符串转成数字**,等价于 `WHERE CAST(phone AS DOUBLE) = 138...`——对索引列套了函数,失效。必须传 `'13800000000'`。反过来,数字列传字符串(`WHERE age = '20'`)不受影响 | +| 3 | **前导模糊 LIKE** | `WHERE name LIKE '%张'` 或 `'%张%'` | B+ 树按前缀有序,`%` 开头无法定位起点。`LIKE '张%'` ✅ 可以走索引 | +| 4 | **OR 连接了非索引列** | `WHERE name = '张三' OR age = 20`(age 无索引) | 一半条件要全表扫,整体只能全表扫。若 name、age **都有索引**,优化器可能用 index_merge 分别查再合并 | +| 5 | **NOT IN / != / NOT LIKE(部分场景)** | `WHERE status != 1` | 不是绝对失效:若排除后剩余行占比很小,仍可能走索引;占比大时优化器主动放弃(见 #7) | +| 6 | **联合索引不满足最左前缀** | 索引 `(a,b,c)`,`WHERE b = 2` | 见上节 | +| 7 | **优化器认为全表更快** | 小表;或 `WHERE gender = '男'` 命中 50% 的行 | 走索引 = 扫二级索引树 + 大量回表(随机 I/O),全表扫 = 顺序读聚簇索引。命中率过高或表很小时,全表扫反而快,优化器基于成本模型主动选 ALL。**这不是 bug,是正确决策** | + +还有一个字符集层面的坑:两表 JOIN 时,若关联列的**字符集或排序规则(collation)不一致**(如 utf8 vs utf8mb4),也会触发隐式转换导致其中一边索引失效。 + +### EXPLAIN 怎么看 + +`EXPLAIN` 是索引优化的第一工具,重点盯四列:**type、key、rows、Extra**。 + +#### type:访问类型,性能从好到坏 + +``` +system > const > eq_ref > ref > range > index > ALL +``` + +| type | 含义 | 典型场景 | +|------|------|---------| +| `system` | 表只有一行(系统表) | 基本见不到 | +| `const` | 主键/唯一索引等值查,最多一行 | `WHERE id = 1` | +| `eq_ref` | JOIN 时被驱动表用主键/唯一索引等值匹配,每次只一行 | `JOIN ... ON a.id = b.uid`(b.uid 唯一) | +| `ref` | 普通(非唯一)索引等值查,可能多行 | `WHERE name = '张三'`(name 有普通索引) | +| `range` | 索引范围扫描 | `WHERE id > 100`、`BETWEEN`、`IN`、`LIKE 'abc%'` | +| `index` | **全索引扫描**:把整棵二级索引树扫一遍(比 ALL 好在索引树更小,且可能覆盖) | 覆盖索引但不带 WHERE 过滤条件 | +| `ALL` | **全表扫描**:扫聚簇索引整棵树 | 索引失效或没有可用索引 | + +经验底线:**线上查询至少要做到 `range`,核心链路争取 `ref` 及以上;见到 `index` 和 `ALL` 就要警惕**。 + +#### 其他关键列 + +| 列 | 看什么 | +|----|--------| +| `key` | 实际用了哪个索引;为 NULL = 没走索引 | +| `key_len` | 实际使用的索引字节数,判断联合索引用到了第几列(varchar 列 = 定义长度×字符集字节数 + 2 长度字节 + 1 NULL 标记字节) | +| `rows` | **估算**要扫描的行数,越小越好;注意是统计信息估算值,不精确 | +| `filtered` | 估算扫描行中满足 WHERE 条件的百分比 | + +#### Extra:附加信息,面试最爱问 + +| Extra | 含义 | 好坏 | +|-------|------|:---:| +| `Using index` | **覆盖索引**,不回表 | ✅ 最好 | +| `Using index condition` | **ICP 索引下推**,引擎层先过滤再回表 | ✅ 好 | +| `Using where` | Server 层还要用 WHERE 再过滤(引擎返回的数据不完全满足条件) | ⚠️ 中性,常见 | +| `Using filesort` | 无法利用索引顺序,需要额外排序(内存或磁盘临时文件) | ❌ 尽量消灭:让 ORDER BY 列走索引 | +| `Using temporary` | 用了临时表(常见于 GROUP BY / DISTINCT / UNION) | ❌ 尽量消灭:让 GROUP BY 列走索引 | + +`Using where` 和 `Using index` 经常同时出现,不冲突:前者说"Server 层还过滤了一次",后者说"数据全部来自索引没回表"。 + +### 自增主键 vs UUID + +InnoDB 数据按主键顺序组织在聚簇索引里,所以**主键的取值顺序直接决定插入性能**: + +``` +自增主键(顺序插入):新行永远追加在最后一页的末尾 +┌────────┐ ┌────────┐ ┌────────┐ +│ 1 ~ 100│→│101~200 │→│201~299 │→ [300 追加在这里,页写满就开新页] +└────────┘ └────────┘ └────────┘ +✅ 顺序 I/O,页分裂极少,插入快 + +UUID(随机插入):新行随机落在整棵树的任意页 +┌────────┐ ┌────────┐ ┌────────┐ +│ 1a3f.. │⇄│ 8c02.. │⇄│ f07b.. │ 插入 "9e11.." → 可能插进第 1 页中间 +└────────┘ └────────┘ └────────┘ +❌ 页频繁写满 → 页分裂;随机 I/O;缓存命中率低 +``` + +| | 自增 BIGINT | UUID | +|---|---|---| +| 插入模式 | 顺序追加,几乎不分裂 | 随机插入,频繁**页分裂** | +| I/O 模式 | 顺序写,缓存友好 | 随机读写 | +| 空间 | 8 字节 | 字符串形式 36 字节(BINARY(16) 也要 16 字节) | +| 连带伤害 | — | **所有二级索引的叶子都存主键值**,主键越肥,每棵二级索引都跟着肥 | +| 适用 | 单机/单库写入 ✅ | 需要全局唯一且可预生成 ID 的场景,慎用做主键;折中方案:有序 UUID(如 UUIDv7)或雪花 ID(趋势递增的 64 位整数,兼具全局唯一与顺序性) | + +补充两个相关知识点: + +- **页分裂**:某页插入新行时空间不够,InnoDB 会申请新页,把原页部分记录挪过去,并修改上层节点指针——一次分裂涉及多个页的读写,代价高。除了随机主键,`DELETE` 留下大量碎片、`INSERT` 时无序键值都会诱发分裂。 +- **页合并**:与分裂相反,当相邻页因删除变得很空(低于阈值,约一半)时,InnoDB 会把两页记录合并成一页,回收空间。`OPTIMIZE TABLE` 可以主动重建表消除碎片。 +- 顺带一提:聚簇索引叶子页之间在物理上是**双向链表**(页内有 Page Directory 加速页内二分查找),这也是范围扫描高效的物理基础。 + +--- + +## 💻 代码示例 + +建表与索引: + +```sql +-- 建表:自增 BIGINT 主键 +CREATE TABLE `order` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `user_id` BIGINT UNSIGNED NOT NULL, + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', + `amount` DECIMAL(10,2) NOT NULL, + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + -- 联合索引:等值列 user_id/status 在前,范围列 create_time 在后 + KEY `idx_user_status_time` (`user_id`, `status`, `create_time`), + -- 覆盖索引:把高频查询要展示的 amount 也放进去,列表页零回表 + KEY `idx_user_cover` (`user_id`, `status`, `create_time`, `amount`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +-- 后续补建索引(MySQL 5.6+ 大多支持 Online DDL,不长时间锁表) +CREATE INDEX idx_create_time ON `order` (`create_time`); + +-- 验证执行计划 +EXPLAIN SELECT id, amount +FROM `order` +WHERE user_id = 1001 AND status = 1 AND create_time >= '2024-01-01'; +-- 期望:type=range, key=idx_user_cover, Extra=Using where; Using index(覆盖,不回表) +``` + +Go 侧配合(`database/sql`): + +```go +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "strings" + + _ "github.com/go-sql-driver/mysql" +) + +type Order struct { + ID int64 + Amount float64 +} + +// QueryUserOrders 演示覆盖索引查询:只 SELECT 需要的列(id, amount 都在 +// idx_user_cover 里),Extra 显示 Using index,零回表。 +// 反面教材:SELECT * —— 任何二级索引都覆盖不了整行,必然回表。 +func QueryUserOrders(ctx context.Context, db *sql.DB, userID int64) ([]Order, error) { + const q = `SELECT id, amount FROM ` + "`order`" + ` + WHERE user_id = ? AND status = 1 AND create_time >= '2024-01-01'` + rows, err := db.QueryContext(ctx, q, userID) // 永远用占位符,防注入 + if err != nil { + return nil, err + } + defer rows.Close() + + var orders []Order + for rows.Next() { + var o Order + if err := rows.Scan(&o.ID, &o.Amount); err != nil { + return nil, err + } + orders = append(orders, o) + } + return orders, rows.Err() +} + +// BatchInsert 演示自增主键的顺序插入: +// 不指定 id,由 AUTO_INCREMENT 分配 → 新行永远追加在聚簇索引最右页, +// 顺序 I/O、几乎不触发页分裂。同时用多值 INSERT 合并事务,减少刷盘次数。 +// 若换成客户端生成的随机 UUID 做主键,这里会变成随机插入,页分裂频繁。 +func BatchInsert(ctx context.Context, db *sql.DB, items [][2]interface{}) error { + if len(items) == 0 { + return nil + } + const cols = "(user_id, status, amount)" + placeholders := make([]string, 0, len(items)) + args := make([]interface{}, 0, len(items)*3) + for _, it := range items { + placeholders = append(placeholders, "(?, 0, ?)") + args = append(args, it[0], it[1]) + } + q := "INSERT INTO `order` " + cols + " VALUES " + strings.Join(placeholders, ",") + + _, err := db.ExecContext(ctx, q, args...) + if err != nil { + return fmt.Errorf("batch insert: %w", err) + } + return nil +} + +func main() { + db, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/shop?parseTime=true") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + ctx := context.Background() + if err := BatchInsert(ctx, db, [][2]interface{}{{1001, 99.9}, {1002, 45.5}}); err != nil { + log.Fatal(err) + } + orders, err := QueryUserOrders(ctx, db, 1001) + if err != nil { + log.Fatal(err) + } + log.Printf("got %d orders", len(orders)) +} +``` + +--- + +## ⚠️ 常见陷阱 + +!!! warning "陷阱一:给低区分度列建索引,基本没用" + `gender`、`status`(只有两三个值)、`is_deleted` 这类列,等值一查就命中全表 30%~50% 的行。走索引 = 扫二级索引树 + 海量回表(随机 I/O),比全表顺序扫还慢,优化器多半直接放弃这个索引(type=ALL),索引白建,还占空间、拖慢写入。**规避**:建索引前先看区分度 `COUNT(DISTINCT col)/COUNT(*)`,太低就别单独建;确有过滤需求时把它作为联合索引的**非首列**(如 `(user_id, status)`),或依赖 ICP 过滤。 + +!!! warning "陷阱二:索引不是越多越好" + 每个索引都是一棵独立的 B+ 树:① 占磁盘和 Buffer Pool;② 每次 INSERT/UPDATE/DELETE 都要维护所有相关索引树,写放大明显(极端情况下更新一个字段要动好几棵树);③ 索引太多会让优化器选路成本变高、甚至选错索引。**规避**:定期用 `sys.schema_unused_indexes` 找出从未被使用的索引删掉;能用联合索引/覆盖索引合并的就合并;单表索引数量控制在个位数。 + +!!! warning "陷阱三:函数包裹索引列、前导模糊,索引悄悄失效还不自知" + `WHERE YEAR(create_time) = 2024`、`WHERE LEFT(name, 2) = '张'`、`WHERE phone = 13800000000`(varchar 列传数字触发隐式转换)、`LIKE '%张'`——这些语句**都能正常返回结果**,只是慢,功能测试根本发现不了,上了量才炸。**规避**:核心 SQL 上线前必须过一遍 `EXPLAIN`,确认 `type` 不是 ALL、`key` 不为 NULL;范围条件改写成 `>= / <` 的裸列比较;应用层参数类型与列类型严格对齐(Go 里 phone 就用 string 传)。 + +!!! warning "陷阱四:SELECT * 让覆盖索引失效,必然回表" + 辛苦设计的覆盖索引 `(user_id, status, create_time, amount)`,一句 `SELECT *` 就全废了——`*` 包含所有列,二级索引里没有的列只能回表取,Extra 从 `Using index` 变成回表 + `Using where`,QPS 高时 I/O 直接翻倍。**规避**:查询永远显式列出需要的字段;代码 review 时把 `SELECT *` 当红线;ORM(如 GORM)默认生成 `SELECT *`,高频查询要用 `Select("id", "amount")` 显式指定。 + +--- + +## 🏋️ 练习题 + +??? question "练习 1:为什么 InnoDB 选 B+ 树而不是 B 树?红黑树和哈希表又输在哪?" + 提示:从"磁盘 I/O 次数 = 树高"和"范围查询"两个角度回答。 + + ??? success "答案" + ① **对比 B 树**:B+ 树非叶子节点只存键和页指针、不存数据,一个 16KB 页能放约 1170 个分叉(主键 8B + 指针 6B),树极矮——3 层约撑 2000 万行(估算),查一行最多 3 次 I/O;B 树非叶子节点也存数据,单页分叉少,同样数据量树更高。另外 B+ 树叶子节点用双向链表按序串起来,范围查询定位到起点后顺链表扫即可;B 树叶子无链表,范围查询要反复回到上层。 + ② **红黑树**:二叉树,1000 万数据高约 23 层,意味着最坏 20 多次磁盘 I/O,且小节点无法填满 16KB 的页,完全不适合磁盘。 + ③ **哈希表**:等值查 O(1),但哈希值无序——不支持范围查询、排序、最左前缀。InnoDB 内部有自适应哈希索引(AHI)作为热点页的加速缓存,但不能替代 B+ 树。 + +??? question "练习 2:表上有联合索引 idx(a,b,c),下面每条 SQL 能用到索引的哪些列?" + ```sql + -- ① WHERE a = 1 AND c = 3 + -- ② WHERE a > 1 AND b = 2 + -- ③ WHERE b = 2 AND c = 3 + -- ④ WHERE a = 1 AND b = 2 ORDER BY c + ``` + + ??? success "答案" + ① **只用到 a 定位**:b 断了,c 无法走索引树定位;但 MySQL 5.6+ 若开启 ICP,`c = 3` 会下推到存储引擎层,在遍历索引时先过滤再回表(Extra: Using index condition),减少回表次数。 + ② **只用到 a**:a 是范围条件,"范围之后全失效",b=2 不能用于索引定位(同样可能靠 ICP 过滤)。 + ③ **完全用不上**:缺最左列 a,b、c 在全索引范围内无序,只能全表扫或全索引扫。 + ④ **a、b 用于定位,c 用于免排序**:在 (a=1, b=2) 段内 c 天然有序,ORDER BY c 直接顺索引读,Extra 无 filesort——这是最理想的一条。 + +??? question "练习 3:为什么不建议用 UUID 做 InnoDB 主键?如果业务上必须全局唯一 ID 怎么办?" + ??? success "答案" + 三个原因:① **页分裂**:聚簇索引按主键顺序组织,UUID 随机,新行随机插入导致页频繁写满、分裂(挪数据 + 改上层指针 + 随机 I/O),而自增主键永远追加在最右页,几乎不分裂;② **空间放大**:UUID 字符串 36 字节 vs BIGINT 8 字节,且**每棵二级索引的叶子都要存一份主键值**,索引越多放大越狠,还挤占 Buffer Pool;③ 随机插入让页缓存/Buffer Pool 命中率下降。 + 替代方案:**雪花算法(Snowflake)ID**——64 位、趋势递增、全局唯一,兼具自增的顺序性和分布式唯一性,是主流选择;或用有序的 UUIDv7(前缀是时间戳);实在要用 UUID,可存 `BINARY(16)` 省一半空间,但页分裂问题仍在。 + +--- + +## 🔗 相关链接 + +- [MySQL 官方文档:InnoDB Index Types](https://dev.mysql.com/doc/refman/8.0/en/innodb-index-types.html) — 聚簇索引与二级索引的官方定义 +- [MySQL 官方文档:Optimizing Queries with EXPLAIN](https://dev.mysql.com/doc/refman/8.0/en/using-explain.html) — EXPLAIN 各列与 type 等级的权威说明 +- [MySQL 官方文档:Index Condition Pushdown](https://dev.mysql.com/doc/refman/8.0/en/index-condition-pushdown-optimization.html) — ICP 的适用条件与开关 +- [Redis 为什么用跳表实现 ZSet](https://redis.io/docs/latest/develop/data-types/sorted-sets/) — 对比理解内存场景与磁盘场景的结构选型 +- [High Performance MySQL(高性能 MySQL)](https://www.oreilly.com/library/view/high-performance-mysql/9781492080503/) — 索引与查询优化的系统性读物,Schema 与索引设计章节值得精读 diff --git a/docs/database/mysql/mvcc.md b/docs/database/mysql/mvcc.md new file mode 100644 index 0000000..2f6a536 --- /dev/null +++ b/docs/database/mysql/mvcc.md @@ -0,0 +1,893 @@ +# MySQL MVCC 多版本并发控制 + +!!! note "💡 一句话概述" + MVCC 让普通 SELECT 不加锁、也不被写事务阻塞:靠「行隐藏列 + undo log 版本链 + Read View」三件套,给每个事务发一张"照片",读的时候沿版本链找到照片里该看到的那一版。READ COMMITTED 每读一次重新拍照,REPEATABLE READ 整个事务只拍一张——**隔离级别的差别就在 Read View 的创建时机**。 + +--- + +## 🔑 核心概念 + +1. **读写不互斥** — 读不加锁、不被写阻塞,写也不被读阻塞;用"多留几个历史版本"这件事换来了并发度。 +2. **隐藏列 `DB_TRX_ID` / `DB_ROLL_PTR`** — 每行都偷偷带着"最近谁改过我"和"上一个版本在哪"两个字段,是版本链的骨架。 +3. **undo log 版本链** — 每次改行都把旧值写进 undo log,`roll_ptr` 一路串下去,同一逻辑行就有了 v3 → v2 → v1 这样一条链。 +4. **Read View(一致性视图)** — 事务的"照片",只有四个字段:`m_ids`、`min_trx_id`、`max_trx_id`、`creator_trx_id`,专门用来判断某个版本对我可见不可见。 +5. **快照读 vs 当前读** — 普通 SELECT 走 MVCC 不加锁(快照读);`FOR UPDATE` / `UPDATE` / `DELETE` / `INSERT` 读最新已提交版本并加锁(当前读)。MVCC 只管前者。 + +--- + +## 📝 详解 + +### 1. 先说人话:MVCC 到底解决了什么问题 + +想象一个库存表,读的人特别多(查商品详情),写的人也不少(下单扣库存)。 + +**如果没有 MVCC,读也得加锁**,世界会变成这样: + +| 读的方式 | 谁等谁 | 后果 | +|---|---|---| +| 读加共享锁(S 锁) | 写事务要等所有读事务放锁才能改 | 一个慢查询能卡住整条写入链路 | +| 读加排他锁 | 读和写完全互斥 | 并发直接掉到接近串行 | +| 读不加锁也不做多版本 | 读到别人还没提交的中间值 | 脏读,业务逻辑全错 | + +MVCC 的答案很朴素:**别抢同一份数据了,我给你留几份旧的。** + +> 写事务改行时,把改之前的样子存进 undo log;读事务按自己那张"照片"去链上找对应版本。写只动链头,读只往链下翻,两条路不打架。 + +一句话记忆:**MVCC = 用「历史版本」把「读写冲突」变成「各读各的」。** + +!!! tip "它不是万能的" + MVCC 只解决**读和写**的冲突。**写和写**照样要抢行锁(两个事务同时 UPDATE 同一行,后者必须等前者提交)。这是最常被搞错的一点,见后面的「常见陷阱三」。 + +--- + +### 2. 三个基础件 + +#### 2.1 隐藏列:`DB_TRX_ID` 与 `DB_ROLL_PTR` + +InnoDB 的每一行记录,除了你 `CREATE TABLE` 写的列,还额外藏着几个系统列: + +| 隐藏列 | 含义 | 什么时候被写 | +|---|---|---| +| `DB_TRX_ID` | **最近修改这一行的事务 ID** | 每次 INSERT / UPDATE / DELETE 都更新成本事务的 trx_id | +| `DB_ROLL_PTR` | **回滚指针**,指向 undo log 里这一行的上一个版本 | 每次修改前,先把旧版本地址写进来 | +| `DB_ROW_ID` | 没有主键时自动生成的隐藏自增 ID | 与 MVCC 无关,顺带提一下 | + +!!! warning "这两个列你 SELECT 不出来" + `SELECT * FROM t` 看不到 `DB_TRX_ID`。InnoDB 没有对外暴露隐藏列的 SQL 接口,网上有些"查看 DB_TRX_ID 的 SQL"是编的。真实可观察的只有 `information_schema.innodb_trx` 里的事务 ID 和 `SHOW ENGINE INNODB STATUS` 里的 undo 堆积指标。 + +#### 2.2 undo log 版本链 + +**undo log 本来是给回滚用的"后悔药"**(记着"怎么把这行改回去"),MVCC 顺手把它当成了"历史版本仓库"——一份日志,两个用途: + +| 用途 | 谁在用 | 生命周期 | +|---|---|---| +| 事务回滚 / 崩溃恢复时回滚未提交事务 | 写事务自己 | **insert undo**(记 INSERT 的)事务提交后即可丢弃——那行本来就不存在,没人需要看"插入前的样子" | +| **MVCC 提供历史版本** | 别的事务的快照读 | **update undo**(记 UPDATE / DELETE 的)提交后**不能**丢,必须等到没有任何 Read View 还需要它,由 purge 线程清理 | + +每改一次,链就长一节。假设 `product` 表 `id=1` 这行被事务 90 → 100 → 101 → 102 依次改过: + +``` + 聚簇索引里的当前行(最新一版) ← 当前读、写操作动的都是这一份 + +------------------------------------------------+ + | id = 1 price = 350 | + | DB_TRX_ID = 102 | + | DB_ROLL_PTR = ptr | + +------------------------------------------------+ + | + | 顺着 roll_ptr 往 undo log 里找上一个版本 + v + +------------------------------------------------+ + | undo v3: price = 300 DB_TRX_ID = 101 | + | DB_ROLL_PTR = ptr | + +------------------------------------------------+ + | + v + +------------------------------------------------+ + | undo v2: price = 200 DB_TRX_ID = 100 | + | DB_ROLL_PTR = ptr | + +------------------------------------------------+ + | + v + +------------------------------------------------+ + | undo v1: price = 100 DB_TRX_ID = 90 | + | DB_ROLL_PTR = NULL <-- 版本链尽头 | + +------------------------------------------------+ +``` + +三个要点: + +- 链头(聚簇索引里的那份)永远是**最新**的,越往下越**旧**。 +- 每个节点都带自己的 `DB_TRX_ID`,这就是待会儿做可见性判断的依据。 +- DELETE 也是走这条链:删除会把行标记为 delete-mark 并留一份 undo 版本,所以别的事务的快照读**还能看见"被删之前"的那一行**。 + +!!! tip "进阶细节:二级索引上没有版本链" + `DB_TRX_ID` / `DB_ROLL_PTR` 只存在于聚簇索引记录上。二级索引记录里没有它们,只有页头存了一个 `PAGE_MAX_TRX_ID`(修改过这个页的最大事务 ID)。快照读走二级索引时:如果 `PAGE_MAX_TRX_ID` 比 Read View 更老,说明这条索引记录没人动过,可以直接用;否则必须回聚簇索引取版本、再做可见性判断。**这也是"覆盖索引更快"的一个隐藏原因**。 + +#### 2.3 Read View:事务的那张"照片" + +光有版本链还不够——链上那么多版本,我该读哪一个?答案是**拿事务自己的 Read View 去比对**。 + +Read View 说白了就是一句话:**"我拍这张照片的瞬间,世界上有哪些事务还没提交。"** 它只有四个字段: + +| 字段 | 含义 | 大白话 | +|---|---|---| +| `m_ids` | 创建 Read View 时,系统里**活跃(未提交)**的事务 ID 列表 | "拍照那一刻还活着没交卷的人" | +| `min_trx_id` | `m_ids` 中的最小值 | "这些人里最早开工的那个" | +| `max_trx_id` | 创建 Read View 时,系统**将要分配给下一个事务**的 ID,即当前最大事务 ID + 1 | "下一个来的人的号,比他大的都是未来人" | +| `creator_trx_id` | 创建这个 Read View 的事务自己的 ID | "我自己" | + +三件套的关系: + +```mermaid +graph LR + subgraph row["行本身"] + H["隐藏列
DB_TRX_ID
DB_ROLL_PTR"] + end + subgraph history["历史(undo log)"] + U["版本链
v3 → v2 → v1"] + end + subgraph view["事务视角"] + RV["Read View
m_ids / min_trx_id
max_trx_id / creator_trx_id"] + end + H -->|"roll_ptr 串起来"| U + RV -->|"拿版本的 trx_id 逐个比对"| H + RV -->|"不可见就往下翻一版"| U +``` + +!!! note "为什么 `m_ids` 里要算上自己?" + 自己也是个"活跃未提交事务",所以它也在 `m_ids` 里。但自己改的数据当然得让自己看见——这就是可见性算法**第一条规则先判 `creator_trx_id`** 的原因:把"自己改的"提前短路掉,免得被第四条规则误判成不可见。 + +--- + +### 3. 可见性判断算法(重点,顺序不能乱) + +拿到版本链上某个版本的 `trx_id`,**严格按下面顺序**判断: + +| 顺序 | 条件 | 结论 | 为什么 | +|:---:|---|:---:|---| +| ① | `trx_id == creator_trx_id` | ✅ **可见** | 自己改的,当然要看见 | +| ② | `trx_id < min_trx_id` | ✅ **可见** | 比"最早的活跃事务"还老 → 拍照前就已经提交了 | +| ③ | `trx_id >= max_trx_id` | ❌ **不可见** | 拍照之后才开启的事务,属于"未来人" | +| ④ | `min_trx_id <= trx_id < max_trx_id` 且 **在** `m_ids` 中 | ❌ **不可见** | 拍照那一刻它还活跃着,还没提交 | +| ④ | `min_trx_id <= trx_id < max_trx_id` 且 **不在** `m_ids` 中 | ✅ **可见** | 拍照前开过工、且不在活跃名单里 → 说明已经提交了 | + +**如果不可见**:顺着 `DB_ROLL_PTR` 找上一个版本,用**同一个** Read View 再判一遍;一直往下,直到找到可见版本,或者走到链尽头(`roll_ptr = NULL`,返回"这行不存在")。 + +```mermaid +flowchart TD + S(["取版本链上的一个版本
读出它的 trx_id"]) --> Q1{"trx_id 等于
creator_trx_id ?"} + Q1 -->|"是"| V1["✅ 可见
我自己改的"] + Q1 -->|"否"| Q2{"trx_id 小于
min_trx_id ?"} + Q2 -->|"是"| V2["✅ 可见
建视图前就已提交"] + Q2 -->|"否"| Q3{"trx_id 大于等于
max_trx_id ?"} + Q3 -->|"是"| N1["❌ 不可见
建视图后才开启的事务"] + Q3 -->|"否"| Q4{"trx_id 在
m_ids 里 ?"} + Q4 -->|"在"| N2["❌ 不可见
建视图时它还没提交"] + Q4 -->|"不在"| V3["✅ 可见
建视图前已提交"] + N1 --> R{"roll_ptr 还有
上一个版本 ?"} + N2 --> R + R -->|"有"| S + R -->|"没有"| E(["返回空
这行对本事务不存在"]) + V1 --> OUT(["返回这一版"]) + V2 --> OUT + V3 --> OUT +``` + +!!! warning "顺序真的很重要" + 规则 ① 必须在 ④ 之前,否则"自己改的行"会因为自己也在 `m_ids` 里而被判成不可见。 + 规则 ② 必须在 ④ 之前,否则 `trx_id < min_trx_id` 的老版本会被误塞进区间判断。 + 面试时**背顺序**比背结论值钱:`自己 → 太老 → 太新 → 区间内查名单`。 + +--- + +### 4. 贯穿全文的例子:事务 90 / 99 / 100 / 101 / 102 + +现在把上面所有零件装起来跑一遍。 + +**场景**:`product` 表 `id=1` 这行,`price` 初始是 100。 + +- **事务 90**:很久以前把 `price` 改成 100 并提交了(链底)。 +- **事务 99**:一个**只读**事务,全程只做普通 SELECT,是我们的主角。 +- **事务 100 / 101 / 102**:陆续来改 `price` 的写事务。 + +```sql +CREATE TABLE product ( + id INT PRIMARY KEY, + name VARCHAR(32), + price INT +) ENGINE = InnoDB; + +INSERT INTO product VALUES (1, 'gopher 玩偶', 100); -- 事务 90,早已提交 +``` + +#### 4.1 时间线 + +| 时刻 | 事务 99(读者) | 事务 100 | 事务 101 | 事务 102 | 链上状态 | +|:---:|---|---|---|---|---| +| T1 | — | — | — | — | `price=100`,`trx_id=90`(已提交) | +| T2 | `BEGIN` | — | — | — | 同上 | +| T3 | — | `BEGIN` | — | — | 同上 | +| T4 | — | `UPDATE price=200`(拿到行锁,**未提交**) | — | — | 链头 `200/100`,下接 `100/90` | +| **T5** | `SELECT price` → **创建 Read View A** | 仍未提交 | — | — | 同上 | +| T6 | — | — | `BEGIN` | — | 同上 | +| T7 | — | `COMMIT` | 等行锁… | — | 链头 `200/100` 已提交 | +| T8 | — | — | `UPDATE price=300`(未提交) | — | `300/101` → `200/100` → `100/90` | +| **T9** | `SELECT price` | — | 仍未提交 | — | 同上 | +| T10 | — | — | `COMMIT` | — | 同上 | +| T11 | — | — | — | `BEGIN; UPDATE price=350; COMMIT` | `350/102` → `300/101` → `200/100` → `100/90` | +| **T12** | `SELECT price` | — | — | — | 同上 | + +!!! note "关于事务 99 的一个较真点" + InnoDB 里**纯只读事务其实不分配 trx_id**(它不修改任何行,也就不会出现在活跃读写事务列表里)。这里为了讲解方便,把它当成一个有 ID 的事务。放心,这不影响任何可见性结论——把它从 `m_ids` 里去掉,`min_trx_id` 变成 100,下面每一步的判断结果都一模一样。 + +#### 4.2 T5:创建 Read View A,第一次读 + +拍照那一刻,谁还没提交?**99(自己)和 100**。下一个要分配的事务 ID 是 101。 + +``` +Read View A + m_ids = [99, 100] + min_trx_id = 99 + max_trx_id = 101 <-- 下一个将分配的事务 ID = 当前最大 ID(100) + 1 + creator_trx_id = 99 +``` + +此时的版本链(注意:100 **还没提交**,但聚簇索引里的行**已经**被改成 200 了——InnoDB 是"先改后提交",靠锁挡住别人): + +``` + +------------------------------------------------+ + | 当前行 price = 200 DB_TRX_ID = 100 | <-- 未提交 + +------------------------------------------------+ + | + v + +------------------------------------------------+ + | undo v1: price = 100 DB_TRX_ID = 90 | <-- 已提交 + +------------------------------------------------+ +``` + +**走一遍算法**(`trx_id = 100`): + +| 步 | 判断 | 结果 | +|:---:|---|---| +| ① | `100 == creator_trx_id(99)`? | 否 | +| ② | `100 < min_trx_id(99)`? | 否 | +| ③ | `100 >= max_trx_id(101)`? | 否(100 < 101) | +| ④ | `99 <= 100 < 101`,落在区间内。`100` 在 `m_ids=[99,100]` 里吗? | **在 → 不可见** ❌ | + +不可见 → 顺 `roll_ptr` 翻到 `undo v1`(`trx_id = 90`): + +| 步 | 判断 | 结果 | +|:---:|---|---| +| ① | `90 == 99`? | 否 | +| ② | `90 < min_trx_id(99)`? | **是 → 可见** ✅ | + +**事务 99 读到 `price = 100`。** 而磁盘上的行明明是 200。这就是 MVCC:**没加一把锁,没等事务 100,读的是 undo 里的旧版本。** + +#### 4.3 T9(RR 下):复用 Read View A + +现在链长这样: + +``` + +------------------------------------------------+ + | 当前行 price = 300 DB_TRX_ID = 101 | <-- 未提交 + +------------------------------------------------+ + | + v + +------------------------------------------------+ + | undo v2: price = 200 DB_TRX_ID = 100 | <-- T7 已提交 + +------------------------------------------------+ + | + v + +------------------------------------------------+ + | undo v1: price = 100 DB_TRX_ID = 90 | <-- 已提交 + +------------------------------------------------+ +``` + +REPEATABLE READ 下**不新建 Read View,继续用 A**。 + +**第一版 `trx_id = 101`:** + +| 步 | 判断 | 结果 | +|:---:|---|---| +| ① | `101 == 99`? | 否 | +| ② | `101 < 99`? | 否 | +| ③ | `101 >= max_trx_id(101)`? | **是 → 不可见** ❌(101 是 T6 才开的,在照片之后) | + +**翻到 `undo v2`,`trx_id = 100`:** + +| 步 | 判断 | 结果 | +|:---:|---|---| +| ① | `100 == 99`? | 否 | +| ② | `100 < 99`? | 否 | +| ③ | `100 >= 101`? | 否 | +| ④ | 落在 `[99, 101)`,`100` 在 `m_ids=[99,100]` 里吗? | **在 → 不可见** ❌ | + +**这一步最反直觉,也最关键**:事务 100 明明在 T7 就提交了!但 Read View A 是 T5 拍的,它的 `m_ids` 把"100 未提交"这个事实**冻结**住了。A 不知道后来发生了什么——照片不会自己更新。 + +**翻到 `undo v1`,`trx_id = 90`:** `90 < 99` → **可见** ✅ + +**事务 99 在 T9 仍然读到 `price = 100`。** 和 T5 一模一样 → **这就是可重复读。** + +#### 4.4 T9(RC 下):新建 Read View B + +换成 READ COMMITTED,T9 会**重新拍照**。此刻 100 已提交、101 还活跃: + +``` +Read View B + m_ids = [99, 101] <-- 100 从名单里消失了 + min_trx_id = 99 + max_trx_id = 102 + creator_trx_id = 99 +``` + +**第一版 `trx_id = 101`:** ①否 → ②否 → ③ `101 >= 102`?否 → ④ 落在 `[99,102)`,`101` 在 `m_ids` 里 → **不可见** ❌ + +**翻到 `undo v2`,`trx_id = 100`:** + +| 步 | 判断 | 结果 | +|:---:|---|---| +| ① | `100 == 99`? | 否 | +| ② | `100 < 99`? | 否 | +| ③ | `100 >= 102`? | 否 | +| ④ | 落在 `[99, 102)`,`100` 在 `m_ids=[99,101]` 里吗? | **不在 → 可见** ✅ | + +**事务 99 在 RC 下 T9 读到 `price = 200`。** 和 T5 读的 100 不一样了 → **这就是不可重复读。** + +同一份版本链、同一套算法,**只因为 Read View 换没换,结果就完全不同**。 + +#### 4.5 收尾:不同 Read View 下的结果对比 + +把前面几步横向摆一起。注意**版本链一直在变长,算法一直是同一套**,唯一的变量是手上那张照片: + +| 时刻 | 用的 Read View | 当时的版本链(新→旧) | 判断路径 | 读到 | +|:---:|---|---|---|:---:| +| T5 | A `m_ids=[99,100]`
`max=101` | `200/100` → `100/90` | `100`:④在名单 → 不可见;`90`:②可见 | **100** | +| T9(RR) | 复用 A | `300/101` → `200/100` → `100/90` | `101`:③`101>=101` 不可见;`100`:④在名单 不可见;`90`:②可见 | **100** | +| T9(RC) | 新建 B `m_ids=[99,101]`
`max=102` | 同上 | `101`:④在名单 不可见;`100`:④**不在**名单 → 可见 | **200** | +| T12(RR) | 仍复用 A | `350/102` → `300/101` → `200/100` → `100/90` | `102`:③ 不可见;`101`:③ 不可见;`100`:④在名单 不可见;`90`:②可见 | **100** | +| T12(RC) | 新建 C `m_ids=[99]`
`max=103`(全部已提交) | 同上 | `102`:④不在名单 → 可见 | **350** | + +RR 三次读下来死死咬住 100;RC 一路 100 → 200 → 350 跟着真实世界走。 + +!!! note "RC 不是读不到新数据,只是每次读都要重新问一遍" + T12 的 RC 读到 350,走的是规则④的"不在名单 → 可见"分支;T9 的 RC 读到 200,走的也是④。**同一套算法,输入(Read View)不同,输出就不同。** 这就是为什么面试问"RC 和 RR 的区别",标准答案只需要一句话:**Read View 什么时候建、建几次。** + + +--- + +### 5. Read View 的创建时机 = 隔离级别的分水岭 + +这是面试最高频的一问,结论先给: + +| | READ COMMITTED (RC) | REPEATABLE READ (RR) | +|---|---|---| +| **Read View 创建时机** | **每一条**普通 SELECT 都新建一个 | 事务内**第一次**快照读时创建,之后**全程复用** | +| **能看到别人后来的提交吗** | 能(每次读都刷新视野) | 不能(视野冻结在第一次读) | +| **不可重复读** | ✅ 会发生 | ❌ 不会 | +| **幻读(快照读层面)** | ✅ 会发生 | ❌ 被 MVCC 挡住 | +| **undo log 压力** | 小(视图活得很短,purge 跟得上) | 大(视图要活到事务结束) | +| **MySQL 默认** | 不是(很多互联网公司手动改成 RC) | ✅ **InnoDB 默认** | + +再往外扩一层,四个隔离级别下 MVCC 的参与度完全不同: + +| 隔离级别 | Read View | 读的方式 | 允许的现象 | +|---|---|---|---| +| **READ UNCOMMITTED** | ❌ 不建 | 直接读版本链**链头**,哪怕它还没提交 | 脏读、不可重复读、幻读 | +| **READ COMMITTED** | ✅ **每条 SELECT 新建** | 快照读 | 不可重复读、幻读 | +| **REPEATABLE READ**(默认) | ✅ **首次快照读建,全程复用** | 快照读 + 当前读配 Next-Key Lock | 基本都没有(当前读混用时仍可能幻读) | +| **SERIALIZABLE** | ⚠️ 基本不用 | 普通 SELECT 被悄悄升级成 `LOCK IN SHARE MODE`,全走当前读 | 都没有,但并发极差 | + +``` +时间 ───────────────────────────────────────────────────────────▶ + + T5 T9 T12 + │ │ │ +RR 下: SELECT① ───── SELECT② ─────── SELECT③ + 建 Read View A 复用 A 复用 A + │ │ │ + ▼ ▼ ▼ + price=100 price=100 price=100 ✅ 可重复读 + +RC 下: SELECT① ───── SELECT② ─────── SELECT③ + 建 Read View A 新建 B 新建 C + │ │ │ + ▼ ▼ ▼ + price=100 price=200 price=350 ❌ 不可重复读 + +外部世界: 事务100 提交 事务101 提交 事务102 提交 + price 100→200 price 200→300 price 300→350 +``` + +#### 5.1 SQL 时序例子一:REPEATABLE READ(默认) + +```sql +-- ============ 会话 A(读者,事务 99)============ +SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; +BEGIN; + +-- ============ 会话 B(写者,事务 100)============ +BEGIN; +UPDATE product SET price = 200 WHERE id = 1; -- 拿到 id=1 的行锁,未提交 + +-- ============ 会话 A ============ +SELECT price FROM product WHERE id = 1; -- ★ 100 + -- 此刻建 Read View A + -- 链头 200/trx=100 在 m_ids 里 → 不可见 + -- 回溯到 100/trx=90 → 可见 + -- 注意:没有等会话 B 的锁 + +-- ============ 会话 B ============ +COMMIT; -- price=200 正式生效 + +-- ============ 会话 A ============ +SELECT price FROM product WHERE id = 1; -- ★ 还是 100 + -- 复用 Read View A + -- A 的 m_ids 冻结了"100 未提交" +COMMIT; + +SELECT price FROM product WHERE id = 1; -- ★ 200 + -- 事务结束后新事务重新拍照 +``` + +#### 5.2 SQL 时序例子二:READ COMMITTED + +```sql +-- ============ 会话 A ============ +SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; +BEGIN; + +-- ============ 会话 B ============ +BEGIN; +UPDATE product SET price = 200 WHERE id = 1; + +-- ============ 会话 A ============ +SELECT price FROM product WHERE id = 1; -- ★ 100(Read View A,B 未提交) + +-- ============ 会话 B ============ +COMMIT; + +-- ============ 会话 A ============ +SELECT price FROM product WHERE id = 1; -- ★ 200 ← 同一条 SQL,值变了 + -- 重新建了 Read View B + -- B 的 m_ids 里已经没有 100 + -- → 规则④「不在名单里」→ 可见 +COMMIT; +``` + +!!! tip "RC 也不是脏读" + 上面 RC 读到 200 的前提是会话 B **已经 COMMIT**。如果 B 还没提交,会话 A 新建的 Read View 里 B 依然在 `m_ids` 中,照样读不到 200。**RC 只是"愿意接受新的已提交数据",不是"愿意接受未提交数据"。** + +--- + +### 6. 快照读 vs 当前读 + +MVCC **只对快照读生效**。这是必须刻进脑子里的一条边界。 + +| | 快照读(Snapshot Read) | 当前读(Current Read) | +|---|---|---| +| **别名** | 一致性非锁定读 | 加锁读 | +| **哪些语句** | 普通 `SELECT` | `SELECT ... LOCK IN SHARE MODE`(8.0 推荐 `FOR SHARE`)
`SELECT ... FOR UPDATE`
`UPDATE` / `DELETE` / `INSERT` | +| **读哪个版本** | 沿版本链找 Read View 可见的那一版 | 直接读**最新已提交**版本 | +| **加不加锁** | ❌ 不加锁 | ✅ 加锁(Record / Gap / Next-Key) | +| **建不建 Read View** | 建(或复用) | 不建 | +| **会被写阻塞吗** | 不会 | 会(要等对方释放行锁) | + +``` + 一条读请求进来 + | + +---------------+---------------+ + | | + 普通 SELECT SELECT ... FOR UPDATE + (快照读) SELECT ... LOCK IN SHARE MODE + | UPDATE / DELETE / INSERT + | (当前读) + | | + 不加任何锁 加锁:Record / Gap / Next-Key + | | + 用事务的 Read View 不建 Read View + | | + 沿版本链回溯找可见版本 直接读聚簇索引里的 + | 最新已提交版本 + v v + 读不阻塞写、写不阻塞读 写写互斥、读会等锁 + —— MVCC 的主战场 —— 锁的主战场 +``` + +!!! warning "为什么 UPDATE / DELETE 必须是当前读?" + 如果 `UPDATE product SET price = price + 1 WHERE id = 1` 走快照读,它可能拿着一个 5 分钟前的旧 `price` 去计算,然后覆盖掉别人已经提交的结果——**丢失更新**。所以所有写操作都必须读最新已提交版本,并且加锁。这不是实现偷懒,是正确性要求。 + +--- + +### 7. MVCC 与锁的配合:RR 下怎么防幻读 + +**幻读** = 同一事务内,两次执行同一个范围查询,**行数**变了(别人插了新行)。它和不可重复读的区别是:不可重复读关心"同一行的值变了",幻读关心"结果集多了一行/少了一行"。 + +InnoDB 在 RR 下用**两条腿**防幻读,一条都不能少: + +| 读的类型 | 防幻读靠什么 | 原理 | +|---|---|---| +| **快照读**(普通 SELECT) | **MVCC** | 整个事务复用同一个 Read View;别人 INSERT 的新行 `trx_id >= max_trx_id`,规则③直接判不可见 | +| **当前读**(`FOR UPDATE` / `UPDATE` / `DELETE`) | **Next-Key Lock** | 锁住扫描范围,别人**插不进来** | + +锁三兄弟: + +| 锁 | 锁什么 | 作用 | +|---|---|---| +| **Record Lock**(行锁) | 索引上的**某一条具体记录** | 防止别人改/删这一行 | +| **Gap Lock**(间隙锁) | 索引记录**之间的空隙**(不含记录本身) | 专门防止别人往这个空隙里 INSERT;间隙锁之间通常不互斥 | +| **Next-Key Lock**(临键锁) | Record Lock + Gap Lock,**左开右闭**区间 | RR 下当前读防幻读的主力 | + +```mermaid +sequenceDiagram + participant A as 会话 A(RR) + participant DB as InnoDB + participant B as 会话 B + + A->>DB: SELECT * FROM product WHERE price >= 100 + Note over A,DB: 快照读:建 Read View,1 行 + B->>DB: INSERT INTO product VALUES (2,'新玩偶',150) + DB-->>B: 成功(快照读不加锁,挡不住插入) + B->>DB: COMMIT + A->>DB: SELECT * FROM product WHERE price >= 100 + Note over A,DB: 快照读:复用 Read View
新行 trx_id ≥ max_trx_id → 不可见
仍然 1 行 ✅ MVCC 挡住了幻读 + A->>DB: SELECT * FROM product WHERE price >= 100 FOR UPDATE + Note over A,DB: 当前读!读到 2 行,并对区间加 Next-Key Lock +``` + +!!! warning "注意上图最后一步" + 一旦在 RR 事务里用了 `FOR UPDATE`(当前读),就会看到快照读看不到的那一行——**MVCC 的"可重复"承诺在这里失效**。这正是「常见陷阱一」的现场。 + +--- + +### 8. purge:undo log 不能无限留 + +版本链越攒越长,undo log 迟早撑爆磁盘,快照读也会越翻越慢。所以 InnoDB 有个后台 **purge 线程**专门回收。 + +``` + purge 线程的判断(简化) + ========================= + + InnoDB 内部维护「所有存活 Read View」的信息 + | + v + +-------------------------------------------+ + | 扫回滚段里的一条 undo 版本记录 | + | | + | 问:还有没有任何一个存活的 Read View, | + | 可能需要看到这个旧版本? | + +-------------------------------------------+ + | | + 没有 还有 + | | + v v + 回收这块 undo 原地保留,谁都不能动 + History list length 下降 长事务就卡在这里 + → 版本链越拖越长 + → 快照读一路回溯,越查越慢 +``` + +判断依据正是 Read View,但要注意方向:**purge 的进度由「最老的那个存活 Read View」一个人说了算。** 只有当所有存活的 Read View 都会选择比某个版本**更新**的版本时,这个版本以及它下面更旧的一串才能被回收。 + +所以一个长期不提交的事务,会独自把整条版本链的回收进度**钉死**在最老那一版——这就是长事务能把 `History list length` 从几百拖到几百万的全部原因。 + +| 相关参数 | 说明 | 默认值 | +|---|---|---| +| `innodb_purge_threads` | purge 后台线程数 | 4 | +| `innodb_purge_batch_size` | 单次 purge 处理的 undo log 页数 | 300 | +| `innodb_max_purge_lag` | 允许的 History list 最大长度,超了会**主动延迟 DML** | 0(不限制) | +| `innodb_undo_log_truncate` | 是否允许 undo 表空间截断回收(8.0) | ON | + +**观测手段**(这两个是真的能用的): + +```sql +-- History list length:还没被 purge 掉的 undo 版本数量,越大越危险 +SHOW ENGINE INNODB STATUS\G +-- 在输出的 TRANSACTIONS 段里找: +-- History list length 3147 + +-- 谁在拖着不让 purge 干活?找最老的活跃事务 +SELECT trx_id, + trx_state, + trx_started, + TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec, + trx_mysql_thread_id AS conn_id, + trx_rows_modified, + trx_query +FROM information_schema.innodb_trx +ORDER BY trx_started +LIMIT 10; +``` + +--- + +### 9. MVCC 不是免费的 + +| 代价 | 说明 | 应对 | +|---|---|---| +| **空间** | 每次改行都要多存一份 undo 版本 | 靠 purge 回收;别让长事务堵住它 | +| **时间** | 快照读可能要沿链回溯好几跳才找到可见版本 | 控制事务长度;避免热点行被高频更新 | +| **回滚段膨胀** | 长事务把 undo 全钉住,`History list length` 飙升 | 监控 + 杀长事务;`innodb_max_purge_lag` 兜底 | +| **写写仍要锁** | MVCC 完全不解决两个事务改同一行 | 老实排队等行锁 | + +--- + +## 💻 代码示例 + +### SQL:一次完整的可重复读验证 + +```sql +-- 1. 确认自己在哪个隔离级别 +-- MySQL 8.0 用 transaction_isolation,5.7 用 tx_isolation +SELECT @@global.transaction_isolation AS global_level, + @@session.transaction_isolation AS session_level; + +-- 2. 准备数据 +DROP TABLE IF EXISTS product; +CREATE TABLE product ( + id INT PRIMARY KEY, + name VARCHAR(32), + price INT +) ENGINE = InnoDB; +INSERT INTO product VALUES (1, 'gopher 玩偶', 100); + +-- 3. 观察自己的事务 ID(用来跟 Read View 的字段对应上) +BEGIN; +SELECT trx_id, trx_state, trx_started +FROM information_schema.innodb_trx +WHERE trx_mysql_thread_id = CONNECTION_ID(); +-- 注意:纯只读事务在真正读数据前,这里可能查不到记录 +-- (InnoDB 不给只读事务分配 trx_id) +ROLLBACK; +``` + +### SQL:亲手复现「快照读 + 当前读混用」的幻读 + +```sql +-- ============ 会话 A(RR)============ +BEGIN; +SELECT * FROM product WHERE price >= 100; +-- 1 行:(1, 'gopher 玩偶', 100) + +-- ============ 会话 B ============ +INSERT INTO product VALUES (2, '新玩偶', 150); +COMMIT; + +-- ============ 会话 A ============ +SELECT * FROM product WHERE price >= 100; +-- 还是 1 行 ← MVCC 生效,新行 trx_id >= max_trx_id,不可见 + +UPDATE product SET price = price + 1 WHERE price >= 100; +-- 影响 2 行! ← 当前读,读到了最新已提交数据,id=2 也被改了 +-- 而且 id=2 的 DB_TRX_ID 被写成了会话 A 自己的 trx_id + +SELECT * FROM product WHERE price >= 100; +-- 变成 2 行了 ← 幻读出现 +-- 原因:id=2 的 trx_id 现在等于 creator_trx_id,命中可见性规则① +COMMIT; +``` + +### Go:`database/sql` 的只读事务 + +```go +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// readPriceTwice 演示 REPEATABLE READ 下的可重复读。 +// +// 要点:Go 侧能控制的只有「隔离级别」和「是否只读」两件事, +// Read View 什么时候建、沿哪条版本链回溯,全是 InnoDB 内部行为, +// 应用代码既看不到也管不着。 +func readPriceTwice(ctx context.Context, db *sql.DB, id int) (int, error) { + // 给事务加超时:Read View 活得越久,undo log 越清不掉。 + // 这个 timeout 本质上是在给「长事务」上保险。 + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + tx, err := db.BeginTx(ctx, &sql.TxOptions{ + // 驱动会先发 SET TRANSACTION ISOLATION LEVEL REPEATABLE READ + Isolation: sql.LevelRepeatableRead, + // 驱动会发 START TRANSACTION READ ONLY; + // InnoDB 不会给纯只读事务分配 trx_id,也保证你不会误写 + ReadOnly: true, + }) + if err != nil { + return 0, fmt.Errorf("begin tx: %w", err) + } + // 兜底:任何一条 return 路径都能把事务关掉,避免连接带着 + // 一个存活的 Read View 被还回连接池(这是长事务的头号来源)。 + // Commit 成功后 Rollback 返回 sql.ErrTxDone,忽略即可。 + defer func() { _ = tx.Rollback() }() + + var first, second int + + // ★ 第一条 SELECT 就在这里创建了 Read View A + if err := tx.QueryRowContext(ctx, + `SELECT price FROM product WHERE id = ?`, id).Scan(&first); err != nil { + return 0, fmt.Errorf("first read: %w", err) + } + + // ★ 第二条 SELECT 复用 Read View A。 + // 即使这期间别的事务把 price 改了并提交,这里仍然读到 first。 + if err := tx.QueryRowContext(ctx, + `SELECT price FROM product WHERE id = ?`, id).Scan(&second); err != nil { + return 0, fmt.Errorf("second read: %w", err) + } + + if first != second { + // RR 下不该发生。真发生了,八成是: + // ① 隔离级别被 DSN 或连接池配置改成了 RC; + // ② 中间夹了 FOR UPDATE / UPDATE 之类的当前读。 + return 0, fmt.Errorf("unexpected non-repeatable read: %d != %d", first, second) + } + + if err := tx.Commit(); err != nil && !errors.Is(err, sql.ErrTxDone) { + return 0, fmt.Errorf("commit: %w", err) + } + return first, nil +} +``` + +!!! warning "不开事务的 `db.Query` 等于每次都是新照片" + Go 里直接 `db.QueryRow(...)` 而不开事务,MySQL 是 **autocommit**:每条语句自成一个小事务,各建各的 Read View。所以两次 `db.QueryRow` 之间完全可能读到不同的值——**行为上等同于 READ COMMITTED,哪怕你的 session 隔离级别是 RR**。要可重复读,必须显式 `BeginTx`。 + +```go +// ❌ 反例:把慢操作塞进事务,Read View 被迫长期存活 +func badLongTransaction(ctx context.Context, db *sql.DB, id int) error { + tx, _ := db.BeginTx(ctx, nil) // RR,读写事务 + defer tx.Rollback() + + var price int + tx.QueryRowContext(ctx, + `SELECT price FROM product WHERE id = ?`, id).Scan(&price) + // ↑ Read View A 从这一刻开始存活 + + resp, err := callInventoryRPC(ctx, id) // ← 一次 300ms~数秒 的跨服务调用 + if err != nil { // 这期间 A 一直钉着 undo log, + return err // purge 一点都清不动 + } + + _, err = tx.ExecContext(ctx, + `UPDATE product SET price = ? WHERE id = ?`, resp.Price, id) + if err != nil { + return err + } + return tx.Commit() +} + +// ✅ 正解:RPC 放事务外,事务里只做 DB 操作,尽量短 +func goodShortTransaction(ctx context.Context, db *sql.DB, id int) error { + resp, err := callInventoryRPC(ctx, id) // 先做慢的、非 DB 的事 + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, + `UPDATE product SET price = ? WHERE id = ?`, resp.Price, id); err != nil { + return err + } + return tx.Commit() // 事务存活时间 = 两条 SQL 的耗时 +} +``` + +--- + +## ⚠️ 常见陷阱 + +!!! warning "陷阱一:以为 RR 下 MVCC 能挡住一切幻读" + **现象**:RR 事务里先 `SELECT` 看到 1 行,然后 `UPDATE` / `DELETE` 却发现影响了 2 行,再 `SELECT` 变成 2 行。 + + **原因**:MVCC 只管**快照读**。`UPDATE` / `DELETE` / `SELECT ... FOR UPDATE` 是**当前读**,读的是最新已提交版本,会碰到别人后来插入的行;一旦被本事务改过,这行的 `DB_TRX_ID` 就变成了 `creator_trx_id`,命中可见性规则①,于是后续的快照读也能看见它了——快照被自己"戳穿"。 + + **解决**:RR 防幻读是"MVCC + Next-Key Lock"两条腿,**只要事务里出现当前读,就要按当前读的规则想问题**。需要严格一致的范围,一开始就用 `SELECT ... FOR UPDATE` 把区间锁住(此时会加 Next-Key Lock 阻止插入),别先快照读再改。 + +!!! warning "陷阱二:长事务——MVCC 最贵的账单" + **现象**:磁盘慢慢涨满、`History list length` 从几百飙到几百万、普通 SELECT 越来越慢、最后可能大面积超时。 + + **原因**:一个事务开着不提交(典型:Go 代码 `BeginTx` 后忘了 `Rollback`、事务里夹了 HTTP/RPC 调用、人工在客户端 `BEGIN` 之后去开了个会),它的 Read View 就一直存活。purge 线程不敢回收任何它可能需要的 undo 版本,于是: + - undo 表空间只增不减; + - 热点行的版本链越拖越长,每次快照读都要一路回溯,读放大严重; + - 极端情况下 `innodb_max_purge_lag` 触发,开始主动给 DML 加延迟。 + + **解决**:① 事务体里**绝不**放网络调用、大循环、人工交互;② `defer tx.Rollback()` 兜底 + `context.WithTimeout`;③ 连接池设 `ConnMaxLifetime`,防止连接带着脏事务被复用;④ 上线监控 `information_schema.innodb_trx` 里 `running_sec` 超阈值的事务,必要时 `KILL`。 + +!!! warning "陷阱三:以为用了 MVCC 就完全不需要锁" + **现象**:两个 goroutine 并发扣库存,都用了事务,结果库存少扣了。 + + **原因**:MVCC 解决的是**读写冲突**(读不阻塞写、写不阻塞读),**写写冲突照样要抢行锁**。两个事务同时 `UPDATE ... WHERE id = 1`,后者必须阻塞等前者提交;更危险的是 `SELECT price` 然后应用层算一算再 `UPDATE price = 新值`——两条语句之间别人可能已经改过了,这就是**丢失更新**,MVCC 一点忙都帮不上。 + + **解决**:需要"读了再决定"的场景,用当前读 + 锁:`SELECT ... FOR UPDATE`;或者干脆用原子 SQL `UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock >= 1`,靠行锁 + 条件判断一次搞定。 + +!!! warning "陷阱四:以为 RR 的快照是 `BEGIN` 那一刻建的" + **现象**:`BEGIN;` 之后隔了几秒才 `SELECT`,结果读到了这几秒内别人提交的数据,觉得"RR 不生效"。 + + **原因**:InnoDB 的 RR **不是**在 `BEGIN` / `START TRANSACTION` 时创建 Read View,而是在事务里**第一次执行快照读**时才创建。(想真的把视图钉在事务开始那一刻,得用 `START TRANSACTION WITH CONSISTENT SNAPSHOT`。)这也意味着:**同一份代码在 RC 和 RR 下的 Read View 数量差异,取决于你 SELECT 了几次,而不是事务开了多久。** + + **解决**:需要"事务开始即快照"的语义时显式使用 `START TRANSACTION WITH CONSISTENT SNAPSHOT`;写业务代码时记住"第一条 SELECT 才是拍照时刻"。 + +--- + +## 🏋️ 练习题 + +??? question "练习 1:给定 Read View `m_ids = [100, 101]`、`min_trx_id = 100`、`max_trx_id = 103`、`creator_trx_id = 99`,请分别判断 `trx_id` 为 98、100、102、103 的四个版本是否可见,并说明命中哪条规则。" + + ??? success "答案" + 按「自己 → 太老 → 太新 → 区间内查名单」的顺序走: + + | trx_id | 判断过程 | 结论 | + |:---:|---|:---:| + | 98 | ① `98 != 99`;② `98 < min_trx_id(100)` 成立 | ✅ **可见**(创建视图前就已提交) | + | 100 | ① 否;② `100 < 100` 不成立;③ `100 >= 103` 不成立;④ 落在 `[100,103)`,`100` **在** `m_ids` 中 | ❌ **不可见**(拍照时它还活跃、未提交) | + | 102 | ①②③ 均否;④ 落在 `[100,103)`,`102` **不在** `m_ids=[100,101]` 中 | ✅ **可见**(拍照前开启且已提交) | + | 103 | ① 否;② 否;③ `103 >= max_trx_id(103)` 成立 | ❌ **不可见**(拍照之后才开启的事务) | + + 不可见的两个版本,都要沿 `DB_ROLL_PTR` 翻到更旧的版本,用同一个 Read View 重判。 + +??? question "练习 2:为什么 READ COMMITTED 会发生不可重复读,而 REPEATABLE READ 不会?请从 Read View 的角度解释,并说明「事务 100 已经提交了,RR 下为什么还看不到它」。" + + ??? success "答案" + **差别只在 Read View 的创建时机**,底层的版本链和可见性算法完全一样。 + + - **RC**:每执行一次普通 SELECT 都**新建**一个 Read View。前一个视图里 `m_ids` 中的事务如果在这期间提交了,新视图的 `m_ids` 里就没有它了,规则④会判"不在名单 → 可见",于是读到新值 → **同一行两次读值不同 = 不可重复读**。 + - **RR**:只在事务内**第一次**快照读时创建 Read View,之后全程复用。旧视图的 `m_ids` 把"事务 100 未提交"这个事实**冻结**住了,即使 100 后来真的提交了,规则④仍然认为它在名单里 → 不可见 → 继续回溯到更老的版本 → **两次读结果一致 = 可重复读**。 + + 关键点:Read View 是一张**照片**,不是实时监控。照片拍完之后世界怎么变,照片本身不会更新。RR 的"可重复"就是靠这种"故意视而不见"实现的,代价是 undo log 必须留到事务结束才能 purge。 + +??? question "练习 3:线上 MySQL 的 `History list length` 从 500 涨到了 300 万,同时业务反馈「简单的主键查询越来越慢」。请分析可能的原因、MVCC 层面的解释,以及排查和处置步骤。" + + ??? success "答案" + **原因**:有长事务(Read View 长期存活),purge 线程无法回收任何它可能需要的 undo 版本,导致未清理的版本不断堆积。 + + **为什么主键查询也变慢**:`History list length` 高意味着热点行的**版本链被拖得很长**。快照读必须从链头开始,拿 Read View 逐版比对、沿 `roll_ptr` 一路回溯,直到找到可见版本。原本一跳就命中的查询,可能要翻几十上百跳,还伴随大量随机 IO 读 undo 页——这就是"读放大"。 + + **排查步骤**: + + ```sql + -- ① 确认堆积规模 + SHOW ENGINE INNODB STATUS\G -- 看 History list length + + -- ② 找出最老的活跃事务 + SELECT trx_id, trx_state, trx_started, + TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec, + trx_mysql_thread_id AS conn_id, trx_rows_modified, trx_query + FROM information_schema.innodb_trx + ORDER BY trx_started LIMIT 10; + + -- ③ 顺着 conn_id 找是谁的连接(IP、用户名、当前 SQL) + SELECT processlist_id, processlist_user, processlist_host, processlist_db + FROM performance_schema.threads + WHERE processlist_id = <上面的 conn_id>; + ``` + + **处置**: + 1. 紧急:确认无误后 `KILL ` 掉那个长事务(注意它可能需要长时间回滚)。 + 2. 短期:设 `innodb_max_purge_lag` 给 DML 加背压,避免继续恶化;观察 purge 追上进度。 + 3. 根治:改代码——事务里不许有 RPC / HTTP / 人工交互,`defer tx.Rollback()` 兜底,`context.WithTimeout` 限时长,连接池设 `ConnMaxLifetime`;再加一条"`innodb_trx` 中 `running_sec > N` 就告警"的监控。 + +--- + +## 🔗 相关链接 + +- [MySQL 官方文档:Consistent Nonlocking Reads](https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html) — 快照读与 Read View 的权威定义 +- [MySQL 官方文档:Transaction Isolation Levels](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html) — 四种隔离级别下 InnoDB 的具体行为 +- [MySQL 官方文档:InnoDB Locking](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html) — Record / Gap / Next-Key Lock 详解,配合 MVCC 一起看 +- [MySQL 官方文档:InnoDB UNDO Tablespaces](https://dev.mysql.com/doc/refman/8.0/en/innodb-undo-tablespaces.html) — undo 表空间与截断回收(purge 相关参数在此章) +- [go-sql-driver/mysql](https://github.com/go-sql-driver/mysql) — Go 侧 `sql.TxOptions` 最终就是发给它的 `SET TRANSACTION ISOLATION LEVEL` +- [《MySQL 技术内幕:InnoDB 存储引擎》](https://book.douban.com/subject/24708143/) — MVCC、锁、事务章节的中文经典参考 diff --git a/docs/database/mysql/transaction-isolation.md b/docs/database/mysql/transaction-isolation.md new file mode 100644 index 0000000..49be3e8 --- /dev/null +++ b/docs/database/mysql/transaction-isolation.md @@ -0,0 +1,712 @@ +# MySQL 事务隔离级别 + +!!! note "💡 一句话摘要" + 隔离级别就是数据库给并发事务「装多厚的隔板」:从薄到厚依次是 READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE,隔板越厚越安全但并发越差;MySQL(InnoDB) 默认 REPEATABLE READ,靠 **MVCC 快照读 + 临键锁** 挡住幻读,而 Oracle / PostgreSQL 默认 READ COMMITTED。本文把三大异常、四级矩阵、RR 幻读破功的边角场景、以及生产上 RC vs RR 怎么选,一次讲清。 + +--- + +## 🔑 核心概念 + +1. **事务(Transaction)** — 一组「要么全做、要么全不做」的 SQL。ACID 一句话:**原子性**靠 undo log(能回滚)、**持久性**靠 redo log + WAL(提交了就不丢)、**隔离性**靠锁 + MVCC(并发互不干扰)、**一致性**是前三者加上约束共同达成的最终结果。 +2. **三大并发异常** — 脏读(读到别人**未提交**的数据)、不可重复读(同一**行**两次读值不同,由 UPDATE 引起)、幻读(同一条件两次查**行数**不同,由 INSERT / DELETE 引起)。 +3. **四种隔离级别** — RU → RC → RR → SERIALIZABLE,隔离强度递增、并发度递减;**MySQL InnoDB 默认 RR**,Oracle / PostgreSQL / SQL Server 默认 RC,RU 基本没人用,SERIALIZABLE 太贵也很少用。 +4. **MVCC(多版本并发控制)** — 普通 SELECT 不加锁,靠行上的 `DB_TRX_ID` + `DB_ROLL_PTR` 串成 undo 版本链,再用 Read View 挑一个「对我可见」的旧版本读出来,于是读写不互相阻塞。 +5. **快照读 vs 当前读** — 普通 SELECT 是快照读(走 MVCC,不加锁);`SELECT ... FOR UPDATE`、`UPDATE`、`DELETE`、`INSERT` 是当前读(读最新已提交版本并加锁)。RR 的幻读防护 = 快照读一致性 + 当前读的临键锁,两条腿缺一不可。 + +--- + +## 📝 详解 + +### 1. 先把事务和 ACID 说人话 + +事务就是「一捆 SQL 绑成一个不可分割的动作」。最典型的例子是转账:扣 A 的钱和加 B 的钱必须一起成功,只成一半就是事故。 + +ACID 四性,每性一句话,外加「InnoDB 用什么实现的」——面试常连着问: + +| 特性 | 大白话 | InnoDB 靠什么 | +|------|--------|---------------| +| **A** 原子性 Atomicity | 要么全做,要么全不做,能反悔 | undo log(记录「怎么改回去」) | +| **C** 一致性 Consistency | 事务前后数据符合业务规则、不破坏约束 | 主键/唯一/外键/NOT NULL 等约束 + 上面三个一起兜底 | +| **I** 隔离性 Isolation | 并发事务之间互不打扰 | **锁 + MVCC**(本文主角) | +| **D** 持久性 Durability | 提交了就永久保存,断电也不丢 | redo log + WAL(先刷日志再刷数据页) | + +> 隔离性是四性里唯一一个「可以调档位」的——这个档位就叫**隔离级别**。其余三性没有开关,InnoDB 直接给你做满。 + +### 2. 并发三大异常:脏读、不可重复读、幻读 + +一句话抓本质: + +- **脏读**:我读到了你**还没提交**的改动,你一 ROLLBACK,我读到的东西就等于从来没存在过——数据是「脏」的。 +- **不可重复读**:我在同一个事务里读**同一行**两次,值变了——因为你在中间 UPDATE 并 COMMIT 了。 +- **幻读**:我在同一个事务里用**同一个条件**查两次,行数变了——因为你在中间 INSERT(或 DELETE)并 COMMIT 了,多出来的行像幻觉。 + +三者最容易混的是后两个,用这张表一刀切开: + +| | 脏读 | 不可重复读 | 幻读 | +|---|------|-----------|------| +| **本质** | 读到未提交数据 | 同一行的**值**变了 | 结果集的**行数**变了 | +| **元凶语句** | 对方 UPDATE 且**没提交** | 对方 **UPDATE** + COMMIT | 对方 **INSERT / DELETE** + COMMIT | +| **变化对象** | 数据的「真实性」 | 已存在的某一行 | 一批行(区间) | +| **要防住它,得** | 只读已提交版本 | 锁住已存在的行 | 锁住「还不存在的空隙」(间隙锁) | +| **严重性** | 最严重 | 中 | 最轻(但最难防) | + +!!! tip "记忆口诀" + **脏读看提交状态,不可重复读看「行内值」,幻读看「行数」。** + 防「行数变化」必须锁住空隙,这就是间隙锁(Gap Lock)存在的唯一理由——普通行锁只能锁住「已经有的行」,锁不住「还不存在的行」。 + +### 3. 四级隔离:异常矩阵 + +ISO/ANSI SQL 定义了四档,从「几乎没有隔板」到「全串行」: + +| 隔离级别 | 中文 | 脏读 | 不可重复读 | 幻读 | 备注 | +|----------|------|:----:|:----------:|:----:|------| +| `READ UNCOMMITTED` | 读未提交 | ⚠️ 允许 | ⚠️ 允许 | ⚠️ 允许 | 基本等于没隔离,生产**禁用** | +| `READ COMMITTED` | 读已提交 | ✅ 禁止 | ⚠️ 允许 | ⚠️ 允许 | **Oracle / PostgreSQL / SQL Server 默认** | +| `REPEATABLE READ` | 可重复读 | ✅ 禁止 | ✅ 禁止 | ⚠️ 标准允许,**InnoDB 基本已解决** | **MySQL InnoDB 默认** | +| `SERIALIZABLE` | 串行化 | ✅ 禁止 | ✅ 禁止 | ✅ 禁止 | 普通 SELECT 隐式加共享锁,并发最差 | + +两个必须记住的点: + +1. **默认值不一样**。MySQL 默认 RR,Oracle 和 PostgreSQL 默认 RC。所以「MySQL 会不会幻读」和「Oracle 会不会幻读」是两个完全不同的问题,面试时先问清楚是哪个数据库。 +2. **RR 那一格是 InnoDB 的「超纲发挥」**。按 SQL 标准,RR 是允许幻读的(只有 SERIALIZABLE 才禁止);但 InnoDB 用 MVCC + 临键锁在 RR 下把幻读基本堵住了。所以「RR 能不能防幻读」的标准答案是:**标准说不能,InnoDB 说基本能,但混用快照读和当前读时会破功**(见第 7 节)。 + +各级别的行为差异,根子上是 InnoDB 的实现方式不同: + +| 级别 | 快照读怎么走 | 当前读怎么走 | 间隙锁 | +|------|-------------|-------------|--------| +| RU | 不加锁,直接读最新数据页(**能看到未提交**) | 加锁 | 基本不用 | +| RC | 每次 SELECT **新建 Read View**,所以能看到最新已提交 | 加锁,读最新已提交 | **基本关闭**(仅外键/唯一键检查时用) | +| RR | 事务内**第一条 SELECT 建 Read View,之后全程复用** | 加锁,读最新已提交 | **开启**(Next-Key Lock) | +| SERIALIZABLE | 普通 SELECT 被强制转成加共享锁的读 | 加锁 | 开启 | + +RC 还有个细节优化叫**半一致读(semi-consistent read)**:UPDATE 扫描时遇到不满足 WHERE 条件的行,会提前释放锁,因此 RC 下 UPDATE 的锁范围明显更小、锁等待更少——这也是很多互联网公司偏爱 RC 的原因之一。 + +### 4. 时序图:脏读怎么发生(RU) + +场景:`stocks` 表初始 `id=1, price=100`,会话 A 改成 50 但**没提交**,会话 B 在 RU 下读到了 50,随后 A 回滚。 + +```mermaid +sequenceDiagram + autonumber + participant A as 会话A + participant DB as InnoDB + participant B as 会话B + + Note over A,B: 隔离级别 READ UNCOMMITTED,stocks 表 id=1 price=100 + A->>DB: BEGIN + A->>DB: UPDATE stocks SET price=50 WHERE id=1 + Note right of A: 只改了内存页 + 写 undo,尚未 COMMIT + B->>DB: SELECT price FROM stocks WHERE id=1 + DB-->>B: 返回 50 + Note over B: B 拿着 50 去算收益 / 展示给用户 + A->>DB: ROLLBACK + DB-->>A: 用 undo log 把 price 改回 100 + Note over A,B: 磁盘上从来没有 50 这个值,B 读的是脏数据 +``` + +同样的场景换成 RR,B 读到的是 100:因为 B 的快照读用 Read View 判断,A 的事务 ID 还在「未提交集合 `m_ids`」里 → 这个版本不可见 → 沿 `DB_ROLL_PTR` 回溯到上一个已提交版本 100。**脏读的本质是「可见性判断缺失」,MVCC 就是可见性判断。** + +### 5. 时序图:不可重复读怎么发生(RC) + +场景:`scores` 表初始 `id=1, score=80`,会话 B 在同一事务里读两次,中间会话 A 改成了 90 并提交。 + +```mermaid +sequenceDiagram + autonumber + participant B as 会话B + participant DB as InnoDB + participant A as 会话A + + Note over A,B: 隔离级别 READ COMMITTED,scores 表 id=1 score=80 + B->>DB: BEGIN + B->>DB: SELECT score FROM scores WHERE id=1 + DB-->>B: 80 + Note right of B: 新建 ReadView ①,看到已提交的 80 + A->>DB: BEGIN + A->>DB: UPDATE scores SET score=90 WHERE id=1 + A->>DB: COMMIT + Note over A: A 的修改已提交,trx_id 不在任何人的未提交集合里了 + B->>DB: SELECT score FROM scores WHERE id=1 + DB-->>B: 90 + Note right of B: 新建 ReadView ②,看到最新已提交的 90 + B->>DB: COMMIT + Note over B: 同一事务、同一行、两次读值不同 = 不可重复读 +``` + +**关键就一句:RC 每次快照读都新建 Read View,RR 全程复用第一个 Read View。** + +```sql +-- 把上面的会话 B 换成 RR,结果就变成 80 / 80 +SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; +BEGIN; +SELECT score FROM scores WHERE id=1; -- 80,此刻建立 Read View +-- (会话 A 在这里 UPDATE 成 90 并 COMMIT) +SELECT score FROM scores WHERE id=1; -- 还是 80:复用同一 Read View,A 对我不可见 +COMMIT; +``` + +### 6. MVCC 与锁:InnoDB 的两条腿 + +隔离性 = **锁 + MVCC**,分工非常清楚: + +| 机制 | 解决的冲突 | 特点 | +|------|-----------|------| +| **MVCC** | 读 vs 写 | 快照读不加锁,读旧版本,读写并行不阻塞 | +| **锁** | 写 vs 写、当前读的幻读 | Record Lock 锁行,Gap Lock 锁空隙,Next-Key Lock = 两者组合 | + +**MVCC 的三个零件**: + +1. 每行两个隐藏列:`DB_TRX_ID`(最后修改这行的事务 ID)、`DB_ROLL_PTR`(指向 undo log 里这行的旧版本)。 +2. **版本链**:多次修改产生的旧版本靠 `DB_ROLL_PTR` 串成一条链,最新的在链头。 +3. **Read View**:四个字段——`m_ids`(建快照这一刻还没提交的事务 ID 集合)、`min_trx_id`(`m_ids` 里最小的)、`max_trx_id`(下一个将要分配的事务 ID)、`creator_trx_id`(自己的事务 ID)。 + +可见性判断顺序(拿行的 `trx_id` 依次比): + +```text +① trx_id == creator_trx_id → 自己改的 → 可见 +② trx_id < min_trx_id → 建快照前就已提交 → 可见 +③ trx_id >= max_trx_id → 建快照后才启动的事务 → 不可见 +④ trx_id ∈ m_ids → 还没提交 → 不可见 +⑤ 其余 → 可见 + ↓ 当前版本不可见,就顺着 DB_ROLL_PTR 往更旧的版本继续判断 +``` + +**锁的三种形态**: + +| 锁 | 锁什么 | 作用 | +|----|--------|------| +| Record Lock(行锁) | 某一条具体索引记录 | 防别人改/删这一行 | +| Gap Lock(间隙锁) | 两条记录之间的**空隙**(不含记录本身) | 防别人往空隙里 INSERT → **专为防幻读而生** | +| Next-Key Lock(临键锁) | 记录 + 它前面的间隙,左开右闭区间 | RR 下当前读的默认加锁单位 | + +举例:`orders` 表已有 `id=2` 和 `id=4`(id 不连续),RR 下执行 + +```sql +SELECT * FROM orders WHERE id BETWEEN 1 AND 4 FOR UPDATE; +``` + +InnoDB 不只锁住 2 和 4 这两行记录,还会用 Next-Key Lock 把它们各自**前面的间隙**一起锁上(大致是 `(-∞, 2]` 和 `(2, 4]` 这样的左开右闭区间)。此时另一个会话执行 `INSERT INTO orders(id) VALUES(3)`,因为 3 落在被锁住的间隙 `(2, 4)` 里,会**卡住阻塞**,直到前一个事务提交或回滚——幻读被物理性地堵死了。 + +> 具体锁到哪几个间隙,跟索引类型、WHERE 是等值还是范围、记录是否存在都有关(比如唯一索引上的等值命中会退化成纯 Record Lock,不锁间隙)。想看真实结果别靠背,直接开一个事务执行完语句,另一个窗口查 `performance_schema.data_locks`(见第 14 节)。 + +> 注意:**间隙锁只在 RR 下开启**。切到 RC,间隙锁基本关闭(只剩外键、唯一键冲突检查时用),并发插入不再被阻塞,代价就是幻读回来了,同时死锁概率明显下降。 + +### 7. RR 真的没有幻读了吗?——边角场景 + +InnoDB 在 RR 下靠两条路防幻读: + +- **全程快照读**:复用同一个 Read View,别人新插入的行对我永远不可见 → 不会有幻行。 +- **全程当前读**:`FOR UPDATE` / `UPDATE` / `DELETE` 加 Next-Key Lock,把区间空隙锁住,别人根本插不进来 → 不会有幻行。 + +**问题是「混用」**:普通 SELECT 不加任何锁,它挡不住别人插入;等你后面执行 UPDATE 时走的是当前读,读的是最新已提交数据,就能碰到别人插进来的那行。经典案例: + +```text +时间 → +会话 A (RR) 会话 B (RR) +────────────────────────────────────────────────────────────────── +BEGIN; +SELECT * FROM t WHERE id = 5; → 空集 + (A 在此建立 Read View,且不加锁) + BEGIN; + INSERT INTO t(id,name) VALUES(5,'bob'); + COMMIT; ← 新行已提交,A 的锁压根不存在 +UPDATE t SET name='alice' WHERE id=5; + → Rows matched: 1 Changed: 1 ❗当前读看到了 B 插入的行 +SELECT * FROM t WHERE id = 5; → (5,'alice') ❗幻读出现 +COMMIT; +``` + +纯 SQL 版本,可以直接拿去两个客户端窗口对着跑: + +```sql +-- 建表 + 初始数据(id=5 故意不存在) +CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(20)) ENGINE=InnoDB; + +-- ===== 会话 A ===== +SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; +BEGIN; +SELECT * FROM t WHERE id = 5; -- Empty set,建立 Read View + +-- ===== 会话 B(趁 A 还没提交)===== +SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; +BEGIN; +INSERT INTO t(id, name) VALUES (5, 'bob'); +COMMIT; -- 成功,没人锁这个间隙 + +-- ===== 回到会话 A ===== +UPDATE t SET name = 'alice' WHERE id = 5; -- Rows matched: 1 ❗A 竟然改到了 B 的行 +SELECT * FROM t WHERE id = 5; -- (5, 'alice') ❗幻读:多出来一行 +COMMIT; +``` + +为什么最后一次快照读也能看见?因为 `UPDATE` 是 A 自己执行的,改完这行的 `DB_TRX_ID` 就变成了 A 自己的 ID,命中可见性规则第 ① 条「自己改的永远可见」,于是快照读也能读到它。 + +!!! warning "这个「幻读」算 bug 吗" + 不算,这是 InnoDB 的设计取舍:**UPDATE/DELETE 必须读最新已提交数据**,否则会出现「基于旧快照覆盖别人的写入」这种更严重的丢失更新问题。真正的解法不是抱怨隔离级别,而是:**要读就直接用当前读**(`SELECT ... FOR UPDATE`),把间隙一起锁住;或者用唯一索引 + 捕获重复键错误 + 重试来兜底。 + +同理,「先快照读统计、再 UPDATE」这类写法(比如先 `SELECT COUNT(*) FROM coupon WHERE status=0` 判断库存,再 `UPDATE coupon SET status=1 WHERE status=0 LIMIT 1`)在 RR 下同样可能改到统计时还不存在的行——这也是抢券/秒杀超卖的经典成因之一。 + +### 8. SERIALIZABLE:最强也最少人用 + +SERIALIZABLE 的做法非常粗暴:**把所有普通 SELECT 隐式改成 `SELECT ... LOCK IN SHARE MODE`**,也就是快照读全部退化成当前读,再配合间隙锁把区间锁死。结果就是读和读之间也要排队。 + +| | RR | SERIALIZABLE | +|---|---|---| +| 普通 SELECT | 快照读,走 MVCC,不加锁 | 隐式加共享锁,变成当前读 | +| 两个事务同时读同一行 | 互不阻塞(各读各的快照) | 都能拿到共享锁,不阻塞 | +| 一个读 + 一个写 | 不阻塞(读写并行是 MVCC 的红利) | **互相阻塞** | +| 三大异常 | 脏读/不可重复读禁止,幻读基本防住 | 全部禁止 | +| 并发度 | 高 | 最低,锁等待与死锁明显增多 | + +为什么几乎没人用:它牺牲掉的正是 InnoDB 最值钱的那个特性——**读写不互斥**。真要那么强的保证,用 RR + 精确的 `SELECT ... FOR UPDATE` 只锁你关心的那几行,代价小得多、可控得多。SERIALIZABLE 更适合「一个事务里跑一堆复杂只读查询、要求这几条查询看到的是同一个时刻的世界」这种批处理/报表场景,而且还得接受它拖慢并发写。 + +!!! tip "面试常问:SERIALIZABLE 是「真的串行执行」吗" + 不是。名字叫串行化,实现上是**两阶段锁(读加共享锁、写加排他锁,锁到事务结束才释放)**,事务之间仍然是交错执行的,只是通过锁让最终结果**等价于**某种串行顺序。它不是 PostgreSQL 那种 SSI(可串行化快照隔离,靠检测读写依赖来回滚冲突事务)。 + +### 9. 隔离级别管不到的事:丢失更新 + +一个高频误区:以为把隔离级别调到 RR 就不会有并发问题。**不会。** 隔离级别只定义了三大异常的行为,而「丢失更新」在 RC 和 RR 下都可能发生: + +```sql +-- 账户余额 100,两个会话同时执行「读出来 +10 再写回」 +-- 会话 A -- 会话 B +BEGIN; BEGIN; +SELECT balance FROM acc WHERE id=1; -- 读到 100 + SELECT balance FROM acc WHERE id=1; -- 也读到 100 +UPDATE acc SET balance = 110 WHERE id=1; +COMMIT; + UPDATE acc SET balance = 110 WHERE id=1; + COMMIT; -- A 的 +10 被覆盖了,最终 110 而不是 120 +``` + +即使在 RR 下,B 的 UPDATE 会被 A 的行锁挡住一会儿,但 A 提交后 B 照样把自己算出来的 110 写进去——**B 的业务逻辑是基于旧值算的,隔离级别救不了它**。 + +四种正确写法,按推荐度排序: + +| 方案 | 写法 | 适用 | +|------|------|------| +| **原子更新**(首选) | `UPDATE acc SET balance = balance + 10 WHERE id=1 AND balance + 10 >= 0` | 能用 SQL 表达式搞定就别读出来算,天然无竞态 | +| **悲观锁** | `SELECT balance FROM acc WHERE id=1 FOR UPDATE` 再改 | 需要读出来做复杂判断时;RR 下还能锁间隙防插入 | +| **乐观锁** | `UPDATE acc SET balance=?, version=version+1 WHERE id=1 AND version=?`,检查 affected rows | 冲突少、想避免锁等待;冲突了要重试 | +| **唯一索引** | 靠 `INSERT` 撞 `Error 1062` 判重 | 幂等去重、防重复下单 | + +```go +// 乐观锁的正确姿势:靠 affected rows 判断有没有被别人改过,而不是靠错误 +res, err := tx.ExecContext(ctx, + "UPDATE acc SET balance = ?, version = version + 1 WHERE id = ? AND version = ?", + newBalance, id, oldVersion) +if err != nil { + return err +} +if n, _ := res.RowsAffected(); n == 0 { + return ErrConcurrentUpdate // 版本没对上 = 被别人改过,业务层重试或返回失败 +} +``` + +### 10. 怎么查看和修改隔离级别 + +**查看**(8.0 和 5.7 变量名不同,这是最容易踩的坑): + +```sql +-- MySQL 8.0+ +SELECT @@global.transaction_isolation; -- 实例默认 +SELECT @@session.transaction_isolation; -- 当前连接 +SELECT @@transaction_isolation; -- 等价于 session 那个 +SHOW VARIABLES LIKE 'transaction_isolation'; + +-- MySQL 5.7 及更早(8.0 里 tx_isolation 已被移除,执行会报 Unknown system variable) +SELECT @@tx_isolation; +SELECT @@global.tx_isolation; +``` + +**修改**(三个作用域,语义完全不同): + +```sql +-- ① 只影响「之后新建」的连接,当前连接不变;需要 SYSTEM_VARIABLES_ADMIN/SUPER 权限 +SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; + +-- ② 影响当前连接后续所有事务(连接归还连接池后设置会跟着走,见常见陷阱) +SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; + +-- ③ 只影响「下一个」事务,事务结束后自动恢复 +SET TRANSACTION ISOLATION LEVEL READ COMMITTED; + +-- 顺手还能设只读事务 +SET SESSION TRANSACTION READ ONLY; +``` + +**持久化**要写进配置文件,`SET GLOBAL` 重启就没了: + +```ini +[mysqld] +transaction-isolation = READ-COMMITTED # 8.0 写法;5.7 用 tx-isolation = READ-COMMITTED +binlog_format = ROW # 用 RC 必须配 ROW,见第 12 节 +``` + +Go 侧对应的常量(`database/sql`): + +| `sql.IsolationLevel` | 对应 SQL | +|----------------------|----------| +| `sql.LevelDefault` | 不发送设置语句,用数据库默认(MySQL 即 RR) | +| `sql.LevelReadUncommitted` | `READ UNCOMMITTED` | +| `sql.LevelReadCommitted` | `READ COMMITTED` | +| `sql.LevelRepeatableRead` | `REPEATABLE READ` | +| `sql.LevelSerializable` | `SERIALIZABLE` | + +Go 侧的写法核心是 `sql.TxOptions{Isolation: ...}`,它是**事务级**的,最安全;只有确实需要「整条连接都换个级别」时才用 `db.Conn()` 拿独占连接去 `SET SESSION`: + +```go +// 推荐:事务级指定,只影响这一个事务 +tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + +// 次选:会话级指定,必须自己管好这条独占连接的归还与复原 +func runWithReadCommitted(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { + conn, err := db.Conn(ctx) // 从池里取一条独占连接,用完必须归还 + if err != nil { + return err + } + defer conn.Close() + + // 注意:连接归还后会带着 RC 设置回到池里,污染后续使用者。 + // 严格做法是任务结束前再 SET SESSION 恢复成默认级别。 + if _, err = conn.ExecContext(ctx, + "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"); err != nil { + return err + } + + tx, err := conn.BeginTx(ctx, nil) // 这条连接上的事务就是 RC + if err != nil { + return err + } + defer tx.Rollback() // 提交成功后 Rollback 返回 sql.ErrTxDone,可安全忽略 + + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} +``` + +### 11. 为什么 MySQL 默认 RR,别人默认 RC + +这不是口味问题,是**历史包袱**。MySQL 早年默认的 binlog 格式是 STATEMENT,而 STATEMENT 格式的 binlog 在 RC 下无法保证从库重放得到相同结果(RC 关掉了间隙锁,主从上并发语句的加锁与执行顺序可能不同)。为了让「默认配置就能安全地做主从复制」,MySQL 把默认隔离级别定成了 RR。 + +后来 binlog 默认格式在 5.7.7 之后变成了 ROW,这个约束消失了,RC 才重新变成可行的选项——所以今天很多互联网公司敢把线上改成 RC,前提是 binlog 用 ROW。Oracle 和 PostgreSQL 从一开始就不依赖语句级日志做复制,所以默认 RC 没这个包袱。 + +理解这条脉络,你就能答出「MySQL 为什么默认 RR」这种看起来无厘头的问题:**因为默认的 STATEMENT binlog 配 RC 会破坏主从一致性。** + +### 12. 生产实践:RC 还是 RR? + +这是真实线上会做的决策,也是面试高频追问。 + +| 维度 | READ COMMITTED | REPEATABLE READ | +|------|----------------|-----------------| +| 谁在用 | Oracle / PostgreSQL / SQL Server 默认,很多互联网线上 MySQL 也改成 RC | **MySQL InnoDB 默认** | +| 间隙锁 | 基本关闭 → 锁范围小、锁等待少、**死锁概率低** | 开启 → 范围锁可能锁很大,死锁排查更费劲 | +| UPDATE 行为 | 有半一致读,不匹配的行提前放锁 | 扫到的间隙都要锁 | +| 长事务影响 | 每次读新建 Read View,undo 版本链能被及时 purge | 一个长事务的老 Read View 会**拖住 purge**,undo 膨胀、`SHOW ENGINE INNODB STATUS` 里 history list length 飙升 | +| 幻读防护 | 无(要靠 `FOR UPDATE` 或业务乐观锁) | 有(快照读 + 临键锁),但混用当前读会破功 | +| 语义直觉 | 「读到的就是已提交的最新值」,符合大多数业务代码的直觉 | 「事务内读到的是一致快照」,写对账/批处理时更省心 | +| **binlog 要求** | **必须 `binlog_format = ROW`**(或 MIXED),否则写语句直接报错 | STATEMENT / ROW 都能跑 | + +!!! danger "RC 与 STATEMENT binlog 是不兼容的" + RC 下语句级 binlog 无法保证主从一致(同一条 SQL 在主从上因加锁顺序/半一致读不同,可能产生不同结果),所以 MySQL 会**直接拒绝执行**: + + ```text + ERROR 1665 (HY000): Cannot execute statement: impossible to write to binary log + since BINLOG_FORMAT = STATEMENT and at least one table uses a storage engine + limited to row-based logging. + ``` + + MySQL 5.7.7+ 与 8.0 的 `binlog_format` 默认已是 ROW,但**很多老库、自建镜像、云厂商旧实例仍是 STATEMENT**。改隔离级别前先 `SELECT @@binlog_format;` 确认一遍。 + +**怎么选,判断标准按这个顺序过一遍**: + +| 你的情况 | 建议 | +|----------|------| +| 高并发互联网业务(订单、账户、内容),死锁敏感,写多 | **RC**,配 `binlog_format=ROW`,需要防幻读的地方显式 `SELECT ... FOR UPDATE` 或用版本号乐观锁 | +| 团队沿用 MySQL 默认配置,不想改全局参数,业务里有对账/批量统计这类需要「一致快照」的逻辑 | **RR**(保持默认),代价是注意间隙锁和长事务 | +| 用了 Canal / Flink CDC 等订阅 binlog 的组件 | 必须 ROW 格式;此时 RC 反而更顺手 | +| 强一致要求极端(金融核心账务),能接受吞吐下降 | 别上 SERIALIZABLE(读也加锁,代价太大),用 RR + 显式 `FOR UPDATE` 精确控制锁范围 | +| READ UNCOMMITTED | 任何生产环境都别用 | + +一句总结:**RC 是「用业务代码的显式加锁换并发度」,RR 是「用数据库的自动加锁换省心」。** 两者都能做对,区别在于防幻读的责任落在谁身上——选哪个不重要,重要的是团队里统一一个、并且代码写法与之匹配。 + +### 13. 一起影响并发的几个参数 + +隔离级别不是孤立存在的,下面这几个参数经常和它一起决定线上表现: + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `autocommit` | `ON` | 每条语句自成一个事务。`BEGIN` 会临时关掉它直到 COMMIT/ROLLBACK | +| `transaction_isolation` | `REPEATABLE-READ` | 本文主角 | +| `binlog_format` | `ROW`(5.7.7+/8.0) | RC 必须 ROW,否则写语句报错 | +| `innodb_lock_wait_timeout` | `50` 秒 | 等锁超过这个时间抛 `ERROR 1205 Lock wait timeout exceeded`,只回滚当前语句 | +| `innodb_deadlock_detect` | `ON` | 主动检测死锁并回滚代价小的那个事务,报 `ERROR 1213 Deadlock found` | + +还有一个极易被误解的点:**`BEGIN` 并不会立刻建立 Read View。** + +```sql +-- RR 下这两者的快照时机不同 +BEGIN; +SELECT ...; -- Read View 是在【这里】才建立的,不是 BEGIN 那一刻 + +START TRANSACTION WITH CONSISTENT SNAPSHOT; +-- 上面这句会立刻建立 Read View 并拿到一致性快照,哪怕你一条 SELECT 都还没执行。 +-- 做「多张表必须读同一时刻数据」的对账/日切时用它。 +-- 注意:它只在 RR 下有意义;RC 下每条语句仍会新建 Read View,拿不到跨语句的一致快照。 +``` + +顺带一提:`SELECT ... LOCK IN SHARE MODE` 在 MySQL 8.0 里已标记为过时写法,官方推荐等价但更好读的 `SELECT ... FOR SHARE`,两者行为一致(都加共享锁)。 + +### 14. 线上排查:看事务、看锁、看 undo + +真出事的时候,下面这几条 SQL 比背概念有用得多(②③⑤ 需要 MySQL 8.0): + +```sql +-- ① 现在有哪些事务在跑?重点看 trx_started(跑得久 = 长事务)、 +-- trx_rows_locked(锁了多少行,RR 下范围锁会让这个数字很大) +SELECT trx_id, trx_state, trx_started, + TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec, + trx_mysql_thread_id, trx_rows_locked, trx_rows_modified, + LEFT(trx_query, 120) AS query +FROM information_schema.innodb_trx +ORDER BY trx_started; + +-- ② 谁在等谁的锁?(MySQL 8.0;5.7 用 information_schema.innodb_lock_waits) +SELECT * FROM performance_schema.data_lock_waits\G + +-- ③ 当前到底加了哪些锁、是 Record 还是 Gap/Next-Key? +SELECT engine_transaction_id, object_name, index_name, + lock_type, lock_mode, lock_status, lock_data +FROM performance_schema.data_locks +ORDER BY engine_transaction_id; + +-- ④ 顺手确认一下隔离级别和 binlog 格式(改配置前后都要看一眼) +SELECT @@global.transaction_isolation, @@session.transaction_isolation, @@binlog_format; + +-- ⑤ undo 有没有被长事务拖住(history list length 持续上涨就是信号) +SHOW ENGINE INNODB STATUS\G +``` + +`lock_mode` 字段读法:`X` 是排他锁、`S` 是共享锁;带 `,REC_NOT_GAP` 表示**只锁记录不锁间隙**(Record Lock);带 `,GAP` 表示**只锁间隙**(Gap Lock);不带后缀的 `X`/`S` 就是 Next-Key Lock(记录 + 间隙)。看到一堆 `GAP` 锁基本就说明你跑在 RR 下且有范围查询。 + +--- + +## 💻 代码示例 + +### Go:`database/sql` 指定事务隔离级别 + +```go +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "sort" + + _ "github.com/go-sql-driver/mysql" +) + +// ErrInsufficientBalance 余额不足 +var ErrInsufficientBalance = errors.New("余额不足") + +// transfer 转账:用 RR + SELECT ... FOR UPDATE 锁住两行,避免并发扣款 +func transfer(ctx context.Context, db *sql.DB, from, to int64, amount int) error { + // BeginTx 会驱动 go-sql-driver 发出事务级的 + // "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ", + // 只作用于这一个事务,不会长期改变连接上的 SESSION 设置。 + tx, err := db.BeginTx(ctx, &sql.TxOptions{ + Isolation: sql.LevelRepeatableRead, // 可选:LevelReadCommitted / LevelSerializable ... + ReadOnly: false, // true 时驱动会发 SET TRANSACTION READ ONLY + }) + if err != nil { + return fmt.Errorf("开启事务失败: %w", err) + } + defer tx.Rollback() // 提交成功后再 Rollback 只会返回 sql.ErrTxDone,可安全忽略 + + // 关键:按固定顺序(id 升序)加锁,避免两个并发转账互相持锁等待造成死锁 + ids := []int64{from, to} + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + + balance := map[int64]int{} + for _, id := range ids { + var b int + // 当前读:FOR UPDATE 会对这一行加排他锁,别的事务改不了也删不了 + if err := tx.QueryRowContext(ctx, + "SELECT balance FROM accounts WHERE id = ? FOR UPDATE", id).Scan(&b); err != nil { + return fmt.Errorf("锁定账户 %d 失败: %w", id, err) + } + balance[id] = b + } + + if balance[from] < amount { + return ErrInsufficientBalance + } + + if _, err := tx.ExecContext(ctx, + "UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from); err != nil { + return fmt.Errorf("扣款失败: %w", err) + } + if _, err := tx.ExecContext(ctx, + "UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to); err != nil { + return fmt.Errorf("入账失败: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交失败: %w", err) + } + return nil +} + +func main() { + dsn := "app:pass@tcp(127.0.0.1:3306)/demo?parseTime=true&charset=utf8mb4" + db, err := sql.Open("mysql", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := transfer(context.Background(), db, 1001, 1002, 50); err != nil { + log.Fatal(err) + } +} +``` + +### GORM 版本 + +```go +// 需要 import "database/sql" / "gorm.io/gorm/clause" / "time" +// GORM 的 Begin 直接接受 *sql.TxOptions +func payOrder(ctx context.Context, db *gorm.DB, orderID uint64) error { + tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if tx.Error != nil { + return tx.Error + } + + // 出错就回滚(包括 panic) + defer func() { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } + }() + + // 当前读:Clauses(clause.Locking{Strength: "UPDATE"}) 会拼出 FOR UPDATE + var order Order + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + First(&order, orderID).Error; err != nil { + tx.Rollback() + return err + } + if order.Status != StatusUnpaid { + tx.Rollback() + return ErrOrderAlreadyPaid // 幂等保护:状态不对直接退出 + } + + if err := tx.WithContext(ctx).Model(&order). + Updates(map[string]any{"status": StatusPaid, "paid_at": time.Now()}).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} +``` + +--- + +## ⚠️ 常见陷阱 + +!!! warning "陷阱一:以为 RR 完全杜绝幻读" + **现象**:RR 下先普通 `SELECT` 判断某行不存在,再 `UPDATE` / `INSERT`,结果 UPDATE 竟然影响到了 1 行,或者 SELECT 之后又冒出一行新数据。 + **原因**:普通 SELECT 是快照读,**不加任何锁**,挡不住别的事务往这个区间插入;等你执行 UPDATE 时走的是当前读,读的是最新已提交数据,自然能看到那行新数据。而且这行被你改过后带上了你自己的 `trx_id`,之后的快照读也可见了——幻读就这么破功了。 + **解法**:要「判断存在性再操作」的场景,第一次读就用 `SELECT ... FOR UPDATE`(RR 下会连带锁住间隙,别人插不进来);或者依赖唯一索引 + 捕获 `Error 1062` 重复键 + 重试;或者干脆用版本号乐观锁 `UPDATE ... WHERE id=? AND version=?`,靠 affected rows 判断成败。 + +!!! warning "陷阱二:改隔离级别只改了 SESSION,或者忘了 binlog_format=ROW" + **现象 A**:`SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;` 跑通了,上线后大部分事务还是 RR。 + **原因 A**:SESSION 级设置**只对当前这一条连接生效**。Go 的连接池有几十条连接,你改的只是恰好被复用的那一条;而且连接归还池后设置还会「污染」后续使用者,导致同一服务里不同请求跑在不同级别下,问题极难复现。 + **解法 A**:单事务指定用 `db.BeginTx(ctx, &sql.TxOptions{Isolation: ...})`;要全实例生效就改配置文件 `[mysqld] transaction-isolation = READ-COMMITTED` 并重启(`SET GLOBAL` 只影响之后新建的连接,且重启丢失)。 + **现象 B**:改成 RC 之后,写操作直接报 `ERROR 1665 ... BINLOG_FORMAT = STATEMENT`,或者更糟——主从数据悄悄不一致。 + **原因 B**:RC 与 STATEMENT 格式 binlog 不兼容,从库重放同一条语句可能得到不同结果。 + **解法 B**:改隔离级别之前先 `SELECT @@binlog_format;`,确保是 `ROW`;主库改完还要检查所有从库和中转实例的配置一致。 + +!!! warning "陷阱三:把「不可重复读」和「幻读」搞混" + **现象**:面试或代码评审时说「RC 下会幻读,所以同一行两次读值不同」——概念错位。 + **原因**:两者的区分点不在「读几次」,而在**变的是什么**: + + - 不可重复读 = 同一**行**的**值**变了,元凶是别人 `UPDATE` 后提交; + - 幻读 = 同一条件查出的**行数**变了,元凶是别人 `INSERT` / `DELETE` 后提交。 + + **解法**:记住「UPDATE 改值 → 不可重复读;INSERT/DELETE 改行数 → 幻读」。防前者锁住**已存在的行**(Record Lock 就够),防后者必须锁住**还不存在的空隙**(Gap Lock / Next-Key Lock),这也是为什么 RC 关掉间隙锁之后幻读就回来了。 + +!!! warning "陷阱四:RR 下的长事务把 undo 撑爆" + **现象**:`SHOW ENGINE INNODB STATUS` 里 `History list length` 持续增长,磁盘上涨、查询变慢。 + **原因**:RR 下一个长事务持有早期的 Read View,purge 线程就不能清理比它更老的 undo 版本,版本链越拖越长,快照读要回溯的版本越来越多。 + **解法**:事务里**不要夹 HTTP 调用、不要夹人工审批、不要跑大批量循环**;`BEGIN` 之后尽快做完尽快 `COMMIT`;监控 `information_schema.innodb_trx` 里 `trx_started` 很久的事务并告警。 + +--- + +## 🏋️ 练习题 + +??? question "练习 1:MySQL 默认 RR 下,会话 A 执行 `SELECT * FROM t WHERE id=5` 返回空集,此时会话 B 插入 id=5 并提交,然后 A 执行 `UPDATE t SET name='x' WHERE id=5` 会影响 1 行。这算幻读吗?为什么 RR 没挡住?怎么修?" + 提示:想清楚普通 SELECT 和 UPDATE 分别走的是哪条读路径、有没有加锁。 + + ??? success "答案" + **算幻读**,而且是 RR 下最经典的破功场景。 + + 原因:RR 的幻读防护有两条腿——快照读靠 MVCC 复用 Read View,当前读靠 Next-Key Lock 锁间隙。但**普通 SELECT 是快照读,不加任何锁**,所以它既看不到新行,也拦不住别人插入新行。等 A 执行 `UPDATE` 时,这是**当前读**,必须读最新已提交数据,于是命中了 B 插入的那一行;而这一行被 A 改过之后,它的 `DB_TRX_ID` 变成 A 自己的事务 ID,按可见性规则「自己改的永远可见」,A 后面再用普通 SELECT 也能查到它——幻读就此显形。 + + 修法(三选一): + 1. 第一次读就用当前读:`SELECT * FROM t WHERE id=5 FOR UPDATE`,RR 下会连带锁住间隙,B 的 INSERT 会被阻塞到 A 提交; + 2. 靠唯一索引兜底:直接 INSERT,捕获 `Error 1062` 重复键后走更新或重试分支; + 3. 乐观锁:`UPDATE t SET ... WHERE id=5 AND version=?`,用 affected rows 判断是否被别人动过。 + +??? question "练习 2:不可重复读和幻读到底差在哪?在 READ COMMITTED 下,`SELECT COUNT(*) FROM orders WHERE uid=1` 在同一事务里执行两次结果不同,这属于哪一种?" + 提示:先看变的是「值」还是「行数」,再看是谁的 SQL 造成的。 + + ??? success "答案" + 核心区别是**变化的对象不同**: + + | | 不可重复读 | 幻读 | + |---|---|---| + | 变化 | 同一行的**值** | 结果集的**行数** | + | 元凶 | 别人 `UPDATE` + COMMIT | 别人 `INSERT` / `DELETE` + COMMIT | + | 防护手段 | Record Lock 锁住已存在的行 | Gap Lock / Next-Key Lock 锁住空隙 | + + `COUNT(*)` 两次结果不同,说明**行数**变了,因此属于**幻读**(如果两次查同一行 `SELECT price FROM orders WHERE id=1` 值变了,那才是不可重复读)。 + + 在 RC 下这很正常:RC 只禁止脏读,每次普通 SELECT 都新建 Read View,能看到别人最新提交的插入,同时 RC 下间隙锁基本关闭,`FOR UPDATE` 也锁不住空隙,所以幻读完全可能发生。要在 RC 下避免它,得自己在业务层用唯一约束、乐观锁,或者把这个事务单独提到 RR(`db.BeginTx` 里指定 `sql.LevelRepeatableRead`)并用 `SELECT ... FOR UPDATE` 锁区间。 + +??? question "练习 3:你要把线上 MySQL 的默认隔离级别从 RR 改成 RC,上线前的检查清单有哪些?改完能拿到什么好处、要自己补上什么?" + 提示:至少涉及 binlog、连接池、代码里的加锁写法、从库一致性四件事。 + + ??? success "答案" + **上线前检查**: + + 1. `SELECT @@binlog_format;` 必须是 `ROW`(或 MIXED)。RC 与 STATEMENT 不兼容,否则写语句直接报 `ERROR 1665`,或者主从数据悄悄不一致。 + 2. 主库、所有从库、以及做备份/订阅 binlog 的实例(Canal、Flink CDC、DTS)配置要一致,避免中途某台还是 RR。 + 3. 持久化方式:写进 `[mysqld] transaction-isolation = READ-COMMITTED` 后重启,而不是只 `SET GLOBAL`(重启即丢,且只影响新建连接);5.7 上变量名是 `tx-isolation`。 + 4. 代码扫描:搜出所有依赖「事务内多次读结果一致」的逻辑(对账、批量统计、先查再改),这些地方在 RC 下会读到别人新提交的值。 + + **好处**:间隙锁基本关闭 → 锁范围小、锁等待少、死锁概率显著下降;UPDATE 有半一致读,不匹配的行提前放锁;Read View 生命周期短,purge 更及时,长事务不容易把 undo 撑爆。整体并发吞吐更高。 + + **要自己补上的**:RC 不防幻读也不防不可重复读,凡是「先查后改」的关键路径必须显式加锁或加版本控制——`SELECT ... FOR UPDATE`(RC 下只锁命中行,不锁间隙)、唯一索引 + 重复键重试、或 `WHERE ... AND version=?` 的乐观锁,并用 affected rows 判断是否生效。 + +--- + +## 🔗 相关链接 + +- [MySQL 8.0 · Transaction Isolation Levels](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html) — 官方对四级隔离的定义与 InnoDB 实现说明 +- [MySQL 8.0 · Consistent Nonlocking Reads](https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html) — 快照读与 Read View 的官方描述 +- [MySQL 8.0 · Locks Set by Different SQL Statements](https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html) — 各类语句加的 Record / Gap / Next-Key Lock +- [MySQL 8.0 · Binary Log Format](https://dev.mysql.com/doc/refman/8.0/en/binary-log-setting.html) — `binlog_format` 与 RC 的兼容性约束 +- [PostgreSQL · Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html) — 对比 PG 默认 RC 及其 SSI 实现 +- [Go pkg · database/sql TxOptions](https://pkg.go.dev/database/sql#TxOptions) — `Isolation` 与 `ReadOnly` 字段 +- [go-sql-driver/mysql](https://github.com/go-sql-driver/mysql) — `BeginTx` 如何把隔离级别翻译成 SQL +- [GORM · Transactions](https://gorm.io/docs/transactions.html) — `db.Begin(&sql.TxOptions{...})` 用法 +- [多级缓存与读写策略](../../architecture/cache/cache-multilevel-read-write.md) — 缓存与 DB 之间的一致性问题,常和事务隔离一起被问 diff --git a/docs/database/redis/cluster.md b/docs/database/redis/cluster.md new file mode 100644 index 0000000..f881934 --- /dev/null +++ b/docs/database/redis/cluster.md @@ -0,0 +1,512 @@ +# Redis Cluster 集群 + +!!! note "💡 一句话概述" + 哨兵解决了"主挂了谁来顶",但写能力和容量仍卡在单机上;Redis Cluster 用 **16384 个哈希槽把数据切片分散到多个主节点**(分片),同时每个主节点带从节点、节点间用 Gossip 互相心跳(去中心化高可用),是 Redis 官方的水平扩展方案。 + +--- + +## 🔑 核心概念 + +1. **分片(Sharding)** — 数据按 key 切分到多个主节点,每个主节点只存一部分,容量和写能力随节点数线性扩展。 +2. **哈希槽(Hash Slot)** — 固定 16384 个槽,`slot = CRC16(key) mod 16384`,槽是分片和数据迁移的最小单位。 +3. **去中心化** — 没有 proxy、没有中心元数据服务,每个节点都保存完整的"槽 → 节点"映射表,节点间用 Gossip 协议(集群总线端口 = 客户端端口 + 10000)交换状态。 +4. **MOVED / ASK 重定向** — 客户端访问错节点时服务端返回的重定向错误:MOVED 表示槽已永久搬家(客户端该更新映射),ASK 表示槽正在迁移中(只此一次,去那边试试)。 +5. **hash tag** — key 里带 `{...}` 时只用大括号内的内容算槽位,强制多个 key 落在同一槽,解决集群下多 key 操作 / 事务 / Lua 的同槽限制。 + +--- + +## 📝 详解 + +### 1. 为什么需要 Cluster:哨兵没解决的两个问题 + +先回顾一下演进路线。单机 Redis 挂了怎么办?加从节点 + 哨兵(Sentinel)——哨兵负责监控、自动故障转移,**高可用**解决了。但还有两个问题解决不了: + +| 问题 | 单机 + 哨兵 | 原因 | +|------|:---:|------| +| 高可用(主挂了自动切) | ✅ 已解决 | 哨兵负责选新主 | +| **容量扩展** | ❌ | 所有数据都在一台主节点上,内存就那么大(比如单机 32G 到头了) | +| **写能力扩展** | ❌ | 写只能打到唯一的主节点,从节点只读,单核单线程的写上限卡死 | + +大白话:哨兵模式就像**一个仓库配了几个备份仓库**——仓库烧了能顶上,但一个仓库的货架总量、装卸货速度还是那么多。Cluster 的思路是**多开几个仓库,每个仓库存一部分货**——货架总量和装卸能力都上去了。 + +``` +哨兵模式(一主多从,数据全量复制): + + 写 ──► [主节点:全部数据] + │ 全量复制 + ┌─────┴─────┐ + ▼ ▼ + [从节点1] [从节点2] ← 只读,分担读压力 + (哨兵进程在旁边盯着谁挂了) + +Cluster 模式(数据分片,每片自带副本): + + 写 key A/B ──► [主1:槽 0~5460]──► [从1] + 写 key C/D ──► [主2:槽 5461~10922]──► [从2] + 写 key E/F ──► [主3:槽 10923~16383]──► [从3] +``` + +所以 **Cluster = 分片(解决容量 + 写扩展)+ 去中心化的高可用(每个主带从,挂了从顶上)**。 + +### 2. 哈希槽:16384 个抽屉 + +Cluster 不是简单地把 key 按 `hash(key) mod N` 分到 N 台机器(那样加减机器要重算几乎所有 key),而是引入了一个中间层——**哈希槽(hash slot)**: + +> 大白话:把整个数据空间想象成 **16384 个抽屉**。每个 key 用 CRC16 算出一个数,mod 16384 得到"该放哪个抽屉";每台机器(主节点)负责管一批抽屉。加减机器只是**搬抽屉**,不用重算每个 key。 + +``` +slot = CRC16(key) mod 16384 + +key "user:1001" ──CRC16──► 22096 ──mod 16384──► 槽 5712 ──► 落在主节点 2 +key "order:88" ──CRC16──► 17876 ──mod 16384──► 槽 1492 ──► 落在主节点 1 +key "goods:1001" ──CRC16──► 27673 ──mod 16384──► 槽 11289 ──► 落在主节点 3 + +槽分配(3 主均分 16384): +┌──────────────┬──────────────┬──────────────┐ +│ 槽 0~5460 │ 槽 5461~10922│槽 10923~16383│ +│ 主节点1 │ 主节点2 │ 主节点3 │ +└──────────────┴──────────────┴──────────────┘ +``` + +槽的好处是**迁移以槽为单位**:要把节点 3 上的一部分数据挪走,只需把某几千个槽(连同槽里的 key)迁到别的节点,其余槽完全不动。 + +#### 为什么是 16384(2^14)而不是 65536? + +这是面试高频题,作者 antirez 在 GitHub issue 里给过官方解释,核心两点: + +| 原因 | 说明 | +|------|------| +| **心跳包体积** | 节点间 Gossip 心跳(PING/PONG)要携带自己的槽位图(bitmap)。16384 bit = **2KB**;如果用 65536 bit = **8KB**,心跳包太大,浪费带宽 | +| **节点数上限** | Redis 官方建议集群节点数**不超过 1000 个**。16384 个槽分给 1000 个节点,每个节点平均还有 16 个槽,粒度完全够用,再多没有意义 | + +一句话:**16384 是"槽位图 2KB 能塞进心跳包"和"够 1000 个节点分"之间的折中**。 + +### 3. 去中心化:Gossip 协议 + +Cluster 没有中心节点、没有 proxy、也没有像 ZooKeeper 那样的元数据服务: + +- **每个节点都保存全部 16384 个槽的映射表**(哪个槽归谁),所以客户端问任何一个节点,它都知道"你这个 key 该找谁"。 +- 节点之间用 **Gossip 协议**通信:每个节点随机挑几个节点定期发消息,消息像八卦一样扩散,最终全集群收敛到一致视图。 + +Gossip 的四种消息类型: + +| 消息 | 作用 | +|------|------| +| **MEET** | 通知新节点加入集群(`redis-cli --cluster create` 时发送) | +| **PING** | 定期探测对方状态,附带自己已知的部分节点信息(含槽位图) | +| **PONG** | 回应 PING / MEET,同样携带节点信息 | +| **FAIL** | 某节点被判定客观下线后,向**全集群广播**(这个不是随机的,是广播) | + +Gossip 走的不是客户端端口,而是专门的**集群总线(cluster bus)**: + +``` +集群总线端口 = 客户端端口 + 10000 + +客户端命令: 应用 ──► 6379 (RESP 协议) +节点间通信: 节点A ◄──► 16379 节点B (二进制 Gossip 协议) + +⚠️ 部署时防火墙要同时放行两个端口(6379 和 16379), + 只开 6379 是最常见的"集群起不来"原因之一。 +``` + +#### 对比代理方案 + +| | Redis Cluster | Codis | Twemproxy | +|---|---|---|---| +| 架构 | 去中心化,客户端直连节点 | 中心化 proxy + ZooKeeper 存元数据 | 轻量 proxy | +| 数据迁移 | 支持(在线槽迁移) | 支持 | ❌ 不支持 | +| 高可用 | 内置(从节点自动顶上) | 依赖哨兵/自身组件 | ❌ 无 | +| 额外组件 | 无 | proxy、dashboard、ZK | 仅 proxy | +| 客户端要求 | 必须支持 cluster 协议 | 基本透明 | 基本透明 | + +Twemproxy 是 Twitter 的早期方案(只能分片、不能迁移、无高可用),Codis 是豌豆荚的方案(功能全但要维护 proxy + ZK 一套组件)。Redis 3.0 官方推出 Cluster 后,这两个方案基本退出历史舞台。 + +### 4. MOVED 与 ASK:两种重定向(重点) + +客户端本地缓存了槽映射表,但映射会变(迁移槽、故障转移)。访问错节点时,服务端返回两种重定向错误,**语义完全不同**: + +#### MOVED:槽已经永久搬家 + +``` +$ redis-cli -c GET user:1001 +客户端 ──GET user:1001──► 节点A(按旧映射) +节点A:槽 5712 已经不归我管了 + ◄──MOVED 5712 192.168.1.2:6379── + +客户端行为: + 1. 更新本地槽映射:槽 5712 → 节点B(192.168.1.2:6379) + 2. 把请求重发给节点B + 3. 以后再访问槽 5712 的 key,直接找节点B(不再经过A) +``` + +#### ASK:槽正在搬家中,这次你去那边问问 + +槽迁移过程中,槽里的一部分 key 已经搬到目标节点、一部分还在源节点。此时: + +``` +槽 5712 迁移中:源节点A ────搬迁────► 目标节点B + +客户端 ──GET user:1001──► 节点A +情况1:key 还没搬走 → A 直接返回数据 ✅ +情况2:key 已经搬走 → A 返回 ASK 5712 节点B + 客户端重发(先 ASKING,再 GET)到 B + ⚠️ 不更新本地映射!槽的归属还没最终变更, + 下次这个槽的请求仍然先发给 A +``` + +#### 一张表总结区别 + +| | MOVED | ASK | +|---|---|---| +| 含义 | 槽**已永久**属于另一节点 | 槽**正在迁移**,部分 key 已搬走 | +| 客户端是否更新映射 | ✅ 更新,以后直连新节点 | ❌ 不更新,仅本次请求去目标节点 | +| 触发时机 | 迁移完成后 / 故障转移后 | 迁移进行中 | +| 重发前是否要先发 ASKING | 不需要 | 需要(目标节点默认不接这个槽的请求) | + +#### smart client 与 redis-cli + +- **smart client**(go-redis、Jedis Cluster、redis-py-cluster 等):启动时通过 `CLUSTER SLOTS` / `CLUSTER SHARDS` 拉取全量槽映射缓存在本地,正常请求**直连目标节点,零重定向开销**;收到 MOVED/ASK 自动跟随并重刷映射。 +- **redis-cli 默认不是 cluster 模式**,访问错节点只会把 MOVED 当普通错误打印出来。必须加 `-c` 参数才会自动跟随重定向: + +```bash +# 不加 -c:报错给你看 +$ redis-cli -h 127.0.0.1 -p 6379 GET user:1001 +(error) MOVED 5712 192.168.1.2:6379 + +# 加 -c:自动跳转 +$ redis-cli -c -h 127.0.0.1 -p 6379 GET user:1001 +-> Redirected to slot [5712] located at 192.168.1.2:6379 +"zhangsan" +``` + +### 5. hash tag:强制多个 key 同槽 + +集群下有个硬限制:**多 key 操作(MSET、MGET、事务 MULTI/EXEC、Lua 脚本 EVAL)要求所有 key 在同一个槽**,否则报错: + +``` +(error) CROSSSLOT Keys in the request don't hash to the same slot +``` + +原因很直白:两个 key 在不同节点上,Redis 没法跨节点保证一次操作的原子性,干脆拒绝。 + +**hash tag** 是官方给的解法:key 中如果包含 `{...}`,则**只用大括号内的内容计算槽位**: + +``` +普通 key: seckill:1001:stock → CRC16("seckill:1001:stock") 算槽 +hash tag: seckill:{1001}:stock → 只用 CRC16("1001") 算槽 + +规则细节: +- 取第一对完整 {} 内的内容;{} 为空(如 "foo{}bar")则按整个 key 算 +- 只认第一对大括号:"{a}x{b}" → 用 "a" 算槽 +``` + +秒杀场景的例子(与《高并发秒杀系统设计》一文保持一致)——把同一商品的库存 key 和用户已购标记 key 绑到同一槽: + +``` +seckill:{1001}:stock ┐ + ├─ 都只按 CRC16("1001") = 48159 算槽 +seckill:{1001}:bought:123 ┘ → 槽 15391 → 同一槽 → 同一节点 + +Lua 脚本同时操作这两个 key:✅ 原子执行,不报 CROSSSLOT +不加 hash tag:seckill:1001:stock 和 seckill:1001:bought:123 + 按整串算槽 → 大概率不同槽 → ❌ CROSSSLOT +``` + +!!! warning "hash tag 滥用会导致数据倾斜" + 如果把大量 key 都打上同一个 tag(比如全部 `{seckill}`),这些 key 会全部挤进同一个槽、落在同一个主节点上——**分片等于白做**,该节点内存和 CPU 被打爆,其他节点闲着。正确姿势是 tag 取"业务实体 ID"(如商品 ID),让不同实体自然散列到不同槽;只有确实需要一起操作的 key 才共享 tag。 + +### 6. 集群搭建 + +#### 节点配置(redis.conf) + +```conf +# ========== Cluster 核心配置 ========== +cluster-enabled yes # 开启集群模式(节点以 cluster node 身份启动) +cluster-config-file nodes.conf # 集群状态文件,由 Redis 自动维护,不要手改 +cluster-node-timeout 15000 # 节点失联判定超时(毫秒),超时进 PFAIL 流程 + +# ========== 持久化(集群下仍建议开 AOF)========== +appendonly yes # 集群的故障转移是异步复制顶替,AOF 能减少丢数据窗口 +appendfsync everysec + +# 可选:槽迁移时容忍脏数据比例,控制迁移对写入的影响 +# cluster-migration-barrier 1 # 主节点至少留 1 个从,多余的从可迁去无从的主 +# cluster-require-full-coverage no # 部分槽不可用时,其余槽是否继续服务 +``` + +注意两点: + +- `nodes.conf` 是节点自己读写的集群元数据(记录节点 ID、槽归属、纪元),**由 Redis 自动维护,人工不要编辑**。 +- 端口放行要成对:客户端端口(如 6379)+ 集群总线端口(16379)。 + +#### 创建集群 + +```bash +# 6 个节点(3 主 3 从)一键建集群,--cluster-replicas 1 表示每个主配 1 个从 +$ redis-cli --cluster create \ + 192.168.1.1:6379 192.168.1.2:6379 192.168.1.3:6379 \ + 192.168.1.4:6379 192.168.1.5:6379 192.168.1.6:6379 \ + --cluster-replicas 1 + +[CLUSTERINFO] ft_state=ok ... +Can I set the above configuration? (type 'yes' to accept): yes +[OK] All 16384 slots covered. +``` + +为什么**最少 3 主**?故障转移选举需要"多数派主节点投票"(见下一节)。2 个主节点时,挂掉 1 个,剩 1 个凑不齐多数派(2/2+1),选举必然失败;3 主挂 1 个,剩 2 个刚好是多数派(2 ≥ 3/2+1)。所以 **3 主 3 从是能自动故障转移的最小生产拓扑**。 + +#### 查看集群状态 + +```bash +# 节点列表:节点ID、角色(master/slave/myself)、地址、槽区间 +$ redis-cli -c cluster nodes +0f3e2b... 192.168.1.1:6379@16379 myself,master - 0 ... connected 0-5460 +8a1c4d... 192.168.1.2:6379@16379 master - 0 ... connected 5461-10922 +c22ef1... 192.168.1.3:6379@16379 master - 0 ... connected 10923-16383 +5d9b3a... 192.168.1.4:6379@16379 slave 0f3e2b... 0 ... connected +... + +# 槽分布:每段槽 → 负责的主节点及其从节点 +$ redis-cli cluster slots +1) 1) (integer) 0 + 2) (integer) 5460 + 3) 1) "192.168.1.1" + 2) (integer) 6379 + ... + +# 集群整体健康:状态、节点数、槽覆盖 +$ redis-cli cluster info +cluster_state:ok +cluster_slots_assigned:16384 +cluster_known_nodes:6 +cluster_size:3 +``` + +常用运维命令还有:`redis-cli --cluster check `(检查槽覆盖与迁移状态)、`--cluster reshard`(在线迁移槽)、`--cluster add-node` / `del-node`(增删节点)、`--cluster rebalance`(重新均衡槽)。 + +### 7. 故障转移:集群内置的"哨兵" + +哨兵模式需要额外部署哨兵进程来做故障判定和选举;**Cluster 把这套机制内置了**——每个节点都参与监控和投票,不需要任何外部进程。 + +完整流程四步: + +1. **PFAIL(主观下线)**:节点 A 在 `cluster-node-timeout` 内 PING 不通节点 B,就在自己的视图里把 B 标记为 PFAIL。这只是 A 的一家之言(可能只是 A 和 B 之间网络抖了)。 +2. **FAIL(客观下线)**:A 通过 Gossip 传播 PFAIL 标记;当**半数以上的主节点**都在超时窗口内标记了 B 为 PFAIL,B 被升级为 FAIL——这是集群共识。标记 FAIL 的节点会向**全集群广播 FAIL 消息**,所有节点立刻知道 B 客观下线。 +3. **选举**:B 的从节点们发起选举,请求其他主节点投票。每个主节点在**一个配置纪元(configuration epoch)内只有一票**(先到先得,类似 Raft 的 term)。从节点获得**多数派主节点**投票即胜出;若多个从同时竞选,纪元 +1 重来一轮。 +4. **接管**:胜出的从节点执行 `slaveof no one` 提升为主,接管原主的槽范围,并以新纪元 PONG 广播出去,客户端和其他节点随之更新映射。 + +```mermaid +sequenceDiagram + participant M2 as 主节点2(故障) + participant M1 as 主节点1 + participant M3 as 主节点3 + participant S2 as 从节点2(M2 的从) + + Note over M1,M3: ① 各节点 PING M2 超时
分别标记 PFAIL(主观下线) + M1-->>M3: Gossip:我这看 M2 是 PFAIL + Note over M1,M3: ② 半数以上主节点都报 PFAIL
→ M2 升级为 FAIL(客观下线) + M1->>S2: 广播 FAIL 消息 + Note over S2: ③ S2 发起选举
(延迟 = 复制偏移越新延迟越短,数据新的先选) + S2->>M1: 请求投票(epoch N) + S2->>M3: 请求投票(epoch N) + M1-->>S2: 投票给 S2(本纪元仅一票) + M3-->>S2: 投票给 S2 + Note over S2: 获得 2/3 多数派 → 胜出 + Note over S2: ④ slaveof no one 提升为主
接管 M2 的槽(5461~10922) + S2->>M1: PONG 广播新主身份与新槽映射 + S2->>M3: PONG 广播 + Note over M1,M3: 集群恢复正常
客户端收到 MOVED 后更新映射 +``` + +#### 与哨兵故障转移的对比 + +| | 哨兵模式 | Cluster | +|---|---|---| +| 故障判定/选举由谁做 | 独立部署的哨兵进程 | 集群节点自己(内置) | +| 额外组件 | 需要 ≥3 个哨兵进程 | 无 | +| 投票权 | 哨兵投票 | **主节点**投票(每纪元一票) | +| 新主接管什么 | 全部数据(全量副本) | 只接管故障主的**槽** | +| 数据丢失风险 | 异步复制,可能丢 | 异步复制,同样可能丢 | + +一个容易混淆的点:**Cluster 节点数下限是 3 主**(为了多数派投票),哨兵部署下限也是 3 个哨兵进程,但原因都是"多数派"——只是投票主体一个是主节点、一个是哨兵。 + +### 8. 集群的局限(重点) + +Cluster 不是银弹,上生产前这些坑要心里有数: + +| # | 局限 | 说明 | 应对 | +|---|------|------|------| +| ① | **多 key 操作受同槽限制** | MSET/MGET、MULTI/EXEC 事务、EVAL Lua 涉及的所有 key 必须在同一槽,否则 `CROSSSLOT` 报错 | hash tag 绑定同槽;或业务上拆开单 key 操作 | +| ② | **不支持多数据库** | 只有 db0,`SELECT` 命令在集群模式下直接报错,单机时代的 `SELECT 1` 习惯全废 | 用 key 前缀做逻辑隔离 | +| ③ | **数据倾斜与大 key** | hash tag 滥用或 key 分布不均 → 某槽/某节点数据暴涨;且**单个 key 永远只在一个槽一个节点上**,无法再拆 | tag 设计要散列;大 key 业务侧拆成多个子 key;热点 key 上本地缓存 | +| ④ | **异步复制,故障转移可能丢数据** | 主节点写入后异步复制给从;主挂掉瞬间未同步的写会丢 | 开 AOF、设置 `min-replicas-to-write 1`(至少 1 个从确认才算写成功,牺牲部分可用性换数据)| +| ⑤ | **节点数不宜过多** | 官方建议 ≤ 1000:节点越多 Gossip 心跳流量越大(每节点定期互相发消息),收敛越慢 | 一般 3~几十主足够;真到千级考虑业务拆分多套集群 | +| ⑥ | **客户端必须支持 cluster 协议** | 要会解析槽映射、跟随 MOVED/ASK;老客户端、部分简单封装库直接用不了 | go-redis、Jedis Cluster 等主流库都已支持;用前确认版本 | + +### 9. 选型:单机 vs 哨兵 vs Cluster + +| | 单机 | 哨兵(主从) | Cluster | +|---|---|---|---| +| 容量扩展 | ❌ 单机内存上限 | ❌ 每台都是全量数据 | ✅ 分片,加主节点即扩容 | +| 写扩展 | ❌ | ❌ 写仍集中在唯一主节点 | ✅ 多主同时写 | +| 读扩展 | ❌ | ✅ 从节点分担读 | ✅ 各片的从节点分担读 | +| 高可用 | ❌ 挂了就挂 | ✅ 哨兵自动切换 | ✅ 内置选举自动切换 | +| 多 key/事务/Lua | ✅ 无限制 | ✅ 无限制 | ⚠️ 受同槽限制 | +| 多数据库 SELECT | ✅ db0~15 | ✅ db0~15 | ❌ 只有 db0 | +| 运维复杂度 | 最低 | 中(要部署哨兵) | 最高(分片、迁移、均衡) | +| 客户端要求 | 无 | 需支持哨兵发现 | **必须支持 cluster 协议** | +| 适用规模 | 开发测试 / 小数据量 | 数据单机放得下,要高可用 | 数据量或写 QPS 超单机上限 | + +**选型口诀**:单机放得下 → 哨兵;放不下或写不动 → Cluster。别为了"显得架构先进"给 2GB 数据上 Cluster——分片的复杂度(CROSSSLOT、hash tag、倾斜、迁移)是实打实的成本。秒杀场景如果单商品库存数据量很小,一个哨兵集群 + 本地缓存往往就够了。 + +--- + +## 💻 代码示例 + +### Go:go-redis v9 连接集群 + +```go +package main + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +func main() { + // NewClusterClient:go-redis v9 的集群客户端 + // 它就是一个 smart client:启动时拉取槽映射缓存在本地, + // 请求直连目标节点,收到 MOVED/ASK 自动跟随并刷新映射 + rdb := redis.NewClusterClient(&redis.ClusterOptions{ + Addrs: []string{ + "192.168.1.1:6379", + "192.168.1.2:6379", + "192.168.1.3:6379", // 写全部主节点地址,客户端会自动发现其余节点 + }, + // 只把从节点用于读,写仍走主节点 + RouteByLatency: true, // 按延迟路由 + MaxRedirects: 3, // MOVED/ASK 最大跟随次数,防死循环 + DialTimeout: 2 * time.Second, + ReadTimeout: time.Second, + PoolSize: 50, // 每个节点的连接池大小 + }) + + ctx := context.Background() + + // 单 key 操作:客户端算好槽位直连对应节点 + if err := rdb.Set(ctx, "user:1001", "zhangsan", time.Hour).Err(); err != nil { + panic(err) + } + val, err := rdb.Get(ctx, "user:1001").Result() + if err != nil { + panic(err) + } + fmt.Println(val) // zhangsan + + // ⚠️ 多 key 操作:两个 key 不同槽会报 CROSSSLOT + // rdb.MSet(ctx, "seckill:1001:stock", 100, "seckill:1001:bought:123", 1) + // → error: CROSSSLOT Keys in the request don't hash to the same slot + + // ✅ 用 hash tag 绑定同槽后,多 key 操作合法 + rdb.MSet(ctx, "seckill:{1001}:stock", 100, "seckill:{1001}:bought:123", 0) + + // 查看某个 key 落在哪个槽(CLUSTER KEYSLOT,服务端按 hash tag "1001" 计算) + slot, err := rdb.ClusterKeySlot(ctx, "seckill:{1001}:stock").Result() + if err == nil { + fmt.Println("slot:", slot) // 库存 key 与 bought key 算出的槽相同 + } +} +``` + +### Go + Lua:秒杀扣减(hash tag 是集群下的生命线) + +```go +// 秒杀 Lua 脚本:原子完成「查已购 → 判库存 → 扣减 → 打标」 +// KEYS[1] = 库存 key,KEYS[2] = 用户已购标记 key +// 返回值:1=成功 / -1=已购过 / 0=售罄 +const seckillScript = ` +if redis.call('exists', KEYS[2]) == 1 then + return -1 +end +local stock = tonumber(redis.call('get', KEYS[1])) +if stock == nil or stock <= 0 then + return 0 +end +redis.call('decr', KEYS[1]) +redis.call('set', KEYS[2], 1, 'EX', 86400) +return 1 +` + +var seckillCmd = redis.NewScript(seckillScript) + +// Seckill 执行秒杀扣减。 +// 关键:两个 key 用相同的 hash tag {goodsID}, +// CRC16 只对大括号内的 goodsID 计算 → 两 key 必然同槽同节点, +// Lua 才能原子执行;否则集群直接报 CROSSSLOT。 +func Seckill(ctx context.Context, rdb *redis.ClusterClient, goodsID, userID string) (int64, error) { + stockKey := fmt.Sprintf("seckill:{%s}:stock", goodsID) + userKey := fmt.Sprintf("seckill:{%s}:bought:%s", goodsID, userID) + + // NewScript + Run:go-redis 自动处理 EVALSHA/EVAL 回退 + res, err := seckillCmd.Run(ctx, rdb, []string{stockKey, userKey}).Int64() + if err != nil { + return 0, err + } + return res, nil // 1=成功(发MQ异步落库) / -1=已购 / 0=售罄 +} +``` + +``` +如果去掉 hash tag(错误示范): + +stockKey = "seckill:1001:stock" → CRC16("seckill:1001:stock") = 51746 → 槽 2594 (主节点1) +userKey = "seckill:1001:bought:123" → CRC16("seckill:1001:bought:123") = 59352 → 槽 10200 (主节点2) + +两个槽在不同主节点上 → Lua 无法跨节点原子执行: +(error) CROSSSLOT Keys in the request don't hash to the same slot +``` + +--- + +## ⚠️ 常见陷阱 + +!!! warning "陷阱一:集群下 Lua/事务操作多 key 忘加 hash tag" + 单机时代 `EVAL` 随便传多个 key,上了集群直接 `CROSSSLOT Keys in the request don't hash to the same slot`,秒杀、购物车合并扣减等逻辑整体不可用。**解决**:所有需要一起操作的 key 用相同 hash tag(如 `seckill:{1001}:stock` 与 `seckill:{1001}:bought:123`);上集群前全局排查 MSET/MGET/MULTI/EVAL 的多 key 调用。 + +!!! warning "陷阱二:以为集群能解决热点大 key" + Cluster 分的是"槽",但**单个 key 永远只属于一个槽、落在一个主节点上**。一个被 10 万 QPS 打的热点 key(如爆款库存),集群再大也只有那一台主节点扛,照样打满。**解决**:热点 key 要业务侧拆(库存分桶:`stock:{1001}:bucket:1~N` 摊到多槽)或上本地缓存/多级缓存,分片救不了单 key 热点。 + +!!! warning "陷阱三:节点太少(< 3 主),故障转移失败" + 选举需要多数派主节点投票:2 主挂 1 个,剩 1 个凑不齐多数派,从节点永远选不上,槽不可用且**不会自动恢复**。同理,3 主集群挂 2 个也完蛋。**解决**:生产至少 3 主 3 从;跨机架/可用区部署,避免一次断电带走多数派。 + +!!! warning "陷阱四:带着单机习惯上集群(SELECT 多库、hash tag 滥用)" + 集群只有 db0,`SELECT 1` 直接报错——用 key 前缀替代多库隔离。另一个极端是 hash tag 滥用:把一堆 key 全打成 `{same-tag}`,全挤到一个槽一个节点,分片白做、单节点被打爆。**解决**:tag 粒度取业务实体 ID(商品 ID、用户 ID),保证不同实体自然散列;只有"必须一起原子操作"的 key 才共享 tag。 + +--- + +## 🏋️ 练习题 + +??? question "练习 1:Redis Cluster 为什么是 16384 个槽,而不是 65536 个?" + ??? success "答案" + 两个原因:① **心跳包体积**——节点间 Gossip 心跳要携带槽位图(bitmap),16384 bit = 2KB 可以接受,65536 bit = 8KB 心跳包太大;② **节点数上限**——官方建议集群不超过 1000 个节点,16384 个槽分给 1000 个节点粒度已足够,槽再多没有收益。本质上 16384 是带宽开销与分片粒度之间的折中。 + +??? question "练习 2:客户端收到 MOVED 和收到 ASK,处理方式有什么区别?为什么?" + ??? success "答案" + **MOVED**:槽已永久迁移到目标节点,客户端应**更新本地缓存的槽映射**,之后该槽的请求直接发新节点。**ASK**:槽正在迁移中,只有部分 key 已搬走,客户端**只对本次请求**去目标节点重试(先发 ASKING 再发命令),**不更新映射**——因为槽的最终归属还没变,其余 key 可能还在源节点。区别根源:MOVED 代表"迁移已完成"的确定事实,ASK 代表"迁移进行中"的临时状态。 + +??? question "练习 3:Cluster 中一个主节点宕机后,故障转移的完整流程是什么?和哨兵模式有什么本质区别?" + ??? success "答案" + 流程:① 某节点在 `cluster-node-timeout` 内 PING 不通故障主,标记 **PFAIL**(主观下线);② 半数以上**主节点**都标记 PFAIL 后升级为 **FAIL**(客观下线)并广播;③ 故障主的从节点发起选举,其他主节点按 **configuration epoch** 投票(每纪元一票),获得多数派主节点票的从胜出;④ 胜者提升为主,接管原主的槽并广播新映射。**本质区别**:哨兵模式要额外部署哨兵进程来做判定和选举,Cluster 把这套机制内置到数据节点自身,无需任何外部组件;另外 Cluster 新主只接管故障主的槽(分片数据),哨兵模式新主接管全量数据。 + +--- + +## 🔗 相关链接 + +- [Redis Cluster Tutorial(官方教程)](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/) — 集群搭建、槽迁移与 hash tag 的权威说明 +- [Redis Cluster Specification](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/) — 集群规范:MOVED/ASK、Gossip、故障转移的精确定义 +- [go-redis ClusterOptions 文档](https://redis.uptrace.dev/guide/go-redis.html) — Go 集群客户端配置参考 +- [高并发秒杀系统设计](../../architecture/seckill-design.md) — 本站文章:hash tag 在秒杀 Lua 扣减中的实战用法 +- [多级缓存与读写策略](../../architecture/cache/cache-multilevel-read-write.md) — 本站文章:热点 key 的多级缓存解法(集群救不了单 key 热点) diff --git a/docs/database/redis/persistence.md b/docs/database/redis/persistence.md new file mode 100644 index 0000000..514d613 --- /dev/null +++ b/docs/database/redis/persistence.md @@ -0,0 +1,1059 @@ +# Redis 持久化:RDB / AOF / 混合持久化 + +!!! note "💡 一句话概述" + Redis 的数据在内存里,断电就没了,所以需要把它落到磁盘:**RDB 是「拍照」**(定时把整个内存状态存成二进制快照,恢复快但两次快照之间的数据会丢),**AOF 是「记流水账」**(每条写命令追加到文件,重启时重放,安全但文件大恢复慢),**混合持久化是「照片 + 流水账」**(AOF 重写时前半段用 RDB 二进制、后半段用增量命令,既快又不丢)——这是生产环境的默认答案。 + +--- + +## 🔑 核心概念 + +1. **RDB(快照)** — 把某一时刻内存里的**全部键值状态**序列化成一个紧凑的二进制文件(`dump.rdb`),恢复时直接载入,不执行任何命令。存的是「结果」。 +2. **AOF(追加日志)** — 把每一条**写命令**以 RESP 协议文本追加到文件末尾,恢复时从头到尾**重放**这些命令。存的是「过程」。 +3. **AOF 重写** — 不是压缩旧文件,而是**遍历当前内存**,为每个还存在的 key 生成一条能重建其最终值的最小命令。本质是把「命令序列」转换成「键值状态快照」。 +4. **混合持久化(`aof-use-rdb-preamble yes`)** — AOF 重写时,文件前半段用 RDB 二进制写全量快照,后半段用 AOF 文本记录重写期间的增量写入。加载时先快速载入 RDB 段,再重放少量增量。 +5. **`appendfsync`** — 决定「多久把缓冲区真正刷到磁盘」的旋钮,它**直接等于你允许丢多少数据**:`always` 每条命令刷、`everysec` 每秒刷(默认)、`no` 交给操作系统。 + +--- + +## 📝 详解 + +### 1. 为什么 Redis 需要持久化(以及什么时候真的可以不要) + +Redis 把所有数据放在内存里,这是它快的根本原因,也是它脆弱的根本原因。只要发生下面任意一件事,内存里的数据就彻底没了: + +- 机器断电、内核 panic、云主机被强制回收 +- `kill -9 redis`、OOM Killer 把它干掉、进程 segfault +- 发版重启、容器被驱逐(K8s Pod 重启后本地磁盘也可能没了) + +持久化要解决的就是一件事:**进程/机器死掉之后,重启能把数据找回来。** + +但这里有个很多人搞反的前提 —— **Redis 并不是非持久化不可。** + +如果你的 Redis 只是缓存:DB 是唯一真相源,缓存里的每一条数据都能从 DB 查回来,那丢了就丢了,顶多重启后一段时间命中率低、DB 压力大一点。这种场景下关掉持久化反而是对的:省掉 fork 的卡顿、省掉 fsync 的 IO、省掉磁盘空间和重写时的内存峰值。 + +真正的分水岭是这一个问题: + +> **Redis 里有没有「别处查不到」的数据?** + +| 你在 Redis 里存了什么 | 丢了会怎样 | 要不要持久化 | +|---|---|---| +| 商品详情缓存、用户信息缓存、Session 副本 | 回 DB 查一次就行,短暂压力大 | ❌ 可以不开,或只开 RDB 图个省事 | +| 分布式锁(`SETNX lock:xxx`) | 锁凭空消失 → 两个客户端同时进临界区 | ✅ 必须,但光靠持久化还不够(见第 8 节) | +| 秒杀库存预扣、限流计数器 | 库存超卖、限流形同虚设 | ✅ 必须开 AOF | +| Write-Behind 异步落库的中间态(先写缓存、后台批量刷 DB) | 这批写永久丢失,DB 里再没有 | ✅✅ 必须,而且要想清楚 Redis 能不能当唯一真源 | +| 延迟队列、排行榜、消息 ACK 状态 | 任务丢失、无法重放 | ✅ 必须开 AOF | + +!!! tip "一句话判断" + **缓存丢了叫「回源」,数据丢了叫「事故」。** 只要你把 Redis 从「缓存」升级成了「存储」,持久化就从可选项变成了必选项。 + +--- + +### 2. RDB:给内存拍张照 + +RDB(Redis Database)就是字面意思:**把内存里的数据在某一刻的样子,完整地拍下来存成一个二进制文件。** + +它有两个命令,差别大到能决定你的服务是不是会挂: + +| | `SAVE` | `BGSAVE` | +|---|---|---| +| 谁来干活 | **主进程自己** | **fork 出来的子进程** | +| 主进程状态 | 完全阻塞,不响应任何请求 | 几乎不受影响(只有 fork 那一下) | +| 10GB 实例耗时 | 几十秒,期间服务不可用 | 子进程慢慢写,主进程照常服务 | +| 生产环境 | 🚫 **绝对禁用** | ✅ 标准做法 | + +`SAVE` 阻塞的原因很直白:Redis 处理命令是**单线程**的,主进程跑去遍历整个内存写文件了,就没人处理客户端请求了。这不是性能下降,是**服务完全停摆**。 + +#### BGSAVE 与写时复制(COW) + +`BGSAVE` 的魔法在于 `fork()`: + +1. 主进程调用 `fork()`,操作系统创建出一个子进程 +2. 子进程**继承父进程的全部内存数据**——但不是真的复制一份,而是父子共享同一批物理内存页,页表项被标记为只读 +3. 子进程遍历这份内存,把数据写成一个临时 RDB 文件,写完 `fsync` 再 `rename` 原子替换掉旧的 `dump.rdb` +4. 主进程继续正常处理请求 + +问题来了:子进程正在读,父进程同时在写,怎么保证子进程拍到的是「fork 那一刻」的一致快照? + +答案是 **COW(Copy-On-Write,写时复制)**:父进程要修改某一页时,触发缺页中断,内核先把这一页**复制一份**给父进程改,子进程仍然指向原来那份没被动过的旧页。 + +``` +fork() 瞬间:父子共享同一批物理页,页表项标记为只读 + + 父进程页表 物理内存 子进程页表 + ┌────────┐ ┌────────┐ ┌────────┐ + │ A │───────►│ A: k=1 │◄───────│ A │ + │ B │───────►│ B: x=2 │◄───────│ B │ + └────────┘ └────────┘ └────────┘ + +父进程执行 SET k 3(要写页 A)→ 缺页中断 → 内核复制出一份 A' + + ┌────────┐ ┌──────────┐ + │ A │───────►│ A' : k=3 │ ← 父进程改自己的副本 + └────────┘ └──────────┘ + ┌──────────┐ + 子进程仍指向 ────►│ A : k=1 │ ← 子进程看到的还是 fork 那一刻的旧值 + └──────────┘ + + 页 B 没人写 → 父子继续共享,一份都不多占 +``` + +```mermaid +sequenceDiagram + participant C as 客户端 + participant M as Redis 主进程 + participant K as Linux 内核 + participant S as 子进程 + C->>M: BGSAVE + M->>K: fork() + K->>S: 创建子进程(共享物理页,标记只读) + M-->>C: Background saving started(立即返回) + S->>S: 遍历内存 → 写临时 RDB 文件 + C->>M: SET k v(正常写入) + M->>K: 修改某个内存页 + K->>K: COW:复制该页给父进程 + Note over M,S: 子进程眼里的数据永远停在 fork 那一刻 + S->>S: fsync 临时文件 → rename 覆盖 dump.rdb + S->>M: 子进程退出,主进程收到 SIGCHLD +``` + +#### RDB 的四种触发方式 + +| 触发方式 | 说明 | +|---|---| +| **`save m n` 配置** | m 秒内发生了 n 次写,就自动 BGSAVE。可配多条,任一满足即触发 | +| **手动 `BGSAVE`** | `redis-cli BGSAVE`,或代码里调 | +| **执行 `SHUTDOWN`** | 没开 AOF 时,Redis 退出前会做一次 SAVE(开了 AOF 就直接退出,反正日志能恢复) | +| **主从全量同步** | 从节点第一次连上来(或复制积压缓冲区不够用),主节点**自动 BGSAVE** 生成 RDB 发给从节点,期间的写入用复制缓冲区补上 | + +最后一条经常被忽略,但很重要:**哪怕你配了 `save ""` 关掉 RDB 持久化,主从全量同步时主节点照样会 fork 一次。** + +#### RDB 配置片段 + +```bash +# ===== RDB 快照 ===== +# 900 秒内至少 1 次写 → 快照 +# 300 秒内至少 10 次写 → 快照 +# 60 秒内至少 10000 次写 → 快照 +save 900 1 +save 300 10 +save 60 10000 + +# save "" # 完全关闭 RDB(纯缓存场景) + +stop-writes-on-bgsave-error yes # BGSAVE 失败就拒绝写入,防止你以为在存其实没存 +rdbcompression yes # 用 LZF 压缩字符串对象,CPU 换空间,建议开 +rdbchecksum yes # 文件尾加 CRC64 校验,加载时验完整性 +dbfilename dump.rdb # 文件名 +dir /var/lib/redis # 数据目录(AOF 也放这里) +rdb-save-incremental-fsync yes # 增量 fsync,避免一次性刷 4GB 造成长时间卡顿 +``` + +#### RDB 优缺点 + +| 优点 | 缺点 | +|---|---| +| 文件**紧凑**,是二进制编码 + 可选压缩,同样数据体积通常只有 AOF 的几分之一 | **两次快照之间的数据会全部丢失**——`save 900 1` 意味着最坏情况丢 15 分钟 | +| **恢复极快**:直接把二进制反序列化进内存,不需要执行任何命令,10GB 数据可能只要几十秒 | **fork 大内存实例有卡顿风险**:fork 要复制页表,耗时和内存大小成正比,几 GB 的实例可能卡几十到几百毫秒 | +| 天生适合**备份和灾难恢复**:单个文件、可以直接拷走、可以按时间点归档(`dump-20260907.rdb`) | COW 在写入高峰期可能复制大量页,**内存峰值接近 2 倍** | +| 对主进程性能影响小(脏活都在子进程) | 快照频率是「丢多少数据」和「fork 多频繁」之间的硬权衡,没有两全 | + +```bash +# 运维时最常用的两个观测指标 +redis-cli INFO persistence | grep -E "rdb_last_bgsave_status|rdb_changes_since" +redis-cli INFO stats | grep latest_fork_usec # 上一次 fork 耗时(微秒),这个值大就说明要拆分实例了 +``` + +--- + +### 3. AOF:把每条写命令记成流水账 + +AOF(Append Only File)的思路完全反过来:**不存数据,只存「你对数据做了什么」。** + +每执行一条写命令,Redis 就把它以 RESP 协议格式追加到 AOF 文件末尾。重启时,Redis 打开这个文件,从第一条命令开始**逐条重新执行**一遍,内存状态就被重建出来了。 + +``` +客户端发来 SET mikey hello,AOF 文件里追加的就是这段 RESP 文本: + +*3\r\n ← 这条命令有 3 个参数 +$3\r\nSET\r\n ← 第 1 个:长度 3 的 "SET" +$5\r\nmikey\r\n +$5\r\nhello\r\n +``` + +注意几个细节: + +- **只记写命令**。`GET`、`MGET`、`TTL` 这些读命令不会进 AOF(它们不改数据) +- **记的是命令,不是数据变化**。`INCRBY counter 1` 记的就是这条命令本身,不是「counter 从 5 变成 6」 +- 会做一些等价改写:过期 key 被删除时会追加 `DEL`,`EXPIRE` 到期的 key 会记录删除动作 + +#### 写命令的完整路径:四个环节 + +理解 `appendfsync` 之前,必须先看清一条写命令从执行到落盘要过几道关: + +``` +客户端 SET k v + │ + ▼ +┌──────────────────┐ ① 执行命令,改内存 —— 到这一步客户端就已经收到 OK 了 +│ Redis 主进程 │─────────────────────────► 内存数据集 +│ │ +│ │ ② 把命令以 RESP 文本追加进 aof_buf(用户态缓冲区) +└──────────────────┘ + │ + │ ③ write():aof_buf → OS page cache(内核态缓存,还没到磁盘!) + ▼ +┌──────────────────┐ +│ OS Page Cache │ +└──────────────────┘ + │ + │ ④ fsync():page cache → 磁盘(只有这一步做完,断电才真的不丢) + ▼ +┌──────────────────┐ +│ appendonly.aof │ +└──────────────────┘ + +appendfsync 这个配置,就是在决定「第 ④ 步多久做一次」 +``` + +#### 三种 appendfsync + +| 取值 | 行为 | 丢失窗口 | 性能 | 适用 | +|---|---|---|---|---| +| **`always`** | 每条写命令都 `fsync` 之后才回复客户端 | Redis 进程崩溃:**0**;整机断电 + 磁盘 write-back 缓存:可能丢最后几条 | 最慢。每条命令一次同步磁盘 IO,QPS 可能掉一个数量级 | 数据绝对不能丢,且能接受吞吐牺牲 | +| **`everysec`** | 后台线程每秒 `fsync` 一次(**默认**) | 最多 **1 秒** 的写入 | 快。绝大多数时候只是内存追加 | **生产默认,性价比最高** | +| **`no`** | Redis 从不主动 `fsync`,完全交给 OS 决定 | 取决于内核刷盘策略,通常 **几十秒** | 最快 | 基本别用 | + +`everysec` 还有个容易踩的细节:如果上一次的 `fsync` 还没完成,主线程在写新命令时**会被阻塞**。所以当磁盘 IO 抖动时,`everysec` 也可能出现毛刺。这时可以配: + +```bash +no-appendfsync-on-rewrite yes # BGSAVE / BGREWRITEAOF 期间暂停 fsync,避免「重写占满 IO + fsync 阻塞」双重打击 +``` + +代价是这段时间的刷盘策略退化成 `no`,整机断电可能丢更多数据。 + +#### AOF 配置片段 + +```bash +# ===== AOF ===== +appendonly yes # 打开 AOF(默认 no) +appendfilename "appendonly.aof" # Redis 6.x 及以前的文件名 +# appenddirname "appendonlydir" # Redis 7.0+ 多文件 AOF 的目录(见第 5 节) +appendfsync everysec # always / everysec / no +no-appendfsync-on-rewrite no # 重写期间是否暂停 fsync +auto-aof-rewrite-percentage 100 # AOF 比上次重写后增长 100%(翻倍)就自动重写 +auto-aof-rewrite-min-size 64mb # 但文件至少要有 64MB 才重写(避免小文件反复重写) +aof-use-rdb-preamble yes # 混合持久化(Redis 4.0 引入,5.0 起默认 yes) +aof-rewrite-incremental-fsync yes # 重写时分块 fsync,避免一次性刷大文件造成延迟尖峰 +``` + +#### AOF 优缺点 + +| 优点 | 缺点 | +|---|---| +| **数据安全性高**:丢失窗口从「上次快照到现在」压缩到「1 秒」甚至「0」 | **文件大**:文本格式,同样数据比 RDB 大好几倍(重写后能缓解,但仍偏大) | +| **可读、可审计**:`appendonly.aof` 是文本,能直接 `grep` 出「谁在什么时候删了这个 key」,排查误操作很有用 | **恢复慢**:要逐条解析并执行命令,几 GB 的 AOF 重放可能要几分钟到十几分钟 | +| 文件损坏可修:`redis-check-aof --fix` 能截掉尾部损坏的部分(RDB 二进制损坏基本没救) | 写放大:每条命令多一次内存追加 + 定期 fsync,吞吐略低于关持久化 | +| 追加写是顺序 IO,对磁盘友好 | 重写期间有 fork + 缓冲区开销,大实例要小心内存 | + +```bash +# AOF 文件损坏时的修复工具(会丢掉损坏点之后的所有命令,先备份!) +redis-check-aof --fix /var/lib/redis/appendonlydir/appendonly.aof.1.incr.aof +``` + +--- + +### 4. AOF 重写:从「命令序列」到「键值状态快照」 + +AOF 是「只追加」的,所以它必然无限膨胀。想想这个场景:一个计数器每秒 `INCR` 一次,跑一天就是 86400 条命令写进文件——但恢复时你真正需要的只有一条:`SET counter 86400`。 + +这就是 AOF 重写(Rewrite)要解决的问题。 + +#### 重写的本质:不是压缩文件,是重新生成 + +**关键认知:AOF 重写不会去读旧的 AOF 文件。** + +它做的是:**遍历当前内存里的数据集,为每个还存在的 key 生成一条能重建其最终值的最小命令**,写进一个新文件,然后原子替换掉旧文件。 + +所以重写的本质是一次**数据结构的转换**: + +``` +重写前:AOF 是「命令序列」(log 结构,一条时间线) + → 记录「怎么改」,包含大量互相覆盖的中间过程 + +重写后:AOF 是「键值状态快照」(hashmap 结构的等价描述) + → 记录「改成什么样」,只保留最终结果 +``` + +#### 一个具体例子 + +假设 AOF 里按时间顺序追加了这 5 条命令: + +``` +SET k 1 ← 中间态,被后面覆盖 +INCRBY k 5 ← 中间态,k 变成 6 +SET k 9 ← 中间态,被后面覆盖 +DEL k ← 中间态,key 被删了 +SET k 2 ← 最终态 +``` + +触发 `BGREWRITEAOF` 之后,Redis 遍历内存,发现只有一个 key `k`,值是 `2`。于是新文件里**只有一条命令**: + +``` +SET k 2 +``` + +前 4 条全部消失。恢复效果完全一致(`k = 2`),文件从 5 条变成 1 条。 + +再看一个更能说明问题的例子: + +``` +SET user:1001 zhangsan +SET user:1001 zhangsanfeng +SET user:1002 lisi +DEL user:1001 +INCR view:homepage +SET counter 5 +INCRBY counter 1 +DEL counter +``` + +8 条命令,重写后: + +| key | 重写后写入什么 | 为什么 | +|---|---|---| +| `user:1001` | **什么都不写** | 最后被 `DEL` 了,内存里根本不存在这个 key | +| `user:1002` | `SET user:1002 lisi` | 唯一一次写入,仍是最终值 | +| `view:homepage` | `SET view:homepage <当前计数值>` | 计数器不用保留那串 `INCR`,直接写终值 | +| `counter` | **什么都不写** | 也被 `DEL` 了 | + +8 条 → 2 条。注意 `user:1001` 那一点:**已经被删除的 key 连一条 `DEL` 都不用写**,因为「不存在」本身就是它的状态,重放一个空数据库时它自然就不存在。 + +#### 重写期间主进程还在写,怎么办? + +重写是子进程干的活(`fork` + COW,和 BGSAVE 一样),但主进程不能停下来——业务还在写。如果只让子进程写新文件,那重写期间的写入就丢了。 + +Redis(7.0 之前)的做法是**双写 + 增量补齐**: + +```mermaid +sequenceDiagram + participant C as 客户端 + participant M as 主进程 + participant S as 子进程(重写) + M->>S: BGREWRITEAOF → fork() + S->>S: 遍历内存,为每个 key 生成最小重建命令 → 写临时新 AOF + C->>M: 写命令 W1 + M->>M: ① 追加到【旧 AOF】(万一重写失败,旧文件还能恢复) + M->>M: ② 追加到【AOF 重写缓冲区】(aof_rewrite_buf) + C->>M: 写命令 W2 + M->>M: 同样双写 + S->>M: 重写完成,通知主进程 + M->>M: ③ 把重写缓冲区里的增量(W1、W2)追加到新 AOF 末尾 + M->>M: ④ fsync 新文件 → rename 原子替换旧 AOF → 删除旧文件 +``` + +要点: + +1. **双写**:重写期间每条写命令同时进「旧 AOF」和「重写缓冲区」。旧 AOF 是保险——重写崩了还能退回原样。 +2. **增量补齐**:子进程写完 base 之后,主进程把缓冲区里的增量命令追加到新文件末尾,保证一条不漏。 +3. **原子替换**:`rename` 是原子操作,不存在「一半新一半旧」的中间状态。 + +!!! warning "重写期间的内存开销" + 重写缓冲区是**内存里的一块链表**。如果重写很慢(实例大、磁盘 IO 差),而业务写入很快,这个缓冲区会一直涨,加上 COW 复制的页,**内存峰值可能接近平时的 2 倍**,极端情况被 OOM Killer 干掉。大实例上线前一定要压测这个场景,并且给足内存余量。 + +#### 自动重写的触发条件 + +| 参数 | 含义 | 默认 | +|---|---|---| +| `auto-aof-rewrite-percentage` | 当前 AOF 体积比**上次重写后**增长了多少百分比就触发。`100` = 翻倍 | `100` | +| `auto-aof-rewrite-min-size` | 但文件至少要这么大才触发,避免几十 KB 的小文件反复重写 | `64mb` | +| `no-appendfsync-on-rewrite` | 重写期间是否暂停 fsync | `no` | + +**两个条件是「与」关系**:增长超过 100% **且** 文件超过 64MB,才自动重写。设成 `0` 表示禁用自动重写。 + +```bash +# 手动触发(生产上建议低峰期执行) +redis-cli BGREWRITEAOF +# Background append only file rewriting started + +# 观察重写状态 +redis-cli INFO persistence | grep -E "aof_rewrite_in_progress|aof_current_size|aof_base_size|aof_pending_rewrite" +``` + +--- + +### 5. 混合持久化:RDB 的头 + AOF 的尾 + +到这里矛盾已经很清楚了: + +- RDB **恢复快**(二进制直接载入),但**不安全**(两次快照之间全丢) +- AOF **安全**(最多丢 1 秒),但**恢复慢**(几 GB 命令要逐条重放) + +Redis 4.0 引入了混合持久化(`aof-use-rdb-preamble yes`),**5.0 起默认开启**。它的做法非常聪明: + +> **在 AOF 重写的时候,新文件的前半部分不用 RESP 文本写命令,而是直接用 RDB 二进制格式写一份全量快照;后半部分才用 AOF 格式追加重写期间产生的增量命令。** + +这样你既拿到了 AOF 的数据安全性(增量命令一条不漏,丢的还是 1 秒),又拿到了 RDB 的加载速度(全量部分是二进制,直接载入不用重放)。 + +#### AOF 文件结构 + +``` +appendonly.aof(混合持久化,Redis 4.0+) +┌────────────────────────────────────────────────────────────┐ +│ ① 前半段:RDB 二进制格式(全量快照) │ +│ │ +│ "REDIS0009" │ 紧凑编码的键值对 ... │ EOF │ CRC64 校验 │ +│ │ +│ ← 重写子进程遍历内存生成,占文件绝大部分体积 │ +│ ← 载入方式:直接反序列化,不执行任何命令,快 │ +├────────────────────────────────────────────────────────────┤ +│ ② 后半段:AOF 文本格式(增量命令) │ +│ │ +│ *3\r\n$3\r\nSET\r\n$1\r\nk\r\n$1\r\nv\r\n │ +│ *2\r\n$6\r\nAPPEND\r\n... │ +│ │ +│ ← 重写期间主进程新写入的命令,条数很少 │ +│ ← 载入方式:逐条重放,慢但量小,可忽略 │ +└────────────────────────────────────────────────────────────┘ + +Redis 靠文件头是不是 "REDIS" 这个 magic string 来判断: +是 → 混合格式(RDB 段 + AOF 段);不是 → 纯 RESP 文本 AOF +``` + +#### 加载流程 + +``` +Redis 启动 + │ + ▼ +┌───────────────────────────────────────────┐ +│ appendonly yes? │ +│ 是 → 加载 AOF(数据更全,优先级高于 RDB) │ +│ 否 → 加载 dump.rdb │ +└───────────────────────────────────────────┘ + │(走 AOF 分支) + ▼ +┌───────────────────────────────────────────┐ +│ 读文件头 5 字节 │ +│ "REDIS" → 混合持久化,走下面两步 │ +│ "*" → 纯 AOF,逐条重放到文件尾 │ +└───────────────────────────────────────────┘ + │ + ▼ +┌───────────────────┐ 秒级(二进制反序列化) ┌─────────────────────┐ +│ 第 1 步:载入 RDB 段│ ─────────────────────► │ 内存里已有 99% 的数据 │ +└───────────────────┘ └─────────────────────┘ + │ + ▼ +┌───────────────────┐ 毫秒级(命令条数少) ┌─────────────────────┐ +│ 第 2 步:重放 AOF 段│ ─────────────────────► │ 补齐最后一点增量 │ +│ 增量命令 │ │ → 数据完整可用 ✅ │ +└───────────────────┘ └─────────────────────┘ +``` + +**效果对比**:10GB 数据、纯 AOF 重放可能要 10 分钟;混合持久化下 RDB 段几十秒载入完,增量段只有重写期间那点命令,几乎瞬间完成。启动时间从「分钟级」压到「秒级」。 + +#### Redis 7.0:多文件 AOF(Multi Part AOF) + +Redis 7.0 把「一个巨大的 AOF 文件」拆成了「一组文件 + 一个清单」: + +``` +appendonlydir/ ← 由 appenddirname 指定 +├── appendonly.aof.1.base.rdb ← base:全量部分(RDB 格式) +├── appendonly.aof.1.incr.aof ← incr:base 之后的增量命令 +├── appendonly.aof.2.incr.aof ← 新一轮重写产生的增量文件 +└── appendonly.aof.manifest ← 清单:谁是 base、哪些 incr 有效 + +命名规则:appendonly.aof.<序号>.. +``` + +重写流程变成: + +1. 子进程生成新的 `base` 文件 +2. 主进程的增量命令**直接写进当前的 `incr` 文件**——不再需要 7.0 之前那个独立的「AOF 重写缓冲区」 +3. 新 base 写完 → **原子改写 manifest**(把新 base 登记进去,旧文件标记为可删) +4. 删除旧的 base / incr 文件 + +好处很实在: + +| | 7.0 之前 | 7.0 多文件 AOF | +|---|---|---| +| 重写期间的增量 | 堆在内存里的重写缓冲区 | 直接落 `incr` 文件,内存压力小得多 | +| 替换旧文件 | `rename` 一个大文件 | 改一个很小的 manifest,更轻量、更不容易出错 | +| 文件损坏范围 | 整个 AOF 可能受影响 | 只影响单个 `incr` 文件 | + +!!! note "配置兼容性" + Redis 7.0 引入了 `appenddirname`(默认 `appendonlydir`)来指定这组文件的目录,`appendfilename`(默认 `appendonly.aof`)从「文件名」变成了「文件名前缀」——真正的文件叫 `appendonly.aof.1.base.rdb` 这种。`aof-use-rdb-preamble` 依然是控制 base 段用 RDB 还是 AOF 格式的开关,默认 `yes`。 + +--- + +### 6. 三种方案对比与选型 + +#### 横向对比 + +| 维度 | 只开 RDB | 只开 AOF(everysec,未开混合) | RDB + AOF + 混合持久化 | +|---|---|---|---| +| **数据安全性** | 🔴 低:丢「上次快照 → 宕机」之间的**全部**写入,可能几分钟到几十分钟 | 🟢 高:正常最多丢 **1 秒** | 🟢 高:等同 AOF(RDB 段只是 AOF 文件的一部分,不影响安全性) | +| **文件体积** | 🟢 最小,二进制 + 可压缩 | 🔴 最大,RESP 文本;重写后接近 RDB 但仍偏大 | 🟡 中:AOF ≈ RDB 体积 + 少量增量命令 | +| **恢复速度** | 🟢 最快,直接反序列化 | 🔴 最慢,逐条重放命令 | 🟢 快:RDB 段快速载入 + 少量增量重放 | +| **对运行性能影响** | fork 卡顿(大实例明显);快照期间 CPU/IO 升高 | 每条命令多一次内存追加;后台 fsync 线程;重写时 fork + 缓冲区 | 同 AOF,但重写后文件更小、要 fsync 的数据更少 | +| **可审计性** | 🔴 二进制,看不出谁改了什么 | 🟢 文本,可 grep | 🟡 base 段二进制,incr 段可 grep | +| **适合做什么** | 备份、灾难恢复、主从全量同步的传输载体 | 数据不能丢的业务 | **生产默认推荐** | + +#### 选型建议 + +| 你的 Redis 用来干什么 | 建议配置 | +|---|---| +| **纯缓存**,DB 是唯一真源,重启回填即可 | 只开 RDB(`save 900 1` 之类),甚至 `save ""` + `appendonly no` 追求极致性能 | +| 缓存,但不想重启后 DB 被打崩(省掉预热期) | 开 RDB,快照频率调高一点;或直接上混合持久化 | +| **分布式锁 / 秒杀库存预扣 / 限流计数 / Write-Behind 待落库中间态** | **必须开 AOF**,`appendfsync everysec` 起步,最好 `aof-use-rdb-preamble yes` | +| 钱、订单状态这种绝对不能丢的 | 别指望 Redis。就算 `appendfsync always`,那也只是**一台机器**的保证。真源必须在 MySQL,Redis 只做加速 | +| 生产环境的通用答案 | RDB + AOF + 混合持久化全开,`everysec`,自动重写配好 | + +```mermaid +graph TD + A["Redis 里有没有 DB 查不到的数据?"] -->|没有,纯缓存| B["只开 RDB
甚至 save '' + appendonly no"] + A -->|有:锁 / 计数 / 待落库中间态| C["必须开 AOF"] + C --> D{"能容忍丢 1 秒吗?"} + D -->|能,绝大多数业务| E["appendfsync everysec
+ aof-use-rdb-preamble yes"] + D -->|不能| F["appendfsync always
吞吐可能掉一个数量级,先想清楚值不值"] + B --> G["重启靠 DB 回填 + 缓存预热"] + E --> H["RDB 同时保留,用于备份和灾难恢复"] + F --> H +``` + +!!! tip "两个都开时,重启用哪个?" + **优先用 AOF。** 只要 `appendonly yes`,Redis 启动时就加载 AOF,完全不看 `dump.rdb`。原因很简单:AOF 的数据更全(RDB 快照可能是几小时前的,AOF 是 1 秒前的)。RDB 此时的角色退化为**备份和主从同步的载体**。 + + 反过来,如果你只开 RDB 却手动关掉了它,或者 AOF 文件损坏被删,Redis 会回退去加载 RDB —— 这时候你会拿到一份**很旧**的数据,比「什么都没有」更危险,因为业务以为数据是对的。 + +--- + +### 7. 和 MySQL 日志的类比:哪里像、哪里不像 + +面试里经常被问「Redis 的 AOF 相当于 MySQL 的什么」。答案是 **binlog**,但这个类比**只对了一半**。 + +#### 像的地方 + +| 共同点 | 说明 | +|---|---| +| 都记录**写操作** | AOF 记 Redis 命令,binlog 记 SQL 语句(statement)或行变更(row) | +| 都是**追加写**的日志文件 | 只往后加,不原地改 | +| 都能**重放** | AOF 重放命令恢复内存;binlog 重放做主从复制、做时间点恢复 | +| 都有**刷盘频率旋钮** | `appendfsync` ↔ `sync_binlog` / `innodb_flush_log_at_trx_commit` | +| 都是**逻辑日志** | 记的是「做了什么操作」,不是「哪个磁盘扇区变成了什么」 | + +#### 不像的地方 + +**① 时间点恢复(PITR)能力完全不同** + +这是最关键的区别。 + +- **binlog 有精确位点**。每个事务在 binlog 里都有「文件号 + 字节偏移(position)」。`mysqlbinlog --start-position=1234 --stop-position=5678` 可以精确到字节地重放任意区间,配合全量备份就能恢复到「误删表之前的那一毫秒」。`--start-datetime/--stop-datetime` 是时间近似过滤(可能切在事务中间,不如 position 精确)。 +- **AOF 没有原生的时间点回放**。它的设计目标就是「从文件头顺序重放到文件尾」,命令里**没有时间戳,没有位点索引**。想恢复到历史某一刻,只能**手动截断文件**(找到那条命令的字节位置,把后面的删掉)再重放——纯手工活,没有工具支持。 + +```bash +# MySQL:原生 PITR,标准操作 +mysqlbinlog --start-position=154 --stop-position=8923 binlog.000123 | mysql -uroot -p + +# Redis:想做同样的事?只能自己 dd / truncate 文件,然后祈祷没切错位置 +``` + +**② 防膨胀机制不同:显式重写 vs 隐式覆盖** + +| | Redis AOF | InnoDB redo log | +|---|---|---| +| 文件结构 | **无限追加**,会一直长 | **固定大小环形缓冲**,写满回头覆盖最旧的 | +| 防膨胀方式 | **显式重写**:`BGREWRITEAOF`,把命令序列压成状态快照,生成新文件替换旧文件 | **隐式覆盖**:旧记录对应的脏页刷盘后(checkpoint 推进),那段空间就可以被循环复用 | +| 需要人工干预吗 | 需要配阈值、需要关注重写耗时和内存 | 完全自动,运维基本不碰 | + +为什么 redo 敢覆盖而 AOF 不敢?因为**职责不同**: + +- redo log 只服务于**崩溃恢复**——它要保证的是「已提交但脏页还没落盘的那部分事务能重做」。脏页一旦落盘,对应的 redo 记录就没用了,覆盖掉完全安全。 +- AOF 是 Redis **唯一的数据本体**(开了 AOF 就不看 RDB 了)。它不能覆盖任何东西,只能整体重写成一个更小的等价文件。 + +**③ 物理日志 vs 逻辑日志** + +- **redo log 是物理日志**:记的是「第 X 号数据页的第 Y 偏移处的字节,改成了 Z」。它是 InnoDB 引擎内部的,跟 SQL 长什么样没关系。 +- **AOF / binlog 是逻辑日志**:记的是「执行了 `SET k v`」/「执行了 `UPDATE t SET ...`」。 + +**④ 所在层次不同** + +- binlog 在 **MySQL Server 层**产生,任何存储引擎都有,主要用于主从复制、PITR、CDC(Canal/Maxwell)、审计 +- redo log 在 **InnoDB 引擎层**,只管崩溃恢复 +- AOF 就是 Redis 自己的持久化,**跟 MySQL 的日志没有任何代码或协议上的关系**,只是思想相通 + +#### 三种日志横向对比 + +| 维度 | Redis AOF | MySQL binlog | InnoDB redo log | +|---|---|---|---| +| **记录内容** | 写命令(RESP 文本) | SQL(statement)或行变更(row) | 数据页的物理变更(页号 + 偏移 + 新值) | +| **日志类型** | 逻辑 | 逻辑 | **物理** | +| **所在层** | Redis 自身 | Server 层(跨引擎) | InnoDB 引擎层 | +| **文件结构** | 追加,会膨胀 | 追加,靠 rotate + 过期清理 | **固定大小环形缓冲** | +| **防膨胀** | **显式重写**(命令序列 → 状态快照) | 不压缩,只删旧文件 | **隐式覆盖**(checkpoint 推进后复用) | +| **主要用途** | 自身持久化、崩溃恢复 | 主从复制、PITR、CDC、审计 | 崩溃恢复(WAL),保证已提交不丢 | +| **时间点恢复** | ❌ 无原生位点,只能手动截断 | ✅ `--start/--stop-position` 精确到字节 | ❌ 只做崩溃点前向重做,不回放历史 | +| **刷盘旋钮** | `appendfsync` always/everysec/no | `sync_binlog` = 0/1/N | `innodb_flush_log_at_trx_commit` = 0/1/2 | + +#### 刷盘策略对照表 + +这两个旋钮的思想是一模一样的:**用刷盘频率换「安全 vs 性能」**。 + +| 安全等级 | Redis | MySQL | 含义 | +|---|---|---|---| +| 最安全 | `appendfsync always` | `innodb_flush_log_at_trx_commit=1` + `sync_binlog=1` | 每次提交都 fsync,进程崩溃不丢数据 | +| 平衡 | `appendfsync everysec` | `trx_commit=2` + `sync_binlog=N` | 攒一批再刷,进程崩溃不丢、机器断电可能丢 1 秒/1 批 | +| 最快 | `appendfsync no` | `trx_commit=0` | 交给 OS,MySQL/Redis 进程崩了都可能丢 | + +!!! note "面试话术" + 「AOF 和 binlog 都是可重放的逻辑追加日志,这是它们像的地方。不像的地方有三点:binlog 有 position 能做精确 PITR,AOF 只能从头重放、要定点恢复得手动截断文件;AOF 靠显式重写把命令序列压成状态快照来防膨胀,redo log 是固定环形缓冲、脏页刷盘后隐式覆盖所以天生不膨胀;redo 是物理日志记页变更,AOF 和 binlog 是逻辑日志记操作。」 + +--- + +### 8. 经典坑:持久化与分布式锁 + +这是把上面所有知识点串起来的一个真实事故场景。 + +#### 故障时序 + +前提:Redis 一主一从 + 哨兵,**只开了 RDB**,用 `SETNX` 做分布式锁。 + +```mermaid +sequenceDiagram + participant A as 客户端 A + participant M as Redis 主节点 + participant R as 从节点(异步复制) + participant B as 客户端 B + A->>M: SETNX lock:order 1 EX 30 → OK,拿到锁 + Note over M: 只开 RDB,且还没到快照点
这把锁只存在于内存里 + M--xR: 还没来得及异步复制过去 + M->>M: 主节点宕机 💥 + R->>R: 哨兵把它提升为新主 + Note over R: 内存里没有 lock:order 这个 key + B->>R: SETNX lock:order 1 EX 30 → OK + Note over A,B: A 和 B 同时持有同一把锁 → 互斥彻底失效 +``` + +结果:两个客户端同时在临界区里跑「扣库存」,超卖。 + +#### 开 AOF 能解决多少? + +分两种情况,别混: + +| 故障类型 | 只开 RDB | 开 AOF(everysec) | 开 AOF(always) | +|---|---|---|---| +| **主节点进程崩溃 / 重启,还是同一台机器** | 锁丢失(回到上次快照) | 最多丢 1 秒内的锁 | 锁保住 ✅ | +| **主节点整机报废,从节点升主** | 锁丢失 | ⚠️ **锁可能仍然丢失** | ⚠️ **锁可能仍然丢失** | + +第二行是重点:**持久化保的是「这台机器重启后数据还在」,复制保的是「数据在别的机器上也有一份」,这是两件完全不同的事。** Redis 主从复制是**异步**的,主节点回完 `OK` 就开始复制,但复制没完成前主节点挂了,从节点升主后照样没有这把锁——哪怕主节点的 AOF 里明明记着它(但主节点已经死了,没人读那份 AOF)。 + +缓解手段(按代价从低到高): + +1. **`WAIT` 命令**:加锁后执行 `WAIT 1 100`,阻塞到至少 1 个从节点确认收到写命令。这是**伪同步**,能大幅降低概率,但仍不是强一致(从节点确认了也可能还没写进自己的内存)。 +2. **Redlock**:不依赖主从,改用多个完全独立的实例。 +3. **换存储**:用 etcd / ZooKeeper 这种基于 Raft / ZAB 共识的组件做锁,它们提供**单调递增的 fencing token**,这是 Redis 方案给不了的。 + +#### Redlock 是怎么工作的 + +``` +部署 N 个(通常 5 个)完全独立的 Redis 实例 +—— 注意:不是主从、不是集群,是 5 台互不相干的单机 Redis + +加锁流程: + ① 客户端记录当前时间 T1 + ② 依次向 N 个实例发 SETNX lock:key <唯一value> EX 30 + 每个请求的超时设得极小(5~50ms),避免卡在挂掉的实例上 + ③ 记录结束时间 T2,耗时 = T2 - T1 + ④ 判定成功需要同时满足: + - 成功数 ≥ N/2 + 1(多数派,5 个里至少 3 个) + - 耗时 < 锁的有效期 + 锁的真实有效期 = 30s - 耗时 + ⑤ 失败或超时 → 向所有 N 个实例发 DEL 释放(包括没加上的,防止残留) + +释放流程: + 向所有实例执行「比对 value 再 DEL」的 Lua 脚本 +``` + +直觉上这很稳:挂 2 台还能工作,多数派保证了「不会出现两个客户端同时拿到多数派」。 + +#### 但 Redlock 是有争议的 + +分布式系统专家 **Martin Kleppmann** 在《How to do distributed locking》(2016) 里提出了著名批评,Redis 作者 antirez 也写了回应文章。核心争论点: + +**批评一:没有 fencing token(栅栏令牌)** + +这是最致命的一条,而且**和持久化配置无关**: + +``` +时刻 1:客户端 A 拿到锁(TTL 30s) +时刻 2:A 发生长时间 GC 暂停 / 网络分区 / 虚拟机被挂起 —— 30 秒过去了 +时刻 3:锁自动过期,客户端 B 拿到锁,开始写数据 +时刻 4:A 从暂停中醒来,它「以为」自己还持有锁,继续写数据 + → A 和 B 同时写,数据损坏 +``` + +AOF 配 `always` 也救不了这个场景,因为问题不在「锁有没有被持久化」,而在「**客户端无法知道自己持有的锁是否已经失效**」。 + +Kleppmann 的解法是 **fencing token**:每次成功加锁都返回一个**单调递增的整数**,存储层(比如写文件的那个服务)拒绝所有 token 比「已见过的最大值」更小的写入请求。这样即使 A 醒过来,它的旧 token 也会被拒绝。 + +**Redis 的单实例和 Redlock 都不提供 fencing token。** 你可以用 `INCR` 自己造一个,但存储侧必须配合校验,改造成本不小。 + +**批评二:依赖时钟假设** + +Redlock 的安全性论证建立在「各个 Redis 实例的时钟不会突然跳变」之上。但现实中: + +- NTP 校时导致时间**向前跳**(不是缓慢漂移,是突然跳 5 秒)→ 锁提前过期 +- 虚拟机被 hypervisor 挂起后恢复 → 时钟断层 +- 运维手动改了系统时间 +- 某个实例发生长时间 GC 暂停,它眼里的「TTL 还剩多久」是错的 + +任何一个实例的时钟出问题,多数派的安全性论证就塌了。 + +**antirez 的回应要点**:Redlock 用的是**相对时间**(TTL 倒计时),不是绝对时间戳;只要不出现「大幅度、非预期的时钟跳变」,它就是安全的;而且可以在部署层面约束(禁用 NTP 大步进、用单调时钟)。 + +**批评三:这是过度设计** + +Kleppmann 提出应该先区分你加锁是为了什么: + +| 锁的目的 | 例子 | 推荐方案 | +|---|---|---| +| **效率锁**(efficiency) | 避免 10 个 worker 重复处理同一个任务,偶尔重复一次能接受 | 单实例 Redis `SETNX` 就够,**Redlock 是过度设计** | +| **正确性锁**(correctness) | 两个人同时扣同一笔库存 = 数据永久损坏 | 用带 fencing token 的共识系统:**etcd / ZooKeeper** | + +#### 落到实践 + +1. **单实例 SETNX + 唯一 value + Lua 释放**,能覆盖 90% 的场景。别一上来就 Redlock。 +2. **锁不是唯一防线,业务侧必须做幂等**。扣库存用「带版本号的 CAS」或 DB 层的 `UPDATE ... WHERE stock >= n`,把 Redis 锁当作「减少冲突的优化」而不是「正确性的唯一保证」。 +3. **TTL 一定要设**,且要大于业务最长耗时(配 watchdog 续期),否则锁提前过期照样出事。 +4. 如果这个锁错了会导致**数据永久损坏**,别用 Redis,用 etcd。 + +--- + +## 💻 代码示例 + +### redis.conf 推荐配置(生产通用) + +```bash +########## RDB ########## +save 900 1 +save 300 10 +save 60 10000 +stop-writes-on-bgsave-error yes +rdbcompression yes +rdbchecksum yes +dbfilename dump.rdb +dir /var/lib/redis +rdb-save-incremental-fsync yes + +########## AOF ########## +appendonly yes +appendfsync everysec # 最多丢 1 秒,性能/安全的最佳平衡 +no-appendfsync-on-rewrite no +auto-aof-rewrite-percentage 100 # 增长 100% 触发重写 +auto-aof-rewrite-min-size 64mb # 且至少 64MB +aof-rewrite-incremental-fsync yes + +########## 混合持久化 ########## +aof-use-rdb-preamble yes # Redis 5.0+ 默认已开启,显式写上更清楚 + +########## Redis 7.0+ 多文件 AOF ########## +# appenddirname "appendonlydir" # 7.0 起用目录代替单文件,appendfilename 已废弃 + +########## 大内存实例的保命配置 ########## +maxmemory 8gb +maxmemory-policy allkeys-lru # 纯缓存用 allkeys-lru;当存储用要改成 noeviction +# 另外:宿主机务必关闭透明大页(THP),否则 COW 会一次复制 2MB 而不是 4KB +# echo never > /sys/kernel/mm/transparent_hugepage/enabled +``` + +### redis-cli 常用运维命令 + +```bash +# —— RDB —— +redis-cli BGSAVE +# Background saving started +redis-cli LASTSAVE +# (integer) 1757145600 ← 上次成功快照的 Unix 时间戳 +redis-cli INFO stats | grep latest_fork_usec +# latest_fork_usec:48213 ← fork 耗时 48ms,实例再大就要考虑拆分了 + +# —— AOF —— +redis-cli BGREWRITEAOF +# Background append only file rewriting started +redis-cli CONFIG GET appendfsync +# 1) "appendfsync" +# 2) "everysec" +redis-cli CONFIG GET aof-use-rdb-preamble +# 1) "aof-use-rdb-preamble" +# 2) "yes" +redis-cli INFO persistence | grep -E "aof_enabled|aof_current_size|aof_rewrite_in_progress|loading" + +# —— 在线改配置(不用重启)—— +redis-cli CONFIG SET appendfsync always # 临时提高安全等级 +redis-cli CONFIG REWRITE # 把内存中的配置写回 redis.conf,否则重启失效 + +# —— 文件体检 —— +redis-check-aof /var/lib/redis/appendonlydir/appendonly.aof.1.incr.aof +redis-check-rdb /var/lib/redis/dump.rdb +``` + +### Go:go-redis 实现 SETNX 分布式锁 + +```go +package redlock + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +// 释放锁的 Lua 脚本:先比对 value 再 DEL +// 为什么必须用 Lua?因为 GET + DEL 是两步操作: +// 你 GET 确认是自己的锁 → 就在这一瞬间锁过期了 → 别人 SETNX 成功 +// → 你接着 DEL → 删掉了别人的锁 💥 +// Lua 脚本在 Redis 里是原子执行的,中间不会被插入其他命令 +const releaseScript = ` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +else + return 0 +end +` + +// 续期用的 Lua:只有 value 还是自己的才延长 TTL +const renewScript = ` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("PEXPIRE", KEYS[1], ARGV[2]) +end +return 0 +` + +var ErrLockNotAcquired = errors.New("lock not acquired") + +// Lock 单实例 Redis 分布式锁 +type Lock struct { + rdb *redis.Client + key string + value string // 唯一标识,用于安全释放,防止误删别人的锁 + ttl time.Duration +} + +// Acquire 抢锁,本质就是一条命令:SET key value NX EX ttl +// NX —— key 不存在才设置(这就是「互斥」的来源) +// EX —— 同时设置过期时间(防止持锁进程崩溃后锁永久不释放 → 死锁) +// NX 和 EX 必须在一条命令里完成,分成 SETNX + EXPIRE 两步是经典错误: +// 两步之间进程崩了,锁就永远不会过期 +func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) { + l := &Lock{ + rdb: rdb, + key: key, + value: uuid.NewString(), + ttl: ttl, + } + ok, err := l.rdb.SetNX(ctx, l.key, l.value, ttl).Result() + if err != nil { + return nil, err + } + if !ok { + return nil, ErrLockNotAcquired // 被别人占着,调用方决定重试还是快速失败 + } + return l, nil +} + +// Release 释放锁,幂等,且只删自己的 +func (l *Lock) Release(ctx context.Context) (bool, error) { + n, err := l.rdb.Eval(ctx, releaseScript, []string{l.key}, l.value).Int64() + if err != nil { + return false, err + } + return n == 1, nil +} + +// WaitReplicas 加锁后等待至少 n 个从节点确认收到写命令(伪同步复制) +// +// ⚠️ 这跟「持久化」是两件事: +// - AOF 保证的是「这台 Redis 重启后锁还在」 +// - WAIT 降低的是「主节点整机挂掉、从节点升主后锁丢了」的概率 +// 两者都不能提供强一致。WAIT 返回的 n 只代表「副本收到了命令」, +// 不代表「副本已经写进内存/落盘」,所以它只是把丢锁概率压低,不是消除。 +func (l *Lock) WaitReplicas(ctx context.Context, n int, timeout time.Duration) (int64, error) { + return l.rdb.Wait(ctx, n, timeout).Result() +} + +// Watchdog 后台续期:业务耗时可能超过 TTL 时用来续命 +// +// ⚠️ 续期本身也有 Kleppmann 指出的风险:如果进程发生长时间 GC 暂停, +// watchdog 线程也被冻住了,锁照样会过期,而业务代码醒来后还以为自己持有锁。 +// 所以关键业务一定要在存储侧做 fencing(版本号 / 单调递增 token / DB 层 CAS)兜底。 +func (l *Lock) Watchdog(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + l.rdb.Eval(ctx, renewScript, []string{l.key}, l.value, l.ttl.Milliseconds()) + } + } +} +``` + +使用示例(秒杀扣库存): + +```go +func SeckillDeduct(ctx context.Context, rdb *redis.Client, skuID string, userID int64) error { + key := "lock:stock:" + skuID + + lock, err := Acquire(ctx, rdb, key, 30*time.Second) + if errors.Is(err, ErrLockNotAcquired) { + return errors.New("系统繁忙,请重试") // 快速失败,别让请求堆积 + } + if err != nil { + return err + } + defer lock.Release(ctx) // 一定要 defer 释放 + + // 真正的扣减:用 Lua 保证「判断 + 扣减」原子 + // 光有分布式锁还不够 —— Redis 的 MULTI/EXEC 不会回滚, + // 条件判断和写入必须塞进一个 Lua 脚本里才是原子的 + n, err := rdb.Eval(ctx, ` + local stock = tonumber(redis.call("GET", KEYS[1])) + if stock == nil or stock <= 0 then return -1 end + redis.call("DECR", KEYS[1]) + return stock - 1 + `, []string{"stock:" + skuID}).Int64() + if err != nil { + return err + } + if n < 0 { + return errors.New("已售罄") + } + + // 落库(DB 才是唯一真源,Redis 只是加速层) + // UPDATE stock SET num = num - 1 WHERE sku_id = ? AND num >= 1 + // 这条 SQL 的 WHERE num >= 1 就是最后一道 fencing, + // 哪怕 Redis 锁真的失效了、两个请求都进来了,DB 也只会让一个成功 + return deductInDB(ctx, skuID, userID) +} +``` + +!!! note "持久化配置如何影响这把锁" + | 持久化配置 | 主节点进程重启后 | 主节点整机报废、从节点升主后 | + |---|---|---| + | 只开 RDB | 🔴 锁丢失(回到上次快照) | 🔴 锁丢失 | + | AOF `everysec` | 🟡 1 秒内的锁丢失 | 🔴 锁可能丢失(异步复制没追上) | + | AOF `always` + `WAIT 1 100` | 🟢 锁保住 | 🟡 概率大幅降低,但仍非强一致 | + | etcd / ZooKeeper | 🟢 锁保住 | 🟢 共识保证,且有 fencing token | + + 结论:**开 AOF 是必要的,但不是充分的。** 锁的正确性最终要靠业务侧的幂等和 DB 层的 CAS 兜底。 + +--- + +## ⚠️ 常见陷阱 + +!!! warning "陷阱一:生产环境用 SAVE" + **现象**:执行 `SAVE` 后整个 Redis 服务卡住几十秒,所有客户端超时,上游服务雪崩。 + + **原因**:Redis 处理命令是单线程的,`SAVE` 让**主进程自己**去遍历内存写文件,这期间没有任何人处理客户端请求。10GB 的实例可能卡几十秒。 + + **解决**:生产环境**永远用 `BGSAVE`**(fork 子进程干活,主进程照常服务)。同时注意 `SHUTDOWN` 命令在没开 AOF 时会隐式执行一次 SAVE——优雅停机脚本里要用 `SHUTDOWN NOSAVE`。另外别忽略:**主从全量同步时主节点会自动 BGSAVE**,所以哪怕你配了 `save ""`,加从节点的那一刻照样会 fork。 + +!!! warning "陷阱二:以为 appendfsync everysec 就绝对不丢" + **现象**:跟业务方承诺「开了 AOF 一条都不会丢」,结果机器断电后丢了数据,被追着问。 + + **原因**:`everysec` 的字面意思就是**最多丢 1 秒**。而且这 1 秒还不只是「Redis 没来得及 fsync」——写命令先进 `aof_buf`,再 `write()` 到 OS page cache,最后才 `fsync()` 到磁盘。整机断电时,**page cache 里没刷下去的部分一样会丢**,这跟 Redis 进程崩不崩没关系。即使配 `always`,如果磁盘自带 write-back 缓存又没有电池/FUA 支持,`fsync` 返回成功也不代表数据真的在盘片上了。 + + **解决**:① 老老实实告诉业务方「最多丢 1 秒」,不要承诺零丢失;② 真正零丢失的数据不要放 Redis,放 MySQL(`trx_commit=1` + `sync_binlog=1`);③ 对丢 1 秒也敏感的场景(分布式锁),要在业务侧做 fencing 兜底,而不是指望刷盘策略。 + +!!! warning "陷阱三:只开 RDB 却拿 Redis 当分布式锁 / 唯一真源" + **现象**:秒杀超卖、任务被两个 worker 重复执行、Write-Behind 的一批写永久丢失。 + + **原因**:只开 RDB 时,`save 900 1` 意味着**最坏情况丢 15 分钟**的数据。锁 key、库存计数、待落库的中间态,全都在这个丢失窗口里裸奔。更隐蔽的是主从场景:主节点内存里有锁,但异步复制还没送到从节点,主节点一挂、从节点升主,锁凭空消失,另一个客户端立刻能拿到同一把锁。 + + **解决**:① 只要 Redis 里有「别处查不到」的数据,**必须开 AOF**(`everysec` 起步)+ 混合持久化;② 主从切换丢锁的问题持久化解决不了,需要 `WAIT` 伪同步、Redlock 或干脆换 etcd;③ **Write-Behind 架构里绝不能把缓存当唯一真源**——必须有 AOF 兜底 + 消息队列做补偿,否则未落库的写会永久蒸发。 + +!!! warning "陷阱四:AOF 重写 / BGSAVE 期间内存翻倍,大实例被 OOM 干掉" + **现象**:业务高峰期 Redis 突然被内核 OOM Killer 杀掉,日志里看到 `Out of memory` 或者 `Can't save in background: fork: Cannot allocate memory`。 + + **原因**:三重内存叠加—— + 1. **fork 本身要内存**:内核需要为子进程复制一份页表,内存越大页表越大,而且这一步是**阻塞主进程**的(几 GB 实例可能卡几十到几百毫秒,看 `latest_fork_usec`) + 2. **COW 复制页**:重写/快照期间业务还在写,被写过的页会被复制一份。写入越密集、耗时越长,复制的页越多,**峰值可能接近平时的 2 倍** + 3. **AOF 重写缓冲区**(7.0 之前):重写期间的新命令还要额外堆在内存里 + + 另外如果开了**透明大页(THP)**,COW 每次复制的是 2MB 而不是 4KB,内存放大和延迟都会严重恶化。 + + **解决**:① `maxmemory` 别设成机器物理内存的 100%,留出至少 1 倍余量给 COW;② **关闭 THP**(`echo never > /sys/kernel/mm/transparent_hugepage/enabled`);③ 把自动重写挪到低峰期,或者干脆关掉自动重写改成定时任务手动触发;④ 监控 `latest_fork_usec`、`mem_fragmentation_ratio`、`aof_pending_rewrite`;⑤ 单实例别做太大,超过 10GB 就该考虑拆分或用集群;⑥ Redis 7.0 的多文件 AOF 去掉了重写缓冲区,能明显缓解这个问题。 + +--- + +## 🏋️ 练习题 + +??? question "练习 1:AOF 重写到底是「把旧文件压缩一遍」还是「重新生成一份」?给定命令序列 `SET k 1` → `INCRBY k 5` → `SET k 9` → `DEL k` → `SET k 2`,重写后文件里是什么?为什么被删除的 key 连一条 `DEL` 都不用写?" + + ??? success "答案" + **是重新生成,完全不读旧 AOF 文件。** + + 重写的做法是:`fork` 出子进程后,**遍历当前内存里的数据集**,为每个**还存在**的 key 生成一条能重建其最终值的最小命令,写进新文件,最后 `fsync` + `rename` 原子替换旧文件。 + + 所以给定序列重写后只剩一条: + + ``` + SET k 2 + ``` + + 前 4 条全部消失——`SET k 1`、`INCRBY k 5`、`SET k 9` 的结果都被后面的命令覆盖了,`DEL k` 的结果又被最后的 `SET k 2` 覆盖了。 + + **被删除的 key 为什么不写 `DEL`**:因为重放是从一个**空数据库**开始的。「这个 key 不存在」这件事,在空数据库里天然就成立,不需要任何命令去表达它。写一条 `DEL k` 反而是多余的动作。 + + 本质上,AOF 重写完成了一次**数据结构转换**:从「命令序列 / log 结构(记录怎么改)」转换成「键值状态快照 / hashmap 结构的等价描述(记录改成什么样)」。这就是 AOF 能被大幅压缩的根本原因——大量历史命令对恢复最终状态是冗余的。 + +??? question "练习 2:RDB 和 AOF 都开着,Redis 重启时用哪个恢复?为什么?如果 AOF 文件损坏被删掉了会发生什么?" + + ??? success "答案" + **优先用 AOF。** 只要 `appendonly yes`,Redis 启动时就加载 AOF 文件,**完全不看 `dump.rdb`**。 + + 原因:AOF 的数据更全。RDB 快照可能是几小时前的(`save 900 1` 最坏情况落后 15 分钟),而 AOF(`everysec`)最多落后 1 秒。用 AOF 恢复的数据一定包含 RDB 里的全部内容。开了混合持久化的话,加载过程是「先按 RDB 二进制快速载入全量段,再重放少量 AOF 增量段」,速度也接近纯 RDB。 + + 此时 RDB 的角色退化为:**定期备份的归档载体** + **主从全量同步的传输格式**,不再承担崩溃恢复。 + + **AOF 损坏被删的后果**:Redis 找不到 AOF,会**回退去加载 RDB**。这时候你会拿到一份**很旧的数据**,而业务完全不知道——它以为一切正常,实际上可能已经退回到几小时前的状态。这比「启动失败、什么都没有」更危险,因为错误是静默的。 + + 所以:① AOF 文件一定要监控(`aof_last_bgrewrite_status`、`aof_last_write_status`);② 损坏时用 `redis-check-aof --fix` 修复(它会截掉尾部损坏部分,那部分数据丢失)而不是直接删;③ RDB 和 AOF 都要有独立的备份策略,不能只靠一个。 + +??? question "练习 3:混合持久化(`aof-use-rdb-preamble yes`)解决了什么矛盾?它和「RDB、AOF 两个都开」有什么区别?为什么说它是「AOF 文件内部」的事?" + + ??? success "答案" + **解决的矛盾**:RDB 恢复快但不安全(丢两次快照之间的数据),AOF 安全但恢复慢(几 GB 命令要逐条重放,可能十几分钟)。 + + 混合持久化的做法:**在 AOF 重写的时候**,新 AOF 文件的前半部分用 **RDB 二进制格式**写一份全量快照,后半部分用 **AOF 文本格式**追加重写期间产生的增量命令。 + + ``` + [ RDB 格式全量快照 | AOF 格式增量命令 ] + ``` + + 加载时:Redis 先看文件头是不是 magic string `"REDIS"`——是就按 RDB 二进制反序列化载入全量(秒级),然后重放后面少量增量命令(毫秒级)。既拿到了 AOF 的数据安全性(增量一条不漏,丢的还是 1 秒),又拿到了 RDB 的加载速度。 + + **和「两个都开」的区别**:这是最关键的一点。「RDB + AOF 都开」是**两个独立的文件**——`dump.rdb` 和 `appendonly.aof`,恢复时只用 AOF,RDB 白白浪费磁盘和 fork 开销。而混合持久化是**一个 AOF 文件内部的格式优化**:RDB 二进制内容就存在 AOF 文件里,作为它的前半段。 + + 所以说它是「AOF 文件内部」的事——它不产生新文件,不改变恢复优先级(还是加载 AOF),只是让 AOF 重写生成的那份文件**前半段换个更紧凑、加载更快的编码方式**。Redis 7.0 的多文件 AOF 把这个结构显式化了:`appendonly.aof.N.base.rdb`(base 段,RDB 格式)+ `appendonly.aof.N.incr.aof`(incr 段,命令格式)+ `appendonly.aof.manifest`(清单)。 + +--- + +## 🔗 相关链接 + +- [Redis Persistence 官方文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/) — RDB / AOF 的权威说明,含 Redis 7.0 多文件 AOF +- [Redis 7.0 Multi Part AOF 设计文档](https://github.com/redis/redis/blob/unstable/src/MULTI-PART-AOF.md) — base / incr / manifest 三件套的实现细节 +- [Redis Persistence (antirez)](http://antirez.com/latest/0) — 作者视角讲 RDB 与 AOF 的取舍 +- [Redis Distributed Locks 官方文档](https://redis.io/docs/latest/manual/patterns/distributed-locks/) — SETNX 单实例锁与 Redlock 规范 +- [Redlock 算法原始提案](https://redis.io/docs/latest/manual/patterns/distributed-locks/#the-redlock-algorithm) — antirez 提出的多数派加锁 +- [How to do distributed locking — Martin Kleppmann](https://martin.klepmann.com/2016/02/08/how-to-do-distributed-locking.html) — 对 Redlock 的著名批评,fencing token 与效率锁/正确性锁的区分 +- [An analysis of Redlock (antirez 回应)](https://antirez.com/news/101) — antirez 对 Kleppmann 的逐条反驳 +- [go-redis 文档](https://redis.uptrace.dev/) — Go 客户端,`SetNX` / `Eval` / `Wait` 的用法 +- [MySQL binlog 与时间点恢复](https://dev.mysql.com/doc/refman/8.0/en/point-in-time-recovery.html) — `mysqlbinlog --start-position/--stop-position` 的 PITR 实操 +- [多级缓存与读写策略](../../architecture/cache/cache-multilevel-read-write.md) — Write-Behind 为什么必须配 AOF 兜底 +- [高并发秒杀系统设计](../../architecture/seckill-design.md) — 库存预扣与分布式锁的实战场景 diff --git a/docs/database/redis/sentinel.md b/docs/database/redis/sentinel.md new file mode 100644 index 0000000..4e56adc --- /dev/null +++ b/docs/database/redis/sentinel.md @@ -0,0 +1,970 @@ +# Redis 哨兵(Sentinel)机制 + +!!! note "💡 一句话概述" + 主从复制解决了"读扩展 + 数据冗余",但主节点挂了还得靠人爬起来改配置、切客户端;**哨兵(Sentinel)就是那个替你值班的运维**——它持续监控主从,主挂了就自动投票选一个从升为主,并把新主地址告诉所有客户端。它只解决**高可用**,不解决**容量与写扩展**(写仍然只有一个主),而且因为是异步复制,切换瞬间**可能丢数据**。 + +--- + +## 🔑 核心概念 + +1. **哨兵(Sentinel)**:独立进程(默认端口 `26379`),不存业务数据,只负责监控、通知、自动故障转移、提供主地址。 +2. **主观下线 SDOWN**:单个哨兵在 `down-after-milliseconds` 内 PING 不通某个实例——"我觉得它挂了"。 +3. **客观下线 ODOWN**:多个哨兵都认为主挂了,票数 `>= quorum`——"大家投票确认它挂了"。**只有 master 会被判 ODOWN**。 +4. **Leader 选举**:故障转移不是谁想干就能干,哨兵之间用类 Raft 算法选出一个 Leader 来执行,需要拿到 `max(quorum, 哨兵数/2+1)` 票。 +5. **Configuration Provider**:客户端不直连 Redis IP,而是先问哨兵"`mymaster` 现在的主是谁",切主后客户端自动感知新地址。 + +--- + +## 📝 详解 + +### 1. 为什么需要哨兵:主从复制的"最后一公里" + +先打个比方。**主从复制像给病人配了备份的病历**——一个主治医生(master)负责开药写字(可写),几个实习医生(replica)抄一份病历、帮忙接待轻症病人(可读)。抄病历这件事解决了两个问题:实习医生也能看病(读扩展),主治医生桌子烧了病历还在(数据冗余)。 + +但问题来了:**主治医生突然心梗倒地,谁来接班?** + +没有哨兵的时候,流程是这样的(也就是所谓"人工干预"): + +``` +凌晨 3:00 master 进程 OOM 被 kill +凌晨 3:01 监控告警响了(或者没响) +凌晨 3:10 值班同学被电话叫醒,揉着眼睛登机器 +凌晨 3:15 确认 master 真挂了,不是网络抖动 +凌晨 3:20 挑一个数据最全的 replica,执行 SLAVEOF NO ONE +凌晨 3:25 把其他 replica 改成 SLAVEOF 新主 +凌晨 3:30 改业务配置里的 master IP,滚动重启所有服务实例 +凌晨 3:45 服务恢复。中间 45 分钟,全站写入不可用。 +``` + +这 45 分钟就是"最后一公里"。哨兵做的事,就是把上面这一整套流程**自动化 + 秒级完成**: + +| 环节 | 人工运维 | 哨兵 | +|------|---------|------| +| 发现主挂了 | 监控告警 → 人看 → 人判断 | 每秒 PING,`down-after-milliseconds` 后自动判下线 | +| 避免误判 | 靠人的经验("是不是网络抖了?") | 多个哨兵投票(quorum),少数派说了不算 | +| 选新主 | 人肉比对 `INFO replication` 的 offset | 按优先级 / offset / runid 自动排序 | +| 切主 | 手敲 `SLAVEOF` 命令 | Leader 自动下发 `REPLICAOF NO ONE` | +| 通知客户端 | 改配置文件 + 重启服务 | 发布 `+switch-master`,客户端自动重连新主 | +| 耗时 | 几十分钟 | 通常几秒到几十秒 | + +> ⚠️ 注意:哨兵**不会**帮你解决"写太多了扛不住"和"内存装不下了"这两个问题。那需要分片(Redis Cluster 或业务层分库)。 + +### 2. 哨兵的四大功能 + +Redis 官方文档把哨兵的职责归纳成四件事,面试常考: + +| 功能 | 英文 | 具体做什么 | +|------|------|-----------| +| **监控** | Monitoring | 持续检查 master、replica、其他哨兵是否正常工作(PING + INFO 轮询) | +| **通知** | Notification | 发现异常时,通过 API 通知运维系统,再由运维系统转发邮件 / 钉钉 / 企业微信 / Slack | +| **自动故障转移** | Automatic Failover | master 挂了,自动把一个 replica 提升为新 master,并让其他 replica 改挂到新主 | +| **配置提供者** | Configuration Provider | 客户端启动时不连 Redis,而是先连哨兵问:"服务名 `mymaster` 现在的主节点地址是什么?" | + +这四个功能是有**顺序依赖**的:先能监控,才谈得上通知;能通知 + 能投票,才敢做故障转移;故障转移做完了,还得有个统一的地方告诉客户端"新主是谁",否则切了也白切。 + +关于"通知"多说一句:哨兵自己**不直接发邮件**。它的做法是把事件 PUBLISH 到自己进程内的 pub/sub 频道(如 `+sdown`、`+odown`、`+switch-master`),或者调用 `sentinel notification-script` 配置的脚本。你的运维系统订阅这些频道,再决定往哪个 IM 推。 + +``` +哨兵内部事件频道(在哨兵进程的 26379 端口上订阅): + +reset-master 主地址被重置 + +slave / +replica 发现新的从节点 + +failover-state-* 故障转移进入某个阶段 + +failover-end 故障转移完成 + +switch-master 主从切换完成 ← 客户端最关心的一个 + +sdown / -sdown 主观下线 / 主观下线解除 + +odown / -odown 客观下线 / 客观下线解除 +``` + +### 3. 部署形态:独立进程,生产至少 3 个 + +哨兵**不是** Redis 的一个功能开关,它是一个**独立的进程**,虽然用的是同一份 `redis-server` 二进制: + +```bash +# 两种等价的启动方式 +redis-sentinel /etc/redis/sentinel.conf +redis-server /etc/redis/sentinel.conf --sentinel +``` + +启动后它监听 **26379** 端口(哨兵之间互相同步用的也是这个端口,不是 6379)。它不存业务数据,只存"谁是我的 master、有哪些 replica、有哪些哨兵同伴"这些元信息,而且**会把自己写回 `sentinel.conf`**——所以哨兵的配置文件启动后会变,别用只读卷挂载它。 + +一套典型的哨兵集群长这样: + +``` + ┌──────────── 哨兵集群(独立进程,:26379)─────────────┐ + │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ + 客户端 ───►│ │Sentinel-1 │ │Sentinel-2 │ │Sentinel-3 │ │ + (先问主地址)│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ + │ └──── hello 消息互相发现/互相监控 ────┘ │ + └─────────────────────────┬────────────────────────────┘ + │ 每 1s PING + │ 每 2s 向 __sentinel__:hello 发 hello + │ 每 10s INFO(顺带发现新 replica) + ┌───────────────────────────────┼───────────────────────────────┐ + ▼ ▼ ▼ + ┌──────────────┐ 异步复制 ┌──────────────┐ ┌──────────────┐ + │ master ├───────────────► │ replica-1 │ │ replica-2 │ + │10.0.0.21:6379│ │10.0.0.22:6379│ │10.0.0.23:6379│ + │ 可读可写 │ │ 只读 │ │ 只读 │ + └──────▲───────┘ └──────────────┘ └──────────────┘ + │ + └──── 客户端从哨兵拿到地址后,直连 master 读写 +``` + +**生产环境的三条硬要求**: + +| 要求 | 为什么 | +|------|--------| +| 至少 3 个哨兵 | 选举需要多数派。2 个哨兵时挂 1 个只剩 1 个,凑不出多数派,故障转移直接瘫痪 | +| 奇数个 | 4 个哨兵的多数派是 3,容错能力跟 3 个哨兵一样(都只能挂 1 个),白多一台机器 | +| 分布在不同物理机 / 不同机架 / 不同机房 | 一台机器挂了同时带走 master 和它上面的哨兵,等于哨兵数量凭空少一个 | + +容错能力对照表(多数派 = `哨兵数/2 + 1`): + +| 哨兵数量 | 多数派 | 最多能挂几个哨兵还能故障转移 | +|:---:|:---:|:---:| +| 1 | 1 | 0(挂了整个高可用没了,纯玩具) | +| 2 | 2 | 0(挂 1 个就废,比 3 个还差) | +| **3** | 2 | **1** ✅ 最小生产配置 | +| 5 | 3 | 2 ✅ 推荐跨机房配置 | +| 7 | 4 | 3(一般用不到) | + +**一个极其重要的对称性**:哨兵节点自己也**被其他哨兵监控**。Sentinel-1 挂了,Sentinel-2 和 Sentinel-3 会把它标成 SDOWN,后续选举自动不再算它那一票。 + +### 4. 主观下线 SDOWN vs 客观下线 ODOWN + +这是哨兵最容易考混的一对概念。用护士的比方:**SDOWN 是"我这个班次的护士发现 3 号床没呼吸了",ODOWN 是"叫来另外两个护士一起确认,大家都说没呼吸了"**。 + +- **SDOWN(Subjectively Down,主观下线)**:单个哨兵在 `down-after-milliseconds` 时间内向某个实例发 `PING`,没有收到**有效回复**(有效回复包括 `+PONG`、`-LOADING`、`-MASTERDOWN` 三种),就单方面标记它为 SDOWN。 +- **ODOWN(Objectively Down,客观下线)**:SDOWN 只是"一家之言",可能是网络抖动、可能是这个哨兵自己网卡抽风。所以对 **master**,哨兵还要做第二步:向其他哨兵发 `SENTINEL is-master-down-by-addr` 询问"你们也觉得主挂了吗"。**当认为主已下线的哨兵数量(含自己)`>= quorum` 时**,这个哨兵才把 master 标记为 ODOWN。 + +```mermaid +graph TD + A["哨兵每 1s PING 一个实例"] --> B{"down-after-milliseconds 内
收到有效回复?"} + B -->|是| C["状态 online"] + B -->|否| D["标记 SDOWN(主观下线)"] + D --> E{"它是什么角色?"} + E -->|"replica / 其他哨兵"| F["停在 SDOWN
永远不会升级成 ODOWN"] + E -->|master| G["向其他哨兵发
SENTINEL is-master-down-by-addr"] + G --> H{"认为主下线的哨兵数
>= quorum ?"} + H -->|否| I["仅 SDOWN,不触发故障转移
(继续问,票数够了再说)"} + H -->|是| J["标记 ODOWN(客观下线)
→ 触发 Leader 选举"] +``` + +**关键结论(面试高频)**: + +| 实例类型 | 会 SDOWN 吗 | 会 ODOWN 吗 | 原因 | +|---------|:---:|:---:|------| +| master | ✅ | ✅ | 只有主挂了才需要故障转移,值得多哨兵投票确认 | +| replica | ✅ | ❌ | 从挂了不影响可用性,没必要投票 | +| 其他 sentinel | ✅ | ❌ | 哨兵挂了只需从选举名单里摘掉即可 | + +还有一点很反直觉:**ODOWN 是"局部共识",不是全局状态**。Sentinel-1 凑够了 3 票认为主 ODOWN,Sentinel-2 可能因为自己刚好也问了一圈但票没凑够,仍然认为只是 SDOWN。这没关系——只要**有一个**哨兵认定 ODOWN 并发起选举,故障转移就能往前走。 + +另外,`SENTINEL is-master-down-by-addr` 这个命令是**一鱼两吃**的: + +``` +SENTINEL is-master-down-by-addr + +第 4 个参数 runid 传 "*" → 我只是想问问主挂没挂(探测阶段) +第 4 个参数 runid 传自己的 runid → 我在给自己拉票(选举阶段) +``` + +返回值里会带上 `leader`(我认为的 Leader 是谁)和 `vote-granted`(我给不给你投票),选举就是复用这个命令完成的。 + +### 5. 故障转移完整流程(重点) + +把整条链路走一遍。假设 3 个哨兵、`quorum = 2`、`down-after-milliseconds = 5000`。 + +```mermaid +sequenceDiagram + autonumber + participant C as 客户端 + participant S1 as Sentinel-1 + participant S2 as Sentinel-2 + participant S3 as Sentinel-3 + participant M as master 21 + participant R1 as replica-1 22 + participant R2 as replica-2 23 + + Note over S1,M: 阶段一 下线判定 + S1->>M: PING(每秒) + Note over S1,M: 连续 5000ms 无有效回复 + Note over S1: master 标记 SDOWN + S1->>S2: SENTINEL is-master-down-by-addr 21 6379 epoch * + S2-->>S1: down=1(我也认为它挂了) + S1->>S3: SENTINEL is-master-down-by-addr 21 6379 epoch * + S3-->>S1: down=1 + Note over S1: 赞同票 3 大于等于 quorum=2 → ODOWN + + Note over S1,S3: 阶段二 Leader 选举(类 Raft) + S1->>S1: current_epoch++,先投自己一票 + S1->>S2: is-master-down-by-addr 21 6379 epoch5 S1runid + S2-->>S1: vote-granted, leader=S1 + S1->>S3: is-master-down-by-addr 21 6379 epoch5 S1runid + S3-->>S1: vote-granted, leader=S1 + Note over S1: 得票 3 大于等于 max(quorum=2, 3/2+1=2) → 当选 Leader + + Note over S1,R2: 阶段三 选新主 + 执行切换 + Note over S1: 候选排序:priority → offset → runid
选中 replica-1 + S1->>R1: REPLICAOF NO ONE + R1-->>S1: OK + Note over S1: 每秒轮询 R1 的 INFO
直到 role:master + S1->>R2: REPLICAOF 10.0.0.22 6379 + S1->>S2: 通过 __sentinel__:hello 广播新配置(config_epoch++) + S1->>S3: 同上 + M-->>S1: (master 复活了) + S1->>M: REPLICAOF 10.0.0.22 6379(旧主降级为从) + S1-->>C: PUBLISH +switch-master mymaster 21 6379 22 6379 + C->>R1: 重新解析主地址,连上新主,写入恢复 +``` + +下面把六个步骤逐个拆开讲。 + +#### 步骤 1:客观下线判定 + +见上一节。`down-after-milliseconds` 是这个阶段唯一的旋钮,它同时决定了**误判概率**和**发现速度**: + +| 取值 | 效果 | +|------|------| +| 太小(如 1000ms) | 一次 GC 停顿 / 网络抖动就可能触发故障转移,**误切** | +| 默认 30000ms | 稳妥但慢,故障后 30 秒才开始动手 | +| 生产常用 5000~10000ms | 在"发现速度"和"抗抖动"之间取平衡 | + +#### 步骤 2:Leader 选举 + +**为什么需要 Leader?** 因为故障转移是个"只能有一个人在操作"的动作。如果 3 个哨兵同时认定主挂了、同时去提升 replica,那 replica-1 被 S1 提升、replica-2 被 S2 提升,就会出现**两个主同时接受写入**——数据直接分叉,这是灾难。所以必须选一个代表来干活。 + +哨兵用的是一套**简化版 Raft**: + +``` +① 每个哨兵维护一个 current_epoch(纪元),启动时从本地配置读,选举时 +1 +② 谁先认定 ODOWN,谁就先当候选人:epoch++,投票给自己,然后拿着自己的 runid + 去向其他哨兵发 is-master-down-by-addr 拉票 +③ 每个哨兵在同一个 epoch 内【只能投一票,先到先得】 + —— 已经投给别人了,就回复对方 leader=<已投的那位> +④ 候选人收到的票数 >= max(quorum, 哨兵总数/2 + 1) → 当选 Leader +⑤ 本轮没选出 Leader(票不够 / 候选人对撞)→ 不会立刻重来,而是等一个 + 与 failover-timeout 相关的间隔(并做随机化)后,epoch 再 +1 开始新一轮选举 + —— 随机化是为了避免两个候选人每轮都同时发起、互相把对方的票吃掉 +``` + +这里藏着**全文最重要的一句话**: + +> **`quorum` 只决定"判定主下线需要几票",它不决定"能不能执行故障转移"。** +> 真正执行 failover 的 Leader,必须拿到**哨兵总数的多数派**(`哨兵数/2 + 1`)选票。 + +举个数字例子,3 个哨兵、`quorum = 1`: + +| 存活哨兵数 | 能否判 ODOWN(需 >= 1 票) | 能否选出 Leader(需 >= 2 票) | 结果 | +|:---:|:---:|:---:|------| +| 3 | ✅ | ✅ | 正常故障转移 | +| 2 | ✅(1 票就够) | ✅(2 票刚好等于多数派) | 能故障转移 | +| 1 | ✅(它自己一票就够) | ❌(只有 1 票,多数派要 2 票) | **认定主挂了,但永远切不动** | + +所以 `quorum = 1` **绝不等于**"一个哨兵就能自己切主"。这也是为什么"哨兵只部署 2 个甚至 1 个"是致命错误——挂了之后系统会卡在一个非常别扭的状态:**明明知道主挂了,就是不肯切**。 + +#### 步骤 3:Leader 挑选新主 + +Leader 从所有 replica 里挑一个"最合适的"。这是一个**三级排序**,前一级分不出胜负才看下一级: + +``` +第 0 步:先淘汰不合格的 + ✗ 已经 SDOWN 的 replica + ✗ 与哨兵断开连接太久的 replica + ✗ master_link_down_time > down-after-milliseconds × 10 的 replica + (主从链路断了太久,数据落后太多,提升它等于丢一大片数据) + ✗ 最近 5 秒内没响应过哨兵 PING 的 replica + +第 1 级:replica-priority(旧名 slave-priority)—— 值越小优先级越高 + replica-priority = 0 表示【永远不参与选举】(人工禁用的副本) + 默认值 100 + +第 2 级:replication offset 最大 —— 数据最全,丢数据最少 + offset 是从主节点复制过来的字节数偏移量,越大说明追得越紧 + +第 3 级:run id 最小 —— 字典序最小的那个 + 纯粹为了打破平局,保证选举结果确定、可复现 +``` + +**为什么 offset 是第二重要的判据?** 因为 Redis 主从是**异步复制**的:master 收到写请求 → 立刻回 `OK` 给客户端 → 再异步把命令发给 replica。所以任何时刻 replica 的数据都**落后** master 一点点。挑 offset 最大的那个,就是把这点损失压到最小。 + +#### 步骤 4:执行切换 + +Leader 对被选中的 replica 发 `REPLICAOF NO ONE`(旧名 `SLAVEOF NO ONE`),然后**每秒轮询它的 `INFO`**,直到看到 `role:master` 才继续往下走。 + +接着对其余 replica 发 `REPLICAOF <新主IP> <新主端口>`,让它们改挂到新主。这一步受 `parallel-syncs` 控制: + +``` +parallel-syncs = 1(默认): + replica-2 先同步 → 同步完 → replica-3 再同步 + ✅ 任何时刻都有尽可能多的 replica 在线提供读服务 + ❌ 整体恢复时间长 + +parallel-syncs = 2: + replica-2 和 replica-3 同时同步 + ✅ 恢复快 + ❌ 同步期间大量 replica 不可读,读请求全压到新主上 +``` + +`parallel-syncs` **越小,故障转移期间对服务的影响越小,但整个恢复过程越慢**。副本数量多(比如 5 个以上)时可以适当调大到 2~3。 + +#### 步骤 5:旧主复活后自动降级 + +这是哨兵非常贴心的一个设计。老 master 修好了重新启动,它内存里还留着 `replicaof` 为空的状态,会以为自己是主,开始接受写入——**这就双主了**。 + +哨兵的处理:老 master 一上线,哨兵立刻向它发送 `REPLICAOF <新主IP> <新主端口>`,把它降级成新主的从节点,然后它开始全量/增量同步新主的数据。 + +``` +故障转移前 故障转移后 +────────────────────────── ────────────────────────── + master(21) ✗ 挂了 replica-1(22) ★ 新主,可写 + ▲ ▲ ▲ ▲ + │ └─── replica-2(23) 只读 │ └─── replica-2(23) 只读 + └────────── replica-1(22) 只读 └────────── master(21) 只读 + ↑ 复活后自动降级 + 客户端 → 问哨兵 → 21:6379 客户端 → 问哨兵 → 22:6379 +``` + +!!! warning "旧主复活那一刻的写入会全部丢失" + 从"master 挂掉"到"哨兵把它降级为 replica"这段时间里,如果 master 其实没挂、只是网络分区(哨兵连不上它,但客户端还连得上),它仍在接受写入。等分区恢复、它被降级为 replica 时,会**清空自己的数据去同步新主**——这期间的所有写入,**永久丢失,无法找回**。这就是"脑裂",详见常见陷阱。 + +#### 步骤 6:配置传播与客户端通知 + +切换完成后,新配置需要让所有人知道。哨兵用了**两套不同的通道**,很容易搞混: + +| 通道 | 在哪里 | 给谁看 | 干什么 | +|------|-------|-------|--------| +| `__sentinel__:hello` | **被监控的 master / replica** 上的 pub/sub 频道 | 其他哨兵 | 哨兵每 2 秒往这里发一条 hello 消息,内容是"我是谁、我的 IP 端口、我认为当前主是谁、主的 config_epoch"。用于**哨兵互相发现**和**配置传播** | +| `+switch-master` 等 | **哨兵自己**(26379 端口)上的 pub/sub 频道 | 客户端 / 运维系统 | 通知"主地址从 A 变成了 B" | + +`hello` 消息的格式(在 master 上 `PSUBSCRIBE __sentinel__:*` 就能抓到): + +``` +,,,, +,,, + +例: +10.0.0.11,26379,8a2f3...,5,mymaster,10.0.0.22,6379,7 +``` + +**客户端的正确姿势**:不要自己硬编码 Redis 主节点 IP,而是把哨兵地址列表交给客户端库,由库去处理"问地址 + 订阅 `+switch-master` + 重连"这一整套。见下面的代码示例。 + +### 6. 关键配置项 + +`sentinel.conf` 里的配置项分两类:**手写的**和**哨兵自己维护的**。手写的只有前 5 个,剩下的是哨兵运行时自动追加进配置文件的——这就是为什么你的 `sentinel.conf` 启动几次后会越来越长。 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `sentinel monitor ` | 声明要监控的主节点。`master-name` 是客户端寻址用的服务名;`quorum` 是判定 ODOWN 所需票数 | 无(必填) | +| `sentinel down-after-milliseconds ` | 多久 PING 不通就判主观下线 | `30000` | +| `sentinel failover-timeout ` | 故障转移各环节的超时基准:重启一轮 failover 需等 `2×` 该值;纠正挂错主的 replica 需等 `1×`;取消未产生变更的 failover 也用它 | `180000`(3 分钟) | +| `sentinel parallel-syncs ` | 故障转移时**同时**向新主发起同步的 replica 数量。越小对读服务影响越小,恢复越慢 | `1` | +| `sentinel auth-pass ` | 被监控 Redis 设了 `requirepass` 时,哨兵连它要用的密码 | 无 | +| `sentinel auth-user ` | 配合 ACL 使用的用户名(Redis 6+) | 无 | +| `sentinel announce-ip` / `announce-port` | 哨兵对外宣告的地址,容器 / NAT 环境必填 | 自动探测 | +| `sentinel notification-script ` | 主观/客观下线时调用的通知脚本 | 无 | +| `sentinel client-reconfig-script ` | 切主完成后调用,用于自动改客户端配置 | 无 | +| `requirepass`(写在 sentinel.conf 里) | 给哨兵自己设密码,客户端用 `SentinelPassword` 连 | 无 | + +**哨兵自动维护、不要手改**的项:`sentinel myid`、`sentinel config-epoch`、`sentinel leader-epoch`、`sentinel known-replica`、`sentinel known-sentinel`、`sentinel current-epoch`。 + +而下面这些是写在 **Redis 实例自己的 `redis.conf`** 里,**不是** `sentinel.conf`——这是实操中最高频的踩坑点: + +| 配置项(redis.conf) | 写在哪个节点 | 说明 | 默认值 | +|---------------------|------------|------|--------| +| `replica-priority`(旧名 `slave-priority`) | **replica** | 选新主时的优先级,**值越小越优先**,`0` = 永不当选 | `100` | +| `min-replicas-to-write` | **master** | 在线从库少于 N 个时主节点拒绝写入,用于**缓解脑裂** | `0`(关闭) | +| `min-replicas-max-lag` | **master** | 从库延迟超过 N 秒就不算"在线从库",与上面配套 | `10` | +| `replica-serve-stale-data` | **replica** | 与主断开时,继续用旧数据应答还是直接报错 | `yes` | +| `requirepass` | 全部实例 | 实例自己的访问密码,哨兵侧要配 `sentinel auth-pass` 才能连上 | 无 | + +一张图理清三类文件的分工: + +``` +┌── sentinel.conf ──────────────┐ ┌── master 的 redis.conf ─────┐ ┌── replica 的 redis.conf ───┐ +│ 谁是我的监控对象 │ │ 我自己怎么防脑裂 │ │ 我要跟谁同步、我能不能当主 │ +│ │ │ │ │ │ +│ sentinel monitor mymaster ... │ │ min-replicas-to-write 1 │ │ replicaof 10.0.0.21 6379 │ +│ sentinel down-after-ms ... │ │ min-replicas-max-lag 10 │ │ replica-priority 100 │ +│ sentinel failover-timeout ... │ │ requirepass ... │ │ replica-serve-stale-data │ +│ sentinel parallel-syncs ... │ │ appendonly yes │ │ appendonly yes │ +│ sentinel auth-pass mymaster.. │ │ │ │ │ +│ requirepass ...(哨兵自己的) │ │ │ │ │ +└───────────────────────────────┘ └────────────────────────────┘ └────────────────────────────┘ + ▲ 决定"怎么切" ▲ 决定"切的时候少丢数据" ▲ 决定"谁能被切成主" +``` + +!!! tip "两个最容易写错位置的配置" + - **`replica-priority` 写进 `sentinel.conf`**:不生效。它是 Redis 实例自己的属性,哨兵是通过每 10 秒一次的 `INFO` 命令**读**出来的,不是在哨兵侧配的。想立即生效可以直接在 replica 上执行 `CONFIG SET replica-priority 50`(注意 `CONFIG SET` 不落盘,重启会丢,最终还是得写进 `redis.conf`)。 + - **`min-replicas-to-write` 写进 `sentinel.conf`**:同样不生效,它必须在**被监控的 master** 的 `redis.conf` 里。而故障转移后新主是从某个 replica 升上来的——所以实践上**所有实例的 `redis.conf` 都统一带上这两行**,才能保证不管谁当主都有防脑裂保护。 + +### 7. 哨兵之间是怎么互相认识的 + +新手最常问的问题:"我起了 3 个哨兵,需要在每个哨兵的配置里把另外两个的地址都写上吗?" + +**不需要。** 只写 `sentinel monitor` 指向 master 就够了,其余全自动: + +```mermaid +graph LR + A["Sentinel-1 启动
只配了 monitor master"] -->|"每 2s 发布 hello 到
__sentinel__:hello"| M["master:6379
pub/sub 频道"] + B["Sentinel-2 启动
只配了 monitor master"] -->|订阅| M + C["Sentinel-3 启动
只配了 monitor master"] -->|订阅| M + M -->|"转发 hello"| B + M -->|"转发 hello"| C + B -.->|"解析出 Sentinel-1 的地址
建立直连,写入 known-sentinel"| A + C -.->|"同上"| A +``` + +机制拆开看: + +1. 每个哨兵在 master(和每个 replica)上 **`SUBSCRIBE __sentinel__:hello`**; +2. 同时每 **2 秒**向这个频道 **PUBLISH** 一条自己的 hello 消息(含自己的 IP、端口、runid、epoch,以及它认为的 master 地址和 config_epoch); +3. 其他哨兵订阅到这条消息,就"认识"了这个新同伴,建立起哨兵之间的直连(走 26379 端口),并把它记进本地配置的 `sentinel known-sentinel`; +4. 同理,哨兵每 **10 秒**对 master 执行一次 `INFO`,从返回结果里解析出所有 replica 的地址,自动记进 `sentinel known-replica`——**所以新增一个 replica 也不需要改哨兵配置**。 + +**唯一的硬要求**:新哨兵必须能连上 master,否则它谁也不认识。 + +> 🔥 一个坑:如果 master 设了 `requirepass`,而你忘了在 `sentinel.conf` 里配 `sentinel auth-pass`,哨兵会连不上 master,于是**发现不了任何其他哨兵,也发现不了任何 replica**——整个哨兵集群静默失效,`SENTINEL masters` 看着正常但 `num-other-sentinels` 是 0。排查哨兵问题时,先看这个数字。 + +**每个哨兵都在跑的定时任务**(这张表能解释上面所有"自动发现"行为): + +| 周期 | 对谁 | 做什么 | 作用 | +|:---:|------|-------|------| +| **每 1 秒** | 所有 master、replica、其他哨兵 | 发 `PING` | 判定 SDOWN / ODOWN 的唯一依据 | +| **每 2 秒** | master(通过 pub/sub) | 向 `__sentinel__:hello` 发布一条 hello 消息 | 让同伴发现自己;同步当前 master 地址与 `config_epoch` | +| **每 10 秒** | master、replica | 发 `INFO` | 发现新的 replica;确认 master 当前角色(`role:` 字段) | + +所以: + +- 新哨兵上线后,**最多 2 秒**就会被其他哨兵通过 hello 消息发现; +- 新 replica 挂到 master 上后,**最多 10 秒**就会被哨兵通过 `INFO` 发现并纳入监控; +- 判定一个实例下线,**至少需要 `down-after-milliseconds`**(因为它就是"连续 PING 失败多久"的阈值),这也是故障发现速度的下限。 + +### 8. 客户端寻址协议:怎么找到"当前主" + +"配置提供者"是四个功能里最容易被忽略、但对业务代码影响最大的一个。核心思想一句话: + +> **客户端永远不要记住 master 的地址,只记住哨兵的地址和 master 的名字。** + +一次完整的寻址流程: + +```mermaid +sequenceDiagram + autonumber + participant App as Go 服务 + participant S as 哨兵 26379 + participant M as master + + Note over App,S: ① 启动阶段(一次性) + App->>S: SENTINEL get-master-addr-by-name mymaster + S-->>App: ["10.0.0.21", "6379"] + App->>App: 用该地址建立连接池 + App->>S: SUBSCRIBE +switch-master
(保持长连接,等推送) + + Note over App,M: ② 正常读写(不再碰哨兵) + App->>M: SET / GET ... + M-->>App: OK + + Note over App,S: ③ 故障转移发生 + S-->>App: +switch-master mymaster
10.0.0.21 6379 → 10.0.0.22 6379 + App->>App: 丢弃旧连接池,指向新主 + App->>M: 重连新主,业务恢复 +``` + +关键点:**正常读写期间客户端是不碰哨兵的**,它拿着地址直连 master,性能跟普通客户端完全一样。哨兵只在两个时刻被用到——启动时问一次地址,以及切主时收一次推送。 + +那"定期拉取"体现在哪?两条兜底路径: + +| 路径 | 触发方式 | 说明 | +|------|---------|------| +| **推送(主)** | 客户端在哨兵上 `SUBSCRIBE +switch-master` | 切主完成的瞬间就知道,延迟最低。go-redis、Jedis、redis-py 都用这条 | +| **重连时拉取(兜底 1)** | 命令报错(连接失败 / `READONLY`) | 连接池发现连接坏了,重新调 `get-master-addr-by-name` 解析 | +| **定期轮询(兜底 2)** | 定时器周期性询问哨兵 | 有些实现(如早期 Jedis、部分自研客户端)会每隔若干秒主动问一次,防止 pub/sub 长连接静默断开导致漏消息 | + +自己实现客户端时,最小可用版本就是这样(伪代码): + +```go +// 极简版哨兵寻址:真实项目请直接用 NewFailoverClient,这里只为看清原理 +// 片段省略了 import,实际需要:context / errors / strings / sync / time +// 以及 github.com/redis/go-redis/v9 +type sentinelAwareClient struct { + masterName string + sentinels []string // 哨兵地址列表,全填上 + cur *redis.Client + curAddr string // 自己记住当前主地址,避免依赖客户端库的内部字段 + mu sync.RWMutex +} + +// resolveMaster 挨个问哨兵"当前主是谁",第一个答上来的就用 +// 任何一个哨兵挂掉都不影响 —— 这就是为什么 SentinelAddrs 要填全 +func (c *sentinelAwareClient) resolveMaster(ctx context.Context) (string, error) { + for _, addr := range c.sentinels { + sc := redis.NewSentinelClient(&redis.Options{Addr: addr, DialTimeout: 2 * time.Second}) + res, err := sc.GetMasterAddrByName(ctx, c.masterName).Result() + sc.Close() + if err == nil && len(res) == 2 { + return res[0] + ":" + res[1], nil + } + } + return "", errors.New("所有哨兵均不可达,无法解析主节点地址") +} + +// refresh 定期 + 出错时调用,把连接池指向最新主节点 +func (c *sentinelAwareClient) refresh(ctx context.Context) error { + addr, err := c.resolveMaster(ctx) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + if c.cur != nil && c.curAddr == addr { + return nil // 地址没变,什么都不做(避免无意义地重建连接池) + } + if c.cur != nil { + c.cur.Close() // 关掉指向老主的连接池 + } + c.cur = redis.NewClient(&redis.Options{Addr: addr}) + c.curAddr = addr + return nil +} + +// watch 订阅切主事件,一有推送立刻刷新(推送优先,轮询兜底) +func (c *sentinelAwareClient) watch(ctx context.Context) { + // 注意:这里连的是哨兵端口,不是 Redis 端口 + sentinelConn := redis.NewClient(&redis.Options{ + Addr: c.sentinels[0], + DialTimeout: 2 * time.Second, + }) + pubsub := sentinelConn.Subscribe(ctx, "+switch-master") + defer pubsub.Close() + defer sentinelConn.Close() // 别泄漏连接,哨兵侧也要维持连接池 + + ch := pubsub.Channel() + ticker := time.NewTicker(30 * time.Second) // 定期兜底轮询 + defer ticker.Stop() + + for { + select { + case msg, ok := <-ch: + if !ok { + return // pub/sub 连接被关掉,退出,由外层重启 + } + // payload: "mymaster 10.0.0.21 6379 10.0.0.22 6379" + if strings.Contains(msg.Payload, c.masterName) { + _ = c.refresh(ctx) + } + case <-ticker.C: + _ = c.refresh(ctx) // pub/sub 静默断开时的兜底 + case <-ctx.Done(): + return + } + } +} +``` + +!!! tip "生产环境的三种接入方式" + | 方式 | 做法 | 适用 | + |------|------|------| + | **客户端直连哨兵**(主流) | `NewFailoverClient` / Jedis `JedisSentinelPool` / redis-py `Sentinel` | 自研服务、能改代码的场景 | + | **VIP / LVS 漂移** | 切主时由脚本把 VIP 漂到新主,客户端仍连固定 VIP | 老系统改造成本高的场景 | + | **代理层** | Twemproxy / Codis / 云厂商 Redis 代理,代理自己去问哨兵 | 多语言混用、想统一收口连接管理 | + +### 9. 哨兵的局限 + +| 局限 | 说明 | 应对 | +|------|------|------| +| **① 只解决高可用,不解决容量和写扩展** | 无论挂几个 replica,**写入永远只有一个 master**,单机内存上限、单线程写入上限一个都没突破 | 需要分片:Redis Cluster,或业务层一致性哈希分库 | +| **② 异步复制 → 切主可能丢数据** | master 回 `OK` 给客户端之后、还没同步给 replica 就挂了,这部分写入随旧主一起消失。脑裂场景下丢得更多 | `min-replicas-to-write 1` + `min-replicas-max-lag 10` 缩小窗口;关键数据落 DB,别把 Redis 当唯一真源 | +| **③ 客户端必须支持哨兵协议** | 客户端如果硬编码 `10.0.0.21:6379`,切主后它会一直往老地址撞墙,报连接超时或 `READONLY You can't write against a read only replica` | 用 `NewFailoverClient` 这类封装;或走 VIP / 代理层(如 Twemproxy、Codis、云厂商代理) | +| **④ 故障转移不是瞬时的** | 从"主挂"到"客户端能写新主",通常是 `down-after-milliseconds` + 选举 + 提升 + 客户端重连,几秒到几十秒 | 业务侧做重试与降级(写失败进本地队列 / 返回友好错误),别假设 Redis 永远可用 | +| **⑤ 哨兵自身可能误判** | 哨兵所在机器负载高、网络抖动,都可能触发不必要的 failover | 调大 `down-after-milliseconds`;哨兵跨机房部署;避免哨兵和高负载服务混部 | + +**哨兵 vs Redis Cluster 怎么选?** + +| 维度 | Sentinel | Cluster | +|------|----------|---------| +| 解决什么 | 高可用(主挂了自动切) | 高可用 **+ 水平扩展**(数据分片到多主) | +| 写入能力 | 单主,等于单机上限 | 多主,可线性扩展 | +| 数据分布 | 每个节点都有全量数据 | 16384 个 slot 分片存储 | +| 客户端复杂度 | 低(只多一步"问主地址") | 高(要处理 `MOVED` / `ASK` 重定向) | +| 命令限制 | 无 | 跨 slot 的多 key 命令受限(`MSET`、Lua 脚本要用 hash tag) | +| 适用规模 | 数据量单机装得下(几十 GB 以内) | 数据量大 / 写入压力大 | +| 运维成本 | 低(多起 3 个小进程) | 高(至少 3 主 3 从 6 个节点起) | + +**结论**:数据量单机装得下 → 主从 + 哨兵,简单够用;装不下或写压力顶不住 → Cluster。 + +--- + +## 💻 代码示例 + +### sentinel.conf 配置片段 + +```conf +# /etc/redis/sentinel.conf (三个哨兵节点上内容基本一致,只有 myid 不同) + +# 哨兵监听端口,默认 26379 +port 26379 + +# 哨兵自己的密码(Redis 5.0.1+),客户端用 SentinelPassword 连接 +requirepass "sentinel-secret" + +# 容器 / NAT 环境下必须显式宣告自己的地址,否则其他哨兵连不上你 +# announce-ip 10.0.0.11 +# announce-port 26379 + +# ============ 核心:声明要监控的主节点 ============ +# 格式:sentinel monitor <服务名> +# 服务名 mymaster 是客户端寻址用的 key,全集群必须一致 +# quorum=2:3 个哨兵中至少 2 个认为主挂了,才判定 ODOWN +sentinel monitor mymaster 10.0.0.21 6379 2 + +# 主节点有 requirepass 时必须配,否则哨兵连不上 master, +# 后果是发现不了 replica、也发现不了其他哨兵(静默失效) +sentinel auth-pass mymaster "redis-secret" +# Redis 6+ ACL 场景还需要: +# sentinel auth-user mymaster sentinel_user + +# 5 秒 PING 不通就判主观下线(默认 30000,生产建议调小到 5000~10000) +sentinel down-after-milliseconds mymaster 5000 + +# 故障转移时,同时向新主同步的从节点数量 +# 1 = 串行同步,读服务影响最小,恢复最慢(默认) +sentinel parallel-syncs mymaster 1 + +# 故障转移各环节的超时基准,默认 180000(3 分钟) +sentinel failover-timeout mymaster 180000 + +# 异常时调用脚本通知运维系统(脚本自己决定发邮件还是发钉钉) +# sentinel notification-script mymaster /opt/redis/notify.sh +# 切主完成后自动改客户端配置(不推荐,建议让客户端走哨兵协议) +# sentinel client-reconfig-script mymaster /opt/redis/reconfig.sh + +# ↓↓↓ 以下由哨兵自动维护,绝对不要手改 ↓↓↓ +# sentinel myid 8a2f3c... +# sentinel config-epoch mymaster 0 +# sentinel leader-epoch mymaster 0 +# sentinel known-replica mymaster 10.0.0.22 6379 +# sentinel known-sentinel mymaster 10.0.0.12 26379 5b1e... +``` + +被监控的 **master 的 `redis.conf`** 里,建议加上防脑裂的两行: + +```conf +# 至少要 1 个从库在线、且延迟不超过 10 秒,主节点才接受写入 +# 网络分区时,被孤立的旧主会自动拒绝写入 —— 用可用性换一致性,避免脑裂丢数据 +min-replicas-to-write 1 +min-replicas-max-lag 10 +``` + +### 启动与运维命令 + +```bash +# 启动哨兵(两种方式等价) +redis-sentinel /etc/redis/sentinel.conf +redis-server /etc/redis/sentinel.conf --sentinel + +# 连上哨兵(注意端口是 26379,不是 6379) +redis-cli -h 10.0.0.11 -p 26379 -a sentinel-secret +``` + +```bash +# ===== 查看哨兵掌握的全局信息 ===== +127.0.0.1:26379> SENTINEL masters +# 返回所有被监控 master 的详情:ip/port/flags/num-slaves/num-other-sentinels/quorum/... +# ⚠️ 排查第一眼看 num-other-sentinels:应该是 2(3 哨兵集群),是 0 就说明没发现同伴 + +127.0.0.1:26379> SENTINEL master mymaster +# 只看 mymaster 这一个主的详情 + +127.0.0.1:26379> SENTINEL replicas mymaster # Redis 5.0 前叫 SENTINEL slaves +# 列出哨兵已知的所有从节点 + +127.0.0.1:26379> SENTINEL sentinels mymaster +# 列出哨兵已知的所有同伴哨兵 + +# ===== 客户端寻址:问"当前主是谁" ===== +127.0.0.1:26379> SENTINEL get-master-addr-by-name mymaster +1) "10.0.0.21" +2) "6379" + +# ===== 健康检查与演练 ===== +127.0.0.1:26379> SENTINEL ckquorum mymaster +# OK 3 usable Sentinels. Quorum and failover authorization can be reached +# 检查当前存活的哨兵数是否够完成一次故障转移 —— 上线前必跑! + +127.0.0.1:26379> SENTINEL failover mymaster +# 手动触发一次故障转移(不判断主是否真的挂了),用于演练 +# ⚠️ 生产环境慎用,这会造成一次真实的主从切换和数据丢失窗口 + +# ===== 动态增删监控(无需重启哨兵) ===== +127.0.0.1:26379> SENTINEL monitor newmaster 10.0.0.31 6379 2 +127.0.0.1:26379> SENTINEL remove oldmaster +127.0.0.1:26379> SENTINEL set mymaster down-after-milliseconds 10000 +127.0.0.1:26379> SENTINEL set mymaster quorum 2 + +# ===== 直接在 master 上抓哨兵的 hello 消息(调试用) ===== +redis-cli -h 10.0.0.21 -p 6379 PSUBSCRIBE __sentinel__:hello + +# ===== 在哨兵上订阅切主事件 ===== +redis-cli -h 10.0.0.11 -p 26379 SUBSCRIBE +switch-master +``` + +### Go 客户端:go-redis v9 接入哨兵 + +```go +package main + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// newFailoverRDB 创建一个"哨兵感知"的 Redis 客户端。 +// +// 关键点:这里填的【不是】Redis 主节点的地址,而是【哨兵集群】的地址。 +// go-redis 内部会做三件事: +// 1. 启动时向哨兵发 SENTINEL get-master-addr-by-name,解析出当前主节点地址; +// 2. 订阅哨兵的 +switch-master 频道,故障转移完成后第一时间拿到新主地址; +// 3. 遇到连接错误时主动重新解析主地址(兜底,防止漏掉 pub/sub 消息)。 +func newFailoverRDB() *redis.Client { + return redis.NewFailoverClient(&redis.FailoverOptions{ + // 必须与 sentinel.conf 里 `sentinel monitor ` 的名字完全一致 + MasterName: "mymaster", + + // 哨兵地址列表。建议全部填上:go-redis 会随机挑一个连, + // 连不上就自动换下一个,所以哨兵挂了客户端也能自愈 + SentinelAddrs: []string{ + "10.0.0.11:26379", + "10.0.0.12:26379", + "10.0.0.13:26379", + }, + + // 哨兵自己的密码(sentinel.conf 里的 requirepass),Redis 5.0.1+ 才有 + SentinelPassword: "sentinel-secret", + // 哨兵开启 ACL 时的用户名(Redis 6+) + // SentinelUsername: "sentinel_user", + + // 被监控 Redis 实例的密码(redis.conf 里的 requirepass) + // 注意区分:上面是"连哨兵"的密码,这里是"连 Redis"的密码 + Password: "redis-secret", + DB: 0, + + // 连哨兵的超时,别用默认值裸奔——哨兵不可达时能更快 failover 到下一个地址 + SentinelClientConfig: &redis.Options{ + DialTimeout: 2 * time.Second, + ReadTimeout: 2 * time.Second, + WriteTimeout: 2 * time.Second, + PoolSize: 5, + }, + + // 连接池与超时:故障转移期间这几项决定了你"多久能发现连不上老主" + PoolSize: 100, + MinIdleConns: 10, + DialTimeout: 2 * time.Second, + ReadTimeout: 500 * time.Millisecond, + WriteTimeout: 500 * time.Millisecond, + + // 重试:故障转移通常几秒内完成,配合下面的业务层重试可以平滑度过 + MaxRetries: 3, + MinRetryBackoff: 100 * time.Millisecond, + MaxRetryBackoff: 2 * time.Second, + + // 主从路由策略(默认只读写主节点): + // RouteByLatency: true → 按延迟选最快的节点读(可能读到从库) + // RouteRandomly: true → 随机选节点读 + // ReplicaOnly: true → 只读从库 + // ⚠️ 读从库会读到异步复制的滞后数据,强一致场景别开 + RouteByLatency: false, + }) +} + +// setWithFailoverRetry 写操作的重试封装。 +// +// 故障转移窗口内会碰到两类典型错误: +// - 连接类错误(老主进程没了 / 网络不通)→ 重试即可,go-redis 会自动重新解析主地址 +// - READONLY 错误(老主复活后被降级为从库,客户端还连着它)→ 必须重试 +func setWithFailoverRetry(ctx context.Context, rdb *redis.Client, key, val string) error { + const maxAttempts = 4 + var lastErr error + + for attempt := 1; attempt <= maxAttempts; attempt++ { + err := rdb.Set(ctx, key, val, 10*time.Minute).Err() + if err == nil { + return nil + } + lastErr = err + + // 只对"可能是切主导致"的错误重试,业务错误(如 OOM、WRONGTYPE)直接返回 + if !isRetryable(err) { + return err + } + + log.Printf("[redis] 第 %d 次写入失败,疑似故障转移中:%v", attempt, err) + + // 退避等待,给哨兵留出完成 failover 的时间 + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Duration(attempt) * 500 * time.Millisecond): + } + } + return fmt.Errorf("写入 %s 失败,重试 %d 次后放弃: %w", key, maxAttempts, lastErr) +} + +// isRetryable 判断错误是否属于"切主导致的临时不可用"。 +// 这里统一用错误消息匹配,避免依赖具体版本的错误类型定义; +// 生产代码可以在此基础上再叠加 context.DeadlineExceeded 等判断。 +func isRetryable(err error) bool { + if err == nil { + return false + } + // redis.Nil 表示 key 不存在,是正常的业务返回,绝不能重试 + if errors.Is(err, redis.Nil) { + return false + } + msg := err.Error() + for _, kw := range []string{ + // 老主复活后被降级为 replica,往它写就是这个错误 + "READONLY", + // 老主进程没了 / 网络不通 / 超时 + "connection refused", "connection reset", "EOF", "timeout", "i/o timeout", + } { + if strings.Contains(msg, kw) { + return true + } + } + return false +} + +func main() { + ctx := context.Background() + rdb := newFailoverRDB() + defer rdb.Close() + + // 看一眼当前解析到的主节点地址(故障转移后再调一次,地址会变) + if addr, err := masterAddr(ctx, rdb); err == nil { + log.Printf("[redis] 当前主节点:%s", addr) + } + + if err := setWithFailoverRetry(ctx, rdb, "user:1001:name", "alice"); err != nil { + log.Fatalf("写入失败: %v", err) + } + log.Println("写入成功") +} + +// masterAddr 演示"手动问哨兵要主地址"的底层做法。 +// 平时不需要这么写,NewFailoverClient 已经帮你做了; +// 但排查问题、或者自己实现客户端时,这就是核心那一步。 +func masterAddr(ctx context.Context, rdb *redis.Client) (string, error) { + sc := redis.NewSentinelClient(&redis.Options{ + Addr: "10.0.0.11:26379", + Password: "sentinel-secret", + }) + defer sc.Close() + + // 等价于 redis-cli 里的 SENTINEL get-master-addr-by-name mymaster + addr, err := sc.GetMasterAddrByName(ctx, "mymaster").Result() + if err != nil { + return "", err + } + return addr[0] + ":" + addr[1], nil // []string{"10.0.0.21", "6379"} +} +``` + +> 📌 **客户端到底是怎么感知切主的?** 两条路,go-redis 两条都走了: +> **主动推送** —— 客户端在哨兵上 `SUBSCRIBE +switch-master`,故障转移完成的一刻哨兵就 PUBLISH 一条 `+switch-master mymaster 10.0.0.21 6379 10.0.0.22 6379`,客户端立刻把连接池指向新主; +> **被动兜底** —— 万一 pub/sub 连接也断了漏掉消息,客户端在下一次操作报连接错误时会重新向哨兵解析主地址。 + +--- + +## ⚠️ 常见陷阱 + +!!! warning "陷阱一:哨兵只部署 1~2 个,多数派永远凑不出来" + **现象**:master 挂了,哨兵日志刷了满屏 `+odown`,但死活不动手 failover,`SENTINEL masters` 里 flags 一直是 `master,odown`。 + **原因**:判定 ODOWN 只需要 `quorum` 票,但**执行 failover 需要多数派票**(`哨兵数/2 + 1`)。2 个哨兵挂 1 个只剩 1 个,多数派要 2 票,永远选不出 Leader;1 个哨兵同理(多数派要 1 票看似够,但单点哨兵自己挂了整套高可用就没了)。 + **解法**:生产**至少 3 个哨兵,推荐奇数,且分散在不同物理机 / 机架 / 机房**。上线前必跑 `SENTINEL ckquorum ` 验证。 + +!!! warning "陷阱二:把 quorum 当成「执行 failover 需要的票数」" + **现象**:以为配 `sentinel monitor mymaster ... 1` 就能让任意一个哨兵独立决定切主,于是只部 2 个哨兵 + `quorum=1`,觉得"容错更高了"。 + **原因**:`quorum` 的语义只是**"多少个哨兵赞同,才能把 master 标成 ODOWN"**,它是下线判定的门槛,不是选举门槛。选举 Leader 需要的是 `max(quorum, 哨兵总数/2 + 1)` 票。 + **解法**:记死这句话——**quorum 管"认不认为主挂了",多数派管"敢不敢动手切主"**。3 哨兵集群 `quorum` 设 2 是常见配置;`quorum` 设 1 也能工作(更容易认定 ODOWN,但更容易被单个哨兵的误判带节奏)。 + +!!! warning "陷阱三:脑裂——旧主还在接受写入,切主后这些写全部丢失" + **现象**:网络分区时,哨兵和 replica 都连不上 master,判定 ODOWN 并提升了新主;但**另一侧的客户端还连着老 master 并且写入成功**(客户端拿到了 `OK`)。分区恢复后,老 master 被降级为 replica,执行 `REPLICAOF` 时会**清空自己的数据集去全量同步新主**——分区期间的所有写入,永久蒸发。 + **原因**:Redis 主从是异步复制 + AP 取向,没有 Raft 那种"写请求必须多数派确认"的机制。老 master 不知道自己已经被废黜了。 + **解法**:在**每个 master 的 `redis.conf`** 里加—— + + ```conf + min-replicas-to-write 1 # 在线从库少于 1 个时,主节点拒绝所有写入 + min-replicas-max-lag 10 # 从库延迟超过 10 秒就不算"在线" + ``` + + 被孤立的老 master 发现身边没有从库了,会主动拒绝写入(客户端收到错误),从而把数据丢失窗口压缩到 `min-replicas-max-lag` 秒以内。**代价是牺牲了一点可用性**:正常状态下如果所有从库都挂了,主库也会拒绝写入。此外,业务侧对关键数据(订单、余额)必须落 DB,别拿 Redis 当唯一真源。 + +!!! warning "陷阱四:客户端硬编码 master IP,切主后全线连不上" + **现象**:哨兵那边故障转移做得漂漂亮亮,业务侧却大面积报错——`connection refused`(老主进程没了),或者更阴间的 `READONLY You can't write against a read only replica.`(老主复活被降级为从库,客户端还死连着它写)。 + **原因**:代码里写死了 `redis.NewClient(&redis.Options{Addr: "10.0.0.21:6379"})`,压根没走哨兵协议,切主对它来说等于"地址失效"。 + **解法**:换成 `redis.NewFailoverClient`,把**哨兵地址列表 + MasterName** 交给客户端库;或者在中间加一层 VIP / 代理(云厂商的 Redis 代理、Twemproxy、Codis)屏蔽地址变化。同时务必实现**写重试**:切主的几秒窗口内,第一次失败要能自动重试到新主上。 + +--- + +## 🏋️ 练习题 + +??? question "练习 1:3 个哨兵、`quorum = 2`,现在挂了 2 个哨兵只剩 1 个存活,同时 master 也挂了。剩下的这个哨兵能完成故障转移吗?为什么?" + 提示:想一想"判定 ODOWN"和"选举 Leader"分别需要多少票。 + + ??? success "答案" + **不能。** 分两步看: + + 1. **判定 ODOWN**:需要 `>= quorum = 2` 票。只剩 1 个哨兵,它自己一票不够 → 连客观下线都判不了(严格说它会标 SDOWN,但凑不够 2 票,不会标 ODOWN)。 + 2. **选举 Leader**:需要 `max(quorum, 哨兵数/2 + 1) = max(2, 3/2+1) = 2` 票。同样只有 1 票。 + + 两道门槛都过不去,所以剩下的哨兵只会干瞪眼。这就是"**哨兵数量必须为奇数且至少 3 个**"的根本原因——3 个哨兵最多容忍挂 1 个(剩 2 个刚好等于多数派 2)。挂 2 个就等于整套高可用机制失效。上线前用 `SENTINEL ckquorum mymaster` 检查当前是否具备 failover 授权能力。 + +??? question "练习 2:哨兵挑选新主时,`replica-priority`、`replication offset`、`run id` 三个条件的优先级顺序是什么?为什么 offset 要排在 run id 前面?" + 提示:想一想每个条件代表什么业务含义。 + + ??? success "答案" + **顺序:`replica-priority` → `replication offset` → `run id`**(在此之前还会先淘汰掉 SDOWN、断连、`master_link_down_time` 超过 `down-after-milliseconds × 10` 的不合格副本)。 + + - **`replica-priority`(值越小越优先,0 = 永不当选,默认 100)**:这是**人为干预**的手段。比如某个 replica 机器配置差、或者跨机房延迟高,就给它设个大一点的优先级,或者直接设 0 让它只做冷备。人说了算,所以排第一。 + - **`replication offset`(越大越好)**:代表这个副本**同步到了多少数据**。offset 最大 = 落后主节点最少 = 提升它丢的数据最少。这是**数据完整性**考量,排第二。 + - **`run id`(字典序最小者胜)**:run id 是实例启动时随机生成的 40 位字符串,跟数据质量**毫无关系**。它纯粹是为了打破前两级都平局的情况,保证选举结果**确定且可复现**,避免出现"两个哨兵各自选了不同副本"的分歧。 + + 所以 offset 必须在 run id 前面:**offset 是有业务意义的选择,run id 只是无意义的决胜规则**。反过来的话,等于随机挑一个副本当主,白白多丢一批数据。 + +??? question "练习 3:你的 Go 服务用 `NewFailoverClient` 接入哨兵,某天日志里出现 `READONLY You can't write against a read only replica.`。请解释这个错误产生的完整过程,以及应该怎么处理。" + 提示:什么情况下客户端会连着一个"从库"往里写数据? + + ??? success "答案" + **产生过程(脑裂或切主滞后场景)**: + + 1. 网络抖动,哨兵判定老 master `10.0.0.21` ODOWN,选举 Leader 后把 `10.0.0.22` 提升为新主; + 2. 但你的服务此刻 pub/sub 消息漏了 / 连接池里的老连接还没失效,**仍然握着指向 `10.0.0.21` 的连接**; + 3. 老 master 复活(或分区恢复),哨兵向它下发 `REPLICAOF 10.0.0.22 6379`,它**被降级成从库**; + 4. 你的服务往这个"曾经的 master、现在的 replica"发写命令,Redis 返回 `READONLY You can't write against a read only replica.` + + **处理方式**: + + - **客户端层**:确认用的是 `NewFailoverClient` 而不是 `NewClient` 硬编码地址;go-redis 收到这个错误后会标记连接失效并重新向哨兵解析主地址,所以配置 `MaxRetries: 3` + 指数退避基本就能自愈。 + - **业务层**:写操作包一层重试(见代码示例的 `setWithFailoverRetry`),把 `READONLY`、`connection refused`、`connection reset`、`timeout` 归为可重试错误,重试 2~4 次、总耗时控制在几秒内。 + - **数据层**:重试全部失败时,别静默吞掉——落本地队列 / 返回明确错误码给上游,让关键写入有补偿路径。 + - **根因层**:如果这个错误频繁出现,说明发生了脑裂,检查 master 上有没有配 `min-replicas-to-write 1` + `min-replicas-max-lag 10`。 + +--- + +## 🔗 相关链接 + +- [Redis Sentinel 官方文档](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) — 哨兵机制的权威说明,failover 步骤与配置项以此为准 +- [Redis Replication 官方文档](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/) — 主从复制原理,哨兵的前置知识 +- [go-redis 官方文档](https://redis.uptrace.dev/) — Go 客户端,`NewFailoverClient` 与哨兵接入示例 +- [Raft 共识算法](https://raft.github.io/) — 哨兵 Leader 选举借鉴的算法(哨兵用的是简化版,无日志复制) +- [The Raft Consensus Algorithm 论文](https://raft.github.io/raft.pdf) — 想深挖 epoch / 投票 / 多数派机制的读这篇 +- [Redis Cluster 集群](cluster.md) — 本站同系列文章:哨兵解决不了容量与写扩展,Cluster 用 16384 个哈希槽做分片 +- [多级缓存与读写策略](../../architecture/cache/cache-multilevel-read-write.md) — 哨兵故障转移期间 Redis 可能短暂不可写,多级缓存是重要的兜底手段 diff --git a/mkdocs.yml b/mkdocs.yml index 2a9ad8a..efdb357 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,17 @@ nav: - 缓存系统设计: project/thumbup/cache-system.md - 并发控制与数据一致性: project/thumbup/data-consistency.md - 简历技术要点: project/thumbup/resume.md + - 数据库: + - database/index.md + - MySQL: + - ACID 与三大日志: database/mysql/acid-logs.md + - 事务隔离级别: database/mysql/transaction-isolation.md + - MVCC 机制: database/mysql/mvcc.md + - B+ 树索引与优化: database/mysql/btree-index.md + - Redis: + - 持久化 RDB/AOF/混合: database/redis/persistence.md + - 哨兵机制: database/redis/sentinel.md + - 集群 Cluster: database/redis/cluster.md - 架构: - architecture/index.md - 高并发秒杀系统设计: architecture/seckill-design.md