四大数据库底层原理详解

1 阅读15分钟

四大数据库底层原理详解

MySQL · MongoDB · Redis · Elasticsearch 完整指南


目录

  1. 核心概念总览
  2. MySQL —— 关系型数据库
  3. MongoDB —— 文档型 NoSQL
  4. Redis —— 内存键值缓存/数据库
  5. Elasticsearch —— 全文检索引擎
  6. 横向对比与选型指南
  7. 进阶深入方向

一、核心概念总览

1.1 一句话对比

数据库核心数据结构存储介质主要用途一句话描述
MySQL行存储 + B+树索引磁盘结构化事务数据最成熟的关系型数据库,事务一致性强
MongoDBBSON文档 + B-Tree磁盘灵活文档型数据灵活的文档存储,适合快速迭代
Redis哈希表 + 多种数据结构内存高速缓存/临时数据极速的内存数据库,适合缓存场景
Elasticsearch倒排索引 + Segment磁盘全文搜索与分析强大的搜索引擎,适合日志和检索

1.2 存储介质对比

存储介质访问速度容量成本典型应用
内存~100nsGB级Redis、缓存
SSD磁盘~100μsTB级MySQL、MongoDB、ES
HDD磁盘~10msTB-PB级冷数据存储、备份

二、MySQL —— 关系型数据库(RDBMS)

2.1 基础架构:行存储

MySQL(InnoDB)按「行」把数据存到磁盘上:

  • 磁盘上最小管理单位是页(Page),默认 16KB
  • 一行记录(INSERT 的一条数据)会被放进某个页里
  • 一个页里通常放多行记录
┌──────────────────────────────────────┐
│         数据页 (16KB)                 │
│  ┌─────────┐ ┌─────────┐            │
│  │ row1    │ │ row2    │  ...       │
│  └─────────┘ └─────────┘            │
└──────────────────────────────────────┘

优缺点分析:

维度优点缺点
读取SELECT * 整行一次性读出只查某几列时也会读整行
写入整行写入效率高更新部分列需要锁定整行
压缩适合行级压缩列级压缩效率不如列存储

2.2 B+树索引

2.2.1 B+树结构特点

B+树是一种多路平衡查找树,核心特点:

              [18, 35]
             /    |    \
       ┌─────┘    └─────┐
   [3,7,12]      [20,25]      [36,40]
    ↓  ← 链表连接 →   ↓   ← 链表 →  ↓
节点类型存储内容说明
非叶子节点索引键 + 指针不存真实数据,减少树高度
叶子节点主键索引存整行数据
二级索引存主键值
所有叶子节点用双向链表连接
2.2.2 聚簇索引(Clustered Index)

InnoDB 中:主键索引 = 聚簇索引

B+树叶子节点就是这一行完整数据。

CREATE TABLE user(
  id INT PRIMARY KEY,
  name VARCHAR(20),
  age INT
);

查询过程示例:

SELECT * FROM user WHERE id = 18;

执行流程:
1. 从 B+树根节点向下查找
2. 到达叶子节点
3. 直接拿到整行数据(id, name, age)

✅ 快的原因:索引即数据,无需回表
2.2.3 二级索引(普通索引)
CREATE INDEX idx_age ON user(age);

B+树叶节点不存整行,只存:(age, id)

查询流程(需要回表):

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 三大核心组件
┌─────────────────────────────────────────────────────────────┐
│                      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)更新了这条数据:

