MySQL+SQLServer数据库-笔记

三、数据库

结构 :📖 是什么 → 🤔 为什么 → 🛠 怎么用 → 💡 总结

风格:专业词提前讲清楚(避免堆术语)+ 真实业务痛点驱动 + 代码注释白话化


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 UPDATEUPDATEINSERTDELETE
  • undo log:回滚日志,记录数据的"历史版本"
  • trx_id:事务 ID,每次开启事务递增
  • read_view:事务的"读视图",决定能看到哪些版本
  • 隐藏列 :InnoDB 每行数据都有 3 个隐藏列(trx_idroll_pointerrow_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 的 LIMIT vs 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 → NVARCHARLIMIT → OFFSETTINYINT(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 页强制按时间筛选)。


相关推荐
XS0301061 小时前
Spring AI第一课
数据库·人工智能·spring
宠友信息2 小时前
消息撤回与已读状态如何在即时通讯源码中统一管理
java·spring boot·websocket·mysql·uni-app
Delta-delta2 小时前
M85-GCC13.3-CMSIS-接入笔记
笔记·单片机·嵌入式硬件
宠友信息2 小时前
从重复请求到订单同步看内容社区源码的处理思路
java·spring boot·redis·后端·mysql·spring
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-07-17
前端·数据库·人工智能·经验分享·mysql·chatgpt·html
ysa0510302 小时前
【板子】德州扑克模拟
c++·笔记·板子
DFT计算杂谈2 小时前
DeepSeek 集群服务器无root本地部署指南
数据库·人工智能·python·opencv·算法
潇凝子潇2 小时前
UnsupportedOperationException
java·前端·数据库
七夜zippoe2 小时前
DolphinDB实时聚合计算:多维度聚合
数据库·dolphindb