全栈手账 订阅 RSS

数据库进阶

数据库性能与架构的进阶手册:从 B+ 树索引原理、回表与覆盖索引,到执行计划分析与慢查询优化,再到事务隔离级别、MVCC、锁与死锁、主从复制读写分离、分库分表与分布式 ID,并附大数据量下的运维实践。

读了 25 分钟 更新于 11 月 3 日

一、索引原理

1.1 为什么用 B+ 树

结构 问题
二叉搜索树 数据量大时树高过高,磁盘 IO 次数多
红黑树 仍是二叉树,树高依然大
哈希索引 只支持等值查询,不支持范围与排序
B+ 树 多叉、矮胖、叶子节点有序且相连——等值、范围、排序都支持

关键特性:

  • 非叶子节点只存索引键,不存数据 → 单节点能放更多键 → 树更矮(通常 3–4 层就能支撑千万级数据)
  • 叶子节点存数据(或数据指针)并用链表相连 → 范围查询只需顺着链表扫
  • 一次查询通常只需 2–4 次磁盘 IO

1.2 聚簇索引与二级索引

sql
CREATE TABLE users (  id BIGINT PRIMARY KEY,       -- 聚簇索引  name VARCHAR(50),  age INT,  INDEX idx_name (name)        -- 二级索引);
类型 叶子节点存什么 说明
聚簇索引(主键) 整行数据 表数据本身就按主键组织
二级索引(普通索引) 主键值 查到主键后还要回表

推论:

  1. 主键不应太大——二级索引都要存主键,主键过长会让所有索引变大。
  2. 主键最好单调递增——避免随机插入导致的页分裂(UUID 作为主键就是典型问题)。

1.3 回表与覆盖索引

sql
-- 有索引 idx_name(name)SELECT id, name FROM users WHERE name = 'Alice';--   ✅ 索引里已有 name 和 id → 不需要回表(覆盖索引)SELECT id, name, age FROM users WHERE name = 'Alice';--   ❌ 索引里没有 age → 需要回表查整行
sql
-- 优化:把常用查询字段加到索引里,避免回表ALTER TABLE users ADD INDEX idx_name_age (name, age);SELECT id, name, age FROM users WHERE name = 'Alice';   -- 现在可覆盖

覆盖索引是最有效的优化手段之一:EXPLAIN 中出现 Using index 即表示命中覆盖索引。

1.4 联合索引与最左前缀

sql
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, created_at);

能命中索引的查询:

sql
WHERE user_id = 1                                    -- ✅ 用到第 1 列WHERE user_id = 1 AND status = 2                     -- ✅ 用到前 2 列WHERE user_id = 1 AND status = 2 AND created_at > '2026-01-01'   -- ✅ 全用WHERE user_id = 1 AND created_at > '2026-01-01'      -- ⚠️ 只用到第 1 列(跳过了 status)

不能命中:

sql
WHERE status = 2                                     -- ❌ 跳过最左列WHERE created_at > '2026-01-01'                      -- ❌ 跳过前两列

索引列顺序原则:

场景 建议顺序
有等值 + 范围 等值列在前,范围列在后
有排序需求 排序字段放在索引末尾,顺序与 ORDER BY 一致
区分度 一般不按区分度排序,按查询模式排序更有效

1.5 索引失效的常见写法

写法 问题 改法
WHERE DATE(created_at) = '2026-01-01' 列上做函数运算 WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02'
WHERE name LIKE '%abc' 前置通配符 用后缀匹配或全文检索
WHERE phone = 13800000000(列为 varchar) 隐式类型转换 加引号:= '13800000000'
WHERE status != 1 不等值常走全表 改为 IN (0,2,3) 列举
WHERE a = 1 OR b = 2 OR 两侧不同列 拆成两个查询 UNION,或建联合索引覆盖两列
ORDER BY 与索引顺序不符 产生 filesort 索引列顺序对齐排序需求

二、执行计划与慢查询

2.1 EXPLAIN 关键列

