📌 一、SQL优化篇
1.1 explain执行计划怎么看?重点关注哪些字段?
✅ 正确回答思路:
explain是SQL优化必备工具,我重点说几个最关键的字段:
使用方法:
sql
EXPLAIN SELECT * FROM user WHERE username = 'zhangsan';
最重要的几个字段:
1. type(访问类型) - 这是最关键的指标!
从好到差的顺序:
-
system: 表只有一行记录,基本不会出现 -
const: 通过主键或唯一索引查询,最多返回一行,非常快sqlSELECT * FROM user WHERE id = 1; -
eq_ref: 连接查询时,对于前表的每一行,后表只有一行匹配,常见于主键或唯一索引连接sqlSELECT * FROM order o JOIN user u ON o.user_id = u.id; -
ref: 使用非唯一索引,可能返回多行sqlSELECT * FROM user WHERE age = 25; -- age有普通索引 -
range: 范围查询,使用索引sqlSELECT * FROM user WHERE id > 100 AND id < 200; -
index: 全索引扫描,虽然走了索引,但要扫描整个索引树sqlSELECT id FROM user; -- 只查主键,扫描主键索引 -
ALL: 全表扫描,性能最差,要优化!sqlSELECT * FROM user WHERE YEAR(create_time) = 2024;
优化目标:至少达到range级别,最好是ref或const
2. key(实际使用的索引)
- 显示实际用了哪个索引
- 如果是
NULL,说明没用索引,需要优化
3. key_len(索引使用的字节数)
- 数值越大,说明用了越多的索引列
- 对于联合索引,可以通过这个判断用了几个列
比如联合索引idx_abc(a, b, c):
- 如果key_len=4,可能只用了a列(假设a是int,4字节)
- 如果key_len=8,可能用了a和b列
4. rows(扫描行数)
- 这个值越小越好
- 显示MySQL估计要扫描多少行才能找到结果
- 如果这个值很大,说明查询效率低
5. Extra(额外信息)
重点关注几个:
-
Using index: 很好! 使用了覆盖索引,不需要回表sql-- 假设有索引idx_username(username) SELECT username FROM user WHERE username = 'zhangsan'; -
Using where: 在存储引擎层过滤后,还需要在Server层再过滤 -
Using index condition: 好! 使用了索引下推(ICP) -
Using filesort: 要优化! 需要额外排序,不能用索引排序sqlSELECT * FROM user ORDER BY age; -- age没索引 -
Using temporary: 要优化! 需要创建临时表,通常出现在group by或distinctsqlSELECT DISTINCT age FROM user;
实战案例:
慢查询SQL:
sql
EXPLAIN SELECT * FROM order WHERE user_id = 100 ORDER BY create_time DESC;
优化前:
type: ALL
key: NULL
rows: 500000
Extra: Using filesort
问题:全表扫描,还要额外排序
优化:
sql
-- 建立联合索引
CREATE INDEX idx_user_time ON order(user_id, create_time);
优化后:
type: ref
key: idx_user_time
rows: 1000
Extra: Using index
效果:走索引,行数大减,还用上了覆盖索引!
💡 记忆口诀:
- type看访问,ALL要避免
- key看索引,NULL要优化
- rows看数量,越小越快
- Extra看细节,filesort/temporary要当心
1.2 分页查询limit 100000, 10很慢,怎么优化?
✅ 正确回答思路:
这是个非常经典的MySQL性能问题,我在实际项目中也遇到过!
首先说明为什么慢:
sql
SELECT * FROM user ORDER BY id LIMIT 100000, 10;
这个SQL的执行过程是:
- MySQL要先扫描前100010行数据
- 然后丢掉前100000行
- 最后才返回10行
也就是说,虽然你只要10条数据,但MySQL实际处理了100010条数据!offset越大,丢掉的数据越多,性能越差。
优化方案有三种:
方案1:子查询优化(最常用)
问题在于SELECT *会回表,如果能先用覆盖索引找到id,再回表,就能减少很多IO。
sql
-- 原SQL(慢)
SELECT * FROM user ORDER BY id LIMIT 100000, 10;
-- 优化后(快)
SELECT * FROM user
WHERE id >= (SELECT id FROM user ORDER BY id LIMIT 100000, 1)
LIMIT 10;
原理:
- 子查询
SELECT id只查主键,走主键索引,不回表,很快找到第100000条的id - 外层查询
WHERE id >= xxx LIMIT 10从这个id开始取10条,也很快
实际测试,100万数据的表,原SQL要几秒,优化后只要几十毫秒!
方案2:延迟关联(Deferred Join)
这个也是利用覆盖索引的思想:
sql
SELECT u.* FROM user u
INNER JOIN (
SELECT id FROM user ORDER BY id LIMIT 100000, 10
) t ON u.id = t.id;
原理跟方案1类似,子查询先用覆盖索引快速找到id,然后关联查询。
方案3:记录上次位置(推荐,前端配合)
这个需要前端配合,但效果最好!
sql
-- 第一页
SELECT * FROM user WHERE id > 0 ORDER BY id LIMIT 10;
-- 返回的最大id假设是10
-- 第二页
SELECT * FROM user WHERE id > 10 ORDER BY id LIMIT 10;
-- 返回的最大id假设是20
-- 第三页
SELECT * FROM user WHERE id > 20 ORDER BY id LIMIT 10;
优点:
- 无论翻到多少页,性能都稳定
- 不需要计算offset
- 特别适合Timeline类的场景,比如微博、朋友圈
缺点:
- 不能跳页,只能上一页/下一页
- 需要前端记录上次查询的最大id
实际项目经验:
我之前做过一个后台管理系统,用户表有几百万数据,客户反馈翻到后面几页特别慢。我用方案1优化后,原来3-5秒的查询降到了100毫秒以内,效果非常明显。
后来做App的朋友圈功能,就用了方案3,采用"加载更多"的方式,体验很好,性能也稳定。
💡 如果非要支持跳页怎么办?
大offset无解,只能:
- 限制最多翻页数,比如只能翻前100页
- 或者用Elasticsearch等搜索引擎,专门为大数据翻页优化过的
1.3 count(*)、count(1)、count(字段)有什么区别?哪个更快?
✅ 正确回答思路:
这个问题很多人都混淆,我详细说明一下:
首先说三者的区别:
1. count(*)
- 统计所有行数,包括NULL
- InnoDB引擎会自动优化,遍历最小的索引树(通常是二级索引)来统计
- 不会取值,只统计行数
2. count(1)
- 也是统计所有行数,包括NULL
- 遍历每一行,给每行赋值1,然后统计
- 实际上MySQL优化器会将count(1)和count(*)处理成一样的
3. count(字段) 分两种情况:
sql
-- 情况1: count(字段) 字段不允许NULL
count(user_id)
-- 统计user_id不为NULL的行数,如果字段定义NOT NULL,结果和count(*)一样
-- 情况2: count(字段) 字段允许NULL
count(nickname)
-- 统计nickname不为NULL的行数,会跳过NULL值
然后说哪个更快:
在InnoDB引擎下:
- count(*) ≈ count(1) > count(主键) > count(字段)
为什么count(*)最快(或跟count(1)一样快)?
很多人以为count(1)更快,因为不用取值。但实际上:
- MySQL优化器做了优化 :count(*)不是真的去SELECT *取所有字段,而是专门统计行数
- InnoDB的优化 :
- InnoDB会选择最小的索引树来扫描(通常是二级索引,因为比主键索引小)
- 如果有二级索引,扫描二级索引;没有就扫描主键索引
- 不会取具体的值,只统计行数
- count(1)被优化成count(*): MySQL优化器发现count(1)的常量对结果无影响,会优化成count(*)
为什么count(字段)慢?
sql
count(age) -- age是普通字段
原因:
- 需要遍历主键索引(聚簇索引)
- 需要从索引里把age字段取出来
- 还要判断age是否为NULL
- 不为NULL才计数
多了取值和判空两个步骤,所以更慢!
实际测试(100万数据):
sql
-- 1. count(*)
SELECT count(*) FROM user;
-- 耗时: 0.5秒
-- 2. count(1)
SELECT count(1) FROM user;
-- 耗时: 0.5秒 (和count(*)一样)
-- 3. count(主键id)
SELECT count(id) FROM user;
-- 耗时: 0.6秒 (稍慢,因为要取id的值)
-- 4. count(普通字段age)
SELECT count(age) FROM user;
-- 耗时: 1.2秒 (最慢,要扫主键索引并取值)
为什么InnoDB的count(*)这么慢?
你可能会问,为什么执行count(*)还要扫描,不能像MyISAM一样,直接返回一个存好的总数吗?
原因是:MVCC多版本并发控制!
- 在RR隔离级别下,不同事务看到的数据行数可能不一样
- 比如事务A看到100行,同时事务B插入了10行但未提交,事务A还是只能看到100行
- 所以InnoDB没法简单存一个总数,必须实时统计
优化建议:
如果业务需要频繁count,有几个办法:
1. 用Redis缓存总数
java
// 插入数据时
redisTemplate.incr("user:count");
// 删除数据时
redisTemplate.decr("user:count");
// 查询总数时
Long count = redisTemplate.get("user:count");
2. 自己维护一个计数表
sql
CREATE TABLE user_count (
cnt bigint
);
-- 插入数据时
UPDATE user_count SET cnt = cnt + 1;
-- 查询总数时
SELECT cnt FROM user_count;
3. 使用预估值 如果不需要精确值,可以用:
sql
-- 查看表的预估行数(很快但不准确)
SHOW TABLE STATUS LIKE 'user';
-- 或者
SELECT TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_NAME = 'user';
💡 结论:
- 如果要统计总行数,用
count(*)或count(1),两者性能一样 - 如果要统计某个字段非NULL的行数,用
count(字段) - 如果需要高性能count,用Redis缓存或自维护计数表
1.4 order by是怎么工作的?怎么优化?
✅ 正确回答思路:
order by的执行过程比较复杂,可能走索引排序,也可能走文件排序,我详细说明:
一、order by的两种执行方式
1. Using Index(走索引排序) - 最快
如果order by的字段有索引,而且满足一定条件,就直接用索引的顺序,不需要额外排序。
sql
-- 假设有索引 idx_age(age)
SELECT * FROM user WHERE age > 20 ORDER BY age;
条件:
- order by的字段必须在索引里
- 索引的顺序要和order by的顺序一致
- 如果是联合索引,需要满足最左前缀
2. Using Filesort(文件排序) - 慢,需要优化
如果不能用索引排序,MySQL就要把数据取出来,在内存(sort_buffer)或临时文件里排序。
sql
-- age没索引,或者用了函数
SELECT * FROM user ORDER BY age;
二、Filesort的两种算法
1. 双路排序(回表排序)
老版本MySQL的方式:
- 先根据排序字段age,把(age, 主键id)取出来,放内存排序
- 排好序后,再根据id回表查询完整数据
问题:需要回表,IO次数多
2. 单路排序(全字段排序) - 新版本默认
新版本MySQL优化后:
- 把(age, 主键id, name, email...)所有需要的字段都取出来
- 在内存里直接排序
- 不需要回表
优点:不回表,IO少 缺点:占用内存多,如果字段多或数据多,可能超过sort_buffer,就要用临时文件
三、怎么判断用了哪种方式?
用EXPLAIN看Extra字段:
sql
EXPLAIN SELECT * FROM user ORDER BY age;
- 没有filesort: 说明用了索引排序,最快
- Using filesort: 需要额外排序,要优化
四、优化策略
1. 建立索引
最直接有效:
sql
-- 单字段排序
CREATE INDEX idx_age ON user(age);
-- 多字段排序,建联合索引
-- SELECT * FROM user ORDER BY age, create_time DESC
CREATE INDEX idx_age_time ON user(age, create_time DESC);
2. 调整索引顺序
sql
-- 假设有联合索引 idx_ab(a, b)
-- ✅ 能用上索引
ORDER BY a, b
-- ❌ 不能用上索引
ORDER BY b, a -- 顺序反了
-- ❌ 不能用上索引
ORDER BY a DESC, b ASC -- 排序方向不一致(除非建索引时指定)
3. 覆盖索引优化
如果不能避免filesort,尽量用覆盖索引,减少数据量:
sql
-- ❌ 要取所有字段,filesort数据量大
SELECT * FROM user ORDER BY age LIMIT 10;
-- ✅ 只取必要字段,filesort数据量小
SELECT id, username, age FROM user ORDER BY age LIMIT 10;
4. 调大sort_buffer_size
如果确实要filesort,可以调大内存缓冲区,避免使用临时文件:
sql
-- 查看当前值
SHOW VARIABLES LIKE 'sort_buffer_size';
-- 调大(单位字节,默认256KB)
SET SESSION sort_buffer_size = 2097152; -- 2MB
5. 利用limit优化
有limit的情况下,MySQL会做优化,只在内存里维护limit个数的数据:
sql
-- 即使是filesort,也只要排序和维护10条数据
SELECT * FROM user ORDER BY age DESC LIMIT 10;
实战案例:
我之前有个订单查询需求:
sql
SELECT * FROM `order`
WHERE user_id = 12345
ORDER BY create_time DESC
LIMIT 20;
优化前:
type: ref (user_id有索引)
Extra: Using filesort (create_time没索引,要额外排序)
耗时: 800ms
优化:建立联合索引
sql
CREATE INDEX idx_user_time ON `order`(user_id, create_time DESC);
优化后:
type: ref
Extra: Using index (覆盖索引,不回表)
耗时: 50ms
效果提升了16倍!
💡 记忆口诀:
- 索引排序快如风,filesort慢如龟
- 联合索引要注意,顺序方向要一致
- 覆盖索引减数据,limit优化很给力
📌 二、日志与主从复制篇
2.1 MySQL的三大日志(binlog、redo log、undo log)分别是什么?
✅ 正确回答思路:
这三个日志是MySQL的核心,我分别说明:
一、binlog(归档日志/二进制日志)
是什么:
- MySQL Server层的日志,所有存储引擎都有
- 记录了所有DDL和DML语句(SELECT不记录)
- 是逻辑日志,记录的是SQL语句或者数据修改的逻辑
作用:
- 主从复制: 主库把binlog发给从库,从库重放,实现数据同步
- 数据恢复: 可以基于binlog做时间点恢复(PITR)
格式:
- STATEMENT: 记录SQL语句,日志量小,但可能导致主从不一致(比如now()函数)
- ROW: 记录每行数据的变化,日志量大,但最安全,推荐使用
- MIXED: 混合前两种
二、redo log(重做日志)
是什么:
- InnoDB引擎特有的日志
- 是物理日志,记录的是"在某个数据页上做了什么修改"
- 循环写,空间固定,会覆盖
作用:
- 保证事务的持久性(ACID的D)
- 实现crash-safe: 即使MySQL宕机,重启后也能恢复数据
工作原理(WAL - Write-Ahead Logging):
- 事务提交时,先写redo log,标记为prepare状态
- 然后写binlog
- 最后redo log标记为commit状态
- 真正的数据写入磁盘是异步的,由后台线程慢慢刷
好处:
- 写redo log是顺序写,很快
- 真正的数据写入可以批量、合并,效率更高
举例说明:
你去银行转账:
- 你填单子(写redo log)
- 柜员记账本(写binlog)
- 柜员在单子上盖章(redo log commit)
- 真正的钱可以晚点转(异步刷盘)
即使突然停电(MySQL宕机),重启后可以根据盖章的单子(redo log)知道哪些转账成功了。
三、undo log(回滚日志)
是什么:
- InnoDB引擎特有的日志
- 是逻辑日志,记录的是数据修改前的值
- 用于回滚和实现MVCC
作用:
- 事务回滚: 如果事务失败,根据undo log恢复到修改前的状态
- MVCC: 提供历史版本的数据,实现快照读
举例:
sql
UPDATE user SET age = 30 WHERE id = 1;
undo log会记录:id=1的记录,age之前是25
- 如果事务回滚,根据undo log把age改回25
- 如果其他事务要读旧版本,也从undo log里读
三大日志对比表:
| 日志类型 | 层次 | 内容 | 作用 | 写入方式 |
|---|---|---|---|---|
| binlog | Server层 | 逻辑日志(SQL) | 主从复制、数据恢复 | 追加写 |
| redo log | InnoDB引擎 | 物理日志(数据页修改) | 崩溃恢复、持久性 | 循环写 |
| undo log | InnoDB引擎 | 逻辑日志(修改前的值) | 事务回滚、MVCC | 追加写,定期清理 |
实际面试高频追问:
Q: 为什么需要两份日志(redo log和binlog)?
A: 历史原因。MySQL最开始只有binlog,是用来归档的。后来InnoDB加入,为了实现crash-safe,引入了redo log。
Q: 为什么要两阶段提交?
A: 保证redo log和binlog的一致性。
假设不用两阶段提交:
- 先写redo log,再写binlog,如果写完redo log后宕机,重启后数据已恢复,但binlog没记录,主从不一致
- 先写binlog,再写redo log,如果写完binlog后宕机,binlog有记录但redo log没有,重启后数据丢失,主从不一致
所以用两阶段提交:
- redo log prepare
- binlog写入
- redo log commit
这样即使在任何时刻宕机,重启后都能判断事务是否成功,保证一致性。
💡 记忆口诀:
- binlog主从复制用,归档恢复少不了
- redo log崩溃恢复快,持久可靠WAL好
- undo log回滚MVCC行,历史版本都记牢
2.2 MySQL主从复制原理是什么?有哪些复制方式?
✅ 正确回答思路:
主从复制是MySQL高可用的基础,我从原理、复制方式、延迟问题三方面来说:
一、主从复制的基本原理
三个线程协同工作:
- 主库的binlog dump线程
- 当从库连接主库时,主库创建binlog dump线程
- 负责读取binlog,发送给从库
- 从库的I/O线程
- 连接到主库,请求binlog
- 接收到binlog后,写入本地的relay log(中继日志)
- 从库的SQL线程
- 读取relay log
- 重放(replay)这些操作,应用到从库数据库
流程图(用文字描述):
主库:
1. 执行SQL → 2. 写binlog → 3. binlog dump线程读取
↓ 网络传输 ↓
从库:
4. I/O线程接收 → 5. 写relay log → 6. SQL线程读取 → 7. 重放SQL
二、复制方式
按同步机制分:
1. 异步复制(默认)
- 主库执行完事务,写完binlog就返回成功,不管从库有没有同步
- 优点: 性能最好,主库不阻塞
- 缺点: 主库宕机可能导致部分数据丢失,从库可能没来得及同步
2. 半同步复制
- 主库执行完事务后,至少等一个从库接收到binlog并写入relay log,才返回成功
- 优点: 数据更安全,至少一个从库有数据
- 缺点: 性能略差,主库要等从库确认
sql
-- 主库开启半同步
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
-- 从库开启半同步
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;
3. 全同步复制(基本不用)
- 主库等所有从库都执行完才返回
- 性能太差,基本不用
按binlog格式分:
1. 基于语句的复制(STATEMENT)
sql
SET GLOBAL binlog_format = 'STATEMENT';
- binlog记录的是SQL语句
- 优点:日志量小
- 缺点:有些语句(now(), uuid())会导致主从不一致
2. 基于行的复制(ROW) - 推荐
sql
SET GLOBAL binlog_format = 'ROW';
- binlog记录的是每行数据的变化
- 优点:最安全,主从一定一致
- 缺点:日志量大,尤其是批量操作
3. 混合复制(MIXED)
sql
SET GLOBAL binlog_format = 'MIXED';
- MySQL自动选择,一般用STATEMENT,需要时用ROW
三、主从延迟问题
产生原因:
- 从库压力大: 从库要承担查询压力,SQL线程重放跟不上
- 大事务: 主库一个大事务(比如删除100万行),从库要重放很久
- 网络延迟: binlog传输慢
- 从库配置差: 从库机器配置比主库差
- 单线程重放: MySQL 5.6之前,从库SQL线程是单线程
如何监控延迟:
sql
-- 在从库执行
SHOW SLAVE STATUS\G;
看Seconds_Behind_Master字段:
- 0: 没有延迟
- NULL: 复制线程未运行
- 数字: 延迟的秒数
解决方案:
1. 并行复制(MySQL 5.7+)
sql
-- 基于COMMIT_ORDER的并行复制
SET GLOBAL slave_parallel_type = 'LOGICAL_CLOCK';
SET GLOBAL slave_parallel_workers = 4; -- 开启4个worker线程
2. 减少大事务
sql
-- ❌ 大事务,从库延迟
DELETE FROM user WHERE create_time < '2020-01-01'; -- 删除100万行
-- ✅ 分批删除
DELETE FROM user WHERE create_time < '2020-01-01' LIMIT 1000;
-- 循环执行,每次删1000行
3. 从库只读,减轻压力
sql
SET GLOBAL read_only = 1;
SET GLOBAL super_read_only = 1;
4. 提升从库硬件配置
5. 把binlog_format改成ROW
- ROW格式在从库重放更快,因为不需要解析SQL,直接改数据
实际案例:
我之前项目有个统计脚本,在主库执行了一个大的GROUP BY查询,导致主库生成了一个大binlog。从库SQL线程重放这个查询时,延迟飙升到几十秒。
解决办法:
- 把这种统计查询改到从库执行
- 开启并行复制
- 监控Seconds_Behind_Master,延迟超过10秒就报警
💡 记忆口诀:
- 三个线程配合好,dump、IO、SQL少不了
- 异步快但可能丢,半同步安全性更高
- 延迟监控要做好,并行复制效率高
📌 三、高级特性篇
3.1 什么情况下需要分库分表?怎么分?
✅ 正确回答思路:
分库分表是解决大数据量和高并发的终极方案,我从判断标准、分片策略、带来的问题三方面说:
一、什么情况下需要分库分表?
分表的判断标准:
- 单表数据量超过1000万 -2000万行
- 单表容量超过10GB
- 查询响应时间明显变慢(超过100ms)
- 索引文件过大,无法全部加载到内存
分库的判断标准:
- 单库连接数不够用(MySQL默认max_connections=151)
- 单库IO瓶颈(IOPS达到上限)
- 单机磁盘容量不够
二、分库分表的策略
1. 垂直拆分
垂直分表: 把一个宽表拆成多个窄表,把不常用的字段拆出去:
sql
-- 原表(字段太多)
CREATE TABLE user (
id, username, password, nickname, avatar,
email, phone, address, description, ... -- 还有几十个字段
);
-- 拆分后
-- 基础表(常用字段)
CREATE TABLE user (
id, username, password, nickname, avatar
);
-- 扩展表(不常用字段)
CREATE TABLE user_ext (
id, email, phone, address, description, ...
);
好处:
- 核心表变小,查询更快
- 把大字段拆出去,一个数据页能存更多行
垂直分库: 按业务模块拆分数据库:
原来: 单个db
- user表
- order表
- product表
拆分后:
user_db
- user表
- user_address表
order_db
- order表
- order_item表
product_db
- product表
- product_category表
好处:
- 业务隔离,互不影响
- 分散单库压力
- 容易扩展,可以给不同的库配置不同规格的机器
2. 水平拆分
把同一个表的数据分散到多个表或多个库:
① 按范围分片(Range)
sql
-- order_2023 存储2023年的订单
-- order_2024 存储2024年的订单
-- order_2025 存储2025年的订单
优点:
- 简单,容易扩展
- 范围查询效率高
缺点:
- 数据分布不均,可能热点集中在新表
② 按哈希分片(Hash)
sql
-- 按user_id取模
user_id % 4 = 0 → user_0
user_id % 4 = 1 → user_1
user_id % 4 = 2 → user_2
user_id % 4 = 3 → user_3
优点:
- 数据分布均匀
- 负载均衡
缺点:
- 扩容麻烦(要重新hash)
- 范围查询要查所有分片
③ 一致性哈希分片
解决普通hash扩容问题,但实现较复杂。
④ 按地理位置分片
sql
-- 华东用户 → db_east
-- 华南用户 → db_south
-- 华北用户 → db_north
优点:
- 就近访问,延迟低
- 符合业务逻辑
三、分库分表带来的问题
1. 分布式事务问题
跨库操作无法保证ACID:
sql
-- 原来在一个库,可以用事务
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE user_id = 1; -- user_db
INSERT INTO order (...) VALUES (...); -- order_db
COMMIT;
-- 分库后,两个操作在不同库,事务失效
解决方案:
- 最终一致性: 用消息队列、定时任务补偿
- 分布式事务: Seata、XA协议(性能差,少用)
- 避免跨库事务: 数据冗余,把常用的关联数据放在一起
2. 跨库join问题
sql
-- 原来可以join
SELECT u.username, o.order_no
FROM user u JOIN order o ON u.id = o.user_id;
-- 分库后,user和order在不同库,无法join
解决方案:
- 字段冗余: 在order表里冗余username
- 应用层join: 先查user,再拿id查order,代码里拼接
- 全局表: 把user这种小表在每个库都复制一份
3. 分页、排序、统计问题
sql
-- 分表前
SELECT * FROM order ORDER BY create_time DESC LIMIT 10;
-- 分表后,有4个分片,要
1. 从每个分片取TOP 10
2. 在应用层合并,重新排序
3. 再取TOP 10
这会很麻烦,性能也差。
解决方案:
- 尽量在分片键上排序
- 用Elasticsearch做复杂查询
4. 全局唯一ID问题
分库分表后,自增ID不能用了,需要全局唯一ID。
解决方案:
- UUID: 简单,但无序,索引性能差
- 雪花算法(Snowflake): 推荐,64bit,有序
- Redis incr: 性能好,但单点问题
- 数据库号段模式: 每次从DB取一批ID
实际项目经验:
我之前做过一个电商项目,订单表2年积累了5000万数据,查询很慢。我们的分片方案:
- 选择分片键: 用user_id,因为90%的查询都带user_id
- 分片策略: hash取模,分16个表
- 路由规则: user_id % 16
- 全局ID: 用雪花算法
- 冗余字段: order表冗余username,避免join
上线后,查询从几秒降到几十毫秒,效果很明显。
💡 什么时候不建议分库分表?
- 数据量还不够大(千万级以下)
- 业务逻辑简单
- 团队经验不足
因为分库分表会带来很多复杂度,没必要过早优化。可以先用读写分离、缓存、索引优化等方案。
3.2 MySQL的读写分离怎么实现?有什么问题?
✅ 正确回答思路:
读写分离是提升MySQL并发能力的重要手段,我从实现方式、路由策略、遇到的问题三方面说:
一、为什么需要读写分离?
大部分互联网应用都是读多写少(比如9:1甚至19:1),如果所有请求都打到一个MySQL,很容易成为瓶颈。
读写分离的思路:
- 主库(Master)负责写操作(INSERT、UPDATE、DELETE)
- 从库(Slave)负责读操作(SELECT)
- 通过主从复制,保持数据同步
好处:
- 分散压力,提高并发能力
- 从库可以有多个,进一步分散读压力
- 主库专注写,性能更好
二、读写分离的实现方式
1. 应用层实现 - 最常用
在代码里判断是读还是写,选择不同的数据源:
java
// 伪代码
public class DataSourceRouter {
private DataSource masterDB; // 主库
private List<DataSource> slaveDBs; // 从库列表
public DataSource getDataSource(boolean isRead) {
if (isRead) {
// 读操作,随机或轮询选一个从库
return slaveDBs.get(random.nextInt(slaveDBs.size()));
} else {
// 写操作,走主库
return masterDB;
}
}
}
// 使用
@Transactional
public void updateUser(User user) {
// 写操作,自动路由到主库
userMapper.update(user);
}
public User getUser(Long id) {
// 读操作,自动路由到从库
return userMapper.selectById(id);
}
常用框架/中间件:
- Spring的AbstractRoutingDataSource: 自己实现路由逻辑
- ShardingSphere: Apache开源,功能强大,支持读写分离、分库分表
- MyCat: 数据库中间件,支持读写分离、分库分表
优点:
- 灵活,可以根据业务自定义路由规则
- 性能好,减少一层代理
缺点:
- 代码侵入,需要改代码
- 增加复杂度
2. 中间件代理实现
在应用和MySQL之间加一层代理:
应用
↓
代理(MyCat/ProxySQL)
↓ ↓
主库 从库
代理层负责:
- SQL解析,判断读写
- 路由到对应的库
- 连接池管理
优点:
- 对应用透明,不需要改代码
- 统一管理,方便维护
缺点:
- 多一层代理,性能有损耗
- 代理层成为单点,需要高可用
三、读写分离的路由策略
写操作: 很简单,都走主库
读操作: 有几种策略
1. 随机
java
int index = random.nextInt(slaveDBs.size());
return slaveDBs.get(index);
简单,负载均衡
2. 轮询
java
int index = counter.getAndIncrement() % slaveDBs.size();
return slaveDBs.get(index);
均匀分配
3. 加权 从库配置不同,给配置高的从库更大的权重:
java
// slave1权重3,slave2权重1
// slave1被选中概率75%,slave2概率25%
4. 最少连接 选当前连接数最少的从库,负载最均衡
四、读写分离会遇到的问题
1. 主从延迟导致的数据不一致
这是最常见也最头疼的问题!
场景:
T1: 用户在主库INSERT一条订单
T2: 立即查询这个订单 → 路由到从库 → 从库还没同步 → 查不到!
用户看到:明明下单成功了,怎么查不到?
解决方案:
① 强制路由到主库
java
// 写操作后立即读,强制走主库
@Transactional
public void createAndQuery() {
orderMapper.insert(order); // 写,走主库
Order result = orderMapper.selectById(order.getId()); // 读,也走主库
}
② 延迟读取
java
// 写完后,sleep几百毫秒再读
orderMapper.insert(order);
Thread.sleep(500); // 等从库同步
return orderMapper.selectById(order.getId());
简单粗暴,但不优雅
③ 读写都在一个事务里,走主库
java
@Transactional // 整个事务都走主库
public void createOrder() {
orderMapper.insert(order);
Order result = orderMapper.selectById(order.getId());
}
④ 缓存
java
// 写完后,放Redis
orderMapper.insert(order);
redisTemplate.set("order:" + orderId, order);
// 读的时候,先查缓存
Order order = redisTemplate.get("order:" + orderId);
if (order == null) {
order = orderMapper.selectById(orderId); // 走从库
}
推荐!不仅解决延迟,还提升性能
⑤ 半同步复制 从根本上减少主从延迟,但会降低写性能
2. 事务一致性问题
在一个事务里,读写都应该路由到主库,保证一致性:
java
@Transactional
public void transferMoney() {
accountMapper.updateById(from, -100); // 写,走主库
accountMapper.selectById(to); // 读,也应该走主库!
}
如果读走了从库,可能读到旧数据,导致逻辑错误。
解决: 在事务内的所有操作都走主库
3. 从库故障
某个从库挂了,怎么办?
解决:
- 健康检查: 定期ping从库,检测心跳
- 自动摘除: 发现从库挂了,从路由列表里删除
- 自动加入: 从库恢复后,重新加入
实际项目经验:
我们的商城系统,主库承担所有写操作,3个从库分担读操作。
遇到的坑:
- 刚开始没考虑主从延迟,用户下单后立即查看订单详情,偶尔会看不到,投诉很多
- 优化方案:
- 下单后的立即查询,强制走主库
- 把新订单放入Redis,有效期1分钟
- 列表查询走从库没问题(用户不会下单后立即看列表)
💡 读写分离适用场景:
- 读多写少(读写比例至少3:1)
- 允许一定程度的数据延迟(最终一致性)
- 写操作集中在主库,没有分散压力的需求
如果写压力也大,就需要分库分表了。
📌 四、面试回答技巧总结
1. 先总后分,层次清晰
- 先用一句话概括答案
- 再分点详细说明
- 最后总结或补充实际经验
2. 结合实际项目
- 不要只说理论,多说"我在项目中..."
- 说遇到的问题和解决方案
- 体现你真正用过,而不是背书
3. 适当展示深度
- 适当展示你知道更深的东西
- 但别主动挖坑,说了就要能讲清楚
- 比如说到MVCC,可以补充"底层是通过ReadView和版本链实现的",但面试官如果追问,你得讲得清
4. 多用对比
- 比如说索引,可以对比不同数据结构
- 说隔离级别,可以对比不同级别的问题
- 对比能体现你理解深度
5. 画图辅助
- 如果面试官允许,可以画图说明
- 比如B+树结构、主从复制流程、MVCC版本链
- 图比文字更直观
6. 诚实应对不会的问题
- 不会就说不会,别瞎编
- 可以说"这个问题我之前没深入研究过,但我的理解是..."
- 表现出学习能力和诚实态度
7. 控制时间长度
- 每个问题回答2-3分钟最合适
- 太短显得肤浅,太长显得啰嗦
- 看面试官反应,如果他想深入,会追问的
📌 总结
如果这篇文章对你有帮助,记得收藏起来,面试前看一遍,效果更佳!