Skip to content

七、MySQL 数据库(18 题)

7.1 ★★★★MySQL 的存储引擎 InnoDB 和 MyISAM 的区别是什么?为什么 InnoDB 是默认引擎?

📖 点击查看答案
特性InnoDBMyISAM
事务支持 ACID不支持
锁粒度行锁(默认)+ 表锁仅表锁
外键支持不支持
索引结构B+ 树聚簇索引(数据和主键索引在一起)B+ 树非聚簇(索引和数据分离)
全文索引5.6+ 支持支持
崩溃恢复支持(redo log)不支持
MVCC支持不支持
主键查询快(直接定位数据)慢(需回表)
COUNT(*)需扫描(MVCC 下不能缓存)有缓存(存储总行数)
适用场景OLTP、高并发读写只读、统计(已弃用)

InnoDB 成为默认引擎原因(MySQL 5.5+):

  1. 支持事务,满足 OLTP 业务需求
  2. 行锁高并发性能好
  3. 支持外键,保证数据完整性
  4. redo log 保证崩溃恢复,数据安全
  5. MVCC 实现非阻塞读,读写不冲突
  6. 聚簇索引对主键查询性能优秀
  7. MyISAM 表锁高并发差,崩溃易损坏

InnoDB 关键特性

  • 插入缓冲(Insert Buffer / Change Buffer):对非唯一二级索引的插入先缓冲,再合并
  • 两次写(Double Write):防止页撕裂,保证数据完整性
  • 自适应哈希索引(AHI):对热点数据自动建哈希索引
  • 异步 IO(AIO):提高 I/O 性能
  • 刷新邻接页(Flush Neighbor Page):顺序 I/O 优化

7.2 ★★★MySQL 为什么选择 B+ 树作为索引结构?对比 B 树、Hash、红黑树?

📖 点击查看答案

B+ 树优势

  1. 非叶子节点不存数据,只存索引:每个节点可存更多索引,树更矮,磁盘 I/O 次数少(B+ 树 3-4 层即可存储千万级数据)
  2. 所有数据在叶子节点:查询稳定,都要走到叶子节点,时间复杂度稳定 O(logN)
  3. 叶子节点形成双向链表:范围查询高效(WHERE id BETWEEN 1 AND 100 只需定位起点遍历链表)
  4. 节点大小等于页大小(16KB):一次 I/O 读取一页,充分利用局部性原理

对比 B 树

  • B 树非叶子节点也存数据,每个节点索引数少,树更高
  • B 树范围查询需中序遍历,效率低
  • B+ 树叶子链表支持高效范围扫描

对比 Hash 索引

  • Hash 查询 O(1),但不支持范围查询、排序、最左前缀匹配
  • Hash 碰撞问题
  • 仅适用于等值查询(如 Memory 引擎)
  • InnoDB 自适应哈希索引(AHI)对热点数据自动建 Hash

对比红黑树(二叉平衡树)

  • 二叉树,每个节点只有 2 个子节点,树高 log₂(N),千万数据约 23 层
  • 树高过高,磁盘 I/O 次数多
  • B+ 树是多叉(通常 1000+ 叉),3-4 层即可

对比 AVL 树

  • 同样是二叉树,树高问题
  • 旋转调整成本高

对比跳表

  • Redis ZSet 用跳表(内存场景,实现简单)
  • 磁盘场景 B+ 树更适合(页对齐、节点大)

总结:B+ 树在磁盘存储场景下综合性能最优,适合数据库的等值查询、范围查询、排序需求。


7.3 ★★★★聚簇索引和非聚簇索引的区别是什么?什么是回表查询?如何避免回表?

📖 点击查看答案

聚簇索引(Clustered Index)

  • 数据行按主键顺序存储,数据和主键索引在同一棵 B+ 树
  • 叶子节点直接存储完整数据行
  • 一张表只能有一个聚簇索引(通常是主键)
  • InnoDB 的主键索引是聚簇索引

非聚簇索引(Secondary / Non-Clustered Index)

  • 也叫二级索引、辅助索引
  • 叶子节点存储的是主键值,而非完整数据行
  • 一张表可有多个非聚簇索引
  • InnoDB 的非主键索引都是非聚簇索引

MyISAM 索引

  • 全部是非聚簇索引,数据和索引分离存储
  • 叶子节点存储数据行的物理地址

回表(Table Lookup)

  • 通过非聚簇索引查询时,先在二级索引 B+ 树找到主键值
  • 再到聚簇索引 B+ 树中查找完整数据行
  • 两次 B+ 树查找,称为回表
sql
-- 假设有索引 idx_name(name)
SELECT * FROM users WHERE name = 'Tom';
-- 1. 在 idx_name 树找到 name='Tom' 对应的主键 id=5
-- 2. 回表到聚簇索引找到 id=5 的完整数据行

如何避免回表

  1. 覆盖索引(Covering Index)

    • 索引包含查询所需的所有列,直接在索引中获取数据
    sql
    -- 建联合索引 idx_name_age(name, age)
    SELECT name, age FROM users WHERE name = 'Tom';
    -- 直接从 idx_name_age 获取,无需回表
    • Explain 中 Extra 显示 Using index 表示用覆盖索引
  2. 避免 SELECT *

    • 只查需要的列,配合覆盖索引
  3. 将常用字段加入联合索引

    • 但索引列不宜过多(索引膨胀)
  4. 主键设计优化

    • 主键用自增整型,插入顺序,减少页分裂
    • 避免长字符串主键(二级索引存储主键值,空间浪费)

7.4 ★★★★什么是覆盖索引?什么是回表?什么场景下适合使用覆盖索引?

📖 点击查看答案

覆盖索引

  • 一个索引包含了查询所需的全部字段(SELECTWHEREORDER BYGROUP BY 涉及的列)
  • 查询可直接从索引 B+ 树获取数据,无需回表到聚簇索引
  • Explain 中 Extra 显示 Using index

回表

  • 通过二级索引找到主键值后,再到聚簇索引查找完整数据行的过程
  • 需要两次 B+ 树查找,性能较低
sql
-- 表结构
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50),
    age INT,
    city VARCHAR(50),
    INDEX idx_name_age(name, age)
);