当前数据(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 包含四个关键字段:

m_ids        -- 开启 Read View 时,当前活跃的事务ID列表(未提交)
min_trx_id   -- m_ids 中最小的事务ID
max_trx_id   -- 下一个将分配的事务ID(不是m_ids最大值)
creator_trx_id -- 创建该 Read View 的事务ID

判断规则(当前事务读取某行时):

该行的 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+树的工作流程
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+树是聚簇索引,没有单独存储"总行数"的地方

┌─────────────────────────────────────────┐
│ MyISAM(有元数据存储)                     │
├─────────────────────────────────────────┤
│ 表元数据中存储:rows = 1000000            │
│ SELECT COUNT(*) → 直接读元数据,O(1)      │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ InnoDB(没有总行数存储)                   │
├─────────────────────────────────────────┤
│ 因为 MVCC:                               │
│   - 每个事务可能看到不同的行数              │
│   - 有些行被删除了但还没 purge             │
│   - 有些行是刚插入的还没提交                │
│ 所以无法存储一个"准确的"总行数              │
└─────────────────────────────────────────┘
2.5.2 COUNT(*) 执行过程
SELECT COUNT(*) FROM user;

执行流程:

1. 选择最小的辅助索引(不是聚簇索引)
   → 辅助索引叶子节点只存 (索引列, 主键),比聚簇索引小

2. 遍历该索引的所有叶子节点
   → 统计符合条件的行数

3. 需要判断可见性(MVCC)
   → 不是简单的数节点个数
2.5.3 各种 COUNT 的区别
-- 推荐 ✅
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:使用计数表

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 缓存

SET user:count 1000000
INCR user:count
DECR user:count

方案3:估算(不需要精确值)

EXPLAIN SELECT * FROM user;
-- rows 字段是估算值

-- 或使用 information_schema
SELECT TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_NAME = 'user';

2.6 索引失效的常见场景

2.6.1 最左匹配原则

复合索引的匹配规则:从左到右依次匹配,不能跳过

-- 假设有索引: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) 组合排序的

叶子节点:
(张三, 18) → (张三, 20) → (张三, 25) → (李四, 18) → (李四, 22)

查询 name='张三':可以定位到张三的起始位置,范围扫描
查询 age=20:B+树先按 name 排序,age 是无序的,只能全表扫描

范围查询会中断后续索引使用:

-- 假设索引:(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 隐式类型转换
-- 假设 phone 是 VARCHAR 类型,有索引

-- ❌ 不走索引(字符串和数字比较,MySQL 会把字符串转数字)
SELECT * FROM user WHERE phone = 13800138000;
-- 等价于:CAST(phone AS SIGNED) = 13800138000

-- ✅ 赋索引
SELECT * FROM user WHERE phone = '13800138000';
2.6.3 对索引列做运算/函数
-- ❌ 不走索引
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 以通配符开头
-- ✅ 赋索引(前缀匹配)
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 NULLWHERE name IS NULL(NULL值比例高)⚠️ 可能不走

2.7 适用场景

场景是否推荐
银行交易、订单系统✅ 强推荐
CMS 内容管理✅ 推荐
电商核心业务✅ 推荐
需要复杂关联查询✅ 推荐
日志存储⚠️ 可用但不是最优
海量数据写入⚠️ 需要分库分表

三、MongoDB —— 文档型 NoSQL

3.1 BSON 格式

3.1.1 JSON vs BSON

JSON 示例:

{
  "name": "张三",
  "age": 28,
  "hobbies": ["篮球", "吉他"]
}

MongoDB 不存 JSON,存的是 BSON(Binary JSON)

BSON 相比 JSON 的优势:

特性JSONBSON
数据类型字符串、数字、布尔、数组、对象额外支持 Date、Binary、Decimal128、ObjectId
解析速度需逐字符扫描长度前缀,可直接定位
空间效率较低较高(二进制编码)

JSON 是人看的,BSON 是机器快速读写用的二进制版 JSON

3.1.2 BSON 在磁盘上的样子(简化)

假设文档 {"_id": 1, "name": "张三", "age": 28}:

┌─────────────────────────────────────────────┐
│ 总长度 │ _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+TreeMongoDB WiredTiger B-Tree
叶子节点存数据存数据
非叶子节点只存索引键也存键值对
叶子链表有(范围查询快)没有专门的链表
本质索引和数据分离两层索引和数据在一棵树里
3.2.2 MVCC(多版本并发控制)
时刻1:{_id:1, name:"张三", version:1}
时刻2:更新 name="李四" → 保留旧版本,新版本 version:2
时刻3:另一个读事务仍在读 version:1(不受影响)

特点:

  • 每个文档修改时,不覆盖旧数据,而是生成一个新版本
  • 读操作看到的是某个时间点的快照(snapshot)
  • 写操作之间通过**文档级锁(document-level lock)**隔离

好处:读写互不阻塞,高并发下表现好

3.2.3 写入流程
应用写入 {_id:1, name:"张三"}
        ↓
  内存中的 B-Tree 缓存(cache)
        ↓
  写入 WAL(预写日志,Write-Ahead Log) → 保证宕机不丢数据
        ↓
  定期刷盘(checkpoint):将内存中的 B-Tree 页面写入磁盘
  • WAL 先写,保证 crash-safe
  • 批量刷盘,提升写入吞吐

3.3 分片(Sharding)

3.3.1 为什么需要分片?
单机瓶颈:
- 存储容量:磁盘不够
- 读写性能:CPU/IO 成为瓶颈
- 内存限制:活跃数据放不下

分片 = 水平扩展(Scale Out)
把数据拆分到多个节点,每个节点存一部分数据
3.3.2 分片架构
┌─────────────────────────────────────────────────────────────┐
│                     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)