sql
EXPLAIN SELECT id, name FROM users WHERE name = 'Alice' ORDER BY created_at DESC LIMIT 20;
列 关注点
type 访问类型,优先级:const > eq_ref > ref > range > index > ALL(全表,最差)
key 实际使用的索引;为 NULL 说明没走索引
key_len 使用的索引长度;可判断联合索引用到了几列
rows 预估扫描行数,越小越好
filtered 扫描后剩余百分比
Extra Using index(覆盖索引,好)、Using filesort(需要额外排序)、Using temporary(用了临时表)

2.2 慢查询定位流程

文本
1. 开慢查询日志 → 2. 汇总找出 TOP 慢 SQL → 3. EXPLAIN 分析     → 4. 加/改索引 或 改写 SQL → 5. 复测验证
ini
# my.cnfslow_query_log = 1long_query_time = 1log_queries_not_using_indexes = 0     # 数据量大时开启会产生大量日志
bash
# 按总耗时排序(t=总时间, c=次数, l=锁时间, r=返回行数)mysqldumpslow -s t -t 20 /var/log/mysql/slow.log

2.3 常见慢查询类型与对策

类型 特征 对策
全表扫描 type=ALL,rows 很大 补索引或改写条件
深分页 LIMIT 100000, 20 游标分页(WHERE id < lastId)
大表 JOIN 多表关联无索引 检查关联列索引;必要时反范式冗余
排序慢 Using filesort 索引顺序对齐 ORDER BY;或减少排序字段
临时表 Using temporary 常见于 GROUP BY 无索引;补索引
函数/隐式转换 key=NULL 改写为不破坏索引的形式

2.4 深分页优化实例

sql
-- ❌ 需要扫描并丢弃前 100000 行SELECT * FROM orders ORDER BY id DESC LIMIT 100000, 20;-- ✅ 游标分页:始终走主键索引SELECT * FROM orders WHERE id < 100020 ORDER BY id DESC LIMIT 20;-- ✅ 延迟关联:先只查主键,再回表取整行SELECT o.* FROM orders oJOIN (SELECT id FROM orders ORDER BY id DESC LIMIT 100000, 20) t ON o.id = t.id;

三、事务与并发

3.1 四种隔离级别

级别 脏读 不可重复读 幻读 MySQL 默认
READ UNCOMMITTED ✅ 可能 ✅ 可能 ✅ 可能 —
READ COMMITTED ❌ ✅ 可能 ✅ 可能 —
REPEATABLE READ ❌ ❌ ⚠️ 部分(间隙锁缓解) ✅ 默认
SERIALIZABLE ❌ ❌ ❌ —

三种异常的含义:

异常 现象
脏读 读到了别的事务未提交的数据
不可重复读 同一事务内两次读同一行,结果不同(别人改并提交了)
幻读 同一事务内两次范围查询,行数不同(别人插入并提交了)

3.2 MVCC:为什么 RR 级别读不加锁

MVCC(多版本并发控制)通过版本链 + ReadView 实现「读不加锁、读写不阻塞」:

  • 每行有隐藏的 trx_id(最后修改它的事务 ID)
  • 读操作根据 ReadView 判断该版本是否可见
  • 快照读(普通 SELECT)读历史版本;当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)读最新版本

实践含义:

操作 读哪个版本 是否加锁
普通 SELECT 快照 ❌
SELECT ... FOR UPDATE 最新 ✅ 排他锁
SELECT ... LOCK IN SHARE MODE 最新 ✅ 共享锁
UPDATE / DELETE 最新 ✅ 排他锁

3.3 锁的类型

锁 粒度 场景
表锁 整表 无索引更新会升级为表锁
行锁 单行 基于索引实现
间隙锁(Gap Lock) 索引区间 RR 级别下防幻读
临键锁(Next-Key) 行 + 间隙 RR 默认加锁方式

重要:行锁是基于索引的。更新条件没有索引时,会退化为表锁——这是线上最容易造成大面积阻塞的原因。

sql
-- ❌ status 无索引 → 全表扫描并锁住所有扫描行(甚至升级为表锁)UPDATE orders SET status = 'cancelled' WHERE status = 'pending';-- ✅ 有索引 → 只锁匹配的行ALTER TABLE orders ADD INDEX idx_status (status);

3.4 死锁

成因:两个事务以相反顺序加锁。

文本
事务 A:锁行 1 → 请求行 2事务 B:锁行 2 → 请求行 1        ↓      死锁

排查:

sql
SHOW ENGINE INNODB STATUS;      -- 查看 LATEST DETECTED DEADLOCK 段

预防:

措施 说明
统一加锁顺序 批量操作按 ID 升序处理
缩短事务 事务内不做远程调用、不发消息
减少范围更新 尽量用主键精确定位
降低隔离级别(评估后) RC 无间隙锁,死锁概率更低(但有其他影响)
使用乐观锁 高并发更新用版本号代替加锁
sql
-- ✅ 乐观锁:避免长事务与锁竞争UPDATE stock SET count = count - 1, version = version + 1WHERE id = ? AND version = ? AND count >= 1;-- 影响 0 行 → 版本冲突,由业务重试

3.5 事务使用原则

原则 说明
事务尽可能短 只包住必要的数据库操作,不包含网络调用
不在事务内调用第三方 第三方超时会长时间持锁
保持加锁顺序一致 降低死锁概率
明确回滚条件 业务异常必须回滚,不要吞掉异常

四、主从复制与读写分离

4.1 复制原理

文本
主库写 binlog    ↓从库 IO 线程拉取 binlog,写入 relay log    ↓从库 SQL 线程重放 relay log
复制模式 特点
异步复制(默认) 主库提交即返回,可能丢数据(主库宕机时未同步的 binlog 丢失)
半同步复制 至少一个从库确认收到 binlog 才返回,安全性更高
全同步复制 所有从库确认,性能最差

4.2 读写分离的收益与代价

收益 代价
读请求分散到从库,提升整体吞吐 主从延迟导致读到旧数据
主库专注写,减少锁竞争 应用需区分读写路由
从库可用于备份与报表查询 架构与运维复杂度上升

4.3 主从延迟的应对

典型场景:用户下单后立即跳转订单详情页,却查不到订单——因为写主库、读从库,从库还没同步。

方案 说明
写后强制读主 关键操作后的一段时间内(如 1 秒)该用户读请求走主库
前端延迟跳转 简单但体验差,不推荐为主方案
半同步复制 缩短延迟窗口
关键业务不分离 支付、订单等强一致场景直接读主
javascript
// 写后强制读主:用 Redis 标记async function createOrder(userId, data) {  const order = await db.master.insert('orders', data)  await redis.set(`force-master:${userId}`, '1', 'EX', 2)   // 2 秒内读主  return order}function pickConnection(userId) {  return forceMaster.has(userId) ? db.master : db.replica}

4.4 延迟监控

sql
SHOW SLAVE STATUS\G-- 关注 Seconds_Behind_Master:持续增大说明从库跟不上

延迟超过阈值应告警。常见原因:从库配置过低、大事务、主库写入突增、从库上有慢查询。


五、分库分表

5.1 先尝试不做分表的手段

手段 适用
加索引 + SQL 优化 先做的第一步
归档历史数据 冷数据占比大(如只保留近 1 年)
分区表 按时间范围查询、便于按分区删除
读写分离 读多写少
引入缓存 热点数据

分库分表是最后手段:它会带来跨库 JOIN 失效、分布式事务、全局 ID、扩容困难等一连串问题。

5.2 拆分方式

方式 说明 优点 缺点
垂直分表 把大字段拆到另一张表 主表变小,查询更快 需联表取详情
垂直分库 按业务拆到不同库 业务隔离 跨库 JOIN 失效
水平分表 同一张表按规则拆成多张 单表数据量可控 跨表查询、分页复杂
水平分库 分表再分散到多个库 突破单机限制 分布式事务、扩容难

5.3 分片键选择

分片键 优点 缺点
用户 ID 同一用户数据集中,查询高效 按其他维度查需广播
订单 ID 写入分散均匀 查「某用户的订单」需扫所有分片
时间 便于归档 热点集中(当前时间片压力大)

原则:分片键要选最高频的查询维度,避免跨分片查询。

5.4 分表后的新问题与解法

问题 解法
跨分片查询 冗余存储(宽表)、搜索引擎(ES)、异构索引表
跨分片分页 各分片取 N 条再归并排序;或限制深度分页
跨分片聚合(count/sum) 各分片计算后汇总;或用计数表维护
分布式事务 尽量让同一业务落在同一分片;否则用 Saga/最终一致
全局唯一 ID 雪花算法、号段模式(见下章)
扩容 一致性哈希 或 成倍扩容 + 数据迁移

