存储与数据库深入 | RAID·B+树·SQL·键值存储·对象存储·缓存
章节导言
在[07-存储高性能]中,我们从架构层面讨论了读写分离、分库分表、NoSQL 与缓存等存储高性能方案。然而,架构决策的底气来自对底层原理的深刻理解——为什么 RAID 10 是数据库的首选?为什么 B+ 树而非哈希表成为数据库索引的标准?SQL 的声明式查询如何被优化器转化为物理执行计划?键值存储、对象存储与缓存各自解决了什么本质问题?
许式伟提出了一个深刻洞察:存储即数据结构。客户端开发中数据结构是内存中的 Map、List、Tree;服务端开发中数据结构是外存上的 KV 存储、数据库、消息队列。存储中间件本质上是面向外存的、高可用的、分布式的"元数据结构"。理解存储系统,就是理解这些"元数据结构"的设计权衡。
本节将从物理存储基础(RAID)到逻辑数据组织(B+ 树),从查询语言(SQL)到存储中间件选型(键值存储、对象存储),再到性能加速(缓存),构建一个完整的存储与数据库知识体系。
核心问题:
- RAID 各级别在性能、可靠性、成本之间如何权衡?大容量磁盘时代为何 RAID 5 不再适用?
- B+ 树为何是外存数据结构的最优选择?树高与 I/O 次数的数学关系是什么?
- SQL 的声明式查询如何通过关系代数等价变换被优化器转化为最优执行计划?
- 键值存储、文档数据库、列族数据库、图数据库各自的定位与适用场景是什么?
- 对象存储为何是服务端与桌面操作系统分道扬镳的标志?
- 缓存策略(Cache-Aside / Write-Through / Write-Behind)的一致性边界在哪里?
一、RAID:多磁盘冗余阵列
1.1 RAID 的本质
单块磁盘始终面临两个根本性问题:容量上限与单点故障。RAID(Redundant Array of Independent Disks,独立磁盘冗余阵列)通过将多块物理磁盘组织为逻辑整体,在性能、可靠性和成本之间寻找平衡点。它是现代存储系统的基石——无论是企业级 SAN 还是云厂商的块存储,底层无一例外都依赖 RAID 或其变体思想。
RAID 的本质是一种存储虚拟化技术,将 N 块物理磁盘抽象为一块逻辑磁盘,对上层文件系统完全透明。其核心手段有三:
- 条带化(Striping):将数据分片跨磁盘分布,提升并行 I/O 能力
- 镜像(Mirroring):将同一数据写入多块磁盘,实现冗余
- 校验(Parity):通过异或等编码计算校验信息,以更低的存储开销实现容错
三者并非互斥,不同 RAID 级别是对这三者的不同组合与权衡。
核心术语:
| 术语 | 含义 |
|---|---|
| Stripe | 条带,数据跨磁盘分布的最小单元 |
| Stripe Size | 条带大小,即单块磁盘上连续写入的数据量 |
| Chunk / Extent | 单块磁盘上分配的连续空间单元 |
| Rebuild | 故障磁盘更换后,从冗余信息恢复数据的过程 |
| Degraded | 降级状态,阵列中有磁盘故障但仍可工作 |
| Hot Spare | 热备盘,自动顶替故障磁盘的空闲盘 |
1.2 RAID 级别详解
RAID 0:纯条带化
- 原理:数据按条带轮转写入所有磁盘,无冗余
- 性能:读写均提升 N 倍(N 为磁盘数)
- 可靠性:任一磁盘故障即全盘数据丢失,MTTDL 反而降低
- 空间利用率:100%
- 适用场景:临时数据、缓存、对可靠性无要求的场景
RAID 1:镜像
- 原理:每块数据完整写入两块(或更多)磁盘
- 性能:读性能可翻倍(可从任一副本读取),写性能无提升
- 可靠性:允许 N-1 块磁盘故障(N 为副本数)
- 空间利用率:50%(双副本)
- 适用场景:操作系统盘、关键数据库日志
RAID 5:带校验的条带化
- 原理:条带化 + 分布式校验,校验块轮转分布在各磁盘上
- 校验算法:P = D0 ⊕ D1 ⊕ ... ⊕ Dn-1(异或运算),故障时通过剩余数据与校验值异或恢复
- 性能:读性能近似 RAID 0;写性能因校验计算(读-改-写)而下降,尤其小随机写(RAID 5 Write Penalty)
- 可靠性:允许单盘故障
- 空间利用率:(N-1)/N
- 适用场景:文件服务器、Web 服务器等读多写少场景
RAID 5 小随机写惩罚:一次 4KB 随机写需要 2 次读(读旧数据 + 读旧校验)+ 2 次写(写新数据 + 写新校验),即 I/O 放大为 4 倍。这是 RAID 5 在 OLTP 场景下的致命缺陷。
RAID 6:双校验条带化
- 原理:在 RAID 5 基础上增加第二维校验(Q),通常基于 Reed-Solomon 编码或伽罗瓦域运算
- 可靠性:允许双盘同时故障——这在磁盘 Rebuild 周期越来越长的今天至关重要
- 空间利用率:(N-2)/N
- 写惩罚:小随机写 I/O 放大为 6 倍
- 适用场景:对可靠性要求较高的企业级存储
RAID 10(1+0):镜像 + 条带化
- 原理:先做 RAID 1 镜像,再做 RAID 0 条带化
- 性能:读性能翻倍,写性能无校验开销,整体性能最优
- 可靠性:每个镜像对内可坏一盘,但不同镜像对不能同时坏两盘(概率上优于 RAID 5)
- 空间利用率:50%
- 适用场景:数据库 OLTP 的首选——高并发随机写无校验惩罚
1.3 RAID 级别对比总览
| RAID 级别 | 最少磁盘 | 容错能力 | 空间利用率 | 读性能 | 写性能 | 典型场景 |
|---|---|---|---|---|---|---|
| RAID 0 | 1 | 0 | 100% | N 倍 | N 倍 | 临时数据 |
| RAID 1 | 2 | N-1 | 50% | N 倍 | 1 倍 | 系统盘/日志 |
| RAID 5 | 3 | 1 | (N-1)/N | 近 N 倍 | 受限 | 文件服务 |
| RAID 6 | 4 | 2 | (N-2)/N | 近 N 倍 | 受限 | 归档存储 |
| RAID 10 | 4 | 每镜像对1 | 50% | N 倍 | N/2 倍 | 数据库 OLTP |
1.4 三角博弈:性能、可靠性、成本
不存在同时满足高性能、高可靠、低成本的 RAID 方案。所有 RAID 级别都是在此三角形上的不同投影:
- RAID 0 极致性能与成本,但零可靠性
- RAID 6 极致可靠性,但写性能差、成本高
- RAID 10 高性能与高可靠,但成本最高
关键权衡维度:
-
写惩罚 vs 空间效率:RAID 5/6 以写性能换取空间效率;RAID 10 以空间效率换取写性能。在 SSD 时代,写惩罚的影响进一步放大——SSD 寿命由写入量决定,RAID 5/6 的小写放大直接缩短 SSD 寿命。
-
Rebuild 时间 vs 数据安全窗口:磁盘容量增长远快于带宽增长。一块 20TB 磁盘的 Rebuild 可能需要数十小时,在此期间阵列处于降级状态。RAID 5 在 Rebuild 期间遇到不可恢复读错误(URE)将导致数据丢失。这是大容量磁盘时代 RAID 5 逐渐被淘汰的根本原因。
-
一致性 vs 性能:RAID 写入顺序关乎一致性。非易失性缓存(BBU/NVCache)是解决"写空洞"问题的关键——它确保断电后校验与数据的一致性。
1.5 硬件 RAID vs 软件 RAID
| 维度 | 硬件 RAID | 软件 RAID |
|---|---|---|
| 实现方式 | 专用 RAID 控制器卡 | 操作系统内核模块(mdadm/ZFS) |
| 校验计算 | 硬件 Offload,不占 CPU | 消耗 CPU 资源 |
| 缓存保护 | BBU/NVCache,掉电数据不丢失 | 依赖系统内存,需配置 Write Barrier |
| 供应商锁定 | 有,控制器故障需同型号替换 | 无,可跨硬件迁移 |
| 灵活性 | 固定级别,升级需换卡 | 可动态调整级别和参数 |
| 单点故障 | 控制器本身可能故障 | 无额外硬件故障点 |
| 适用场景 | 传统企业级存储 | 互联网公司、云环境、软件定义存储 |
深度注记:现代分布式存储系统(如 Ceph、Google Colossus)本质上是对 RAID 思想的分布式化——将校验、条带化、冗余从单机提升到集群维度。某大型互联网公司的存储演进路径为:硬件 RAID 控制器 + RAID 10 → 软件 RAID + JBOD + 副本冗余 → 分布式存储(Erasure Coding + 多副本)。这体现了从硬件 RAID 到软件定义存储的不可逆趋势。
1.6 RAID 反模式
反模式 1:RAID 5 在大容量磁盘上的使用
在 10TB+ 磁盘上使用 RAID 5 是典型反模式:
- 20TB 磁盘 Rebuild 可超过 24 小时
- SATA 磁盘的 URE 率约 10^(-14),20TB 全盘读取遇到 URE 的概率接近 20%
- Rebuild 过程中遇到 URE,RAID 5 阵列直接失效
正确做法:大容量磁盘场景应使用 RAID 6(双校验)或 RAID 10,或转向分布式 Erasure Coding。
反模式 2:忽略 Write Barrier 与一致性
为追求性能关闭 RAID 控制器的 Write Cache 策略,导致文件系统元数据与 RAID 校验不一致。断电后可能出现文件系统损坏——这在数据库场景下尤为致命。
正确做法:确保 RAID 控制器配备 BBU(电池备份单元)或 NVCache,使 Write Cache 在掉电后数据不丢失,兼顾性能与一致性。
1.7 Erasure Coding——RAID 6 的分布式延伸
纠删码(Erasure Coding)可以视为 RAID 6 的泛化:将数据分为 K 个数据块,计算 M 个校验块,任意 K 个块即可恢复数据。这使得空间利用率可达 K/(K+M),远超传统三副本的 33%。
冷数据存储中,Erasure Coding 已成为标配。以 28+4 为例:
- 将文件切分为 28 份数据片,计算出 4 份冗余片,共 32 份存储在 32 台机器
- 成本:1.14x(对比 3 副本的 3x,节省 62%)
- 容错:允许同时损坏 4 块盘(对比 3 副本仅允许 2 块)
- 代价:读修复需要 N 台机器参与计算,网络和 CPU 开销更大
二、B+ 树:外存索引的基石
2.1 外存 vs 内存:访问模型的根本差异
理解 B+ 树之前,必须先理解外存与内存访问模型的根本差异:
| 维度 | 内存 | 磁盘(HDD) | 磁盘(SSD) |
|---|---|---|---|
| 访问单位 | 字节(Byte) | 扇区(512B/4KB) | 页(4KB~16KB) |
| 随机读延迟 | ~100ns | ~10ms | ~100μs |
| 顺序读吞吐 | ~10GB/s | ~200MB/s | ~3GB/s |
| 随机/顺序比 | ~1:1 | ~1:1000 | ~1:10 |
| 访问模式 | 任意地址 O(1) | 必须整页读入 | 必须整页读入 |
核心洞察:磁盘以"页"为最小访问单位,且顺序访问远快于随机访问。因此,外存数据结构的设计目标是:
- 最小化 I/O 次数:每次访问一个节点应尽量装载更多数据
- 最大化顺序访问:范围查询应能顺序扫描而非随机跳转
内存数据结构(哈希表、红黑树、跳表等)的访问以纳秒计,而磁盘访问以毫秒计,两者相差约 10^6 倍。这意味着一个设计精良的内存算法在磁盘上可能完全不可用。B+ 树正是为外存而生的数据结构。
2.2 B 树族谱
2.3 B+ 树的定义与结构
一棵 m 阶 B+ 树满足以下性质:
- 每个非叶节点最多有 m 个子节点,最少有 ⌈m/2⌉ 个子节点(根节点至少 2 个)
- 非叶节点存储 ⌈m/2⌉-1 到 m-1 个关键字,作为子树的划分界标
- 所有数据记录仅存储在叶节点,非叶节点仅存储索引(关键字 + 子指针)
- 叶节点形成有序双向链表,支持范围查询的顺序扫描
- 所有叶节点位于同一层——B+ 树是平衡的
2.4 页大小与树高计算
关键参数计算(以 MySQL InnoDB 为例):
- 非叶节点大小:16KB
- 主键索引项大小:8B(主键)+ 6B(页指针)= 14B
- 非叶节点扇出 f ≈ 16KB / 14B ≈ 1170
- 叶节点记录数:约 16KB / 1KB ≈ 15~16 条(假设单行 1KB)
树高与记录数的关系:
| 树高 | 最大记录数 | I/O 次数 |
|---|---|---|
| 2 | 1170 × 16 ≈ 18,720 | 2 |
| 3 | 1170 × 1170 × 16 ≈ 21,902,400 | 3 |
| 4 | 1170^3 × 16 ≈ 25.6 亿 | 4 |
这意味着:一张 2000 万行的表,通过主键索引查找仅需 3 次磁盘 I/O。这就是 B+ 树的威力——扇出决定树高,树高决定 I/O 次数。
2.5 B+ 树的核心操作
点查询流程:从根节点开始,在当前节点中二分查找确定子指针,沿子指针向下读入子节点(1 次 I/O),重复直到叶节点,在叶节点中二分查找目标键。
范围查询流程:先点查定位到起始键所在叶节点,然后沿链表指针顺序扫描后续叶节点。关键优势在于:一旦定位到起始叶节点,后续访问沿链表顺序进行,每次 I/O 读取整个叶节点页,最大化了页内数据的利用率。
插入与分裂:插入操作可能触发节点分裂——叶节点已满时,分裂为两个各约半数记录的叶节点,将中间键上推到父节点;父节点满则递归分裂,可能传播至根节点导致树高 +1。
删除与合并:删除操作可能触发节点合并或 redistribute(重分布)。实践中为避免频繁合并,许多实现采用延迟合并策略——节点低于半满时不立即合并,仅标记为 underflow,在后续操作中再处理。
2.6 B+ 树 vs 其他索引结构
| 维度 | B+ 树 | B 树 | 哈希索引 | LSM-Tree |
|---|---|---|---|---|
| 点查询 | O(log_f N) | O(log_f N) | O(1) | O(1)~O(log N) |
| 范围查询 | O(log_f N + K) | O(log_f N + K) | 不支持 | O(K) |
| 插入 | O(log_f N) | O(log_f N) | O(1) | O(1) |
| 顺序访问 | 优秀(叶节点链表) | 较差(需中序遍历) | 不支持 | 需 Compaction |
| 数据位置 | 仅叶节点 | 所有节点 | — | 多层组件 |
| 写模式 | 就地更新 | 就地更新 | 就地更新 | 追加写入 |
B+ 树相对 B 树的三大优势:
- 非叶节点不存数据 → 扇出更大 → 树更矮 → I/O 更少
- 叶节点链表 → 范围查询无需中序遍历 → 顺序 I/O
- 查询路径稳定 → 所有查询都走到叶节点 → 缓存友好
2.7 聚簇索引 vs 非聚簇索引
- 聚簇索引:数据按主键物理排列,主键查询无需回表。但二级索引需先查到主键,再回表查聚簇索引——两次 B+ 树查找。InnoDB 的聚簇索引是其核心设计。
- 非聚簇索引:索引与数据分离,所有索引查找都需回表,但一张表可有多个聚簇索引的替代方案。
权衡:聚簇索引对主键范围查询最优,但插入顺序若非主键序将导致频繁页分裂。InnoDB 的自增主键策略正是为了避免这一问题。
2.8 B+ 树 vs LSM-Tree:现代存储引擎的核心抉择
| 维度 | B+ 树 | LSM-Tree |
|---|---|---|
| 写模式 | 就地更新(Read-Modify-Write) | 追加写入(Append-Only) |
| 写性能 | 随机写,受页分裂影响 | 顺序写,极高吞吐 |
| 读性能 | O(log_f N),稳定 | 需查多层 + 布隆过滤器,不稳定 |
| 空间放大 | 页分裂碎片 | 过期版本 + 层级冗余 |
| 写放大 | 页分裂(1~2 次 I/O) | Compaction(多次重写) |
| 适用场景 | 读多写少(OLTP) | 写多读少(时序、日志) |
深度注记:B+ 树和 LSM-Tree 的选择,本质上是对"读优化 vs 写优化"这一根本权衡的回应。许式伟强调"存储即数据结构"——存储引擎的选型就是对数据结构特性与业务读写模式的匹配。
2.9 数据库性能为何随数据量增长而退化
B+ 树理论上是 O(log_f N) 的,但随着数据量增长,数据库性能确实会退化,原因如下:
- 树高增加:数据量从 2000 万增长到 25 亿,树高从 3 变为 4,多一次 I/O
- Buffer Pool 命中率下降:内存有限,数据量增大后缓存命中率降低,更多请求需访问磁盘
- 页分裂累积:随机插入导致页分裂,页内碎片增多,实际数据密度降低
- 二级索引回表代价增大:数据行分散在更多页中,回表产生更多随机 I/O
- Compaction/Maintenance 开销:大表的索引维护、统计信息更新更耗时
2.10 B+ 树反模式
反模式 1:用 UUID 做聚簇索引主键
UUID 作为主键导致:插入位置随机,B+ 树频繁页分裂;分裂导致页内数据搬迁,写放大严重;随机插入使叶节点缓存命中率极低。
正确做法:使用自增整数作为主键,确保插入始终在 B+ 树最右端,避免页分裂。UUID 可作为业务唯一标识存储为普通列。
反模式 2:忽视索引选择性
在低选择性列(如性别、状态枚举)上建索引:B+ 树返回大量记录,回表开销远超全表扫描;优化器可能放弃使用该索引。
正确做法:索引应建在高选择性列上(选择性 = 不同值数 / 总行数,应接近 1)。低选择性列适合与高选择性列组合为复合索引。
反模式 3:过度索引
每增加一个二级索引:插入/更新/删除需维护所有索引 B+ 树;索引占用额外磁盘空间和内存(Buffer Pool);索引维护可能导致锁竞争。
正确做法:根据实际查询模式建立索引,定期审计并删除未使用索引。
三、SQL 数据库:关系模型与查询优化
3.1 关系模型:SQL 的数学基础
关系模型由 E.F. Codd 于 1970 年提出,其核心由三部分组成:数据结构(关系 = 集合论中的关系)、完整性规则(实体完整性 + 参照完整性)、关系代数(选择、投影、连接、聚合等运算)。
| 概念 | 数学定义 | SQL 对应 |
|---|---|---|
| 关系 (Relation) | 笛卡尔积的子集 | 表 (Table) |
| 元组 (Tuple) | 关系中的元素 | 行 (Row) |
| 属性 (Attribute) | 元组的分量 | 列 (Column) |
| 域 (Domain) | 属性的取值集合 | 数据类型 |
| 候选键 (Candidate Key) | 最小唯一标识属性集 | UNIQUE + NOT NULL |
| 主键 (Primary Key) | 选定的候选键 | PRIMARY KEY |
| 外键 (Foreign Key) | 引用其他关系的属性 | FOREIGN KEY |
关键性质:
- 关系的元组无序:行没有固有的物理顺序(尽管实现中有物理排列)
- 属性无序:列没有固有的左右顺序
- 属性值原子性:每个单元格不可再分(第一范式 1NF)
- 集合语义:关系是集合,无重复元组(SQL 实际使用包/Multiset 语义)
3.2 关系代数:SQL 的运算基础
关系代数是 SQL 背后的形式化运算体系:
| 关系代数 | SQL |
|---|---|
| σ_{age>25}(Student) | SELECT * FROM Student WHERE age > 25 |
| π_{name,age}(Student) | SELECT name, age FROM Student |
| Student ⋈_{Student.id=Enroll.sid} Enroll | SELECT * FROM Student JOIN Enroll ON Student.id = Enroll.sid |
| γ_{dept, AVG(salary)}(Employee) | SELECT dept, AVG(salary) FROM Employee GROUP BY dept |
关系代数的意义在于:SQL 语句可以被等价转换为不同的关系代数表达式,而查询优化器正是基于这种等价变换寻找最优执行计划。
3.3 SQL 的层次结构
SQL 并非单一语言,而是由多个子语言组成:
| 子语言 | 全称 | 核心语句 | 职责 |
|---|---|---|---|
| DDL | 数据定义语言 | CREATE, ALTER, DROP | Schema 定义(表结构、索引、约束) |
| DML | 数据操纵语言 | SELECT, INSERT, UPDATE, DELETE | 查询与变更 |
| DCL | 数据控制语言 | GRANT, REVOKE | 权限管理 |
| TCL | 事务控制语言 | BEGIN, COMMIT, ROLLBACK | 事务管理 |
3.4 SQL 执行全流程
一条 SQL 语句从提交到返回结果,经过以下完整流程:
- 词法分析 & 语法分析:生成抽象语法树 AST
- 语义分析:名称解析、类型检查、权限验证
- 逻辑优化:基于关系代数的等价变换(谓词下推、列裁剪、常量折叠)
- 物理优化:基于代价模型选择执行计划(索引选择、JOIN 顺序、算法选择)
- 生成执行计划:算子树(Operator Tree)
- 执行引擎:迭代器模型 / 向量化执行
- 返回结果集
3.5 查询优化器详解
查询优化器是数据库最核心、最复杂的组件。它将声明式的 SQL 转化为命令式的执行计划。
逻辑优化:基于规则的等价变换
- 谓词下推(Predicate Pushdown):尽早过滤,减少上游数据量
plaintext
σ_{age>25}(Student ⋈ Enroll) ≡ (σ_{age>25}(Student)) ⋈ Enroll - 投影下推(Projection Pushdown):尽早裁剪列,减少数据传输
- 连接重排序(Join Reordering):根据连接条件和基数估计,选择最优 JOIN 顺序
- 子查询展开(Subquery Unnesting):将相关子查询转化为 JOIN 或半连接
物理优化:基于代价的执行计划选择
物理优化依赖统计信息(行数、直方图、唯一值数等)和代价模型,核心决策包括:
- JOIN 算法选择:Nested Loop Join(小表驱动大表)、Hash Join(等值连接,大表 + 大表)、Sort-Merge Join(已排序数据,范围连接)
- 索引选择:主键索引(聚簇索引,无回表)、二级索引(需回表,覆盖索引避免)、全表扫描(大量数据,顺序 I/O)
- JOIN 顺序:左深树(流水线友好)vs Bushy 树(并行度高,优化空间大)
代价模型:Cost = I/O Cost + CPU Cost,其中 I/O Cost 通常是主导因素。优化器估算每种执行计划的代价,选择代价最小的方案。
3.6 ACID 事务
| 特性 | 含义 | 实现方式 |
|---|---|---|
| 原子性 (Atomicity) | 事务不可分割,全做或全不做 | WAL 日志 |
| 一致性 (Consistency) | 事务前后数据库满足完整性约束 | 约束检查 |
| 隔离性 (Isolation) | 并发事务互不干扰 | 锁 + MVCC |
| 持久性 (Durability) | 提交后数据永久保存 | WAL + 刷盘 |
3.7 隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 无锁 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 行级锁 / MVCC |
| REPEATABLE READ | 不可能 | 不可能 | 可能 | 间隙锁 / MVCC |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 全范围锁 / 串行化 |
InnoDB 默认 REPEATABLE READ,通过 MVCC + Next-Key Lock 在大多数场景下避免幻读。
深度注记:隔离级别的递进本质上是"并发度 vs 一致性"的权衡。READ UNCOMMITTED 最高并发但最不安全,SERIALIZABLE 最安全但并发度最低。实际业务中 REPEATABLE READ 是最常见的折中选择,InnoDB 通过 MVCC 在此级别下实现了接近 READ COMMITTED 的并发度。
3.8 MVCC:多版本并发控制
MVCC 的核心思想是:读不阻塞写,写不阻塞读。每行数据维护多个版本(通过 Undo Log 链),读操作根据事务的开始时间(Read View)选择可见的版本,写操作创建新版本。
MVCC 解决的核心问题:
- 读-写冲突:传统锁方案中,读操作需加共享锁,阻塞写操作。MVCC 让读操作访问历史版本,与写操作互不影响
- 一致性读(Snapshot Read):事务内多次读取同一数据看到相同结果,即使该数据已被其他事务修改
- 当前读(Current Read):SELECT ... FOR UPDATE / LOCK IN SHARE MODE 直接读取最新版本并加锁
3.9 存储过程与触发器
存储过程:预编译的 SQL 语句集合,存储在数据库服务器上。
优势:减少网络往返(一次调用执行多条 SQL)、预编译提升性能、集中管理业务逻辑。 劣势:调试困难、版本控制不便、将业务逻辑耦合到数据库层、数据库扩展性受限。
触发器:在特定事件(INSERT/UPDATE/DELETE)上自动执行的存储过程。
适用场景:审计日志、数据校验、级联更新。 风险:隐式执行增加理解难度、级联触发可能死循环、影响 DML 性能。
深度注记:现代架构倾向于将业务逻辑从数据库中剥离("胖数据库"→"瘦数据库"),存储过程和触发器的使用日趋谨慎。但在数据一致性约束(如级联更新、审计追踪)场景下,触发器仍是最高效且最可靠的方案——因为它们与数据操作在同一个事务中执行,不存在应用层与数据库层之间的一致性缝隙。
3.10 SQL 优化实战
索引优化:
- 在高选择性列上建立索引(选择性 = 不同值数 / 总行数,应接近 1)
- 利用覆盖索引避免回表(索引列覆盖查询列 → Using index)
- 复合索引遵循最左前缀原则
- 避免在索引列上使用函数(包括隐式类型转换)
查询计划分析:
| EXPLAIN 字段 | 含义 | 优化关注点 |
|---|---|---|
| type | 访问类型 | const > eq_ref > ref > range > index > ALL |
| key | 使用的索引 | 是否命中预期索引 |
| rows | 预估扫描行数 | 过大则考虑加索引 |
| Extra | 额外信息 | Using index = 覆盖索引, Using filesort = 需优化 |
分区:大表按范围、哈希或列表分区,将数据物理分散到不同文件,提升查询性能(分区裁剪)和管理效率(按分区维护/归档)。
3.11 SQL 反模式
反模式 1:N+1 查询
ORM 框架中常见——先查主表获取 N 条记录,再逐条查关联表,产生 N+1 次查询。本质是将集合操作降级为逐行操作,丧失了关系代数的批量处理优势。正确做法是使用 JOIN 一次查询。
**反模式 2:SELECT ***
增加网络传输量、阻碍覆盖索引优化、表结构变更时可能引入意外字段、使 SQL 语义不明确。正确做法是明确列出所需列名,配合覆盖索引避免回表。
反模式 3:在索引列上使用函数
对索引列使用函数(包括隐式类型转换)会阻止优化器使用该索引,因为 B+ 树中存储的是原始值,函数变换后的值无法在 B+ 树中直接定位。
反模式 4:大事务
大事务(长事务)的危害:锁持有时间长阻塞其他事务;MVCC 版本链过长影响读性能;Undo Log 膨胀占用大量空间;故障恢复慢。正确做法是事务粒度尽量小,只包含必要的操作,避免在事务中执行 RPC、文件 I/O 等外部调用。
3.12 规范化 vs 反规范化
关系模型追求规范化(Normalization),消除数据冗余和更新异常:
| 范式 | 核心约束 | 消除的异常 |
|---|---|---|
| 1NF | 属性不可再分 | 重复组 |
| 2NF | 非主属性完全依赖于候选键 | 部分依赖 → 插入/删除异常 |
| 3NF | 非主属性不传递依赖于候选键 | 传递依赖 → 更新异常 |
| BCNF | 每个决定因素都是候选键 | 更严格的依赖约束 |
但实践中往往需要反规范化:
- 高范式意味着多表 JOIN,查询性能下降
- 读密集场景中,适当冗余减少 JOIN 次数
- 数据仓库中,星型模型/雪花模型本质上是反规范化的
权衡:OLTP 追求规范化(写优化,避免更新异常);OLAP 追求反规范化(读优化,减少 JOIN)。
3.13 SQL 的局限性
- 递归查询受限:WITH RECURSIVE 支持有限递归,但不如图数据库原生
- 时序数据处理笨拙:窗口函数提供了部分支持,但不如时序数据库专用查询
- 半结构化数据:JSON 支持是后期添加,类型检查和优化不如原生关系列
- 跨库查询:SQL 标准缺乏跨库 JOIN 的原生支持
这些局限性催生了 NoSQL、NewSQL、图数据库等分支——但它们并非替代 SQL,而是在特定场景下的补充。
四、键值存储与数据库
4.1 存储即数据结构
许式伟提出了一个深刻的洞察:存储即数据结构。在客户端开发中,数据结构是内存中的 Map、List、Tree;在服务端开发中,数据结构是外存上的 KV 存储、数据库、消息队列。存储中间件本质上是面向外存的、高可用的、分布式的"元数据结构"。
4.2 键值存储的本质
键值存储是最简单的存储抽象:一个从 Key 到 Value 的映射。它的接口极简:
Get(key) → value
Put(key, value) → ok
Delete(key) → ok
Scan(prefix) → iterator但极简的接口背后,是极复杂的实现——因为服务端的 KV 存储必须同时满足:
- 持久性:数据不能因宕机而丢失
- 可用性:单机故障不能导致服务中断
- 高性能:单机 IOPS 达到 10 万级
- 可扩展:数据量增长时可以水平扩容
4.3 哈希表实现
KV 存储的基础实现是哈希表。核心问题是如何处理哈希冲突:
- 开放寻址法:冲突时在哈希表中线性/二次/双重哈希探测空位。优势是缓存友好,劣势是删除复杂(需标记墓碑)
- 链表法:冲突时将元素链入桶的链表。优势是实现简单、删除方便,劣势是链表指针破坏缓存局部性
- 完美哈希:静态数据可构造无冲突哈希函数。适用于只读场景
深度注记:单机哈希表无法解决分布式问题。当数据量超过单机容量时,必须将数据分片到多台机器——这就是一致性哈希的用武之地。
4.4 一致性哈希
传统哈希分片(shard = hash(key) % N)在节点增减时会导致大量数据迁移。一致性哈希解决了这一问题:
- 将哈希值空间组织为环(0 ~ 2^32-1)
- 每个节点映射到环上的一个位置
- 每个 Key 顺时针找到最近的节点
- 节点增减时,只影响相邻节点间的数据
虚拟节点:为解决数据倾斜问题,每个物理节点映射到环上的多个虚拟节点,使数据分布更均匀。
4.5 LSM-Tree:写优化的存储引擎
LSM-Tree(Log-Structured Merge Tree)是现代 KV 存储的核心数据结构,LevelDB、RocksDB、Cassandra 都基于它。
写入路径:写入请求 → WAL 日志 → MemTable(内存跳表)→ MemTable 满 → Immutable MemTable → 后台刷盘 → SSTable L0 → 后台合并 → SSTable L1 → ... → SSTable Ln
读取路径:读取请求 → MemTable 查找 → Immutable 查找 → L0 SSTable → L1 SSTable → ... → Ln SSTable
LSM-Tree 将随机写转化为顺序写,是磁盘 I/O 模型的根本优化。代价是读路径变长(需要从 MemTable 到 L0 到 L1 逐级查找),以及 Compaction 带来的后台 I/O 开销。
4.6 Redis 数据结构
Redis 作为最流行的 KV 存储,提供了丰富的数据结构:
| 数据类型 | 底层实现 | 典型场景 |
|---|---|---|
| String | SDS(简单动态字符串) | 缓存、计数器、分布式锁 |
| List | QuickList(压缩链表 + 链表) | 消息队列、最新列表 |
| Hash | 压缩列表 / 哈希表 | 对象存储(用户信息) |
| Set | 整数集合 / 哈希表 | 标签、共同好友 |
| Sorted Set | 压缩列表 / 跳表 + 哈希表 | 排行榜、延迟队列 |
| HyperLogLog | 概率算法 | UV 统计 |
| Bitmap | String 的位操作 | 签到、布隆过滤器 |
| Geo | Sorted Set + GeoHash | 附近的人 |
深度注记:Redis 的 Sorted Set 使用跳表而非 B+ 树,因为跳表在内存中的实现更简单,且在并发场景下锁粒度更细。这再次印证了"存储即数据结构"——内存存储和磁盘存储的最优数据结构选择截然不同。
4.7 数据库分类体系
| 数据库类型 | 代表 | 核心特征 | 适用场景 |
|---|---|---|---|
| 关系型 | MySQL/PostgreSQL | 强 Schema、SQL 查询、ACID 事务 | 结构化数据、事务性业务 |
| 文档型 | MongoDB | 弱/无 Schema、JSON 文档、灵活但易脏数据 | Schema 频繁变化、嵌套数据 |
| 键值型 | Redis/Cassandra/DynamoDB | 唯一索引、水平扩展、简单但查询受限 | 缓存、会话、配置 |
| 列族型 | HBase/Cassandra | 列族存储、宽行、稀疏数据 | 时序、日志、宽表 |
| 图型 | Neo4j | 关系遍历、图算法 | 社交/知识图谱、推荐 |
| 时序型 | InfluxDB | 时间维度优化、降采样 | 监控/IoT |
4.8 事务:ACID 的实现原理
事务是数据库区别于 KV 存储的核心能力。许式伟深入分析了乐观锁的实现机制:
- 读阶段不加锁:事务自由读取和计算,只记录读集和写集
- 提交时才检测冲突:检查读集中的数据是否被其他事务修改过
- 冲突则回滚:宁可重试,不阻塞等待
数据版本化:每个 Key 维护版本链 KEY → [(VER0, VAL0), (VER1, VAL1), ...],已提交版本和未提交版本共存。
4.9 主从复制与分布式
主从复制的三种模式:
| 模式 | 一致性 | 可用性 | 延迟 | 适用 |
|---|---|---|---|---|
| 异步复制 | 弱(可能丢数据) | 高 | 低 | 日志、非关键数据 |
| 半同步复制 | 中(至少一个从确认) | 中 | 中 | 大多数业务 |
| 全同步复制 | 强 | 低(任一从挂则阻塞) | 高 | 金融核心 |
分片策略:
- 哈希分片:
shard = hash(key) % N,分布均匀但范围查询困难 - 范围分片:按 Key 范围划分,支持范围查询但可能热点倾斜
4.10 键值存储反模式
反模式 1:用 KV 存储做复杂查询
将需要多条件组合查询的数据存入 Redis,然后在应用层做过滤和排序,导致大量数据传输和内存开销。
正确做法:结构化数据存入关系数据库,利用索引和 SQL 优化器完成查询。
反模式 2:忽视 LSM-Tree 的 Compaction 风暴
LSM-Tree 在 L0 → L1 合并时,如果层级大小比例设置不当(如 10:1),可能在某个时刻触发大量数据搬迁,造成 I/O 风暴,影响在线读写延迟。
正确做法:合理设置层级大小比例(如 4:1 或 8:1),控制单次 Compaction 的数据量,分时段限速 Compaction。
4.11 实战模式:多级存储协同
一个用户系统同时使用多种存储:
- 用户核心信息 → MySQL(强一致,事务保证注册原子性)
- 用户会话 → Redis(高频读写,TTL 自动过期)
- 用户行为日志 → Kafka + ClickHouse(异步写入,OLAP 分析)
- 用户头像 → S3 对象存储(大文件,CDN 加速)
深度注记:多级存储协同的本质是"让合适的存储做合适的事"——关系数据库保一致性,KV 存储保性能,对象存储保成本,缓存保延迟。没有一种存储能解决所有问题,架构师的职责是在一致性、性能、成本之间做出合理的权衡和组合。
五、文件系统与对象存储
5.1 非结构化数据的存储困境
互联网上 90% 以上的数据量是非结构化数据——图片、音视频、Office 文档。这些数据的存储需求与结构化数据截然不同:没有固定的 Schema,文件大小差异巨大,访问模式以整文件读写为主。
许式伟指出,对象存储的出现,是服务端体系架构与桌面操作系统分道扬镳的标志性事件。文件系统(File System)是桌面操作系统为"人手工管理数据"设计的产物,而对象存储(Object Storage)是服务端为"机器高效存取海量数据"设计的产物。
5.2 POSIX 文件系统 vs 对象存储
| 维度 | 文件系统 | 对象存储 |
|---|---|---|
| 数据组织 | 目录树(层级关系) | 扁平键空间(无层级) |
| 元数据 | 固定属性(权限/时间/大小) | 自定义元数据(KV 对) |
| 一致性 | POSIX 语义(强一致) | 最终一致(大多数) |
| 扩展性 | 单机瓶颈 | 水平扩展 |
| API 风格 | POSIX 文件 API | RESTful HTTP API |
| 适用规模 | 百万级文件 | 百亿级对象 |
深度注记:对象存储的 Key 中虽然可以有
/字符,看起来像路径,但/只是一个普通字符,不存在目录的概念。这使得对象存储可以轻松通过 Key 的 Hash 或 Range 分区,将请求路由到特定机器。这是对象存储可水平扩展的根本原因——没有目录树的层级依赖,任何 Key 都可以独立路由。
5.3 对象存储模型
对象存储的三大核心概念:
- Bucket(桶):对象的顶层命名空间,全局唯一,类似于"存储容器"
- Object(对象):存储的基本单元,由 Key(键)、Value(数据体)、Metadata(元数据)组成
- Metadata(元数据):自定义 KV 对,描述对象属性(Content-Type、自定义标签等)
三大核心接口:
PutObject(bucket, key, object) → etag // 上传对象
GetObject(bucket, key) → object // 下载对象
DeleteObject(bucket, key) → ok // 删除对象5.4 对象存储架构
对象存储通常采用分层架构:
- 接入层:API 网关(上传/下载/管理)、CDN 加速(下载加速)
- 元数据层:元数据集群(Bucket/Key/Version/自定义元数据)
- 数据层:数据集群(纠删码 EC / 多副本)
- 处理层:多媒体处理(转码/缩略图/水印)
5.5 S3 API:对象存储的事实标准
Amazon S3 定义的对象存储 API 已成为事实标准,几乎所有对象存储服务都兼容 S3 协议:
- PUT /bucket/key:上传对象
- GET /bucket/key:下载对象
- DELETE /bucket/key:删除对象
- HEAD /bucket/key:获取元数据
- LIST /bucket:列举对象
- multipart upload:分片上传大文件
S3 API 的设计哲学是极简:只有 CRUD + List,没有目录操作、没有部分更新、没有事务。这种极简设计换取了极高的可扩展性。
5.6 CDN 集成
对象存储与 CDN 是天然搭档:
- 写入路径:客户端 → 上传服务 → 对象存储
- 读取路径:客户端 → CDN(缓存命中直接返回)→ 图片处理服务 → 对象存储(缓存未命中时回源)
- 缓存策略:CDN 缓存 TTL 根据数据更新频率设置;不常变化的图片/视频设长 TTL;动态内容设短 TTL 或不缓存
对象存储的完整能力模型(以七牛云为例):
对象存储 = 基础存取 + 上传下载加速 + 多媒体处理
5.7 纠删码(Erasure Coding):成本与持久性的平衡
3 副本存储的冗余度为 3x,成本高昂。纠删码用算术冗余替代复制冗余,在保持同等持久性的前提下大幅降低成本。
| 方案 | 冗余度 | 容错能力 | 读修复开销 |
|---|---|---|---|
| 3 副本 | 3x | 2 块盘 | 直接读副本 |
| EC 8+4 | 1.5x | 4 块盘 | 需 N 台机器参与解码 |
| EC 28+4 | 1.14x | 4 块盘 | 需 N 台机器参与解码 |
冗余方案选择指南:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 热数据,高频访问 | 3 副本 | 读性能好,无需解码 |
| 温数据,中等访问 | EC 8+4 | 成本低,持久性好 |
| 冷数据,归档访问 | EC 12+4 + 压缩 | 极致成本优化 |
| 超冷数据,合规归档 | EC + 磁带 | 最低成本,可接受慢速 |
5.8 持久性的定量分析
许式伟对存储密度与持久性的关系做了精辟的定性分析:
- 增加单机磁盘数对持久性影响不大(单位修复时长 T0 不变)
- 增加单盘容量对持久性伤害较大(T0 增大,修复时间变长)
- 集群规模扩大对持久性略有正面影响(T0 减小,修复更快)
5.9 何时使用文件系统 vs 对象存储
| 场景 | 推荐 | 理由 |
|---|---|---|
| 应用本地配置/日志 | 文件系统 | POSIX 语义、就地修改 |
| 海量图片/视频 | 对象存储 | 水平扩展、CDN 加速 |
| 机器学习训练数据 | 对象存储 | 大文件、只读、批量访问 |
| 数据库数据文件 | 文件系统 | 随机读写、POSIX 语义 |
| 用户上传的文档 | 对象存储 | 多媒体处理、CDN 分发 |
| Hadoop 大文件日志 | HDFS | 顺序读写、大块优化 |
5.10 生命周期管理
对象存储支持基于规则的数据生命周期管理:
- 转换存储类:30 天后从标准存储转为低频访问存储,90 天后转为归档存储
- 过期删除:指定天数后自动删除对象
- 非当前版本管理:对版本化 Bucket,自动清理旧版本
生命周期管理是成本优化的关键手段——数据的价值随时间递减,存储成本也应随之递减。
5.11 对象存储反模式
反模式 1:用 HDFS 存海量小文件
HDFS 的 Block 大小默认 64MB(或 128MB),设计目标是大文件日志存储。将百万级小图片存入 HDFS:每个文件至少占 64MB 空间(空间浪费 99%+);NameNode 内存瓶颈(每个文件约 150B 元数据,1 亿文件需 15GB 内存);目录树维护代价高。
正确做法:海量小文件用对象存储,大文件日志用 HDFS。
反模式 2:在对象存储上模拟目录操作
试图实现 CreateDirectory、MoveDirectory 等操作——对象存储没有目录概念,这些操作要么无意义,要么代价极高("移动目录"需要修改所有子对象的 Key)。
正确做法:接受扁平键空间的设计,用 Key 的前缀约定模拟逻辑分组,但不依赖其实现目录语义。
六、存储与缓存
6.1 缓存的本质
许式伟警告:缓存不是免费的——它引入了数据一致性、缓存穿透、缓存雪崩、缓存击穿等一整套新问题。加缓存容易,管缓存难。
缓存的本质是用空间换时间、用冗余换速度。理解缓存,不是记住几个策略名称,而是深刻理解数据访问的局部性原理和一致性边界的妥协。
6.2 缓存层次体系
核心定律:存储层级中,速度越快,容量越小,成本越高。缓存的本质是在这个层级链路中插入一个"速度-成本平衡点"。
6.3 缓存写入策略
| 策略 | 写操作 | 读操作 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 先更新 DB,再删缓存 | 先查缓存,未命中查 DB | 最终一致 | 通用场景 |
| Read-Through | 同 Cache-Aside | 缓存层自动加载 | 最终一致 | 简化应用层 |
| Write-Through | 同步写 DB 和缓存 | 同 Read-Through | 强一致 | 数据安全优先 |
| Write-Behind | 只写缓存,异步写 DB | 同 Read-Through | 弱一致 | 写密集场景 |
| Write-Around | 只写 DB,不更新缓存 | 同 Cache-Aside | 最终一致 | 写多读少 |
6.4 Read-Through vs Cache-Aside
Cache-Aside(旁路缓存):应用层负责缓存的读取和更新。缓存未命中时,应用先查 DB,再写入缓存。一致性由应用保证。
Read-Through(读穿透):缓存层代理读操作。缓存未命中时,缓存库自动从 DB 加载数据并填充缓存。应用层无感知缓存的存在。
| 维度 | Cache-Aside | Read-Through |
|---|---|---|
| 缓存管理 | 应用层负责 | 缓存库负责 |
| 代码侵入 | 高 | 低 |
| 灵活性 | 高 | 低 |
| 一致性控制 | 应用可精细控制 | 缓存库统一控制 |
| 典型实现 | 手写 Redis 逻辑 | Spring Cache、MyBatis Cache |
深度注记:Cache-Aside 是工业界使用最广泛的缓存策略,但它的名字暗示了一个重要设计思想——缓存是"旁路"而非"主路",数据源(DB)才是唯一真相源(Single Source of Truth)。这意味着缓存可以随时丢弃而不影响正确性,只是性能退化。
6.5 缓存一致性问题
缓存引入的核心矛盾是:数据存在于两个地方(缓存 + 数据源),如何保持一致?
Cache-Aside 的经典并发问题:
时间线:
T1: 读请求 A 缓存未命中,查 DB 得到旧值
T2: 写请求 B 更新 DB 为新值
T3: 写请求 B 删除缓存
T4: 读请求 A 将旧值写入缓存 ← 缓存中是旧值!解决方案:
- 延迟双删:写请求在删除缓存后,等待一段时间再删一次,覆盖 T4 时刻的脏写
- 监听 Binlog 异步删除缓存:DB 变更 → Binlog → 消息队列 → 异步删缓存,最终一致保证
- 设置 TTL:过期自动失效,兜底一致
- 写请求加互斥锁:保证同一 Key 的读写串行化,但降低并发度
6.6 缓存三大经典问题
| 问题 | 触发条件 | 后果 | 根因 | 最佳方案 |
|---|---|---|---|---|
| 穿透 | 大量请求查询不存在的数据 | 全部打到 DB | 缓存无值可缓存 | 布隆过滤器 + 空值缓存 |
| 雪崩 | 大量 Key 同时过期 | 瞬间 DB 压力暴增 | TTL 集中 | 随机过期时间 + 多级缓存 |
| 击穿 | 热点 Key 过期瞬间大量并发 | DB 被单 Key 请求压垮 | 热点 + 并发 | 互斥锁 + 预热 |
缓存穿透的防御:
- 布隆过滤器:在缓存层前加一层布隆过滤器,不存在的 Key 直接拦截。注意布隆过滤器有假阳性(不存在的 Key 可能通过),但无假阴性(存在的 Key 一定通过)
- 缓存空值:对查询结果为空的 Key,缓存一个特殊值(如 NULL),设短 TTL(如 60s)
- 请求参数校验:在入口层拦截非法请求
缓存雪崩的防御:
- 随机化过期时间:
TTL = base_ttl + random(0, max_jitter) - 多级缓存:L1 本地缓存 + L2 分布式缓存,不同层 TTL 不同
- 永不过期 + 异步刷新:逻辑过期,后台线程定期刷新
缓存击穿的防御:
- 互斥锁:缓存未命中时,只允许一个线程查 DB 并回填缓存,其他线程等待
- 热点 Key 预热:系统启动时主动加载热点数据,避免冷启动击穿
- 永不过期 + 异步刷新:同雪崩防御
6.7 缓存淘汰策略
当缓存空间不足时,选择淘汰哪些数据?
| 策略 | 原理 | 优势 | 劣势 | 典型实现 |
|---|---|---|---|---|
| LRU | 淘汰最近最少使用 | 适应访问模式变化 | 偶发访问污染缓存 | Redis maxmemory-policy: allkeys-lru |
| LFU | 淘汰最不经常使用 | 保留高频数据 | 历史频率不反映当前 | Redis maxmemory-policy: allkeys-lfu |
| FIFO | 先进先出 | 实现简单 | 不考虑访问频率 | — |
| ARC | 自适应替换缓存 | 结合 LRU + LFU | 实现复杂 | ZFS ARC |
| Random | 随机淘汰 | 零开销 | 不保证淘汰最差数据 | Redis maxmemory-policy: allkeys-random |
LRU 的变种:
- LRU-K:记录最近 K 次访问,只有被访问 K 次才进入缓存,过滤偶发访问
- 2Q(Two Queues):冷数据区和热数据区,新数据先进冷区,再次访问才进入热区
- TinyLFU:用 Count-Min Sketch 近似频率统计,空间效率极高
深度注记:Redis 4.0 之前只支持 LRU 和 Random 淘汰策略,4.0 之后引入了 LFU。但 Redis 的 LRU 并非精确实现——它对 Key 采样一小部分(默认 5 个),从中淘汰最久未使用的。这是性能与精度的权衡:精确 LRU 需要维护全局双向链表,每次访问都更新链表,开销极大。采样 LRU 以极小的精度损失换取了显著的性能提升。
6.8 分布式缓存
Redis Cluster:
- 数据分片:16384 个 Hash Slot,每个节点负责一部分
- 路由:
slot = CRC16(key) % 16384 - 故障转移:主节点故障时,从节点自动提升为主节点
- 扩缩容:迁移 Slot 到新节点,在线完成
Memcached:
- 纯内存缓存,无持久化
- 多线程架构(vs Redis 单线程)
- 客户端路由(一致性哈希)
- 简单的 LRU 淘汰
| 维度 | Redis Cluster | Memcached |
|---|---|---|
| 数据结构 | 丰富(String/List/Hash/Set/ZSet) | 简单(String) |
| 持久化 | RDB/AOF | 无 |
| 内存管理 | 自定义分配器 | Slab 分配器 |
| 线程模型 | 单线程(6.0 后 I/O 多线程) | 多线程 |
| 集群 | 原生 Cluster | 客户端分片 |
| 适用 | 需要数据结构/持久化 | 纯缓存场景 |
6.9 多级缓存架构
多级缓存的一致性策略:
- L1(本地缓存)TTL 短(秒级),只做热数据加速
- L2(分布式缓存)TTL 中(分钟级),做共享缓存
- DB(持久层)永不过期,数据唯一真相源(Single Source of Truth)
6.10 缓存反模式
反模式 1:缓存作为持久层
将数据只存在 Redis 中,不落盘。Redis 宕机后数据全部丢失。
正确做法:Redis 作为缓存层,MySQL 作为持久层。Redis 故障时系统降级但可用(直接查 DB,响应变慢但数据不丢)。
反模式 2:无过期时间的缓存
Key 不设 TTL,依赖手动删除。结果大量僵尸数据占满内存,新数据被淘汰。
正确做法:所有 Key 必须设置合理的 TTL,即使预期"永不过期"的数据也应设长 TTL(如 24h)并配合自动刷新机制。
6.11 实战模式:电商商品详情页的多级缓存
- L1 本地缓存:Caffeine,TTL 5s,只缓存最热数据
- L2 分布式缓存:Redis 集群,TTL 60s,共享缓存
- 数据更新:DB 变更 → Binlog → 消息队列 → 异步删 Redis Key + 通知其他节点删本地缓存
- 一致性保证:最终一致窗口 < 1s
6.12 缓存的黄金法则
- 先加监控再加缓存:缓存命中率、QPS、延迟必须在加缓存的同时建设
- 缓存不是持久层:任何时刻缓存数据都可能丢失,系统必须能在缓存全部失效时正常工作
- 过期时间必须加随机:避免 Key 集中过期导致雪崩
- 缓存空值防止穿透:对不存在的数据也缓存一个短 TTL 的空值
- 热点 Key 必须预热:系统启动时主动加载热点数据,避免冷启动击穿
七、总结
7.1 存储与数据库核心权衡矩阵
| Trade-off | 一端 | 另一端 | 决策依据 |
|---|---|---|---|
| 性能 vs 可靠性 | RAID 0 | RAID 6/10 | 数据重要性 |
| 空间效率 vs 写性能 | RAID 5/6 | RAID 10 | 写入模式 |
| 读优化 vs 写优化 | B+ 树 | LSM-Tree | 读写比 |
| 规范化 vs 反规范化 | OLTP | OLAP | 查询模式 |
| Schema 灵活性 | 文档数据库 | 关系数据库 | Schema 变化频率 |
| 查询能力 | SQL(多索引) | KV(单索引) | 查询复杂度 |
| 一致性 vs 可用性 | 强一致 | 最终一致 | 业务容忍度 |
| 成本 vs 持久性 | EC 编码 | 多副本 | 存储规模与预算 |
| 一致性 vs 性能 | Write-Through | Write-Behind | 数据安全 vs 写入延迟 |
| 淘汰精度 vs 开销 | LFU/TinyLFU | LRU | 热点分布稳定性 |
7.2 存储选型速查表
| 场景 | 推荐存储 | RAID 级别 | 缓存策略 |
|---|---|---|---|
| OLTP 核心业务 | MySQL/PostgreSQL | RAID 10 | Cache-Aside |
| OLAP 数据分析 | ClickHouse/Doris | RAID 6/10 | Write-Through |
| 缓存/会话 | Redis | — | 本身即缓存 |
| 海量图片/视频 | S3 对象存储 | EC | CDN + Cache-Aside |
| 时序/监控数据 | InfluxDB/TDengine | RAID 6 | Write-Behind |
| 社交/知识图谱 | Neo4j | RAID 10 | Cache-Aside |
| 日志/时序写入 | Kafka + LSM-Tree | RAID 0/10 | Write-Around |
| 大文件归档 | HDFS + EC | EC | Write-Around |
7.3 关键要点回顾
- RAID 是存储虚拟化的基石:通过条带化、镜像、校验三种基本手段组合,在性能、可靠性、成本之间寻求平衡。RAID 5 在大容量磁盘时代已不适用
- B+ 树是为外存而生的数据结构:扇出决定树高,树高决定 I/O 次数。百万级数据 3 次 I/O,十亿级数据 4 次 I/O
- SQL 建立在关系模型之上:声明式语言将优化权交给数据库,查询优化器通过关系代数等价变换选择最优执行计划
- ACID 事务是数据库的核心能力:隔离级别的递进是并发度与一致性的权衡,MVCC 在读写冲突中提供了优雅的折中
- 存储即数据结构:KV 存储是最简单的存储抽象,LSM-Tree 是写优化的存储引擎,不同数据库类型是对不同数据结构特性的封装
- 对象存储是服务端与桌面分道扬镳的标志:扁平键空间取代目录树,RESTful API 取代 POSIX API,EC 取代多副本
- 缓存是空间换时间的经典手段:Cache-Aside 是最通用策略,但需警惕穿透/雪崩/击穿三大问题
思考题
- 如果你是数据库 DBA,需要为一台新的数据库服务器选择 RAID 级别,服务器将承载一个 OLTP 业务(读写比约 3:1,数据量约 500GB),你会选择哪种 RAID?为什么?
- 一张 5000 万行的表,主键为自增整数,二级索引建有 (status, create_time) 复合索引。查询
SELECT * FROM orders WHERE status = 'PAID' AND create_time > '2024-01-01'性能不佳,请分析可能的原因和优化方案。 - 在 Cache-Aside 模式下,为什么"先删缓存再更新 DB"比"先更新 DB 再删缓存"更容易产生一致性问题?请画出两种顺序的并发时序图并分析。
- 某电商系统需要存储商品图片(平均 200KB/张,日增 100 万张)和商品信息(结构化数据),请设计存储方案,并说明 CDN 如何与对象存储协同工作。
- 为什么 Redis 的 Sorted Set 使用跳表而非 B+ 树?这个选择在什么条件下可能不是最优的?
关联阅读
- [07-存储高性能]:读写分离、分库分表、NoSQL 与缓存的架构层面讨论
- [09-CAP理论与高可用存储]:CAP 定理与分布式存储的一致性权衡
- [10-异地多活架构]:分布式存储在异地多活场景下的应用
- [12-可扩展架构思想与实践]:存储层的可扩展架构设计
延伸视角
从 RAID 到 Erasure Coding:冗余思想的演进
RAID 是单机维度的冗余方案,Erasure Coding 是集群维度的冗余方案。两者的核心思想一脉相承:用计算换存储。但 EC 引入了新的权衡——修复代价。3 副本修复只需从任一存活副本复制,而 EC 修复需要 N 台机器参与计算。在分布式系统中,修复代价不仅影响数据可用性,还影响网络带宽和 CPU 资源。因此,热数据用多副本(修复快),冷数据用 EC(成本低),成为行业标准实践。
B+ 树与 LSM-Tree 的融合趋势
传统上 B+ 树和 LSM-Tree 是两个对立的范式。但近年来出现了融合趋势:WiredTiger(MongoDB 默认引擎)支持 B+ 树和 LSM 两种模式;RocksDB 在 LSM-Tree 上层构建了支持事务的 API;TiDB 的 TiFlash 引擎在 LSM-Tree 上实现了列存加速。这些融合方案试图兼得两者的优势——B+ 树的读性能 + LSM-Tree 的写性能,代价是系统复杂度大幅增加。
缓存的哲学:一致性与可用性的永恒博弈
缓存的一致性问题本质上是分布式系统中 CAP 定理的微观体现。在缓存层,我们几乎总是选择可用性(AP)而非一致性(CP)——因为缓存的职责是加速而非保证正确。数据源(DB)才是 Single Source of Truth。理解这一点,就不会纠结于"缓存与 DB 不一致"——只要不一致窗口可控,且最终一致,就是可接受的。真正危险的不是不一致本身,而是没有意识到不一致的存在。