数据库索引与事务:原理与实战

5 阅读11分钟

以 MySQL InnoDB 为主线讲解,PostgreSQL 差异处会标注。

一、为什么需要索引?

无索引时,查找一条记录需要全表扫描(O(N))。索引相当于书的目录,把查找复杂度降到 O(log N)。

代价

  • 占用磁盘空间(一个大表的索引可能数 GB)
  • 写入变慢(每个索引都要维护)
  • 维护成本(统计信息更新、碎片整理)

经验值:单表索引数量控制在 5 个以内,超过往往是表设计有问题。


二、索引数据结构选型

结构查找范围查询适用
哈希表O(1)❌ 不支持Memory 引擎、Redis
二叉搜索树O(log N)内存场景
B 树O(log N)早期文件系统
B+ 树O(log N)✅ 强MySQL InnoDB
LSM 树写极快RocksDB、HBase、TiDB

为什么 MySQL 选 B+ 树?

  1. 磁盘 I/O 优化:B+ 树矮胖(一般 3-4 层就能存 2 千万行),每次查询只需 3-4 次磁盘 I/O
  2. 范围查询优秀:叶子节点用双向链表串起,区间扫描只需找到起点后顺序遍历
  3. 数据全在叶子节点:非叶子节点只存索引,单页能容纳更多 key,进一步降低树高
B+ 树示意(每个节点对应一个 16KB 数据页):

              [50, 100]                   ← 根节点
             /    |    \
       [10,30] [60,80] [120,150]          ← 中间节点
        / | \    / | \    / | \
     [叶][叶][叶]...                      ← 叶子节点(双向链表,存实际数据)

三、聚簇索引 vs 二级索引

InnoDB 的核心特性:表数据本身就是按主键聚簇存储的

3.1 聚簇索引(Clustered Index)

  • 主键索引的叶子节点直接存放整行数据
  • 一张表只能有一个聚簇索引(主键)
  • 没显式定义主键时,InnoDB 用第一个 NOT NULL 唯一索引;都没有就生成隐藏的 ROW_ID

3.2 二级索引(Secondary Index / 辅助索引)

  • 叶子节点存的是主键值,不是行数据
  • 通过二级索引查询完整数据需要回表:先查二级索引拿主键 → 再查聚簇索引拿行
-- 假设 idx_name(name)
SELECT * FROM users WHERE name = 'Alice';
-- 步骤:
-- 1. 在 idx_name 中找到 name='Alice' → 得到 pk=42
-- 2. 在聚簇索引中找到 pk=42 → 拿到完整行

3.3 覆盖索引(Covering Index):避免回表

-- 二级索引 idx_name_age(name, age)
SELECT name, age FROM users WHERE name = 'Alice';
-- ✅ 索引中已经有 name 和 age,无需回表
-- EXPLAIN 显示 Extra: Using index

优化技巧:把高频查询字段塞进二级索引,让它"覆盖"查询。

3.4 主键设计的金科玉律

  • 必须递增(自增 ID 或雪花 ID 排序):避免页分裂
  • 必须短:每个二级索引都会存主键,短主键节省空间
  • 避免 UUID v4:随机分布导致页分裂频繁,主键 36 字节 vs BIGINT 8 字节

四、最左前缀原则

联合索引 idx(a, b, c) 等同于建立了:(a)(a,b)(a,b,c) 三个索引。

查询条件是否用索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND c = 3✅ a 用索引,c 不用
WHERE b = 2
WHERE a = 1 AND b > 2 AND c = 3✅ a, b 用索引,c 不用(范围后失效)
WHERE a = 1 ORDER BY b✅ 排序也走索引

核心规则

  1. 必须从最左字段开始匹配
  2. 范围查询会让后面的字段失效><BETWEENLIKE 'xx%'
  3. =IN 不影响后续字段使用索引(优化器会自动调整顺序)

联合索引顺序设计

-- ❌ 错误:区分度低的在前
KEY idx(gender, age, name)