分片键决定了数据如何分布

// 启用分片
sh.enableSharding("mydb")

// 选择分片键
sh.shardCollection("mydb.user", { "userId": 1 })  // 按 userId 分片
3.3.4 三种分片策略

1. 范围分片(Range Sharding)

按分片键的值范围划分:

Shard 1: userId [0, 10000)
Shard 2: userId [10000, 20000)
Shard 3: userId [20000, ∞)
  • ✅ 优点:范围查询效率高
  • ❌ 缺点:数据分布可能不均匀

2. 哈希分片(Hash Sharding)

按分片键的哈希值分布:

sh.shardCollection("mydb.user", { "userId": "hashed" })

计算 hash(userId) % 分片数 → 决定去哪个分片
  • ✅ 优点:数据分布均匀
  • ❌ 缺点:范围查询需要查询所有分片

3. 区域分片(Zones)

给分片定义"区域",数据按规则路由到特定区域:

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 复制集架构
┌─────────────────────────────────────────────────────────────┐
│                     MongoDB 复制集                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│         ┌──────────────┐                                    │
│         │   Primary    │ ← 所有写操作                        │
│         │   (主节点)    │                                    │
│         └──────┬───────┘                                    │
│                │                                            │
│                │ 同步数据(Oplog)                            │
│                │                                            │
│     ┌──────────┴──────────┐                                 │
│     ↓                     ↓                                  │
│ ┌──────────┐         ┌──────────┐                           │
│ │Secondary │         │Secondary │                           │
│ │ (从节点)  │         │ (从节点)  │                           │
│ └──────────┘         └──────────┘                           │
└─────────────────────────────────────────────────────────────┘

可选角色:

  • Arbiter(仲裁者):不存数据,只参与投票
  • Hidden Secondary:隐藏节点,不接收读请求
  • Delayed Secondary:延迟节点,用于灾备恢复
3.4.2 选主流程

选举算法:Raft 协议变种

1. 发现 Primary 不可达的节点,发起选举
2. 该节点成为"候选人",增加自己的 term
3. 向其他节点发送选举请求(包含自己的优先级、数据新鲜度)
4. 其他节点投票(每个 term 只能投一票)
5. 获得多数票(> N/2)的节点成为新 Primary

投票规则:

因素说明
优先级(priority)priority 越高,越可能被选为主
数据新鲜度(optime)optime 最新的节点优先
网络可达性候选人必须能和多数节点通信
3.4.3 数据同步机制(Oplog)
写操作流程:

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(聚合管道)

// 类似 SQL 的 LEFT JOIN
db.orders.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "user"
    }
  }
])

性能问题:

