三、数据库
结构 :📖 是什么 → 🤔 为什么 → 🛠 怎么用 → 💡 总结
风格:专业词提前讲清楚(避免堆术语)+ 真实业务痛点驱动 + 代码注释白话化
3.1 MySQL
Q1:InnoDB 是什么?为什么 MySQL 默认用它?
📖 是什么
InnoDB 是 MySQL 默认的"数据存储方式" 。
类比:MySQL 就像一个仓库,InnoDB 是仓库管理员,决定怎么存、怎么取、怎么锁门。
关键概念解释(专业词提前讲清楚):
- 存储引擎:MySQL 的"数据存取引擎",负责数据怎么存磁盘、怎么读内存
- 事务:要么全部成功,要么全部失败的"打包操作"
- 行级锁:修改一行数据时,只锁这一行(不让别人同时改),不影响其他行
- 外键:表与表之间的"父子关系"约束
🤔 为什么
Q1:为什么 MySQL 要默认 InnoDB,而不是其他引擎?
因为它解决了 MySQL 早期三个致命问题。
❌ 早期默认引擎(MyISAM)的问题:
1. 不支持事务 → 转账可能"扣了 A,没加 B"(钱凭空消失)
2. 不支持行锁 → 一查全表锁死(100 人同时下单只能排队)
3. 崩溃会丢数据 → 服务器突然断电,数据没保存
✅ InnoDB 解决了这三大问题:
- 事务 → 转账要么全成功要么全失败
- 行级锁 → 100 人同时下单各锁各的
- Redo Log → 崩溃后能恢复数据
Q2:InnoDB 是怎么做到这些的?
它有 4 大法宝。
| 法宝 | 解决什么问题 | 一句话原理 |
|---|---|---|
| ACID 事务 | 数据不一致 | 改之前写日志,改完才提交 |
| 行级锁 | 并发性能差 | 只锁要改的那一行,不影响其他行 |
| MVCC | 读不阻塞写 | 读的是"快照",不是最新数据 |
| Redo Log | 崩溃丢数据 | 先写日志再写磁盘,崩溃后重放日志 |
🛠 怎么用(实际业务场景)
业务场景:登录 MySQL 后查看 InnoDB 运行状态
bash
# ============ 1. 看看 MySQL 在用什么引擎 ============
# 登录 MySQL 命令行
mysql -u root -p
# 查看所有存储引擎(InnoDB 应该排在第一个)
SHOW ENGINES;
# 关注 Support 列:DEFAULT 表示默认,YES 表示支持,NO 表示不支持
bash
# ============ 2. 查看 InnoDB 关键配置 ============
# 重点看这个:Buffer Pool 大小(InnoDB 性能核心)
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
# 默认 134217728 = 128MB(生产环境不够用!)
# 建议改成物理内存的 60%-80%(比如 16GB 内存就设 10GB)
# 修改方法:my.cnf 加 innodb_buffer_pool_size = 10G
bash
# ============ 3. 查看 InnoDB Buffer Pool 命中率(监控必备)============
# 公式:命中率 = 1 - (磁盘读 / 总读)
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
# Innodb_buffer_pool_read_requests = 从内存读的次数(越多越好)
# Innodb_buffer_pool_reads = 从磁盘读的次数(越少越好)
# 命中率应该 > 99%,如果 < 95% 说明 Buffer Pool 不够大
bash
# ============ 4. 查看 InnoDB 详细运行状态 ============
# \G 是 MySQL 特有的"竖排显示",方便看长输出
SHOW ENGINE INNODB STATUS\G
# 输出会包含:锁等待、事务、Buffer Pool 命中率等
# 重点关注:LATEST DETECTED DEADLOCK(最近的死锁)
生产配置推荐(my.cnf):
ini
[mysqld]
# Buffer Pool:物理内存的 60-80%
# 比如 16GB 内存的服务器,设 10G
innodb_buffer_pool_size = 10G
# Redo Log:默认 48MB 太小,生产建议 1-4G
innodb_log_file_size = 2G
# 后台线程数(默认够用,高并发可调大)
innodb_read_io_threads = 8
innodb_write_io_threads = 8
# 最大连接数(超过这个会拒绝连接)
max_connections = 1000
💡 一句话总结
InnoDB 是 MySQL 默认存储引擎,因为有事务、有行锁、不丢数据 ;监控看
innodb_buffer_pool_size和命中率,命中率 > 99% 才健康。
什么时候用:
- ✅ 几乎所有 MySQL 场景默认选 InnoDB
- ❌ 几乎淘汰的 MyISAM(除非只读 + 全文检索)
Q2:InnoDB 的事务隔离级别?RR 为什么是 MySQL 默认?
📖 是什么
隔离级别 = 多个事务并发时,互相"看不见"的程度 。
像"几个人在同一个房间工作",隔离级别决定能不能看到对方的草稿。
关键概念解释(专业词提前讲清楚):
- 脏读:读到别人"没提交"的数据(万一对方回滚,数据是假的)
- 不可重复读:同一行读两次,结果不一样(中间被别人改了)
- 幻读:同一范围读两次,行数不一样(中间被别人插入/删除了)
- 快照读:读"历史版本",不是最新数据(MVCC 的核心)
- 当前读 :读最新数据,并加锁(
SELECT ... FOR UPDATE)
🤔 为什么
Q1:为什么会有这 3 个问题?
因为多个事务同时改数据,谁先谁后说不清。
场景:账户余额 = 1000
事务 A:UPDATE 余额 = 2000(还没提交)
事务 B:SELECT 余额 → 看到 2000(脏读!)
事务 A:ROLLBACK(回滚)
事务 B:拿"假数据"做了业务判断 → 出错
Q2:4 个隔离级别怎么权衡?
隔离级别越高 = 并发问题越少 = 性能越差。
SQL 标准定义了 4 个级别,让业务自己选。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 | 适用 |
|---|---|---|---|---|---|
| READ UNCOMMITTED(读未提交) | ✅ 出现 | ✅ 出现 | ✅ 出现 | 最高 | 几乎不用 |
| READ COMMITTED(读已提交) | ❌ 解决 | ✅ 出现 | ✅ 出现 | 高 | Oracle / PostgreSQL 默认 |
| REPEATABLE READ(可重复读) | ❌ | ❌ 解决 | ✅(InnoDB 大部分解决) | 中 | MySQL InnoDB 默认 |
| SERIALIZABLE(串行化) | ❌ | ❌ | ❌ 解决 | 最低 | 强一致场景(如库存盘点) |
Q3:MySQL 为什么默认 RR 而不是 RC?
主要是为了兼容主从复制的 binlog 顺序。
历史原因:
- MySQL 5.7 之前,binlog 用 STATEMENT 格式(记录 SQL 语句)
- STATEMENT 格式下,RC 级别会出现"主从数据不一致"
- RR 级别下,主库 binlog 顺序 = 从库执行顺序
现代原因:
- MySQL 5.7+ 推荐 ROW 格式 binlog(记录每行变化)
- ROW 格式下,RC 和 RR 都能保证主从一致
- 所以高并发读写场景可以选 RC(避免间隙锁,性能更好)
🛠 怎么用(实际业务场景)
业务场景 1:转账业务(必须用 RR 或 RC)
java
// ============ 业务:用户 A 转账 100 给 用户 B ============
// 不用事务 → A 扣了 100,B 没收到(钱凭空消失)
@Transactional(isolation = Isolation.REPEATABLE_READ) // MySQL 默认,可省略
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 1. 扣 A 的钱(用 FOR UPDATE 锁这一行,防止别人同时改)
// FOR UPDATE = 当前读,加行锁
Account from = accountMapper.selectForUpdate(fromUserId);
if (from.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("余额不足");
}
accountMapper.deductBalance(fromUserId, amount);
// 2. 给 B 加钱
accountMapper.addBalance(toUserId, amount);
// 任何一步抛异常,整个事务回滚(A 钱不会被扣)
}
业务场景 2:根据业务选隔离级别
java
// ✅ 场景 1:报表统计(可重复读,RR)
// 需求:统计 1 月订单,统计过程中不能被别人改
// 选 RR
@Transactional(isolation = Isolation.REPEATABLE_READ)
public List<Order> statJanuaryOrders() {
return orderMapper.selectByMonth("2026-01");
}
// ✅ 场景 2:高并发秒杀(读已提交,RC)
// 需求:库存扣减,不希望间隙锁影响并发
// 选 RC(需要 MySQL 5.7+ ROW 格式 binlog)
@Transactional(isolation = Isolation.READ_COMMITTED)
public boolean deductStock(Long productId, int count) {
int affected = stockMapper.deduct(productId, count);
return affected > 0;
}
// ✅ 场景 3:财务对账(串行化,强一致)
// 需求:月末对账,绝对不能出错
// 选 SERIALIZABLE(性能差,但数据绝对对)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void monthlyReconciliation() {
// 整个表锁住,别人不能改
accountMapper.lockAll();
// ... 对账逻辑
}
MySQL 隔离级别配置(my.cnf):
ini
[mysqld]
# 全局默认隔离级别(推荐 RR)
transaction-isolation = REPEATABLE-READ
# 或 RC(高并发性能更好,但要确认 binlog 格式是 ROW)
# transaction-isolation = READ-COMMITTED
查看当前隔离级别:
sql
-- 会话级(当前连接)
SELECT @@transaction_isolation;
-- 全局级(所有连接)
SELECT @@global.transaction_isolation;
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 隔离级别用 SERIALIZABLE 做秒杀 | 性能爆炸(整个表锁死) | 用 RC + 乐观锁 |
| 隔离级别和 binlog 格式不匹配 | 主从数据不一致 | MySQL 5.7+ 用 ROW 格式 binlog |
| 长事务跑在 RR 下 | 占用 undo log,影响性能 | 事务尽量短(秒级) |
| 在事务里做 HTTP/RPC 调用 | 锁一直不释放 | 先查数据,事务里只做 DB 操作 |
什么时候用:
- ✅ 绝大多数业务用默认 RR 即可
- ✅ 高并发写场景(秒杀)用 RC + 乐观锁
- ✅ 财务对账用 SERIALIZABLE
- ❌ 几乎不用 READ UNCOMMITTED(脏读危害太大)
💡 一句话总结
4 个隔离级别(读未提交/读已提交/可重复读/串行化),MySQL 默认 RR(兼容主从复制);越高越安全但越慢;InnoDB 用 MVCC + 间隙锁解决大部分幻读。
Q3:MySQL 索引底层数据结构?B+ 树为什么好?
📖 是什么
B+ 树是 InnoDB 索引的"骨架" 。
类比:书的目录------不用从第一页翻到尾,先查目录找到页码,直接翻到那一页。
关键概念解释(专业词提前讲清楚):
- 索引:数据的"目录",加速查找(用空间换时间)
- B 树(Balanced Tree):多路平衡查找树,每个节点存数据和指针
- B+ 树 :B 树的升级版,数据全在叶子节点,非叶子节点只存索引
- 叶子节点:树最底层的节点,存真实数据(或主键)
- 页(Page):InnoDB 磁盘 I/O 的最小单位,默认 16KB
- 聚簇索引:数据和索引放一起(InnoDB 主键索引就是聚簇索引)
- 非聚簇索引(二级索引):只存索引键 + 主键,不存完整数据
🤔 为什么
Q1:为什么不用哈希表?
哈希不支持范围查询。
❌ 哈希表:
key = 1 → addr1
key = 5 → addr2
查 id > 100 的数据 → 哈希直接懵圈(要全表扫描)
✅ B+ 树:
数据按 id 排好序(叶子节点是链表)
查 id > 100 → 从 100 开始往后扫(快)
Q2:为什么不用二叉树 / 红黑树?
因为树太高,磁盘 IO 太多。
二叉树 / 红黑树:
100 万数据 → 树高约 20 层
每次查数据要 20 次磁盘 IO(太慢!)
B+ 树(多路):
每个节点存 1000+ 个 key
100 万数据 → 树高 2-3 层
每次查只要 2-3 次 IO(快!)
Q3:为什么 B+ 树比 B 树好?
因为数据全在叶子节点 + 叶子节点有链表。
B 树:
每个节点都存数据 → 一次 IO 拿到的 key 少 → 树高 → IO 多
B+ 树:
非叶子节点只存 key → 一次 IO 拿很多 key → 树矮 → IO 少
叶子节点有链表 → 范围查询极快(直接顺着链表扫)
🛠 怎么用(实际业务场景)
业务场景 1:看 SQL 走没走索引
sql
-- 准备测试表
CREATE TABLE t_user (
id BIGINT PRIMARY KEY, -- 聚簇索引(主键)
name VARCHAR(50),
age INT,
INDEX idx_age (age) -- 二级索引(非聚簇)
);
-- ============ 1. EXPLAIN 看执行计划 ============
EXPLAIN SELECT * FROM t_user WHERE id = 1;
-- type = const:常数查找(主键或唯一索引等值查询,最快)
-- key = PRIMARY:用了主键索引
EXPLAIN SELECT * FROM t_user WHERE age = 25;
-- type = ref:非唯一索引等值查询
-- key = idx_age:用了 age 索引
EXPLAIN SELECT * FROM t_user WHERE age > 20;
-- type = range:范围查询(用了索引,但不是最优)
-- key = idx_age
EXPLAIN SELECT * FROM t_user WHERE name = 'Tom';
-- type = ALL:全表扫描(name 没建索引,灾难!)
-- key = NULL
业务场景 2:B+ 树能存多少数据?
java
/**
* 计算 B+ 树能存多少行
*
* 假设:
* - 主键 BIGINT(8 字节)
* - 指针 6 字节
* - 每页 16KB = 16384 字节
* - 非叶子节点 key 数量 = 16384 / (8+6) ≈ 1170
* - 叶子节点 1 行 = 1KB(假设)
* - 叶子节点 1 页能存 16 行
*
* 树高 2:1170(根)→ 1170 × 16(叶子)= 18720 行
* 树高 3:1170 → 1170 × 1170 → 1170 × 1170 × 16 ≈ 21 亿行
*
* 结论:3 层 B+ 树能存 21 亿行数据,查找最多 3 次磁盘 IO
*/
业务场景 3:聚簇索引 vs 二级索引
java
/**
* 聚簇索引(主键索引):
* 叶子节点存"完整数据行"
* 一张表只有一个
*
* 二级索引(非主键索引):
* 叶子节点存"索引值 + 主键"
* 查询时如果需要其他字段,要"回表"(用主键再查一次)
*/
// 例:先按 age 查,再取 name(回表)
EXPLAIN SELECT name FROM t_user WHERE age = 25;
-- 如果 idx_age 叶子节点只存 (age, id),需要回表查 name
-- 优化:建"覆盖索引"避免回表
-- 覆盖索引:联合索引 (age, name),叶子节点直接有 name
CREATE INDEX idx_age_name ON t_user(age, name);
EXPLAIN SELECT name FROM t_user WHERE age = 25;
-- Extra = "Using index":用上了覆盖索引,不用回表
B+ 树可视化:
┌─────────────────────┐
│ [30, 60, 90] │ ← 非叶子节点(只存 key)
└──────┬──────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│20, 25 │ ←→ │40, 50 │ ←→ │70, 80 │ ← 叶子节点(存数据 + 链表)
│完整行 │ │完整行 │ │完整行 │
└────────┘ └────────┘ └────────┘
- 叶子节点用链表连起来 → 范围查询极快
- 非叶子节点只存 key → 单个节点能存更多 key → 树更矮
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 用 UUID 当主键 | 每次插入随机位置 → 页分裂 | 用自增 ID(AUTO_INCREMENT) |
| 在大字段上建索引 | 索引大、查询慢 | 索引列尽量小(用前缀索引) |
| 索引建太多 | 写入慢(每次要维护所有索引) | 按查询需要建,不要冗余 |
| 没建联合索引 | 多条件查询全表扫描 | 高频组合条件建联合索引 |
什么时候用:
- ✅ 主键默认聚簇索引(自增 ID)
- ✅ 高频查询条件建二级索引
- ✅ 范围查询、排序用 B+ 树索引
- ❌ 等值查询 + 大量数据(如缓存穿透)用布隆过滤器
- ❌ 不确定的值(如长文本)不适合建索引
💡 一句话总结
InnoDB 用 B+ 树做索引(多路平衡、叶子链表),3 层能存 21 亿行;聚簇索引存数据,二级索引要"回表";用自增主键避免页分裂。
Q4:最左前缀、索引下推、覆盖索引是什么?怎么用?
📖 是什么
三个索引优化技巧 ,目的都是让 SQL 跑得更快。
关键概念解释(专业词提前讲清楚):
- 联合索引(复合索引) :多个列一起建索引,如
(name, age, city) - 最左前缀:联合索引从最左边的列开始匹配,缺一个就用不上
- 索引下推(ICP, Index Condition Pushdown):MySQL 5.6+,在存储引擎层就用索引过滤数据
- 覆盖索引 :查询的列正好在索引里,不用回表
- 回表:用二级索引查到主键后,再去主键索引查完整数据
🤔 为什么
Q1:为什么要懂这三个?
因为面试必问、实际工作天天用,不懂就写慢 SQL。
Q2:最左前缀为什么会丢索引?
因为联合索引按"列顺序"组织,缺了左边的列,后面的就找不到了。
联合索引 (name, age, city):
索引按 name → age → city 排好序
没 name → 索引完全失效
有 name 没 age → 只能用到 name 部分
有 name + age → 完美匹配
Q3:索引下推省了什么?
原本要"回表 100 次再过滤" → 索引层就过滤掉 99 次。
没 ICP:存储引擎回表 100 次 → 服务层过滤 → 只 1 条有效
有 ICP:存储引擎用索引过滤 99 条 → 只回表 1 次
Q4:覆盖索引省了什么?
省了"回表"这一步(叶子节点直接有要的数据)。
🛠 怎么用(实际业务场景)
业务场景 1:最左前缀实战
sql
-- 联合索引
CREATE INDEX idx_name_age_city ON t_user(name, age, city);
-- ✅ 走索引:name 在最左
EXPLAIN SELECT * FROM t_user WHERE name = 'Tom'; -- 用 name
EXPLAIN SELECT * FROM t_user WHERE name = 'Tom' AND age = 25; -- 用 name + age
EXPLAIN SELECT * FROM t_user WHERE name = 'Tom' AND age = 25 AND city = 'BJ'; -- 全用
-- ❌ 不走索引:缺最左 name
EXPLAIN SELECT * FROM t_user WHERE age = 25; -- 全表扫描!
EXPLAIN SELECT * FROM t_user WHERE city = 'BJ'; -- 全表扫描!
-- ⚠️ 范围查询会"中断"后续索引
EXPLAIN SELECT * FROM t_user WHERE name = 'Tom' AND age > 25 AND city = 'BJ';
-- name = 走索引,age > 走索引,city 走不上(因为 age 是范围)
业务场景 2:索引下推(ICP)实战
sql
-- 没开 ICP 时(MySQL 5.5 及之前):
-- 1. 存储引擎用 name 索引找出所有 'Tom'(假设 1000 条)
-- 2. 1000 条都回表查完整数据
-- 3. 服务层再过滤 age = 25(只 10 条符合)
-- 4. 返回 10 条
-- 共 1000 次回表
-- 开了 ICP(MySQL 5.6+):
-- 1. 存储引擎用 name 索引找 'Tom',且直接用 age = 25 过滤
-- 2. 只剩 10 条回表
-- 3. 返回 10 条
-- 共 10 次回表
sql
-- 查看 ICP 是否开启
SHOW VARIABLES LIKE 'optimizer_switch';
-- 关注:index_condition_pushdown = on
-- ICP 适用条件:
-- 1. 联合索引
-- 2. WHERE 条件里有索引列的范围/等值判断
-- 3. 不是覆盖索引(如果是覆盖索引,根本不用回表)
业务场景 3:覆盖索引实战
sql
-- 表结构
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
user_id BIGINT,
product_name VARCHAR(100),
price DECIMAL(10,2),
INDEX idx_user_id (user_id)
);
-- ❌ 不走覆盖索引:要查 product_name(不在索引里)
EXPLAIN SELECT * FROM t_order WHERE user_id = 100;
-- Extra: NULL(要回表)
-- ❌ 不走覆盖索引
EXPLAIN SELECT product_name FROM t_order WHERE user_id = 100;
-- Extra: NULL(要回表,因为 product_name 不在 idx_user_id 里)
-- ✅ 走覆盖索引:建联合索引 (user_id, product_name)
CREATE INDEX idx_user_product ON t_order(user_id, product_name);
EXPLAIN SELECT product_name FROM t_order WHERE user_id = 100;
-- Extra: "Using index"(覆盖索引成功,不用回表!)
3 个技巧综合实战:
sql
-- 需求:查 "北京、25 岁的 Tom" 的订单
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
user_id BIGINT,
product_name VARCHAR(100),
INDEX idx_user_product (user_id, product_name) -- 联合索引
);
-- 慢 SQL:
EXPLAIN SELECT product_name FROM t_order
WHERE user_id = 100 AND product_name = 'iPhone';
-- 走 idx_user_product 联合索引
-- Extra: "Using where; Using index"
-- - Using where:服务层过滤
-- - Using index:覆盖索引(不用回表)
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 联合索引列顺序不对 | 索引失效 | 把高频查询的列放最左 |
| 范围查询后接索引列 | 后续索引失效 | 范围查询放最后 |
| 索引列参与计算 | 索引失效 | WHERE age + 1 = 26 改成 WHERE age = 25 |
| 索引列用函数 | 索引失效 | WHERE DATE(create_time) = '2026-07-17' 改成范围 |
什么时候用:
- ✅ 联合索引把高频查询列放最左
- ✅ 高频"等值+范围"查询建联合索引触发 ICP
- ✅ 频繁 SELECT 的字段和 WHERE 字段建联合索引(覆盖索引)
- ❌ 不要为了 ICP 强行加索引(评估收益)
💡 一句话总结
最左前缀 :联合索引从最左列开始匹配,缺了就失效;
索引下推 :存储引擎层就用索引过滤,省回表;
覆盖索引:查询列在索引里,不用回表。
Q5:MVCC 是如何实现的?解决了什么问题?
📖 是什么
MVCC(Multi-Version Concurrency Control,多版本并发控制)= 让"读不阻塞写、写不阻塞读"的魔法 。
类比:图书馆里同一本书有多个版本,你读的"旧版本"不影响别人改"新版本"。
关键概念解释(专业词提前讲清楚):
- 快照读(Snapshot Read) :读"历史版本"的数据,不加锁(普通的
SELECT) - 当前读(Current Read) :读"最新版本"的数据,并加锁(
SELECT ... FOR UPDATE、UPDATE、INSERT、DELETE) - undo log:回滚日志,记录数据的"历史版本"
- trx_id:事务 ID,每次开启事务递增
- read_view:事务的"读视图",决定能看到哪些版本
- 隐藏列 :InnoDB 每行数据都有 3 个隐藏列(
trx_id、roll_pointer、row_id)
🤔 为什么
Q1:为什么需要 MVCC?
不用 MVCC 的话,读和写会互相阻塞,并发性能极差。
❌ 没有 MVCC(用锁隔离):
事务 A:UPDATE t SET name = 'Tom' WHERE id = 1(加行锁)
事务 B:SELECT * FROM t WHERE id = 1(要等 A 提交)
100 个查询 → 全部卡住
✅ 有 MVCC:
事务 A:UPDATE t SET name = 'Tom' WHERE id = 1(加行锁)
事务 B:SELECT * FROM t WHERE id = 1(读"快照",不阻塞)
100 个查询 → 全部能读
Q2:MVCC 解决了哪些问题?
解决了脏读、不可重复读(部分解决幻读)。
RR 隔离级别下:
- 事务 B 多次读同一行 → 都看到事务开始时的"快照"
- 事务 A 在中间改 → 不影响 B 的读
- 这就是"可重复读"
Q3:MVCC 怎么实现的?
靠 3 样东西:隐藏列 + undo log + read_view。
🛠 怎么用(实际业务场景)
业务场景 1:理解 InnoDB 行格式
sql
-- InnoDB 每行数据都有 3 个隐藏列
CREATE TABLE t_user (
id BIGINT PRIMARY KEY,
name VARCHAR(50)
);
-- 实际存储(简化):
-- | trx_id | roll_pointer | id | name |
-- |--------|--------------|-----|------|
-- | 100 | 0x1234 → | 1 | Tom | ← 当前版本
-- | | | | |
-- | 历史版本(在 undo log): |
-- | trx_id=80, name=Jack ← 通过 roll_pointer 链起来 |
-- | trx_id=60, name=Rose ← 更老的历史 |
业务场景 2:MVCC 读流程(RR 级别)
java
/**
* 事务 B(READ COMMITTED 或 REPEATABLE READ)读 id=1 的数据
*/
public class MvccExample {
public void demo() {
// ============ 步骤 1:事务 B 开始 ============
// trx_id = 100(事务 B 自己的 ID)
// 创建 read_view:[活跃事务 ID 列表 = {101, 102}, 最小=100, 最大=103]
// ============ 步骤 2:事务 B 第一次读 id=1 ============
// 找到 id=1 这一行,看 trx_id = 80
// 80 < 100(最小活跃 ID)→ 80 已提交 → 返回 Jack(老版本)
String name1 = jdbc.queryForObject("SELECT name FROM t_user WHERE id = 1", String.class);
// name1 = "Jack"
// ============ 步骤 3:事务 A(trx_id=101)修改并提交 ============
// UPDATE t_user SET name = 'Tom' WHERE id = 1
// 此时 id=1 的 trx_id = 101,并产生 undo log(保留 Jack 的旧版本)
// ============ 步骤 4:事务 B 第二次读 id=1 ============
// 找到 id=1 这一行,看 trx_id = 101
// 101 在"活跃事务列表"里 → 不可见
// 通过 roll_pointer 找上一版本(trx_id=80, name=Jack)
// 80 < 100(最小活跃 ID)→ 80 已提交 → 返回 Jack
String name2 = jdbc.queryForObject("SELECT name FROM t_user WHERE id = 1", String.class);
// name2 = "Jack"(依然不变!)
}
}
业务场景 3:当前读 vs 快照读
java
// ============ 快照读(普通 SELECT)============
// 不加锁,读 undo log 里的历史版本
SELECT * FROM t_user WHERE id = 1; // 快照读
SELECT * FROM t_user WHERE name = 'Tom'; // 快照读
// ============ 当前读(加锁的 SELECT)============
// 读最新版本,并加锁
SELECT * FROM t_user WHERE id = 1 FOR UPDATE; // 当前读
SELECT * FROM t_user WHERE id = 1 LOCK IN SHARE MODE; // 当前读
// 当前读场景:
// 1. UPDATE / DELETE → 自动当前读
// 2. INSERT → 触发当前读(检查唯一键冲突)
// 3. SELECT ... FOR UPDATE → 手动当前读(行锁 + 读最新)
// ============ 业务场景:扣库存(必须当前读)============
@Transactional
public boolean deductStock(Long productId, int count) {
// 当前读:拿最新库存 + 加行锁
Stock stock = stockMapper.selectForUpdate(productId);
if (stock.getCount() < count) {
return false; // 库存不足
}
// 扣减(当前读 + 写)
stockMapper.deduct(productId, count);
return true;
}
MVCC 可视化:
事务时间线:
时间 ─────────────────────────────────────►
T1: 事务 A 开始(trx_id=80)
T2: 事务 A 第一次读 id=1(快照:name=Jack)
T3: 事务 B 开始(trx_id=90)
T4: 事务 B 修改 id=1(name=Tom,产生 undo log)
T5: 事务 B 提交
T6: 事务 A 第二次读 id=1(快照:name=Jack,RR 下不变)
RR 级别下:事务 A 整个生命周期用同一个 read_view → 永远读 Jack
RC 级别下:每次 SELECT 创建新 read_view → T6 时能读到 Tom
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 长事务 + MVCC | undo log 堆积,磁盘占满 | 事务尽量短(秒级) |
| 用快照读做"实时"业务 | 读到旧数据 | 改用当前读(FOR UPDATE) |
| 在 RR 下大批量 SELECT | 占用大量 undo log | 拆成小批次 |
什么时候用:
- ✅ 普通查询用快照读(性能高、不阻塞)
- ✅ 需要"最新数据 + 独占"用当前读(FOR UPDATE)
- ✅ 报表统计用快照读(避免不一致)
- ❌ 不用快照读做"扣库存"等强一致操作
💡 一句话总结
MVCC = 隐藏列 + undo log + read_view;让"读不阻塞写、写不阻塞读";RR 下同一事务读快照不变,RC 下每次读最新已提交。
Q6:主从复制原理是什么?怎么解决主从延迟?
📖 是什么
主从复制 = 把"主库"的数据同步到"从库" 。
类比:老板(主库)做决策,秘书(从库)抄老板的笔记本。
关键概念解释(专业词提前讲清楚):
- binlog :二进制日志,记录主库的所有数据变更(
INSERT/UPDATE/DELETE) - 主库(Master):处理所有写操作的库
- 从库(Slave/Replica):同步主库数据的库,主要承担读
- relay log:中继日志,从库 IO 线程把 binlog 存到本地叫 relay log
- binlog dump 线程:主库线程,负责把 binlog 推给从库
- IO 线程:从库线程,负责拉取 binlog
- SQL 线程:从库线程,负责执行 relay log
- ROW 格式 binlog:记录每行数据的最终值(推荐)
🤔 为什么
Q1:为什么需要主从复制?
3 大业务痛点:
❌ 单库问题:
1. 读多写少(100:1)→ 单库扛不住读
2. 单点故障 → 主库挂了全完
3. 备份慢 → 大库备份几个小时
✅ 主从复制解决:
1. 读写分离 → 主写从读,扛 10 倍读
2. 主库挂了从库顶上 → 高可用
3. 从库备份不影响主库
Q2:MySQL 怎么同步的?
3 步流程:主库写 binlog → 从库拉 binlog → 从库重放。
主库 从库
┌────────┐ binlog ┌────────┐
│ 应用写 │ ──────────────► │ IO 线程 │ 拉
│ 数据 │ └────────┘
└────┬───┘ │
│ ▼
▼ ┌────────┐
┌────────┐ │relay log│
│ binlog │ └────┬───┘
└────────┘ │
▼
┌────────┐
│SQL 线程│ 执行
└────┬───┘
▼
┌────────┐
│ 从库数据│
└────────┘
🛠 怎么用(实际业务场景)
业务场景 1:搭建主从复制
ini
# ============ 主库配置(my.cnf)============
[mysqld]
server-id = 1 # 主库 ID
log_bin = /var/log/mysql/mysql-bin.log # 开启 binlog
binlog_format = ROW # 推荐 ROW 格式
binlog_do_db = mydb # 只同步 mydb 库(可选)
# ============ 从库配置(my.cnf)============
[mysqld]
server-id = 2 # 从库 ID(必须不同)
relay_log = /var/log/mysql/relay-bin.log
read_only = 1 # 从库只读
sql
-- ============ 主库:创建复制账号 ============
CREATE USER 'repl'@'192.168.1.%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'192.168.1.%';
FLUSH PRIVILEGES;
-- 查看主库状态(记下 File 和 Position)
SHOW MASTER STATUS;
-- File: mysql-bin.000001
-- Position: 154
-- ============ 从库:配置复制 ============
CHANGE MASTER TO
master_host = '192.168.1.100',
master_user = 'repl',
master_password = 'password',
master_log_file = 'mysql-bin.000001',
master_log_pos = 154;
-- 启动复制
START SLAVE;
-- 查看复制状态
SHOW SLAVE STATUS\G
-- Slave_IO_Running: Yes
-- Slave_SQL_Running: Yes
-- Seconds_Behind_Master: 0(延迟秒数)
业务场景 2:解决主从延迟
java
// ============ 问题:从库延迟 5 秒 ============
// 用户下单(写主库)→ 立刻查订单(查从库)→ 查不到!
// ✅ 方案 1:读主库(最简单)
@Service
public class OrderService {
public Order getById(Long id) {
// 强一致场景:读主库
return orderMapper.selectById(id); // 走主库
}
public List<Order> listByUser(Long userId) {
// 非强一致:读从库
return orderMapper.selectByUserIdFromSlave(userId);
}
}
// ✅ 方案 2:延迟感知(读不到就降级读主库)
@Service
public class OrderService {
@Autowired
private DataSource masterDataSource;
public Order getByIdWithDelayCheck(Long id) {
// 先查从库
Order order = slaveJdbcTemplate.queryForObject(...);
if (order == null) {
// 从库没数据,可能是延迟
// 降级读主库
order = masterJdbcTemplate.queryForObject(...);
}
return order;
}
}
// ✅ 方案 3:写入后"强制读主库"(短时间标记)
@Service
public class OrderService {
public Order createOrder(OrderDTO dto) {
Order order = orderMapper.insert(dto);
// 把订单 ID 存到 Redis,标记"刚写入"
redisTemplate.opsForValue().set(
"fresh:order:" + order.getId(),
"1",
5, // 5 秒内强制读主库
TimeUnit.SECONDS
);
return order;
}
public Order getById(Long id) {
if (redisTemplate.hasKey("fresh:order:" + id)) {
// 5 秒内的订单,读主库
return masterJdbcTemplate.queryForObject(...);
}
return slaveJdbcTemplate.queryForObject(...);
}
}
业务场景 3:MySQL 8.0 并行复制(解决延迟)
ini
# ============ 从库配置:开启并行复制 ============
[mysqld]
# 8.0 默认开启 WRITESET 并行复制
slave_parallel_workers = 8 # 8 个 SQL 线程并行执行
slave_parallel_type = LOGICAL_CLOCK # 按库/表并行
# 5.7 用 DATABASE 并行(按库并行,库多才有效)
# slave_parallel_type = DATABASE
主从延迟监控:
sql
-- 查看主从延迟
SHOW SLAVE STATUS\G
-- Seconds_Behind_Master: 0(关键字段)
-- > 5 秒就要警惕,> 30 秒就是事故
-- 监控脚本(每分钟跑一次)
SELECT
CASE
WHEN Seconds_Behind_Master IS NULL THEN '复制中断'
WHEN Seconds_Behind_Master < 5 THEN '正常'
WHEN Seconds_Behind_Master < 30 THEN '警告'
ELSE '严重延迟'
END AS status
FROM (
SHOW SLAVE STATUS
) AS s;
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 主库大事务(删除 1000 万行) | 从库延迟几小时 | 分批删除(LIMIT 10000) |
| 从库算复杂 SQL | 延迟 + 占用资源 | 从库只做简单读 |
| 主从 binlog 格式不匹配 | 复制失败 | 都用 ROW 格式 |
| 复制账号权限不足 | IO 线程报错 | 给 REPLICATION SLAVE 权限 |
什么时候用:
- ✅ 读多写少 → 主从 + 读写分离
- ✅ 单库压力太大 → 主从分摊
- ✅ 需要高可用 → 主从 + 主库挂了从库顶上
- ❌ 强一致场景(订单支付)→ 不适合主从分离
💡 一句话总结
主从复制 = 主库写 binlog + 从库 IO 拉 + SQL 线程执行 ;
解决延迟:并行复制 + 强制读主库 + 业务降级;Seconds_Behind_Master > 5 秒要警惕。
Q7:场景题:慢 SQL 怎么排查和优化?
📖 是什么
慢 SQL = 执行时间超过预期(如 > 1 秒)的 SQL 。
类比:体检发现"血脂偏高",要找出是"吃太多油"还是"代谢慢"。
关键概念解释(专业词提前讲清楚):
- 慢查询日志:MySQL 记录"跑得慢"的 SQL 的日志
- EXPLAIN:SQL 执行计划工具,看 MySQL 怎么执行这条 SQL
- type(访问类型):ALL(全表扫描,最差)/ index(扫索引)/ range(范围)/ ref(非唯一索引等值)/ const(主键等值,最优)
- rows(扫描行数):MySQL 估算要扫描多少行(越少越好)
- Extra(额外信息) :包含
Using filesort(要排序)、Using temporary(要用临时表)等
🤔 为什么
Q1:为什么要排查慢 SQL?
一个慢 SQL 能拖垮整个数据库。
案例:一条慢 SQL 把数据库打挂
SQL:SELECT * FROM t_order WHERE user_id = 123 AND status = 0
场景:t_order 有 1 亿行,user_id 没建索引
结果:单条 SQL 跑 30 秒,连接池被占满,所有请求排队
修复:加索引后,单条 SQL 跑 50ms,吞吐量提升 600 倍
Q2:慢 SQL 怎么找出来的?
3 步:开启慢查询日志 → 用工具分析 → EXPLAIN 看执行计划。
🛠 怎么用(实际业务场景)
业务场景 1:完整排查流程
bash
# ============ 步骤 1:开启慢查询日志 ============
# my.cnf
[mysqld]
# 慢查询阈值(超过 1 秒就记录)
slow_query_log = 1
long_query_time = 1
slow_query_log_file = /var/log/mysql/slow.log
# 重启 MySQL
systemctl restart mysql
sql
-- ============ 步骤 2:查看最慢的 10 条 SQL ============
-- 用 mysqldumpslow 工具(Linux 命令行)
-- mysqldumpslow -s t -n 10 /var/log/mysql/slow.log
-- 输出:
-- Count: 100 Time=30.5s (3050s) Lock=0.0s Rows=1.0 User=app
-- SELECT * FROM t_order WHERE user_id = N AND status = N
-- ============ 步骤 3:找到具体的慢 SQL ============
SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 10;
sql
-- ============ 步骤 4:EXPLAIN 看执行计划 ============
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 AND status = 0;
EXPLAIN 关键字段解读:
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 AND status = 0;
-- 输出:
-- id select_type table type possible_keys key rows Extra
-- 1 SIMPLE t_order ALL NULL NULL 1亿 Using where
-- ↑ ↑ ↑
没索引 扫全表 1亿行
sql
-- ============ 步骤 5:加索引 + 验证 ============
CREATE INDEX idx_user_status ON t_order(user_id, status);
-- 重新 EXPLAIN
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 AND status = 0;
-- type = ref rows = 100 key = idx_user_status
-- 扫描 1 亿行 → 扫描 100 行!
业务场景 2:10 个常见慢 SQL 优化技巧
sql
-- ============ 技巧 1:避免 SELECT * ============
-- ❌ 慢
SELECT * FROM t_user WHERE id = 1;
-- ✅ 快(只查需要的列,可能走覆盖索引)
SELECT id, name FROM t_user WHERE id = 1;
-- ============ 技巧 2:避免在索引列上做计算 ============
-- ❌ 索引失效
SELECT * FROM t_user WHERE DATE(create_time) = '2026-07-17';
-- ✅ 用范围查询
SELECT * FROM t_user
WHERE create_time >= '2026-07-17 00:00:00'
AND create_time < '2026-07-18 00:00:00';
-- ============ 技巧 3:避免函数运算 ============
-- ❌ 索引失效
SELECT * FROM t_user WHERE SUBSTRING(name, 1, 3) = 'Tom';
-- ✅ 用前缀索引
CREATE INDEX idx_name_prefix ON t_user(name(3));
SELECT * FROM t_user WHERE name LIKE 'Tom%';
-- ============ 技巧 4:小表驱动大表(IN vs EXISTS)============
-- ✅ 小表用 IN
SELECT * FROM t_order WHERE user_id IN (SELECT id FROM t_user WHERE vip = 1);
-- ✅ 大表用 EXISTS
SELECT * FROM t_order o WHERE EXISTS (
SELECT 1 FROM t_user u WHERE u.id = o.user_id AND u.vip = 1
);
-- ============ 技巧 5:深分页优化 ============
-- ❌ LIMIT 1000000, 10(扫描 100 万 + 10)
SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10;
-- ✅ 用主键定位("延迟关联")
SELECT * FROM t_order o
INNER JOIN (SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10) t
ON o.id = t.id;
-- ============ 技巧 6:批量插入代替循环单条 ============
-- ❌ 慢(1000 次 INSERT)
for (int i = 0; i < 1000; i++) {
jdbcTemplate.update("INSERT INTO t VALUES (?)", i);
}
-- ✅ 快(1 次批量)
jdbcTemplate.batchUpdate("INSERT INTO t VALUES (?)", list);
-- ============ 技巧 7:避免不必要的 ORDER BY ============
-- ❌ 业务上不需要排序
SELECT * FROM t_order WHERE user_id = 123 ORDER BY id;
-- ✅ 业务上根本不用排,删掉 ORDER BY
SELECT * FROM t_order WHERE user_id = 123;
-- ============ 技巧 8:合理用 UNION 代替 OR ============
-- ❌ OR 可能导致索引失效
SELECT * FROM t_user WHERE name = 'Tom' OR age = 25;
-- ✅ UNION ALL(两个查询都用索引)
SELECT * FROM t_user WHERE name = 'Tom'
UNION ALL
SELECT * FROM t_user WHERE age = 25;
-- ============ 技巧 9:避免 % 开头的 LIKE ============
-- ❌ 索引失效
SELECT * FROM t_user WHERE name LIKE '%Tom%';
-- ✅ 前缀匹配
SELECT * FROM t_user WHERE name LIKE 'Tom%';
-- ============ 技巧 10:NOT IN 改成 NOT EXISTS ============
-- ❌ 慢
SELECT * FROM t_user WHERE id NOT IN (SELECT user_id FROM t_blacklist);
-- ✅ 快
SELECT * FROM t_user u WHERE NOT EXISTS (
SELECT 1 FROM t_blacklist b WHERE b.user_id = u.id
);
业务场景 3:排查工具推荐
bash
# ============ 工具 1:mysqldumpslow(MySQL 自带)============
# 找出最慢的 10 条 SQL
mysqldumpslow -s t -n 10 /var/log/mysql/slow.log
# ============ 工具 2:pt-query-digest(Percona 出品)============
# 更强大,能分析慢日志、binlog、general log
pt-query-digest /var/log/mysql/slow.log > report.txt
# ============ 工具 3:SHOW PROCESSLIST(实时查看)============
SHOW FULL PROCESSLIST;
-- 找 State 列有 "Sending data"、"Sorting result"、"Copying to tmp table" 的(正在执行的慢 SQL)
-- Id 字段是线程 ID,可以 KILL 掉
KILL 12345;
慢 SQL 排查流程总结:
1. 开启慢查询日志(long_query_time = 1)
2. 找出最慢的 TOP 10 SQL(mysqldumpslow)
3. 对每条 SQL 跑 EXPLAIN
4. 看 type(ALL = 全表扫描 = 优化重点)
5. 看 rows(扫描行数,越少越好)
6. 看 Extra(Using filesort / Using temporary 要优化)
7. 加索引 / 改 SQL / 加缓存
8. 重新 EXPLAIN 验证
什么时候用:
- ✅ 所有新上线的 SQL 都跑一遍 EXPLAIN
- ✅ 生产环境开启慢查询日志(监控告警)
- ✅ 慢 SQL 优化:先看 type(ALL 必须改),再看 rows,最后看 Extra
- ❌ 不要用
SELECT *(永远只查需要的列)
💡 一句话总结
慢 SQL 排查 = 开慢查询日志 → 找 TOP 10 → EXPLAIN 看执行计划 → 加索引/改 SQL ;
重点看 type(ALL = 灾难) 、rows(扫描行数) 、Extra(Using filesort/temporary)。
Q8:场景题:千万级数据怎么分库分表?
📖 是什么
分库分表 = 把"一个大表"拆成"多个小表/多个库" 。
类比:公司人多了 → 拆成多个部门(分库),每个部门再按业务分组(分表)。
关键概念解释(专业词提前讲清楚):
- 垂直拆分:按"列"拆(如用户表拆成"用户基本信息"+"用户详细信息")
- 水平拆分:按"行"拆(如订单表按 user_id 拆成 10 张表)
- 分库键(Sharding Key):决定数据分到哪个库/表的字段(如 user_id)
- 数据迁移:把老库数据搬到新库(分库分表最难的一步)
- Sharding-JDBC:当当开源的客户端分库分表中间件(应用层)
- MyCat:阿里开源的代理层分库分表中间件(独立服务)
🤔 为什么
Q1:单库单表为什么扛不住?
3 大瓶颈:
❌ 单库单表问题:
1. 单表数据太大 → 索引 10GB,B+ 树 4 层,查询 4 次 IO → 慢
2. 单库连接数有限 → MySQL 默认 151 个连接 → 并发上不去
3. 单库磁盘 IO 瓶颈 → 10000 TPS 后磁盘 IO 100% → 卡死
Q2:垂直拆分和水平拆分怎么选?
垂直拆"业务",水平拆"数据"。
✅ 垂直拆分(按业务):
- 用户库(user_db)→ t_user、t_user_profile
- 订单库(order_db)→ t_order、t_order_item
- 商品库(product_db)→ t_product、t_sku
目的:业务解耦,不同业务不同库
✅ 水平拆分(按数据):
- 订单表 → t_order_0、t_order_1、...、t_order_15(按 user_id hash 拆 16 张)
目的:单表数据太多,水平切分
Q3:分库分表后会带来什么新问题?
4 大难题:
1. 分布式 ID → 不能用自增,要用雪花算法
2. 跨库 JOIN → 禁止,改成"应用层 JOIN"或冗余字段
3. 跨库事务 → 不用,改成最终一致性(MQ + 补偿)
4. 排序分页 → 不能跨库排序,改成"各库查 + 内存排序"
🛠 怎么用(实际业务场景)
业务场景 1:Sharding-JDBC 配置分库分表
xml
<!-- 引入 Sharding-JDBC -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>5.1.0</version>
</dependency>
yaml
# application.yml
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3
ds0:
url: jdbc:mysql://192.168.1.1:3306/order_db_0
ds1:
url: jdbc:mysql://192.168.1.2:3306/order_db_0
ds2:
url: jdbc:mysql://192.168.1.3:3306/order_db_1
ds3:
url: jdbc:mysql://192.168.1.4:3306/order_db_1
rules:
sharding:
# ============ 订单表分库分表策略 ============
tables:
t_order:
# 真实节点:ds0.t_order_0, ds0.t_order_1, ds1.t_order_0...
actual-data-nodes: ds$->{0..3}.t_order_$->{0..3}
# 库策略:user_id % 2(拆 2 个库)
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-inline
# 表策略:order_id % 4(拆 4 张表)
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: t-order-inline
sharding-algorithms:
db-inline:
type: INLINE
props:
# 表达式:user_id % 2
algorithm-expression: ds$->{user_id % 2}
t-order-inline:
type: INLINE
props:
# 表达式:order_id % 4
algorithm-expression: t_order_$->{order_id % 4}
java
// ============ 使用:和单库单表一样 ============
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper; // Sharding-JDBC 自动路由
public void createOrder(Order order) {
// 自动根据 order_id 路由到对应库表
orderMapper.insert(order);
}
public Order getById(Long orderId) {
// 自动根据 order_id 路由到对应库表
return orderMapper.selectById(orderId);
}
// ⚠️ 注意:必须带 sharding column
public List<Order> getByUserId(Long userId) {
return orderMapper.selectByUserId(userId); // 自动按 user_id 路由
}
}
业务场景 2:分库分表后 ID 怎么生成?
java
// ❌ 错误:还用自增 ID(会重复)
CREATE TABLE t_order (id BIGINT AUTO_INCREMENT PRIMARY KEY);
// ✅ 正确:用雪花算法(分布式唯一)
@Component
public class IdGenerator {
// 雪花算法参数
private final long workerId = 1L; // 机器 ID(0-31)
private final long datacenterId = 1L; // 数据中心 ID(0-31)
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨处理
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成 ID");
}
// 同一毫秒内,sequence 自增
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 12 位 sequence
if (sequence == 0) {
// 同一毫秒 sequence 用完,等下一毫秒
timestamp = waitNextMillis();
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// 拼成 64 位:1 位符号 + 41 位时间戳 + 5 位数据中心 + 5 位机器 + 12 位序列
return ((timestamp - 0L) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
}
业务场景 3:分库分表后数据迁移
bash
# ============ 方案 1:停机迁移(最简单)============
# 1. 晚上 0 点停服
# 2. mysqldump 导出老库
mysqldump -uroot -p old_db t_order > t_order.sql
# 3. 导入到新分片
for shard in 0 1 2 3; do
mysql -uroot -p new_db_${shard} < t_order_${shard}.sql
done
# 4. 改配置,启用新库
# 5. 验证,起服
# ============ 方案 2:双写迁移(不停机)============
# 阶段 1:双写(老库 + 新库都写)
# 阶段 2:历史数据迁移(用工具,Canal/Maxwell 监听 binlog)
# 阶段 3:数据校验(对比老库和新库)
# 阶段 4:切读流量到新库
# 阶段 5:停老库写入
业务场景 4:分库分表后 4 大问题解决方案
java
// ============ 问题 1:跨库 JOIN 怎么办?============
public class UserOrderQuery {
// ❌ 跨库 JOIN(分库分表后禁止)
String sql1 = "SELECT o.*, u.name FROM t_order o "
+ "JOIN t_user u ON o.user_id = u.id "
+ "WHERE o.user_id = 123";
// ✅ 方案 A:冗余字段(最常用)
// t_order 表加 name 字段,下单时同步存
@Data
public class Order {
private Long orderId;
private Long userId;
private String userName; // 冗余字段
}
// ✅ 方案 B:应用层 JOIN(次常用)
public List<OrderVO> getOrdersWithUser(Long userId) {
// 1. 查订单(分库分表)
List<Order> orders = orderMapper.selectByUserId(userId);
if (orders.isEmpty()) return new ArrayList<>();
// 2. 批量查用户(不分库分表,按主键批量查)
List<Long> userIds = orders.stream()
.map(Order::getUserId).distinct().collect(toList());
List<User> users = userMapper.selectByIds(userIds);
// 3. 应用层组装
Map<Long, User> userMap = users.stream()
.collect(toMap(User::getId, u -> u));
return orders.stream().map(o -> {
OrderVO vo = new OrderVO();
BeanUtils.copyProperties(o, vo);
vo.setUserName(userMap.get(o.getUserId()).getName());
return vo;
}).collect(toList());
}
}
// ============ 问题 2:跨库事务怎么办?============
public class TransferService {
// ❌ 分布式事务(性能差)
@GlobalTransactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// 用户库和订单库分开了,本地事务管不到
}
// ✅ 最终一致性(MQ 异步)
public void transferAsync(Long fromUserId, Long toUserId, BigDecimal amount) {
// 1. 本地事务扣 A 的钱
accountMapper.deduct(fromUserId, amount);
// 2. 发 MQ 消息(事务消息 + 订单库订阅)
mqProducer.send("transfer-topic", new TransferMessage(
fromUserId, toUserId, amount
));
// 3. 订单库消费 MQ,加 B 的钱
// 即使失败,重试即可(最终一致)
}
}
// ============ 问题 3:跨库分页怎么办?============
public class OrderQuery {
// ❌ 分库分表后 LIMIT 不可用
String sql = "SELECT * FROM t_order ORDER BY order_id LIMIT 100000, 10";
// ✅ 方案:各库查 + 内存排序
public List<Order> pageQuery(int pageNo, int pageSize) {
// 1. 每个分片查 pageNo * pageSize 条
int shardingPageSize = pageNo * pageSize;
List<Order> result = new ArrayList<>();
for (int i = 0; i < 4; i++) { // 4 张表
result.addAll(orderShardMapper.selectFromShard(i, shardingPageSize));
}
// 2. 内存排序
result.sort(Comparator.comparing(Order::getOrderId));
// 3. 取 pageNo 这一页
return result.stream()
.skip((pageNo - 1) * pageSize)
.limit(pageSize)
.collect(toList());
}
}
分库分表决策树:
你的表有多少数据?
├─ < 500 万 → 不需要分库分表(加索引就够)
├─ 500 万 ~ 5000 万 → 考虑分表
│ ├─ 单表查询慢 → 水平分表
│ └─ 单表写入慢 → 水平分表
├─ 5000 万 ~ 5 亿 → 分库分表
└─ > 5 亿 → 分库分表 + 归档历史数据
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 分库键选错(如用 status) | 数据分布严重不均 | 选查询最频繁的字段(如 user_id) |
| 分库分表后用自增 ID | ID 冲突 | 用雪花算法(Snowflake) |
| 跨库 JOIN | 性能极差 | 冗余字段 / 应用层 JOIN |
| 跨库事务 | 数据不一致 | 最终一致性(MQ + 补偿) |
| 一次查不带 sharding column | 全分片扫描 | 必须带分库键查询 |
什么时候用:
- ✅ 单表 > 500 万 + 查询明显变慢 → 分表
- ✅ 单库 > 5000 万 + 并发上不去 → 分库
- ✅ 业务上有"租户隔离" → 按租户 ID 分库
- ❌ 不到 500 万不要分(徒增复杂度)
💡 一句话总结
分库分表 = 单表 > 500 万 的解决方案;
用 Sharding-JDBC 水平拆分,分库键必须带 ;
4 大难题:分布式 ID(雪花)、跨库 JOIN(冗余字段)、跨库事务(MQ 最终一致)、跨库分页(内存排序)。
3.2 SQLServer
Q1:SQLServer 与 MySQL 的事务隔离、锁机制区别?
📖 是什么
SQLServer 和 MySQL 都能做"事务 + 锁",但实现细节不一样 。
类比:两个品牌的汽车都能开,但油门、刹车手感不同。
关键概念解释(专业词提前讲清楚):
- 事务隔离级别:决定多个事务并发时互相"看不见"的程度
- 锁粒度:锁的范围(行级/页级/表级)
- 行版本控制(Row Versioning):SQLServer 独有的并发控制(类似 MVCC)
- RCSI(Read Committed Snapshot Isolation):SQLServer 读已提交 + 行版本
- SI(Snapshot Isolation):SQLServer 快照隔离
- 锁升级:SQLServer 会把"很多行锁"自动升级成"表锁"(MySQL 不会)
🤔 为什么
Q1:为什么 SQLServer 也有"隔离级别"?
因为并发事务会遇到同样问题(脏读、不可重复读、幻读)。
Q2:SQLServer 和 MySQL 隔离级别有什么区别?
SQLServer 有行版本控制(更接近 MVCC),默认行为不同。
| 隔离级别 | MySQL InnoDB | SQLServer | 默认 |
|---|---|---|---|
| READ UNCOMMITTED | 支持 | 支持 | ❌ |
| READ COMMITTED | 支持 | 支持(默认,但 RCSI 模式用行版本) | SQLServer 默认 |
| REPEATABLE READ | 支持(默认) | 支持 | MySQL 默认 |
| SERIALIZABLE | 支持 | 支持 | ❌ |
| SNAPSHOT | ❌ 不支持 | ✅ 支持(SQLServer 独有) | ❌ |
Q3:SQLServer 锁机制和 MySQL 有什么不同?
SQLServer 会自动锁升级,MySQL 不会。
SQLServer 锁升级(自动):
- 同一事务里更新 5000 行 → 自动升级为表锁
- 后果:整个表被锁,其他人全卡住
MySQL 不会升级:
- 一直保持行级锁
- 后果:锁数量多,但并发性能好
🛠 怎么用(实际业务场景)
业务场景 1:SQLServer 开启 RCSI(推荐)
sql
-- ============ 开启 RCSI:数据库级 ============
-- RCSI = Read Committed Snapshot Isolation
-- 开启后,READ COMMITTED 隔离级别自动用"行版本"(不阻塞读)
ALTER DATABASE MyDB SET READ_COMMITTED_SNAPSHOT ON;
-- 验证
SELECT name, is_read_committed_snapshot_on
FROM sys.databases WHERE name = 'MyDB';
sql
-- ============ 测试:开启 RCSI 后,READ COMMITTED 不阻塞 ============
-- 会话 1:开启事务,修改数据不提交
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRAN;
UPDATE t_user SET name = 'Tom' WHERE id = 1;
-- 不提交!
-- 会话 2:能读到旧数据(行版本控制)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM t_user WHERE id = 1;
-- 返回:name = 'Jack'(不阻塞!)
业务场景 2:避免锁升级
sql
-- ============ 关闭锁升级(单表)============
ALTER TABLE t_order SET (LOCK_ESCALATION = DISABLE);
-- 或者用提示强制不升级
BEGIN TRAN;
UPDATE t_order SET status = 1 WHERE order_date >= '2026-01-01';
-- 上面这条可能触发锁升级
-- 解决:分批更新
DECLARE @BatchSize INT = 1000;
DECLARE @Rows INT = 1;
WHILE @Rows > 0
BEGIN
UPDATE TOP (@BatchSize) t_order
SET status = 1
WHERE order_date >= '2026-01-01' AND status = 0;
SET @Rows = @@ROWCOUNT;
WAITFOR DELAY '00:00:01'; -- 暂停 1 秒,让别人能进来
END
COMMIT;
业务场景 3:SQLServer 的死锁排查
sql
-- ============ 1. 开启死锁跟踪 ============
DBCC TRACEON(1222, -1); -- 1222 把死锁信息写到错误日志
-- ============ 2. 实时查看死锁 ============
SELECT
session_id, -- 哪个会话
blocking_session_id, -- 被谁阻塞
wait_type, -- 等待类型(LCK_M_X = 排他锁等待)
wait_time, -- 等待时间(毫秒)
wait_resource, -- 等的资源(KEY = 行锁)
text -- 正在执行的 SQL
FROM sys.dm_exec_requests
CROSS APPLY sys.dm_exec_sql_text(sql_handle)
WHERE blocking_session_id <> 0;
-- ============ 3. 看历史死锁(SQLServer Profiler 或 Extended Events)============
-- 推荐用 Extended Events
CREATE EVENT SESSION DeadlockMonitor ON SERVER
ADD EVENT sqlserver.xml_deadlock_report
ADD TARGET package0.event_file(SET filename=N'DeadlockMonitor');
ALTER EVENT SESSION DeadlockMonitor ON SERVER STATE = START;
-- 4. KILL 死锁的牺牲品
KILL 54; -- 54 是 session_id
SQLServer vs MySQL 死锁对比:
java
// ============ 共同点:死锁都是"循环等待" ============
// 事务 A 锁住 X 等 Y,事务 B 锁住 Y 等 X
// 解决:保持一致的加锁顺序
// ============ 差异:===========
// MySQL:InnoDB 自动检测死锁,回滚代价小的事务
// SQLServer:默认也自动检测,但锁升级可能让死锁更严重
// SQLServer 特有的死锁:页锁竞争
// 多个事务同时 UPDATE 同一页(不同行)→ 竞争页锁
// 解决:升级为行锁(用索引命中具体的行)
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 没开 RCSI | READ COMMITTED 阻塞读 | 业务库都开 RCSI |
| 大批量 UPDATE 触发锁升级 | 整个表卡死 | 分批更新(每次 1000 行) |
| 索引失效导致锁表 | 比 MySQL 更严重 | 关键 SQL 必加索引 |
| 跨库查询 | 性能极差 | 禁止跨库 JOIN |
什么时候用:
- ✅ 新业务库都开 RCSI(避免读阻塞)
- ✅ 大批量操作分批执行
- ✅ 高频写表关闭锁升级
- ❌ 不开 RCSI 用默认隔离级别(性能差)
💡 一句话总结
SQLServer 隔离级别和 MySQL 类似,但默认 RC(不开 RCSI 就阻塞读) ;
特有锁升级 问题(5000 行 → 表锁),解决:分批更新;RCSI 必开。
Q2:SQLServer 索引类型有哪些?怎么选?
📖 是什么
SQLServer 索引和 MySQL 类似,但类型更丰富 。
类比:MySQL 是"瑞士军刀",SQLServer 是"专业工具箱"。
关键概念解释(专业词提前讲清楚):
- 聚集索引(Clustered Index):数据按索引顺序物理存储(类似 MySQL 聚簇索引)
- 非聚集索引(Non-Clustered Index):索引和数据分开存(类似 MySQL 二级索引)
- 覆盖索引(Covering Index) :查询的列全在索引里(用
INCLUDE关键字) - 筛选索引(Filtered Index) :只索引"部分行"(如
WHERE status = 1) - 列存储索引(Columnstore Index):按列存储,适合 OLAP 报表
🤔 为什么
Q1:SQLServer 索引和 MySQL 有什么不同?
SQLServer 多 3 类索引:覆盖索引、筛选索引、列存储索引。
| 索引类型 | MySQL | SQLServer | 适用场景 |
|---|---|---|---|
| 聚集索引(聚簇) | ✅ | ✅ | 一张表只能有一个(主键默认) |
| 非聚集索引(二级) | ✅ | ✅ | 普通查询优化 |
| 覆盖索引 | ✅(联合索引) | ✅(INCLUDE 语法) |
不用回表 |
| 筛选索引 | ❌ | ✅ | 只查"部分数据"(如活跃用户) |
| 列存储索引 | ❌ | ✅ | 数据仓库、聚合查询 |
Q2:什么时候用 SQLServer 特有索引?
3 个场景:覆盖索引、筛选索引、列存储索引。
🛠 怎么用(实际业务场景)
业务场景 1:覆盖索引(INCLUDE)
sql
-- 表结构
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
user_id BIGINT,
order_date DATETIME,
amount DECIMAL(10,2)
);
CREATE INDEX idx_user_id ON t_order(user_id);
-- ❌ 慢:查 amount 要回表
SELECT order_date, amount FROM t_order WHERE user_id = 123;
-- 执行计划:Index Seek → Key Lookup(回表)
-- ✅ 快:建覆盖索引(INCLUDE 把 amount 加到索引里)
CREATE INDEX idx_user_include ON t_order(user_id) INCLUDE (order_date, amount);
-- INCLUDE 的列不参与排序,只在叶子节点存"值"
-- 适合"非 WHERE 条件但要 SELECT 的列"
-- 重新查:直接走索引,不用回表
SELECT order_date, amount FROM t_order WHERE user_id = 123;
-- 执行计划:Index Seek(无 Key Lookup)
业务场景 2:筛选索引(Filtered Index)
sql
-- 业务场景:99% 的订单是"已完成",只有 1% 是"待处理"
-- 我们 99% 的查询都是查"待处理"订单
-- 表结构
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
user_id BIGINT,
status TINYINT -- 0=待处理 1=已完成 2=已取消
);
-- ❌ 普通索引:所有行都索引
CREATE INDEX idx_status ON t_order(status);
-- 索引有 1 亿行,但 99% 都是 status=1(浪费空间)
-- ✅ 筛选索引:只索引"待处理"订单
CREATE INDEX idx_pending ON t_order(user_id) WHERE status = 0;
-- 索引只有 100 万行(节省 99% 空间!)
-- 查询"待处理"订单:极快
SELECT * FROM t_order WHERE user_id = 123 AND status = 0;
-- 走 idx_pending,扫描 100 万行里的 1 条
业务场景 3:列存储索引(OLAP 报表)
sql
-- 表结构(订单明细 10 亿行)
CREATE TABLE t_order (
id BIGINT,
user_id BIGINT,
product_id BIGINT,
amount DECIMAL(10,2),
order_date DATETIME
);
-- ✅ 列存储索引:适合"按列聚合"(如"统计每个用户的总金额")
CREATE COLUMNSTORE INDEX idx_columnstore ON t_order(user_id, product_id, amount);
-- 查询:按 user_id 聚合
SELECT user_id, SUM(amount) AS total
FROM t_order
GROUP BY user_id;
-- 列存储索引比行存储快 100 倍(因为只读需要的列)
-- 适用:数据仓库、报表、聚合
-- 不适用:高并发 OLTP(INSERT/UPDATE 慢)
SQLServer vs MySQL 索引对比:
sql
-- ============ 联合索引(两者都有)============
-- MySQL
CREATE INDEX idx_a_b ON t(a, b);
-- SQLServer(语法一样)
CREATE INDEX idx_a_b ON t(a, b);
-- ============ 覆盖索引 ============
-- MySQL:把列加到联合索引末尾
CREATE INDEX idx_a_b_c ON t(a, b, c);
-- SQLServer:用 INCLUDE(更灵活,INCLUDE 列不参与排序)
CREATE INDEX idx_a ON t(a) INCLUDE (b, c);
-- ============ 筛选索引 ============
-- MySQL:不支持
-- SQLServer:支持
CREATE INDEX idx_active ON t(user_id) WHERE status = 1;
-- ============ 列存储索引 ============
-- MySQL:不支持(除非用 ClickHouse、Doris)
-- SQLServer:支持
CREATE COLUMNSTORE INDEX idx_cs ON t(col1, col2, col3);
索引选择建议:
1. 查"高频 + 少部分数据" → 筛选索引
例:WHERE status = 0
2. 查"特定列" + 不需要其他字段 → 覆盖索引
例:SELECT user_id, order_date WHERE user_id = 123
3. 查"聚合/报表" → 列存储索引
例:GROUP BY、SUM、AVG
4. 范围查询/排序 → 普通索引
例:WHERE order_date BETWEEN ... ORDER BY order_date
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 覆盖索引 INCLUDE 太多列 | 索引太大 | INCLUDE ≤ 5 个常用列 |
| 筛选索引用错字段 | 索引失效 | WHERE 条件要和筛选条件一致 |
| 列存储索引用于 OLTP | INSERT 慢 | 列存储只用于报表库 |
| 主键用 GUID | 聚集索引碎片化 | 主键用 INT/BIGINT 自增 |
什么时候用:
- ✅ OLTP 业务用聚集索引(主键)+ 非聚集索引
- ✅ 高频"等值 + 回表"用覆盖索引
- ✅ "少数数据"查询用筛选索引
- ✅ 报表库用列存储索引
- ❌ 不要为不常用查询建索引
💡 一句话总结
SQLServer 索引多 3 类:覆盖索引(INCLUDE 避免回表) 、筛选索引(只索引部分行) 、列存储索引(OLAP 报表) ;
选型:OLTP 用 B+ 树,OLAP 用列存储。
Q3:SQLServer 执行计划怎么分析?
📖 是什么
执行计划 = SQLServer 打算怎么执行你的 SQL 。
类比:GPS 导航告诉你"先走京港澳,再转沪昆"------是路线图。
关键概念解释(专业词提前讲清楚):
- 执行计划(Execution Plan):SQLServer 优化器生成的"执行步骤"
- 实际执行计划:SQL 真正执行后的统计(包含真实行数)
- 估计执行计划:优化器"猜"的执行计划(不执行 SQL)
- 扫描(Scan):从头到尾扫(顺序读)
- 查找(Seek):按索引直接定位(随机读,比 Scan 快)
- Lookup:用非聚集索引找到主键后,再回聚集索引查数据
- 成本(Cost):优化器估算的"代价"(CPU + IO)
🤔 为什么
Q1:为什么要看执行计划?
慢 SQL 调优全靠执行计划,不看就是"盲调"。
没看执行计划:
"我猜这条 SQL 慢是因为数据多"
调了半天没用
看了执行计划:
"哦,Index Scan 是全索引扫描,加个索引就能变 Index Seek"
一行 SQL 解决
Q2:SQLServer 执行计划怎么查?
2 种方式:SSMS 图形化、SQL Server Profiler。
🛠 怎么用(实际业务场景)
业务场景 1:3 步看懂执行计划
sql
-- ============ 步骤 1:打开"实际执行计划" ============
-- SSMS:Ctrl + M
-- 或在查询窗口加:SET STATISTICS IO ON;
-- ============ 步骤 2:看每个操作的"图标" ============
-- Index Scan(索引扫描):全索引扫(不太好)
-- Index Seek(索引查找):按索引直接定位(好)
-- Key Lookup(键查找):回表(不太好,要尽量避免)
-- Table Scan(表扫描):全表扫(最差)
-- Sort(排序):如果有 Using filesort 类似的东西,要优化
-- Hash Match / Nested Loops / Merge Join(JOIN 方式)
-- ============ 步骤 3:看"成本"占比 ============
-- 鼠标悬停每个操作,会显示 CPU/IO 成本
-- 优化"成本占比最高"的那个
业务场景 2:常见执行计划案例
sql
-- ============ 案例 1:Table Scan(全表扫描,最差)============
SELECT * FROM t_user WHERE name = 'Tom';
-- 执行计划:Table Scan(扫全表)
-- 原因:name 字段没建索引
-- ✅ 解决
CREATE INDEX idx_name ON t_user(name);
-- 重新跑:Index Seek
sql
-- ============ 案例 2:Index Scan(索引扫描,不好)============
SELECT * FROM t_user WHERE name LIKE '%Tom%';
-- 执行计划:Index Scan(前缀模糊匹配走不了索引)
-- 原因:LIKE '%xxx' 前缀通配符
-- ✅ 解决
-- 1. 改用前缀匹配
SELECT * FROM t_user WHERE name LIKE 'Tom%';
-- 2. 用全文索引
CREATE FULLTEXT INDEX idx_name_fulltext ON t_user(name);
SELECT * FROM t_user WHERE CONTAINS(name, 'Tom');
sql
-- ============ 案例 3:Key Lookup(回表,常见)============
-- 索引只有 name,但 SELECT 要查 age
CREATE INDEX idx_name ON t_user(name);
SELECT name, age FROM t_user WHERE name = 'Tom';
-- 执行计划:Index Seek(idx_name)→ Key Lookup(聚集索引)
-- ✅ 解决:覆盖索引
CREATE INDEX idx_name_age ON t_user(name) INCLUDE (age);
-- 重新跑:Index Seek(无 Key Lookup)
sql
-- ============ 案例 4:Sort(排序,可能慢)============
SELECT * FROM t_user ORDER BY name;
-- 执行计划:Index Scan → Sort
-- 原因:数据没按 name 排序,要"内存排序"
-- ✅ 解决:让索引覆盖排序列
CREATE INDEX idx_name ON t_user(name);
-- 重新跑:Index Scan(无 Sort,因为索引已经排好序)
业务场景 3:用 STATISTICS IO 看具体 IO
sql
-- ============ 开启 IO 统计 ============
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- 跑 SQL
SELECT * FROM t_user WHERE name = 'Tom';
-- 输出:
-- Table 't_user'. Scan count 1, logical reads 5000
-- ↑ 表名 ↑ 扫描次数 ↑ 逻辑读次数
--
-- logical reads = 5000 表示读了 5000 个页
-- 优化目标:减少 logical reads
业务场景 4:强制/禁止索引(HINT)
sql
-- ============ 强制用某个索引 ============
SELECT * FROM t_user WITH (INDEX(idx_name)) WHERE name = 'Tom';
-- ============ 强制不走索引(测试用)============
SELECT * FROM t_user WITH (INDEX(0)) WHERE name = 'Tom';
-- INDEX(0) 表示强制用堆(不建索引时的存储方式)
-- ============ 强制锁粒度(高级)============
SELECT * FROM t_user WITH (ROWLOCK) WHERE name = 'Tom';
-- ROWLOCK:强制行锁(即使 SQLServer 打算用页锁)
SELECT * FROM t_user WITH (TABLOCK) WHERE name = 'Tom';
-- TABLOCK:强制表锁(适合大批量导入)
SQLServer vs MySQL 执行计划对比:
| 维度 | MySQL | SQLServer |
|---|---|---|
| 查看方式 | EXPLAIN SQL |
SSMS 图形化 / EXPLAIN |
| 关键字段 | type、rows、Extra | 图形节点 + 成本占比 |
| 强制索引 | FORCE INDEX |
WITH (INDEX(...)) |
| 实际统计 | EXPLAIN ANALYZE(8.0) |
实际执行计划(Ctrl+M) |
| 文本格式 | EXPLAIN FORMAT=JSON |
SHOWPLAN_TEXT |
sql
-- SQLServer 看文本执行计划
SET SHOWPLAN_TEXT ON;
GO
SELECT * FROM t_user WHERE name = 'Tom';
GO
SET SHOWPLAN_TEXT OFF;
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
用 %xxx% 模糊查询 |
Index Scan(扫全索引) | 用全文索引或前缀匹配 |
| SELECT * | 必回表 | 只查需要的列 |
| 索引列用函数 | 索引失效 | 改写为范围查询 |
| 在 SSMS 里不显示执行计划 | 看不出慢在哪 | 习惯 Ctrl+M |
什么时候用:
- ✅ 慢 SQL 必看执行计划
- ✅ 上线前新 SQL 跑一次执行计划
- ✅ 看"成本占比最高"的操作
- ❌ 不要"凭感觉"调优
💡 一句话总结
SQLServer 调优全靠执行计划 (SSMS 图形化 / Ctrl+M);
重点看 Index Seek(优)vs Index Scan(差) 、Key Lookup(回表) 、Sort(排序) ;
优化目标:减少 logical reads(逻辑读次数)。
Q4:临时表、表变量、CTE 适用场景?
📖 是什么
临时表、表变量、CTE 都是"中间结果集" ,但适用场景不同。
类比:临时表是"草稿纸"(能反复看),表变量是"便利贴"(贴上就用),CTE 是"手写公式"(一次性)。
关键概念解释(专业词提前讲清楚):
- 临时表(Temp Table) :以
#开头,存在 tempdb,会话结束自动删除 - 表变量(Table Variable) :以
@开头,存在内存,会话结束自动删除 - CTE(Common Table Expression) :用
WITH ... AS (...)定义的"临时查询" - tempdb:SQLServer 的系统数据库,存所有临时表
- 统计信息(Statistics):SQLServer 优化器用的"数据分布信息"
🤔 为什么
Q1:为什么有 3 种"中间结果集"?
因为不同场景需要不同的性能和灵活性。
临时表:能建索引、写统计信息 → 适合"复杂查询/大量数据"
表变量:无统计信息 → 适合"小数据 + 简单查询"
CTE:不存数据 → 适合"递归查询/简化 SQL"
Q2:3 者的性能差异?
临时表 > CTE > 表变量(对大量数据而言)。
| 特性 | 临时表 | 表变量 | CTE |
|---|---|---|---|
| 存储位置 | tempdb | 内存 | 不存储(视图) |
| 能建索引 | ✅ | ❌(只能 PK) | ❌ |
| 统计信息 | ✅ | ❌ | ❌ |
| 适合行数 | 任意 | < 1000 行 | 任意 |
| 能递归 | ❌ | ❌ | ✅ |
| 事务回滚 | ✅ 支持 | ❌ 不支持 | ✅ 支持 |
🛠 怎么用(实际业务场景)
业务场景 1:临时表(适合复杂 + 大数据)
sql
-- ============ 场景:复杂报表统计 ============
-- 1. 创建临时表 + 建索引
CREATE TABLE #temp_user_order (
user_id BIGINT,
order_count INT,
total_amount DECIMAL(18,2),
INDEX idx_user_id (user_id) -- 临时表也能建索引!
);
-- 2. 灌数据
INSERT INTO #temp_user_order (user_id, order_count, total_amount)
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM t_order
WHERE order_date >= '2026-01-01'
GROUP BY user_id;
-- 3. 再关联用户表
SELECT u.name, t.order_count, t.total_amount
FROM t_user u
JOIN #temp_user_order t ON u.id = t.user_id
WHERE t.total_amount > 1000
ORDER BY t.total_amount DESC;
-- 4. 自动清理(会话结束删除)
DROP TABLE #temp_user_order;
业务场景 2:表变量(适合小数据 + 简单)
sql
-- ============ 场景:传一个"用户 ID 列表"给存储过程 ============
-- 1. 定义表变量
DECLARE @user_ids TABLE (user_id BIGINT PRIMARY KEY);
-- 2. 灌数据(小数据,< 1000 行)
INSERT INTO @user_ids (user_id)
SELECT id FROM t_user WHERE vip = 1;
-- 3. 关联查询
SELECT o.*
FROM t_order o
JOIN @user_ids u ON o.user_id = u.user_id;
-- 注意:表变量没有统计信息,SQLServer 总是假设只有 1 行
-- 数据多时优化器会"误判",导致全表扫描
-- 所以表变量只适合小数据!
业务场景 3:CTE(适合递归 + 简化)
sql
-- ============ 场景 1:递归查询(树形结构)============
-- 表结构:员工表,manager_id 指向上级
CREATE TABLE t_employee (
id INT PRIMARY KEY,
name VARCHAR(50),
manager_id INT -- 上级 ID
);
-- 查"Tom 的所有下属(包括下属的下属)"
WITH EmployeeCTE AS (
-- 锚点:Tom 本人
SELECT id, name, manager_id, 0 AS level
FROM t_employee
WHERE name = 'Tom'
UNION ALL
-- 递归:查 Tom 下属
SELECT e.id, e.name, e.manager_id, c.level + 1
FROM t_employee e
JOIN EmployeeCTE c ON e.manager_id = c.id
)
SELECT * FROM EmployeeCTE
WHERE level > 0; -- 排除 Tom 本人
-- ============ 场景 2:CTE 简化复杂 SQL ============
-- 不用 CTE:嵌套子查询难读
SELECT *
FROM (
SELECT user_id, SUM(amount) AS total
FROM t_order
WHERE order_date >= '2026-01-01'
GROUP BY user_id
) o
WHERE o.total > 1000;
-- 用 CTE:清晰
WITH UserOrderTotal AS (
SELECT user_id, SUM(amount) AS total
FROM t_order
WHERE order_date >= '2026-01-01'
GROUP BY user_id
)
SELECT *
FROM UserOrderTotal
WHERE total > 1000;
3 者的最佳实践:
sql
-- ============ 决策树 ============
-- 数据 < 1000 行 + 简单查询 → 表变量
-- 数据 > 1000 行 + 复杂查询 → 临时表(能建索引!)
-- 递归查询 / 简化 SQL → CTE
-- 多次复用同一中间结果 → 临时表
业务场景 4:临时表 vs 表变量性能对比
sql
-- ============ 准备 100 万行数据 ============
SELECT TOP 1000000
id = IDENTITY(INT, 1, 1),
user_id = ABS(CHECKSUM(NEWID())) % 10000,
amount = ABS(CHECKSUM(NEWID())) % 1000
INTO #big_table
FROM sys.all_columns a, sys.all_columns b;
-- ============ 方案 A:临时表(有统计信息)============
CREATE TABLE #t1 (user_id INT, amount INT);
INSERT INTO #t1 SELECT user_id, amount FROM #big_table;
-- 优化器知道 #t1 有 100 万行 → 生成最优计划
SELECT user_id, SUM(amount) FROM #t1 GROUP BY user_id;
-- 耗时:~500ms
-- ============ 方案 B:表变量(无统计信息)============
DECLARE @t2 TABLE (user_id INT, amount INT);
INSERT INTO @t2 SELECT user_id, amount FROM #big_table;
-- 优化器以为 @t2 只有 1 行 → 生成糟糕计划(嵌套循环)
SELECT user_id, SUM(amount) FROM @t2 GROUP BY user_id;
-- 耗时:~5000ms(慢 10 倍!)
-- ============ 结论:大量数据用临时表 ============
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 表变量灌 100 万行 | 性能灾难 | 改用临时表 |
| CTE 套 CTE 套 5 层 | 难调试 | 拆成临时表 |
| 临时表忘记删 | 占用 tempdb | 用完 DROP TABLE |
| 临时表建太多索引 | 写入慢 | 只建必要的索引 |
什么时候用:
- ✅ 大量数据 + 复杂查询 → 临时表
- ✅ 小数据 + 简单查询 → 表变量
- ✅ 递归查询 / 简化 SQL → CTE
- ❌ 不要用表变量处理大数据
- ❌ 不要用 CTE 套 3 层以上
💡 一句话总结
临时表 (有统计信息、能建索引)→ 大数据;
表变量 (无统计信息)→ 小数据(< 1000 行);
CTE(不存数据)→ 递归查询 + 简化 SQL。
Q5:场景题:MySQL 迁移到 SQLServer 怎么操作?
📖 是什么
MySQL 迁移到 SQLServer = 把"一种数据库的数据和结构"搬到"另一种数据库" 。
类比:从 Windows 电脑换到 Mac,要"导数据 + 装软件 + 改习惯"。
关键概念解释(专业词提前讲清楚):
- 数据迁移:把老库的数据导入新库
- Schema 转换 :把 MySQL 的
CREATE TABLE转换成 SQLServer 的 - SQL 方言差异 :MySQL 的
LIMITvs SQLServer 的TOP - SSMA(SQL Server Migration Assistant):微软官方的迁移工具
- 双写期:新老库同时写入一段时间,保证数据一致
🤔 为什么
Q1:为什么要从 MySQL 迁到 SQLServer?
业务需要(用 .NET 生态、对接 SQLServer BI 报表、合规要求)。
Q2:迁移最大难点是什么?
3 大难点:SQL 方言、数据类型、事务/锁差异。
| 差异 | MySQL | SQLServer | 处理方法 |
|---|---|---|---|
| 自增 ID | AUTO_INCREMENT |
IDENTITY(1,1) |
转换 |
| 字符串 | VARCHAR(n) |
NVARCHAR(n) |
改成 NVARCHAR(支持中文) |
| 分页 | LIMIT 10 OFFSET 20 |
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY |
重写 SQL |
| 日期 | DATETIME |
DATETIME2 |
用 DATETIME2 |
| 布尔 | TINYINT(1) |
BIT |
转换 |
| 枚举 | ENUM('a','b') |
不支持 | 改成 VARCHAR + CHECK |
| IF EXISTS | DROP TABLE IF EXISTS |
不支持 | 写 IF OBJECT_ID(...) IS NOT NULL |
🛠 怎么用(实际业务场景)
业务场景 1:用 SSMA 工具迁移(推荐)
bash
# ============ 步骤 1:下载 SSMA ============
# 微软官网:SQL Server Migration Assistant for MySQL
# 下载地址:https://www.microsoft.com/en-us/sql-server/sql-server-migration-assistant
# ============ 步骤 2:创建 SSMA 项目 ============
# File → New Project
# 选择目标:SQL Server 2019(或你的版本)
# ============ 步骤 3:连接 MySQL 源库 ============
# Connect to MySQL
# Host: 192.168.1.100
# Port: 3306
# User: root
# Password: ******
# ============ 步骤 4:连接 SQLServer 目标库 ============
# Connect to SQL Server
# 同上
# ============ 步骤 5:转换 Schema ============
# 1. 选 MySQL 库
# 2. 右键 → Convert Schema
# 3. SSMA 自动把 CREATE TABLE 转换成 SQLServer 语法
# 4. 看转换报告(有警告的需要人工处理)
# ============ 步骤 6:迁移数据 ============
# 1. 右键 MySQL 表 → Migrate Data
# 2. 选择目标库
# 3. 等待迁移完成
# 4. 验证数据(行数 + 抽样对比)
业务场景 2:手写 Schema 转换(关键差异)
sql
-- ============ MySQL 建表语句 ============
CREATE TABLE t_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(100),
status TINYINT(1) DEFAULT 1, -- 布尔
bio TEXT,
balance DECIMAL(10,2),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============ SQLServer 等价语句 ============
CREATE TABLE t_user (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
name NVARCHAR(50) NOT NULL, -- VARCHAR → NVARCHAR
email NVARCHAR(100),
status BIT DEFAULT 1, -- TINYINT(1) → BIT
bio NVARCHAR(MAX), -- TEXT → NVARCHAR(MAX)
balance DECIMAL(10,2),
created_at DATETIME2 DEFAULT GETDATE(), -- DATETIME → DATETIME2
CONSTRAINT uk_email UNIQUE (email) -- UNIQUE KEY → CONSTRAINT
);
业务场景 3:SQL 语法批量转换
sql
-- ============ 1. 分页:MySQL → SQLServer ============
-- MySQL
SELECT * FROM t_user ORDER BY id LIMIT 10 OFFSET 20;
-- SQLServer 2012+(推荐)
SELECT * FROM t_user ORDER BY id
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- SQLServer 2008(旧版本)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM t_user
) t WHERE rn BETWEEN 21 AND 30;
-- ============ 2. 日期函数 ============
-- MySQL
SELECT DATE_FORMAT(created_at, '%Y-%m') FROM t_user;
SELECT NOW();
SELECT DATEDIFF('2026-07-17', created_at) FROM t_user;
-- SQLServer
SELECT FORMAT(created_at, 'yyyy-MM') FROM t_user;
SELECT GETDATE();
SELECT DATEDIFF(DAY, created_at, '2026-07-17') FROM t_user;
-- ============ 3. 字符串函数 ============
-- MySQL
SELECT CONCAT('Hello', ' ', 'World'); -- CONCAT
SELECT SUBSTRING(name, 1, 3); -- SUBSTRING
SELECT LENGTH(name); -- LENGTH(字符数)
-- SQLServer
SELECT 'Hello' + ' ' + 'World'; -- + 号拼接(NULL 会变 NULL!)
SELECT SUBSTRING(name, 1, 3); -- SUBSTRING(一样)
SELECT LEN(name); -- LEN(字符数)
-- ============ 4. IFNULL → ISNULL ============
-- MySQL
SELECT IFNULL(name, 'unknown') FROM t_user;
-- SQLServer
SELECT ISNULL(name, 'unknown') FROM t_user;
-- ============ 5. LIMIT → TOP / OFFSET ============
-- MySQL
SELECT * FROM t_user LIMIT 1;
-- SQLServer
SELECT TOP 1 * FROM t_user;
业务场景 4:双写迁移(不停机)
java
// ============ 阶段 1:双写(1-2 周)============
@Service
public class UserService {
@Autowired
private MySqlUserRepository mysqlRepo;
@Autowired
private SqlServerUserRepository sqlServerRepo;
public void save(User user) {
// 1. 先写 MySQL(保证老业务正常)
mysqlRepo.save(user);
// 2. 再写 SQLServer(异步 + 重试)
try {
sqlServerRepo.save(user);
} catch (Exception e) {
// 写入失败,发到重试队列
retryQueue.add(user);
log.error("SQLServer 写入失败,进入重试队列", e);
}
}
public User getById(Long id) {
// 读 MySQL
return mysqlRepo.findById(id).orElse(null);
}
}
// ============ 阶段 2:历史数据全量迁移 ============
// 用 SSMA 迁移,或手写 ETL
// 验证:MySQL 行数 == SQLServer 行数
// ============ 阶段 3:切读流量到 SQLServer(观察 1-2 周)============
@Service
public class UserService {
public User getById(Long id) {
// 切到读 SQLServer
User user = sqlServerRepo.findById(id).orElse(null);
if (user == null) {
// 降级读 MySQL(防止 SQLServer 漏数据)
user = mysqlRepo.findById(id).orElse(null);
}
return user;
}
}
// ============ 阶段 4:停 MySQL 写入(1 周观察)============
// 1. 把 save() 只写 SQLServer
// 2. 保留 MySQL 只读
// 3. 监控 1 周没问题
// ============ 阶段 5:下线 MySQL =============
业务场景 5:性能对比与优化
sql
-- ============ 关键性能调优(迁移后必做)============
-- 1. 开启 RCSI
ALTER DATABASE MyDB SET READ_COMMITTED_SNAPSHOT ON;
-- 2. 关闭锁升级(高频写表)
ALTER TABLE t_order SET (LOCK_ESCALATION = DISABLE);
-- 3. 重建索引(迁移后索引可能碎片化)
ALTER INDEX ALL ON t_user REBUILD;
-- 4. 更新统计信息
UPDATE STATISTICS t_user WITH FULLSCAN;
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| VARCHAR 没改 NVARCHAR | 中文乱码 | 全部用 NVARCHAR |
| 大量数据用表变量 | 性能差 | 用临时表 |
| 没开 RCSI | 读阻塞写 | 业务库都开 RCSI |
MySQL 的 LIMIT m, n 直接转 |
语法错误 | 改成 OFFSET/FETCH |
| 双写时序错乱(MySQL 写完,SQLServer 失败) | 数据丢失 | 重试 + 监控 |
什么时候用:
- ✅ 中小数据量用 SSMA 一键迁移
- ✅ 大数据量用 ETL 工具(DataX、Kettle)
- ✅ 必须双写(避免停机)
- ❌ 不要"裸迁"(直接停 MySQL → 切 SQLServer),风险极大
💡 一句话总结
MySQL 迁 SQLServer = SSMA 工具 + SQL 语法改写 + 双写验证 ;
关键差异:VARCHAR → NVARCHAR 、LIMIT → OFFSET 、TINYINT(1) → BIT ;
流程:迁移 Schema → 灌数据 → 双写 1-2 周 → 切读 → 切写 → 下线老库。
3.3 SQL 调优
Q1:SQL 调优常见手段有哪些?
📖 是什么
SQL 调优 = 让 SQL 跑得更快 (从 5 秒优化到 50ms)。
类比:汽车保养 + 改装------加机油(基础维护) + 换轮胎(性能优化)。
关键概念解释(专业词提前讲清楚):
- SQL 调优金字塔:从"不改 SQL"到"改架构",越往下收益越大、难度也越大
- 执行计划:数据库怎么执行 SQL 的"路线图"
- 索引优化:加合适的索引,让 SQL 走索引而不是全表扫描
- SQL 改写:把慢 SQL 改成等价的快 SQL(如用 JOIN 代替子查询)
🤔 为什么
Q1:为什么要 SQL 调优?
因为一条慢 SQL 能拖垮整个数据库。
案例:双 11 大促
平时:订单 SQL 跑 100ms
大促:流量涨 10 倍,SQL 变 5 秒
后果:连接池被占满,所有请求排队,整个系统挂掉
调优后:SQL 跑 50ms,撑过双 11
Q2:SQL 调优的优先级?
金字塔模型:从下到上投入产出比从高到低。
🔺 SQL 改写(投入小、收益中) ← 改 1 条 SQL,可能快 10 倍
🔺 索引优化(投入小、收益大) ← 加 1 个索引,可能快 100 倍
🔻 架构优化(投入大、收益中) ← 分库分表
🔻 硬件升级(投入大、收益小) ← 换 SSD、加内存
🛠 怎么用(实际业务场景)
业务场景 1:SQL 调优 10 大手段
sql
-- ============ 手段 1:加索引(最有效)============
-- ❌ 全表扫描
SELECT * FROM t_user WHERE name = 'Tom';
-- ✅ 加索引
CREATE INDEX idx_name ON t_user(name);
-- ============ 手段 2:避免 SELECT * ============
-- ❌ 查所有列
SELECT * FROM t_user WHERE id = 1;
-- ✅ 只查需要的列
SELECT id, name FROM t_user WHERE id = 1;
-- ============ 手段 3:避免在索引列上做运算 ============
-- ❌ 函数运算(索引失效)
SELECT * FROM t_user WHERE DATE(create_time) = '2026-07-17';
-- ✅ 范围查询
SELECT * FROM t_user
WHERE create_time >= '2026-07-17' AND create_time < '2026-07-18';
-- ============ 手段 4:用覆盖索引避免回表 ============
-- 表:id, name, age(有 id 主键索引,name 单列索引)
-- ❌ 回表
SELECT name, age FROM t_user WHERE name = 'Tom';
-- ✅ 覆盖索引
CREATE INDEX idx_name_age ON t_user(name, age);
-- 叶子节点直接有 name + age,不用回表
-- ============ 手段 5:小表驱动大表 ============
-- ❌ 大表 IN
SELECT * FROM t_order WHERE user_id IN (SELECT id FROM t_user);
-- ✅ 小表驱动(用 JOIN)
SELECT o.* FROM t_order o
INNER JOIN t_user u ON o.user_id = u.id;
-- ============ 手段 6:深分页优化 ============
-- ❌ LIMIT 1000000, 10(扫 100 万 + 10)
SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10;
-- ✅ 延迟关联
SELECT * FROM t_order o
INNER JOIN (SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10) t
ON o.id = t.id;
-- ============ 手段 7:批量操作代替循环 ============
-- ❌ 1000 次单条 INSERT
for (User u : users) {
jdbcTemplate.update("INSERT INTO t_user VALUES (?)", u);
}
-- ✅ 1 次批量 INSERT
jdbcTemplate.batchUpdate("INSERT INTO t_user VALUES (?)", users);
-- ============ 手段 8:用 EXISTS 代替 IN(大数据时)============
-- ❌ IN(先查子查询,再关联)
SELECT * FROM t_user WHERE id IN (SELECT user_id FROM t_order WHERE amount > 1000);
-- ✅ EXISTS(边查边判断)
SELECT u.* FROM t_user u
WHERE EXISTS (SELECT 1 FROM t_order o WHERE o.user_id = u.id AND o.amount > 1000);
-- ============ 手段 9:避免不必要的排序 ============
-- ❌ 业务上不需要排序
SELECT * FROM t_user ORDER BY name;
-- 业务上不分页、不知道顺序 → 删掉 ORDER BY
-- ============ 手段 10:用 UNION ALL 代替 UNION(不需要去重时)============
-- ❌ UNION(会去重,耗性能)
SELECT name FROM t_user WHERE vip = 1
UNION
SELECT name FROM t_user WHERE age > 60;
-- ✅ UNION ALL(不去重,快)
SELECT name FROM t_user WHERE vip = 1
UNION ALL
SELECT name FROM t_user WHERE age > 60;
业务场景 2:调优步骤(5 步法)
sql
-- ============ 步骤 1:定位慢 SQL ============
-- 开慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
-- 看最慢的 10 条
-- mysqldumpslow -s t -n 10 /var/log/mysql/slow.log
-- ============ 步骤 2:EXPLAIN 看执行计划 ============
EXPLAIN SELECT * FROM t_order WHERE user_id = 123;
-- 看 type(ALL = 全表扫描 = 优化重点)
-- 看 rows(扫描行数)
-- 看 Extra(Using filesort / Using temporary)
-- ============ 步骤 3:加索引 / 改 SQL ============
CREATE INDEX idx_user_id ON t_order(user_id);
-- ============ 步骤 4:验证 ============
EXPLAIN SELECT * FROM t_order WHERE user_id = 123;
-- type = ref,rows = 100
-- 真实跑一次,确认时间缩短
-- ============ 步骤 5:上线 + 监控 ============
-- 上线后监控:
-- - QPS、TPS 是否提升
-- - 没有出现新的慢 SQL
-- - 数据库 CPU、IO 没有异常
业务场景 3:调优案例(完整过程)
sql
-- ============ 案例:订单查询接口慢 ============
-- 用户反馈:订单列表加载 5 秒
-- 定位:慢查询日志
-- Time: 5.2s
-- SELECT * FROM t_order WHERE user_id = 123 AND status = 0 ORDER BY create_time DESC LIMIT 20;
-- ============ 第一步:EXPLAIN ============
EXPLAIN SELECT * FROM t_order
WHERE user_id = 123 AND status = 0
ORDER BY create_time DESC LIMIT 20;
-- type = ALL(全表扫描)
-- rows = 10000000(1000 万行)
-- Extra = Using where; Using filesort; Using temporary
-- 灾难!
-- ============ 第二步:加索引 ============
-- 联合索引:user_id(等值)+ status(等值)+ create_time(排序)
CREATE INDEX idx_user_status_time ON t_order(user_id, status, create_time DESC);
-- ============ 第三步:EXPLAIN 验证 ============
EXPLAIN SELECT * FROM t_order
WHERE user_id = 123 AND status = 0
ORDER BY create_time DESC LIMIT 20;
-- type = ref
-- rows = 20
-- Extra = NULL(完美,没有 filesort)
-- 索引完美匹配!
-- ============ 第四步:真实测试 ============
-- 优化前:5.2 秒
-- 优化后:0.02 秒
-- 提升 260 倍!
业务场景 4:数据库层面调优
ini
# ============ my.cnf 关键参数 ============
[mysqld]
# InnoDB 缓冲池(最重要)
innodb_buffer_pool_size = 10G # 物理内存的 60-80%
# 日志大小
innodb_log_file_size = 2G # 默认 48MB 太小
# 连接数
max_connections = 1000 # 根据业务调整
# 慢查询日志
slow_query_log = 1
long_query_time = 1
sql
-- ============ 表结构层面 ============
-- 1. 用合适的数据类型
-- ❌ 用 BIGINT 存手机号
phone_number BIGINT
-- ✅ 用 VARCHAR(11)
phone_number VARCHAR(11)
-- 2. 用 NOT NULL(节省存储 + 优化器更准确)
-- ❌ 允许 NULL
name VARCHAR(50) NULL
-- ✅ 强制 NOT NULL
name VARCHAR(50) NOT NULL
-- 3. 用数字类型代替字符串
-- ❌ 用 VARCHAR 存状态
status VARCHAR(20) -- 'active', 'inactive'
-- ✅ 用 TINYINT
status TINYINT -- 0=inactive, 1=active
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 加太多索引 | 写入慢 | 按查询需要建 |
| 索引失效(运算/函数) | 等于没加 | 避免在索引列上做运算 |
| 没 EXPLAIN 就上线 | 慢 SQL 上线 | 上线前必跑 EXPLAIN |
| 调优后没监控 | 不知道效果 | 看 QPS、RT、慢查询数 |
什么时候用:
- ✅ 所有上线 SQL 必跑 EXPLAIN
- ✅ 慢 SQL 必走 5 步法(定位 → EXPLAIN → 优化 → 验证 → 监控)
- ✅ 定期 review 慢查询日志
- ❌ 不要"凭感觉"调优
💡 一句话总结
SQL 调优 = *加索引(最有效)+ 改 SQL(避免运算/SELECT )+ 改架构 ;
流程:慢查询日志定位 → EXPLAIN 看计划 → 加索引/改 SQL → 验证 → 监控。
Q2:EXPLAIN 关键字段怎么解读?
📖 是什么
EXPLAIN = MySQL 的"执行计划说明书" 。
类比:导航地图------告诉你"先走哪条路、扫多少行、有没有堵车"。
关键概念解释(专业词提前讲清楚):
- id:SQL 执行顺序(id 越大越先执行,id 相同按顺序)
- select_type:查询类型(SIMPLE / SUBQUERY / DERIVED / UNION)
- type(访问类型):连接类型(性能从高到低:system > const > eq_ref > ref > range > index > ALL)
- possible_keys:可能用到的索引
- key:实际用到的索引(NULL = 没用到索引)
- rows:估算要扫描的行数(越少越好)
- Extra:额外信息(Using filesort / Using temporary / Using index)
🤔 为什么
Q1:为什么要懂 EXPLAIN?
慢 SQL 调优全靠 EXPLAIN,不懂 EXPLAIN = 盲调。
Q2:type 字段怎么看?
type 决定性能(从最优到最差):
type 性能天梯:
system > const > eq_ref > ref > range > index > ALL
↑
全表扫描(最差)
🛠 怎么用(实际业务场景)
业务场景 1:EXPLAIN 完整示例
sql
EXPLAIN SELECT * FROM t_order o
INNER JOIN t_user u ON o.user_id = u.id
WHERE o.status = 0 AND o.create_time >= '2026-07-01'
ORDER BY o.create_time DESC
LIMIT 20;
+----+-------------+-------+-------+-----------------+---------+---------+-----------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+-----------------+---------+---------+-----------+------+-------------+
| 1 | SIMPLE | o | range | idx_status_time | idx_st | 4 | NULL | 1000 | Using where |
| 1 | SIMPLE | u | const | PRIMARY | PRIMARY | 8 | o.user_id | 1 | NULL |
+----+-------------+-------+-------+-----------------+---------+---------+-----------+------+-------------+
逐字段解读:
id = 1:
- 只有一个查询(如果是子查询,会有 1 和 2)
- id 相同按顺序执行
select_type = SIMPLE:
- 简单查询(不包含子查询、UNION)
- 复杂场景:SUBQUERY(子查询)/ DERIVED(临时表)/ UNION(UNION 查询)
table = o:
- 当前访问的表
- 多个表会有多行
type = range(o 表):
- 范围查询(status 和 create_time 在联合索引里做范围)
- 比 ALL 好,比 ref 差
type = const(u 表):
- 主键等值查询(通过 user_id 查主键)
- 性能最好
possible_keys = idx_status_time(o 表):
- 可能用到的索引(优化器"觉得"可以用)
- 不一定真用
key = idx_status_time(o 表):
- 实际用到的索引
- NULL = 全表扫描(灾难!)
key_len = 4(o 表):
- 索引使用的字节数
- 这里 4 字节 = INT 类型的 status
- 越短越好(说明用到的索引列越少)
ref = o.user_id(u 表):
- 哪一列或常量被用于索引查找
- 这里用 o 表的 user_id 关联
rows = 1000(o 表):
- 优化器估算要扫描的行数
- 越少越好
- 不是真实值(优化器估算可能不准)
Extra = Using where(o 表):
- 服务层用 WHERE 过滤
- 如果出现 Using filesort / Using temporary,要优化!
业务场景 2:10 种 type 类型详解
sql
-- ============ 1. system(最优,理论值)============
-- 表只有 1 行(= MyISAM 的系统表)
SELECT * FROM mysql.proc WHERE id = 1;
-- 几乎遇不到
-- ============ 2. const(极优)============
-- 主键或唯一索引等值查询
SELECT * FROM t_user WHERE id = 1;
-- type = const
-- ============ 3. eq_ref(极优)============
-- JOIN 时用主键或唯一索引
SELECT u.* FROM t_user u
JOIN t_order o ON u.id = o.user_id;
-- type = eq_ref(o 表的 user_id 是主键或唯一索引)
-- ============ 4. ref(优秀)============
-- 非唯一索引等值查询
SELECT * FROM t_user WHERE name = 'Tom';
-- name 建的是普通索引(非唯一)→ type = ref
-- ============ 5. range(良好)============
-- 索引范围查询
SELECT * FROM t_user WHERE age BETWEEN 20 AND 30;
-- type = range
-- ============ 6. index(一般)============
-- 全索引扫描(比 ALL 好,因为索引比数据小)
SELECT id FROM t_user;
-- 只查主键 → 走主键索引 → type = index
-- 但要避免!
-- ============ 7. ALL(最差,必须优化)============
-- 全表扫描
SELECT * FROM t_user WHERE name LIKE '%Tom%';
-- name 没索引 → type = ALL(灾难!)
业务场景 3:Extra 字段 8 种关键信息
sql
-- ============ 1. Using filesort(要优化)============
-- 排序不能在索引里完成,要"额外排序"
SELECT * FROM t_user WHERE age = 25 ORDER BY name;
-- age 索引,name 没在索引里 → Using filesort
-- ✅ 优化:建联合索引 (age, name)
CREATE INDEX idx_age_name ON t_user(age, name);
-- 重新查:Extra = NULL(完美)
-- ============ 2. Using temporary(要优化)============
-- 要用临时表(如 GROUP BY、DISTINCT)
SELECT name, COUNT(*) FROM t_user GROUP BY name;
-- name 没在索引里 → Using temporary
-- ✅ 优化:建索引
CREATE INDEX idx_name ON t_user(name);
-- 重新查:可能不再需要临时表
-- ============ 3. Using index(极好)============
-- 覆盖索引(不用回表)
SELECT id, name FROM t_user WHERE name = 'Tom';
-- 走 (name, id) 联合索引 → Using index
-- ============ 4. Using where(常见)============
-- 服务层用 WHERE 过滤
-- 不一定是坏事,但配合 type 一起看
-- ============ 5. Using join buffer(要优化)============
-- JOIN 没用索引,要用连接缓冲
-- 优化:JOIN 列建索引
-- ============ 6. Impossible WHERE(最优)============
-- WHERE 条件永远不成立
SELECT * FROM t_user WHERE 1 = 0;
-- 优化器直接返回 0 行
-- ============ 7. Select tables optimized away(极优)============
-- 用索引就能拿到结果,不用查表
SELECT MIN(id) FROM t_user;
-- 走主键索引就能拿到 → Select tables optimized away
-- ============ 8. Using index condition(ICP 索引下推)============
-- 存储引擎层用索引过滤
SELECT * FROM t_user WHERE name = 'Tom' AND age = 25;
-- 联合索引 (name, age) → Using index condition
业务场景 4:EXPLAIN 实战案例
sql
-- ============ 案例 1:ALL → ref 优化 ============
-- 优化前
EXPLAIN SELECT * FROM t_order WHERE user_id = 123;
-- type = ALL(user_id 没索引)
-- rows = 10000000
-- 加索引
CREATE INDEX idx_user_id ON t_order(user_id);
-- 优化后
EXPLAIN SELECT * FROM t_order WHERE user_id = 123;
-- type = ref
-- rows = 100
-- ============ 案例 2:Using filesort 优化 ============
-- 优化前
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 ORDER BY create_time;
-- type = ref
-- Extra = Using filesort
-- 加联合索引(包含排序列)
CREATE INDEX idx_user_time ON t_order(user_id, create_time);
-- 优化后
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 ORDER BY create_time;
-- type = ref
-- Extra = NULL(完美!)
-- ============ 案例 3:type = index → ALL 优化?============
-- 优化前
EXPLAIN SELECT id FROM t_user;
-- type = index(全索引扫描)
-- 看似还行,但其实可以更好
-- 优化:明确告诉优化器只要主键
EXPLAIN SELECT id FROM t_user WHERE id IS NOT NULL;
-- type = index(差不多)
-- 进一步优化:直接 SELECT MIN/MAX
EXPLAIN SELECT MIN(id) FROM t_user;
-- type = const 或 Select tables optimized away
MySQL 8.0 EXPLAIN ANALYZE(实际执行):
sql
-- MySQL 8.0+ 才支持
EXPLAIN ANALYZE SELECT * FROM t_order WHERE user_id = 123;
-- 输出包含:实际执行时间、循环次数等
-- 比 EXPLAIN 更准确(因为 EXPLAIN 是"估算")
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| type = ALL 不知道要优化 | 慢 SQL 上线 | ALL 必优化 |
| Extra 有 filesort 不处理 | 排序慢 | 加联合索引覆盖排序列 |
| 错把"Using where"当问题 | 过度优化 | 看 type 一起判断 |
| EXPLAIN 估的行数不准 | 误判性能 | MySQL 8.0 用 EXPLAIN ANALYZE |
什么时候用:
- ✅ 所有上线 SQL 必跑 EXPLAIN
- ✅ type = ALL 必须优化
- ✅ Extra 有 filesort/temporary 要优化
- ❌ 不要"凭感觉"调优
💡 一句话总结
EXPLAIN 关键看 type (ALL 最差、const 最好)、rows (越少越好)、Extra (filesort/temporary 要优化、Using index 极好);
优化目标:ALL → ref → range → const。
Q3:慢查询日志怎么开启和分析?
📖 是什么
慢查询日志 = MySQL 的"性能监控摄像头" ,专门记录"跑得慢"的 SQL。
类比:行车记录仪------专门录"出事故"的过程。
关键概念解释(专业词提前讲清楚):
- long_query_time:慢查询阈值(默认 10 秒,建议设 1 秒)
- slow_query_log:是否开启慢查询日志(ON/OFF)
- log_queries_not_using_indexes:是否记录"没走索引"的 SQL
- mysqldumpslow:MySQL 自带的慢查询分析工具
- pt-query-digest:Percona 出品的慢查询分析工具(更强大)
🤔 为什么
Q1:为什么要开慢查询日志?
因为没有监控就没有调优。
场景:用户说"系统慢"
没开慢查询日志:你只能"猜"哪条 SQL 慢
开了慢查询日志:直接看到"TOP 10 慢 SQL",对症下药
Q2:为什么生产环境必须开?
因为生产环境的 SQL"长得和测试不一样"(数据量、并发、参数都不同)。
🛠 怎么用(实际业务场景)
业务场景 1:开启慢查询日志
ini
# ============ my.cnf 配置 ============
[mysqld]
# 开启慢查询日志
slow_query_log = 1
# 慢查询阈值(执行超过 1 秒就记录)
long_query_time = 1
# 日志文件位置
slow_query_log_file = /var/log/mysql/slow.log
# 记录"没走索引"的 SQL(即使很快)
log_queries_not_using_indexes = 1
# 记录的管理命令(如 OPTIMIZE TABLE)
log_slow_admin_statements = 1
# 重启 MySQL
systemctl restart mysql
sql
-- ============ 动态开启(不用重启)============
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
-- 查看当前配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
业务场景 2:用 mysqldumpslow 分析
bash
# ============ 安装(MySQL 自带)============
# 通常在 /usr/bin/mysqldumpslow
# ============ 基础用法 ============
# 1. 看最慢的 10 条 SQL(按总时间排序)
mysqldumpslow -s t -n 10 /var/log/mysql/slow.log
# 2. 看出现次数最多的 10 条(按执行次数排序)
mysqldumpslow -s c -n 10 /var/log/mysql/slow.log
# 3. 看平均时间最长的 10 条
mysqldumpslow -s at -n 10 /var/log/mysql/slow.log
# 4. 看返回行数最多的 10 条
mysqldumpslow -s r -n 10 /var/log/mysql/slow.log
# 输出示例:
# Count: 100 Time=30.5s (3050s) Lock=0.0s Rows=1.0 User=app
# SELECT * FROM t_order WHERE user_id = N AND status = N
#
# 解读:
# Count = 100:这条 SQL 跑了 100 次
# Time = 30.5s:平均每次 30.5 秒
# (3050s):累计耗时 3050 秒
业务场景 3:用 pt-query-digest 分析(更强大)
bash
# ============ 安装 pt-query-digest ============
# Percona 官方工具
# https://www.percona.com/downloads/percona-toolkit
# 或 yum install percona-toolkit
# ============ 生成分析报告 ============
pt-query-digest /var/log/mysql/slow.log > report.txt
# ============ 报告内容示例 ============
# Profile
# Rank Query ID Response time Calls R/Call V/M Item
# ==== ================== ============== ====== ======= ===== ==============
# 1 0xABCDEF123456 3050.0s 60.0% 100 30.5s 0.5 SELECT t_order
# 2 0x123456ABCDEF 1500.0s 30.0% 500 3.0s 0.3 SELECT t_user
# 3 ...
#
# 解读:
# Rank 1(最严重的):占了 60% 的总慢查询时间
# 优先优化 Rank 1!
# ============ 高级用法 ============
# 1. 分析最近 1 小时的慢日志
pt-query-digest --since=1h /var/log/mysql/slow.log
# 2. 分析特定数据库的慢日志
pt-query-digest --filter '($event->{db} || "") =~ m/mydb/' /var/log/mysql/slow.log
# 3. 保存到数据库(长期分析)
pt-query-digest --review h=localhost,D=slow_log,t=global_query_review \
--no-report /var/log/mysql/slow.log
业务场景 4:日志格式详解
bash
# ============ 慢查询日志样例 ============
# Time: 2026-07-17T10:30:00.123456Z
# User@Host: app[app] @ [192.168.1.100] Id: 12345
# Query_time: 30.5 Lock_time: 0.001 Rows_sent: 1 Rows_examined: 10000000
# Schema: mydb Last_errno: 0 Killed: 0
# Bytes_sent: 1024
SET timestamp=1721212200;
SELECT * FROM t_order WHERE user_id = 123 AND status = 0;
# 逐行解读:
# Time: SQL 执行时间
# User@Host: 哪个用户从哪个 IP 连的
# Query_time: SQL 跑了多少秒(30.5 秒)
# Lock_time: 锁等待时间(0.001 秒)
# Rows_sent: 返回 1 行
# Rows_examined: 扫描了 1000 万行(灾难!)
# Schema: 哪个数据库
业务场景 5:监控告警
bash
# ============ 方案 1:crontab + 邮件告警 ============
#!/bin/bash
# /opt/scripts/check_slow_query.sh
SLOW_LOG="/var/log/mysql/slow.log"
THRESHOLD=10 # 超过 10 条就告警
COUNT=$(grep -c "Query_time" $SLOW_LOG)
if [ $COUNT -gt $THRESHOLD ]; then
echo "慢查询告警:当前 $COUNT 条" | mail -s "MySQL 慢查询告警" ops@company.com
fi
# 每天早上 8 点跑
# 0 8 * * * /opt/scripts/check_slow_query.sh
# ============ 方案 2:Prometheus + Grafana 监控 ============
# 1. 用 mysqld_exporter 暴露 MySQL 指标
# 2. Prometheus 抓取
# 3. Grafana 配置慢查询数量告警
# 告警规则:slow_queries > 100/min
# ============ 方案 3:实时告警(企业级)============
# 用 ELK + Filebeat 收集慢查询日志
# 用 Kibana 配置告警
业务场景 6:日志轮转(避免磁盘占满)
bash
# ============ 用 logrotate 轮转慢查询日志 ============
# /etc/logrotate.d/mysql
/var/log/mysql/slow.log {
daily # 每天轮转
rotate 7 # 保留 7 天
missingok
notifempty
compress # 压缩
postrotate
# 轮转后通知 MySQL 重新打开日志
mysql -e "SET GLOBAL slow_query_log = 'OFF'; SET GLOBAL slow_query_log = 'ON';"
endscript
}
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| 长期不开慢查询日志 | 不知道哪里慢 | 生产环境必开 |
| 阈值设太大(如 10 秒) | 漏掉很多慢 SQL | 建议 1 秒 |
| 不分析日志 | 慢 SQL 一直存在 | 用 pt-query-digest 每周分析 |
| 慢日志不轮转 | 磁盘占满 | 用 logrotate |
什么时候用:
- ✅ 生产环境必开慢查询日志
- ✅ 阈值设 1 秒(不是默认的 10 秒)
- ✅ 用 pt-query-digest 每周分析 TOP 10
- ✅ 监控慢查询数量,告警
- ❌ 不要设 0 秒(会记录所有 SQL,包括快的)
💡 一句话总结
慢查询日志 = MySQL 的"性能监控摄像头" ;
配置:slow_query_log = 1 + long_query_time = 1 ;
分析:mysqldumpslow(基础)/ pt-query-digest(推荐) ;
重点:Rows_examined(扫描行数) 比 Query_time 更能说明问题。
Q4:场景题:慢 SQL 完整排查流程
📖 是什么
完整排查流程 = "发现 → 定位 → 分析 → 优化 → 验证 → 监控"6 步法 。
类比:医生看病------"听症状(发现)→ 拍片(定位)→ 看报告(分析)→ 开药(优化)→ 复查(验证)→ 随访(监控)"。
关键概念解释(专业词提前讲清楚):
- 慢 SQL 报警阈值:业务自定义(如 RT > 1s)
- SQL 指纹(SQL Fingerprint):把 SQL 的"结构"提取出来(如 "SELECT * FROM t WHERE id = ?")
- pt-online-schema-change:Percona 的在线 DDL 工具(不锁表加索引)
🤔 为什么
Q1:为什么要"完整流程"?
跳过任何一步都可能导致"治标不治本"。
反例:用户反馈"订单查询慢"
→ 开发直接加索引(跳过分析)
→ 加错了索引(基于猜测)
→ 慢 SQL 没解决,还占用了索引空间
正例:完整流程
→ 慢查询日志定位 SQL
→ EXPLAIN 分析
→ 发现 type=ALL,rows=1000 万
→ 加 user_id 索引
→ 验证 type=ref,rows=100
→ 监控 QPS 恢复正常
🛠 怎么用(实际业务场景)
业务场景:订单查询接口慢,从报警到优化
bash
# ============ 第 1 步:发现(报警)============
# 监控告警:
# [10:00] MySQL 慢查询告警
# 慢 SQL 数量:100 条/分钟
# 持续时间:5 分钟
sql
-- ============ 第 2 步:定位(找出慢 SQL)============
-- 方法 1:看慢查询日志
mysqldumpslow -s t -n 5 /var/log/mysql/slow.log
-- 输出:
-- Count: 100 Time=30.5s (3050s) Lock=0.0s Rows=1.0 User=app
-- SELECT * FROM t_order WHERE user_id = N AND status = N
-- 看到这条 SQL 跑了 100 次,每次 30 秒
-- 方法 2:实时看正在执行的慢 SQL
SHOW FULL PROCESSLIST;
-- 找 State 列有 "Sending data"、"Sorting result" 的
sql
-- ============ 第 3 步:分析(EXPLAIN 执行计划)============
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 AND status = 0 ORDER BY create_time DESC LIMIT 20;
-- 输出:
-- id select_type table type possible_keys key rows Extra
-- 1 SIMPLE t_order ALL NULL NULL 1000万 Using where; Using filesort
-- 解读:
-- type = ALL → 全表扫描(必须优化)
-- rows = 1000万 → 扫描行数太多
-- key = NULL → 没走索引
-- Extra = Using filesort → 还要排序(要建联合索引覆盖排序列)
sql
-- ============ 第 4 步:优化(加索引)============
-- 1. 选联合索引列:WHERE 列 + 排序列
-- 2. 顺序:等值列(user_id、status)在前,排序列(create_time)在后
CREATE INDEX idx_user_status_time ON t_order(user_id, status, create_time DESC);
-- 注意:生产环境加索引可能锁表!
-- 解决方案:pt-online-schema-change(不锁表加索引)
-- pt-online-schema-change --alter "ADD INDEX idx_user_status_time (user_id, status, create_time)" \
-- --execute D=mydb,t=t_order
sql
-- ============ 第 5 步:验证(重新 EXPLAIN + 真实测试)============
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 AND status = 0 ORDER BY create_time DESC LIMIT 20;
-- 输出:
-- id select_type table type possible_keys key rows Extra
-- 1 SIMPLE t_order ref idx_user_status_time idx_user_status_time 20 NULL
-- 解读:
-- type = ref(优秀)
-- rows = 20(完美,从 1000 万 → 20)
-- Extra = NULL(没有 filesort,完美)
-- key = idx_user_status_time(用上了新索引)
sql
-- 真实 SQL 跑一次,确认时间
SET @start = NOW(6);
SELECT * FROM t_order WHERE user_id = 123 AND status = 0 ORDER BY create_time DESC LIMIT 20;
SELECT TIMESTAMPDIFF(MICROSECOND, @start, NOW(6)) / 1000 AS ms;
-- 优化前:30500 ms
-- 优化后:15 ms
-- 提升 2000 倍!
bash
# ============ 第 6 步:监控(确认没恶化)============
# 1. 监控指标
# - 慢查询数量:从 100/分钟 → 0/分钟
# - 数据库 QPS:从 100 → 5000
# - 平均 RT:从 30 秒 → 50ms
# - 数据库 CPU:从 90% → 30%
# 2. 持续观察 1-2 天
# 3. 写入慢查询日志归档(保留证据)
完整流程总结:
1. 发现(监控告警)
↓
2. 定位(慢查询日志 / SHOW PROCESSLIST)
↓
3. 分析(EXPLAIN)
↓
4. 优化(加索引 / 改 SQL)
↓
5. 验证(EXPLAIN + 真实测试)
↓
6. 监控(持续观察 1-2 天)
实际工作中的"踩坑点":
sql
-- ============ 坑 1:索引建错了 ============
-- 看 SQL 用了 user_id,索引也建在 user_id 上
-- 但表里有联合索引 (user_id, status),user_id 单列索引其实是冗余的
-- 解决:删掉单列索引,扩展联合索引
-- ============ 坑 2:加了索引但没生效 ============
-- 因为 SQL 用了函数
SELECT * FROM t_user WHERE DATE(create_time) = '2026-07-17';
-- 索引 (create_time) 失效
-- 解决:改成范围查询
SELECT * FROM t_user
WHERE create_time >= '2026-07-17' AND create_time < '2026-07-18';
-- ============ 坑 3:索引建在错的地方 ============
-- 查 user_id 最多,但索引建在 order_id 上
-- 解决:建在 user_id 上
-- ============ 坑 4:忽略了 ORDER BY ============
-- 只考虑 WHERE,没考虑 ORDER BY
-- 加了 WHERE 索引,但 ORDER BY 还要 filesort
-- 解决:建联合索引包含排序列
-- ============ 坑 5:索引碎片化 ============
-- 长期运行的表,索引碎片化严重
-- 即使索引存在,也扫得很慢
-- 解决:OPTIMIZE TABLE(生产用 pt-online-schema-change)
OPTIMIZE TABLE t_order;
什么时候用:
- ✅ 所有慢 SQL 报警必走 6 步流程
- ✅ 加索引前必 EXPLAIN 看执行计划
- ✅ 优化后必验证(EXPLAIN + 真实测试)
- ✅ 优化后必监控(1-2 天)
- ❌ 不要"凭感觉"加索引
💡 一句话总结
慢 SQL 完整流程 = 发现(告警)→ 定位(慢日志)→ 分析(EXPLAIN)→ 优化(加索引)→ 验证(EXPLAIN + 真实测试)→ 监控(持续观察) ;
重点:EXPLAIN 的 type 必须是 ref 以上,rows 越少越好。
Q5:场景题:深分页怎么优化?
📖 是什么
深分页 = LIMIT 越大越慢 (如 LIMIT 1000000, 10)。
类比:翻一本书翻到第 100 万页------前面 100 万页都得翻(即使你只想看最后 10 行)。
关键概念解释(专业词提前讲清楚):
- 分页(Pagination):把数据分成多页展示
- OFFSET:跳过的行数(OFFSET 越大越慢)
- 延迟关联(Deferred Join):先查主键再回表
- 游标分页(Cursor-based):用"上一页最后一条 ID"作为下一页起点
- 覆盖索引(Covering Index):查询列全在索引里
🤔 为什么
Q1:为什么 LIMIT 越大越慢?
因为 MySQL 要扫 OFFSET + LIMIT 行才能返回。
LIMIT 10:扫 10 行(快)
LIMIT 1000, 10:扫 1010 行(慢一点)
LIMIT 1000000, 10:扫 1000010 行(灾难!)
Q2:为什么业务上会有深分页?
3 大场景:
1. 后台管理系统:导出全量数据(翻到最后几页)
2. 搜索引擎:用户翻到第 1000 页找东西
3. 数据分析:查询历史数据
🛠 怎么用(实际业务场景)
业务场景 1:传统分页 vs 优化分页
sql
-- ============ 传统分页(深分页慢)============
SELECT * FROM t_order
WHERE user_id = 123
ORDER BY create_time DESC
LIMIT 1000000, 10;
-- MySQL 要扫 1000010 行,然后丢掉前 1000000 行
-- 耗时:~30 秒
sql
-- ============ 方案 1:延迟关联(推荐)============
-- 第 1 步:先查主键(走覆盖索引,快)
SELECT id FROM t_order
WHERE user_id = 123
ORDER BY create_time DESC
LIMIT 1000000, 10;
-- 耗时:~100ms
-- 第 2 步:用主键回表(只回 10 次)
SELECT * FROM t_order o
INNER JOIN (上述结果) t ON o.id = t.id;
-- 耗时:~50ms
-- 完整 SQL(用子查询合并)
SELECT * FROM t_order o
INNER JOIN (
SELECT id FROM t_order
WHERE user_id = 123
ORDER BY create_time DESC
LIMIT 1000000, 10
) t ON o.id = t.id;
-- 耗时:~150ms(提升 200 倍!)
sql
-- ============ 方案 2:游标分页(最推荐)============
-- 不再用 OFFSET,而是用"上一页最后一条 ID"
-- 适合"无限下拉"的场景(如朋友圈、微博)
-- 第 1 页
SELECT * FROM t_order
WHERE user_id = 123
ORDER BY create_time DESC, id DESC
LIMIT 10;
-- 拿到最后一条:(create_time='2026-07-17 10:00', id=12345)
-- 第 2 页(用上一页最后一条作为起点)
SELECT * FROM t_order
WHERE user_id = 123
AND (create_time, id) < ('2026-07-17 10:00', 12345)
ORDER BY create_time DESC, id DESC
LIMIT 10;
-- 耗时:~10ms(极快!)
-- 优点:无论翻多少页,都只扫 10 行
-- 缺点:不能"跳页"(只能"下一页")
sql
-- ============ 方案 3:覆盖索引(避免回表)============
-- 索引包含所有查询列(不用回表)
CREATE INDEX idx_user_time_cover ON t_order(user_id, create_time, id, amount, status);
-- 查询:只查需要的列,且都在索引里
SELECT user_id, create_time, id, amount, status
FROM t_order
WHERE user_id = 123
ORDER BY create_time DESC
LIMIT 1000000, 10;
-- 走覆盖索引,不用回表
-- 耗时:~500ms(比传统分页快 60 倍)
sql
-- ============ 方案 4:业务限制(最简单)============
-- 直接限制:用户最多翻到 100 页
-- 100 页之后用"按时间筛选"
-- 例:电商列表只让用户看前 100 页,超过的用"按月份筛选"
-- 业务代码
if (pageNo > 100) {
// 强制让用户选时间范围
return Result.error("请选择时间范围");
}
业务场景 2:API 设计示例
java
// ============ 方案 A:传统分页(适合后台管理)============
public class PageRequest {
private Integer pageNo = 1; // 页码
private Integer pageSize = 20; // 每页大小
}
// 优点:可以跳页
// 缺点:深分页慢
// ============ 方案 B:游标分页(适合 C 端 App)============
public class CursorPageRequest {
private Long lastId; // 上一页最后一条 ID
private Integer pageSize = 20;
}
// 优点:性能好
// 缺点:不能跳页
// ============ API 实现(游标分页)============
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/list")
public Result<List<Order>> list(
@RequestParam(required = false) Long lastId,
@RequestParam(defaultValue = "20") Integer pageSize
) {
List<Order> orders = orderService.listByCursor(lastId, pageSize);
Long newLastId = orders.isEmpty() ? null : orders.get(orders.size() - 1).getId();
return Result.success(orders, newLastId); // 返回新的 lastId 给前端
}
}
java
// ============ Service 实现(游标分页)============
@Service
public class OrderService {
public List<Order> listByCursor(Long lastId, int pageSize) {
// ============ 关键 SQL ============
String sql = "SELECT * FROM t_order " +
"WHERE user_id = ? " +
(lastId != null ? "AND id < ? " : "") +
"ORDER BY id DESC " +
"LIMIT ?";
// ============ 参数化 ============
// 如果 lastId 不为空,加上 (user_id, lastId) 两个参数
// 避免深分页(无论查第几页都只扫 pageSize 行)
}
}
业务场景 3:3 种方案对比
| 方案 | 性能 | 适用场景 | 缺点 |
|---|---|---|---|
| 传统分页 | 深分页慢(30s) | 后台管理、跳页 | OFFSET 越大越慢 |
| 延迟关联 | 中等(150ms) | 后台管理、深分页 | 还是要扫 OFFSET |
| 游标分页 | 极快(10ms) | C 端 App、无限下拉 | 不能跳页 |
| 覆盖索引 | 较快(500ms) | 列少 + 深分页 | 索引大 |
| 业务限制 | 极快 | 业务可控 | 体验差 |
深分页优化决策树:
你的业务是什么场景?
├─ C 端 App / 无限下拉 → 游标分页
│ 例:朋友圈、微博、订单列表
│
├─ 后台管理 / 要跳页 → 延迟关联
│ 例:CRM、ERP、运营后台
│
├─ 列少 + 深分页 → 覆盖索引
│ 例:日志查询
│
└─ 数据量极大 + 跳页需求 → 业务限制 + 按时间筛选
例:订单导出
业务场景 4:监控深分页
sql
-- ============ 找出最慢的分页 SQL ============
-- 慢查询日志里搜 "LIMIT"
grep "LIMIT" /var/log/mysql/slow.log | sort -t' ' -k10 -n -r | head -20
-- ============ 监控平均 OFFSET ============
-- pt-query-digest 报告里看 "LIMIT N OFFSET M" 的 M
pt-query-digest /var/log/mysql/slow.log | grep "LIMIT"
注意事项:
| 坑 | 后果 | 怎么避免 |
|---|---|---|
| OFFSET 巨大(> 10 万) | 查询慢 | 用游标分页 |
| ORDER BY 没用索引 | Using filesort | 排序列加索引 |
| 索引失效 | 回退全表扫描 | EXPLAIN 验证 |
| 游标分页用了非主键排序 | 重复或漏数据 | ORDER BY 必须包含游标列 |
什么时候用:
- ✅ C 端 App 用游标分页(性能好)
- ✅ 后台管理用延迟关联(可跳页)
- ✅ 超过 10 万页强制业务限制
- ❌ 不要用 OFFSET 超过 10 万
💡 一句话总结
深分页优化 = 传统 OFFSET 慢 (扫 OFFSET + LIMIT);
推荐:游标分页(C 端) 、延迟关联(后台) ;
终极方案:业务限制(超过 N 页强制按时间筛选)。