-- 1. 需要回表(SELECT 包含索引外的列 city)
SELECT * FROM users WHERE name = 'Tom';
-- Extra: NULL

-- 2. 覆盖索引(只查 name, age)
SELECT name, age FROM users WHERE name = 'Tom';
-- Extra: Using index

-- 3. 覆盖索引(WHERE + SELECT 都在索引内)
SELECT name, age FROM users WHERE name = 'Tom' AND age > 18;
-- Extra: Using index

适合覆盖索引的场景

  1. 高频查询的特定列组合

    • 如用户列表查询 SELECT id, name, age FROM users WHERE name = ?
    • idx_name_age(name, age, id) 或直接用主键 id(聚簇索引天然包含)
  2. 统计查询

    • SELECT COUNT(*), city FROM users WHERE age > 18 GROUP BY city
    • idx_age_city(age, city) 避免 Using temporaryUsing filesort
  3. 排序分页

    • SELECT name, age FROM users ORDER BY name LIMIT 100
    • idx_name_age(name, age) 利用索引有序性
  4. 避免 SELECT * 的场景

    • 明确只查某几列,将这几列加入联合索引
  5. 查询列少且频繁

    • 索引列不宜过多(每列都占空间),适合列数少的查询

注意事项

  • 覆盖索引会增加索引列数,占用更多存储空间
  • 写操作(INSERT/UPDATE/DELETE)需维护多个索引,性能下降
  • 应根据实际查询模式权衡,不是所有查询都需要覆盖索引

7.5 ★★★★联合索引的最左前缀匹配原则是什么?联合索引 (a, b, c) 在哪些查询条件下会失效?

📖 点击查看答案

最左前缀匹配原则

  • 联合索引 (a, b, c) 按 a → b → c 的顺序建立 B+ 树
  • 查询条件必须从最左列开始,按顺序匹配
  • 遇到范围查询则停止匹配后续列

联合索引 (a, b, c) 的 B+ 树结构

  • 先按 a 排序,a 相同按 b 排序,b 相同按 c 排序
  • 因此只能利用 a 开头、连续的列

有效使用索引的情况

sql
-- 1. 全部使用
WHERE a = 1 AND b = 2 AND c = 3        -- 使用 abc
-- 2. 前缀使用
WHERE a = 1                             -- 使用 a
WHERE a = 1 AND b = 2                   -- 使用 ab
-- 3. 顺序无关(优化器会调整)
WHERE b = 2 AND a = 1                   -- 使用 ab
WHERE c = 3 AND a = 1 AND b = 2         -- 使用 abc
-- 4. 范围查询后续列仍可用(MySQL 5.6+ ICP)
WHERE a = 1 AND b > 2 AND c = 3         -- 使用 ab,c 通过 ICP 过滤

索引失效的情况

  1. 跳过最左列

    sql
    WHERE b = 2                           -- 不使用索引(无 a)
    WHERE c = 3                           -- 不使用索引(无 a)
    WHERE b = 2 AND c = 3                 -- 不使用索引(无 a)
  2. 范围查询后的列

    sql
    WHERE a > 1 AND b = 2                 -- 只使用 a,b 失效(a 是范围,b 无法利用索引有序性)
    WHERE a = 1 AND b > 2 AND c = 3       -- 使用 a、b,c 失效(但 ICP 可用)
  3. 对索引列使用函数/计算

    sql
    WHERE LEFT(a, 1) = '1'                -- 失效
    WHERE a + 1 = 2                       -- 失效
    WHERE a = 1                            -- 正常
    WHERE DATE(create_time) = '2024-01-01' -- 失效,改为 create_time >= '2024-01-01' AND create_time < '2024-01-02'
  4. 类型隐式转换

    sql
    -- a 是 VARCHAR,但传数字
    WHERE a = 1                           -- 失效(隐式转换相当于 CAST(a AS INT))
    WHERE a = '1'                         -- 正常
  5. LIKE 以通配符开头

    sql
    WHERE a LIKE '%abc'                    -- 失效
    WHERE a LIKE 'abc%'                    -- 正常(前缀匹配)
    WHERE a LIKE 'abc_def'                 -- 正常
  6. OR 条件

    sql
    WHERE a = 1 OR b = 2                   -- a 走索引,b 不走(除非 b 也有索引)
    WHERE a = 1 OR d = 2                   -- 若 d 无索引则全表扫描
  7. NOT / != / NOT IN / NOT LIKE

    sql
    WHERE a != 1                          -- 通常失效(扫描大部分数据,优化器选择全表)
  8. ORDER BY 与索引方向不一致

    sql
    ORDER BY a DESC, b ASC                -- b 无法利用索引(MySQL 8.0+ 支持降序索引)

7.6 ★★★什么是索引下推(ICP)?Explain 中哪些字段可以确认索引下推生效?

📖 点击查看答案

索引下推(Index Condition Pushdown,ICP)

  • MySQL 5.6 引入,针对联合索引的优化
  • 存储引擎层(而非 Server 层)对索引列进行条件过滤,减少回表次数

传统流程(无 ICP)

  1. 存储引擎根据联合索引第一个条件找到匹配的主键
  2. 回表取完整数据行
  3. Server 层根据其他条件过滤

ICP 流程

  1. 存储引擎根据联合索引遍历
  2. 在索引上直接判断其他条件(联合索引中的列),不符合则跳过
  3. 符合的才回表
  4. Server 层做剩余条件过滤(非索引列)
