# 数据库深度进阶

用生活化的比喻,让你深入理解 MySQL 索引原理、MVCC、锁机制和分库分表

前置知识:第01章 JVM 深度原理 + 第02章 并发编程(锁和并发基础)


阅读指南(初学者必看)

为什么你需要学习数据库深度进阶?

数据库是游戏后端最容易出现性能瓶颈的环节。不理解底层原理,就无法:

  • 设计高效的索引策略,避免慢查询
  • 理解 MVCC 和锁机制,排查死锁和数据不一致
  • 做分库分表设计,支撑百万级玩家数据

学完本章,你能回答:

  • B+ 树为什么适合数据库索引?3 层能存多少行?
  • MVCC 是怎么实现的?RC 和 RR 隔离级别有什么区别?
  • 行锁、间隙锁、Next-Key Lock 分别锁什么?
  • 分库分表怎么分?分片键怎么选?分布式 ID 怎么生成?

本文结构

第一部分:MySQL 索引原理(B+ 树 + 索引优化)
第二部分:MVCC 与锁(多版本并发控制 + 锁机制)
第三部分:分库分表(拆分策略 + 分布式 ID)

一、MySQL 索引原理

B+ 树为什么适合数据库?

生活类比:B+ 树就像图书馆的索引卡柜——先查分类(根节点),再查子分类(中间节点),最后找到具体书的位置(叶子节点)。而且同一层的索引卡用链表串起来,范围查找超快。

B+ 树结构(3 层可以存 2000 万行):
                [根节点]
              /    |    \
        [中间节点] [中间节点] [中间节点]
        / | \     / | \     / | \
     [叶子] ←→ [叶子] ←→ [叶子] ←→  链表连接,范围查询高效

为什么 B+ 树比 B 树更适合数据库?

B 树 B+ 树
数据位置 所有节点都存数据 只有叶子节点存数据
叶子节点 无链表 双向链表连接
查询稳定性 不稳定(可能在中间节点就找到) 稳定(必须到叶子节点)
范围查询 需要中序遍历 直接沿链表扫描

索引优化要点

  • 最左前缀原则:联合索引 (a,b,c),查询条件必须从 a 开始
  • 覆盖索引:查询的字段都在索引中,不需要回表
  • 索引下推(ICP):在存储引擎层就过滤,减少回表次数
  • 避免索引失效:LIKE '%xx'、函数操作、隐式类型转换

生活类比:联合索引就像通讯录——先按姓氏排序,同姓再按名字排序。你不能跳过姓氏直接按名字查。

-- 联合索引 (a, b, c)
-- ✅ 能用索引
SELECT * FROM t WHERE a = 1;
SELECT * FROM t WHERE a = 1 AND b = 2;
SELECT * FROM t WHERE a = 1 AND b = 2 AND c = 3;

-- ❌ 不能用索引(跳过了 a)
SELECT * FROM t WHERE b = 2;
SELECT * FROM t WHERE b = 2 AND c = 3;

-- ❌ 索引失效的场景
SELECT * FROM t WHERE a LIKE '%abc';     -- 左模糊
SELECT * FROM t WHERE YEAR(create_time) = 2026;  -- 函数
SELECT * FROM t WHERE a = 1 ORDER BY b DESC, c ASC;  -- 排序方向不一致

索引类型对比

索引类型 存储方式 适用场景 游戏中的使用
主键索引 聚簇索引(数据和索引在一起) 每表一个 玩家ID
唯一索引 非聚簇 唯一约束 玩家昵称
普通索引 非聚簇 加速查询 登录时间
联合索引 非聚簇 多条件查询 (服务器ID, 等级)
全文索引 倒排索引 文本搜索 物品名称搜索

二、MVCC 与锁

MVCC(多版本并发控制)── 读不阻塞写,写不阻塞读

每行数据有隐藏列:
- DB_TRX_ID:最近修改的事务 ID
- DB_ROLL_PTR:回滚指针(指向 undo log)
- DB_ROW_ID:隐藏自增 ID

Read View(读视图)── 决定当前事务能看到哪个版本
- RC 隔离级别:每次 SELECT 创建新的 Read View
- RR 隔离级别:只在第一次 SELECT 创建 Read View

生活类比:MVCC 就像 Wikipedia 的版本历史——每个人看到的都是自己"快照"时刻的版本,编辑者创建新版本不影响阅读者看旧版本。

MVCC 可见性判断

判断规则(当前事务 ID = trx_id,Read View 中的最小事务 ID = min_id,最大事务 ID = max_id):

1. DB_TRX_ID < min_id → 可见(事务在 Read View 创建前已提交)
2. DB_TRX_ID >= max_id → 不可见(事务在 Read View 创建后才开始)
3. min_id <= DB_TRX_ID < max_id → 看是否在活跃列表中
   - 在活跃列表中 → 不可见(事务未提交)
   - 不在活跃列表中 → 可见(事务已提交)
4. 不可见时,沿 DB_ROLL_PTR 找上一个版本,重复判断

RC vs RR 隔离级别

RC(Read Committed) RR(Repeatable Read)
Read View 创建时机 每次 SELECT 只在第一次 SELECT
幻读 可能出现 大部分情况避免(Next-Key Lock)
一致性 语句级一致性 事务级一致性
性能 略好 略差