执行过程:
1. 扫描 orders 集合
2. 对每个 order,扫描 users 集合找匹配项
3. 没有索引优化,类似"嵌套循环连接"

对比 MySQL JOIN:
- MySQL 有多种 JOIN 优化算法
- MongoDB 的 $lookup 本质上是嵌套循环,O(N*M)
3.5.2 数据模型设计理念不同

MongoDB 的设计哲学:嵌入 > 关联

关系型设计(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(快照)
每隔 N 分钟 / 写满多少条后
→ fork 一个子进程
→ 把当前内存里的全部数据 dump 到一个 .rdb 文件
→ 下次启动时加载这个文件恢复数据
特性说明
文件大小
恢复速度
数据丢失可能丢失最近几分钟的数据
4.2.2 AOF(追加日志)
每次写操作(SET/DEL...)
→ 追加到 AOF 文件末尾
→ 可配置:每秒刷盘 / 每次写都刷盘
→ 重启时重放 AOF 恢复数据
特性说明
文件大小
恢复速度
数据丢失最多丢1秒数据

生产环境建议:两者都开,RDB 用于快速恢复,AOF 用于防丢数据

4.3 事件驱动单线程模型

4.3.1 「单线程」是指什么?

Redis 的命令处理是单线程的,所有客户端发来的命令串行执行:

时间轴 →
客户端A: SET a 1
客户端B: GET a
客户端C: DEL b
                    ↓
          Redis 单线程依次处理:
          SET a 1GET a → DEL b

优缺点:

优点缺点
没有锁竞争一个耗时命令会阻塞后面所有命令
没有死锁风险不能充分利用多核 CPU
实现简单-
4.3.2 「事件驱动」是什么?

Redis 用**事件循环(Event Loop)**来处理:

无限循环:
  1. 等待事件发生(epoll/kqueue 监听 socket)
  2. 有事件来了 → 处理这个事件(读请求、执行命令、写响应)
  3. 回到步骤1

跟 Node.js、Nginx 的事件循环是同一个思路

4.3.3 Redis 6.0 之后的多线程

Redis 6.0 的「多线程」只针对网络 IO 部分:

传统单线程:
  [网络读][命令执行][网络写]
  全部由一个线程完成

Redis 6.0+:
  [网络读] ← 多线程并行读
  [命令执行] ← 仍然是单线程!
  [网络写] → 多线程并行写

核心命令执行还是单线程,只是把网络收发这种 CPU 密集的活分给多线程干

4.4 核心数据结构

结构底层实现典型场景
StringSDS(Simple Dynamic String)缓存、计数器
Listquicklist(ziplist + linkedlist 混合)消息队列
Sethashtable / intset标签、去重
ZSetskiplist + hashtable排行榜
Hashziplist / hashtable对象缓存
Streamradix tree消息队列(替代 Pub/Sub)

4.5 跳跃表(Skiplist)详解

4.5.1 什么是跳跃表?

跳跃表 = 有序链表 + 多级索引

原始有序链表(查找 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 的实现
// Redis 源码中的 ZSET 结构
typedef struct zset {
    dict *dict;      // 哈希表:member → score(O(1) 查找分数)
    zskiplist *zsl;  // 跳跃表:score → member(O(logN) 范围查询)
} zset;

为什么要同时用两个结构?

查询某用户分数:用 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 为什么需要淘汰策略?
Redis 是内存数据库,内存有限

当内存使用达到 maxmemory 阈值时:
1. 新写入会失败(OOM)
2. 或者根据淘汰策略删除部分 key
4.6.2 八种淘汰策略
┌─────────────────────────────────────────────────────────────┐
│                  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)

定义: 查询一个根本不存在的数据,缓存和数据库都没有

请求流程:
客户端请求 key=999999
    ↓
Redis: 不存在MySQL: 也不存在
    ↓
返回空,但下次请求还会查数据库

危害: 恶意请求大量不存在的 key,压垮数据库

解决方案:

方案1:缓存空值

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)

布隆过滤器:一个超大的位数组 + 多个哈希函数

