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 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 的 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 → 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 页强制按时间筛选)。


相关推荐
A.说学逗唱的Coke4 小时前
【人工智能专题】PolarDB 深度解析:存算分离到 AI Lakebase,云原生数据库的架构演进与实战指南
数据库·人工智能·云原生
我不会起名字3224 小时前
InnoDB 间隙锁与死锁:两个 UPDATE 互相等待的 3 个原因
数据库·mysql·事务·innodb·锁
ClickHouseDB4 小时前
ClickHouse Terraform Provider 正式支持 ClickStack 资源管理
网络·数据库·python
步行cgn4 小时前
Spring 事务属性详解
java·数据库·spring
yi0114 小时前
DAY23: LeetCode 27 → 283:从移除元素到移动零,理解快慢指针的两种思路
人工智能·笔记·python·算法·leetcode·排序算法·双指针
我滴老baby4 小时前
Navidrome:飞牛OS安装、曲库导入与远程收听全流程
开发语言·数据库·php
陈年老古董4 小时前
Hive 文件格式与查询学习笔记
hive·笔记·学习
weixin_448119945 小时前
Datawhale Easy Data × AI:构建知识与记忆驱动的 Agent笔记3
人工智能·windows·笔记
瀚高PG实验室5 小时前
HGHAC集群VIP-MANAGER挂载的VIP消失
数据库·postgresql·瀚高数据库
野生码农AI实战5 小时前
AI 看不了网页?我用 CDP 重建浏览器采集通道,30 行脚本 2.7 秒读完全文
数据库·人工智能·ai编程