# 数据库深度进阶
用生活化的比喻,让你深入理解 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字节码与动态编程