面试准备(MySQL存粹问题版)
一、MySQL基础篇
面试官:请你介绍一下MySQL的架构,它主要由哪些部分组成?
回答:
好的,MySQL的架构可以分为三层,我从上到下给您介绍一下:
第一层是连接层,也叫客户端连接层。当我们的应用程序连接MySQL时,首先会经过这一层。它主要负责:
- 处理客户端的连接请求
- 进行身份认证,验证用户名密码
- 管理连接池,维护线程
第二层是服务层,这是MySQL的核心层,包含了很多重要组件:
- 查询缓存:不过在MySQL 8.0已经移除了,因为命中率太低
- 解析器:对SQL语句进行词法分析和语法分析,生成解析树
- 优化器:这个很重要,它会对SQL进行优化,选择最优的执行计划,比如选择用哪个索引
- 执行器:调用存储引擎接口,执行SQL语句
第三层是存储引擎层,MySQL支持插件式的存储引擎,常见的有InnoDB、MyISAM等。存储引擎负责数据的存储和读取。
一条SQL语句的执行流程大概是这样的:客户端发送SQL → 连接器验证 → 解析器解析 → 优化器优化 → 执行器执行 → 存储引擎读写数据。
面试官:你刚才提到了InnoDB和MyISAM,能详细说说它们的区别吗?
回答:
好的,这两个是MySQL最常用的存储引擎,它们的区别我从几个维度来说:
首先是事务支持方面:
- InnoDB支持事务,支持ACID特性,可以进行COMMIT和ROLLBACK操作
- MyISAM不支持事务,如果操作出错,没办法回滚
其次是锁的粒度:
- InnoDB支持行级锁,锁的粒度更细,并发性能更好
- MyISAM只支持表级锁,一个写操作会锁住整张表,并发性能较差
第三是外键约束:
- InnoDB支持外键,可以保证数据的引用完整性
- MyISAM不支持外键
第四是索引结构:
- InnoDB使用聚簇索引,数据文件和索引文件是绑定在一起的,主键索引的叶子节点直接存储数据行
- MyISAM使用非聚簇索引,数据文件和索引文件是分开的,索引的叶子节点存储的是数据的地址
第五是COUNT(*)性能:
- MyISAM会保存表的总行数,执行COUNT(*)时直接返回,非常快
- InnoDB不保存行数,需要全表扫描来统计
第六是崩溃恢复:
- InnoDB有redo log和undo log,支持崩溃后的数据恢复
- MyISAM没有这个机制,崩溃后可能丢失数据
总结一下,现在基本都推荐使用InnoDB,它从MySQL 5.5版本开始就是默认引擎了。除非是一些只读的、不需要事务的场景,否则都应该选InnoDB。
面试官:你提到InnoDB使用聚簇索引,能详细解释一下什么是聚簇索引和非聚簇索引吗?
回答:
好的,这个问题很重要,我来详细说一下。
聚簇索引,英文叫Clustered Index,它的特点是:索引和数据是存储在一起的。在InnoDB中,主键索引就是聚簇索引,它的B+Tree叶子节点直接存储的是完整的数据行。
举个形象的例子,聚簇索引就像一本按拼音顺序排列的字典,字的解释就直接跟在拼音后面,找到拼音就找到了内容。
非聚簇索引,也叫二级索引或辅助索引,它的叶子节点存储的不是数据行,而是主键的值。
比如我们在username字段上建了一个普通索引,当我们通过username查询时:
- 首先在username索引的B+Tree中找到对应的主键id
- 然后再用这个id去聚簇索引中查找完整的数据行
这个过程就叫做回表。
关于回表,我再补充一下: 回表是有性能损耗的,因为需要两次B+Tree查找。所以在实际开发中,我们会尽量避免回表,方法就是使用覆盖索引。
什么是覆盖索引呢?就是我们查询的字段刚好都在索引中,不需要回表就能拿到数据。比如:
-- 假设有联合索引 idx_name_age(name, age)SELECT name, age FROMuserWHERE name ='张三';-- 这个查询就用到了覆盖索引,不需要回表还有一点要补充:InnoDB要求表必须有主键。如果我们没有显式定义主键,InnoDB会这样处理:
- 首先找一个非空的唯一索引作为聚簇索引
- 如果也没有,就会自动生成一个6字节的隐藏主键ROW_ID
所以建表时一定要显式定义主键,而且推荐使用自增主键,因为顺序插入效率更高,不会造成页分裂。
二、索引篇
面试官:为什么MySQL选择B+Tree作为索引的数据结构,而不是B-Tree或者Hash?
回答:
这是个好问题,我从几个角度来分析:
首先说说为什么不用Hash:
Hash索引的等值查询确实很快,时间复杂度是O(1)。但它有几个致命缺点:
- 不支持范围查询:Hash只能做等值比较,像
WHERE age > 20这种范围查询就没法用 - 不支持排序:Hash是散列存储的,没有顺序
- 不支持最左前缀匹配:对于联合索引没办法部分使用
- 存在Hash冲突:大量数据时冲突会影响性能
然后说说B+Tree相比B-Tree的优势:
B-Tree的每个节点都存储数据,而B+Tree只在叶子节点存储数据,非叶子节点只存储索引键。这带来几个好处:
- 树的高度更低
- 因为非叶子节点不存数据,每个节点能存储更多的索引键
- 一个节点通常是一个磁盘页(16KB),能存更多key意味着树更矮
- 树矮了,磁盘IO次数就少了,查询更快
- 范围查询效率高
- B+Tree的叶子节点之间用双向链表连接
- 范围查询时,找到起点后沿着链表遍历就行
- B-Tree做范围查询需要中序遍历,效率低很多
- 查询效率稳定
- B+Tree所有查询都要走到叶子节点,路径长度一致
- B-Tree可能在中间节点就找到数据,查询效率不稳定
实际举个例子:
假设一个节点能存1000个key,3层的B+Tree能存储1000×1000×1000 = 10亿条数据,而3次磁盘IO就能定位到任意数据。这就是B+Tree的威力。
面试官:什么是最左前缀原则?能举例说明吗?
回答:
好的,最左前缀原则是使用联合索引时必须遵循的规则。
简单来说,对于联合索引,查询条件必须从索引的最左边开始匹配,并且不能跳过中间的列。
我举个具体例子:
假设我们有一个联合索引idx_a_b_c(a, b, c),包含a、b、c三个字段。
-- 能用到索引的情况:WHERE a =1-- 用到索引(a)WHERE a =1AND b =2-- 用到索引(a, b)WHERE a =1AND b =2AND c =3-- 用到索引(a, b, c),完全匹配-- 能部分用到索引的情况:WHERE a =1AND c =3-- 只用到(a),c用不上,因为跳过了b-- 用不到索引的情况:WHERE b =2-- 用不到,没有最左边的aWHERE b =2AND c =3-- 用不到,没有最左边的aWHERE c =3-- 用不到有一个特殊情况要注意:查询条件的顺序不影响索引使用,因为MySQL优化器会自动调整顺序。
WHERE b =2AND a =1-- 优化器会调整为 a = 1 AND b = 2,能用到索引还有一个重要的点:范围查询会导致后面的列无法使用索引。
WHERE a =1AND b >5AND c =3-- a走索引,b走索引(范围),但c就用不到索引了实际工作中的建议:
- 把区分度高的字段放在联合索引的前面
- 把等值查询的字段放在范围查询字段的前面
- 利用覆盖索引避免回表
面试官:哪些情况会导致索引失效?
回答:
索引失效是面试高频题,我总结了几种常见的情况:
1. 违反最左前缀原则
-- 联合索引 idx_a_b_c(a, b, c)SELECT*FROM t WHERE b =1;-- 没有a,索引失效2. 在索引列上使用函数或运算
-- 索引失效SELECT*FROMuserWHERELEFT(name,3)='张';SELECT*FROMuserWHERE age +1=25;SELECT*FROMuserWHEREYEAR(create_time)=2024;-- 正确写法SELECT*FROMuserWHERE name LIKE'张%';SELECT*FROMuserWHERE age =24;SELECT*FROMuserWHERE create_time >='2024-01-01'AND create_time <'2025-01-01';3. 隐式类型转换
-- 假设phone是varchar类型SELECT*FROMuserWHERE phone =13800138000;-- 数字,索引失效SELECT*FROMuserWHERE phone ='13800138000';-- 字符串,索引有效这里的原理是:当类型不匹配时,MySQL会对索引列进行类型转换,相当于加了函数,就失效了。
4. LIKE以%开头
SELECT*FROMuserWHERE name LIKE'%张';-- 索引失效SELECT*FROMuserWHERE name LIKE'%张%';-- 索引失效SELECT*FROMuserWHERE name LIKE'张%';-- 索引有效5. OR条件中有非索引列
-- 假设name有索引,status没有索引SELECT*FROMuserWHERE name ='张三'ORstatus=1;-- 整体不走索引要解决这个问题,要么给status也加索引,要么改写成UNION:
SELECT*FROMuserWHERE name ='张三'UNIONSELECT*FROMuserWHEREstatus=1;6. 使用NOT IN、NOT EXISTS、!=、<>
SELECT*FROMuserWHERE id NOTIN(1,2,3);SELECT*FROMuserWHERE name !='张三';这些操作可能导致优化器认为全表扫描更快,从而放弃索引。
7. IS NULL / IS NOT NULL(看情况)
如果表中NULL值很多,IS NOT NULL可能走索引;如果NULL值很少,IS NULL可能走索引。这个取决于数据分布和优化器的判断。
总结一句话:索引列要保持"干净",不要对它做任何加工,让它直接参与比较。
三、事务篇
面试官:说说事务的四大特性ACID,以及MySQL是如何实现的?
回答:
ACID是事务的四大特性,我来逐一说明:
A - 原子性(Atomicity)
原子性是指事务是一个不可分割的整体,要么全部成功,要么全部失败。
比如转账操作,A给B转100块:
- A的账户减100
- B的账户加100
这两步必须同时成功或同时失败,不能出现A扣了钱但B没收到的情况。
MySQL的实现方式:通过undo log(回滚日志) 来实现。在执行事务时,MySQL会把修改前的数据保存到undo log中。如果事务需要回滚,就用undo log中的数据恢复。
C - 一致性(Consistency)
一致性是指事务执行前后,数据库从一个一致状态变到另一个一致状态。比如转账前后,A和B的总金额应该不变。
MySQL的实现方式:一致性是事务的最终目标,是由其他三个特性(原子性、隔离性、持久性)共同保证的。
I - 隔离性(Isolation)
隔离性是指多个事务并发执行时,相互之间不能干扰。一个事务内部的操作对其他并发事务是隔离的。
MySQL的实现方式:通过锁和MVCC(多版本并发控制) 来实现。
- 写写冲突通过锁来解决
- 读写冲突通过MVCC来解决
D - 持久性(Durability)
持久性是指事务一旦提交,对数据的修改就是永久的,即使系统崩溃也不会丢失。
MySQL的实现方式:通过redo log(重做日志) 来实现。事务提交时,先把修改写入redo log并刷盘,这样即使系统崩溃,重启后也能通过redo log恢复数据。
补充一下redo log和undo log的区别:
- redo log:记录的是"物理日志",即数据页的修改,用于崩溃恢复
- undo log:记录的是"逻辑日志",即相反的操作,用于事务回滚和MVCC
面试官:MySQL的事务隔离级别有哪些?分别能解决什么问题?
回答:
MySQL有四种事务隔离级别,从低到高分别是:
1. 读未提交(READ UNCOMMITTED)
这是最低的隔离级别,一个事务可以读取到另一个事务未提交的数据。
问题:会产生脏读。比如事务A读到了事务B修改但未提交的数据,如果B回滚了,A读到的就是无效的脏数据。
实际开发中基本不用这个级别。
2. 读已提交(READ COMMITTED)
一个事务只能读取到其他事务已提交的数据。
解决了:脏读问题
仍存在:不可重复读。就是在同一个事务中,两次读取同一条数据可能得到不同的结果,因为期间可能有其他事务修改并提交了。
Oracle数据库的默认隔离级别就是这个。
3. 可重复读(REPEATABLE READ)
在同一个事务中多次读取同一数据,结果是一致的。
解决了:脏读、不可重复读
仍存在:幻读。就是在同一事务中,两次查询的结果集行数不同,因为有其他事务插入或删除了数据。
但是,MySQL的InnoDB在这个级别通过Next-Key Lock(临键锁) 在很大程度上解决了幻读问题。
MySQL的默认隔离级别就是REPEATABLE READ。
4. 串行化(SERIALIZABLE)
最高的隔离级别,事务串行执行,完全隔离。
解决了:脏读、不可重复读、幻读
代价:性能最差,并发度最低
我用表格总结一下:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB基本解决) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
实际工作中的选择:
- 大多数互联网项目用默认的REPEATABLE READ就够了
- 对于一些金融类的高一致性要求场景,可能会用到SERIALIZABLE
- 如果是读多写少、对一致性要求不那么高的场景,可以用READ COMMITTED提高并发性能
面试官:什么是MVCC?它是怎么实现的?
回答:
MVCC,全称Multi-Version Concurrency Control,多版本并发控制。它的核心思想是:通过保存数据的多个版本,让读操作和写操作不冲突,从而提高并发性能。
简单来说,当你读数据的时候,读的是某一个历史版本;当其他事务在写数据的时候,并不影响你的读取。这就是所谓的"快照读"。
MVCC的实现依赖三个核心组件:
1. 隐藏字段
InnoDB会为每行数据添加几个隐藏字段:
- DB_TRX_ID(6字节):记录最后一次修改该行的事务ID
- DB_ROLL_PTR(7字节):回滚指针,指向这行数据的上一个版本(在undo log中)
- DB_ROW_ID(6字节):隐藏的自增主键,如果表没有主键才会有这个
2. Undo Log(版本链)
每次修改数据时,旧版本会被保存到undo log中。多次修改就形成了一个版本链:
当前数据 → undo log版本1 → undo log版本2 → undo log版本3 → ... 通过DB_ROLL_PTR指针可以找到所有历史版本。
3. Read View(读视图)
当事务执行快照读(普通SELECT)时,会生成一个Read View,它包含:
- m_ids:当前活跃(未提交)的事务ID列表
- min_trx_id:m_ids中的最小事务ID
- max_trx_id:系统应该分配给下一个事务的ID
- creator_trx_id:创建这个Read View的事务ID
Read View的可见性判断规则:
对于版本链中的某个版本,它的trx_id与Read View比较:
- 如果trx_id < min_trx_id,说明这个版本在Read View创建之前就已经提交了,可见
- 如果trx_id >= max_trx_id,说明这个版本是在Read View创建之后才生成的,不可见
- 如果min_trx_id <= trx_id < max_trx_id:
- 如果trx_id在m_ids中,说明这个版本的事务还未提交,不可见
- 如果trx_id不在m_ids中,说明这个版本的事务已经提交,可见
不同隔离级别下Read View的生成时机不同:
- READ COMMITTED:每次SELECT都会生成新的Read View
- REPEATABLE READ:只在第一次SELECT时生成Read View,后续复用
这就是为什么RC级别会有不可重复读,而RR级别可以保证可重复读的原因。
四、锁篇
面试官:MySQL中有哪些类型的锁?
回答:
MySQL的锁可以从多个维度来分类,我来详细说一下:
一、按锁的粒度分
1. 全局锁
锁住整个数据库实例,使其处于只读状态。主要用于全库逻辑备份。
FLUSH TABLESWITHREADLOCK;-- 加锁UNLOCKTABLES;-- 解锁2. 表级锁
锁住整张表,开销小、加锁快,但并发度低。包括:
- 表锁:
LOCK TABLES t READ/WRITE - 元数据锁(MDL):访问表时自动加,防止DDL和DML冲突
- 意向锁:表级别的锁,用于快速判断表中是否有行锁
3. 行级锁
只锁住需要的行,开销大、加锁慢,但并发度高。InnoDB支持行级锁。
二、按锁的模式分
1. 共享锁(S锁/读锁)
SELECT...LOCKINSHAREMODE;-- MySQL 5.xSELECT...FORSHARE;-- MySQL 8.0+多个事务可以同时持有S锁,用于读取数据。
2. 排他锁(X锁/写锁)
SELECT...FORUPDATE;只有一个事务能持有X锁,用于修改数据。INSERT/UPDATE/DELETE会自动加X锁。
三、InnoDB的行锁类型(重点)
1. Record Lock(记录锁)
锁住索引中的一条记录。
2. Gap Lock(间隙锁)
锁住索引记录之间的间隙,防止其他事务在间隙中插入数据。这是为了解决幻读问题。
比如表中有id为1、5、10的记录,间隙锁可以锁住(1,5)、(5,10)这些区间。
3. Next-Key Lock(临键锁)
Record Lock + Gap Lock的组合,锁住一条记录以及它前面的间隙。
这是InnoDB在REPEATABLE READ级别下默认的行锁算法。
举个例子:
假设表中有id为1、5、10的记录,执行:
SELECT*FROM t WHERE id =5FORUPDATE;在RR级别下,会加Next-Key Lock,锁住(1,5]这个范围。
面试官:什么是死锁?怎么避免和解决?
回答:
什么是死锁?
死锁是指两个或多个事务在执行过程中,因互相持有对方需要的锁而造成的一种阻塞现象。如果没有外力介入,这些事务都无法继续执行。
举个经典的例子:
-- 事务ASTARTTRANSACTION;UPDATE account SET balance = balance -100WHERE id =1;-- 获得id=1的X锁-- 此时事务A持有id=1的锁,等待id=2的锁-- 事务BSTARTTRANSACTION;UPDATE account SET balance = balance -100WHERE id =2;-- 获得id=2的X锁UPDATE account SET balance = balance +100WHERE id =1;-- 等待id=1的锁-- 事务A继续UPDATE account SET balance = balance +100WHERE id =2;-- 等待id=2的锁-- 死锁产生!A等B释放id=2,B等A释放id=1如何检测和处理死锁?
InnoDB有两种策略:
1. 等待超时
参数innodb_lock_wait_timeout,默认50秒。超时后事务会回滚。
缺点:等待时间长,业务响应慢。
2. 死锁检测(推荐)
参数innodb_deadlock_detect=ON(默认开启)。
InnoDB会主动检测死锁,发现后立即回滚其中一个代价较小的事务。
如何避免死锁?
1. 按固定顺序访问资源
所有业务代码都按照相同的顺序获取锁。比如都先锁id小的,再锁id大的。
2. 减小锁的粒度和持有时间
- 尽量使用行级锁而不是表级锁
- 事务中的SQL尽量少,尽快提交
- 避免在事务中进行耗时操作(如调用外部接口)
3. 使用合理的索引
如果查询没有走索引,InnoDB会进行全表扫描并锁住所有行,更容易死锁。
4. 降低隔离级别
如果业务允许,使用READ COMMITTED级别,没有Gap Lock,死锁概率降低。
5. 使用乐观锁代替悲观锁
-- 乐观锁方式UPDATE product SET stock = stock -1, version = version +1WHERE id =1AND version =10;如何排查死锁?
-- 查看最近一次死锁信息SHOWENGINEINNODBSTATUS;-- 查看当前锁等待SELECT*FROM information_schema.INNODB_LOCKS;SELECT*FROM information_schema.INNODB_LOCK_WAITS;面试官:说说乐观锁和悲观锁的区别,以及各自的使用场景
回答:
悲观锁
悲观锁的思想是:每次操作数据时都认为会有并发冲突,所以先加锁再操作。
实现方式:数据库的锁机制,如SELECT FOR UPDATE
STARTTRANSACTION;-- 先加排他锁SELECT stock FROM product WHERE id =1FORUPDATE;-- 业务逻辑处理UPDATE product SET stock = stock -1WHERE id =1;COMMIT;特点:
- 数据安全性高
- 性能开销大(锁等待、可能死锁)
- 适合写多读少的场景
乐观锁
乐观锁的思想是:假设冲突很少发生,不加锁,而是在更新时检查数据是否被修改过。
实现方式:通常用版本号或时间戳
-- 1. 先查询数据和版本号SELECT id, stock, version FROM product WHERE id =1;-- 假设返回 stock=100, version=1-- 2. 更新时检查版本号UPDATE product SET stock = stock -1, version = version +1WHERE id =1AND version =1;-- 3. 检查受影响行数-- 如果是0,说明数据被其他事务修改过,需要重试特点:
- 性能好(无锁开销)
- 可能需要重试机制
- 适合读多写少的场景
使用场景对比:
| 场景 | 推荐使用 | 原因 |
|---|---|---|
| 电商秒杀/库存扣减 | 乐观锁 | 读多写少,用户量大 |
| 银行转账 | 悲观锁 | 资金安全优先,写操作频繁 |
| 文章点赞/阅读数 | 乐观锁 | 允许少量误差,并发量大 |
| 订单状态流转 | 悲观锁/分布式锁 | 状态一致性要求高 |
CAS(Compare And Swap):
乐观锁的本质其实就是CAS思想:比较并交换。在Java中,Atomic类就是用CAS实现的。在数据库中,我们用version字段来模拟CAS操作。
五、SQL优化篇
面试官:如何定位和优化慢SQL?
回答:
这是一个很实际的问题,我按照实际工作中的流程来说:
第一步:开启慢查询日志,定位慢SQL
-- 查看慢查询日志是否开启SHOW VARIABLES LIKE'slow_query_log';-- 开启慢查询日志SETGLOBAL slow_query_log =ON;-- 设置慢查询阈值,比如2秒SETGLOBAL long_query_time =2;-- 查看慢查询日志文件位置SHOW VARIABLES LIKE'slow_query_log_file';在生产环境中,也可以通过监控系统(如Prometheus+Grafana)来发现慢SQL。
第二步:使用EXPLAIN分析执行计划
EXPLAINSELECT*FROMuserWHERE username ='zhangsan';重点关注这几个字段:
- type:访问类型,从好到差:system > const > eq_ref > ref > range > index > ALL
- const:通过主键或唯一索引查询,最多一行
- ref:使用非唯一索引
- range:索引范围扫描
- index:全索引扫描
- ALL:全表扫描(最差,要优化)
- key:实际使用的索引,NULL表示没用索引
- rows:预估扫描的行数,越少越好
- Extra:额外信息
- Using index:覆盖索引,好
- Using filesort:需要额外排序,需要优化
- Using temporary:使用临时表,需要优化
第三步:针对性优化
1. 添加合适的索引
-- 为WHERE条件字段添加索引CREATEINDEX idx_username ONuser(username);-- 为排序字段添加索引CREATEINDEX idx_create_time ONuser(create_time);-- 联合索引CREATEINDEX idx_status_create_time ONorder(status, create_time);2. 优化SQL写法
-- 避免SELECT *SELECT id, username, email FROMuserWHERE id =1;-- 避免函数操作索引列-- 不好SELECT*FROMuserWHEREYEAR(create_time)=2024;-- 好SELECT*FROMuserWHERE create_time >='2024-01-01'AND create_time <'2025-01-01';-- 避免隐式类型转换-- phone是varchar类型-- 不好SELECT*FROMuserWHERE phone =13800138000;-- 好SELECT*FROMuserWHERE phone ='13800138000';3. 分页优化
-- 深分页问题SELECT*FROMorderLIMIT1000000,10;-- 需要扫描100万+10行-- 优化方案:延迟关联SELECT*FROMorderWHERE id >=(SELECT id FROMorderORDERBY id LIMIT1000000,1)LIMIT10;-- 或者:记录上次查询的最后一个IDSELECT*FROMorderWHERE id >1000000ORDERBY id LIMIT10;4. 批量操作
-- 不好:循环单条插入INSERTINTOuserVALUES(1,'a');INSERTINTOuserVALUES(2,'b');-- 好:批量插入INSERTINTOuserVALUES(1,'a'),(2,'b'),(3,'c');-- 大批量数据建议分批,每批500-1000条5. 合理使用JOIN
-- 小表驱动大表-- 如果department表小,用INSELECT*FROM employee WHERE dept_id IN(SELECT id FROM department);-- 如果employee表小,用EXISTSSELECT*FROM employee e WHEREEXISTS(SELECT1FROM department d WHERE d.id = e.dept_id);-- JOIN时确保关联字段有索引-- 控制JOIN的表数量,一般不超过3张面试官:你在实际项目中做过哪些SQL优化?能举个具体的例子吗?
回答:
好的,我举一个之前处理过的真实案例。
背景:
有一个订单列表查询接口,在数据量达到500万后,查询变得很慢,平均响应时间超过5秒。
原始SQL:
SELECT*FROMorderWHERE user_id =12345ANDstatusIN(1,2,3)AND create_time >='2024-01-01'ORDERBY create_time DESCLIMIT0,20;排查过程:
- 首先用EXPLAIN分析,发现type是ALL,全表扫描,没走索引
- 检查发现表上只有主键索引,没有针对查询条件的索引
优化方案:
第一步:添加联合索引
CREATEINDEX idx_user_status_time ONorder(user_id,status, create_time);添加后,type变成了range,rows从500万降到了几千。
**第二步:优化SELECT ***
-- 改成只查需要的字段SELECT id, order_no,status, amount, create_time FROMorderWHERE...第三步:考虑覆盖索引
因为查询的字段比较多,创建覆盖索引不太现实。但对于一些只需要少量字段的查询,可以考虑。
第四步:分页优化
原来的分页到后面页数时会很慢:
-- 原来的,第1000页时要扫描20000行SELECT...LIMIT19980,20;-- 优化后,使用游标分页SELECT...WHERE id < 上一页最后一条的id ORDERBY id DESCLIMIT20;优化效果:
- 查询时间从5秒+降到50毫秒以内
- 支撑了日均百万级的查询量
总结几个要点:
- 一定要有合适的索引
- 联合索引要考虑查询条件的顺序和区分度
- 避免SELECT *
- 深分页要特别处理
- 定期EXPLAIN分析关键SQL
六、其他高频问题
面试官:MySQL主从复制的原理是什么?
回答:
MySQL主从复制是通过binlog来实现的,整个过程可以分为三个步骤:
第一步:Master记录binlog
主库执行写操作时(INSERT/UPDATE/DELETE),会把变更记录到binlog(二进制日志)中。
第二步:从库IO线程读取binlog
从库有一个IO线程,它会连接到主库,读取主库的binlog,然后写入到从库本地的relay log(中继日志)中。
第三步:从库SQL线程执行relay log
从库还有一个SQL线程,它会读取relay log中的事件,在从库上重新执行一遍,从而实现数据同步。
整个流程:
Master写数据 → 写入binlog → 从库IO线程读取 → 写入relay log → 从库SQL线程执行 → 数据同步 binlog的三种格式:
- STATEMENT:记录SQL语句本身
- 优点:日志量小
- 缺点:某些函数(如NOW()、UUID())可能导致主从数据不一致
- ROW:记录每行数据的变化
- 优点:不会出现数据不一致
- 缺点:日志量大
- MIXED:混合模式,MySQL自动选择
- 一般语句用STATEMENT,特殊情况用ROW
推荐使用ROW格式,虽然日志量大,但数据一致性有保证。
主从延迟的原因和解决方案:
原因:
- 主库并发写入,从库单线程重放
- 网络延迟
- 从库机器性能差
- 大事务
解决方案:
- 使用并行复制(MySQL 5.6+)
- 从库配置更好的硬件
- 避免大事务
- 读写分离时,对实时性要求高的读走主库
面试官:如何保证MySQL和Redis缓存的数据一致性?
回答:
这是一个经典问题,我来说说常见的几种策略:
策略一:Cache Aside Pattern(旁路缓存模式)
这是最常用的策略:
- 读:先读缓存,缓存没有再读数据库,然后写入缓存
- 写:先更新数据库,再删除缓存
// 读取publicUsergetUser(Long id){User user = redis.get("user:"+ id);if(user ==null){ user = db.getUser(id); redis.set("user:"+ id, user);}return user;}// 写入publicvoidupdateUser(User user){ db.update(user); redis.delete("user:"+ user.getId());}为什么是删除缓存而不是更新缓存?
- 更新缓存可能有并发问题:A先更新DB,B后更新DB,但B先更新缓存,A后更新缓存,导致缓存是旧数据
- 删除更简单,让下次读取时重新加载
为什么是先更新DB再删缓存,而不是先删缓存再更新DB?
- 先删缓存的问题:删除缓存后、更新DB前,另一个请求读到旧数据并写入缓存,导致脏数据
策略一的问题:
即使先更新DB再删缓存,极端情况下仍可能不一致:
- 缓存刚好失效
- 请求A读DB(旧值)
- 请求B更新DB(新值)
- 请求B删除缓存
- 请求A把旧值写入缓存
不过这种情况概率很低,因为写操作通常比读操作慢。
策略二:延迟双删
为了解决上面的极端情况:
publicvoidupdateUser(User user){ redis.delete("user:"+ user.getId());// 第一次删除 db.update(user);Thread.sleep(500);// 等待一段时间 redis.delete("user:"+ user.getId());// 第二次删除}延迟时间要大于一次读操作的时间。
策略三:异步更新缓存(基于消息队列或binlog)
写操作 → 更新DB → 发送消息/监听binlog → 异步更新/删除缓存 使用Canal监听binlog的方式更可靠,缺点是有一定延迟。
总结:
- 一般场景:Cache Aside Pattern + 设置缓存过期时间
- 要求较高:延迟双删
- 强一致性要求:分布式事务或不用缓存
面试官:最后一个问题,你觉得一个合格的索引应该怎么设计?
回答:
这个问题很好,我总结一下索引设计的核心原则:
1. 选择合适的字段建索引
- 高频查询条件字段:WHERE后面经常出现的字段
- 排序字段:ORDER BY的字段
- 连接字段:JOIN ON的字段
- 高区分度字段:值的种类多,重复少(如用户ID),而不是像性别这种
2. 联合索引的设计原则
- 最左前缀:把最常用的查询条件放在最左边
- 区分度高的在前:区分度高的字段放前面,可以更快过滤数据
- 范围查询放最后:范围查询会使后面的字段无法使用索引
- 考虑覆盖索引:尽量让查询的字段都在索引中
-- 假设查询条件是:WHERE status = 1 AND type = 2 AND create_time > '2024-01-01'-- 好的设计:idx_status_type_time(status, type, create_time)-- 等值查询在前,范围查询在后3. 避免过多索引
- 索引不是越多越好,会影响写入性能
- 一般一张表的索引控制在5个以内
- 定期清理无用索引
4. 主键索引的设计
- 推荐使用自增ID,顺序插入效率高
- 分布式场景用雪花算法,也是趋势递增的
- 避免使用UUID或随机字符串,会导致页分裂
5. 避免冗余索引
-- 冗余INDEX idx_a (a)INDEX idx_a_b (a, b)-- idx_a是冗余的,idx_a_b可以覆盖它-- 不冗余INDEX idx_a_b (a, b)INDEX idx_b_a (b, a)-- 顺序不同,不冗余6. 使用前缀索引压缩长字段
-- 对于很长的字符串字段CREATEINDEX idx_email ONuser(email(10));-- 只索引前10个字符7. 实践建议
- 先有业务查询,再设计索引(不要凭空设计)
- 定期用EXPLAIN检查关键SQL
- 用慢查询日志发现问题
- 数据量小的表不需要太多索引