sql
-- 联合索引 idx_name_age(name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age > 18;

-- 无 ICP:
-- 1. 存储引擎找到所有 name LIKE '张%' 的记录(可能 1000 条)
-- 2. 回表 1000 次取完整数据
-- 3. Server 层过滤 age > 18(可能只剩 100 条)

-- 有 ICP:
-- 1. 存储引擎找到 name LIKE '张%' 的记录
-- 2. 在索引上判断 age > 18(索引中有 age),不符合跳过
-- 3. 只对符合的记录回表(100 次)
-- 减少 900 次回表

Explain 中确认 ICP 生效的字段

  • Extra 列显示 Using index condition:表示使用了索引下推
  • 对比:
    • Using index:覆盖索引,无需回表
    • Using index condition:索引下推,先在索引过滤再回表
    • Using where:Server 层过滤(回表后再过滤)
sql
EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND age > 18;
-- +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+
-- | id | select_type | table | partitions | type  | possible_keys | key          | key_len | ref  | rows | filtered | Extra                 |
-- +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+
-- |  1 | SIMPLE      | users | NULL       | range | idx_name_age  | idx_name_age | 156     | NULL | 1000 |    33.33 | Using index condition |
-- +----+-------------+-------+------------+-------+---------------+--------------+---------+------+------+----------+-----------------------+

ICP 适用条件

  • 联合索引
  • 范围查询后的列仍可被利用
  • WHERE 条件中包含索引列
  • 不支持子查询、存储函数

ICP 局限

  • 只能下推 WHERE 条件,不能下推 JOIN ON 条件
  • 不支持虚拟列上的条件

7.7 ★★★EXPLAIN 的各个字段含义是什么?如何根据 Explain 结果优化慢 SQL?type 列的各种值分别代表什么含义?

📖 点击查看答案

EXPLAIN 关键字段

字段含义
id查询序号,相同表示同一步骤,不同则从大到小执行
select_typeSIMPLE(简单查询)、PRIMARY(最外层)、SUBQUERY(子查询)、DERIVED(派生表)、UNION
table表名
type访问类型,性能从好到差:system > const > eq_ref > ref > range > index > ALL
possible_keys可能使用的索引
key实际使用的索引(NULL 表示未用索引)
key_len索引使用的字节数,可判断联合索引用了几个列
ref索引比较的列或常量,如 constdb.t1.id
rows预估扫描行数(越小越好)
filtered过滤后剩余比例(百分比)
Extra额外信息(重要)

type 列值含义(性能从好到差):

  1. system:表只有一行(系统表)
  2. const:通过主键或唯一索引等值查询,最多返回一行
    sql
    WHERE id = 1  -- 主键等值
  3. eq_ref:JOIN 时被驱动表使用主键或唯一索引,最多匹配一行
    sql
    JOIN b ON a.id = b.a_id  -- b.a_id 是唯一索引
  4. ref:通过普通索引等值查询,可能返回多行
    sql
    WHERE name = 'Tom'  -- name 是普通索引
  5. range:索引范围扫描
    sql
    WHERE id BETWEEN 1 AND 100
    WHERE id IN (1, 2, 3)
    WHERE id > 10
  6. index:扫描整个索引树(不回表,比 ALL 快)
    sql
    SELECT COUNT(*) FROM users;  -- 扫描最小索引
  7. ALL:全表扫描(最差,需优化)

Extra 关键值

  • Using index:覆盖索引,无需回表(好)
  • Using index condition:索引下推(ICP)
  • Using where:Server 层过滤(回表后过滤)
  • Using temporary:使用临时表(需优化,常见于 GROUP BY、DISTINCT)
  • Using filesort:文件排序(需优化,常见于 ORDER BY 未用索引)
  • Using join buffer:使用 Join Buffer(被驱动表无索引)
  • Impossible WHERE:WHERE 条件恒为假

优化慢 SQL 步骤

  1. type:避免 ALL,至少 range,最好 ref/const

  2. key:是否走了预期索引,未走则调整 SQL 或加索引

  3. rows:扫描行数是否过大,过大需优化

  4. Extra

    • Using filesort:ORDER BY 列加索引或调整
    • Using temporary:GROUP BY 列加索引
    • Using where:考虑覆盖索引避免回表
  5. key_len:判断联合索引用了几列

    • key_len = 字段长度 + 是否可空(1) + 变长(2)
    • INT NOT NULL = 4,VARCHAR(50) NOT NULL utf8mb4 = 50*4 + 2 = 202
  6. 优化建议

    • 加合适索引(联合索引、覆盖索引)
    • 避免 SELECT *
    • 避免函数操作索引列
    • 大表分页用游标:WHERE id > last_id LIMIT 100 替代 LIMIT 1000000, 100

7.8 ★★★★MySQL 事务的 ACID 特性是如何实现的?(MVCC + undo log、redo log、锁机制)

📖 点击查看答案

ACID 特性及实现机制

1. 原子性(Atomicity)

  • 通过 undo log(回滚日志) 实现
  • 事务执行前先写 undo log,记录修改前的旧值
  • 事务回滚时根据 undo log 恢复数据
  • undo log 还用于 MVCC 读取历史版本
事务:UPDATE users SET name='Tom' WHERE id=1;
-- undo log 记录:id=1, name='old_value'
-- 回滚:根据 undo log 恢复 name='old_value'

2. 持久性(Durability)

  • 通过 redo log(重做日志) 实现
  • 事务修改数据时,先写 redo log(WAL,Write-Ahead Logging),再修改 Buffer Pool 中的数据页
  • redo log 是物理日志(记录页的修改),顺序写性能高
  • 事务提交时 redo log 刷盘(innodb_flush_log_at_trx_commit=1 默认)
  • 崩溃恢复时重放 redo log,恢复已提交但未刷盘的数据

3. 隔离性(Isolation)

  • 通过 锁机制 + MVCC 实现
  • 写操作用锁保证互斥(行锁、间隙锁)
  • 读操作用 MVCC(多版本并发控制)避免阻塞写
  • 不同隔离级别对应不同的锁和 MVCC 策略

4. 一致性(Consistency)

  • 由原子性 + 持久性 + 隔离性共同保证
  • 应用层约束(如外键、约束、业务逻辑)也参与
  • 事务执行前后数据满足完整性约束

redo log 工作流程(WAL):

1. 修改数据 → 先写 redo log buffer(内存)
2. 事务提交 → redo log buffer 刷到 redo log file(OS cache 或 fsync 到磁盘)
3. 异步将 Buffer Pool 脏页刷到数据文件(checkpoint)
4. 崩溃恢复 → 重放 redo log,恢复未刷盘的已提交数据

undo log 工作流程

1. 修改数据前 → 写 undo log 到 undo log segment
2. 通过回滚指针 `roll_pointer` 将数据行的历史版本串联成版本链
3. 回滚 → 沿版本链恢复
4. MVCC 读 → 根据 ReadView 沿版本链找到可见版本
5. undo log 在无事务引用时由 purge 线程清理

MVCC(Multi-Version Concurrency Control)

  • 每行数据有隐藏字段:trx_id(最近修改的事务ID)、roll_pointer(指向 undo log 版本链)
  • 读操作根据 ReadView 判断版本可见性,实现非阻塞读
  • RC 级别每次 SELECT 生成新 ReadView,RR 级别事务首次 SELECT 生成 ReadView

锁机制

  • 行锁:Record Lock(记录锁)、Gap Lock(间隙锁)、Next-Key Lock(临键锁)
  • 表锁:表锁、MDL 锁、IS/IX 意向锁
  • 不同隔离级别锁策略不同:RR 用 Next-Key Lock 防幻读,RC 用 Record Lock

7.9 ★★★★MySQL 事务隔离级别有哪些?InnoDB 的默认隔离级别是什么?为什么选择 RR(可重复读)?

📖 点击查看答案

四个隔离级别(从低到高):

隔离级别脏读不可重复读幻读实现机制
READ UNCOMMITTED(读未提交)可能可能可能无锁
READ COMMITTED(读已提交,RC)避免可能可能MVCC(每次 SELECT 新 ReadView)
REPEATABLE READ(可重复读,RR)避免避免避免*MVCC + Next-Key Lock
SERIALIZABLE(串行化)避免避免避免所有读加共享锁

*InnoDB 的 RR 通过 Next-Key Lock 在一定程度上避免了幻读,但特定场景下仍有幻读。

InnoDB 默认隔离级别:RR(REPEATABLE READ)

为什么选择 RR 而不是 RC

  1. 历史原因:早期 MySQL binlog 只有 statement 格式,RC 级别下主从复制会有数据不一致问题

    • Statement 格式记录 SQL 语句,RC 下并发执行顺序不确定,导致从库数据与主库不同
    • RR 配合 binlog_format=ROW 才能保证一致性,但早期默认 statement
  2. 数据一致性

    • RR 在事务内多次读结果一致,业务逻辑更稳定
    • 避免不可重复读带来的业务问题
  3. 主从复制安全

    • statement 格式下,RR 的顺序执行保证主从一致
    • 现在用 ROW 格式后,RC 也可用于主从复制,但 RR 仍是默认

为什么不用 SERIALIZABLE

  • 性能极差,所有读加锁,并发性几乎为 0

RR 下幻读问题

  • Next-Key Lock(Record + Gap)防止其他事务在范围内插入,解决大部分幻读
  • 当前事务内自己执行 INSERT/UPDATE 后再 SELECT,可能看到新插入的行(快照读 vs 当前读混用)
sql
-- RR 下幻读示例
BEGIN;
SELECT * FROM users WHERE id > 10;  -- 快照读,看不到 id=15
-- 另一事务 INSERT id=15 并提交
UPDATE users SET name='Tom' WHERE id = 15;  -- 当前事务 UPDATE 触发当前读
SELECT * FROM users WHERE id > 10;  -- 此时能看到 id=15(幻读)
COMMIT;

查看/设置隔离级别

sql
SELECT @@transaction_isolation;  -- 查看当前隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- 设置会话级别

7.10 ★★★脏读、不可重复读和幻读分别是什么?每种隔离级别分别解决了什么问题?

📖 点击查看答案

三种读异常

1. 脏读(Dirty Read)

  • 事务 A 读到了事务 B 未提交的修改
  • 事务 B 回滚后,事务 A 读到的数据是无效的(脏数据)
sql
-- 事务 A
BEGIN;
SELECT name FROM users WHERE id = 1;  -- name='Tom'
-- 事务 B
BEGIN;
UPDATE users SET name='Jerry' WHERE id = 1;  -- 未提交
-- 事务 A 再读
SELECT name FROM users WHERE id = 1;  -- name='Jerry'(脏读,B 未提交)
-- 事务 B 回滚
ROLLBACK;

2. 不可重复读(Non-Repeatable Read)

  • 事务 A 两次读同一行,结果不同
  • 中间被事务 B 修改或删除并提交
sql
-- 事务 A
BEGIN;
SELECT name FROM users WHERE id = 1;  -- name='Tom'
-- 事务 B
BEGIN;
UPDATE users SET name='Jerry' WHERE id = 1;
COMMIT;
-- 事务 A 再读
SELECT name FROM users WHERE id = 1;  -- name='Jerry'(不可重复读)

3. 幻读(Phantom Read)

  • 事务 A 两次执行同一范围查询,结果集行数不同
  • 中间被事务 B 插入新行并提交
sql
-- 事务 A
BEGIN;
SELECT * FROM users WHERE age > 18;  -- 10 条
-- 事务 B
BEGIN;
INSERT INTO users(name, age) VALUES('New', 20);
COMMIT;
-- 事务 A 再查
SELECT * FROM users WHERE age > 18;  -- 11 条(幻读)

不可重复读 vs 幻读

  • 不可重复读:同一行内容变化(UPDATE/DELETE)
  • 幻读:结果集行数变化(INSERT)

各隔离级别解决的问题

隔离级别脏读不可重复读幻读
READ UNCOMMITTED不解决不解决不解决
READ COMMITTED(RC)解决不解决不解决
REPEATABLE READ(RR)解决解决解决*(InnoDB 通过 Next-Key Lock)
SERIALIZABLE解决解决解决

InnoDB RR 级别如何解决幻读

  • 快照读(普通 SELECT):通过 MVCC,事务内始终读同一个 ReadView 快照,看不到新插入的行
  • 当前读(SELECT ... FOR UPDATE、UPDATE、DELETE):通过 Next-Key Lock 锁住范围,阻止其他事务插入
sql
-- 当前读 + Next-Key Lock
SELECT * FROM users WHERE age > 18 FOR UPDATE;
-- 锁住 age > 18 的所有记录 + 间隙,其他事务无法 INSERT

RR 下仍存在的幻读场景

  • 先快照读(无锁),再 UPDATE 触发当前读,可能看到新插入的行
  • 需在首次查询用 FOR UPDATE 加锁才能完全避免

7.11 ★★★★MVCC 的实现原理是什么?ReadView 是如何工作的?MVCC 在 RR 和 RC 级别下的行为有什么区别?

📖 点击查看答案

MVCC(Multi-Version Concurrency Control)实现原理

1. 数据行的隐藏字段(InnoDB 每行数据有):

  • trx_id:最近修改该行的事务 ID(6 字节)
  • roll_pointer:回滚指针,指向 undo log 中的上一版本(7 字节)
  • 通过 roll_pointer 将历史版本串联成版本链(undo log 链表)

2. undo log 版本链

当前数据:id=1, name='Tom', trx_id=100
   ↓ roll_pointer
undo log v3:id=1, name='Jerry', trx_id=90

undo log v2:id=1, name='Old', trx_id=80

undo log v1:插入时的原始数据

3. ReadView 结构

  • m_ids:生成 ReadView 时活跃(未提交)事务 ID 列表
  • min_trx_id:m_ids 中最小的事务 ID
  • max_trx_id:下一个将分配的事务 ID(系统最大事务 ID + 1)
  • creator_trx_id:生成 ReadView 的事务 ID

ReadView 可见性判断规则(沿版本链查找):

对于当前版本的 trx_id

  1. trx_id == creator_trx_id:自己修改的,可见
  2. trx_id < min_trx_id:生成 ReadView 前已提交,可见
  3. trx_id >= max_trx_id:ReadView 生成后才启动的事务,不可见
  4. min_trx_id <= trx_id < max_trx_id
    • m_ids 中:活跃事务,不可见
    • 不在 m_ids 中:已提交,可见
  5. 不可见则沿 roll_pointer 找上一版本,重复判断

RC 和 RR 的区别

特性RC(读已提交)RR(可重复读)
ReadView 生成时机每次 SELECT 都生成新 ReadView事务内首次 SELECT 生成 ReadView,后续复用
效果同一事务内多次读可能不同(看到其他事务已提交的修改)同一事务内多次读结果一致(快照固定)
不可重复读存在避免
幻读(快照读)存在避免快照读的幻读

RC 级别示例

sql
-- 事务 A(RC)
BEGIN;
SELECT name FROM users WHERE id = 1;  -- ReadView1,name='Tom'
-- 事务 B
UPDATE users SET name='Jerry' WHERE id = 1; COMMIT;
-- 事务 A 再读
SELECT name FROM users WHERE id = 1;  -- ReadView2,name='Jerry'(不可重复读)
COMMIT;

RR 级别示例

sql
-- 事务 A(RR)
BEGIN;
SELECT name FROM users WHERE id = 1;  -- ReadView1,name='Tom'
-- 事务 B
UPDATE users SET name='Jerry' WHERE id = 1; COMMIT;
-- 事务 A 再读
SELECT name FROM users WHERE id = 1;  -- 复用 ReadView1,name='Tom'(可重复读)
COMMIT;

MVCC 局限性

  • 只解决快照读的并发问题,当前读(FOR UPDATE)仍需锁
  • 长事务会导致 undo log 版本链过长,影响性能
  • 长事务持有旧 ReadView,导致 undo log 无法 purge,表空间膨胀

7.12 ★★★★MySQL 中有哪些锁类型?行锁、间隙锁、临键锁分别在什么场景下使用?

📖 点击查看答案

MySQL 锁分类

按粒度

  • 全局锁FLUSH TABLES WITH READ LOCK,全库只读,用于备份
  • 表锁
    • 表锁:LOCK TABLES t READ/WRITE
    • 元数据锁(MDL):防止 DDL 和 DML 冲突
    • 意向锁(IS/IX):快速判断表是否有行锁,避免全表扫描
  • 行锁(InnoDB 独有):
    • Record Lock(记录锁):锁住索引上的一条记录
    • Gap Lock(间隙锁):锁住索引记录之间的间隙,防止插入
    • Next-Key Lock(临键锁):Record + Gap,锁住记录 + 前面的间隙
    • Insert Intention Lock(插入意向锁):INSERT 时加,表示要在某间隙插入

按模式

  • 共享锁(S,读锁)SELECT ... LOCK IN SHARE MODE
  • 排他锁(X,写锁)SELECT ... FOR UPDATEUPDATEDELETEINSERT

Record Lock(记录锁)

  • 锁住索引上的一条记录
  • RC 隔离级别默认使用 Record Lock
  • 示例:SELECT * FROM users WHERE id = 1 FOR UPDATE 锁住 id=1 的索引记录

Gap Lock(间隙锁)

  • 锁住索引记录之间的间隙,不包括记录本身
  • 防止其他事务在间隙内插入新记录
  • RR 隔离级别下用于解决幻读
  • 示例:假设 id 有 1, 5, 10,WHERE id > 1 AND id < 5 FOR UPDATE 锁住 (1, 5) 间隙
    • 其他事务无法 INSERT id=2, 3, 4

Next-Key Lock(临键锁)

  • Record Lock + Gap Lock,锁住记录 + 前面的间隙
  • RR 隔离级别下默认的行锁类型
  • 示例:id 有 1, 5, 10,SELECT * FROM users WHERE id = 5 FOR UPDATE
    • 锁住 (1, 5] 区间(间隙 (1,5) + 记录 5)
    • 其他事务无法 INSERT id=2, 3, 4,也无法修改 id=5

使用场景

场景锁类型说明
RC + 等值查询Record Lock只锁匹配的记录
RR + 等值查询唯一索引命中Record Lock退化为记录锁
RR + 等值查询唯一索引未命中Gap Lock锁住间隙
RR + 等值查询非唯一索引Next-Key Lock锁住记录 + 间隙
RR + 范围查询Next-Key Lock锁住范围内所有记录 + 间隙
INSERTInsert Intention Lock插入意向锁,与 Gap Lock 冲突

示例

sql
-- 表数据:id=1, 5, 10, 15
-- 事务 A(RR)
BEGIN;
SELECT * FROM users WHERE id > 5 AND id < 15 FOR UPDATE;
-- 锁住 (5, 10) 和 (10, 15) 的 Next-Key Lock
-- 即 (5, 15) 区间都被锁

-- 事务 B
INSERT INTO users VALUES(7, 'Tom');  -- 阻塞(7 在间隙内)
INSERT INTO users VALUES(20, 'Tom'); -- 成功(20 不在锁范围)

意向锁作用

  • 表级锁,表示表中有行锁
  • 加行锁前先加表意向锁,避免逐行检查
  • IS 与 IS/IX 兼容,与表 X 锁冲突

7.13 ★★★★死锁在 MySQL 中是如何产生的?如何检测和处理死锁?如何从业务层面预防死锁?

📖 点击查看答案

死锁产生原因

  • 两个或多个事务相互等待对方持有的锁,形成循环依赖
  • InnoDB 行锁 + 交叉更新是常见原因

死锁示例

sql
-- 事务 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 锁 id=1
-- 事务 B
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;  -- 锁 id=2
-- 事务 A
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 等待 id=2 的锁
-- 事务 B
UPDATE accounts SET balance = balance + 100 WHERE id = 1;  -- 等待 id=1 的锁
-- 死锁!

死锁检测

  • InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON
  • 事务等待锁时,检测是否存在等待图(Wait-for Graph) 的环路
  • 发现死锁后,回滚undo 量较小的事务(victim),让另一事务继续
  • 报错:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

死锁处理

  • 被回滚的事务收到 1213 错误
  • 应用层捕获错误并重试事务
  • 重试次数有限(如 3 次),避免无限循环
java
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
    try {
        accountMapper.deduct(from, amount);
        accountMapper.add(to, amount);
    } catch (DeadlockLoserDataAccessException e) {
        // 重试
        throw new RetryableException("死锁,请重试");
    }
}