六、分布式 ID

方案 有序性 性能 缺点
数据库自增 ✅ 严格递增 低(单点) 扩展性差、单点瓶颈
数据库号段模式 ✅ 趋势递增 高(批量取号) 需额外表维护
雪花算法(Snowflake) ✅ 趋势递增 高 依赖机器时钟(回拨问题)
UUID ❌ 无序 高 太长,作为主键导致页分裂
Redis INCR ✅ 递增 高 依赖 Redis 可用性

雪花算法结构

文本
| 1 bit 符号 | 41 bit 时间戳 | 10 bit 机器 ID | 12 bit 序列号 |
  • 41 位时间戳:可用约 69 年
  • 10 位机器 ID:最多 1024 个节点
  • 12 位序列号:单节点每毫秒最多 4096 个 ID

时钟回拨问题:服务器时间被回拨会导致生成重复 ID。

javascript
class Snowflake {  constructor(workerId) {    this.workerId = workerId    this.sequence = 0    this.lastTimestamp = -1  }  nextId() {    let ts = Date.now()    if (ts < this.lastTimestamp) {      const offset = this.lastTimestamp - ts      if (offset > 5) throw new Error('时钟回拨过大,拒绝生成')      // 小幅回拨:等待时钟追上      while (ts < this.lastTimestamp) ts = Date.now()    }    if (ts === this.lastTimestamp) {      this.sequence = (this.sequence + 1) & 0xfff      if (this.sequence === 0) {        while (ts <= this.lastTimestamp) ts = Date.now()   // 当前毫秒用尽,等下一毫秒      }    } else {      this.sequence = 0    }    this.lastTimestamp = ts    return ((BigInt(ts) - 1288834974657n) << 22n) | (BigInt(this.workerId) << 12n) | BigInt(this.sequence)  }}

七、表设计进阶

7.1 字段设计原则

原则 说明
用最小够用的类型 TINYINT 代替 INT 存状态;INT 代替 BIGINT 存小数值
金额用 DECIMAL 或整数分 绝不用 FLOAT/DOUBLE(精度丢失)
时间用 DATETIME 或 TIMESTAMP 统一时区策略(推荐存 UTC 或统一存本地并明确约定)
枚举用 TINYINT + 注释 不用字符串枚举(占空间、易拼错)
避免 NULL 用默认值代替(NULL 影响索引与聚合)
预留字段慎用 ext1/ext2 最终会变成垃圾,用 JSON 字段更明确
单表字段不宜过多 超过 30 个考虑垂直拆分

7.2 大字段与 JSON

sql
-- JSON 字段:适合结构不固定的扩展信息ALTER TABLE products ADD COLUMN attrs JSON;-- 可以针对 JSON 内部字段建索引(虚拟列)ALTER TABLE products ADD COLUMN brand VARCHAR(50)  GENERATED ALWAYS AS (JSON_UNQUOTE(attrs->'$.brand')) STORED;CREATE INDEX idx_brand ON products(brand);

注意:JSON 字段不适合做高频查询条件与关联,只适合扩展属性。

7.3 唯一索引保证业务正确性

sql
-- ✅ 用数据库约束兜住并发ALTER TABLE user_coupons ADD UNIQUE KEY uk_user_coupon (user_id, coupon_id);

价值:应用层的「先查后插」在并发下必然失效,唯一索引是最后一道防线。


八、大数据量运维

8.1 观察表健康度

sql
-- 表大小与行数SELECT table_name,       ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb,       table_rowsFROM information_schema.tablesWHERE table_schema = 'your_db'ORDER BY data_length DESCLIMIT 20;-- 索引使用情况(找出从未使用的索引)SELECT * FROM sys.schema_unused_indexes;-- 冗余/重复索引SELECT * FROM sys.schema_redundant_indexes;

未使用的索引是纯成本——拖慢写入、占用空间,应定期清理。

8.2 批量操作

sql
-- ✅ 批量插入(单次 500–1000 条)INSERT INTO logs (user_id, action, created_at) VALUES  (1, 'login', NOW()), (2, 'view', NOW()), ...;-- ✅ 大范围更新分批,避免长事务与主从延迟UPDATE orders SET archived = 1 WHERE id BETWEEN 1 AND 1000;-- 循环推进,每批 sleep 几十毫秒,降低对线上影响

8.3 数据归档

sql
-- 归档策略:冷热分离-- 1. 将 1 年前数据迁到归档表INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);-- 2. 分批删除(避免大事务锁表)DELETE FROM orders WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR) LIMIT 1000;-- 循环执行直到影响行数为 0

8.4 备份

bash
# 逻辑备份(可读、跨版本,但慢)mysqldump --single-transaction --routines --triggers db_name | gzip > db_$(date +%F).sql.gz# 恢复gunzip < db_2026-01-01.sql.gz | mysql db_name
要点 说明
备份要定期演练恢复 没验证过的备份等于没有备份
备份要异地保存 同机备份随机器一起丢
大库考虑物理备份 XtraBackup,速度快、支持增量

九、常见问题

现象 原因 处理
查询突然变慢 索引失效 / 数据量增长 EXPLAIN 对比执行计划
更新操作把全表锁住 更新条件无索引 补索引或用主键更新
偶发死锁 加锁顺序不一致 统一顺序;缩短事务;查看 SHOW ENGINE INNODB STATUS
从库读到旧数据 主从延迟 写后强制读主
深分页越来越慢 LIMIT offset 扫描过多 游标分页或延迟关联
磁盘持续增长 未归档 / binlog 未清理 归档冷数据;配置 binlog 保留天数
连接数打满 连接池过大或连接泄漏 计算总连接数上限;检查是否用完释放
UUID 主键写入慢 随机插入导致页分裂 改用自增或有序 ID(雪花)

十、检查清单

索引