写入时:
1. 对 key 计算多个哈希值
2. 在位数组对应位置标记为 1

查询时:
1. 对 key 计算多个哈希值
2. 检查位数组对应位置是否都为 1
   - 都为 1:key **可能存在**(有误判率)
   - 有一个为 0:key **一定不存在**
// Redis 4.0+ 内置布隆过滤器
BF.ADD users user:1001    // 添加
BF.EXISTS users user:1001 // 查询
4.7.2 缓存击穿(Breakdown)

定义: 某个热点 key 过期,瞬间大量请求穿透到数据库

场景:
key="hot_product_123" 正在被大量访问
TTL 到期,key 被删除
此时 1000 个并发请求同时查询该 key
全部穿透到数据库

解决方案:

方案1:互斥锁(只让一个请求查库)

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,用字段判断)

// 数据结构
{
    "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 宕机

场景1:批量设置缓存,过期时间相同
1小时后,所有 key 同时过期
瞬间大量请求穿透到数据库

场景2:Redis 宕机
所有请求全部打到数据库

解决方案:

方案1:过期时间加随机值

// 过期时间 = 基础时间 + 随机时间
int baseExpire = 3600;
int randomExpire = new Random().nextInt(600);  // 0-600秒随机
redis.set(key, value, baseExpire + randomExpire);

方案2:多级缓存

请求 → L1 缓存(本地) → L2 缓存(Redis) → 数据库

本地缓存(Caffeine/Guava Cache):
- 容量小,速度快
- 即使 Redis 挂了,本地缓存还能抗一部分流量

方案3:熔断降级

@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 那种):

文档1[我, 喜欢, 吃, 苹果]
文档2[苹果, 手机, 很, 好用]
文档3[我, 喜欢, 香蕉]

查询「苹果出现在哪些文档?」→ 得扫一遍所有文档 →

倒排索引(ES 那种):

苹果 → [文档1, 文档2]    ← 词 → 文档列表
我   → [文档1, 文档3]
喜欢 → [文档1, 文档3]
吃   → [文档1]
手机 → [文档2]
...

查询「苹果」→ 直接查词典 → 瞬间知道在文档1和2里 →

倒排索引 = 以词找文档,而不是以文档找词

5.1.2 倒排索引结构(简化版)
┌──────────┬──────────────────────────────┐
│  词项    │  倒排列表(Posting List)       │
├──────────┼──────────────────────────────┤
│ 苹果     │ (doc1, 位置5) (doc2, 位置1)    │
│ 手机     │ (doc2, 位置2)                  │
│ 我喜欢   │ (doc1, 位置1) (doc3, 位置1)    │
└──────────┴──────────────────────────────┘

每个词项后面还记录了:

  • 文档ID
  • 词频(TF):该词在文档中出现次数
  • 位置:在文档中的第几个词
  • 偏移量:在原文中的字符位置(用于高亮)
5.1.3 建索引的过程(分词)
输入:"苹果手机很好用"
        ↓
  分词器(Analyzer):
  1. 字符过滤(去掉HTML标签等)
  2. 分词(按空格/标点/语言规则切词)
  3. 词项过滤(转小写、去停用词、词干提取)
        ↓
  输出词项:[苹果, 手机, 很好, 用]
        ↓
  写入倒排索引

不同语言用不同分词器:

  • 中文:IK分词器、HanLP
  • 英文:Standard Analyzer(自动转小写+去复数)

5.2 Lucene 库

5.2.1 Lucene 是 ES 的发动机
  • Lucene:一个 Java 写的全文检索引擎库(Apache 基金会)
  • Elasticsearch:在 Lucene 外面包了一层:分布式、RESTful API、集群管理、监控……
Elasticsearch = 一辆汽车(整车)
Lucene       = 发动机(核心动力)
5.2.2 Segment(段)—— 不可变的秘密

Lucene 的索引由多个 Segment 组成:

写入流程:
  新文档 → 内存缓冲区
        ↓(每秒 refresh)
  生成一个小 Segment(可被搜索)
        ↓(后台 merge)
  多个小 Segment 合并成大 Segment
        ↓
  删除旧的 Segment

特点:

  • Segment 一旦写入就不变了(immutable)
  • 删除文档只是在 Segment 里打个标记(不是真删)
  • 更新文档 = 标记旧文档删除 + 写入新文档
优点缺点
写入时不需要加锁删除的空间不会立即释放
读取时只需加载 Segment 索引合并时会有 IO 压力
利用 OS Page Cache 加速-

5.3 ES 的「近实时」实现

写入一条文档
        ↓
  ① 写入内存缓冲区(Memory Buffer)
        ↓(同时写入 translog 防止丢数据)
  ② 每 1 秒(默认) → refresh
        ↓
     内存缓冲区清空,生成新的 Segment
     这个 Segment 打开,可以被搜索
        ↓
  ③ 每 30 分钟 或 translog 满了 → flush
        ↓
     Segment 刷到磁盘
     translog 清空
  • 写入后最快 1 秒才能搜到(近实时,不是实时)
  • refresh_interval 改成 -1 → 永远搜不到刚写的数据

5.4 ES 的分布式架构

集群(Cluster)
  └── 节点(Node)
       ├── 主节点(Master):管理集群状态、分配分片
       └── 数据节点(Data):存数据、处理查询

索引(Index)
  └── 分片(Shard) ← 每个分片就是一个独立的 Lucene 实例
       ├── 主分片(Primary Shard):写入口
       └── 副本分片(Replica Shard):读备份
5.4.1 写入流程
客户端 POST /my_index/_doc/1
        ↓
  路由计算:shard = hash(_id) % number_of_primary_shards
        ↓
  转发到对应的主分片所在的节点
        ↓
  写入主分片 → 同步到副本分片 → 返回成功
5.4.2 查询流程
客户端 GET /my_index/_search?q=苹果
        ↓
  协调节点(接收请求的节点)
        ↓
  广播到所有分片(主分片 + 副本分片都行)
        ↓
  各分片本地搜索 → 返回结果给协调节点
        ↓
  协调节点合并排序 → 返回给客户端

5.5 BM25 评分公式

5.5.1 什么是 BM25?

BM25(Best Matching 25) 是 ES 默认的相关性评分算法,用于衡量文档与查询的匹配程度。

5.5.2 BM25 公式
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-IDFBM25
词频增长线性增长饱和增长(有上限)
文档长度不考虑归一化(惩罚长文档)
参数可调固定公式k1、b 可调整

饱和效应示意:

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 聚合类型
┌─────────────────────────────────────────────────────────────┐
│                  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 聚合原理

示例:统计每种商品的销量

GET /orders/_search
{
  "size": 0,
  "aggs": {
    "products": {
      "terms": {
        "field": "product_name.keyword",
        "size": 10
      }
    }
  }
}

执行过程:

1. 协调节点收到请求,广播到所有分片

2. 每个分片本地执行:
   a. 遍历所有文档(或匹配的文档)
   b. 读取 product_name 字段值
   c. 在内存中构建哈希表
   d. 排序,返回 Top N 给协调节点

3. 协调节点合并各分片结果:
   a. 合并哈希表
   b. 重新排序
   c. 返回最终 Top N

5.7 为什么 ES 不适合做事务型数据库

5.7.1 没有完整的事务支持
ES 只支持"单文档"的原子性:

✅ 单文档操作是原子的
PUT /my_index/_doc/1 { ... }  // 成功或失败,不会半成功

❌ 多文档操作不是原子的
POST /_bulk
{ "index": { "_id": 1 } }
{ "balance": 100 }
{ "index": { "_id": 2 } }
{ "balance": 200 }

// 第一个成功,第二个失败 → 部分成功,无法回滚
5.7.2 最终一致性,非强一致性
ES 的写入流程:
- 写入后约 1 秒才能搜到(近实时)
- 宕机可能丢失最近 1 秒的数据(如果没 flush)