死锁日志分析

sql
SHOW ENGINE INNODB STATUS;
-- 查看 LATEST DETECTED DEADLOCK 部分
-- 包含两个事务的锁信息、SQL 语句

业务层面预防死锁

  1. 统一加锁顺序

    • 所有事务按相同顺序访问表和行(如按主键升序)
    sql
    -- 转账时,先锁较小 id 的账户
    if (from > to) { swap(from, to); }
  2. 缩短事务

    • 事务尽量短,减少锁持有时间
    • 避免在事务中执行慢操作(如 RPC 调用、文件 I/O)
  3. 降低隔离级别

    • RC 比 RR 锁范围小(无 Gap Lock),死锁概率低
    • 业务允许时用 RC
  4. 使用合理的索引

    • 无索引的 UPDATE/DELETE 会锁全表(或大量行),易死锁
    • 通过索引精准定位行,缩小锁范围
  5. 批量操作分批

    • 大批量 UPDATE 分小批,减少锁持有
  6. 避免事务嵌套

    • Spring @Transactional 默认传播 REQUIRED,嵌套调用共享事务
  7. 设置锁超时

    sql
    SET innodb_lock_wait_timeout = 10;  -- 锁等待超时 10s
    • 超时后报错,避免长时间阻塞
  8. 乐观锁替代悲观锁

    • 用版本号 UPDATE ... SET version=version+1 WHERE id=? AND version=?
    • 无锁竞争,无死锁

