原则:不只会写 SQL,还要会优化。 MySQL 索引原理 + Redis 缓存策略 + 消息队列异步解耦 = 高并发系统的三条腿。


一、MySQL 深度优化(★★★★★)

1.1 B+ 树索引原理

为什么 MySQL 用 B+ 树而不用 B 树?

B+ 树优势:
1. 数据全部存在叶子节点,非叶子节点只存 key → 非叶子节点可以存更多索引,树更矮
2. 叶子节点之间通过双向链表连接 → 范围查询只需遍历链表,不需要回溯
3. 查询稳定性:所有查询都走到叶子节点,IO 次数固定

B 树缺点:
1. 数据分布在所有节点 → 非叶子节点能存的索引少,树更高
2. 范围查询需要中序遍历 → 效率低
3. 查询不稳定:可能在根节点就命中,也可能到叶子节点

InnoDB 页大小:默认 16KB
假设一行数据 1KB,key 占 8 字节,指针占 6 字节:
- 非叶子节点:16KB / (8+6)B ≈ 1170 个索引项
- 叶子节点:16KB / 1KB ≈ 16 行数据
- 3 层 B+ 树 ≈ 1170² × 16 ≈ 2000 万行
- 所以 2000 万数据,主键查询只需 3 次 IO

1.2 聚簇索引 vs 非聚簇索引

对比维度聚簇索引(主键索引)非聚簇索引(二级索引)
叶子节点存储完整行数据主键 ID(需要回表)
数量限制1 个(只有一个物理排序)多个
查找流程走 B+ 树 → 叶子节点直接拿到数据走 B+ 树 → 叶子节点拿到主键 → 回表查询聚簇索引
插入性能可能页分裂(主键不是自增时)较快

覆盖索引:查询列都在索引中,不需要回表,Extra 列显示 Using index。

1.3 联合索引与最左匹配原则