锁机制

  • 行锁:锁定索引记录
  • 间隙锁(Gap Lock):锁定索引记录之间的间隙,防止幻读
  • Next-Key Lock = 行锁 + 间隙锁

生活类比

  • 行锁:锁定了某个人(精确锁定)
  • 间隙锁:锁定了两个人之间的空位(防止插队)
  • Next-Key Lock:既锁人又锁空位(最严格)
假设索引值为 5, 10, 15, 20:

行锁:锁定 10 这条记录
间隙锁:锁定 (5, 10) 之间的间隙
Next-Key Lock:锁定 (5, 10] —— 左开右闭

游戏服务器中的死锁场景

-- 事务1:先锁玩家A,再锁玩家B
UPDATE player SET gold = gold - 100 WHERE id = 'A';
UPDATE player SET gold = gold + 100 WHERE id = 'B';

-- 事务2:先锁玩家B,再锁玩家A
UPDATE player SET gold = gold - 50 WHERE id = 'B';
UPDATE player SET gold = gold + 50 WHERE id = 'A';

-- 💀 死锁!两个事务互相等待对方释放锁

-- 解决方案:按固定顺序加锁(如按玩家ID排序)

三、分库分表

垂直拆分:按业务拆(用户库、订单库、游戏库)
水平拆分:按数据量拆(order_0, order_1, ..., order_99)

分片键选择:
- 游戏场景:玩家 ID(查询最多按玩家查)
- 订单场景:订单 ID + 时间维度

分布式 ID:
- 雪花算法:64bit = 1bit符号 + 41bit时间 + 10bit机器 + 12bit序列
  → 每台机器每毫秒可生成 4096 个 ID

分库分表策略

生活类比:垂直拆分像把大公司拆成独立子公司(各管各的业务),水平拆分像把一个部门的员工分成多组(每组干一样的活)。

拆分方式 原则 适用场景 游戏中的例子
垂直分库 按业务拆 业务边界清晰 玩家库、道具库、排行榜库
垂直分表 按字段拆 字段访问频率差异大 玩家基础信息 / 玩家扩展属性
水平分库 按数据量拆 单库数据量过大 玩家库按ID取模分4个库
水平分表 按数据量拆 单表数据量过大 背包表按玩家ID哈希分表

分布式 ID:雪花算法

雪花算法 ID 结构(64 bit):
┌─┬──────────────┬───────────┬──────────┐
│0│  41bit 时间戳  │ 10bit 机器 │ 12bit 序列 │
└─┴──────────────┴───────────┴──────────┘

- 1bit 符号位:始终为 0
- 41bit 时间戳:可用约 69 年
- 10bit 机器ID:最多 1024 台机器
- 12bit 序列号:每毫秒最多 4096 个 ID

优势:趋势递增、不依赖数据库、高性能
注意:时钟回拨问题(机器时间被调回)

分库分表后的查询问题

问题1:跨库 JOIN → 不再支持,需要应用层组装
问题2:跨库分页 → 需要每个库查出来再合并排序
问题3:跨库事务 → 需要分布式事务(Seata/TCC)
问题4:聚合统计 → 需要每个库统计再汇总

游戏场景的简化:
- 大部分查询按玩家ID,天然分片键
- 排行榜用 Redis,不走数据库
- 跨玩家操作(交易)用分布式事务

自问自答

Q:为什么游戏场景大多选玩家ID做分片键? A:因为 90% 以上的查询都是按玩家维度(查自己的背包、装备、好友等)。用玩家ID做分片键,同一个玩家的数据落在同一个分库,避免跨库查询。

Q:覆盖索引是什么?为什么能提升性能? A:覆盖索引是指查询的所有字段都在索引中,不需要回表(不需要去聚簇索引查完整数据)。因为索引通常比数据小得多,从索引就能拿到所有需要的字段,大大减少 IO。

Q:MVCC 能完全避免幻读吗? A:在 MySQL 的 RR 隔离级别下,MVCC + Next-Key Lock 能避免大部分幻读。但有一种特殊情况:事务先执行普通 SELECT(快照读),再执行 UPDATE(当前读),可能看到其他事务新插入的行,这是"当前读"打破了快照的一致性。

Q:分库分表后数据迁移怎么做? A:一般用"双写"策略:1)新数据同时写新旧库;2)迁移历史数据;3)校验新旧库数据一致性;4)切换读流量到新库;5)停止写旧库。


实践任务

  • 任务1:用 EXPLAIN 分析一个慢查询,判断索引是否生效
  • 任务2:设计一个联合索引,用最左前缀原则覆盖所有查询场景
  • 任务3:制造一个死锁场景(两个事务交叉更新),用 SHOW ENGINE INNODB STATUS 分析
  • 任务4:实现雪花算法生成分布式 ID,测试每秒生成量
  • 任务5:设计游戏背包表的分库分表方案,画出数据分布图

与其他章节的关联

本章内容 关联章节 关联点
MVCC 与锁 第02章 并发编程 数据库锁和 Java 锁思想相通
分库分表 第13章 游戏数据存储 排行榜、背包需要分库分表策略
索引优化 第11章 游戏服务器架构 数据库是游戏后端最常见瓶颈
分布式 ID 第10章 高并发 雪花算法保证 ID 全局唯一
分布式事务 第10章 高并发 跨库事务需要分布式事务方案

上一章:05-SpringBoot自动配置深度 | 下一章:07-Java字节码与动态编程