数据库面试题¶
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 | listpack/hashtable(旧版本也可能看到 ziplist ) | 对象存储(用户信息) |
| List | quicklist | 消息队列、最新列表 |
| Set | intset/hashtable | 标签、共同好友、去重 |
| ZSet(Sorted Set) | listpack/skiplist+hashtable(旧版本也可能看到 ziplist ) | 排行榜、延迟队列 |
扩展数据类型: - 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 高可用(主从 + 哨兵/集群)
-
限流降级
# 使用限流器,超出阈值走降级逻辑
if rate_limiter.allow():
return query_db()
else:
return default_response # 降级返回默认值
- 多级缓存:本地缓存(Caffeine/Guava) + Redis + 数据库
30. Redis 集群方案有哪些¶
方案 1 :主从复制
- 从库只读,主库读写
- 主库宕机需要手动切换
方案 2 :哨兵( Sentinel )
Sentinel1 Sentinel2 Sentinel3
↓ ↓ ↓ (监控)
Master → Slave1, Slave2
Master宕机 → Sentinel选举 → 自动将Slave提升为新Master
- 自动故障检测和转移
- 客户端通过 Sentinel 获取 Master 地址
- 仍然是单 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 / listpack :紧凑内存布局;较新的 Redis 版本更多使用 listpack 取代 ziplist - skiplist (跳表): O(log n)查找 - hashtable : O(1)查找 - quicklist (快速列表):链表 + 紧凑列表节点的组合结构;不同版本内部节点实现会有差异
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: 当元素少且值小时,Redis 会优先使用紧凑编码(新版本常见为 listpack,旧资料里常写 ziplist )
hash-max-listpack-entries 512 # listpack 最大元素数(旧版本参数名常见为 ziplist)
hash-max-listpack-value 64 # listpack 单个值最大字节
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 改写/分库分表),有数据支撑
- 结合项目:举出实际项目中的缓存穿透、慢查询优化等案例
⚠️ 核验说明(2026-04-03):本页已完成 2026-04-03 人工复核。Redis 编码与配置项已补上版本边界,避免把旧版 ziplist 资料直接写成当前通用事实。
最后更新日期: 2026-04-03