7.14 ★★★★redo logundo logbinlog 三种日志各自的作用是什么?它们之间的关系是怎样的?两阶段提交机制是怎样的?

📖 点击查看答案

三种日志

1. redo log(重做日志)

  • InnoDB 引擎层日志,物理日志(记录页的修改)
  • 作用:崩溃恢复,保证持久性(Durability)
  • WAL(Write-Ahead Logging):先写 redo log,再修改数据页
  • 大小固定,循环写(innodb_log_files_in_group,默认 2 个文件)
  • 刷盘策略:innodb_flush_log_at_trx_commit
    • 0:每秒刷盘(性能高,可能丢 1s 数据)
    • 1:每次提交刷盘(默认,安全)
    • 2:每次提交写 OS cache,每秒 fsync(折中)

2. undo log(回滚日志)

  • InnoDB 引擎层日志,逻辑日志(记录反向操作)
  • 作用:
    • 事务回滚,保证原子性(Atomicity)
    • MVCC,提供历史版本读取
  • 记录修改前的旧值
  • 事务提交后不立即删除,由 purge 线程清理(无活跃事务引用时)
  • 存储在 undo tablespace 或系统表空间

3. binlog(归档日志)

  • Server 层日志,逻辑日志(记录 SQL 语句或行变更)
  • 作用:主从复制数据恢复(PITR,Point-in-Time Recovery)
  • 三种格式:
    • STATEMENT:记录 SQL 语句(小,但有些函数如 NOW() 主从不一致)
    • ROW:记录行的变更(大,但准确,推荐)
    • MIXED:混合(默认,自动选择)

