数据库面试题¶
40道数据库面试高频题 + 详细解答,覆盖MySQL索引、事务与锁、SQL优化、Redis缓存等核心知识点。
一、MySQL索引(10题)¶
1. B+树的原理是什么?为什么MySQL选择B+树作为索引结构?¶
B+树的基本特点:
- 非叶子节点只存储索引键,不存储数据,可以容纳更多键值,降低树高度
- 叶子节点存储所有索引键和数据指针,通过双向链表连接
- 所有查询都走到叶子节点,查询性能稳定(O(log n))
- 叶子节点的链表结构支持高效范围查询
B+树的结构示意:
[30 | 60] ← 根节点(非叶子)
/ | \
[10|20] [40|50] [70|80] ← 非叶子节点
/ | \ / | \ / | \
[10][20] [30][40][50][60][70][80][90] ← 叶子节点(含数据)
↔ ↔ ↔ ↔ ↔ ↔ ↔ ← 双向链表
与其他数据结构的对比:
| 数据结构 | MySQL选择原因分析 |
|---|---|
| B树 | 非叶子也存数据→每个节点存的key少→树高→磁盘IO多 |
| Hash | 等值查询O(1)但不支持范围查询、排序、最左前缀 |
| 红黑树 | 二叉树→树高度大→磁盘IO过多 |
| 跳表 | 有序链表+多级索引,范围查询好,但空间开销大,实际被Redis的ZSet使用 |
为什么MySQL选B+树? 1. 磁盘IO优化:B+树矮胖(一般3-4层),非叶子节点不存数据所以能存更多Key,一次磁盘IO读取一个节点(一页16KB)可以包含更多索引信息 2. 范围查询高效:叶子节点形成有序链表,范围查询只需定位起点后顺序遍历 3. 查询稳定:所有查询都走到叶子节点,路径长度一致 4. 排序友好:有序结构天然支持ORDER BY
InnoDB与MyISAM的B+树区别: - InnoDB:聚簇索引,叶子节点直接存储行数据(数据即索引) - MyISAM:非聚簇索引,叶子节点存储数据文件的行指针
2. MySQL有哪些索引类型?分别有什么特点?¶
按数据结构分: - B+树索引:最常用,支持范围查询(InnoDB默认) - Hash索引:等值查询O(1),Memory引擎支持 - 全文索引(FULLTEXT):文本搜索(MySQL 5.6+ InnoDB支持) - R-Tree索引:空间数据索引
按功能/逻辑分:
| 索引类型 | 说明 | 示例 |
|---|---|---|
| 主键索引 | 唯一且非空,一表一个,InnoDB聚簇索引 | PRIMARY KEY (id) |
| 唯一索引 | 值唯一,允许NULL | UNIQUE KEY idx_email (email) |
| 普通索引 | 最基本的索引,无限制 | KEY idx_name (name) |
| 联合索引(复合索引) | 多列组合索引 | KEY idx_age_name (age, name) |
| 全文索引 | 全文检索 | FULLTEXT KEY idx_content (content) |
| 前缀索引 | 索引列的前N个字符 | KEY idx_name (name(10)) |
特殊概念:
覆盖索引(Covering Index): - 查询的所有列都在索引中,无需回表 - Extra显示Using index
聚簇索引 vs 非聚簇索引:
聚簇索引(主键): 非叶子节点[主键值]
↓
叶子节点[主键值 + 完整行数据]
非聚簇索引(二级索引): 非叶子节点[索引列值]
↓
叶子节点[索引列值 + 主键值]
↓ 回表
通过主键值在聚簇索引中查找完整行
3. 什么是最左前缀匹配原则?¶
最左前缀原则: 联合索引按照从左到右的顺序匹配。查询条件必须包含索引最左边的列,才能使用该索引。
各查询能否使用索引:
| 查询条件 | 是否使用索引 | 说明 |
|---|---|---|
WHERE a = 1 | ✅ 使用a | 最左列 |
WHERE a = 1 AND b = 2 | ✅ 使用a,b | 满足最左前缀 |
WHERE a = 1 AND b = 2 AND c = 3 | ✅ 使用a,b,c | 完整匹配 |
WHERE b = 2 | ❌ 不使用 | 缺少最左列a |
WHERE b = 2 AND c = 3 | ❌ 不使用 | 缺少最左列a |
WHERE a = 1 AND c = 3 | ✅ 使用a | b缺失,c无法使用 |
WHERE a = 1 AND b > 2 AND c = 3 | ✅ 使用a,b | 范围查询后的列©不能使用索引 |
WHERE a = 1 ORDER BY b | ✅ a查找+b排序 | 避免filesort |
WHERE a > 1 AND b = 2 | ✅ 使用a | a是范围查询,b无法使用 |
核心规则总结: 1. 必须从最左列开始匹配 2. 遇到范围查询(>、<、BETWEEN、LIKE)后,右侧列无法使用索引 3. MySQL优化器会自动调整WHERE条件顺序(所以WHERE b=2 AND a=1也能用索引)
联合索引设计原则: - 等值查询多的列放左边 - 区分度高(Cardinality)的列放左边 - 排序和分组用到的列考虑放入联合索引
4. 索引失效的场景有哪些?¶
常见的8种索引失效场景:
1. 对索引列使用函数或计算
-- ❌ 索引失效
SELECT * FROM users WHERE YEAR(created_at) = 2024;
SELECT * FROM users WHERE id + 1 = 10;
-- ✅ 改写
SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
SELECT * FROM users WHERE id = 9;
2. 隐式类型转换
-- phone是varchar类型
-- ❌ 索引失效(字符串转数字)
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 正确
SELECT * FROM users WHERE phone = '13800138000';
3. LIKE以通配符开头
-- ❌ 索引失效
SELECT * FROM users WHERE name LIKE '%张';
SELECT * FROM users WHERE name LIKE '%张%';
-- ✅ 可以使用索引
SELECT * FROM users WHERE name LIKE '张%';
4. OR条件中有非索引列
-- name有索引,age无索引
-- ❌ 索引失效(整个查询全表扫描)
SELECT * FROM users WHERE name = '张三' OR age = 25;
-- ✅ 改用UNION
SELECT * FROM users WHERE name = '张三'
UNION
SELECT * FROM users WHERE age = 25;
5. NOT IN / NOT EXISTS / !=
-- ❌ 可能导致索引失效(优化器判断全表扫描更快时)
SELECT * FROM users WHERE id NOT IN (1, 2, 3);
SELECT * FROM users WHERE name != '张三';
SELECT * FROM users WHERE name IS NOT NULL;
6. 联合索引不满足最左前缀
7. 数据量小或区分度低时优化器选择全表扫描
8. 使用SELECT * 导致无法覆盖索引
-- 联合索引: (name, age)
-- ❌ 需要回表
SELECT * FROM users WHERE name = '张三';
-- ✅ 覆盖索引,不需要回表
SELECT name, age FROM users WHERE name = '张三';
5. 如何使用EXPLAIN分析SQL执行计划?各字段含义是什么?¶
EXPLAIN核心字段详解:
| 字段 | 含义 | 重点关注 |
|---|---|---|
| id | 查询序号 | id大的先执行,相同自上而下 |
| select_type | 查询类型 | SIMPLE/PRIMARY/SUBQUERY/DERIVED |
| table | 访问的表 | |
| partitions | 匹配的分区 | |
| type | 访问类型 | ⭐ 最重要的优化指标 |
| possible_keys | 可能使用的索引 | |
| key | 实际使用的索引 | 为NULL表示没用索引 |
| key_len | 使用的索引长度 | 联合索引中实际用了多少列 |
| ref | 索引引用列 | |
| rows | 预估扫描行数 | ⭐ 越小越好 |
| filtered | 过滤比例 | |
| Extra | 额外信息 | ⭐ 重要优化提示 |
type字段(性能从好到差):
system > const > eq_ref > ref > range > index > ALL
system: 表只有一行(系统表)
const: 通过主键或唯一索引查一行(WHERE id = 1)
eq_ref: 联表查询,每次匹配一行(主键/唯一索引join)
ref: 非唯一索引的等值查询(WHERE name = '张三')
range: 索引范围查询(WHERE id > 10, BETWEEN, IN)
index: 全索引扫描(遍历整个索引树)
ALL: 全表扫描 ⚠️ 需要优化
Extra字段常见值:
Using index : 覆盖索引 ✅ 好
Using where : 使用了WHERE过滤
Using index condition: 索引下推(ICP) ✅ 好
Using temporary : 使用了临时表 ⚠️ 需要优化
Using filesort : 使用了文件排序 ⚠️ 需要优化
Using join buffer : 使用了连接缓冲 ⚠️ 考虑加索引
优化目标: - type至少达到ref或range级别,避免ALL和index - Extra中避免出现Using temporary和Using filesort - rows尽可能小
6. 什么是索引下推(ICP)?¶
索引下推(Index Condition Pushdown, ICP): MySQL 5.6引入的优化,将WHERE条件中与索引相关的条件过滤推到存储引擎层执行,减少回表次数。
无ICP(MySQL 5.6之前):
有ICP(MySQL 5.6+):
1. 存储引擎使用索引找到 name LIKE '张%' 的记录
2. 在存储引擎层直接用索引中的age字段判断 age = 25
3. 只有满足条件的才回表
4. 大幅减少回表次数
EXPLAIN中 Extra显示 Using index condition 表示使用了ICP。
7. 什么是回表查询?如何避免?¶
回表查询: 使用非聚簇索引(二级索引)查询时,先在二级索引B+树中找到主键,再通过主键到聚簇索引B+树中査找完整行数据。
-- 假设name列有普通索引idx_name
SELECT * FROM users WHERE name = '张三';
查询过程:
1. 在idx_name索引树中找到name='张三'的叶子节点 → 获得主键id=123
2. 拿id=123到聚簇索引(主键)树中查找完整行数据 → 这一步就是回表
避免回表的方法 — 覆盖索引:
-- 联合索引: idx_name_age (name, age)
-- ❌ 需要回表 (SELECT * 需要索引中没有的列)
SELECT * FROM users WHERE name = '张三';
-- ✅ 覆盖索引,无需回表 (所有查询列都在索引中)
SELECT id, name, age FROM users WHERE name = '张三';
-- id是主键,name和age在联合索引中,Extra: Using index
设计原则: - 频繁查询的列考虑建联合索引实现覆盖索引 - 避免使用SELECT *,只查需要的列 - 联合索引中将频繁查询的列加入
8. 聚簇索引和非聚簇索引有什么区别?¶
| 维度 | 聚簇索引 | 非聚簇索引(二级索引) |
|---|---|---|
| 存储内容 | 叶子节点存完整行数据 | 叶子节点存主键值 |
| 数量 | 每表只有一个 | 可以有多个 |
| 物理排序 | 数据按索引排序存储 | 不影响数据物理存储 |
| 查询速度 | 直接获取数据 | 可能需要回表 |
| 默认 | InnoDB主键索引 | InnoDB非主键索引 |
InnoDB主键选择策略: 1. 有显式PRIMARY KEY → 使用它作为聚簇索引 2. 没有主键,选第一个NOT NULL的UNIQUE索引 3. 都没有 → InnoDB生成一个隐式的6字节ROWID
主键设计建议: - 使用自增ID而非UUID:自增是顺序插入,避免页分裂 - UUID作为主键会导致随机插入,产生大量页分裂,性能差 - 主键尽量小:二级索引的叶子节点都存储了主键值
9. 如何设计一个好的索引?索引优化有哪些原则?¶
索引设计原则:
1. 选择合适的列建索引 - WHERE、JOIN、ORDER BY、GROUP BY中频繁使用的列 - 区分度(Cardinality)高的列:SELECT COUNT(DISTINCT col)/COUNT(*) FROM table - 区分度>0.3适合建索引
2. 联合索引设计 - 等值查询列放左边,范围查询列放右边 - 区分度高的列放左边 - 尽可能覆盖常用查询的列(覆盖索引)
3. 避免过度索引 - 索引会降低INSERT/UPDATE/DELETE速度 - 每个索引占用额外存储空间 - 建议单表索引不超过5-6个
4. 前缀索引
-- 对长字符串列使用前缀索引
ALTER TABLE users ADD INDEX idx_email (email(6)); -- INDEX索引加速查询
-- 缺点:无法用于ORDER BY和覆盖索引
5. 避免索引失效 - 不在索引列上做函数运算 - 避免隐式类型转换 - LIKE不以%开头
10. 什么是索引合并(Index Merge)?¶
索引合并: MySQL在某些情况下可以同时使用多个索引来处理一个查询,然后合并结果。
三种类型:
1. Index Merge Intersection(交集)
2. Index Merge Union(并集)
3. Index Merge Sort-Union(排序后并集)
注意: 索引合并并不总是最优的,如果经常出现索引合并,考虑建一个联合索引替代。
二、事务与锁(10题)¶
11. 数据库事务的ACID特性分别是什么?¶
| 特性 | 英文 | 含义 | 实现机制 |
|---|---|---|---|
| 原子性 | Atomicity | 事务中的操作要么全部成功,要么全部失败回滚 | undo log |
| 一致性 | Consistency | 事务前后数据库从一个一致状态转到另一个一致状态 | 其他三个特性共同保证 |
| 隔离性 | Isolation | 并发事务之间互不干扰 | 锁 + MVCC |
| 持久性 | Durability | 事务提交后结果永久存储 | redo log |
详细说明:
原子性(undo log实现): - InnoDB通过undo log(回滚日志)记录事务修改前的数据 - 如果事务需要回滚,使用undo log恢复数据到修改前的状态
持久性(redo log实现): - 事务提交时,先写redo log到磁盘(WAL, Write-Ahead Logging) - 即使数据页还没有刷到磁盘,通过redo log也可以恢复数据 - 保证了即使数据库崩溃,已提交的事务不会丢失
隔离性(锁 + MVCC实现): - 写-写冲突:通过锁机制控制 - 写-读冲突:通过MVCC(多版本并发控制)实现
12. MySQL的四种隔离级别分别是什么?各级别会出现什么问题?¶
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交(Read Uncommitted) | ✅可能 | ✅可能 | ✅可能 |
| 读已提交(Read Committed) | ❌解决 | ✅可能 | ✅可能 |
| 可重复读(Repeatable Read) | ❌解决 | ❌解决 | ✅可能* |
| 串行化(Serializable) | ❌解决 | ❌解决 | ❌解决 |
*InnoDB在RR级别通过MVCC+间隙锁基本解决了幻读
三种并发问题:
脏读: 读到其他事务未提交的数据
事务A: UPDATE balance SET amount=0 WHERE id=1; (未提交)
事务B: SELECT amount FROM balance WHERE id=1; → 读到0 (脏数据)
事务A: ROLLBACK; (回滚了)
事务B读到了一个不存在的值
不可重复读: 同一事务中两次读取同一数据结果不一致
事务A: SELECT amount FROM balance WHERE id=1; → 100
事务B: UPDATE ... SET amount=200 WHERE id=1; COMMIT;
事务A: SELECT amount FROM balance WHERE id=1; → 200 (同一行数据变了)
幻读: 同一事务中两次查询结果的行数不一致
事务A: SELECT * FROM users WHERE age > 20; → 3行
事务B: INSERT INTO users (age) VALUES (25); COMMIT;
事务A: SELECT * FROM users WHERE age > 20; → 4行 (多了一行)
MySQL默认隔离级别:Repeatable Read(可重复读)
13. MVCC的原理是什么?¶
MVCC(Multi-Version Concurrency Control,多版本并发控制): 通过维护数据的多个版本,使得读写操作不冲突,提高并发性能。
核心组件:
1. 隐藏列 每行数据有两个隐藏列: - DB_TRX_ID:最后修改该行的事务ID - DB_ROLL_PTR:回滚指针,指向undo log中的旧版本
2. Undo Log版本链
当前版本: [name='王五', trx_id=300, roll_ptr] →
旧版本: [name='李四', trx_id=200, roll_ptr] →
更旧版本: [name='张三', trx_id=100, roll_ptr] → NULL
3. ReadView(快照读) 事务执行第一个SELECT时创建ReadView,包含: - m_ids:创建ReadView时活跃(未提交)的事务ID列表 - min_trx_id:活跃事务中最小的ID - max_trx_id:下一个要分配的事务ID(当前最大ID+1) - creator_trx_id:创建该ReadView的事务ID
可见性判断规则:
对于版本链中的每个版本(trx_id):
1. trx_id == creator_trx_id → 可见(自己修改的)
2. trx_id < min_trx_id → 可见(事务在ReadView创建前已提交)
3. trx_id >= max_trx_id → 不可见(事务在ReadView创建后才开始)
4. min_trx_id <= trx_id < max_trx_id:
- trx_id 在 m_ids 中 → 不可见(事务还未提交)
- trx_id 不在 m_ids 中 → 可见(事务已提交)
如果当前版本不可见,沿roll_ptr找上一个版本继续判断
RC和RR的MVCC区别: - RC(读已提交):每次SELECT都创建新的ReadView - RR(可重复读):只在事务的第一个SELECT时创建ReadView,之后复用
14. MySQL有哪些锁类型?¶
按粒度分:
| 锁类型 | 粒度 | 特点 |
|---|---|---|
| 表锁 | 锁整张表 | 开销小,加锁快,并发低 |
| 行锁 | 锁单行 | 开销大,加锁慢,并发高 |
| 页锁 | 锁数据页 | 介于表锁和行锁之间(BDB引擎) |
InnoDB行级锁的类型:
1. 记录锁(Record Lock): - 锁住索引中的一条记录
2. 间隙锁(Gap Lock): - 锁住索引记录之间的间隙,防止插入 - 只在RR隔离级别下存在
-- 假设表中id有: 1, 5, 10
SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;
-- 间隙锁锁住 (5,10) 区间,防止在该区间插入新记录
3. 临键锁(Next-Key Lock): - 记录锁 + 间隙锁 = 左开右闭区间 - InnoDB默认的行锁类型
4. 意向锁(Intention Lock): - 表级锁,用于表明事务将要对表中的行加什么锁 - 意向共享锁(IS):表明将加行共享锁 - 意向排他锁(IX):表明将加行排他锁 - 作用:快速判断表级锁与行级锁是否冲突
按模式分:
| 锁模式 | 说明 | 兼容性 |
|---|---|---|
| 共享锁(S锁) | 读锁,多个事务可同时持有 | S与S兼容 |
| 排他锁(X锁) | 写锁,独占 | X与任何锁不兼容 |
-- 共享锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; -- MySQL 5.x
SELECT * FROM users WHERE id = 1 FOR SHARE; -- MySQL 8.0+
-- 排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- INSERT/UPDATE/DELETE 自动加排他锁
15. 什么是死锁?如何检测和避免?¶
死锁: 两个或多个事务互相等待对方持有的锁,形成循环等待。
事务A:
1. UPDATE users SET name='X' WHERE id=1; -- 锁住id=1
2. UPDATE users SET name='Y' WHERE id=2; -- 等待id=2的锁 (被事务B持有)
事务B:
1. UPDATE users SET name='X' WHERE id=2; -- 锁住id=2
2. UPDATE users SET name='Y' WHERE id=1; -- 等待id=1的锁 (被事务A持有)
结果: A等B,B等A → 死锁
MySQL死锁检测: - InnoDB内置等待图(wait-for graph)检测死锁 - 检测到死锁后,自动回滚代价最小的事务(持有锁最少的) - SHOW ENGINE INNODB STATUS; 查看最近的死锁信息
避免死锁的策略:
-
固定加锁顺序:所有事务按相同顺序访问资源
-
减小事务粒度:事务尽量短小,减少持锁时间
-
尽量使用索引:避免行锁升级为表锁
-
设置超时:
-
乐观锁替代悲观锁:适合读多写少的场景
16. 乐观锁和悲观锁的区别是什么?¶
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 思想 | 假设不会冲突 | 假设会冲突 |
| 实现 | 版本号/CAS | 数据库锁(FOR UPDATE) |
| 加锁时机 | 更新时检查 | 操作前加锁 |
| 适用场景 | 读多写少,冲突少 | 写多,冲突频繁 |
| 性能 | 冲突少时性能好 | 锁开销大,但稳定 |
乐观锁实现:
-- 方式1: 版本号
-- 1. 查询时获取版本号
SELECT id, name, version FROM users WHERE id = 1; -- version=5
-- 2. 更新时检查版本号
UPDATE users SET name='新名字', version=6 WHERE id = 1 AND version = 5;
-- 影响0行说明被其他事务修改了,需要重试
-- 方式2: CAS (Compare And Swap)
UPDATE stock SET count = count - 1 WHERE id = 1 AND count > 0;
悲观锁实现:
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 加排他锁
-- 其他事务无法修改id=1的记录
UPDATE users SET name = '新名字' WHERE id = 1;
COMMIT; -- 释放锁
17. InnoDB的行锁是如何实现的?¶
关键点:InnoDB的行锁是基于索引实现的!
- 如果查询条件使用了主键索引 → 锁住主键索引对应的行
- 如果使用了二级索引 → 先锁二级索引,再锁主键索引
- 如果没有使用索引(全表扫描)→ 锁住所有行(相当于表锁)
-- 有索引: 只锁一行
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 无索引: 锁全表!
SELECT * FROM users WHERE name = '张三' FOR UPDATE;
-- 如果name没有索引,会扫描全表,锁住所有行
这也是为什么强调要在WHERE条件列上建索引的重要原因之一。
18. 什么是当前读和快照读?¶
快照读(Consistent Nonlocking Read): - 读取的是MVCC版本链中某个快照版本 - 不加锁,并发性能好
当前读(Current Read): - 读取的是数据的最新版本 - 加锁(共享锁或排他锁)
-- 以下都是当前读
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 排他锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; -- 共享锁
INSERT INTO users VALUES (...); -- 排他锁
UPDATE users SET ... WHERE id = 1; -- 排他锁
DELETE FROM users WHERE id = 1; -- 排他锁
19. MySQL的两阶段锁是什么?¶
两阶段锁协议(2PL, Two-Phase Locking):
在InnoDB事务中,行锁是在需要时才加,但并不是不需要时立即释放,而是要等到事务提交时统一释放。
事务A:
BEGIN;
UPDATE t1 SET ... WHERE id=1; -- 加锁
UPDATE t2 SET ... WHERE id=2; -- 加锁
← 加锁阶段(Growing Phase) →
COMMIT; -- 释放所有锁
← 解锁阶段(Shrinking Phase) →
实践意义: 如果事务中需要锁多行,把最可能造成锁冲突、影响并发的锁操作放在事务最后,减少持锁时间。
-- 好的做法: 并发高的操作放最后
BEGIN; -- 事务保证操作原子性
INSERT INTO order_log ...; -- 并发低的操作先做
UPDATE user_balance ...; -- 并发高的操作放后面,减少持锁时间
COMMIT;
20. binlog、redo log和undo log的区别是什么?¶
| 日志 | 作用 | 层次 | 写入时机 |
|---|---|---|---|
| binlog | 主从复制 / 数据恢复 | MySQL Server层 | 事务提交时 |
| redo log | 崩溃恢复(保证持久性) | InnoDB引擎层 | 事务执行中(WAL) |
| undo log | 事务回滚 / MVCC | InnoDB引擎层 | 事务执行前 |
redo log: - 物理日志,记录"在某个数据页上做了什么修改" - 循环写入(固定大小的文件组) - WAL(Write-Ahead Logging):数据修改先写redo log再改内存 - 崩溃恢复时redo log重放已提交事务
undo log: - 逻辑日志,记录修改前的数据(回滚用) - 同时用于MVCC的版本链 - INSERT → undo log记录DELETE - UPDATE → undo log记录旧值
binlog: - 逻辑日志,记录SQL语句(Statement格式)或行变更(Row格式) - 追加写入(不覆盖) - 用于主从复制和基于时间点的数据恢复 - 三种格式:Statement(SQL语句)、Row(行变更)、Mixed(混合)
三、SQL与优化(8题)¶
21. 如何分析慢查询?¶
步骤1:开启慢查询日志
-- 查看是否开启
SHOW VARIABLES LIKE 'slow_query_log%';
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 1; -- 超过1秒记录
步骤2:使用mysqldumpslow分析
# 按查询时间排序,显示前10条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 按锁时间排序
mysqldumpslow -s l -t 10 /var/log/mysql/slow.log
步骤3:EXPLAIN分析执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at; -- EXPLAIN查看查询执行计划
-- 检查type、key、rows、Extra
步骤4:SHOW PROFILE分析详细耗时
SET profiling = 1;
SELECT ...; -- 执行查询
SHOW PROFILES; -- 查看所有查询耗时
SHOW PROFILE FOR QUERY 1; -- 查看某次查询的详细阶段耗时
步骤5:优化SQL
22. SQL优化有哪些常用技巧?¶
15条SQL优化技巧:
**1. 避免SELECT ***
-- ❌
SELECT * FROM users WHERE id = 1;
-- ✅ 只查需要的列,可能命中覆盖索引
SELECT id, name, age FROM users WHERE id = 1;
2. 用EXISTS代替IN(大表)
-- ❌ IN子查询
SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE status = 1); -- 子查询:嵌套在另一个查询中
-- ✅ EXISTS
SELECT * FROM orders o WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.status = 1);
3. 避免在WHERE中对列做运算
-- ❌
SELECT * FROM users WHERE YEAR(created_at) = 2024;
-- ✅
SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
4. 小表驱动大表(JOIN优化)
5. LIMIT优化深分页
-- ❌ 深分页很慢(OFFSET越大越慢)
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
-- ✅ 使用游标分页
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 10;
-- ✅ 延迟关联
SELECT * FROM orders o INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) t ON o.id = t.id; -- JOIN连接多个表
6. 合理使用联合索引
7. 避免隐式类型转换
8. 批量操作
-- ❌ 逐行插入
INSERT INTO users VALUES (1, 'a');
INSERT INTO users VALUES (2, 'b');
-- ✅ 批量插入
INSERT INTO users VALUES (1, 'a'), (2, 'b'), (3, 'c');
9. 使用UNION ALL替代UNION
10. GROUP BY优化
11. 避免大事务
12. 使用连接(JOIN)代替子查询
13. 合理使用索引提示
14. 拆分复杂SQL为多个简单SQL
15. 使用临时表缓存中间结果
23. 分库分表的方案和策略有哪些?¶
什么时候需要分库分表? - 单表数据量超过2000万行(经验值) - 单库连接数成为瓶颈 - 读写压力过大
垂直拆分:
垂直分表: 将表中的列拆分到多个表
users表(字段太多)
↓
users_base(id, name, age, phone) ← 常用字段
users_detail(id, address, bio, avatar) ← 不常用字段
垂直分库: 按业务将不同表拆分到不同数据库
水平拆分:
水平分表: 将同一表的数据按行拆分到多个表
orders表(数据量太大)
↓
orders_0 (id % 4 = 0)
orders_1 (id % 4 = 1)
orders_2 (id % 4 = 2)
orders_3 (id % 4 = 3)
水平分库: 将数据分散到多个数据库实例
分片策略:
| 策略 | 方式 | 优点 | 缺点 |
|---|---|---|---|
| Hash取模 | id % N | 数据均匀 | 扩容需要迁移数据 |
| 范围分片 | id 1-1000W在库1 | 扩容方便 | 可能热点不均 |
| 时间维度 | 按月/年分表 | 适合时序数据 | 查询跨表复杂 |
| 一致性Hash | 虚拟节点 | 扩容迁移数据少 | 实现复杂 |
全局唯一ID方案: - UUID:简单,但无序,影响索引性能 - 数据库自增:简单,但有性能瓶颈 - Snowflake雪花算法:时间戳+机器ID+序列号,趋势递增 - Redis自增:利用Redis的INCR命令 - 号段模式:每次从数据库获取一段ID缓存在本地(如Leaf、Tinyid)
24. MySQL主从复制的原理是什么?¶
主从复制流程:
主库(Master) 从库(Slave)
| |
| 1. 数据变更写入binlog |
|------ binlog ---------------------->| 2. IO线程读取binlog
| | 写入relay log(中继日志)
| |
| | 3. SQL线程读取relay log
| | 重放SQL语句
| | 更新从库数据
三个线程: - 主库:Binlog Dump Thread — 发送binlog给从库 - 从库:IO Thread — 接收binlog,写入relay log - 从库:SQL Thread — 读取relay log,执行SQL
binlog三种格式:
| 格式 | 记录内容 | 优点 | 缺点 |
|---|---|---|---|
| Statement | SQL语句 | 日志量小 | 函数/变量可能导致不一致 |
| Row | 行数据变更 | 数据一致性好 | 日志量大 |
| Mixed | 混合 | 折中 | 复杂 |
主从延迟问题: - 原因:从库单线程重放,主库并行写入 - 解决: - MySQL 5.7+ 多线程复制(MTS) - 半同步复制(主库等待至少一个从库确认才返回) - 强制读主库(关键业务)
25. 什么是读写分离?有什么注意事项?¶
读写分离: 写操作发往主库,读操作发往从库,减轻主库压力。
实现方式: 1. 代码层:根据SQL类型路由到主/从库 2. 中间件:MyCat、ShardingSphere、ProxySQL 3. 云服务:RDS读写分离代理
注意事项: - 主从延迟导致数据不一致:写入后立即读取可能读到旧数据 - 解决:关键业务强制读主库(写完立即查的场景) - 解决:延迟监控 + 半同步复制 - 从库可能被大查询阻塞 - 需要监控主从延迟:SHOW SLAVE STATUS\G 中的 Seconds_Behind_Master
四、Redis(12题)¶
26. Redis有哪些数据类型?各自的应用场景是什么?¶
| 数据类型 | 底层结构 | 应用场景 |
|---|---|---|
| String | SDS(Simple Dynamic String) | 缓存、计数器、分布式锁、Session |
| Hash | ziplist/hashtable | 对象存储(用户信息) |
| List | ziplist/quicklist | 消息队列、最新列表 |
| Set | intset/hashtable | 标签、共同好友、去重 |
| ZSet(Sorted Set) | ziplist/skiplist+hashtable | 排行榜、延迟队列 |
扩展数据类型: - Bitmap:签到、活跃用户统计 - HyperLogLog:UV统计(基数估计) - GEO:附近的人、地理位置 - Stream:消息队列(Redis 5.0+)
常用命令示例:
# String
SET key value EX 3600 # 设置,过期时间3600秒
GET key # 获取
INCR counter # 自增
SETNX lock_key value # 不存在才设置(分布式锁)
# Hash
HSET user:1 name "张三" age 25
HGET user:1 name
HGETALL user:1
# List
LPUSH queue task1 task2 # 左边入队
RPOP queue # 右边出队
LRANGE list 0 -1 # 获取所有
# Set
SADD tags:article1 "Java" "Redis"
SMEMBERS tags:article1
SINTER tags:article1 tags:article2 # 交集
# ZSet
ZADD ranking 100 "user1" 200 "user2"
ZREVRANGE ranking 0 9 WITHSCORES # Top10
ZRANK ranking "user1" # 排名
27. Redis的持久化方式有哪些?RDB和AOF的区别?¶
RDB(Redis DataBase)快照: - 将某个时间点的内存数据以二进制形式保存到磁盘 - 生成dump.rdb文件
# 配置触发条件
save 900 1 # 900秒内有1次修改就触发
save 300 10 # 300秒内有10次修改就触发
save 60 10000 # 60秒内有10000次修改就触发
# 手动触发
SAVE # 阻塞式保存(不推荐)
BGSAVE # 后台异步保存(fork子进程)
AOF(Append Only File): - 记录每一个写命令,追加到AOF文件 - 重启时重放AOF文件恢复数据
# 开启AOF
appendonly yes
appendfilename "appendonly.aof"
# 同步策略
appendfsync always # 每次写入都同步(最安全, 最慢)
appendfsync everysec # 每秒同步一次(默认, 推荐)
appendfsync no # 由操作系统决定何时同步(最快, 不安全)
AOF重写(Rewrite): - AOF文件过大时,自动重写压缩(删除冗余命令) - 例:100次INCR counter → 合并为SET counter 100
RDB vs AOF对比:
| 维度 | RDB | AOF |
|---|---|---|
| 持久化方式 | 快照 | 日志追加 |
| 数据安全 | 可能丢失最后一次快照后的数据 | 最多丢1秒数据(everysec) |
| 文件大小 | 压缩二进制, 文件小 | 文本格式, 文件大 |
| 恢复速度 | 快(直接加载) | 慢(重放命令) |
| 性能影响 | fork子进程, 可能有延迟 | 追加写入, 影响较小 |
混合持久化(Redis 4.0+): - AOF重写时,将RDB数据写入AOF文件头部,增量部分仍用AOF格式 - 兼具RDB快速恢复和AOF数据安全的优点
28. Redis的内存淘汰策略有哪些?¶
当Redis使用内存超过maxmemory设置时,触发内存淘汰。
8种淘汰策略:
| 策略 | 说明 |
|---|---|
| noeviction | 不淘汰,写入操作返回错误(默认) |
| allkeys-lru | 所有key中淘汰最近最少使用的(推荐) |
| allkeys-lfu | 所有key中淘汰最不经常使用的(Redis 4.0+) |
| allkeys-random | 所有key中随机淘汰 |
| volatile-lru | 设置了过期时间的key中淘汰LRU |
| volatile-lfu | 设置了过期时间的key中淘汰LFU |
| volatile-random | 设置了过期时间的key中随机淘汰 |
| volatile-ttl | 淘汰最快过期的key |
LRU vs LFU: - LRU(Least Recently Used):淘汰最久没被访问的 - LFU(Least Frequently Used):淘汰访问频率最低的
推荐使用: - 通用场景:allkeys-lru - 有热点数据:allkeys-lfu(更精确识别热点)
Redis过期键删除策略: - 惰性删除:访问key时检查是否过期,过期则删除 - 定期删除:每隔一段时间随机检查一批key,删除过期的 - 两者结合使用
29. 缓存穿透、缓存击穿、缓存雪崩分别是什么?如何解决?¶
1. 缓存穿透 — 查不存在的数据
问题:大量请求查询不存在的数据,缓存miss→全部打到数据库
请求 key=99999 → Redis(miss) → 数据库(不存在) → 返回null → 不缓存
再次请求 key=99999 → Redis(miss) → 数据库... (每次都打到DB)
解决方案:
方案1:缓存空值
def get_user(user_id):
value = redis.get(f"user:{user_id}")
if value is not None:
return None if value == "" else json.loads(value) # json.loads将JSON字符串转为Python对象
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user is None:
redis.setex(f"user:{user_id}", 300, "") # 缓存空值5分钟
return None
redis.setex(f"user:{user_id}", 3600, json.dumps(user))
return user
方案2:布隆过滤器(Bloom Filter)
# 提前将所有合法ID加入布隆过滤器
bloom_filter.add(all_user_ids)
def get_user(user_id):
if not bloom_filter.exists(user_id): # 一定不存在
return None
# 正常查缓存和数据库
2. 缓存击穿 — 热点key过期
问题:某个热点key过期的瞬间,大量并发请求同时打到数据库
解决方案:
方案1:互斥锁(分布式锁)
def get_hot_data(key):
value = redis.get(key)
if value:
return value
lock_key = f"lock:{key}"
if redis.setnx(lock_key, 1): # 获取锁
redis.expire(lock_key, 10)
try: # try/except捕获异常
value = db.query(...)
redis.setex(key, 3600, value)
finally:
redis.delete(lock_key) # 释放锁
return value
else:
time.sleep(0.1) # 等待
return get_hot_data(key) # 重试
方案2:逻辑过期(不设TTL,由程序判断)
def get_hot_data(key):
data = redis.get(key) # 永不过期
if data and data['expire_time'] > time.time():
return data['value']
# 逻辑过期,异步更新
if try_lock(key):
thread_pool.submit(refresh_cache, key)
return data['value'] # 返回旧值
方案3:热点数据永不过期
3. 缓存雪崩 — 大量key同时过期
问题:大量缓存key在同一时间过期,或Redis宕机,请求全部打到数据库
解决方案:
-
过期时间加随机值
-
Redis高可用(主从 + 哨兵/集群)
-
限流降级
-
多级缓存:本地缓存(Caffeine/Guava) + Redis + 数据库
30. Redis集群方案有哪些?¶
方案1:主从复制
- 从库只读,主库读写 - 主库宕机需要手动切换方案2:哨兵(Sentinel)
Sentinel1 Sentinel2 Sentinel3
↓ ↓ ↓ (监控)
Master → Slave1, Slave2
Master宕机 → Sentinel选举 → 自动将Slave提升为新Master
方案3:Redis Cluster(官方集群方案)
Node1(Master) ←→ Node2(Master) ←→ Node3(Master)
↕ ↕ ↕
Node4(Slave) Node5(Slave) Node6(Slave)
16384个hash slot分配给3个Master:
Node1: slot 0-5460
Node2: slot 5461-10922
Node3: slot 10923-16383
- 数据分布在多个节点,通过hash slot分片
- key的slot = CRC16(key) % 16384
- 每个Master可以有Slave,自动failover
- 支持水平扩展
| 维度 | 主从 | 哨兵 | Cluster |
|---|---|---|---|
| 故障转移 | 手动 | 自动 | 自动 |
| 写扩展 | 不支持 | 不支持 | 支持 |
| 数据分布 | 全量复制 | 全量复制 | 分片存储 |
| 适用场景 | 简单读扩展 | 高可用 | 大规模分布式 |
31. Redis分布式锁怎么实现?有什么问题?¶
基础实现:SETNX
# 加锁
SET lock_key unique_value NX EX 30
# NX: 只在key不存在时设置 (互斥)
# EX 30: 30秒过期 (防止死锁)
# 解锁 (Lua脚本保证原子性)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
为什么用Lua脚本解锁? - GET和DEL是两步操作,不是原子的 - 可能出现:A的锁超时释放了 → B获取了锁 → A删除了B的锁 - Lua脚本在Redis中原子执行,先判断value是否是自己的再删除
存在的问题: 1. 锁过期,业务未完成:业务执行时间超过锁的过期时间 - 解决:看门狗(Watchdog)自动续期
- Redis主从切换锁丢失:主库加锁后,还未同步到从库就宕机,新主库没有锁
Redisson实现(推荐):
RLock lock = redissonClient.getLock("myLock");
try { // try/catch捕获异常
lock.lock(); // 加锁(自动续期,看门狗每10秒续30秒)
// 业务逻辑
} finally {
lock.unlock(); // 解锁
}
Redisson特性: - 看门狗自动续期(Watchdog, 默认30秒锁,每10秒续期一次) - 可重入锁 - 公平锁 - 联锁(MultiLock) - 红锁(RedLock)
RedLock算法(解决主从切换问题): - 使用N个(建议5个)独立的Redis实例 - 向所有实例请求加锁 - 超过半数(N/2+1)实例加锁成功才算获取锁 - 开销大,争议性大,实际使用不多
32. Redis为什么这么快?¶
1. 纯内存操作 - 数据存储在内存中,读写速度远快于磁盘
2. 单线程模型(核心操作) - 避免了多线程的上下文切换和锁竞争开销 - Redis 6.0+引入多线程IO,但命令执行仍是单线程
3. 高效的数据结构 - SDS(动态字符串):O(1)获取长度,预分配空间 - ziplist(压缩列表):紧凑内存布局,缓存命中率高 - skiplist(跳表):O(log n)查找 - hashtable:O(1)查找 - quicklist(快速列表):ziplist的双向链表
4. IO多路复用(epoll) - 一个线程处理大量Socket连接 - 非阻塞IO,高效的事件驱动模型
5. 通信协议简单 - RESP(Redis Serialization Protocol)协议简单高效 - 文本协议,易于解析
6. 渐进式rehash - 扩容时不一次性迁移所有数据,而是分步进行
33. Redis大Key问题如何解决?¶
什么是大Key? - String类型 value > 10KB - Hash/List/Set/ZSet 元素数量 > 5000 或总大小 > 10MB
大Key的危害: 1. 内存不均:集群中某个节点内存远大于其他节点 2. 阻塞:大key的读写、删除操作可能阻塞Redis 3. 网络流量:获取大key消耗大量网络带宽 4. 主从延迟:大key的同步影响复制延迟
发现大Key:
解决方案:
1. 拆分大Key
# 大Hash拆分
user:1 {name, age, email, address, ...100个字段}
↓
user:1:base {name, age, email}
user:1:detail {address, bio, avatar}
# 大List拆分
list:big → list:0, list:1, list:2 (按范围拆分)
2. 压缩Value
import zlib
compressed = zlib.compress(json.dumps(data).encode()) # json.dumps将Python对象转为JSON字符串
redis.set(key, compressed)
3. 异步删除大Key
# Redis 4.0+ 异步删除
UNLINK big_key # 后台线程删除,不阻塞主线程
# 或者渐进式删除
# 对于Hash: HSCAN + HDEL
# 对于List: LTRIM
# 对于Set: SSCAN + SREM
4. 设置合理的过期时间
5. 监控告警:定期检测大Key并告警
34. Redis的过期键删除策略是什么?¶
Redis使用两种策略结合删除过期键:
1. 惰性删除(Lazy Deletion) - 访问key时才检查是否过期 - 过期则删除并返回nil - 优点:CPU友好 - 缺点:大量过期key不被访问时会占用内存
2. 定期删除(Periodic Deletion) - Redis默认每秒10次(hz配置)随机检查一批键 - 每次检查时: 1. 从设置了过期时间的键中随机取20个 2. 删除其中过期的键 3. 如果过期键比例>25%,重复步骤1 4. 否则等待下一次检查 - 优点:平衡了CPU和内存 - 缺点:可能有延迟删除
RDB和AOF对过期键的处理: - RDB生成时不保存过期键 - AOF写入时过期键的DEL命令追加到AOF - 主从复制中,从库的过期键由主库的DEL命令控制
35. Redis事务和Lua脚本有什么区别?¶
Redis事务(MULTI/EXEC):
特点: - 不支持回滚(某条命令失败,其他命令仍然执行) - 弱原子性:命令按顺序执行,但不保证全部成功 - 可以配合WATCH实现乐观锁
Lua脚本:
EVAL "redis.call('SET', KEYS[1], ARGV[1]); redis.call('SET', KEYS[2], ARGV[2])" 2 key1 key2 val1 val2
特点: - 真正的原子性:脚本中的所有操作原子执行 - 可以包含条件判断和复杂逻辑 - 性能好:减少网络往返
推荐使用Lua脚本处理需要原子性的操作,如分布式锁的获取和释放。
36. Redis的Pipeline是什么?有什么好处?¶
Pipeline(管道): 一次性发送多个命令给Redis,减少网络RTT(Round-Trip Time)。
# 不使用Pipeline(3次网络往返)
redis.set("key1", "val1") # RTT
redis.set("key2", "val2") # RTT
redis.set("key3", "val3") # RTT
# 使用Pipeline(1次网络往返)
pipe = redis.pipeline()
pipe.set("key1", "val1")
pipe.set("key2", "val2")
pipe.set("key3", "val3")
pipe.execute() # 一次性发送,一次性接收
Pipeline的特点: - 显著减少网络延迟(RTT),提升吞吐量 - 非原子操作(命令之间可能插入其他客户端的命令) - 命令在服务端按序执行 - 适合批量操作(批量SET/GET)
Pipeline vs 事务 vs Lua: | 维度 | Pipeline | 事务(MULTI) | Lua脚本 | |------|----------|-------------|---------| | 网络往返 | 1次 | 2次(MULTI+EXEC) | 1次 | | 原子性 | 无 | 弱 | 强 | | 复杂逻辑 | 不支持 | 不支持 | 支持 |
37. Redis的内存优化有哪些方法?¶
1. 选择合适的编码 - Hash: 当元素少且值小时使用ziplist编码(更省内存)
2. 合理使用数据结构 - 存储简单对象用Hash而非String(Hash更省内存) - 使用Bitmap、HyperLogLog等节省空间的结构
3. 设置过期时间 - 临时数据设置合理的TTL - 避免永不过期的冗余数据
4. 压缩Value
5. 使用Redis对象共享 - Redis对0-9999的整数对象进行共享 - 使用整数作为value更节省内存
6. 避免大Key,定期清理
面试答题技巧¶
- 索引问题:先讲B+树原理,再结合EXPLAIN实际分析,最后给出优化建议
- 事务问题:从ACID出发,结合隔离级别和MVCC解释
- Redis问题:数据结构→持久化→集群→缓存问题,层层深入
- 优化问题:先分析(慢查询/EXPLAIN),再优化(索引/SQL改写/分库分表),有数据支撑
- 结合项目:举出实际项目中的缓存穿透、慢查询优化等案例
最后更新:2025年