-- ✅ 正确:区分度高、等值查询的在前;范围查询放最后
KEY idx(name, age, gender)

五、索引失效的常见场景

-- 1. 函数 / 表达式(PostgreSQL 可建表达式索引)
WHERE YEAR(created_at) = 2026          -- ❌
WHERE created_at >= '2026-01-01'       -- ✅

-- 2. 隐式类型转换
WHERE phone = 13800138000               -- ❌ phone 是 varchar
WHERE phone = '13800138000'             -- ✅

-- 3. LIKE 左模糊
WHERE name LIKE '%lice'                 -- ❌
WHERE name LIKE 'Ali%'                  -- ✅

-- 4. OR 条件中有非索引列
WHERE name = 'Alice' OR age = 20        -- ❌(age 无索引时)
-- 改用 UNION

-- 5. NOT IN / != 通常失效
WHERE status != 1                        -- ❌

-- 6. 优化器认为全表扫描更快(小表、低区分度)

六、EXPLAIN 实战

EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 1;
字段含义关注点
type访问类型重点const > ref > range > index > ALL,要避免 ALL
key实际用的索引NULL 说明没用索引
rows预估扫描行数越小越好
Extra额外信息Using index(覆盖索引)✅;Using filesortUsing temporary

type 简记

  • const:主键 / 唯一索引等值
  • ref:普通索引等值
  • range:范围扫描
  • index:扫描整个索引树
  • ALL:全表扫描

七、事务与 ACID

特性含义InnoDB 实现
A Atomicity 原子性全部成功或全部失败undo log(用于回滚)
C Consistency 一致性事务前后数据合法由 A、I、D 共同保障
I Isolation 隔离性并发事务互不干扰锁 + MVCC
D Durability 持久性提交后永久生效redo log(崩溃恢复)

7.1 redo log vs undo log vs binlog

日志层次作用
redo logInnoDB 引擎崩溃恢复,物理日志(页修改)
undo logInnoDB 引擎事务回滚 + MVCC 历史版本
binlogServer 层主从复制、数据恢复,逻辑日志

提交流程(两阶段提交保证 redo 和 binlog 一致):

1. 写 redo log(prepare 状态)
2. 写 binlog
3. 写 redo log(commit 状态)

八、隔离级别与并发问题

8.1 四种隔离级别

隔离级别脏读不可重复读幻读
Read Uncommitted
Read Committed (PG 默认)
Repeatable Read (MySQL 默认)✓(InnoDB 用间隙锁解决)
Serializable

✓ 表示防止该问题,✗ 表示存在该问题

8.2 三种异常的实例

-- 脏读:T1 读到 T2 未提交的修改
T2: UPDATE account SET balance = 200 WHERE id = 1;  -- 未提交
T1: SELECT balance FROM account WHERE id = 1;       -- 读到 200
T2: ROLLBACK;                                        -- T1 看到的是脏数据

-- 不可重复读:同一事务内两次读同一行结果不同(聚焦更新/删除)
T1: SELECT balance FROM account WHERE id = 1;       -- 100
T2: UPDATE ... ; COMMIT;
T1: SELECT balance FROM account WHERE id = 1;       -- 200 ❌

-- 幻读:同一事务内两次范围查询行数不同(聚焦插入)
T1: SELECT * FROM orders WHERE amount > 100;        -- 5 行
T2: INSERT INTO orders VALUES (..., 200); COMMIT;
T1: SELECT * FROM orders WHERE amount > 100;        -- 6 行 ❌

九、MVCC 原理

Multi-Version Concurrency Control:通过保存数据的多个版本,让读写不互相阻塞。

9.1 隐藏字段

InnoDB 给每行加 3 个隐藏字段:

  • DB_TRX_ID:最近修改的事务 ID
  • DB_ROLL_PTR:回滚指针,指向 undo log 中的上一版本
  • DB_ROW_ID:隐藏自增 ID(无主键时使用)

9.2 版本链

当前行: id=1, balance=300, trx_id=103
   ↓ roll_ptr