三者关系

日志层级作用写入方式
redo logInnoDB崩溃恢复循环写
undo logInnoDB回滚 + MVCC随机写,随事务清理
binlogServer主从复制 + 恢复追加写

两阶段提交(2PC,Two-Phase Commit)

为什么需要:redo log 和 binlog 是两个独立的日志,若只写一个后崩溃,主从数据不一致。

流程(以 UPDATE 为例):

1. 执行器调用 InnoDB 引擎接口更新数据
2. InnoDB 写 undo log(回滚用)
3. InnoDB 更新 Buffer Pool 中的数据页
4. InnoDB 写 redo log(prepare 状态)  ← 第一阶段
5. InnoDB 提交事务,返回给执行器
6. 执行器写 binlog
7. 执行器调用 InnoDB 提交事务
8. InnoDB 将 redo log 改为 commit 状态   ← 第二阶段

崩溃恢复规则

  • redo log 是 prepare 状态:
    • binlog 完整(有完整事务结束标记):提交事务(重放 redo log)
    • binlog 不完整:回滚事务(用 undo log 回滚)
  • redo log 是 commit 状态:事务已提交,重放 redo log

两阶段提交保证 redo log 和 binlog 的一致性

  • 若 redo log 写完但 binlog 未写崩溃:回滚,从库也不会有该事务
  • 若 binlog 写完但 redo log 未 commit 崩溃:通过 binlog 判断提交,重放 redo log

7.15 ★★★★MySQL 主从复制的原理是什么?异步复制、半同步复制、GTID 复制的区别是什么?

📖 点击查看答案

主从复制原理