MySQL InnoDB:
- Redo Log 保证持久性(实时刷盘)
- 写入立即对其他事务可见(或按隔离级别)
- 宕机恢复到最新状态
5.7.3 更新成本高
ES 的更新 = 删除旧文档 + 写入新文档

问题:
- 更新比插入慢很多
- 频繁更新会产生大量标记删除,占用空间
- Merge 会消耗 IO

5.8 适用场景

场景是否推荐
全文搜索✅ 推荐
日志分析(ELK)✅ 推荐
监控告警✅ 推荐
时序数据✅ 推荐
银行转账❌ 不推荐
订单处理❌ 不推荐
库存扣减❌ 不推荐
复杂事务❌ 不推荐

六、横向对比与选型指南

6.1 核心特性对比

维度MySQLMongoDBRedisElasticsearch
核心数据结构B+树(行)B-Tree(BSON文档)哈希表(内存)倒排索引(词→文档)
存储介质磁盘磁盘内存磁盘
读写速度毫秒级毫秒级微秒级毫秒级(搜索快)
主要用途事务型数据灵活文档存储缓存/临时数据全文搜索/日志
一致性强一致(ACID)最终一致(可调)强一致(单线程)最终一致
扩展方式主从/分库分表分片集群集群(16384槽)分片+副本

6.2 查询能力对比

查询类型MySQLMongoDBRedisElasticsearch
主键查询✅ 极快✅ 快✅ 极快✅ 快
范围查询✅ 快✅ 快✅ 快✅ 快
全文搜索⚠️ 弱⚠️ 弱❌ 不支持✅ 极强
复杂关联✅ 强⚠️ 弱❌ 不支持❌ 不支持
聚合分析✅ 强⚠️ 一般⚠️ 一般✅ 强

6.3 事务支持对比

数据库事务支持隔离级别说明
MySQL✅ 完整 ACIDRU/RC/RR/Serializable最成熟的事务支持
MongoDB⚠️ 4.0+ 支持多文档事务-性能开销大
Redis✅ 单命令原子性-事务队列(MULTI/EXEC)
Elasticsearch❌ 单文档原子性-不支持跨文档事务

6.4 选型决策树

开始选型
    │
    ├─ 需要强事务一致性?
    │   └─ 是 → MySQL
    │
    ├─ 需要全文搜索?
    │   └─ 是 → Elasticsearch
    │
    ├─ 需要极高性能缓存?
    │   └─ 是 → Redis
    │
    ├─ 数据结构灵活多变?
    │   └─ 是 → MongoDB
    │
    └─ 默认选择 → MySQL

6.5 常见组合方案

方案1:MySQL + Redis
┌──────────┐      ┌──────────┐
│   MySQL  │ ←──→ │   Redis  │
│  主数据库  │      │   缓存    │
└──────────┘      └──────────┘

适用场景:
- 需要缓存热点数据
- 读多写少
- 对一致性要求较高
方案2:MySQL + Elasticsearch
┌──────────┐      ┌──────────┐
│   MySQL  │ ───→ │    ES    │
│  主数据库  │ 同步  │   搜索    │
└──────────┘      └──────────┘

适用场景:
- 需要复杂搜索功能
- 日志分析
- MySQL 负责事务,ES 负责搜索
方案3:MySQL + Redis + Elasticsearch
┌──────────┐      ┌──────────┐      ┌──────────┐
│   MySQL  │ ←──→ │   Redis  │ ───→ │    ES    │
│  主数据库  │      │   缓存    │      │   搜索    │
└──────────┘      └──────────┘      └──────────┘

适用场景:
- 电商系统
- 内容管理系统
- 需要 ACID + 高性能 + 搜索

七、进阶深入方向

7.1 MySQL 进阶

主题关键问题
MVCCRead 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?词频饱和增长、文档长度归一化
为什么不适合事务?无跨文档事务、最终一致性、更新成本高