undo: id=1, balance=200, trx_id=102
   ↓ roll_ptr
undo: id=1, balance=100, trx_id=101

9.3 ReadView:决定能看到哪个版本

事务发起 SELECT 时,生成 ReadView,包含:

  • creator_trx_id:当前事务 ID
  • m_ids:当前活跃(未提交)事务列表
  • min_trx_id / max_trx_id:活跃事务的最小/最大 ID

判断行版本可见性:

  • trx_id < min_trx_id:版本来自已提交事务,可见
  • trx_id >= max_trx_id:版本来自更晚的事务,不可见
  • m_ids 中:未提交,不可见
  • 否则:已提交,可见

9.4 RC vs RR 的核心差异

  • Read Committed每次 SELECT 生成新 ReadView → 看到最新已提交数据
  • Repeatable Read事务首次 SELECT 时生成 ReadView,后续复用 → 整个事务看到一致快照

9.5 快照读 vs 当前读

-- 快照读(普通 SELECT):走 MVCC
SELECT * FROM users WHERE id = 1;

-- 当前读(加锁的 SELECT、所有写操作):读最新版本 + 加锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
UPDATE users SET ...;
DELETE FROM users WHERE ...;

陷阱:RR 隔离级别下,快照读不会幻读,但当前读会幻读。InnoDB 用间隙锁解决当前读的幻读。


十、锁机制

10.1 锁的粒度

粒度性能引擎
表锁整张表高并发差MyISAM、特殊场景
行锁单行高并发好InnoDB
页锁一页BDB(已淘汰)

10.2 行锁的三种实现

-- 假设有索引 idx_age,表中数据 age = [10, 20, 30]

Record Lock(记录锁):锁定单条记录

SELECT * FROM users WHERE age = 20 FOR UPDATE;  -- 锁住 age=20 的行

Gap Lock(间隙锁):锁定记录之间的空隙

SELECT * FROM users WHERE age = 25 FOR UPDATE;
-- 锁住 (20, 30) 这个开区间,防止插入 25

Next-Key Lock(临键锁)= Record + Gap:左开右闭区间

-- RR 隔离级别下默认
SELECT * FROM users WHERE age > 15 AND age < 25 FOR UPDATE;
-- 锁住 (10, 20], (20, 30) 等区间

10.3 行锁失效 → 退化为表锁

当查询条件没用索引时,行锁失效退化为表锁(其实是锁住所有行):

-- name 没有索引
UPDATE users SET ... WHERE name = 'Alice';  -- 锁全表

工程教训:写操作的 WHERE 条件必须命中索引

10.4 死锁

-- 经典交叉死锁
T1: UPDATE account SET balance = 100 WHERE id = 1;  -- 持有 id=1 锁
T2: UPDATE account SET balance = 200 WHERE id = 2;  -- 持有 id=2 锁
T1: UPDATE account SET balance = 100 WHERE id = 2;  -- 等待 id=2
T2: UPDATE account SET balance = 200 WHERE id = 1;  -- 等待 id=1,死锁

InnoDB 自动检测死锁并回滚代价小的事务(Deadlock found 错误)。

避免死锁的工程实践

  1. 多行操作时,按相同顺序加锁(如统一按 id 升序)
  2. 大事务拆小,减少持锁时间
  3. 设置合理的锁超时:innodb_lock_wait_timeout(默认 50s)
  4. 排查工具:SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK

十一、实战案例

11.1 慢查询排查 SOP

-- 1. 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;  -- 超过 1 秒记录

-- 2. 用 mysqldumpslow 或 pt-query-digest 分析

-- 3. EXPLAIN 看执行计划
EXPLAIN SELECT ...;

-- 4. 看是否走索引、是否回表过多

11.2 经典误区:分页深度查询

-- ❌ 慢:偏移越大越慢,需要扫描 100020 行
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;

-- ✅ 快:用主键游标
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;