三个线程

  1. Binlog Dump 线程(主库):将 binlog 发送给从库
  2. IO 线程(从库):接收 binlog,写入 relay log(中继日志)
  3. SQL 线程(从库):读取 relay log,重放 SQL,更新数据

流程

主库 → 写 binlog → Binlog Dump 线程 → 从库 IO 线程 → relay log → SQL 线程 → 从库数据

复制方式

1. 异步复制(默认)

  • 主库执行完事务写 binlog 后立即返回客户端,不等从库确认
  • 从库异步拉取 binlog
  • 优点:主库性能高
  • 缺点:主库崩溃可能丢数据,从库有延迟

2. 半同步复制(Semi-Sync)

  • 主库执行完事务后,等待至少一个从库确认收到 binlog(写入 relay log)才返回
  • 超时(rpl_semi_sync_master_timeout,默认 10s)后降级为异步
  • 优点:保证数据至少在一个从库
  • 缺点:性能略降(多一次网络往返)
  • MySQL 5.7+ 支持** Loss-Less** 半同步(在引擎提交前等待,避免幻读)
sql
-- 主库
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
-- 从库
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;

3. 全同步复制

  • 主库等待所有从库执行完毕才返回
  • 性能极差,少用

GTID 复制

GTID(Global Transaction Identifier)

  • 格式:server_uuid:transaction_id,如 3E11FA47-71CA-11E1-9E33-C80AA9429562:23
  • 每个事务有全局唯一 ID

传统复制 vs GTID 复制

特性传统复制GTID 复制
定位基于 binlog 文件名 + position基于 GTID
主从切换需手动计算 position,易错自动找未同步的 GTID
复制过滤复杂简单
错误恢复自动跳过已执行的 GTID

GTID 配置

ini
# 主库
gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON   # 从库也要记录 binlog(级联复制)

# 从库
change master to master_auto_position=1;

