Skip to content

数据库设计

本文系统梳理数据库设计中的核心知识体系,涵盖范式理论、ER 建模、索引原理、事务与锁机制、慢查询优化、分库分表等关键主题,并通过一个完整的博客系统实战案例将理论串联起来。


一、数据库设计范式

1.1 第一范式(1NF)—— 原子性

第一范式要求关系中每个属性都是不可再分的原子值。简单来说,表中的每一列都只存储单一类型的数据,不允许嵌套表或重复组。

反例 —— 违反 1NF:

+----+--------+----------------------+
| id | name   | phones               |
+----+--------+----------------------+
|  1 | 张三   | 13800001111,13900002222 |
|  2 | 李四   | 15000003333            |
+----+--------+----------------------+

phones 列存储了多个电话号码,不满足原子性。

修正 —— 满足 1NF:

+----+--------+-------------+
| id | name   | phone       |
+----+--------+-------------+
|  1 | 张三   | 13800001111 |
|  1 | 张三   | 13900002222 |
|  2 | 李四   | 15000003333 |
+----+--------+-------------+

或者更好的做法是拆分为两张表:

sql
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL
);

CREATE TABLE user_phones (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    phone VARCHAR(20) NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

1.2 第二范式(2NF)—— 消除部分依赖

在满足 1NF 的基础上,2NF 要求非主属性必须完全依赖于候选键,不能只依赖候选键的一部分。这一规则主要针对联合主键的场景。

反例 —— 违反 2NF:

订单明细表(联合主键: order_id + product_id)
+----------+------------+-----------+----------+---------------+
| order_id | product_id | quantity  | price    | product_name  |
+----------+------------+-----------+----------+---------------+
|     1001 |        201 |         2 |    59.90 | Node.js实战    |
|     1001 |        202 |         1 |    89.00 | MySQL高性能     |
|     1002 |        201 |         3 |    59.90 | Node.js实战    |
+----------+------------+-----------+----------+---------------+

product_name 只依赖于 product_id,不依赖完整的联合主键,产生部分依赖

函数依赖关系:

  (order_id, product_id) → quantity     ✅ 完全依赖
  (order_id, product_id) → price        ✅ 完全依赖
  product_id → product_name             ❌ 部分依赖 (只依赖联合主键的一部分)

修正 —— 满足 2NF:

sql
CREATE TABLE products (
    product_id INT PRIMARY KEY,
    product_name VARCHAR(100) NOT NULL,
    price DECIMAL(10, 2) NOT NULL
);

CREATE TABLE order_items (
    order_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    PRIMARY KEY (order_id, product_id),
    FOREIGN KEY (product_id) REFERENCES products(product_id)
);

1.3 第三范式(3NF)—— 消除传递依赖

在满足 2NF 的基础上,3NF 要求非主属性不能传递依赖于候选键。即非主属性之间不能存在依赖关系。

反例 —— 违反 3NF:

+----------+-----------+-------------+---------------+
| staff_id | name      | dept_id     | dept_name     |
+----------+-----------+-------------+---------------+
|        1 | 王五      |         101 | 技术部         |
|        2 | 赵六      |         102 | 产品部         |
|        3 | 孙七      |         101 | 技术部         |
+----------+-----------+-------------+---------------+

dept_name 依赖于 dept_id,而 dept_id 依赖于 staff_id,产生传递依赖

传递依赖链:

  staff_id → dept_id → dept_name

  staff_id 直接决定 dept_id
  dept_id  直接决定 dept_name
  因此 staff_id 传递决定 dept_name  ← 违反 3NF

修正 —— 满足 3NF:

sql
CREATE TABLE departments (
    dept_id INT PRIMARY KEY,
    dept_name VARCHAR(50) NOT NULL
);

CREATE TABLE staff (
    staff_id INT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    dept_id INT NOT NULL,
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
);

1.4 BCNF(Boyce-Codd 范式)

BCNF 是 3NF 的加强版,要求每一个决定因素都必须是候选键。3NF 只要求非主属性不传递依赖于候选键,但没有约束主属性之间的依赖。BCNF 弥补了这一缺口。

BCNF 与 3NF 的区别:

3NF 的要求:  非主属性 不能传递依赖 候选键
BCNF 的要求: 所有属性(包括主属性)的决定因素都必须是候选键

BCNF ⊂ 3NF ⊂ 2NF ⊂ 1NF  (BCNF 比 3NF 更严格)

违反 BCNF 但满足 3NF 的经典案例:

考虑一个"学生选课"场景:一个学生可以选多门课,每门课可以有多个老师教,但每个老师只教一门课。

学生选课表(候选键: {学生, 课程} 或 {学生, 教师})
+--------+--------+--------+
| 学生   | 课程    | 教师   |
+--------+--------+--------+
| 张三   | 数据库  | 李老师 |
| 张三   | 编译原理| 王老师 |
| 李四   | 数据库  | 赵老师 |
+--------+--------+--------+

函数依赖:
  {学生, 课程} → 教师    (一个学生的一门课对应一个老师)
  教师 → 课程            (一个老师只教一门课)

问题: "教师 → 课程" 中,教师不是候选键,违反 BCNF

修正 —— 满足 BCNF:

sql
CREATE TABLE teacher_courses (
    teacher_id INT PRIMARY KEY,
    course_id INT NOT NULL,
    FOREIGN KEY (course_id) REFERENCES courses(course_id)
);

CREATE TABLE student_teachers (
    student_id INT NOT NULL,
    teacher_id INT NOT NULL,
    PRIMARY KEY (student_id, teacher_id),
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (teacher_id) REFERENCES teacher_courses(teacher_id)
);

1.5 范式等级对比

范式核心要求消除的问题典型场景
1NF属性原子性重复组、多值字段所有关系表必须满足
2NF消除部分依赖联合主键下的冗余多对多关联表
3NF消除传递依赖非主属性间冗余主表设计
BCNF决定因素必须是候选键主属性对非候选键的依赖严格要求场景
范式层级关系:

  1NF ──► 2NF ──► 3NF ──► BCNF
   │       │       │       │
   │       │       │       └── 所有决定因素都是候选键
   │       │       └────────── 消除非主属性的传递依赖
   │       └────────────────── 消除非主属性的部分依赖
   └────────────────────────── 属性不可再分

1.6 反范式化 —— 适度冗余换性能

严格遵循范式会带来大量 JOIN 操作,在高并发读场景下可能成为性能瓶颈。此时可以有意引入冗余来减少查询复杂度。

适合反范式化的场景:

场景做法收益
高频读、低频写在订单表中冗余商品名称避免 JOIN 商品表
统计/报表查询预计算汇总字段避免实时聚合运算
分布式环境跨库 JOIN 困难冗余关联字段减少跨库查询
展示类字段高频访问冗余用户昵称/头像到评论表减少关联查询

权衡原则:

        严格范式                               反范式化
        ◄─────────────────────────────────────────────►
  数据一致性高                                  查询性能高
  存储空间小                                    存储空间大
  更新简单                                      更新复杂(需同步冗余字段)
  查询需多表 JOIN                               单表即可完成查询

范式化 vs 反范式化决策流程:

                    ┌─────────────────┐
                    │ 新表/新字段设计   │
                    └────────┬────────┘

                    ┌────────▼────────┐
                    │  数据写入频繁?   │
                    └───┬─────────┬───┘
                      是│         │否
                        ▼         ▼
               ┌──────────┐  ┌──────────────┐
               │ 优先范式化 │  │ 查询是否需要  │
               │ 减少写放大 │  │ 多表 JOIN?   │
               └──────────┘  └──┬────────┬──┘
                              是│        │否
                                ▼        ▼
                       ┌────────────┐ ┌──────────┐
                       │ 考虑反范式化│ │ 维持范式化 │
                       │ 冗余高频字段│ └──────────┘
                       └────────────┘

反范式化示例:

sql
CREATE TABLE orders (
    order_id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    user_name VARCHAR(50) NOT NULL,
    product_id INT NOT NULL,
    product_name VARCHAR(100) NOT NULL,
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

user_nameproduct_name 是冗余字段,避免了查询订单列表时的 JOIN 操作。但要注意在用户修改名称或商品修改名称时,需要同步更新订单表中的冗余数据(或接受历史快照不变的策略)。


二、ER 模型设计

2.1 基本概念

ER(Entity-Relationship)模型由三个核心元素组成:

┌─────────────────────────────────────────────────────┐
│                    ER 模型三要素                      │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌──────────┐     ┌──────────┐     ┌──────────┐    │
│  │  实体     │     │  属性     │     │  关系     │    │
│  │ (Entity)  │     │(Attribute)│     │(Relation) │    │
│  │  矩形     │     │  椭圆     │     │  菱形     │    │
│  └──────────┘     └──────────┘     └──────────┘    │
│                                                     │
│  现实世界中         实体的特征       实体之间的         │
│  可区分的对象       或性质          联系               │
└─────────────────────────────────────────────────────┘

2.2 三种关系类型

一对一(1:1)

一个实体恰好关联另一个实体的一条记录,如"用户"与"用户详情"。

┌──────────┐    1        1    ┌──────────────┐
│   User   │─────────────────│ UserProfile   │
│          │     has          │              │
│  user_id │                  │ profile_id   │
│  name    │                  │ user_id (FK) │
│  email   │                  │ avatar       │
└──────────┘                  │ bio          │
                              └──────────────┘
sql
CREATE TABLE users (
    user_id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE user_profiles (
    profile_id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL UNIQUE,
    avatar VARCHAR(255),
    bio TEXT,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);

1:1 关系的设计考量: 为什么不直接将 avatarbio 放到 users 表中?常见理由包括:垂直分表减少单行数据量(提高热数据查询效率)、不同的访问频率(基本信息高频、详情低频)、以及不同的安全等级。

一对多(1:N)

一个实体关联多条另一个实体的记录,如"部门"与"员工"。

┌──────────────┐    1        N    ┌──────────────┐
│  Department  │──────────────────│   Employee   │
│              │    contains      │              │
│  dept_id     │                  │ emp_id       │
│  dept_name   │                  │ emp_name     │
└──────────────┘                  │ dept_id (FK) │
                                  └──────────────┘
sql
CREATE TABLE departments (
    dept_id INT PRIMARY KEY AUTO_INCREMENT,
    dept_name VARCHAR(50) NOT NULL
);

CREATE TABLE employees (
    emp_id INT PRIMARY KEY AUTO_INCREMENT,
    emp_name VARCHAR(50) NOT NULL,
    dept_id INT NOT NULL,
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
);

多对多(M:N)

两个实体之间互相关联多条记录,如"学生"与"课程"。需要通过**中间表(JOIN 表)**来实现。

┌──────────┐    M              N    ┌──────────┐
│ Student  │────────┐    ┌──────────│  Course  │
│          │        │    │          │          │
│ stu_id   │    ┌───┴────┴───┐     │ course_id│
│ stu_name │    │stu_course   │     │ title    │
└──────────┘    │             │     │ credits  │
                │ stu_id (FK) │     └──────────┘
                │ course_id(FK)│
                │ score       │
                └─────────────┘
sql
CREATE TABLE students (
    stu_id INT PRIMARY KEY AUTO_INCREMENT,
    stu_name VARCHAR(50) NOT NULL
);

CREATE TABLE courses (
    course_id INT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(100) NOT NULL,
    credits INT NOT NULL
);

CREATE TABLE student_courses (
    stu_id INT NOT NULL,
    course_id INT NOT NULL,
    score DECIMAL(5,2),
    PRIMARY KEY (stu_id, course_id),
    FOREIGN KEY (stu_id) REFERENCES students(stu_id),
    FOREIGN KEY (course_id) REFERENCES courses(course_id)
);

2.3 外键约束与级联操作

外键是关系模型中维护引用完整性的核心机制。当父表记录被更新或删除时,可以通过级联操作自动处理子表中的关联记录。

级联操作类型:

操作含义示例
CASCADE级联删除/更新子记录删除用户时自动删除该用户的所有文章
SET NULL将子记录外键设为 NULL删除分类时,文章的分类字段置空
RESTRICT拒绝操作(默认)有文章引用该分类时,不允许删除分类
SET DEFAULT将子记录外键设为默认值InnoDB 不支持
NO ACTION等同于 RESTRICT(SQL 标准)检查时机略有不同
sql
CREATE TABLE articles (
    article_id INT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(200) NOT NULL,
    user_id INT NOT NULL,
    category_id INT DEFAULT NULL,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
        ON DELETE CASCADE
        ON UPDATE CASCADE,
    FOREIGN KEY (category_id) REFERENCES categories(category_id)
        ON DELETE SET NULL
        ON UPDATE CASCADE
);
级联删除执行流程:

  DELETE FROM users WHERE user_id = 1;

  ┌────────────┐
  │   users    │  删除 user_id=1
  └─────┬──────┘
        │ ON DELETE CASCADE

  ┌────────────┐
  │  articles  │  自动删除 user_id=1 的所有文章
  └─────┬──────┘
        │ ON DELETE CASCADE (article_tags 也设置了级联)

  ┌──────────────┐
  │ article_tags │  自动删除关联的标签记录
  └──────────────┘

外键的实际使用争议:

观点理由
支持使用外键数据库层面保证完整性、减少脏数据、语义清晰
反对使用外键影响写入性能、分库分表后无法跨库外键、增加运维复杂度
折中方案逻辑外键(代码层面维护关系)+ 定时数据校验脚本

在互联网高并发场景下,很多团队选择不使用数据库外键约束,而是在应用层维护引用完整性。但在数据一致性要求极高的金融、医疗等领域,外键约束仍然是首选。

2.4 完整 ER 图示例 —— 博客系统

┌──────────┐                              ┌──────────┐
│   User   │                              │   Tag    │
│──────────│                              │──────────│
│ user_id  │◄─┐                     ┌────►│ tag_id   │
│ username │  │                     │     │ tag_name │
│ email    │  │                     │     └──────────┘
│ password │  │                     │          │
└──────────┘  │                     │          │ M:N
     │        │                     │          │
     │ 1:N    │ 1:N                 │   ┌──────┴──────┐
     │        │                     │   │ article_tags │
     ▼        │                     │   │─────────────│
┌──────────┐  │    ┌──────────────┐ │   │ article_id  │
│ Article  │  │    │   Comment    │ │   │ tag_id      │
│──────────│  │    │──────────────│ │   └─────────────┘
│article_id│──┼───►│ comment_id   │ │
│ title    │  │    │ article_id   │ │
│ content  │  │    │ user_id      │─┘
│ user_id  │──┘    │ content      │
│created_at│       │ created_at   │
└──────────┘       └──────────────┘

       │ 关系说明:
       │ User    1:N  Article   (一个用户发表多篇文章)
       │ User    1:N  Comment   (一个用户发表多条评论)
       │ Article 1:N  Comment   (一篇文章有多条评论)
       │ Article M:N  Tag       (文章与标签多对多)

三、索引原理

3.1 B 树 vs B+ 树

MySQL InnoDB 引擎默认使用 B+ 树 作为索引数据结构。要理解 B+ 树的优势,需要先对比 B 树。

B 树结构(所有节点都存数据):

                          B 树 (3阶)
                    ┌───────────────────┐
                    │    [30 | data30]   │
                    │    [60 | data60]   │ ← 非叶子节点也存完整数据
                    └───┬─────┬─────┬───┘
                        │     │     │
              ┌─────────┘     │     └─────────┐
              ▼               ▼               ▼
      ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
      │ [10 | data10]│ │ [40 | data40]│ │ [70 | data70]│
      │ [20 | data20]│ │ [50 | data50]│ │ [80 | data80]│
      └──────────────┘ └──────────────┘ └──────────────┘
           叶子节点间无链接,范围查询需回溯

B+ 树结构(数据只在叶子节点):

                        B+ 树结构示意图

                    ┌───────────────────┐
          ┌─────────│    [30 | 60]       │──────────┐
          │         └───────────────────┘           │
          ▼                   │                     ▼
  ┌───────────────┐           ▼            ┌───────────────┐
  │  [10 | 20]    │   ┌──────────────┐    │  [70 | 80]    │
  └───┬───┬───┬───┘   │ [40 | 50]   │    └───┬───┬───┬───┘
      │   │   │        └──┬───┬──┬──┘        │   │   │
      ▼   ▼   ▼           ▼   ▼  ▼           ▼   ▼   ▼
    ┌───┬───┬───┐      ┌───┬───┬───┐     ┌───┬───┬───┐
    │ 5 │10 │20 │─────►│30 │40 │50 │────►│60 │70 │80 │
    │ 8 │15 │25 │      │35 │45 │55 │     │65 │75 │90 │
    └───┴───┴───┘      └───┴───┴───┘     └───┴───┴───┘
       叶子节点            叶子节点           叶子节点
    (双向链表连接,支持范围查询)

B 树 vs B+ 树对比:

对比维度B 树B+ 树
数据存储位置所有节点都存数据只有叶子节点存数据
非叶子节点容量小(存了数据占空间)大(只存键值和指针)
树高度相对更高相对更矮(同数据量)
范围查询需要中序遍历,效率低叶子链表顺序遍历,极快
等值查询可能在非叶子命中(少数情况更快)必须到叶子节点
磁盘 IO相对更多更少(核心优势)
适用场景文件系统(如 MongoDB 早期)关系数据库索引(MySQL, PostgreSQL)

为什么 MySQL 选择 B+ 树:

磁盘 IO 是数据库最大的性能瓶颈:

  内存访问: ~100ns
  SSD 随机读: ~100μs  (慢 1000 倍)
  HDD 随机读: ~10ms   (慢 100000 倍)

B+ 树的核心优化思路:
  1. 非叶子节点不存数据 → 单节点能容纳更多键值 → 树更矮 → IO 更少
  2. InnoDB 默认页大小 16KB,一个非叶子节点可存约 1200 个指针
  3. 三层 B+ 树可索引: 1200 × 1200 × 每页行数 ≈ 数千万行
  4. 叶子链表 → 范围查询只需顺序读,不用回溯父节点

3.2 查找过程

以查找 WHERE id = 45 为例:

第1次 IO: 读取根节点 [30|60]
          45 > 30 且 45 < 60 → 走中间指针

第2次 IO: 读取中间节点 [40|50]
          45 > 40 且 45 < 50 → 走中间指针

第3次 IO: 读取叶子节点,找到 45 对应的数据行

总共: 3 次磁盘 IO 即可定位数据

B+ 树核心特征:

特征说明
所有数据存在叶子节点非叶子节点只存索引键值
叶子节点形成有序链表支持高效的范围查询和排序
树高度通常 2-4 层千万级数据只需 3-4 次 IO
节点大小等于磁盘页InnoDB 默认 16KB,减少 IO 次数

3.3 聚簇索引 vs 非聚簇索引

         聚簇索引 (Clustered Index)            非聚簇索引 (Secondary Index)
    ┌─────────────────────────────┐      ┌─────────────────────────────┐
    │        [30 | 60]            │      │        [E | M]              │
    │      (主键索引树)            │      │     (name 索引树)           │
    └──────┬──────┬───────────────┘      └──────┬──────┬──────────────┘
           │      │                             │      │
           ▼      ▼                             ▼      ▼
    ┌──────────┐ ┌──────────┐           ┌──────────┐ ┌──────────┐
    │ id=10    │ │ id=30    │           │ name=Amy │ │ name=Eva │
    │ 完整行数据│ │ 完整行数据│           │ id=30    │ │ id=10    │
    │ name,age │ │ name,age │           │ (主键值)  │ │ (主键值)  │
    │ email... │ │ email... │           └──────────┘ └──────────┘
    └──────────┘ └──────────┘                  │
                                               │ 回表查询

                                        再次查询聚簇索引
                                        获取完整行数据
对比维度聚簇索引非聚簇索引(二级索引)
叶子节点存储完整行数据索引列 + 主键值
每张表数量只能有一个可以有多个
默认选择InnoDB 主键其他索引
查询效率直接获取数据可能需要回表
数据物理顺序按主键排列独立的排序顺序

回表的代价:

查询: SELECT * FROM users WHERE name = 'Amy'

步骤 1: 在 name 索引树上查找 'Amy' → 得到 id=30      (1~2 次 IO)
步骤 2: 用 id=30 回表查聚簇索引 → 得到完整行数据        (1~2 次 IO)
                                                      ──────────
总计:                                                  2~4 次 IO

如果使用覆盖索引(只查 name 和 id):
步骤 1: 在 name 索引树上查找 'Amy' → 直接返回           (1~2 次 IO)
无需回表!

3.4 联合索引与最左前缀匹配

联合索引按照字段声明顺序构建 B+ 树排序规则。

sql
CREATE INDEX idx_abc ON users(a, b, c);
联合索引 (a, b, c) 的 B+ 树排列方式:

先按 a 排序 → a 相同时按 b 排序 → b 相同时按 c 排序

┌─────────────────────────────────────────┐
│  (1,1,1) (1,1,3) (1,2,1) (2,1,1) ...  │
│   a=1     a=1     a=1     a=2          │
│   b=1     b=1     b=2     b=1          │
│   c=1     c=3     c=1     c=1          │
└─────────────────────────────────────────┘

最左前缀匹配规则:

查询条件是否命中索引说明
WHERE a = 1✅ 命中使用索引第一列
WHERE a = 1 AND b = 2✅ 命中使用索引前两列
WHERE a = 1 AND b = 2 AND c = 3✅ 命中完整使用索引
WHERE b = 2❌ 不命中缺少最左列 a
WHERE b = 2 AND c = 3❌ 不命中缺少最左列 a
WHERE a = 1 AND c = 3⚠️ 部分命中只用到 a 列,c 无法使用
WHERE a > 1 AND b = 2⚠️ 部分命中a 用范围查询后 b 无法使用索引

为什么跳过中间列就不能使用后续索引:

联合索引 (a, b, c):

查询 WHERE a = 1 AND c = 3:

  在 B+ 树上先找到 a=1 的区间:
  (1,1,1) (1,1,3) (1,2,1) (1,2,5) (1,3,3) ...

  此时 b 的值是无序的: 1, 1, 2, 2, 3...
  c 的值更是无序的:     1, 3, 1, 5, 3...

  由于跳过了 b,无法利用 c 的有序性
  只能在 a=1 的结果集中逐行过滤 c=3

3.5 覆盖索引

当查询所需的所有字段都包含在索引中时,无需回表查询,称为覆盖索引

sql
CREATE INDEX idx_name_age ON users(name, age);

SELECT name, age FROM users WHERE name = '张三';
查询过程:

┌──────────────────────┐
│ idx_name_age 索引树   │
│                      │
│ 叶子节点包含:         │
│  name + age + 主键id │
│                      │
│ SELECT 只需要         │
│  name + age          │
│                      │
│ ✅ 索引已覆盖所有字段  │
│ ✅ 无需回表           │
│                      │
│ EXPLAIN 中            │
│ Extra: Using index   │
└──────────────────────┘

3.6 索引失效场景

序号失效场景示例原因
1对索引列使用函数WHERE YEAR(created_at) = 2024函数破坏了索引的有序性
2隐式类型转换WHERE phone = 13800001111(phone 是 VARCHAR)触发隐式转换等同于函数操作
3LIKE 左模糊WHERE name LIKE '%张'无法利用 B+ 树的前缀匹配
4OR 连接非索引列WHERE a = 1 OR d = 2(d 无索引)需全表扫描 d 列
5不满足最左前缀联合索引 (a,b,c),查 WHERE b = 1跳过了最左列
6对索引列做运算WHERE id + 1 = 10同函数,破坏有序性
7NOT IN / NOT EXISTS视优化器判断部分场景优化器放弃索引
8数据量过小表只有几十行全表扫描比索引更快
9索引选择性低WHERE gender = '男' (仅两种值)优化器判断全表扫描更高效

索引失效修正示例:

sql
SELECT * FROM users WHERE YEAR(created_at) = 2024;

SELECT * FROM users WHERE created_at >= '2024-01-01'
                      AND created_at <  '2025-01-01';
sql
SELECT * FROM orders WHERE id + 1 = 10;

SELECT * FROM orders WHERE id = 9;
sql
SELECT * FROM users WHERE phone = 13800001111;

SELECT * FROM users WHERE phone = '13800001111';

四、事务与并发控制

4.1 ACID 四特性

┌─────────────────────────────────────────────────────────────┐
│                      ACID 特性                               │
├──────────────┬──────────────────────────────────────────────┤
│  A: 原子性    │ 事务中的所有操作要么全部成功,要么全部回滚       │
│  Atomicity   │ 转账: 扣款+收款必须同时成功或同时失败            │
├──────────────┼──────────────────────────────────────────────┤
│  C: 一致性    │ 事务执行前后,数据库从一个一致状态转变到          │
│  Consistency │ 另一个一致状态。如转账前后总金额不变              │
├──────────────┼──────────────────────────────────────────────┤
│  I: 隔离性    │ 并发事务之间相互隔离,一个事务的中间状态          │
│  Isolation   │ 对其他事务不可见                                │
├──────────────┼──────────────────────────────────────────────┤
│  D: 持久性    │ 事务提交后,对数据库的修改是永久性的             │
│  Durability  │ 即使系统崩溃也不会丢失                          │
└──────────────┴──────────────────────────────────────────────┘

ACID 之间的关系:

┌─────────────────────────────────────────────────────┐
│                                                     │
│  原子性 (Atomicity)    ──┐                           │
│                          ├──► 一致性 (Consistency)   │
│  隔离性 (Isolation)    ──┘        ▲                  │
│                                   │                  │
│  持久性 (Durability)  ────────────┘                  │
│                                                     │
│  一致性是最终目标                                     │
│  原子性、隔离性、持久性是实现一致性的手段               │
└─────────────────────────────────────────────────────┘

Node.js 中的事务操作示例(使用 mysql2):

javascript
const mysql = require('mysql2/promise');

async function transfer(fromId, toId, amount) {
    const conn = await mysql.createConnection({
        host: 'localhost',
        user: 'root',
        password: 'password',
        database: 'bank'
    });

    try {
        await conn.beginTransaction();

        const [fromRows] = await conn.execute(
            'SELECT balance FROM accounts WHERE id = ? FOR UPDATE',
            [fromId]
        );

        if (fromRows[0].balance < amount) {
            throw new Error('余额不足');
        }

        await conn.execute(
            'UPDATE accounts SET balance = balance - ? WHERE id = ?',
            [amount, fromId]
        );

        await conn.execute(
            'UPDATE accounts SET balance = balance + ? WHERE id = ?',
            [amount, toId]
        );

        await conn.commit();
        console.log('转账成功');
    } catch (err) {
        await conn.rollback();
        console.error('转账失败,已回滚:', err.message);
        throw err;
    } finally {
        await conn.end();
    }
}

4.2 四种隔离级别

隔离级别由低到高:

  读未提交 ──► 读已提交 ──► 可重复读 ──► 串行化
  (最低)                    (MySQL默认)    (最高)

  并发性能:  高 ────────────────────────► 低
  数据安全:  低 ────────────────────────► 高

4.3 并发问题详解

脏读(Dirty Read)

读取到其他事务尚未提交的数据,如果该事务最终回滚,则读取到的数据是无效的"脏数据"。

时间线  事务A                    事务B
─────────────────────────────────────────────
  T1    BEGIN
  T2    UPDATE balance=500
        WHERE id=1
        (原值1000)
  T3                             BEGIN
  T4                             SELECT balance
                                 WHERE id=1
                                 读到 500 ← 脏读!
  T5    ROLLBACK
        (balance 恢复为1000)
  T6                             基于 500 做业务计算
                                 ← 数据错误!

不可重复读(Non-Repeatable Read)

同一事务内两次读取同一行数据,因为其他事务的提交导致结果不同。侧重于已有数据的修改

时间线  事务A                    事务B
─────────────────────────────────────────────
  T1    BEGIN
  T2    SELECT balance           
        WHERE id=1
        读到 1000
  T3                             BEGIN
  T4                             UPDATE balance=500
                                 WHERE id=1
  T5                             COMMIT
  T6    SELECT balance
        WHERE id=1
        读到 500 ← 不可重复读!
        (同一事务两次读取结果不同)

幻读(Phantom Read)

同一事务内两次查询同一范围的数据,因为其他事务插入了新记录导致结果集行数不同。侧重于新增或删除的行。

时间线  事务A                    事务B
─────────────────────────────────────────────
  T1    BEGIN
  T2    SELECT COUNT(*)
        FROM users
        WHERE age > 20
        结果: 5 行
  T3                             BEGIN
  T4                             INSERT INTO users
                                 (name, age)
                                 VALUES('新人', 25)
  T5                             COMMIT
  T6    SELECT COUNT(*)
        FROM users
        WHERE age > 20
        结果: 6 行 ← 幻读!
        (多出了"幻影"行)

三种并发问题的核心区别:

问题关注点原因关键词
脏读读到未提交的数据其他事务未提交就能读到未提交
不可重复读同一行数据两次读不一致其他事务修改并提交了该行修改(UPDATE)
幻读同一范围查询两次行数不一致其他事务插入了新行新增(INSERT)

4.4 隔离级别对比表

隔离级别脏读不可重复读幻读实现机制
READ UNCOMMITTED✅ 可能✅ 可能✅ 可能无任何锁/快照
READ COMMITTED❌ 避免✅ 可能✅ 可能每次读生成新快照
REPEATABLE READ❌ 避免❌ 避免✅ 可能事务开始时生成快照(MVCC)
SERIALIZABLE❌ 避免❌ 避免❌ 避免加锁串行执行

MySQL InnoDB 默认隔离级别为 REPEATABLE READ,并通过 Next-Key Lock 在一定程度上解决了幻读问题。

sql
SELECT @@transaction_isolation;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

4.5 MVCC 多版本并发控制

MySQL InnoDB 在 REPEATABLE READ 级别下使用 MVCC(Multi-Version Concurrency Control) 实现一致性读,而非加锁。MVCC 的核心思想是:为每行数据维护多个版本,通过版本链和可见性规则让不同事务看到不同版本的数据

┌──────────────────────────────────────────────────────┐
│                     MVCC 原理                         │
├──────────────────────────────────────────────────────┤
│                                                      │
│  每行数据隐藏两列:                                     │
│    DB_TRX_ID  : 最后修改该行的事务 ID                   │
│    DB_ROLL_PTR: 指向 undo log 中旧版本的指针            │
│                                                      │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐       │
│  │ 当前版本  │───►│ 上一版本  │───►│ 更早版本  │       │
│  │ trx_id=5 │    │ trx_id=3 │    │ trx_id=1 │       │
│  │ name=李四 │    │ name=张三 │    │ name=王五 │       │
│  └──────────┘    └──────────┘    └──────────┘       │
│                                                      │
│  ReadView (快照读):                                   │
│    事务开始时记录当前活跃事务列表                         │
│    根据可见性规则判断应该读取哪个版本                     │
│                                                      │
└──────────────────────────────────────────────────────┘

ReadView 可见性判断规则:

ReadView 包含四个关键字段:

  m_ids        : 创建 ReadView 时的活跃事务 ID 列表
  min_trx_id   : m_ids 中的最小值
  max_trx_id   : 系统下一个将分配的事务 ID
  creator_trx_id: 创建该 ReadView 的事务 ID

判断规则 (对于某行数据的某个版本,其 trx_id = T):

  ┌────────────────────────────────────────────────┐
  │  T < min_trx_id ?                              │
  │    是 → 该版本在 ReadView 创建前已提交 → ✅ 可见  │
  │    否 ↓                                        │
  │  T >= max_trx_id ?                             │
  │    是 → 该版本在 ReadView 创建后才开始 → ❌ 不可见│
  │    否 ↓                                        │
  │  T 在 m_ids 中 ?                               │
  │    是 → 该版本的事务还未提交 → ❌ 不可见           │
  │    否 → 该版本的事务已提交 → ✅ 可见              │
  └────────────────────────────────────────────────┘

RC 与 RR 的 ReadView 区别:

隔离级别ReadView 创建时机效果
READ COMMITTED每次 SELECT 都创建新的 ReadView能看到其他事务最新提交的结果
REPEATABLE READ事务第一次 SELECT 时创建,后续复用整个事务期间看到的数据快照一致

4.6 乐观锁 vs 悲观锁

┌─────────────────────────────────┬─────────────────────────────────┐
│           悲观锁                 │           乐观锁                 │
├─────────────────────────────────┼─────────────────────────────────┤
│ 假设冲突经常发生                  │ 假设冲突很少发生                  │
│ 先加锁再操作                     │ 操作时不加锁,提交时检查冲突       │
│ SELECT ... FOR UPDATE           │ 通过版本号/时间戳实现              │
│ 适合写多读少                     │ 适合读多写少                     │
│ 开销大,但安全                    │ 开销小,但可能需重试              │
└─────────────────────────────────┴─────────────────────────────────┘

悲观锁实现:

javascript
async function deductStock(productId, quantity) {
    const conn = await pool.getConnection();
    try {
        await conn.beginTransaction();

        const [rows] = await conn.execute(
            'SELECT stock FROM products WHERE id = ? FOR UPDATE',
            [productId]
        );

        if (rows[0].stock < quantity) {
            throw new Error('库存不足');
        }

        await conn.execute(
            'UPDATE products SET stock = stock - ? WHERE id = ?',
            [quantity, productId]
        );

        await conn.commit();
    } catch (err) {
        await conn.rollback();
        throw err;
    } finally {
        conn.release();
    }
}

乐观锁实现(版本号机制):

javascript
async function deductStockOptimistic(productId, quantity, maxRetries = 3) {
    const conn = await pool.getConnection();

    for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
            const [rows] = await conn.execute(
                'SELECT stock, version FROM products WHERE id = ?',
                [productId]
            );

            const { stock, version } = rows[0];

            if (stock < quantity) {
                throw new Error('库存不足');
            }

            const [result] = await conn.execute(
                'UPDATE products SET stock = stock - ?, version = version + 1 WHERE id = ? AND version = ?',
                [quantity, productId, version]
            );

            if (result.affectedRows === 1) {
                return;
            }

            console.log(`版本冲突,第 ${attempt + 1} 次重试`);
        } catch (err) {
            throw err;
        }
    }

    throw new Error('乐观锁重试次数耗尽');
}

锁选择决策:

场景推荐理由
电商库存扣减悲观锁冲突概率高,重试成本大
社交点赞/收藏乐观锁冲突概率低,重试代价小
金融转账悲观锁必须保证数据准确
CMS 内容编辑乐观锁并发编辑概率低

五、锁机制

5.1 共享锁与排他锁

┌──────────────────────────────────────────────────────┐
│              锁的兼容性矩阵                            │
├──────────┬──────────────┬──────────────┬─────────────┤
│          │  共享锁 (S)   │  排他锁 (X)  │  说明       │
├──────────┼──────────────┼──────────────┼─────────────┤
│ 共享锁(S) │    ✅ 兼容    │   ❌ 冲突    │ 读读并行    │
├──────────┼──────────────┼──────────────┼─────────────┤
│ 排他锁(X) │    ❌ 冲突    │   ❌ 冲突    │ 写写/读写   │
│          │              │              │ 互斥        │
└──────────┴──────────────┴──────────────┴─────────────┘
sql
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;

SELECT * FROM users WHERE id = 1 FOR UPDATE;

5.2 行锁、间隙锁与 Next-Key Lock

InnoDB 在 REPEATABLE READ 级别下使用三种行级锁来解决幻读问题:

假设索引中有记录: 10, 20, 30

┌─────────────────────────────────────────────────────────┐
│                    InnoDB 锁类型                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  Record Lock (记录锁):                                   │
│  锁定索引上的单条记录                                     │
│  ──[10]──────[20]──────[30]──                            │
│     ^                                                   │
│     锁住 id=10 这条记录                                   │
│                                                         │
│  Gap Lock (间隙锁):                                      │
│  锁定索引记录之间的"间隙",不包含记录本身                    │
│  ──(10)─────(20)──────(30)──                            │
│       ^^^^^                                             │
│    锁住 (10, 20) 这个开区间                               │
│    阻止其他事务在此区间 INSERT                              │
│                                                         │
│  Next-Key Lock (记录锁 + 间隙锁):                         │
│  锁定记录本身 + 记录前面的间隙                              │
│  ──(10]─────(20]──────(30]──                            │
│       ^^^^^^                                            │
│    锁住 (10, 20] 这个左开右闭区间                          │
│                                                         │
└─────────────────────────────────────────────────────────┘

5.3 行锁与表锁

锁粒度加锁范围并发度开销死锁适用引擎
表锁锁定整张表不会MyISAM/InnoDB
行锁锁定特定行可能InnoDB
页锁锁定一页数据可能BDB

InnoDB 的行锁是通过索引实现的。如果 SQL 没有使用索引,会退化为表锁

sql
UPDATE users SET name = '张三' WHERE id = 1;

UPDATE users SET name = '张三' WHERE name = '李四';

5.4 死锁及其避免

死锁场景示意:

  事务A                          事务B
  ─────                          ─────
  锁住 id=1 的行                  锁住 id=2 的行
       │                              │
       │ 尝试锁 id=2                   │ 尝试锁 id=1
       │      ┌──────────┐            │
       └─────►│ 互相等待  │◄───────────┘
              │  死 锁!   │
              └──────────┘

死锁避免策略:

策略说明
固定加锁顺序所有事务按相同的顺序访问资源(如按 id 升序)
缩小事务范围尽量减少事务中持有锁的时间
设置超时innodb_lock_wait_timeout(默认 50 秒)
降低隔离级别在可接受范围内使用较低的隔离级别
使用乐观锁减少显式加锁
sql
SHOW ENGINE INNODB STATUS;

SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SET innodb_lock_wait_timeout = 10;

六、慢查询分析与优化

6.1 开启慢查询日志

sql
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

mysqldumpslow 分析工具:

bash
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
参数含义
-s t按查询时间排序
-s c按执行次数排序
-s r按返回行数排序
-t N只显示前 N 条

6.2 EXPLAIN 命令解读

sql
EXPLAIN SELECT u.name, a.title
FROM users u
JOIN articles a ON u.user_id = a.user_id
WHERE u.status = 1
ORDER BY a.created_at DESC
LIMIT 10;

EXPLAIN 输出关键字段:

字段含义关注要点
id查询序号相同 id 从上到下执行,不同 id 大的先执行
select_type查询类型SIMPLE/PRIMARY/SUBQUERY/DERIVED
table访问的表实际表名或别名
type访问类型性能从好到差,见下表
possible_keys可能使用的索引候选索引列表
key实际使用的索引为 NULL 表示未使用索引
key_len使用的索引长度越短说明使用的索引列越少
rows预估扫描行数越小越好
filtered条件过滤百分比越大越好,100% 最理想
Extra额外信息重点关注,见下表

type 字段性能排序(从优到劣):

system > const > eq_ref > ref > range > index > ALL

┌────────┬────────────────────────────────────────┐
│ system │ 表只有一行记录                           │
│ const  │ 通过主键/唯一索引精确匹配一行              │
│ eq_ref │ JOIN 时使用主键/唯一索引                  │
│ ref    │ 使用非唯一索引匹配                        │
│ range  │ 索引范围查找 (BETWEEN, >, <, IN)         │
│ index  │ 全索引扫描(遍历索引树)                   │
│ ALL    │ 全表扫描(最差,必须优化)                  │
└────────┴────────────────────────────────────────┘

Extra 字段重要取值:

Extra 值含义好坏
Using index覆盖索引,无需回表✅ 好
Using where在存储引擎返回后再过滤⚠️ 一般
Using temporary使用临时表❌ 差
Using filesort文件排序(非索引排序)❌ 差
Using index condition索引下推 (ICP)✅ 好

EXPLAIN 实战分析示例:

+----+-------------+-------+------+---------------+----------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key      | key_len | rows | filtered | Extra  |
+----+-------------+-------+------+---------------+----------+---------+------+------+-------------+
|  1 | SIMPLE      | u     | ref  | idx_status    | idx_status| 1      | 5000 | 100.00   | NULL   |
|  1 | SIMPLE      | a     | ref  | idx_user_id   | idx_user_id| 4    |   10 |  100.00  | Using filesort |
+----+-------------+-------+------+---------------+----------+---------+------+------+-------------+

分析:
  1. users 表通过 idx_status 索引,type=ref,预估扫描 5000 行 → 合理
  2. articles 表通过 idx_user_id 索引关联 → 合理
  3. Extra 出现 Using filesort → ORDER BY created_at 未走索引排序
  4. 优化: 建联合索引 (user_id, created_at) 消除 filesort

6.3 索引优化策略

sql
CREATE INDEX idx_status ON users(status);

CREATE INDEX idx_user_created ON articles(user_id, created_at);

CREATE INDEX idx_cover ON orders(user_id, status, created_at, total_amount);

SELECT user_id, status, created_at, total_amount
FROM orders
WHERE user_id = 100 AND status = 1
ORDER BY created_at DESC;

索引设计原则:

原则说明
选择性高的列优先区分度大的列放在联合索引前面
覆盖查询字段将 SELECT 的字段纳入索引,避免回表
避免过多索引每个索引占额外存储,且影响写入性能
前缀索引对长字符串取前 N 个字符建索引
利用索引排序ORDER BY 字段放入联合索引

索引选择性计算:

sql
SELECT
    COUNT(DISTINCT status) / COUNT(*) AS status_selectivity,
    COUNT(DISTINCT email) / COUNT(*) AS email_selectivity,
    COUNT(DISTINCT username) / COUNT(*) AS username_selectivity
FROM users;

+---------------------+---------------------+------------------------+
| status_selectivity  | email_selectivity   | username_selectivity   |
+---------------------+---------------------+------------------------+
|              0.0003 |              1.0000 |                 1.0000 |
+---------------------+---------------------+------------------------+

选择性越接近 1.0,索引效果越好。status 只有 0.0003 的选择性,不适合单独建索引。

6.4 查询重写技巧

技巧 1:避免 SELECT *

sql
SELECT * FROM users WHERE status = 1;

SELECT user_id, name, email FROM users WHERE status = 1;

技巧 2:用 EXISTS 替代 IN(子查询数据量大时)

sql
SELECT * FROM orders
WHERE user_id IN (SELECT user_id FROM users WHERE status = 1);

SELECT o.* FROM orders o
WHERE EXISTS (
    SELECT 1 FROM users u
    WHERE u.user_id = o.user_id AND u.status = 1
);

技巧 3:避免在 WHERE 中对列做运算

sql
SELECT * FROM orders WHERE YEAR(created_at) = 2024;

SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

技巧 4:用 UNION ALL 替代 UNION(不需要去重时)

sql
SELECT name FROM table_a WHERE status = 1
UNION
SELECT name FROM table_b WHERE status = 1;

SELECT name FROM table_a WHERE status = 1
UNION ALL
SELECT name FROM table_b WHERE status = 1;

6.5 分页优化(深分页问题)

传统分页在偏移量很大时性能急剧下降:

sql
SELECT * FROM articles ORDER BY id LIMIT 1000000, 10;
问题分析:

LIMIT 1000000, 10 的执行过程:
  1. 从索引中取出 1000010 行
  2. 丢弃前 1000000 行
  3. 返回最后 10 行
  → 白白扫描了 100 万行!

优化方案 1:游标分页(推荐)

sql
SELECT * FROM articles
WHERE id > 1000000
ORDER BY id
LIMIT 10;

优化方案 2:延迟关联

sql
SELECT a.* FROM articles a
INNER JOIN (
    SELECT id FROM articles
    ORDER BY id
    LIMIT 1000000, 10
) t ON a.id = t.id;

优化方案对比:

方案原理优点缺点
传统 LIMITOFFSET 跳过行简单通用深分页极慢
游标分页基于上次最后 IDO(1) 性能不支持跳页
延迟关联子查询走覆盖索引支持跳页仍有一定开销
搜索引擎ES 等外部方案适合全文搜索架构复杂

6.6 N+1 查询问题与解决方案

N+1 问题是 ORM 中最常见的性能陷阱之一。当查询一个列表(1 次查询)后,对列表中每条记录都执行一次关联查询(N 次),总共执行 N+1 次查询。

N+1 问题示例:

javascript
const articles = await db.query('SELECT * FROM articles LIMIT 20');

for (const article of articles) {
    const [author] = await db.query(
        'SELECT * FROM users WHERE user_id = ?',
        [article.user_id]
    );
    article.author = author;
}
执行的 SQL:
  第 1 次: SELECT * FROM articles LIMIT 20           (1 次)
  第 2 次: SELECT * FROM users WHERE user_id = 1      (第1篇文章)
  第 3 次: SELECT * FROM users WHERE user_id = 3      (第2篇文章)
  第 4 次: SELECT * FROM users WHERE user_id = 1      (第3篇文章)
  ...
  第 21 次: SELECT * FROM users WHERE user_id = 7     (第20篇文章)
                                                      ──────────
  总计: 1 + 20 = 21 次数据库查询!

解决方案 1:JOIN 查询(预加载)

javascript
const articles = await db.query(`
    SELECT a.*, u.username, u.avatar
    FROM articles a
    INNER JOIN users u ON a.user_id = u.user_id
    LIMIT 20
`);
执行的 SQL: 仅 1 次!

解决方案 2:批量查询(IN 查询)

javascript
const articles = await db.query('SELECT * FROM articles LIMIT 20');

const userIds = [...new Set(articles.map(a => a.user_id))];

const users = await db.query(
    `SELECT * FROM users WHERE user_id IN (${userIds.map(() => '?').join(',')})`,
    userIds
);

const userMap = new Map(users.map(u => [u.user_id, u]));
articles.forEach(a => {
    a.author = userMap.get(a.user_id);
});
执行的 SQL:
  第 1 次: SELECT * FROM articles LIMIT 20
  第 2 次: SELECT * FROM users WHERE user_id IN (1, 3, 5, 7, ...)
                                                      ──────────
  总计: 仅 2 次数据库查询! (从 21 次优化到 2 次)

解决方案 3:DataLoader 模式(批量 + 缓存)

javascript
class UserLoader {
    constructor(db) {
        this.db = db;
        this.cache = new Map();
        this.batch = [];
        this.batchScheduled = false;
    }

    async load(userId) {
        if (this.cache.has(userId)) {
            return this.cache.get(userId);
        }

        return new Promise((resolve) => {
            this.batch.push({ userId, resolve });

            if (!this.batchScheduled) {
                this.batchScheduled = true;
                process.nextTick(() => this.executeBatch());
            }
        });
    }

    async executeBatch() {
        const currentBatch = this.batch.splice(0);
        this.batchScheduled = false;

        const ids = currentBatch.map(item => item.userId);
        const users = await this.db.query(
            `SELECT * FROM users WHERE user_id IN (${ids.map(() => '?').join(',')})`,
            ids
        );

        const userMap = new Map(users.map(u => [u.user_id, u]));
        currentBatch.forEach(({ userId, resolve }) => {
            const user = userMap.get(userId);
            this.cache.set(userId, user);
            resolve(user);
        });
    }
}

N+1 解决方案对比:

方案查询次数优点缺点
原始 N+1N+1代码简单性能灾难
JOIN1最少查询次数结果集可能膨胀(一对多 JOIN)
批量 IN2灵活,避免结果膨胀需手动拼接
DataLoader2(自动批量)自动合并、带缓存实现较复杂

七、数据库分库分表

7.1 为什么要分库分表

单库单表的瓶颈:

┌──────────────────────────────────────────────┐
│  数据量: 单表超过 2000 万行性能下降              │
│  QPS:    单库连接数有上限 (通常 3000-5000)      │
│  存储:   单机磁盘空间有限                       │
│  可用性: 单点故障风险                            │
└──────────────────────────────────────────────┘


┌──────────────────────────────────────────────┐
│  解决方案: 分库分表                              │
│                                              │
│  ┌──────────┐  ┌──────────┐                  │
│  │ 垂直拆分  │  │ 水平拆分  │                  │
│  │          │  │          │                  │
│  │ 按业务分库│  │ 按规则分片│                  │
│  │ 按字段分表│  │ 同结构多表│                  │
│  └──────────┘  └──────────┘                  │
└──────────────────────────────────────────────┘

7.2 垂直拆分

垂直分库 —— 按业务模块拆分为独立数据库:

拆分前:                        拆分后:
┌──────────────┐              ┌──────────────┐
│   单体数据库  │              │  用户库 db_user│
│              │              │  users        │
│  users       │              │  user_profiles│
│  orders      │    ────►     └──────────────┘
│  products    │              ┌──────────────┐
│  payments    │              │  订单库 db_order│
│  logistics   │              │  orders       │
└──────────────┘              │  payments     │
                              └──────────────┘
                              ┌──────────────┐
                              │  商品库 db_prod│
                              │  products     │
                              │  categories   │
                              └──────────────┘

垂直分表 —— 将一张宽表的字段拆分为多张表:

sql
CREATE TABLE users (
    user_id INT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL,
    status TINYINT DEFAULT 1
);

CREATE TABLE user_details (
    user_id INT PRIMARY KEY,
    avatar VARCHAR(255),
    bio TEXT,
    address TEXT,
    preferences JSON,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);

7.3 水平拆分

将同一张表的数据行按照某种规则分散到多张结构相同的表或多个库中。

水平拆分示意:

           orders (原表 1亿行)
          ┌───────────────────┐
          │ order_id | ...    │
          │ 1~1亿条数据       │
          └───────────────────┘

        ┌───────────┼───────────┐
        ▼           ▼           ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ orders_00  │ │ orders_01  │ │ orders_02  │
│ 3333万行   │ │ 3333万行   │ │ 3334万行   │
└────────────┘ └────────────┘ └────────────┘
  DB_0 节点      DB_1 节点      DB_2 节点

7.4 分片策略

Hash 分片

javascript
function getShardIndex(userId, shardCount) {
    return userId % shardCount;
}

const shardCount = 4;
const userId = 12345;
const shardIndex = getShardIndex(userId, shardCount);
const tableName = `orders_${String(shardIndex).padStart(2, '0')}`;
Hash 分片示意:

user_id = 12345
12345 % 4 = 1  → orders_01

┌─────────────────────────────────────────┐
│ user_id % 4 = 0  →  orders_00          │
│ user_id % 4 = 1  →  orders_01  ← 命中  │
│ user_id % 4 = 2  →  orders_02          │
│ user_id % 4 = 3  →  orders_03          │
└─────────────────────────────────────────┘

Range 分片

javascript
function getShardByRange(orderId) {
    if (orderId <= 10000000) return 'orders_00';
    if (orderId <= 20000000) return 'orders_01';
    if (orderId <= 30000000) return 'orders_02';
    return 'orders_03';
}
Range 分片示意:

  orders_00          orders_01          orders_02
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│ id: 1~1000万  │  │ id: 1000万~  │  │ id: 2000万~  │
│              │  │     2000万    │  │     3000万    │
└──────────────┘  └──────────────┘  └──────────────┘

一致性哈希

普通 Hash 分片的最大问题是扩容时需要大量数据迁移。一致性哈希通过引入虚拟的"哈希环"来最小化迁移量。

一致性哈希环 (0 ~ 2^32):

                    0 / 2^32

              Node C  ●
                     ╱  ╲
                   ╱      ╲
          ●──────╱          ╲──────●
       Node B   ╱            ╲   Node A
               │     数据     │
               │  顺时针找到   │
               │  第一个节点   │
                ╲            ╱
                  ╲        ╱
                    ╲    ╱

                   Node D

数据路由: hash(key) → 在环上顺时针找到第一个节点

扩容只需迁移相邻节点的部分数据:

  新增 Node E 在 Node A 和 Node B 之间:
  只需要将 Node B 中属于 Node E 范围的数据迁移过去
  其他节点完全不受影响
javascript
const crypto = require('crypto');

class ConsistentHash {
    constructor(nodes, virtualNodes = 150) {
        this.ring = new Map();
        this.sortedKeys = [];
        this.virtualNodes = virtualNodes;

        nodes.forEach(node => this.addNode(node));
    }

    hash(key) {
        return parseInt(
            crypto.createHash('md5').update(key).digest('hex').slice(0, 8),
            16
        );
    }

    addNode(node) {
        for (let i = 0; i < this.virtualNodes; i++) {
            const virtualKey = this.hash(`${node}#${i}`);
            this.ring.set(virtualKey, node);
            this.sortedKeys.push(virtualKey);
        }
        this.sortedKeys.sort((a, b) => a - b);
    }

    removeNode(node) {
        for (let i = 0; i < this.virtualNodes; i++) {
            const virtualKey = this.hash(`${node}#${i}`);
            this.ring.delete(virtualKey);
            this.sortedKeys = this.sortedKeys.filter(k => k !== virtualKey);
        }
    }

    getNode(key) {
        const hashVal = this.hash(key);
        for (const ringKey of this.sortedKeys) {
            if (hashVal <= ringKey) {
                return this.ring.get(ringKey);
            }
        }
        return this.ring.get(this.sortedKeys[0]);
    }
}

const ch = new ConsistentHash(['db_0', 'db_1', 'db_2']);
console.log(ch.getNode('user_12345'));
console.log(ch.getNode('order_67890'));

分片策略对比:

对比维度Hash 分片Range 分片一致性哈希
数据均匀性✅ 分布均匀❌ 可能倾斜(热点)✅ 较均匀(虚拟节点)
范围查询❌ 需要跨分片✅ 连续数据在同一分片❌ 需要跨分片
扩容难度❌ 全量数据迁移✅ 新增分片即可✅ 仅迁移相邻数据
适用场景点查为主范围查询为主弹性扩缩容需求

7.5 分布式 ID 生成 —— 雪花算法

分库分表后,数据库自增 ID 不再全局唯一。雪花算法(Snowflake) 是一种经典的分布式 ID 生成方案。

雪花算法 ID 结构 (64 bit):

┌──────┬──────────────────────────┬────────────┬──────────────┐
│ 符号  │      41位时间戳           │  10位机器   │  12位序列号   │
│ 1bit │  (毫秒级,可用69年)        │  (1024台)  │  (4096/ms)   │
│  0   │                          │            │              │
└──────┴──────────────────────────┴────────────┴──────────────┘
  63      62                   22   21      12  11           0
javascript
class SnowflakeIdGenerator {
    constructor(workerId, datacenterId) {
        this.epoch = 1609459200000n;
        this.workerIdBits = 5n;
        this.datacenterIdBits = 5n;
        this.sequenceBits = 12n;

        this.maxWorkerId = (1n << this.workerIdBits) - 1n;
        this.maxDatacenterId = (1n << this.datacenterIdBits) - 1n;

        this.workerIdShift = this.sequenceBits;
        this.datacenterIdShift = this.sequenceBits + this.workerIdBits;
        this.timestampShift = this.sequenceBits + this.workerIdBits + this.datacenterIdBits;
        this.sequenceMask = (1n << this.sequenceBits) - 1n;

        if (BigInt(workerId) > this.maxWorkerId || BigInt(workerId) < 0n) {
            throw new Error(`workerId 超出范围: 0 ~ ${this.maxWorkerId}`);
        }
        if (BigInt(datacenterId) > this.maxDatacenterId || BigInt(datacenterId) < 0n) {
            throw new Error(`datacenterId 超出范围: 0 ~ ${this.maxDatacenterId}`);
        }

        this.workerId = BigInt(workerId);
        this.datacenterId = BigInt(datacenterId);
        this.sequence = 0n;
        this.lastTimestamp = -1n;
    }

    currentTimestamp() {
        return BigInt(Date.now());
    }

    tilNextMillis(lastTimestamp) {
        let timestamp = this.currentTimestamp();
        while (timestamp <= lastTimestamp) {
            timestamp = this.currentTimestamp();
        }
        return timestamp;
    }

    nextId() {
        let timestamp = this.currentTimestamp();

        if (timestamp < this.lastTimestamp) {
            throw new Error('时钟回拨,拒绝生成 ID');
        }

        if (timestamp === this.lastTimestamp) {
            this.sequence = (this.sequence + 1n) & this.sequenceMask;
            if (this.sequence === 0n) {
                timestamp = this.tilNextMillis(this.lastTimestamp);
            }
        } else {
            this.sequence = 0n;
        }

        this.lastTimestamp = timestamp;

        return (
            ((timestamp - this.epoch) << this.timestampShift) |
            (this.datacenterId << this.datacenterIdShift) |
            (this.workerId << this.workerIdShift) |
            this.sequence
        );
    }
}

const idGen = new SnowflakeIdGenerator(1, 1);
for (let i = 0; i < 5; i++) {
    console.log(idGen.nextId().toString());
}

常见分布式 ID 方案对比:

方案有序性性能依赖缺点
UUID❌ 无序过长(36字符),索引效率低
数据库自增✅ 有序数据库单点瓶颈
Redis INCR✅ 有序Redis需保证 Redis 高可用
雪花算法✅ 趋势递增极高依赖时钟,有时钟回拨问题
Leaf (美团)✅ 有序ZK/DB架构复杂

八、实战案例 —— 博客系统数据库设计

8.1 需求分析

┌──────────────────────────────────────────────────────┐
│               博客系统功能需求                          │
├──────────────────────────────────────────────────────┤
│                                                      │
│  用户模块:                                            │
│    - 注册、登录                                       │
│    - 用户资料(头像、简介)                              │
│                                                      │
│  文章模块:                                            │
│    - 发布、编辑、删除文章                               │
│    - 文章支持分类和标签                                 │
│    - 文章列表分页展示                                   │
│                                                      │
│  评论模块:                                            │
│    - 对文章发表评论                                    │
│    - 支持评论回复(二级评论)                            │
│                                                      │
│  标签模块:                                            │
│    - 文章可打多个标签                                   │
│    - 按标签筛选文章                                    │
│                                                      │
│  统计需求:                                            │
│    - 文章浏览量                                       │
│    - 用户文章数量                                      │
│    - 热门文章排行                                      │
│                                                      │
└──────────────────────────────────────────────────────┘

8.2 实体关系分析

实体关系梳理:

  User ──── 1:N ────► Article      一个用户发布多篇文章
  User ──── 1:N ────► Comment      一个用户发表多条评论
  Article ── 1:N ────► Comment      一篇文章下有多条评论
  Article ── M:N ────► Tag          文章与标签多对多
  Comment ── 1:N ────► Comment      评论的回复(自关联)
  Article ── N:1 ────► Category     文章属于一个分类

8.3 完整 ER 图

┌─────────────┐         ┌──────────────┐         ┌─────────────┐
│  categories │         │    users     │         │    tags     │
│─────────────│         │──────────────│         │─────────────│
│ category_id │◄──┐     │ user_id (PK) │         │ tag_id (PK) │
│ name        │   │     │ username     │         │ name        │
│ sort_order  │   │     │ email        │         │ created_at  │
└─────────────┘   │     │ password_hash│         └──────┬──────┘
                  │     │ avatar       │                │
                  │     │ bio          │                │ M:N
                  │     │ status       │                │
                  │     │ created_at   │         ┌──────┴──────┐
                  │     │ updated_at   │         │article_tags │
                  │     └──────┬───────┘         │─────────────│
                  │            │                 │ article_id  │
                  │            │ 1:N             │ tag_id      │
                  │            │                 └──────┬──────┘
                  │     ┌──────┴───────┐                │
                  │     │   articles   │                │
                  │     │──────────────│◄───────────────┘
                  └────►│ article_id   │
                        │ user_id (FK) │
                        │ category_id  │
                        │ title        │         ┌──────────────┐
                        │ summary      │         │   comments   │
                        │ content      │         │──────────────│
                        │ cover_image  │    ┌───►│ comment_id   │
                        │ view_count   │    │    │ article_id   │
                        │ status       │    │    │ user_id (FK) │
                        │ is_top       │    │    │ parent_id    │──┐
                        │ created_at   │    │    │ content      │  │
                        │ updated_at   │    │    │ status       │  │
                        └──────┬───────┘    │    │ created_at   │  │
                               │            │    └──────────────┘  │
                               │ 1:N        │          ▲           │
                               └────────────┘          │ 自关联    │
                                                       └──────────┘

8.4 完整建表 SQL

sql
CREATE DATABASE blog_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE blog_system;

CREATE TABLE users (
    user_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    avatar VARCHAR(255) DEFAULT NULL,
    bio VARCHAR(500) DEFAULT NULL,
    status TINYINT UNSIGNED NOT NULL DEFAULT 1,
    article_count INT UNSIGNED NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_username (username),
    UNIQUE KEY uk_email (email)
) ENGINE=InnoDB;

CREATE TABLE categories (
    category_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    sort_order INT UNSIGNED NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_name (name)
) ENGINE=InnoDB;

CREATE TABLE tags (
    tag_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(30) NOT NULL,
    article_count INT UNSIGNED NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_name (name)
) ENGINE=InnoDB;

CREATE TABLE articles (
    article_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id INT UNSIGNED NOT NULL,
    category_id INT UNSIGNED DEFAULT NULL,
    title VARCHAR(200) NOT NULL,
    summary VARCHAR(500) DEFAULT NULL,
    content MEDIUMTEXT NOT NULL,
    cover_image VARCHAR(255) DEFAULT NULL,
    view_count INT UNSIGNED NOT NULL DEFAULT 0,
    comment_count INT UNSIGNED NOT NULL DEFAULT 0,
    status TINYINT UNSIGNED NOT NULL DEFAULT 0,
    is_top TINYINT UNSIGNED NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(user_id),
    FOREIGN KEY (category_id) REFERENCES categories(category_id),
    KEY idx_user_id (user_id),
    KEY idx_category_id (category_id),
    KEY idx_status_created (status, created_at),
    KEY idx_is_top_created (is_top, created_at)
) ENGINE=InnoDB;

CREATE TABLE article_tags (
    article_id INT UNSIGNED NOT NULL,
    tag_id INT UNSIGNED NOT NULL,
    PRIMARY KEY (article_id, tag_id),
    FOREIGN KEY (article_id) REFERENCES articles(article_id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(tag_id) ON DELETE CASCADE,
    KEY idx_tag_id (tag_id)
) ENGINE=InnoDB;

CREATE TABLE comments (
    comment_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    article_id INT UNSIGNED NOT NULL,
    user_id INT UNSIGNED NOT NULL,
    parent_id INT UNSIGNED DEFAULT NULL,
    content TEXT NOT NULL,
    status TINYINT UNSIGNED NOT NULL DEFAULT 1,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (article_id) REFERENCES articles(article_id) ON DELETE CASCADE,
    FOREIGN KEY (user_id) REFERENCES users(user_id),
    FOREIGN KEY (parent_id) REFERENCES comments(comment_id) ON DELETE CASCADE,
    KEY idx_article_created (article_id, created_at),
    KEY idx_user_id (user_id),
    KEY idx_parent_id (parent_id)
) ENGINE=InnoDB;

8.5 索引设计分析

索引设计思路:

┌──────────────────────────────────────────────────────────────────┐
│                        索引设计清单                               │
├──────────────┬───────────────────────────┬───────────────────────┤
│ 表            │ 索引                      │ 覆盖的查询场景         │
├──────────────┼───────────────────────────┼───────────────────────┤
│ users        │ uk_username               │ 登录时按用户名查找      │
│              │ uk_email                  │ 邮箱唯一性校验          │
├──────────────┼───────────────────────────┼───────────────────────┤
│ articles     │ idx_user_id               │ 查看某用户的所有文章     │
│              │ idx_category_id           │ 按分类筛选文章          │
│              │ idx_status_created        │ 文章列表(状态+时间排序)  │
│              │ idx_is_top_created        │ 置顶文章查询            │
├──────────────┼───────────────────────────┼───────────────────────┤
│ article_tags │ PK(article_id, tag_id)    │ 查某文章的所有标签       │
│              │ idx_tag_id                │ 查某标签下的所有文章     │
├──────────────┼───────────────────────────┼───────────────────────┤
│ comments     │ idx_article_created       │ 文章详情页的评论列表     │
│              │ idx_user_id               │ 某用户的所有评论        │
│              │ idx_parent_id             │ 查询评论的子回复        │
└──────────────┴───────────────────────────┴───────────────────────┘

8.6 典型查询与优化

查询 1:文章列表(首页分页)

sql
SELECT a.article_id, a.title, a.summary, a.cover_image,
       a.view_count, a.comment_count, a.created_at,
       u.username, u.avatar
FROM articles a
INNER JOIN users u ON a.user_id = u.user_id
WHERE a.status = 1
ORDER BY a.is_top DESC, a.created_at DESC
LIMIT 20;

查询 2:按标签查文章

sql
SELECT a.article_id, a.title, a.summary, a.created_at
FROM article_tags at_rel
INNER JOIN articles a ON at_rel.article_id = a.article_id
WHERE at_rel.tag_id = 5
  AND a.status = 1
ORDER BY a.created_at DESC
LIMIT 20;

查询 3:文章详情(含评论)

sql
SELECT a.article_id, a.title, a.content, a.view_count,
       a.created_at, a.updated_at,
       u.username, u.avatar
FROM articles a
INNER JOIN users u ON a.user_id = u.user_id
WHERE a.article_id = 1001;

SELECT c.comment_id, c.content, c.parent_id, c.created_at,
       u.username, u.avatar
FROM comments c
INNER JOIN users u ON c.user_id = u.user_id
WHERE c.article_id = 1001
  AND c.status = 1
ORDER BY c.created_at ASC
LIMIT 50;

查询 4:浏览量更新(高频写入优化)

sql
UPDATE articles SET view_count = view_count + 1 WHERE article_id = 1001;

高并发场景下的优化思路:

方案对比:

┌──────────────┬─────────────────────────────┬─────────────────┐
│ 方案          │ 实现                        │ 适用场景          │
├──────────────┼─────────────────────────────┼─────────────────┤
│ 直接 UPDATE  │ 每次访问写数据库              │ 低流量             │
│ Redis 缓存   │ INCR 计数,定期同步到 MySQL   │ 中等流量           │
│ 消息队列     │ 异步消费写入                  │ 高流量             │
└──────────────┴─────────────────────────────┴─────────────────┘
javascript
const Redis = require('ioredis');
const redis = new Redis();

async function incrementViewCount(articleId) {
    const key = `article:views:${articleId}`;
    await redis.incr(key);
}

async function syncViewCountsToDB(pool) {
    const keys = await redis.keys('article:views:*');

    for (const key of keys) {
        const articleId = key.split(':')[2];
        const count = await redis.getdel(key);

        if (count && Number(count) > 0) {
            await pool.execute(
                'UPDATE articles SET view_count = view_count + ? WHERE article_id = ?',
                [Number(count), articleId]
            );
        }
    }
}

8.7 Node.js 数据访问层示例

javascript
const mysql = require('mysql2/promise');

const pool = mysql.createPool({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'blog_system',
    waitForConnections: true,
    connectionLimit: 20,
    queueLimit: 0
});

async function getArticleList(page = 1, pageSize = 20) {
    const offset = (page - 1) * pageSize;

    const [rows] = await pool.execute(
        `SELECT a.article_id, a.title, a.summary, a.cover_image,
                a.view_count, a.comment_count, a.created_at,
                u.username, u.avatar
         FROM articles a
         INNER JOIN users u ON a.user_id = u.user_id
         WHERE a.status = 1
         ORDER BY a.is_top DESC, a.created_at DESC
         LIMIT ? OFFSET ?`,
        [pageSize, offset]
    );

    const [countResult] = await pool.execute(
        'SELECT COUNT(*) AS total FROM articles WHERE status = 1'
    );

    return {
        list: rows,
        total: countResult[0].total,
        page,
        pageSize,
        totalPages: Math.ceil(countResult[0].total / pageSize)
    };
}

async function createArticleWithTags(userId, articleData, tagIds) {
    const conn = await pool.getConnection();

    try {
        await conn.beginTransaction();

        const [articleResult] = await conn.execute(
            `INSERT INTO articles (user_id, category_id, title, summary, content, cover_image, status)
             VALUES (?, ?, ?, ?, ?, ?, ?)`,
            [
                userId,
                articleData.categoryId,
                articleData.title,
                articleData.summary,
                articleData.content,
                articleData.coverImage,
                articleData.status || 0
            ]
        );

        const articleId = articleResult.insertId;

        if (tagIds && tagIds.length > 0) {
            const tagValues = tagIds.map(tagId => [articleId, tagId]);
            await conn.query(
                'INSERT INTO article_tags (article_id, tag_id) VALUES ?',
                [tagValues]
            );

            for (const tagId of tagIds) {
                await conn.execute(
                    'UPDATE tags SET article_count = article_count + 1 WHERE tag_id = ?',
                    [tagId]
                );
            }
        }

        await conn.execute(
            'UPDATE users SET article_count = article_count + 1 WHERE user_id = ?',
            [userId]
        );

        await conn.commit();
        return articleId;
    } catch (err) {
        await conn.rollback();
        throw err;
    } finally {
        conn.release();
    }
}

async function getArticlesByTag(tagId, lastArticleId = 0, pageSize = 20) {
    let sql, params;

    if (lastArticleId > 0) {
        sql = `SELECT a.article_id, a.title, a.summary, a.created_at
               FROM article_tags at_rel
               INNER JOIN articles a ON at_rel.article_id = a.article_id
               WHERE at_rel.tag_id = ?
                 AND a.status = 1
                 AND a.article_id < ?
               ORDER BY a.article_id DESC
               LIMIT ?`;
        params = [tagId, lastArticleId, pageSize];
    } else {
        sql = `SELECT a.article_id, a.title, a.summary, a.created_at
               FROM article_tags at_rel
               INNER JOIN articles a ON at_rel.article_id = a.article_id
               WHERE at_rel.tag_id = ?
                 AND a.status = 1
               ORDER BY a.article_id DESC
               LIMIT ?`;
        params = [tagId, pageSize];
    }

    const [rows] = await pool.execute(sql, params);
    return rows;
}

九、知识体系总览

┌─────────────────────────────────────────────────────────────────┐
│                    数据库设计知识体系                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐      │
│   │ 范式理论  │  │ ER 建模  │  │ 索引原理  │  │ 事务机制  │      │
│   │          │  │          │  │          │  │          │      │
│   │ 1NF/2NF  │  │ 实体     │  │ B+ 树    │  │ ACID     │      │
│   │ 3NF/BCNF │  │ 关系     │  │ 聚簇索引  │  │ 隔离级别  │      │
│   │ 反范式化  │  │ 外键级联  │  │ 联合索引  │  │ MVCC     │      │
│   └──────────┘  └──────────┘  │ 覆盖索引  │  └──────────┘      │
│                               └──────────┘                      │
│   ┌──────────┐  ┌──────────┐  ┌──────────┐                     │
│   │ 锁机制   │  │ 查询优化  │  │ 分库分表  │                     │
│   │          │  │          │  │          │                     │
│   │ 共享/排他 │  │ EXPLAIN  │  │ 垂直拆分  │                     │
│   │ 行锁/表锁│  │ 慢查询   │  │ 水平拆分  │                     │
│   │ 乐观/悲观│  │ N+1 问题 │  │ 一致性哈希│                     │
│   │ 死锁避免 │  │ 深分页   │  │ 雪花算法  │                     │
│   └──────────┘  └──────────┘  └──────────┘                     │
│                                                                 │
│   ┌─────────────────────────────────────────────┐              │
│   │              实战:  博客系统                   │              │
│   │  需求分析 → ER图 → 建表 → 索引 → 查询优化     │              │
│   └─────────────────────────────────────────────┘              │
└─────────────────────────────────────────────────────────────────┘

十、面试高频问题

题目 1:请解释数据库三范式及其关系,什么时候需要反范式化?

考查点: 范式理论基础 + 工程实践判断

第一范式要求字段不可再分(原子性);第二范式在 1NF 基础上消除非主属性对联合主键的部分依赖;第三范式在 2NF 基础上消除非主属性间的传递依赖。三者层层递进,每一级都以前一级为前提。当系统读多写少、JOIN 成为瓶颈、分布式环境跨库 JOIN 困难时,可以适度反范式化(冗余高频读取字段),但需要做好冗余数据的同步策略。

题目 2:B+ 树相比 B 树有什么优势?为什么 MySQL 选择 B+ 树做索引?

考查点: 索引数据结构原理

B+ 树所有数据都存储在叶子节点,非叶子节点只存索引键值,单个节点能容纳更多索引项,树更矮,IO 次数更少。叶子节点通过双向链表连接,支持高效范围查询和顺序遍历。B 树的数据分散在所有节点中,范围查询需要中序遍历,效率较低。数据库操作的瓶颈在于磁盘 IO,B+ 树更矮的树高意味着更少的磁盘访问次数。

题目 3:什么是覆盖索引?如何判断查询是否使用了覆盖索引?

考查点: 索引优化能力

覆盖索引是指查询所需的所有字段都包含在索引中,无需回表访问主键索引获取完整行数据。可通过 EXPLAIN 的 Extra 字段判断,如果显示 "Using index" 则说明使用了覆盖索引。设计联合索引时可以将高频查询的 SELECT 字段纳入索引来实现覆盖。

题目 4:MySQL 默认隔离级别是什么?MVCC 是如何工作的?

考查点: 事务与隔离级别、MVCC 原理

MySQL InnoDB 默认隔离级别为 REPEATABLE READ。MVCC 通过为每行数据维护版本链(undo log)和 ReadView 快照来实现非锁定读。每行有隐藏的 DB_TRX_ID 和 DB_ROLL_PTR 字段,分别记录最后修改的事务 ID 和指向旧版本的指针。事务在第一次 SELECT 时创建 ReadView,记录当前活跃事务列表,后续读取时根据可见性规则从版本链中找到可见的版本。RR 级别整个事务复用同一个 ReadView,RC 级别每次 SELECT 都创建新的 ReadView。

题目 5:请描述乐观锁和悲观锁的区别及适用场景

考查点: 并发控制策略

悲观锁假设冲突频繁发生,先加锁再操作(如 SELECT ... FOR UPDATE),适合写多场景。乐观锁假设冲突很少发生,操作时不加锁,提交时通过版本号/CAS 检测冲突,冲突则重试,适合读多写少场景。电商库存扣减适合悲观锁保证准确;社交内容点赞适合乐观锁提高并发。

题目 6:EXPLAIN 中 type 字段从好到差的排列是什么?出现 ALL 说明什么?

考查点: 慢查询分析能力

从好到差依次为:system > const > eq_ref > ref > range > index > ALL。出现 ALL 说明进行了全表扫描,即数据库需要读取表中每一行数据来完成查询,这通常意味着缺少合适的索引或查询条件导致索引失效,需要优化。目标是至少达到 range 级别。

题目 7:深分页(如 LIMIT 1000000, 10)为什么慢?有哪些优化方案?

考查点: 查询优化实战

LIMIT offset, size 需要扫描 offset + size 行再丢弃前 offset 行,offset 越大扫描量越大。优化方案:(1) 游标分页,基于上次查询的最后一个 ID 用 WHERE id > last_id LIMIT size,性能稳定为 O(1);(2) 延迟关联,子查询中只走覆盖索引获取 ID,再关联获取完整数据;(3) 对于搜索类需求使用 Elasticsearch 等专用搜索引擎。

题目 8:什么是 N+1 查询问题?如何解决?

考查点: ORM 性能优化

查询一个列表需要 1 次 SQL,然后对列表中每条记录关联查询需要 N 次 SQL,总共 N+1 次。解决方案:(1) JOIN 预加载,一次 SQL 获取所有关联数据;(2) 批量 IN 查询,先收集所有关联 ID 再一次性查出;(3) DataLoader 模式,自动合并同一 tick 内的查询请求并提供缓存。在 GraphQL 场景下 DataLoader 尤其重要。


十一、延伸阅读

书籍推荐:

书名侧重方向推荐理由
《高性能 MySQL》(第4版)MySQL 全面深入索引、查询优化、复制、分区等核心主题详尽
《MySQL 技术内幕:InnoDB 存储引擎》InnoDB 原理深入讲解 B+ 树、MVCC、锁、事务实现
《数据库系统概念》(第7版)数据库理论范式理论、关系代数、事务处理的经典教材
《Designing Data-Intensive Applications》分布式数据系统分片、复制、一致性等分布式核心主题
《SQL Antipatterns》SQL 反模式常见数据库设计错误及纠正方案

在线资源:

进阶主题:

主题说明
读写分离主从复制 + 应用层路由,读请求分散到从库
分布式事务2PC、TCC、SAGA 模式处理跨库事务一致性
NewSQLTiDB、CockroachDB 等兼顾分布式与 SQL 的新型数据库
列式存储ClickHouse、Apache Parquet 用于 OLAP 分析场景
数据库中间件ShardingSphere、Vitess 等透明化分库分表方案
缓存一致性Cache Aside / Read Through / Write Behind 等缓存策略

用心学习,用代码说话 💻