四大数据库底层原理详解
MySQL · MongoDB · Redis · Elasticsearch 完整指南
目录
- 核心概念总览
- [MySQL ------ 关系型数据库](#MySQL —— 关系型数据库 "#%E4%BA%8Cmysql--%E5%85%B3%E7%B3%BB%E5%9E%8B%E6%95%B0%E6%8D%AE%E5%BA%93rdbms")
- [MongoDB ------ 文档型 NoSQL](#MongoDB —— 文档型 NoSQL "#%E4%B8%89mongodb--%E6%96%87%E6%A1%A3%E5%9E%8B-nosql")
- [Redis ------ 内存键值缓存/数据库](#Redis —— 内存键值缓存/数据库 "#%E5%9B%9Bredis--%E5%86%85%E5%AD%98%E9%94%AE%E5%80%BC%E7%BC%93%E5%AD%98%E6%95%B0%E6%8D%AE%E5%BA%93")
- [Elasticsearch ------ 全文检索引擎](#Elasticsearch —— 全文检索引擎 "#%E4%BA%94elasticsearch--%E5%85%A8%E6%96%87%E6%A3%80%E7%B4%A2%E5%BC%95%E6%93%8E")
- 横向对比与选型指南
- 进阶深入方向
一、核心概念总览
1.1 一句话对比
| 数据库 | 核心数据结构 | 存储介质 | 主要用途 | 一句话描述 |
|---|---|---|---|---|
| MySQL | 行存储 + B+树索引 | 磁盘 | 结构化事务数据 | 最成熟的关系型数据库,事务一致性强 |
| MongoDB | BSON文档 + B-Tree | 磁盘 | 灵活文档型数据 | 灵活的文档存储,适合快速迭代 |
| Redis | 哈希表 + 多种数据结构 | 内存 | 高速缓存/临时数据 | 极速的内存数据库,适合缓存场景 |
| Elasticsearch | 倒排索引 + Segment | 磁盘 | 全文搜索与分析 | 强大的搜索引擎,适合日志和检索 |
1.2 存储介质对比
| 存储介质 | 访问速度 | 容量 | 成本 | 典型应用 |
|---|---|---|---|---|
| 内存 | ~100ns | GB级 | 高 | Redis、缓存 |
| SSD磁盘 | ~100μs | TB级 | 中 | MySQL、MongoDB、ES |
| HDD磁盘 | ~10ms | TB-PB级 | 低 | 冷数据存储、备份 |
二、MySQL ------ 关系型数据库(RDBMS)
2.1 基础架构:行存储
MySQL(InnoDB)按「行」把数据存到磁盘上:
- 磁盘上最小管理单位是页(Page),默认 16KB
- 一行记录(INSERT 的一条数据)会被放进某个页里
- 一个页里通常放多行记录
scss
┌──────────────────────────────────────┐
│ 数据页 (16KB) │
│ ┌─────────┐ ┌─────────┐ │
│ │ row1 │ │ row2 │ ... │
│ └─────────┘ └─────────┘ │
└──────────────────────────────────────┘
优缺点分析:
| 维度 | 优点 | 缺点 |
|---|---|---|
| 读取 | SELECT * 整行一次性读出 |
只查某几列时也会读整行 |
| 写入 | 整行写入效率高 | 更新部分列需要锁定整行 |
| 压缩 | 适合行级压缩 | 列级压缩效率不如列存储 |
2.2 B+树索引
2.2.1 B+树结构特点
B+树是一种多路平衡查找树,核心特点:
css
[18, 35]
/ | \
┌─────┘ └─────┐
[3,7,12] [20,25] [36,40]
↓ ← 链表连接 → ↓ ← 链表 → ↓
| 节点类型 | 存储内容 | 说明 |
|---|---|---|
| 非叶子节点 | 索引键 + 指针 | 不存真实数据,减少树高度 |
| 叶子节点 | 主键索引存整行数据 二级索引存主键值 | 所有叶子节点用双向链表连接 |
2.2.2 聚簇索引(Clustered Index)
InnoDB 中:主键索引 = 聚簇索引
B+树叶子节点就是这一行完整数据。
sql
CREATE TABLE user(
id INT PRIMARY KEY,
name VARCHAR(20),
age INT
);
查询过程示例:
sql
SELECT * FROM user WHERE id = 18;
执行流程:
1. 从 B+树根节点向下查找
2. 到达叶子节点
3. 直接拿到整行数据(id, name, age)
✅ 快的原因:索引即数据,无需回表
2.2.3 二级索引(普通索引)
sql
CREATE INDEX idx_age ON user(age);
B+树叶节点不存整行 ,只存:(age, id)
查询流程(需要回表):
sql
SELECT * FROM user WHERE age = 25;
执行流程:
1. 走 idx_age B+树 → 找到 id
2. 回表:用 id 去主键聚簇索引再查一次
⚠️ 这叫"回表(Look-up)",比主键查询多一次 IO
2.2.4 为什么选择 B+树?
| 结构 | 问题 | 结论 |
|---|---|---|
| Hash | 不支持范围查询(>, BETWEEN) | ❌ 不适合 |
| 红黑树 | 深度太大,磁盘 IO 多 | ❌ 不适合 |
| B+树 | 矮胖(减少磁盘 IO) + 叶子链表支持范围扫描 | ✅ 最优选择 |
核心:数据库最怕磁盘 IO,B+树就是为"少读几次磁盘"设计的
2.3 事务与 ACID
InnoDB 通过以下机制实现 ACID:
| 特性 | 实现机制 | 说明 |
|---|---|---|
| 原子性(A) | Undo Log | 回滚未提交的事务 |
| 一致性(C) | 约束 + 事务 | 保证数据符合规则 |
| 隔离性(I) | 锁机制 + MVCC | 行锁/间隙锁/临键锁 |
| 持久性(D) | Redo Log | 崩溃恢复 |
2.4 MVCC 多版本并发控制
2.4.1 什么是 MVCC?
MVCC(Multi-Version Concurrency Control) 是 InnoDB 实现高并发的核心机制。
核心思想:读不阻塞写,写不阻塞读
2.4.2 三大核心组件
scss
┌─────────────────────────────────────────────────────────────┐
│ MVCC 三剑客 │
├─────────────────────────────────────────────────────────────┤
│ 1. 隐藏字段(每行记录自带) │
│ ├── DB_TRX_ID:最后修改该行的事务ID │
│ ├── DB_ROLL_PTR:回滚指针,指向 Undo Log │
│ └── DB_ROW_ID:行ID(如果没有主键) │
│ │
│ 2. Undo Log(回滚日志) │
│ └── 存储数据的历史版本,形成版本链 │
│ │
│ 3. Read View(读视图) │
│ └── 决定当前事务能看到哪个版本的数据 │
└─────────────────────────────────────────────────────────────┘
2.4.3 版本链示意
假设事务A(id=100)插入一条数据,然后事务B(id=200)更新了这条数据:
ini
当前数据(B+树叶节点):
┌─────────────────────────────────────┐
│ id=1, name='李四' │
│ DB_TRX_ID=200 │
│ DB_ROLL_PTR ─────────────────────┐ │
└──────────────────────────────────┼───┘
↓
Undo Log 版本链:
┌─────────────────────────┐
│ name='张三' │
│ DB_TRX_ID=100 │
│ DB_ROLL_PTR=NULL │
└─────────────────────────┘
2.4.4 Read View 的判断逻辑
Read View 包含四个关键字段:
sql
m_ids -- 开启 Read View 时,当前活跃的事务ID列表(未提交)
min_trx_id -- m_ids 中最小的事务ID
max_trx_id -- 下一个将分配的事务ID(不是m_ids最大值)
creator_trx_id -- 创建该 Read View 的事务ID
判断规则(当前事务读取某行时):
sql
该行的 DB_TRX_ID 与 Read View 对比:
① DB_TRX_ID == creator_trx_id
→ 是自己修改的,可见 ✅
② DB_TRX_ID < min_trx_id
→ 该版本在 Read View 创建前已提交,可见 ✅
③ DB_TRX_ID >= max_trx_id
→ 该版本在 Read View 创建后才开启的事务修改的,不可见 ❌
④ min_trx_id <= DB_TRX_ID < max_trx_id
→ 看 DB_TRX_ID 是否在 m_ids 中
- 在 m_ids 中:说明该事务还未提交,不可见 ❌
- 不在 m_ids 中:说明已提交,可见 ✅
如果不可见 → 沿着 DB_ROLL_PTR 去 Undo Log 找上一个版本,重复判断
2.4.5 RC vs RR 的区别
| 隔离级别 | Read View 生成时机 | 特点 |
|---|---|---|
| 读已提交(RC) | 每次 SELECT 都生成新的 Read View | 可能出现不可重复读 |
| 可重复读(RR) | 第一次 SELECT 生成 Read View,后续复用 | 保证可重复读 |
2.4.6 配合 B+树的工作流程
markdown
SELECT 查询流程:
1. 从 B+树定位到叶子节点(按主键或二级索引)
2. 读取该行的 DB_TRX_ID
3. 用 Read View 判断该版本是否可见
├── 可见 → 直接返回
└── 不可见 → 通过 DB_ROLL_PTR 去 Undo Log 找历史版本
└── 继续判断,直到找到可见版本或版本链结束
核心:B+树存的是"最新版本",通过 Undo Log 版本链 + Read View 实现"看到历史版本"
2.5 COUNT(*) 原理与优化
2.5.1 为什么 InnoDB 的 COUNT(*) 慢?
InnoDB 的 B+树是聚簇索引,没有单独存储"总行数"的地方
scss
┌─────────────────────────────────────────┐
│ MyISAM(有元数据存储) │
├─────────────────────────────────────────┤
│ 表元数据中存储:rows = 1000000 │
│ SELECT COUNT(*) → 直接读元数据,O(1) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ InnoDB(没有总行数存储) │
├─────────────────────────────────────────┤
│ 因为 MVCC: │
│ - 每个事务可能看到不同的行数 │
│ - 有些行被删除了但还没 purge │
│ - 有些行是刚插入的还没提交 │
│ 所以无法存储一个"准确的"总行数 │
└─────────────────────────────────────────┘
2.5.2 COUNT(*) 执行过程
sql
SELECT COUNT(*) FROM user;
执行流程:
markdown
1. 选择最小的辅助索引(不是聚簇索引)
→ 辅助索引叶子节点只存 (索引列, 主键),比聚簇索引小
2. 遍历该索引的所有叶子节点
→ 统计符合条件的行数
3. 需要判断可见性(MVCC)
→ 不是简单的数节点个数
2.5.3 各种 COUNT 的区别
sql
-- 推荐 ✅
SELECT COUNT(*) FROM user;
-- MySQL 优化:不读具体列,只数行数
-- 也行
SELECT COUNT(1) FROM user;
-- 等价于 COUNT(*),MySQL 内部会优化
-- 不推荐 ❌
SELECT COUNT(id) FROM user;
-- 需要读 id 列,判断是否为 NULL
-- 最慢 ❌
SELECT COUNT(name) FROM user;
-- 需要读 name 列,还要过滤 NULL 值
2.5.4 优化方案
方案1:使用计数表
sql
CREATE TABLE table_counts (
table_name VARCHAR(64) PRIMARY KEY,
row_count BIGINT
);
-- 插入/删除时更新计数
INSERT INTO user ...;
UPDATE table_counts SET row_count = row_count + 1 WHERE table_name = 'user';
方案2:使用 Redis 缓存
sql
SET user:count 1000000
INCR user:count
DECR user:count
方案3:估算(不需要精确值)
sql
EXPLAIN SELECT * FROM user;
-- rows 字段是估算值
-- 或使用 information_schema
SELECT TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_NAME = 'user';
2.6 索引失效的常见场景
2.6.1 最左匹配原则
复合索引的匹配规则:从左到右依次匹配,不能跳过
sql
-- 假设有索引:INDEX idx_name_age (name, age)
-- ✅ 走索引
SELECT * FROM user WHERE name = '张三';
SELECT * FROM user WHERE name = '张三' AND age = 20;
-- ❌ 不走索引(跳过了 name)
SELECT * FROM user WHERE age = 20;
-- ✅ 部分走索引(只用到 name)
SELECT * FROM user WHERE name = '张三' AND age > 20;
-- name 用索引,age 用索引下推
原理:B+树是按 (name, age) 组合排序的
ini
叶子节点:
(张三, 18) → (张三, 20) → (张三, 25) → (李四, 18) → (李四, 22)
查询 name='张三':可以定位到张三的起始位置,范围扫描
查询 age=20:B+树先按 name 排序,age 是无序的,只能全表扫描
范围查询会中断后续索引使用:
sql
-- 假设索引:(a, b, c)
-- ✅ 全部走索引
WHERE a = 1 AND b = 2 AND c = 3
-- ⚠️ a、b 走索引,c 不走
WHERE a = 1 AND b > 2 AND c = 3
2.6.2 隐式类型转换
sql
-- 假设 phone 是 VARCHAR 类型,有索引
-- ❌ 不走索引(字符串和数字比较,MySQL 会把字符串转数字)
SELECT * FROM user WHERE phone = 13800138000;
-- 等价于:CAST(phone AS SIGNED) = 13800138000
-- ✅ 赋索引
SELECT * FROM user WHERE phone = '13800138000';
2.6.3 对索引列做运算/函数
sql
-- ❌ 不走索引
SELECT * FROM user WHERE YEAR(create_time) = 2024;
SELECT * FROM user WHERE id + 1 = 100;
SELECT * FROM user WHERE SUBSTRING(name, 1, 1) = '张';
-- ✅ 赋索引
SELECT * FROM user WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
SELECT * FROM user WHERE id = 99;
SELECT * FROM user WHERE name LIKE '张%';
2.6.4 LIKE 以通配符开头
sql
-- ✅ 赋索引(前缀匹配)
SELECT * FROM user WHERE name LIKE '张%';
-- ❌ 不走索引(后缀/中间匹配)
SELECT * FROM user WHERE name LIKE '%张';
SELECT * FROM user WHERE name LIKE '%张%';
原理:B+树按字符串排序,前缀有序,后缀无序
2.6.5 其他索引失效场景
| 场景 | 示例 | 是否走索引 |
|---|---|---|
| OR 一侧无索引 | WHERE a=1 OR b=1(b无索引) | ❌ |
| NOT IN / != | WHERE age != 20 | ❌ |
| IS NULL | WHERE name IS NULL(NULL值比例高) | ⚠️ 可能不走 |
2.7 适用场景
| 场景 | 是否推荐 |
|---|---|
| 银行交易、订单系统 | ✅ 强推荐 |
| CMS 内容管理 | ✅ 推荐 |
| 电商核心业务 | ✅ 推荐 |
| 需要复杂关联查询 | ✅ 推荐 |
| 日志存储 | ⚠️ 可用但不是最优 |
| 海量数据写入 | ⚠️ 需要分库分表 |
三、MongoDB ------ 文档型 NoSQL
3.1 BSON 格式
3.1.1 JSON vs BSON
JSON 示例:
json
{
"name": "张三",
"age": 28,
"hobbies": ["篮球", "吉他"]
}
MongoDB 不存 JSON,存的是 BSON(Binary JSON)。
BSON 相比 JSON 的优势:
| 特性 | JSON | BSON |
|---|---|---|
| 数据类型 | 字符串、数字、布尔、数组、对象 | 额外支持 Date、Binary、Decimal128、ObjectId |
| 解析速度 | 需逐字符扫描 | 长度前缀,可直接定位 |
| 空间效率 | 较低 | 较高(二进制编码) |
JSON 是人看的,BSON 是机器快速读写用的二进制版 JSON
3.1.2 BSON 在磁盘上的样子(简化)
假设文档 {"_id": 1, "name": "张三", "age": 28}:
css
┌─────────────────────────────────────────────┐
│ 总长度 │ _id: int32(1) │ name: string(张三) │ age: int32(28) │ 结束符 │
└─────────────────────────────────────────────┘
优缺点分析:
| 维度 | 优点 | 缺点 |
|---|---|---|
| 灵活性 | schema-less,每条文档自带字段名和类型 | 比固定字段更占空间 |
| 性能 | BSON 可直接定位字段值 | 需要解析二进制 |
| 类型 | 支持丰富的数据类型 | - |
3.2 WiredTiger 存储引擎
MongoDB 3.2 以后的默认存储引擎。
3.2.1 B-Tree vs MySQL 的 B+Tree
| 特性 | MySQL B+Tree | MongoDB WiredTiger B-Tree |
|---|---|---|
| 叶子节点 | 存数据 | 存数据 |
| 非叶子节点 | 只存索引键 | 也存键值对 |
| 叶子链表 | 有(范围查询快) | 没有专门的链表 |
| 本质 | 索引和数据分离两层 | 索引和数据在一棵树里 |
3.2.2 MVCC(多版本并发控制)
css
时刻1:{_id:1, name:"张三", version:1}
时刻2:更新 name="李四" → 保留旧版本,新版本 version:2
时刻3:另一个读事务仍在读 version:1(不受影响)
特点:
- 每个文档修改时,不覆盖旧数据,而是生成一个新版本
- 读操作看到的是某个时间点的快照(snapshot)
- 写操作之间通过**文档级锁(document-level lock)**隔离
✅ 好处:读写互不阻塞,高并发下表现好
3.2.3 写入流程
scss
应用写入 {_id:1, name:"张三"}
↓
内存中的 B-Tree 缓存(cache)
↓
写入 WAL(预写日志,Write-Ahead Log) → 保证宕机不丢数据
↓
定期刷盘(checkpoint):将内存中的 B-Tree 页面写入磁盘
- WAL 先写,保证 crash-safe
- 批量刷盘,提升写入吞吐
3.3 分片(Sharding)
3.3.1 为什么需要分片?
diff
单机瓶颈:
- 存储容量:磁盘不够
- 读写性能:CPU/IO 成为瓶颈
- 内存限制:活跃数据放不下
分片 = 水平扩展(Scale Out)
把数据拆分到多个节点,每个节点存一部分数据
3.3.2 分片架构
scss
┌─────────────────────────────────────────────────────────────┐
│ MongoDB 分片集群 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ mongos 路由 │ │ mongos 路由 │ │ mongos 路由 │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ↓ │
│ ┌────────────────────────┐ │
│ │ Config Server 集群 │ │
│ │ (存储集群元数据) │ │
│ └────────────┬───────────┘ │
│ ↓ │
│ ┌───────────────────────┼───────────────────────┐ │
│ ↓ ↓ ↓ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Shard 1 │ │ Shard 2 │ │ Shard 3 │ │
│ │ (Replica)│ │ (Replica)│ │ (Replica)│ │
│ │ 分片1数据 │ │ 分片2数据 │ │ 分片3数据 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
三个角色:
| 角色 | 作用 |
|---|---|
| mongos(路由) | 接收客户端请求,路由到正确的分片 |
| Config Server | 存储集群元数据(分片范围、均衡状态) |
| Shard(分片) | 存储实际数据,每个分片是一个复制集 |
3.3.3 分片键(Shard Key)
分片键决定了数据如何分布
javascript
// 启用分片
sh.enableSharding("mydb")
// 选择分片键
sh.shardCollection("mydb.user", { "userId": 1 }) // 按 userId 分片
3.3.4 三种分片策略
1. 范围分片(Range Sharding)
yaml
按分片键的值范围划分:
Shard 1: userId [0, 10000)
Shard 2: userId [10000, 20000)
Shard 3: userId [20000, ∞)
- ✅ 优点:范围查询效率高
- ❌ 缺点:数据分布可能不均匀
2. 哈希分片(Hash Sharding)
css
按分片键的哈希值分布:
sh.shardCollection("mydb.user", { "userId": "hashed" })
计算 hash(userId) % 分片数 → 决定去哪个分片
- ✅ 优点:数据分布均匀
- ❌ 缺点:范围查询需要查询所有分片
3. 区域分片(Zones)
css
给分片定义"区域",数据按规则路由到特定区域:
sh.addShardTag("shard1", "US")
sh.addShardTag("shard2", "EU")
3.3.5 分片键选择原则
| 原则 | 说明 | 好的示例 | 差的示例 |
|---|---|---|---|
| 高基数 | 分片键值域要大 | { "userId": 1 } |
{ "gender": 1 } |
| 低频率 | 分片键值不要过于集中 | { "userId": "hashed" } |
{ "createTime": 1 } |
| 查询模式 | 分片键应是常见查询条件 | { "region": 1, "userId": 1 } |
{ "randomId": 1 } |
3.4 复制集(Replica Set)
3.4.1 复制集架构
scss
┌─────────────────────────────────────────────────────────────┐
│ MongoDB 复制集 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ Primary │ ← 所有写操作 │
│ │ (主节点) │ │
│ └──────┬───────┘ │
│ │ │
│ │ 同步数据(Oplog) │
│ │ │
│ ┌──────────┴──────────┐ │
│ ↓ ↓ │
│ ┌──────────┐ ┌──────────┐ │
│ │Secondary │ │Secondary │ │
│ │ (从节点) │ │ (从节点) │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
可选角色:
- Arbiter(仲裁者):不存数据,只参与投票
- Hidden Secondary:隐藏节点,不接收读请求
- Delayed Secondary:延迟节点,用于灾备恢复
3.4.2 选主流程
选举算法:Raft 协议变种
markdown
1. 发现 Primary 不可达的节点,发起选举
2. 该节点成为"候选人",增加自己的 term
3. 向其他节点发送选举请求(包含自己的优先级、数据新鲜度)
4. 其他节点投票(每个 term 只能投一票)
5. 获得多数票(> N/2)的节点成为新 Primary
投票规则:
| 因素 | 说明 |
|---|---|
| 优先级(priority) | priority 越高,越可能被选为主 |
| 数据新鲜度(optime) | optime 最新的节点优先 |
| 网络可达性 | 候选人必须能和多数节点通信 |
3.4.3 数据同步机制(Oplog)
markdown
写操作流程:
1. 客户端写入 Primary
2. Primary 写入本地 Oplog(操作日志)
3. Primary 返回客户端成功
4. Secondary 异步拉取 Oplog,重放操作
Oplog 特点:
- 固定大小(capped collection)
- 幂等操作(可重复执行)
- 包含所有写操作(insert/update/delete)
3.5 为什么 MongoDB 不适合复杂关联查询
3.5.1 缺少高效的 JOIN 实现
MongoDB 的 $lookup(聚合管道)
javascript
// 类似 SQL 的 LEFT JOIN
db.orders.aggregate([
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
}
}
])
性能问题:
markdown
执行过程:
1. 扫描 orders 集合
2. 对每个 order,扫描 users 集合找匹配项
3. 没有索引优化,类似"嵌套循环连接"
对比 MySQL JOIN:
- MySQL 有多种 JOIN 优化算法
- MongoDB 的 $lookup 本质上是嵌套循环,O(N*M)
3.5.2 数据模型设计理念不同
MongoDB 的设计哲学:嵌入 > 关联
bash
关系型设计(MySQL):
┌─────────────┐ ┌─────────────┐
│ users │ │ orders │
├─────────────┤ ├─────────────┤
│ id │ ←──── │ userId │
│ name │ │ orderId │
│ email │ │ amount │
└─────────────┘ └─────────────┘
查询需要 JOIN
文档型设计(MongoDB):
{
"_id": 1,
"name": "张三",
"email": "zhang@example.com",
"orders": [ ← 嵌入文档
{ "orderId": 101, "amount": 100 },
{ "orderId": 102, "amount": 200 }
]
}
查询一次即可获取所有数据
3.6 适用场景
| 场景 | 是否推荐 |
|---|---|
| 嵌入式数据模型(博客文章+评论) | ✅ 推荐 |
| 日志、事件流 | ✅ 推荐 |
| 物联网设备数据 | ✅ 推荐 |
| 快速迭代的产品原型 | ✅ 推荐 |
| 复杂的多表关联查询 | ❌ 不推荐 |
| 强事务一致性要求(银行转账) | ❌ 不推荐 |
四、Redis ------ 内存键值缓存/数据库
4.1 纯内存操作
数据主要存在 RAM 里,读写都是直接操作内存。
| 操作 | MySQL(磁盘) | Redis(内存) |
|---|---|---|
| 读一条数据 | ~1-10ms | < 1μs |
| 写一条数据 | ~1-10ms | < 1μs |
差距大约 1000~10000 倍
4.2 持久化方案
4.2.1 RDB(快照)
perl
每隔 N 分钟 / 写满多少条后
→ fork 一个子进程
→ 把当前内存里的全部数据 dump 到一个 .rdb 文件
→ 下次启动时加载这个文件恢复数据
| 特性 | 说明 |
|---|---|
| 文件大小 | 小 |
| 恢复速度 | 快 |
| 数据丢失 | 可能丢失最近几分钟的数据 |
4.2.2 AOF(追加日志)
css
每次写操作(SET/DEL...)
→ 追加到 AOF 文件末尾
→ 可配置:每秒刷盘 / 每次写都刷盘
→ 重启时重放 AOF 恢复数据
| 特性 | 说明 |
|---|---|
| 文件大小 | 大 |
| 恢复速度 | 慢 |
| 数据丢失 | 最多丢1秒数据 |
生产环境建议:两者都开,RDB 用于快速恢复,AOF 用于防丢数据
4.3 事件驱动单线程模型
4.3.1 「单线程」是指什么?
Redis 的命令处理是单线程的,所有客户端发来的命令串行执行:
sql
时间轴 →
客户端A: SET a 1
客户端B: GET a
客户端C: DEL b
↓
Redis 单线程依次处理:
SET a 1 → GET a → DEL b
优缺点:
| 优点 | 缺点 |
|---|---|
| 没有锁竞争 | 一个耗时命令会阻塞后面所有命令 |
| 没有死锁风险 | 不能充分利用多核 CPU |
| 实现简单 | - |
4.3.2 「事件驱动」是什么?
Redis 用**事件循环(Event Loop)**来处理:
markdown
无限循环:
1. 等待事件发生(epoll/kqueue 监听 socket)
2. 有事件来了 → 处理这个事件(读请求、执行命令、写响应)
3. 回到步骤1
跟 Node.js、Nginx 的事件循环是同一个思路
4.3.3 Redis 6.0 之后的多线程
Redis 6.0 的「多线程」只针对网络 IO 部分:
ini
传统单线程:
[网络读] → [命令执行] → [网络写]
全部由一个线程完成
Redis 6.0+:
[网络读] ← 多线程并行读
[命令执行] ← 仍然是单线程!
[网络写] → 多线程并行写
核心命令执行还是单线程,只是把网络收发这种 CPU 密集的活分给多线程干
4.4 核心数据结构
| 结构 | 底层实现 | 典型场景 |
|---|---|---|
| String | SDS(Simple Dynamic String) | 缓存、计数器 |
| List | quicklist(ziplist + linkedlist 混合) | 消息队列 |
| Set | hashtable / intset | 标签、去重 |
| ZSet | skiplist + hashtable | 排行榜 |
| Hash | ziplist / hashtable | 对象缓存 |
| Stream | radix tree | 消息队列(替代 Pub/Sub) |
4.5 跳跃表(Skiplist)详解
4.5.1 什么是跳跃表?
跳跃表 = 有序链表 + 多级索引
makefile
原始有序链表(查找 O(N)):
1 → 3 → 5 → 7 → 9 → 11 → 13 → 15 → 17 → 19 → 21 → 23 → 25
加一级索引(每隔一个节点提取):
1 ──────────── 9 ──────────── 17 ──────────── 25
↓ ↓ ↓ ↓
1 → 3 → 5 → 7 → 9 → 11 → 13 → 15 → 17 → 19 → 21 → 23 → 25
再加一级索引:
1 ──────────────────────── 17 ──────────────────────── 25
↓ ↓ ↓
1 ──────────── 9 ──────────── 17 ──────────── 25
↓ ↓ ↓ ↓
1 → 3 → 5 → 7 → 9 → 11 → 13 → 15 → 17 → 19 → 21 → 23 → 25
时间复杂度:O(log N),和平衡二叉树一样
4.5.2 为什么 Redis 用跳跃表而不是红黑树?
| 维度 | 跳跃表 | 红黑树/AVL树 |
|---|---|---|
| 实现复杂度 | 简单(链表+随机层数) | 复杂(旋转、变色) |
| 范围查询 | ✅ 天然支持(底层是链表) | ❌ 需要中序遍历 |
| 内存占用 | 稍大(多级指针) | 较小 |
| 并发友好 | ✅ 局部更新 | ❌ 树结构调整范围大 |
| 调试难度 | ✅ 直观 | ❌ 复杂 |
Redis 作者 Antirez 的原话:
"跳跃表更容易实现,调试更简单,对于 ZSET 的使用场景(范围查询),跳跃表更合适。"
4.5.3 ZSET 的实现
c
// Redis 源码中的 ZSET 结构
typedef struct zset {
dict *dict; // 哈希表:member → score(O(1) 查找分数)
zskiplist *zsl; // 跳跃表:score → member(O(logN) 范围查询)
} zset;
为什么要同时用两个结构?
makefile
查询某用户分数:用 dict,O(1)
ZSCORE user:rank 张三
查询排行榜前100名:用 skiplist,O(logN + M)
ZRANGE user:rank 0 99 WITHSCORES
范围查询(分数100-200):用 skiplist
ZRANGEBYSCORE user:rank 100 200 WITHSCORES
4.6 内存淘汰策略
4.6.1 为什么需要淘汰策略?
markdown
Redis 是内存数据库,内存有限
当内存使用达到 maxmemory 阈值时:
1. 新写入会失败(OOM)
2. 或者根据淘汰策略删除部分 key
4.6.2 八种淘汰策略
vbnet
┌─────────────────────────────────────────────────────────────┐
│ Redis 内存淘汰策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 不淘汰(内存满时拒绝写入) │
│ ├── noeviction 不淘汰,写入报错(默认) │
│ │
│ 全局淘汰(从所有 key 中选择) │
│ ├── allkeys-lru 淘汰最近最少使用的 key │
│ ├── allkeys-lfu 淘汰最不经常使用的 key │
│ ├── allkeys-random 随机淘汰 │
│ │
│ 仅淘汰设置了过期时间的 key │
│ ├── volatile-lru 淘汰最近最少使用且设置了 TTL 的 key │
│ ├── volatile-lfu 淘汰最不经常使用且设置了 TTL 的 key │
│ ├── volatile-ttl 淘汰 TTL 最短(即将过期)的 key │
│ ├── volatile-random 随机淘汰设置了 TTL 的 key │
│ │
└─────────────────────────────────────────────────────────────┘
4.6.3 如何选择淘汰策略?
| 场景 | 推荐策略 |
|---|---|
| 缓存场景(部分数据是热点) | allkeys-lru |
| 热点数据访问频率差异大 | allkeys-lfu |
| 只缓存临时数据(有 TTL) | volatile-lru |
| 数据重要性差异大 | volatile-ttl |
| 数据访问模式随机 | allkeys-random |
| 不能丢失任何数据 | noeviction |
4.7 缓存穿透/击穿/雪崩
4.7.1 缓存穿透(Penetration)
定义: 查询一个根本不存在的数据,缓存和数据库都没有
makefile
请求流程:
客户端请求 key=999999
↓
Redis: 不存在
↓
MySQL: 也不存在
↓
返回空,但下次请求还会查数据库
危害: 恶意请求大量不存在的 key,压垮数据库
解决方案:
方案1:缓存空值
java
public Object get(String key) {
Object value = redis.get(key);
if (value != null) {
if ("NULL".equals(value)) {
return null; // 缓存的空值
}
return value;
}
// 查数据库
value = db.query(key);
if (value == null) {
// 缓存空值,设置较短过期时间
redis.set(key, "NULL", 60);
} else {
redis.set(key, value, 3600);
}
return value;
}
方案2:布隆过滤器(Bloom Filter)
markdown
布隆过滤器:一个超大的位数组 + 多个哈希函数
写入时:
1. 对 key 计算多个哈希值
2. 在位数组对应位置标记为 1
查询时:
1. 对 key 计算多个哈希值
2. 检查位数组对应位置是否都为 1
- 都为 1:key **可能存在**(有误判率)
- 有一个为 0:key **一定不存在**
java
// Redis 4.0+ 内置布隆过滤器
BF.ADD users user:1001 // 添加
BF.EXISTS users user:1001 // 查询
4.7.2 缓存击穿(Breakdown)
定义: 某个热点 key 过期,瞬间大量请求穿透到数据库
vbnet
场景:
key="hot_product_123" 正在被大量访问
TTL 到期,key 被删除
此时 1000 个并发请求同时查询该 key
全部穿透到数据库
解决方案:
方案1:互斥锁(只让一个请求查库)
java
public Object get(String key) {
Object value = redis.get(key);
if (value != null) {
return value;
}
// 尝试获取锁
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10)) {
try {
// 再次检查缓存(双重检查)
value = redis.get(key);
if (value != null) {
return value;
}
// 查数据库
value = db.query(key);
redis.set(key, value, 3600);
return value;
} finally {
redis.del(lockKey);
}
} else {
// 等待后重试
Thread.sleep(50);
return get(key);
}
}
方案2:逻辑过期(不设置 TTL,用字段判断)
java
// 数据结构
{
"data": "商品信息",
"expireTime": 1704067200 // 逻辑过期时间
}
public Object get(String key) {
Object cached = redis.get(key);
if (cached != null) {
if (cached.expireTime > System.currentTimeMillis()) {
return cached.data; // 未过期
}
// 过期了,异步刷新
asyncRefresh(key);
return cached.data; // 返回旧数据
}
// 缓存不存在,同步查询
return syncRefresh(key);
}
4.7.3 缓存雪崩(Avalanche)
定义: 大量 key 同时过期 ,或 Redis 宕机
makefile
场景1:批量设置缓存,过期时间相同
1小时后,所有 key 同时过期
瞬间大量请求穿透到数据库
场景2:Redis 宕机
所有请求全部打到数据库
解决方案:
方案1:过期时间加随机值
java
// 过期时间 = 基础时间 + 随机时间
int baseExpire = 3600;
int randomExpire = new Random().nextInt(600); // 0-600秒随机
redis.set(key, value, baseExpire + randomExpire);
方案2:多级缓存
scss
请求 → L1 缓存(本地) → L2 缓存(Redis) → 数据库
本地缓存(Caffeine/Guava Cache):
- 容量小,速度快
- 即使 Redis 挂了,本地缓存还能抗一部分流量
方案3:熔断降级
java
@SentinelResource(value = "getProduct", fallback = "getProductFallback")
public Object getProduct(String key) {
return cache.get(key);
}
public Object getProductFallback(String key) {
// 降级:返回默认值或提示
return "服务繁忙,请稍后重试";
}
方案4:Redis 高可用
- 主从 + 哨兵:主节点故障自动切换
- Redis Cluster:分片 + 副本,部分节点故障不影响整体
4.7.4 三者对比总结
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 缓存空值、布隆过滤器 |
| 击穿 | 热点 key 过期 | 互斥锁、逻辑过期、永不过期 |
| 雪崩 | 大量 key 同时过期或 Redis 宕机 | 随机过期、多级缓存、熔断降级、高可用 |
4.8 适用场景
| 场景 | 是否推荐 |
|---|---|
| 缓存 | ✅ 强推荐 |
| 会话管理 | ✅ 推荐 |
| 排行榜 | ✅ 推荐 |
| 消息队列 | ✅ 推荐(Stream) |
| 分布式锁 | ✅ 推荐 |
| 持久化存储 | ⚠️ 可以但不是最优 |
| 大量数据存储 | ❌ 不推荐(内存成本高) |
五、Elasticsearch ------ 全文检索引擎
5.1 倒排索引
5.1.1 正排索引 vs 倒排索引
假设三篇文档:
| 文档ID | 内容 |
|---|---|
| 1 | 我喜欢吃苹果 |
| 2 | 苹果手机很好用 |
| 3 | 我喜欢香蕉 |
正排索引(MySQL 那种):
css
文档1 → [我, 喜欢, 吃, 苹果]
文档2 → [苹果, 手机, 很, 好用]
文档3 → [我, 喜欢, 香蕉]
查询「苹果出现在哪些文档?」→ 得扫一遍所有文档 → 慢
倒排索引(ES 那种):
css
苹果 → [文档1, 文档2] ← 词 → 文档列表
我 → [文档1, 文档3]
喜欢 → [文档1, 文档3]
吃 → [文档1]
手机 → [文档2]
...
查询「苹果」→ 直接查词典 → 瞬间知道在文档1和2里 → 快
倒排索引 = 以词找文档,而不是以文档找词
5.1.2 倒排索引结构(简化版)
scss
┌──────────┬──────────────────────────────┐
│ 词项 │ 倒排列表(Posting List) │
├──────────┼──────────────────────────────┤
│ 苹果 │ (doc1, 位置5) (doc2, 位置1) │
│ 手机 │ (doc2, 位置2) │
│ 我喜欢 │ (doc1, 位置1) (doc3, 位置1) │
└──────────┴──────────────────────────────┘
每个词项后面还记录了:
- 文档ID
- 词频(TF):该词在文档中出现次数
- 位置:在文档中的第几个词
- 偏移量:在原文中的字符位置(用于高亮)
5.1.3 建索引的过程(分词)
arduino
输入:"苹果手机很好用"
↓
分词器(Analyzer):
1. 字符过滤(去掉HTML标签等)
2. 分词(按空格/标点/语言规则切词)
3. 词项过滤(转小写、去停用词、词干提取)
↓
输出词项:[苹果, 手机, 很好, 用]
↓
写入倒排索引
不同语言用不同分词器:
- 中文:IK分词器、HanLP
- 英文:Standard Analyzer(自动转小写+去复数)
5.2 Lucene 库
5.2.1 Lucene 是 ES 的发动机
- Lucene:一个 Java 写的全文检索引擎库(Apache 基金会)
- Elasticsearch:在 Lucene 外面包了一层:分布式、RESTful API、集群管理、监控......
ini
Elasticsearch = 一辆汽车(整车)
Lucene = 发动机(核心动力)
5.2.2 Segment(段)------ 不可变的秘密
Lucene 的索引由多个 Segment 组成:
css
写入流程:
新文档 → 内存缓冲区
↓(每秒 refresh)
生成一个小 Segment(可被搜索)
↓(后台 merge)
多个小 Segment 合并成大 Segment
↓
删除旧的 Segment
特点:
- Segment 一旦写入就不变了(immutable)
- 删除文档只是在 Segment 里打个标记(不是真删)
- 更新文档 = 标记旧文档删除 + 写入新文档
| 优点 | 缺点 |
|---|---|
| 写入时不需要加锁 | 删除的空间不会立即释放 |
| 读取时只需加载 Segment 索引 | 合并时会有 IO 压力 |
| 利用 OS Page Cache 加速 | - |
5.3 ES 的「近实时」实现
scss
写入一条文档
↓
① 写入内存缓冲区(Memory Buffer)
↓(同时写入 translog 防止丢数据)
② 每 1 秒(默认) → refresh
↓
内存缓冲区清空,生成新的 Segment
这个 Segment 打开,可以被搜索
↓
③ 每 30 分钟 或 translog 满了 → flush
↓
Segment 刷到磁盘
translog 清空
- 写入后最快 1 秒才能搜到(近实时,不是实时)
refresh_interval改成-1→ 永远搜不到刚写的数据
5.4 ES 的分布式架构
scss
集群(Cluster)
└── 节点(Node)
├── 主节点(Master):管理集群状态、分配分片
└── 数据节点(Data):存数据、处理查询
索引(Index)
└── 分片(Shard) ← 每个分片就是一个独立的 Lucene 实例
├── 主分片(Primary Shard):写入口
└── 副本分片(Replica Shard):读备份
5.4.1 写入流程
bash
客户端 POST /my_index/_doc/1
↓
路由计算:shard = hash(_id) % number_of_primary_shards
↓
转发到对应的主分片所在的节点
↓
写入主分片 → 同步到副本分片 → 返回成功
5.4.2 查询流程
ini
客户端 GET /my_index/_search?q=苹果
↓
协调节点(接收请求的节点)
↓
广播到所有分片(主分片 + 副本分片都行)
↓
各分片本地搜索 → 返回结果给协调节点
↓
协调节点合并排序 → 返回给客户端
5.5 BM25 评分公式
5.5.1 什么是 BM25?
BM25(Best Matching 25) 是 ES 默认的相关性评分算法,用于衡量文档与查询的匹配程度。
5.5.2 BM25 公式
scss
score(D, Q) = Σ IDF(qi) · (f(qi, D) · (k1 + 1)) / (f(qi, D) + k1 · (1 - b + b · |D|/avgdl))
其中:
- D:文档
- Q:查询(包含多个词 qi)
- f(qi, D):词 qi 在文档 D 中的词频(TF)
- |D|:文档 D 的长度
- avgdl:平均文档长度
- k1:词频饱和参数(默认 1.2)
- b:文档长度归一化参数(默认 0.75)
5.5.3 BM25 vs TF-IDF
| 特性 | TF-IDF | BM25 |
|---|---|---|
| 词频增长 | 线性增长 | 饱和增长(有上限) |
| 文档长度 | 不考虑 | 归一化(惩罚长文档) |
| 参数可调 | 固定公式 | k1、b 可调整 |
饱和效应示意:
makefile
TF-IDF:
词频 1 → 分数 1.0
词频 2 → 分数 2.0
词频 10 → 分数 10.0 ← 无限增长
BM25:
词频 1 → 分数 0.8
词频 2 → 分数 1.3
词频 10 → 分数 2.0 ← 趋于饱和
5.6 ES 的聚合(Aggregation)原理
5.6.1 聚合类型
python
┌─────────────────────────────────────────────────────────────┐
│ Elasticsearch 聚合类型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 桶聚合(Bucket) - 分组 │
│ ├── terms 按字段值分组 │
│ ├── range 按数值范围分组 │
│ ├── date_histogram 按时间间隔分组 │
│ └── filter 按条件分组 │
│ │
│ 指标聚合(Metric) - 计算 │
│ ├── avg / sum / min / max 基础统计 │
│ ├── stats 综合统计 │
│ ├── cardinality 去重计数(近似) │
│ └── percentiles 百分位数 │
│ │
│ 管道聚合(Pipeline) - 基于其他聚合结果 │
│ ├── derivative 求导 │
│ ├── cumulative_sum 累加和 │
│ └── moving_avg 移动平均 │
│ │
└─────────────────────────────────────────────────────────────┘
5.6.2 Terms 聚合原理
示例:统计每种商品的销量
json
GET /orders/_search
{
"size": 0,
"aggs": {
"products": {
"terms": {
"field": "product_name.keyword",
"size": 10
}
}
}
}
执行过程:
less
1. 协调节点收到请求,广播到所有分片
2. 每个分片本地执行:
a. 遍历所有文档(或匹配的文档)
b. 读取 product_name 字段值
c. 在内存中构建哈希表
d. 排序,返回 Top N 给协调节点
3. 协调节点合并各分片结果:
a. 合并哈希表
b. 重新排序
c. 返回最终 Top N
5.7 为什么 ES 不适合做事务型数据库
5.7.1 没有完整的事务支持
json
ES 只支持"单文档"的原子性:
✅ 单文档操作是原子的
PUT /my_index/_doc/1 { ... } // 成功或失败,不会半成功
❌ 多文档操作不是原子的
POST /_bulk
{ "index": { "_id": 1 } }
{ "balance": 100 }
{ "index": { "_id": 2 } }
{ "balance": 200 }
// 第一个成功,第二个失败 → 部分成功,无法回滚
5.7.2 最终一致性,非强一致性
diff
ES 的写入流程:
- 写入后约 1 秒才能搜到(近实时)
- 宕机可能丢失最近 1 秒的数据(如果没 flush)
MySQL InnoDB:
- Redo Log 保证持久性(实时刷盘)
- 写入立即对其他事务可见(或按隔离级别)
- 宕机恢复到最新状态
5.7.3 更新成本高
diff
ES 的更新 = 删除旧文档 + 写入新文档
问题:
- 更新比插入慢很多
- 频繁更新会产生大量标记删除,占用空间
- Merge 会消耗 IO
5.8 适用场景
| 场景 | 是否推荐 |
|---|---|
| 全文搜索 | ✅ 推荐 |
| 日志分析(ELK) | ✅ 推荐 |
| 监控告警 | ✅ 推荐 |
| 时序数据 | ✅ 推荐 |
| 银行转账 | ❌ 不推荐 |
| 订单处理 | ❌ 不推荐 |
| 库存扣减 | ❌ 不推荐 |
| 复杂事务 | ❌ 不推荐 |
六、横向对比与选型指南
6.1 核心特性对比
| 维度 | MySQL | MongoDB | Redis | Elasticsearch |
|---|---|---|---|---|
| 核心数据结构 | B+树(行) | B-Tree(BSON文档) | 哈希表(内存) | 倒排索引(词→文档) |
| 存储介质 | 磁盘 | 磁盘 | 内存 | 磁盘 |
| 读写速度 | 毫秒级 | 毫秒级 | 微秒级 | 毫秒级(搜索快) |
| 主要用途 | 事务型数据 | 灵活文档存储 | 缓存/临时数据 | 全文搜索/日志 |
| 一致性 | 强一致(ACID) | 最终一致(可调) | 强一致(单线程) | 最终一致 |
| 扩展方式 | 主从/分库分表 | 分片集群 | 集群(16384槽) | 分片+副本 |
6.2 查询能力对比
| 查询类型 | MySQL | MongoDB | Redis | Elasticsearch |
|---|---|---|---|---|
| 主键查询 | ✅ 极快 | ✅ 快 | ✅ 极快 | ✅ 快 |
| 范围查询 | ✅ 快 | ✅ 快 | ✅ 快 | ✅ 快 |
| 全文搜索 | ⚠️ 弱 | ⚠️ 弱 | ❌ 不支持 | ✅ 极强 |
| 复杂关联 | ✅ 强 | ⚠️ 弱 | ❌ 不支持 | ❌ 不支持 |
| 聚合分析 | ✅ 强 | ⚠️ 一般 | ⚠️ 一般 | ✅ 强 |
6.3 事务支持对比
| 数据库 | 事务支持 | 隔离级别 | 说明 |
|---|---|---|---|
| MySQL | ✅ 完整 ACID | RU/RC/RR/Serializable | 最成熟的事务支持 |
| MongoDB | ⚠️ 4.0+ 支持多文档事务 | - | 性能开销大 |
| Redis | ✅ 单命令原子性 | - | 事务队列(MULTI/EXEC) |
| Elasticsearch | ❌ 单文档原子性 | - | 不支持跨文档事务 |
6.4 选型决策树
markdown
开始选型
│
├─ 需要强事务一致性?
│ └─ 是 → MySQL
│
├─ 需要全文搜索?
│ └─ 是 → Elasticsearch
│
├─ 需要极高性能缓存?
│ └─ 是 → Redis
│
├─ 数据结构灵活多变?
│ └─ 是 → MongoDB
│
└─ 默认选择 → MySQL
6.5 常见组合方案
方案1:MySQL + Redis
diff
┌──────────┐ ┌──────────┐
│ MySQL │ ←──→ │ Redis │
│ 主数据库 │ │ 缓存 │
└──────────┘ └──────────┘
适用场景:
- 需要缓存热点数据
- 读多写少
- 对一致性要求较高
方案2:MySQL + Elasticsearch
diff
┌──────────┐ ┌──────────┐
│ MySQL │ ───→ │ ES │
│ 主数据库 │ 同步 │ 搜索 │
└──────────┘ └──────────┘
适用场景:
- 需要复杂搜索功能
- 日志分析
- MySQL 负责事务,ES 负责搜索
方案3:MySQL + Redis + Elasticsearch
diff
┌──────────┐ ┌──────────┐ ┌──────────┐
│ MySQL │ ←──→ │ Redis │ ───→ │ ES │
│ 主数据库 │ │ 缓存 │ │ 搜索 │
└──────────┘ └──────────┘ └──────────┘
适用场景:
- 电商系统
- 内容管理系统
- 需要 ACID + 高性能 + 搜索
七、进阶深入方向
7.1 MySQL 进阶
| 主题 | 关键问题 |
|---|---|
| MVCC | Read View 如何配合 Undo Log 实现可重复读? |
| COUNT(*) | 为什么 InnoDB 的 COUNT(*) 慢?如何优化? |
| 索引失效 | 最左匹配、隐式转换、函数运算等场景 |
| 锁机制 | 行锁、间隙锁、临键锁的原理 |
| Buffer Pool | 页面管理、LRU 淘汰策略 |
7.2 MongoDB 进阶
| 主题 | 关键问题 |
|---|---|
| 分片 | 分片键如何选择?范围分片 vs 哈希分片? |
| 复制集 | 选主流程(Raft)如何工作? |
| $lookup | 为什么不适合复杂关联查询? |
| Change Stream | 如何实现数据变更监听? |
| 索引优化 | 复合索引、TTL 索引的使用 |
7.3 Redis 进阶
| 主题 | 关键问题 |
|---|---|
| 跳跃表 | 为什么 ZSET 用跳跃表而不是红黑树? |
| 淘汰策略 | LRU/LFU 的实现原理?如何选择? |
| 缓存问题 | 穿透/击穿/雪崩的解决方案? |
| 持久化 | RDB vs AOF 的取舍? |
| 集群 | Redis Cluster 的槽位分配原理? |
7.4 Elasticsearch 进阶
| 主题 | 关键问题 |
|---|---|
| BM25 | 评分公式如何计算?参数如何调优? |
| 聚合 | Terms 聚合的精度问题?如何优化? |
| Segment | 为什么 Segment 是不可变的?Merge 策略? |
| 写入性能 | bulk 批量写入的最佳实践? |
| 分片策略 | 分片数量如何规划?热点问题如何解决? |
附录:常见面试题速查
MySQL
| 问题 | 答案要点 |
|---|---|
| 为什么用 B+树? | 矮胖减少磁盘 IO,叶子链表支持范围查询 |
| 聚簇索引 vs 二级索引? | 聚簇索引叶节点存整行,二级索引存主键值(需回表) |
| MVCC 原理? | 隐藏字段 + Undo Log + Read View |
| COUNT(*) 为什么慢? | 需要遍历索引,MVVC 导致无法存储准确行数 |
| 索引何时失效? | 最左匹配、隐式转换、函数运算、前缀通配符 |
MongoDB
| 问题 | 答案要点 |
|---|---|
| BSON vs JSON? | BSON 是二进制格式,支持更多类型,解析更快 |
| 分片键如何选择? | 高基数、低频率、符合查询模式 |
| 复制集选主流程? | Raft 协议变种,多数投票,优先级+数据新鲜度 |
| 为什么不适合复杂关联? | $lookup 是嵌套循环,无 JOIN 优化 |
Redis
| 问题 | 答案要点 |
|---|---|
| 为什么单线程还快? | 内存操作 + 事件驱动 + 无锁竞争 |
| 跳跃表 vs 红黑树? | 实现简单、范围查询友好 |
| 淘汰策略有哪些? | noeviction、allkeys-lru、volatile-lru 等 8 种 |
| 缓存穿透/击穿/雪崩? | 布隆过滤器、互斥锁、随机过期 |
Elasticsearch
| 问题 | 答案要点 |
|---|---|
| 倒排索引原理? | 词项 → 文档列表,以词找文档 |
| 为什么近实时? | refresh 间隔 1 秒,Segment 才可被搜索 |
| BM25 vs TF-IDF? | 词频饱和增长、文档长度归一化 |
| 为什么不适合事务? | 无跨文档事务、最终一致性、更新成本高 |