索引:INDEX(a, 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 a = 1 AND c = 3          ✓ 使用 a(c 不走索引,因为跳过了 b)
WHERE b = 2                    ✗ 全表扫描(跳过了 a)

索引失效场景(面试重点):
1. 对索引列使用函数:WHERE LEFT(name, 3) = 'abc' → 失效
2. 隐式类型转换:WHERE phone = 13800138000 → phone 是 varchar,失效
3. LIKE 前缀模糊:WHERE name LIKE '%abc' → 失效('abc%' 有效)
4. OR 条件包含非索引列:WHERE a=1 OR b=2 → 如果 b 无索引则全表扫描
5. 联合索引不满足最左匹配
6. 范围查询右边的列失效:WHERE a=1 AND b>10 AND c=3 → c 不走索引
7. != / <> / NOT IN:可能导致索引失效(优化器可能选择全表扫描)
8. IS NULL / IS NOT NULL:取决于数据分布

1.4 MVCC 多版本并发控制

核心组成:
- 隐藏列:DB_TRX_ID(事务 ID)、DB_ROLL_PTR(回滚指针)、DB_ROW_ID
- Undo Log:记录数据的历史版本,形成版本链
- Read View:快照读时判断哪些版本可见

Read View 可见性判断:
1. 版本的事务 ID = 当前事务 ID → 可见(自己的修改)
2. 版本的事务 ID < min_trx_id → 可见(已提交的老事务)
3. 版本的事务 ID > max_trx_id → 不可见(未开始的事务)
4. min_trx_id ≤ 版本的事务 ID ≤ max_trx_id → 看是否在活跃列表中
   - 在活跃列表中 → 不可见(未提交)
   - 不在活跃列表中 → 可见(已提交)

RC(读已提交):每次 SELECT 生成新的 Read View
RR(可重复读):事务第一次 SELECT 生成 Read View,后续复用

1.5 MySQL 锁机制

锁类型粒度说明
全局锁整个数据库FLUSH TABLES WITH READ LOCK,全库只读
表锁表级别LOCK TABLE t1 READ,MyISAM 默认
行锁行级别InnoDB 通过索引实现,锁的是索引记录
间隙锁间隙锁定索引记录之间的间隙(RR 级别下防幻读)
临键锁行 + 间隙行锁 + 前间隙的合体(InnoDB RR 级别默认)
意向锁表级别IS/IX,表锁和行锁的桥梁

死锁排查:SHOW ENGINE INNODB STATUS 查看 LATEST DETECTED DEADLOCK 部分。

1.6 EXPLAIN 执行计划解读

EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';
字段含义关注值
type访问类型(越靠左越好)system > const > eq_ref > ref > range > index > ALL
key实际使用的索引看是否命中期盼的索引
rows扫描行数(估算)越小越好
Extra额外信息Using index(覆盖索引,最好)/ Using filesort(需排序优化)/ Using temporary(需临时表优化)

1.7 大表优化方案

单表 > 1000 万行的发展路径:

1. 索引优化 → 覆盖索引 + 联合索引优化
2. SQL 优化 → 避免 SELECT *,分页优化(延迟关联)
3. 缓存 → Redis 热数据缓存
4. 读写分离 → 主库写 + 从库读
5. 分库分表 → ShardingSphere(水平拆分:按 user_id 取模)
6. 冷热分离 → 历史数据归档到 ES/Hive

二、Redis 缓存(★★★★★)

2.1 5 大数据结构底层实现与场景

结构底层实现使用场景
StringSDS(Simple Dynamic String)/ int分布式锁、计数器、Session 共享
Hashziplist → hashtable存储对象(用户信息、购物车)
Listquicklist(ziplist 的双向链表)消息队列、最新列表(LPUSH + LTRIM)
Setintset → hashtable标签、共同好友(SINTER)、抽奖(SRANDMEMBER)
ZSetziplist → skiplist + dict排行榜(ZADD + ZRANGE)、延迟任务

2.2 缓存三大问题及解决方案

┌──────────┬────────────────────┬─────────────────────────────┐
│ 问题     │ 描述               │ 解决方案                      │
├──────────┼────────────────────┼─────────────────────────────┤
│ 缓存穿透 │ 查不存在的数据,每次│ 1. 布隆过滤器(前置过滤)       │
│          │ 都穿透到数据库     │ 2. 缓存空值(TTL 短,如 5 分钟)  │
├──────────┼────────────────────┼─────────────────────────────┤
│ 缓存击穿 │ 热点 key 过期瞬间, │ 1. 互斥锁(SETNX 抢锁后查 DB)  │
│          │ 大量请求打到数据库  │ 2. 逻辑过期(永不过期 + 异步更新)│
├──────────┼────────────────────┼─────────────────────────────┤
│ 缓存雪崩 │ 大量 key 同时过期, │ 1. 过期时间 + 随机值(打散)    │
│          │ 数据库瞬间压力过大  │ 2. 多级缓存(本地 + Redis)    │
│          │                    │ 3. 限流熔断兜底               │
└──────────┴────────────────────┴─────────────────────────────┘

2.3 分布式锁实现

方案演进:
1. SETNX + Expire → ✗ 非原子操作,中间崩溃死锁
2. SET key value NX EX seconds → ✓ 原子操作(Redis 2.6.12+)
3. Lua 脚本释放(先 GET 判断再 DEL)→ ✓ 保证释放的是自己的锁
4. Redisson(WatchDog 自动续期)→ ✓ 生产环境首选

Redisson WatchDog 原理:
- 加锁成功 → 启动定时任务,每 10s 续期到 30s
- 业务执行完 → 释放锁,取消定时任务
- 业务崩溃 → 30s 后锁自动过期(不会永久死锁)

Redlock 算法(多节点 Redis 集群):
1. 依次向 N 个 Redis 节点请求加锁
2. 过半节点加锁成功且总耗时 < 锁过期时间 → 加锁成功
3. 否则向所有节点释放锁

2.4 缓存与数据库双写一致性

方案对比:

1. 先更新 DB,再删除缓存 → Cache Aside(最常用)
   - 缺点:删除缓存失败导致脏数据
   - 补偿:MQ 重试 + 订阅 binlog(Canal)

2. 先删除缓存,再更新 DB → ✗ 有并发问题
   - 问题:删除缓存后、更新 DB 前,另一线程读到旧数据回种缓存

3. 先更新 DB,再更新缓存 → ✗ 并发更新导致数据不一致

生产方案:Cache Aside + Canal binlog 异步更新
  更新 DB → Canal 监听 binlog → 异步删除/更新缓存 → 失败重试

2.5 Redis 为什么快?

1. 纯内存操作:数据存在内存中,无磁盘 IO
2. 单线程模型:无需上下文切换 + 无需考虑并发锁
3. IO 多路复用:一个线程通过 epoll 处理多个客户端连接
4. 高效数据结构:SDS(O(1)获取长度)、跳表(O(logN)查找)、压缩列表(省内存)
5. RESP 协议简单:解析开销小

注意:Redis 6.0+ 引入多线程处理网络 IO,
     但命令执行仍是单线程(保证原子性)

三、消息队列(★★★★☆)

3.1 三大 MQ 对比

对比维度RabbitMQKafkaRocketMQ
语言ErlangJava/ScalaJava
吞吐量万级百万级十万级
延迟微秒级毫秒级毫秒级
消息可靠性高(confirm + ack)可配置高(同步刷盘)
事务消息✗✗✓(半消息机制)
适用场景实时性要求高大数据流处理电商/金融
国内大厂中小企业大数据团队阿里/美团/滴滴

3.2 消息不丢失方案

生产端 → MQ Broker → 消费端 三阶段保证:

1. 生产端:confirm 机制
   RabbitMQ:publisher confirm(异步确认)
   Kafka:acks=all(所有副本写入才算成功)
   RocketMQ:同步刷盘 + 同步复制

2. Broker 端:持久化
   RabbitMQ:消息持久化 + 队列持久化
   Kafka:日志文件 + 多副本
   RocketMQ:CommitLog + ConsumeQueue

3. 消费端:手动确认
   业务处理成功 → 手动 ack
   业务处理失败 → nack + 重试 / 死信队列

3.3 消息幂等性保证

为什么需要幂等:消费者重试可能导致重复消费

方案:
1. 唯一 ID 去重:每条消息生成唯一 ID,Redis SET 记录已消费 ID
2. 数据库唯一约束:INSERT 时依赖唯一索引防重
3. 前置状态判断:UPDATE 时加版本号或状态机条件
   UPDATE order SET status='PAID' WHERE id=123 AND status='UNPAID'
4. Token 机制:消费前获取 Token,消费时校验 Token

3.4 Kafka 高性能原理

1. 顺序写磁盘:比随机写快 3 个数量级(接近内存速度)
2. Page Cache:利用操作系统页缓存,读写都在内存
3. 零拷贝:sendfile() 系统调用,数据不经过用户空间
4. 批量发送:Producer 攒一批数据一起发(batch.size + linger.ms)
5. 压缩传输:支持 Gzip/Snappy/LZ4 压缩,减少网络传输
6. 分区并行:一个 Topic 多 Partition,消费者并行消费

面试自查清单

□ B+ 树和 B 树的 3 个关键区别能说清
□ 聚簇索引和非聚簇索引的回表机制理解
□ 联合索引最左匹配 + 8 种索引失效场景能列举
□ MVCC Read View 的可见性判断流程能画出来
□ EXPLAIN 执行计划 type 列从高到低排序
□ 缓存穿透/击穿/雪崩各能说出 2 种方案
□ 分布式锁从 SETNX 到 Redisson 的演进过程
□ Cache Aside 模式 + 双写一致性问题
□ 消息不丢失的三阶段保证
□ Kafka 高性能的 5 个核心原因