跳转至

数据库面试题

数据库面试题图

40道数据库面试高频题 + 详细解答,覆盖MySQL索引、事务与锁、SQL优化、Redis缓存等核心知识点。


一、MySQL索引(10题)

1. B+树的原理是什么?为什么MySQL选择B+树作为索引结构?

B+树的基本特点:

  1. 非叶子节点只存储索引键,不存储数据,可以容纳更多键值,降低树高度
  2. 叶子节点存储所有索引键和数据指针,通过双向链表连接
  3. 所有查询都走到叶子节点,查询性能稳定(O(log n))
  4. 叶子节点的链表结构支持高效范围查询

B+树的结构示意:

Text Only
                    [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

SQL
-- 联合索引: (name, age)
SELECT name, age FROM users WHERE name = '张三';
-- name和age都在索引中,不需要回表查其他列

聚簇索引 vs 非聚簇索引:

Text Only
聚簇索引(主键):     非叶子节点[主键值]
                    叶子节点[主键值 + 完整行数据]

非聚簇索引(二级索引): 非叶子节点[索引列值]
                      叶子节点[索引列值 + 主键值]
                       ↓ 回表
                      通过主键值在聚簇索引中查找完整行

3. 什么是最左前缀匹配原则?

最左前缀原则: 联合索引按照从左到右的顺序匹配。查询条件必须包含索引最左边的列,才能使用该索引。

SQL
-- 创建联合索引
ALTER TABLE users ADD INDEX idx_a_b_c (a, b, c);

-- 索引结构: 先按a排序,a相同按b排序,b相同按c排序

各查询能否使用索引:

查询条件 是否使用索引 说明
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. 对索引列使用函数或计算

SQL
-- ❌ 索引失效
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. 隐式类型转换

SQL
-- phone是varchar类型
-- ❌ 索引失效(字符串转数字)
SELECT * FROM users WHERE phone = 13800138000;

-- ✅ 正确
SELECT * FROM users WHERE phone = '13800138000';

3. LIKE以通配符开头

SQL
-- ❌ 索引失效
SELECT * FROM users WHERE name LIKE '%张';
SELECT * FROM users WHERE name LIKE '%张%';

-- ✅ 可以使用索引
SELECT * FROM users WHERE name LIKE '张%';

4. OR条件中有非索引列

SQL
-- 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 / !=

SQL
-- ❌ 可能导致索引失效(优化器判断全表扫描更快时)
SELECT * FROM users WHERE id NOT IN (1, 2, 3);
SELECT * FROM users WHERE name != '张三';
SELECT * FROM users WHERE name IS NOT NULL;

6. 联合索引不满足最左前缀

SQL
-- 索引: (a, b, c)
-- ❌ 不满足最左前缀
SELECT * FROM users WHERE b = 2 AND c = 3;

7. 数据量小或区分度低时优化器选择全表扫描

SQL
-- gender只有男/女两个值,区分度太低
-- 优化器可能认为全表扫描更快
SELECT * FROM users WHERE gender = '男';

8. 使用SELECT * 导致无法覆盖索引

SQL
-- 联合索引: (name, age)
-- ❌ 需要回表
SELECT * FROM users WHERE name = '张三';

-- ✅ 覆盖索引,不需要回表
SELECT name, age FROM users WHERE name = '张三';

5. 如何使用EXPLAIN分析SQL执行计划?各字段含义是什么?

SQL
EXPLAIN SELECT * FROM users WHERE name = '张三';

EXPLAIN核心字段详解:

字段 含义 重点关注
id 查询序号 id大的先执行,相同自上而下
select_type 查询类型 SIMPLE/PRIMARY/SUBQUERY/DERIVED
table 访问的表
partitions 匹配的分区
type 访问类型 ⭐ 最重要的优化指标
possible_keys 可能使用的索引
key 实际使用的索引 为NULL表示没用索引
key_len 使用的索引长度 联合索引中实际用了多少列
ref 索引引用列
rows 预估扫描行数 ⭐ 越小越好
filtered 过滤比例
Extra 额外信息 ⭐ 重要优化提示

type字段(性能从好到差):

SQL
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字段常见值:

SQL
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条件中与索引相关的条件过滤推到存储引擎层执行,减少回表次数。

SQL
-- 联合索引: idx_name_age (name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age = 25;

无ICP(MySQL 5.6之前):

Text Only
1. 存储引擎使用索引找到 name LIKE '张%' 的所有记录的主键
2. 全部回表读取完整行
3. Server层再过滤 age = 25

有ICP(MySQL 5.6+):

Text Only
1. 存储引擎使用索引找到 name LIKE '张%' 的记录
2. 在存储引擎层直接用索引中的age字段判断 age = 25
3. 只有满足条件的才回表
4. 大幅减少回表次数

EXPLAIN中 Extra显示 Using index condition 表示使用了ICP。

7. 什么是回表查询?如何避免?

回表查询: 使用非聚簇索引(二级索引)查询时,先在二级索引B+树中找到主键,再通过主键到聚簇索引B+树中査找完整行数据。

SQL
-- 假设name列有普通索引idx_name
SELECT * FROM users WHERE name = '张三';

查询过程:
1. idx_name索引树中找到name='张三'的叶子节点  获得主键id=123
2. id=123到聚簇索引(主键)树中查找完整行数据     这一步就是回表

避免回表的方法 — 覆盖索引:

SQL
-- 联合索引: 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. 前缀索引

SQL
-- 对长字符串列使用前缀索引
ALTER TABLE users ADD INDEX idx_email (email(6));  -- INDEX索引加速查询
-- 缺点:无法用于ORDER BY和覆盖索引

5. 避免索引失效 - 不在索引列上做函数运算 - 避免隐式类型转换 - LIKE不以%开头

10. 什么是索引合并(Index Merge)?

索引合并: MySQL在某些情况下可以同时使用多个索引来处理一个查询,然后合并结果。

三种类型:

1. Index Merge Intersection(交集)

SQL
-- name和age各有独立索引
SELECT * FROM users WHERE name = '张三' AND age = 25;
-- 分别使用两个索引查找,取交集

2. Index Merge Union(并集)

SQL
-- name和age各有独立索引
SELECT * FROM users WHERE name = '张三' OR age = 25;
-- 分别使用两个索引查找,取并集

3. Index Merge Sort-Union(排序后并集)

SQL
SELECT * FROM users WHERE name < '张' OR age > 25;

注意: 索引合并并不总是最优的,如果经常出现索引合并,考虑建一个联合索引替代。


二、事务与锁(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+间隙锁基本解决了幻读

三种并发问题:

脏读: 读到其他事务未提交的数据

SQL
事务A: UPDATE balance SET amount=0 WHERE id=1;  (未提交)
事务B: SELECT amount FROM balance WHERE id=1;    读到0 (脏数据)
事务A: ROLLBACK;  (回滚了)
事务B读到了一个不存在的值

不可重复读: 同一事务中两次读取同一数据结果不一致

SQL
事务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 (同一行数据变了)

幻读: 同一事务中两次查询结果的行数不一致

SQL
事务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版本链

Text Only
当前版本: [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

可见性判断规则:

Text Only
对于版本链中的每个版本(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): - 锁住索引中的一条记录

SQL
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 锁住id=1这一行

2. 间隙锁(Gap Lock): - 锁住索引记录之间的间隙,防止插入 - 只在RR隔离级别下存在

SQL
-- 假设表中id有: 1, 5, 10
SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;
-- 间隙锁锁住 (5,10) 区间,防止在该区间插入新记录

3. 临键锁(Next-Key Lock): - 记录锁 + 间隙锁 = 左开右闭区间 - InnoDB默认的行锁类型

SQL
-- id有: 1, 5, 10
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- Next-Key Lock: (1, 5]

4. 意向锁(Intention Lock): - 表级锁,用于表明事务将要对表中的行加什么锁 - 意向共享锁(IS):表明将加行共享锁 - 意向排他锁(IX):表明将加行排他锁 - 作用:快速判断表级锁与行级锁是否冲突

按模式分:

锁模式 说明 兼容性
共享锁(S锁) 读锁,多个事务可同时持有 S与S兼容
排他锁(X锁) 写锁,独占 X与任何锁不兼容
SQL
-- 共享锁
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. 什么是死锁?如何检测和避免?

死锁: 两个或多个事务互相等待对方持有的锁,形成循环等待。

SQL
事务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等BB等A  死锁

MySQL死锁检测: - InnoDB内置等待图(wait-for graph)检测死锁 - 检测到死锁后,自动回滚代价最小的事务(持有锁最少的) - SHOW ENGINE INNODB STATUS; 查看最近的死锁信息

避免死锁的策略:

  1. 固定加锁顺序:所有事务按相同顺序访问资源

    SQL
    -- 总是按id升序加锁
    UPDATE users SET ... WHERE id = 1;
    UPDATE users SET ... WHERE id = 2;
    

  2. 减小事务粒度:事务尽量短小,减少持锁时间

  3. 尽量使用索引:避免行锁升级为表锁

  4. 设置超时

    SQL
    SET innodb_lock_wait_timeout = 5;  -- 等锁超时时间(秒)
    

  5. 乐观锁替代悲观锁:适合读多写少的场景

    SQL
    -- 乐观锁: 使用版本号
    UPDATE users SET name='X', version=version+1 WHERE id=1 AND version=5;
    

16. 乐观锁和悲观锁的区别是什么?

维度 乐观锁 悲观锁
思想 假设不会冲突 假设会冲突
实现 版本号/CAS 数据库锁(FOR UPDATE)
加锁时机 更新时检查 操作前加锁
适用场景 读多写少,冲突少 写多,冲突频繁
性能 冲突少时性能好 锁开销大,但稳定

乐观锁实现:

SQL
-- 方式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;

悲观锁实现:

SQL
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;  -- 加排他锁
-- 其他事务无法修改id=1的记录
UPDATE users SET name = '新名字' WHERE id = 1;
COMMIT;  -- 释放锁

17. InnoDB的行锁是如何实现的?

关键点:InnoDB的行锁是基于索引实现的!

  • 如果查询条件使用了主键索引 → 锁住主键索引对应的行
  • 如果使用了二级索引 → 先锁二级索引,再锁主键索引
  • 如果没有使用索引(全表扫描)→ 锁住所有行(相当于表锁)
SQL
-- 有索引: 只锁一行
SELECT * FROM users WHERE id = 1 FOR UPDATE;

-- 无索引: 锁全表!
SELECT * FROM users WHERE name = '张三' FOR UPDATE;
-- 如果name没有索引,会扫描全表,锁住所有行

这也是为什么强调要在WHERE条件列上建索引的重要原因之一。

18. 什么是当前读和快照读?

快照读(Consistent Nonlocking Read): - 读取的是MVCC版本链中某个快照版本 - 不加锁,并发性能好

SQL
-- 普通的SELECT就是快照读
SELECT * FROM users WHERE id = 1;

当前读(Current Read): - 读取的是数据的最新版本 - 加锁(共享锁或排他锁)

SQL
-- 以下都是当前读
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事务中,行锁是在需要时才加,但并不是不需要时立即释放,而是要等到事务提交时统一释放。

SQL
事务A:
BEGIN;
UPDATE t1 SET ... WHERE id=1;  -- 加锁
UPDATE t2 SET ... WHERE id=2;  -- 加锁
       加锁阶段(Growing Phase) 
COMMIT;                        -- 释放所有锁
       解锁阶段(Shrinking Phase) 

实践意义: 如果事务中需要锁多行,把最可能造成锁冲突、影响并发的锁操作放在事务最后,减少持锁时间。

SQL
-- 好的做法: 并发高的操作放最后
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:开启慢查询日志

SQL
-- 查看是否开启
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分析

Bash
# 按查询时间排序,显示前10条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

# 按锁时间排序
mysqldumpslow -s l -t 10 /var/log/mysql/slow.log

步骤3:EXPLAIN分析执行计划

SQL
EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at;  -- EXPLAIN查看查询执行计划
-- 检查type、key、rows、Extra

步骤4:SHOW PROFILE分析详细耗时

SQL
SET profiling = 1;
SELECT ...;  -- 执行查询
SHOW PROFILES;  -- 查看所有查询耗时
SHOW PROFILE FOR QUERY 1;  -- 查看某次查询的详细阶段耗时

步骤5:优化SQL

22. SQL优化有哪些常用技巧?

15条SQL优化技巧:

**1. 避免SELECT ***

SQL
-- ❌
SELECT * FROM users WHERE id = 1;
-- ✅ 只查需要的列,可能命中覆盖索引
SELECT id, name, age FROM users WHERE id = 1;

2. 用EXISTS代替IN(大表)

SQL
-- ❌ 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中对列做运算

SQL
-- ❌
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优化)

SQL
-- 小表做驱动表(LEFT JOIN左边放小表)
SELECT * FROM small_table s LEFT JOIN big_table b ON s.id = b.s_id;

5. LIMIT优化深分页

SQL
-- ❌ 深分页很慢(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. 批量操作

SQL
-- ❌ 逐行插入
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

SQL
-- UNION会去重(有排序开销),UNION ALL不去重
SELECT id FROM t1 UNION ALL SELECT id FROM t2;

10. GROUP BY优化

SQL
-- 确保GROUP BY的列有索引
-- 使用WITH ROLLUP获取汇总行

11. 避免大事务

12. 使用连接(JOIN)代替子查询

13. 合理使用索引提示

SQL
SELECT * FROM users FORCE INDEX (idx_name) WHERE name = '张三';

14. 拆分复杂SQL为多个简单SQL

15. 使用临时表缓存中间结果

23. 分库分表的方案和策略有哪些?

什么时候需要分库分表? - 单表数据量超过2000万行(经验值) - 单库连接数成为瓶颈 - 读写压力过大

垂直拆分:

垂直分表: 将表中的列拆分到多个表

Text Only
users表(字段太多)
users_base(id, name, age, phone)      ← 常用字段
users_detail(id, address, bio, avatar) ← 不常用字段

垂直分库: 按业务将不同表拆分到不同数据库

Text Only
电商数据库
用户库(users, addresses)
订单库(orders, order_items)
商品库(products, categories)

水平拆分:

水平分表: 将同一表的数据按行拆分到多个表

Text Only
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主从复制的原理是什么?

主从复制流程:

Text Only
主库(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. 什么是读写分离?有什么注意事项?

读写分离: 写操作发往主库,读操作发往从库,减轻主库压力。

Text Only
应用程序
  ├── 写操作 → 主库(Master)
  └── 读操作 → 从库(Slave1, Slave2, ...)

实现方式: 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+)

常用命令示例:

Text Only
# 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文件

Text Only
# 配置触发条件
save 900 1      # 900秒内有1次修改就触发
save 300 10     # 300秒内有10次修改就触发
save 60 10000   # 60秒内有10000次修改就触发

# 手动触发
SAVE            # 阻塞式保存(不推荐)
BGSAVE          # 后台异步保存(fork子进程)

AOF(Append Only File): - 记录每一个写命令,追加到AOF文件 - 重启时重放AOF文件恢复数据

Text Only
# 开启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数据安全的优点

Text Only
aof-use-rdb-preamble yes

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→全部打到数据库

Text Only
请求 key=99999 → Redis(miss) → 数据库(不存在) → 返回null → 不缓存
再次请求 key=99999 → Redis(miss) → 数据库... (每次都打到DB)

解决方案:

方案1:缓存空值

Python
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)

Python
# 提前将所有合法ID加入布隆过滤器
bloom_filter.add(all_user_ids)

def get_user(user_id):
    if not bloom_filter.exists(user_id):  # 一定不存在
        return None
    # 正常查缓存和数据库


2. 缓存击穿 — 热点key过期

问题:某个热点key过期的瞬间,大量并发请求同时打到数据库

Text Only
热点key过期 → 1000个并发请求同时到来
→ 全部缓存miss → 全部查DB → DB可能崩溃

解决方案:

方案1:互斥锁(分布式锁)

Python
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,由程序判断)

Python
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宕机,请求全部打到数据库

解决方案:

  1. 过期时间加随机值

    Python
    # 避免大量key同时过期
    ttl = base_ttl + random.randint(0, 300)  # 加0-5分钟随机
    redis.setex(key, ttl, value)
    

  2. Redis高可用(主从 + 哨兵/集群)

  3. 限流降级

    Python
    # 使用限流器,超出阈值走降级逻辑
    if rate_limiter.allow():
        return query_db()
    else:
        return default_response  # 降级返回默认值
    

  4. 多级缓存:本地缓存(Caffeine/Guava) + Redis + 数据库

30. Redis集群方案有哪些?

方案1:主从复制

Text Only
Master(读写) --复制--> Slave1(读)
                 --复制--> Slave2(读)
- 从库只读,主库读写 - 主库宕机需要手动切换

方案2:哨兵(Sentinel)

Text Only
Sentinel1  Sentinel2  Sentinel3
    ↓          ↓          ↓      (监控)
  Master → Slave1, Slave2

Master宕机 → Sentinel选举 → 自动将Slave提升为新Master
- 自动故障检测和转移 - 客户端通过Sentinel获取Master地址 - 仍然是单Master,写入有瓶颈

方案3:Redis Cluster(官方集群方案)

Text Only
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

Text Only
# 加锁
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)自动续期

  1. Redis主从切换锁丢失:主库加锁后,还未同步到从库就宕机,新主库没有锁

Redisson实现(推荐):

Java
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:

Bash
# 扫描大key
redis-cli --bigkeys

# 或使用SCAN + MEMORY USAGE
redis-cli MEMORY USAGE big_key_name

解决方案:

1. 拆分大Key

Text Only
# 大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

Python
import zlib
compressed = zlib.compress(json.dumps(data).encode())  # json.dumps将Python对象转为JSON字符串
redis.set(key, compressed)

3. 异步删除大Key

Text Only
# 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):

Text Only
MULTI                    # 开始事务
SET key1 value1          # 入队
SET key2 value2          # 入队
EXEC                     # 执行所有命令

特点: - 不支持回滚(某条命令失败,其他命令仍然执行) - 弱原子性:命令按顺序执行,但不保证全部成功 - 可以配合WATCH实现乐观锁

Lua脚本:

Text Only
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)。

Python
# 不使用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编码(更省内存)

Text Only
hash-max-ziplist-entries 512    # ziplist最大元素数
hash-max-ziplist-value 64      # ziplist单个值最大字节

2. 合理使用数据结构 - 存储简单对象用Hash而非String(Hash更省内存) - 使用Bitmap、HyperLogLog等节省空间的结构

3. 设置过期时间 - 临时数据设置合理的TTL - 避免永不过期的冗余数据

4. 压缩Value

5. 使用Redis对象共享 - Redis对0-9999的整数对象进行共享 - 使用整数作为value更节省内存

6. 避免大Key,定期清理


面试答题技巧

  1. 索引问题:先讲B+树原理,再结合EXPLAIN实际分析,最后给出优化建议
  2. 事务问题:从ACID出发,结合隔离级别和MVCC解释
  3. Redis问题:数据结构→持久化→集群→缓存问题,层层深入
  4. 优化问题:先分析(慢查询/EXPLAIN),再优化(索引/SQL改写/分库分表),有数据支撑
  5. 结合项目:举出实际项目中的缓存穿透、慢查询优化等案例

最后更新:2025年