-- ✅ 子查询优化(无法记录上次 id 时)
SELECT * FROM orders
WHERE id >= (SELECT id FROM orders ORDER BY id LIMIT 100000, 1)
LIMIT 20;

11.3 唯一索引并发插入

-- 防重复下单:靠 UNIQUE 约束 + 捕获 DuplicateKeyException
CREATE UNIQUE INDEX uk_order_no ON orders(order_no);

INSERT INTO orders(order_no, ...) VALUES('20260430001', ...);
-- 第二次插入抛 1062 错误,业务捕获后视为幂等成功

比"先查后插"靠谱:先查后插在并发下仍会重复。

11.4 大事务的危害

-- ❌ 危险:长事务持锁数小时
START TRANSACTION;
UPDATE big_table SET status = 1;  -- 锁数百万行
-- 业务处理 30 分钟...
COMMIT;

后果

  • 阻塞其他事务
  • undo log 无法清理 → 表空间膨胀
  • MVCC 历史版本积累 → 查询变慢
  • 主从复制延迟

原则:事务能短则短,IO 操作(HTTP 调用、消息发送)放事务外。

11.5 索引下推(ICP,5.6+)

-- 联合索引 idx(name, age)
SELECT * FROM users WHERE name LIKE 'A%' AND age = 20;

-- 旧版:拿到 name 匹配的所有行 → 回表 → Server 层过滤 age
-- ICP:在存储引擎层就过滤掉 age != 20 的,减少回表次数
-- EXPLAIN 显示 Extra: Using index condition

十二、PostgreSQL 主要差异点

特性MySQL InnoDBPostgreSQL
默认隔离级别RRRC
表组织聚簇(按主键)堆表(HOT 更新优化)
MVCCundo log + 版本链行内多版本(VACUUM 清理)
索引类型B+ 树为主B-tree、Hash、GIN、GiST、BRIN 等
表达式索引8.0+ 支持原生强大
部分索引WHERE status = 'active'

十三、避坑清单

  1. 主键用自增 BIGINT 或雪花 ID,不用 UUID v4
  2. 每张表必有主键,否则隐式 ROW_ID 不利于复制和性能
  3. 索引数量 ≤ 5,写多的表更少
  4. 联合索引顺序:等值 → 范围
  5. 避免 SELECT *:能走覆盖索引就走
  6. WHERE 条件不要用函数包字段
  7. 大事务拆小,事务内不做 IO
  8. 写操作 WHERE 必走索引,否则锁全表
  9. 死锁场景统一加锁顺序
  10. 慢查询日志要开,定期 review
  11. DDL 操作(加字段、改索引)走 pt-online-schema-change,避免锁表
  12. InnoDB 统计信息可能不准,必要时 ANALYZE TABLE 刷新

十四、面试高频题速记

  • Q:为什么 B+ 树而非 B 树? A:B+ 树更矮(数据全在叶子,非叶子能放更多 key)、范围查询更快(叶子链表)
  • Q:聚簇索引和非聚簇索引区别? A:聚簇叶子存行数据,非聚簇叶子存主键
  • Q:什么是回表?怎么避免? A:二级索引查到主键后再查聚簇索引;用覆盖索引避免
  • Q:联合索引最左前缀什么意思? A:必须从最左字段开始匹配,范围查询后字段失效
  • Q:InnoDB 怎么解决幻读? A:快照读靠 MVCC,当前读靠 Next-Key Lock(间隙锁)
  • Q:MVCC 实现原理? A:隐藏字段 + undo log 版本链 + ReadView 可见性判断
  • Q:RC 和 RR 在 MVCC 上的差异? A:RC 每次 SELECT 生成 ReadView,RR 事务首次生成后复用
  • Q:什么时候表锁? A:写操作未走索引,或 ALTER TABLE 等 DDL
  • Q:redo log 和 binlog 区别? A:redo 是引擎层物理日志(崩溃恢复),binlog 是 Server 层逻辑日志(复制)
  • Q:为什么用两阶段提交? A:保证 redo log 和 binlog 的一致性,避免主从数据不一致