  • 高频查询条件、JOIN 关联列、ORDER BY 列都有索引
  • 联合索引列顺序符合「等值在前、范围在后、排序列末尾」
  • 无索引失效写法(列上函数、前置通配符、隐式转换)
  • 常用查询能命中覆盖索引(Using index)
  • 定期清理未使用的索引
  • 主键单调递增、不过长

查询

  • 关键查询已用 EXPLAIN 验证(type 不为 ALL)
  • 慢查询日志已开启并定期分析
  • 无深分页(或用游标分页)
  • 避免 SELECT *,只取必要列
  • 大结果集分批处理

事务与锁

  • 事务短小,不含远程调用
  • 更新条件走索引(避免表锁)
  • 批量操作按固定顺序加锁(防死锁)
  • 高并发更新用乐观锁或条件更新
  • 隔离级别选择有明确理由

架构

  • 分库分表是最后手段(先试索引/归档/缓存/读写分离)
  • 若读写分离,已处理主从延迟(写后读主)
  • 主从延迟有监控与告警
  • 分片键选择匹配最高频查询维度
  • 分布式 ID 方案已解决时钟回拨与唯一性

运维

  • 表大小与增长趋势有监控
  • 备份策略完备,且演练过恢复
  • binlog 保留策略明确(兼顾磁盘与恢复需求)
  • 冷数据有归档机制
  • 连接池大小按「实例数 × 池大小 < 上限」核算

十一、总结

数据库进阶的知识可以归纳为五条主线:

  1. 索引是性能的基础——理解 B+ 树与聚簇索引结构,才能明白「为什么主键要短、为什么要覆盖索引、为什么函数会导致失效」。
  2. 执行计划是判断依据——不看 EXPLAIN 的索引优化都是在猜;type 从 ALL 变成 ref 通常就是最大的一笔收益。
  3. 锁的问题多源于「没走索引」——更新条件无索引会升级为表锁,这是线上大面积阻塞最常见的根因。
  4. 读写分离要处理延迟——「写后立即读」是最典型的踩坑场景,强制读主是最简单的解法。
  5. 分库分表是最后手段——先用索引、归档、缓存、读写分离;真到分表时,分片键选错会让后续每一步都难受。

最后一条实践建议:把「EXPLAIN 检查」纳入上线流程。新增或修改的 SQL,只要涉及大表,就在提交前跑一次执行计划——这比事后看慢查询日志便宜得多。


完

前端、后端、部署、客服都是我一个人。写这里的东西都是真踩过的坑,不是抄来的教程。