数据库进阶
数据库性能与架构的进阶手册:从 B+ 树索引原理、回表与覆盖索引,到执行计划分析与慢查询优化,再到事务隔离级别、MVCC、锁与死锁、主从复制读写分离、分库分表与分布式 ID,并附大数据量下的运维实践。
一、索引原理
1.1 为什么用 B+ 树
| 结构 | 问题 |
|---|---|
| 二叉搜索树 | 数据量大时树高过高,磁盘 IO 次数多 |
| 红黑树 | 仍是二叉树,树高依然大 |
| 哈希索引 | 只支持等值查询,不支持范围与排序 |
| B+ 树 | 多叉、矮胖、叶子节点有序且相连——等值、范围、排序都支持 |
关键特性:
- 非叶子节点只存索引键,不存数据 → 单节点能放更多键 → 树更矮(通常 3–4 层就能支撑千万级数据)
- 叶子节点存数据(或数据指针)并用链表相连 → 范围查询只需顺着链表扫
- 一次查询通常只需 2–4 次磁盘 IO
1.2 聚簇索引与二级索引
CREATE TABLE users ( id BIGINT PRIMARY KEY, -- 聚簇索引 name VARCHAR(50), age INT, INDEX idx_name (name) -- 二级索引);| 类型 | 叶子节点存什么 | 说明 |
|---|---|---|
| 聚簇索引(主键) | 整行数据 | 表数据本身就按主键组织 |
| 二级索引(普通索引) | 主键值 | 查到主键后还要回表 |
推论:
- 主键不应太大——二级索引都要存主键,主键过长会让所有索引变大。
- 主键最好单调递增——避免随机插入导致的页分裂(UUID 作为主键就是典型问题)。
1.3 回表与覆盖索引
-- 有索引 idx_name(name)SELECT id, name FROM users WHERE name = 'Alice';-- ✅ 索引里已有 name 和 id → 不需要回表(覆盖索引)SELECT id, name, age FROM users WHERE name = 'Alice';-- ❌ 索引里没有 age → 需要回表查整行-- 优化:把常用查询字段加到索引里,避免回表ALTER TABLE users ADD INDEX idx_name_age (name, age);SELECT id, name, age FROM users WHERE name = 'Alice'; -- 现在可覆盖覆盖索引是最有效的优化手段之一:
EXPLAIN中出现Using index即表示命中覆盖索引。
1.4 联合索引与最左前缀
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, created_at);能命中索引的查询:
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)不能命中:
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 关键列
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. 复测验证# my.cnfslow_query_log = 1long_query_time = 1log_queries_not_using_indexes = 0 # 数据量大时开启会产生大量日志# 按总耗时排序(t=总时间, c=次数, l=锁时间, r=返回行数)mysqldumpslow -s t -t 20 /var/log/mysql/slow.log2.3 常见慢查询类型与对策
| 类型 | 特征 | 对策 |
|---|---|---|
| 全表扫描 | type=ALL,rows 很大 |
补索引或改写条件 |
| 深分页 | LIMIT 100000, 20 |
游标分页(WHERE id < lastId) |
| 大表 JOIN | 多表关联无索引 | 检查关联列索引;必要时反范式冗余 |
| 排序慢 | Using filesort |
索引顺序对齐 ORDER BY;或减少排序字段 |
| 临时表 | Using temporary |
常见于 GROUP BY 无索引;补索引 |
| 函数/隐式转换 | key=NULL |
改写为不破坏索引的形式 |
2.4 深分页优化实例
-- ❌ 需要扫描并丢弃前 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 默认加锁方式 |
重要:行锁是基于索引的。更新条件没有索引时,会退化为表锁——这是线上最容易造成大面积阻塞的原因。
-- ❌ 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 ↓ 死锁排查:
SHOW ENGINE INNODB STATUS; -- 查看 LATEST DETECTED DEADLOCK 段预防:
| 措施 | 说明 |
|---|---|
| 统一加锁顺序 | 批量操作按 ID 升序处理 |
| 缩短事务 | 事务内不做远程调用、不发消息 |
| 减少范围更新 | 尽量用主键精确定位 |
| 降低隔离级别(评估后) | RC 无间隙锁,死锁概率更低(但有其他影响) |
| 使用乐观锁 | 高并发更新用版本号代替加锁 |
-- ✅ 乐观锁:避免长事务与锁竞争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 秒)该用户读请求走主库 |
| 前端延迟跳转 | 简单但体验差,不推荐为主方案 |
| 半同步复制 | 缩短延迟窗口 |
| 关键业务不分离 | 支付、订单等强一致场景直接读主 |
// 写后强制读主:用 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 延迟监控
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。
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
-- 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 唯一索引保证业务正确性
-- ✅ 用数据库约束兜住并发ALTER TABLE user_coupons ADD UNIQUE KEY uk_user_coupon (user_id, coupon_id);价值:应用层的「先查后插」在并发下必然失效,唯一索引是最后一道防线。
八、大数据量运维
8.1 观察表健康度
-- 表大小与行数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 批量操作
-- ✅ 批量插入(单次 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 数据归档
-- 归档策略:冷热分离-- 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;-- 循环执行直到影响行数为 08.4 备份
# 逻辑备份(可读、跨版本,但慢)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 保留策略明确(兼顾磁盘与恢复需求)
- 冷数据有归档机制
- 连接池大小按「实例数 × 池大小 < 上限」核算
十一、总结
数据库进阶的知识可以归纳为五条主线:
- 索引是性能的基础——理解 B+ 树与聚簇索引结构,才能明白「为什么主键要短、为什么要覆盖索引、为什么函数会导致失效」。
- 执行计划是判断依据——不看 EXPLAIN 的索引优化都是在猜;
type从ALL变成ref通常就是最大的一笔收益。 - 锁的问题多源于「没走索引」——更新条件无索引会升级为表锁,这是线上大面积阻塞最常见的根因。
- 读写分离要处理延迟——「写后立即读」是最典型的踩坑场景,强制读主是最简单的解法。
- 分库分表是最后手段——先用索引、归档、缓存、读写分离;真到分表时,分片键选错会让后续每一步都难受。
最后一条实践建议:把「EXPLAIN 检查」纳入上线流程。新增或修改的 SQL,只要涉及大表,就在提交前跑一次执行计划——这比事后看慢查询日志便宜得多。
完