复制模式

  • 基于语句(STATEMENT):记录 SQL,主从可能不一致(函数如 NOW()
  • 基于行(ROW):记录行变更,准确但日志大
  • 混合(MIXED):默认,自动选择

多源复制(MySQL 5.7+):

  • 一个从库可从多个主库复制
  • 适用于数据汇聚场景

7.16 ★★★★主从延迟产生的原因是什么?有哪些解决方案?如何从业务层面规避?

📖 点击查看答案

主从延迟原因

  1. 从库单线程重放(MySQL 5.6 之前):

    • SQL 线程串行执行 relay log,主库并发写,从库追不上
    • MySQL 5.7+ 支持并行复制(基于 group commit)
  2. 网络延迟

    • 主从跨机房、跨地域,网络 RTT 大
  3. 从库性能差

    • 从库硬件配置低,或承担读压力大
  4. 大事务

    • 单个大事务(如批量 DELETE 100 万行)执行慢,从库需同样时间重放
  5. DDL 操作

    • DDL(如 ALTER TABLE)在从库执行期间阻塞其他事务
  6. 从库负载高

    • 从库承担报表查询、备份等,影响 SQL 线程执行
  7. binlog 传输延迟

    • 大量 binlog 传输慢

解决方案

1. 并行复制(MySQL 5.7+):

ini
slave_parallel_type = LOGICAL_CLOCK   # 基于组提交的逻辑时钟
slave_parallel_workers = 16            # 并行工作线程数
slave_preserve_commit_order = ON      # 保持提交顺序

2. 半同步复制:保证 binlog 至少到一个从库,但仍有延迟

3. 从库优化

  • 升级硬件(CPU、SSD)
  • 从库不承担重负载(如备份用单独的延迟从库)

4. 减少大事务

  • 大批量操作分批执行
  • 避免长事务

5. 读写分离策略

  • 强制读主:写后立即读的场景,从主库读
  • 延迟判断:延迟超过阈值时读主

业务层面规避

1. 写后立即读强制走主库

java
@Transactional
public void updateUser(Long id, String name) {
    userMapper.update(id, name);
    // 写后立即读,从主库读
    User user = userMapper.selectByPrimaryKey(id);  // 走主库数据源
}

2. 关键业务读主

  • 账户余额、订单状态等强一致性场景直接读主库

3. 延迟感知路由

java
// 检查从库延迟
long delay = replicationMonitor.getDelay(slave);
if (delay > 1000) {  // 延迟超过 1s
    return readFromMaster();
} else {
    return readFromSlave();
}

4. 会话保持

  • 用户写操作后,短时间内(如 3s)该用户读走主库
  • 通过 Session 或 Token 记录

5. 最终一致性业务

  • 如商品列表、评论等,从库延迟可接受

6. 避免 SELECT SLEEP 等

  • 业务避免依赖立即读到最新数据

7. 分布式事务

  • 跨服务写后,通过消息队列通知,而非依赖从库读

7.17 ★★★★慢 SQL 优化的一般思路是什么?如何定位、分析、优化一条慢查询?

📖 点击查看答案

定位慢 SQL

  1. 开启慢查询日志
sql
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;        -- 超过 1s 记录
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL log_queries_not_using_indexes = ON;  -- 记录未用索引的查询
  1. 使用 Performance Schema / sys 库
sql
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile;
  1. 使用工具
    • mysqldumpslow:汇总慢查询日志
    • pt-query-digest(Percona Toolkit):详细分析慢日志
    • APM 工具:Datadog、SkyWalking

分析慢 SQL

  1. EXPLAIN
sql
EXPLAIN SELECT * FROM users WHERE name = 'Tom';
  • 关注 typekeyrowsExtra
  • type=ALL:全表扫描,需加索引
  • Extra=Using filesort:排序未用索引
  • Extra=Using temporary:用了临时表
  1. SHOW PROFILE(详细耗时):
sql
SET profiling = 1;
SELECT ...;
SHOW PROFILE;
SHOW PROFILE FOR QUERY 1;
  1. SHOW STATUS
sql
SHOW STATUS LIKE 'Handler_read%';  -- 查看读取情况
  1. Trace 优化器
sql
SET optimizer_trace='enabled=on';
SELECT ...;
SELECT * FROM information_schema.OPTIMIZER_TRACE;

优化策略

1. 索引优化

  • 加合适索引:WHERE、JOIN ON、ORDER BY、GROUP BY 列
  • 联合索引按区分度排序,遵循最左前缀
  • 覆盖索引避免回表
  • 避免索引失效场景(函数、隐式转换、LIKE %开头)
sql
-- 优化前
SELECT * FROM users WHERE DATE(create_time) = '2024-01-01';
-- 优化后
SELECT * FROM users WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';

2. SQL 改写

sql
-- 避免 SELECT *
SELECT id, name, age FROM users WHERE ...;

-- 大分页优化
-- 优化前
SELECT * FROM users LIMIT 1000000, 10;
-- 优化后(游标分页)
SELECT * FROM users WHERE id > 1000000 LIMIT 10;
-- 或子查询
SELECT * FROM users WHERE id >= (SELECT id FROM users LIMIT 1000000, 1) LIMIT 10;

-- JOIN 优化:小表驱动大表
SELECT * FROM small_table s JOIN big_table b ON s.id = b.small_id;
-- 确保 b.small_id 有索引

-- OR 改 UNION
-- 优化前
SELECT * FROM users WHERE name = 'Tom' OR age = 18;
-- 优化后(age 和 name 分别有索引)
SELECT * FROM users WHERE name = 'Tom'
UNION
SELECT * FROM users WHERE age = 18;

3. 表结构优化

  • 合适的数据类型(INT 代替 VARCHAR)
  • 适度反范式化(冗余字段避免 JOIN)
  • 大表分区、分表

4. 数据库配置

  • innodb_buffer_pool_size:足够大(物理内存 60-70%)
  • innodb_io_capacity:SSD 可调大
  • 连接池配置

5. 架构优化

  • 读写分离
  • 缓存(Redis)
  • 分库分表
  • 异步化(消息队列)

优化流程总结

定位慢 SQL → EXPLAIN 分析 → 识别问题(全表扫描/回表/排序/临时表)
→ 加索引/改写 SQL/调结构 → 验证效果(EXPLAIN + 耗时)

7.18 ★★★★MySQL 分库分表方案有哪些?水平拆分和垂直拆分的区别?如何选择分片键?分库分表后如何处理跨库 JOIN 和分布式 ID?

📖 点击查看答案

分库分表类型

1. 垂直拆分

  • 垂直分库:按业务模块拆分到不同数据库
    • 如:用户库、订单库、商品库
    • 解决单库表多、压力大问题
  • 垂直分表:将一张大表按字段拆分为多张表
    • 如:user_base(id, name, age)+ user_detail(id, bio, avatar)
    • 将热字段和冷字段分离,减少单表字段数

2. 水平拆分

  • 水平分库:同一张表的数据分散到多个库
    • 如:db0.userdb1.userdb2.user
  • 水平分表:同一张表的数据分散到多个表
    • 如:user_0user_1user_2
  • 解决单表数据量过大问题
拆分方式解决问题特点
垂直分库单库压力大、业务耦合按业务拆分,库独立
垂直分表单表字段多、热冷字段混合拆字段,表数量不变
水平分库单库数据量大、并发高拆数据到多个库
水平分表单表数据量大拆数据到多个表

分片键选择

原则

  1. 高基数:值分布均匀,避免数据倾斜(如 user_id 而非 status)
  2. 查询频繁:常用查询条件作为分片键,避免跨库查询
  3. 不可变:值不频繁变更(如 user_id 而非 phone)
  4. 单调递增:可选,利于时间范围查询(如 create_time)

常见分片策略

  • 取模user_id % N,均匀分布,但扩容麻烦(需 rehash)
  • 范围id 0-1000万 在 db0,1000万-2000万 在 db1,利于范围查询,但易热点
  • 一致性哈希:扩容时迁移数据少
  • Hash + 范围结合:先 hash 分库,再范围分表

跨库 JOIN 解决

  1. 绑定表(Binding Table)

    • 将有关联关系的表用相同分片键和分片规则,如 useruser_detail 都按 user_id 分片
    • JOIN 时在同一库内完成
  2. 广播表(Broadcast Table)

    • 小表(如字典表、配置表)在每个库都有全量数据
    • JOIN 时本地查询,无需跨库
  3. 应用层 JOIN

    • 拆分为多次查询,应用层组装
    java
    List<Order> orders = orderMapper.selectByUserId(userId);
    List<Long> productIds = orders.stream().map(Order::getProductId).collect(toList());
    List<Product> products = productMapper.selectByIds(productIds);
    // 应用层组装
  4. 数据冗余

    • 订单表中冗余商品名称等字段,避免 JOIN
  5. Elasticsearch

    • 宽表存入 ES,支持复杂查询和 JOIN

分布式 ID 方案

  1. UUID

    • 简单,但无序,B+ 树插入性能差
  2. 数据库自增 + 步长

    • 每个库设置不同起始值和步长
    • db0: 1,4,7,10... db1: 2,5,8,11... db2: 3,6,9,12...
    • 扩容麻烦
  3. 号段模式(如美团 Leaf):

    • 一次从数据库获取一段 ID(如 1000 个),用完再取
    • 减少数据库访问
  4. Snowflake(雪花算法)

    • 64 位:1 符号位 + 41 时间戳 + 10 机器 ID + 12 序列号
    • 趋势递增,性能高
    • 需处理时钟回拨问题
  5. Redis INCR

    • INCR id_seq,原子自增
    • 依赖 Redis 可用性
  6. Zookeeper / etcd

    • 通过 ZNode 版本号生成 ID
    • 性能较低

ShardingSphere 示例

yaml
spring:
  shardingsphere:
    sharding:
      tables:
        t_order:
          actual-data-nodes: ds${0..1}.t_order${0..1}
          database-strategy:
            inline:
              sharding-column: user_id
              algorithm-expression: ds${user_id % 2}
          table-strategy:
            inline:
              sharding-column: order_id
              algorithm-expression: t_order${order_id % 2}
          key-generator:
            column: order_id
            type: SNOWFLAKE

分库分表中间件

  • ShardingSphere(Sharding-JDBC / Sharding-Proxy):Apache 顶级项目
  • MyCat:早期开源,基于代理
  • TDDL(阿里):内部使用
  • Vitess:YouTube 开源,CNCF

基于 4 份主流面试题库整合精选,共 200 题