Skip to content

第14章:内存数据库


元数据卡

维度
难度(黑魔法)
前置第7、8章(存储引擎 + 缓冲池)
关键词内存数据库、VoltDB、Redis、DuckDB、SAP HANA、持久化、混合架构
代码语言SQL / Redis 命令

你的进度

堡垒顶层有一间特别的快速陈列室。没有沉重的铁质文件柜、没有地下仓库——所有货物就摆在眼前的橡木台面上。管理员要什么直接拿,不用下楼翻箱倒柜。这就是内存数据库。速度极快,但有个问题:台面有限,能摆的东西也有限,而且一旦蜡烛灭了就全没了(好在可以通过快照和日志来补救)。

你的任务

理解为什么"全内存数据库"是一个不同的数据库品类,而不是"把磁盘数据库放在内存里"。掌握 VoltDB 的分区+单线程执行模型、Redis 的类型系统与持久化策略、以及现代混合数据库(DuckDB、SAP HANA)的架构取舍。


破局 · 溯源

磁盘数据库的瓶颈

先看一个典型的 OLTP 查询花了多少时间:

后端请求到达 → SQL 解析 → 计划生成 → Buffer Pool 查页

 = 磁盘 I/O(如果缓存不命中)
 5-15 ms

 累计(含网络):1-10 ms(完全内存命中)到 50+ ms(磁盘 I/O)

问题在于:磁盘 I/O 一次随机读 5-15 ms,是 DRAM 访问(~100 ns)的 5-10 万倍

再看 Buffer Pool 本身的开销:

  1. 页表查找:每次访问都需查找 Buffer Pool 的哈希表(或 LRU 链表)
  2. 页固定/解固定(Pin/Unpin):防止页被淘汰
  3. 锁/闩(Latch):Buffer Pool 内部数据结构的并发保护
  4. 写回策略:脏页需要异步写回(checkpoint 或驱逐时)
  5. 页序列化/反序列化:磁盘格式 ↔ 内存格式的转换

这些开销一个单个 100 ns 级别,但加起来在密集 OLTP 场景下显著。

换个角度看:如果数据能全部放在内存中,你可以去掉整层 Buffer Pool,直接操作内存中的数据。数据结构不再需要"磁盘友好"的设计(如 B+树的页结构),而是可以设计成"CPU 缓存友好"的结构(如定长数组、指针链)。

内存数据库架构的三大特点

特点 1:无 Buffer Pool

内存数据库没有 Buffer Pool——没有"页面在内存/磁盘间移动"的概念。

磁盘数据库: 内存数据库:
 
 SQL 引擎 SQL 引擎 
 
 Buffer Pool 直接内存存储 
 
 存储引擎 持久化层 
 (B+Tree) (日志/快照) 
 
 磁盘文件 磁盘(备份)

没了 Buffer Pool:

  • 不需要 Pin/Unpin(页固定)
  • 不需要 LRU 链表
  • 不需要 Checkpoint 写回脏页(从 Buffer Pool 角度)
  • 数据以"内存格式"直接存储——没有页结构、没有槽位偏移计算

特点 2:定长记录与直接指针

磁盘数据库用页号+偏移寻址(因为行可能被移动、页会被刷新)。内存数据库中,你可以用直接指针——指向一个 C 结构体或 Java 对象的引用。

例子:VoltDB 的 Tuple 是固定长度的字节数组,每个列在数组中的位置是编译时确定的。这完全消除了 SQL 引擎输出时的列值拷贝开销。

传统方案:
 Tuple → 变长 → 每次 SELECT 要逐列拷贝到结果缓冲区

VoltDB 方案:
 Tuple → 定长 → SELECT 直接返回数组引用,列偏移已知

特点 3:编译执行

内存数据库的查询计划不是解释执行的——是在加载时编译成机器码的。

解释执行(磁盘数据库):

SELECT sum(quantity) FROM vault_items WHERE id = ?
 → 遍历计划节点 → 调用 Next() 函数 → 读取元组 → 比较 → 累加
 → 每次函数调用都有虚函数表查找、分支预测开销

编译执行(VoltDB / Hyper / DuckDB 的选择):

SELECT sum(quantity) FROM vault_items WHERE id = ?
 → 编译为 LLVM IR 或 JIT 机器码
 → 生成的代码:直接从内存地址读取 → 比较 → 累加
 → 无函数调用开销,无分支预测(对于热点路径)

性能差距:编译执行可以做到解释执行的 5-10 倍。


案例 1:VoltDB / H-Store

VoltDB 和它的前身 H-Store 是学术项目转商业的代表。核心设计思想来自 Michael Stonebraker 的论文:"分布式 OLTP 系统不需要支持多线程事务"。

架构设计


 Client 

 存储过程调用
 

 SQL 编译器 
 存储过程(SQL + Java) → 编译为 Java 类 

 
 

 分区(Partitions) 

 Partition 0 Partition 1 ... Partition N 
 单线程执行 单线程执行 单线程执行

分区 + 单线程执行

换个角度看:如果每个 CPU 核只负责处理"它自己的数据",就不需要锁。不需要 latch,不需要 2PL,不需要 MVCC——没有并发,所以不需要并发控制

怎么做?

  1. 将数据按主键哈希分到多个分区
  2. 每个分区由一个单线程独占
  3. 所有访问该分区的事务都在这个线程上排队执行
  4. 跨分区事务用两阶段提交协调

性能结果:单分区事务可以做到微秒级延迟,线性可扩展(加核加吞吐)。

存储过程模型

VoltDB 不支持即席 SQL(ad-hoc SQL)——所有操作必须封装成存储过程。下面这个例子演示了宝库中一件物品从炎之剑转到冰霜法杖的移库操作:

java
// VoltDB 存储过程示例
@ProcInfo(
 partitionInfo = "VAULT_ITEMS.ITEM_NAME: 0"
 singlePartition = true
)
public class ItemExchange extends VoltProcedure {
 public final SQLStmt checkStock = new SQLStmt(
 "SELECT quantity FROM vault_items WHERE item_name = ?");
 public final SQLStmt withdraw = new SQLStmt(
 "UPDATE vault_items SET quantity = quantity - ? WHERE item_name = ?");
 public final SQLStmt deposit = new SQLStmt(
 "UPDATE vault_items SET quantity = quantity + ? WHERE item_name = ?");

 public long run(String outItem String inItem int count) {
 voltQueueSQL(checkStock outItem);
 voltQueueSQL(withdraw count outItem);
 voltQueueSQL(deposit count inItem);
 voltExecuteSQL();
 return 0;
 }
}

为什么强制用存储过程?

  1. 减少网络往返:客户端发送一次请求,服务端执行全部 SQL
  2. 可预测执行:优化器知道 SQL 模式,可以预编译
  3. 事务边界明确:整个存储过程就是一个事务

案例 2:Redis

Redis 是内存数据库中最知名的。严格说它不完全是"数据库"——更准确的定位是内存数据结构的远程服务

类型系统

Redis 不存"行"和"列"。它有 5 种基本数据类型 + 3 种高级结构:

类型底层实现(编码)命令举例典型用例
Stringint / embstr / raw(SDS)GET/SET/INCR缓存、计数器、分布式锁
Listquicklist(压缩链表 + 双端链表)LPUSH/LPOP/BRPOP消息队列、时间线
Setintset / hashtableSADD/SMEMBERS/SINTER标签、去重、交集
ZSetziplist / skiplist+dictZADD/ZRANK/ZREVRANGE排行榜、延迟队列
Hashziplist / hashtableHSET/HGET/HGETALL对象缓存、计数器组
BitmapSDS(位图)SETBIT/BITCOUNT用户签到、布隆过滤器
HyperLogLogHLL 结构PFADD/PFCOUNTUV 统计(近似)
GEOZSet + geohashGEOADD/GEORADIUS附近的人

内存效率:Redis 对每个类型都用多种编码(encoding)来优化。例如:

  • 如果 Hash 的 fields 数小于 hash-max-ziplist-entries(默认 512)且每个 value 小于 hash-max-ziplist-value(默认 64),就用 ziplist(连续内存块,CPU 缓存友好)
  • 如果超过阈值,转换为 hashtable(可 O(1) 随机访问,但内存开销更大)

单线程模型

Redis 的核心命令处理是单线程的(6.0+ 的网络 IO 是多线程的,但命令执行仍是单线程)。

为什么单线程?

原因说明
无锁数据结构单线程 = 不需要 mutex/spinlock。INCR 不是原子指令——它只是单线程里没有竞争
内存操作足够快单核可以轻松处理 10 万+ QPS。加锁的开销可能抵消多核带来的收益
简单性没有死锁、没有 race condition、没有事务隔离问题
原子性每个命令天然原子,MULTI/EXEC 块也是单线程执行的

但这不是没有代价的

  • 一个慢命令(KEYS *SMEMBERS large_setZRANGE large_zset)会阻塞所有其他命令
  • 如果 Redis 里的 list 导致 BLPOP 阻塞,其他键的读写也会被影响

Redis 的事务

Redis 也有"事务"的概念——但它的玩法跟关系型数据库的 ACID 事务不太一样,更像命令排队:

redis
MULTI
SET key1 value1
SET key2 value2
GET key1
EXEC
  1. MULTI 开始事务
  2. 所有命令入队(Queued),不立即执行
  3. EXEC 一次性按入队顺序执行

特点

  • 不支持回滚:如果第 2 条命令失败,第 1 条的修改不会被撤销
  • 不支持隔离EXEC 之前不会看到其他客户端的修改,但 EXEC 块是原子执行的(单线程)
  • 乐观锁WATCH 命令可以实现 CAS(Compare-And-Swap)
redis
WATCH counter
val = GET counter
MULTI
SET counter (val + 1)
EXEC
-- 如果在 WATCH 和 EXEC 之间 counter 被修改,EXEC 返回 nil(失败)

这实际上是一种乐观并发控制(OCC)的简化实现。


内存数据库的持久化

"数据在内存中"意味着:如果断电,数据全丢。内存数据库怎么解决这个问题?

Redis 方案

Redis 提供两种持久化方式:

RDB(快照)

 fork() 写磁盘 
 Redis → 子进程 → dump.rdb
 主进程 (写时复制)
  • 主进程调用 fork() 创建子进程
  • 子进程利用 COW(写时复制,Copy-on-Write)将内存快照写入磁盘
  • 主进程继续处理请求——修改内存页时触发复制,被修改的页不会影响子进程的写入
  • 优点:恢复快、文件紧凑
  • 缺点:fork 时如果内存大到几十 GB,COW 期间的内存压力很大

AOF(Append-Only File)

SET key value
 → 追加到缓冲区
 → 按策略刷盘(always/everysec/no)
 → 写入 appendonly.aof

AOF 记录了每条写命令。恢复时重新执行所有命令。

策略持久性性能
always每条命令立即 fsync最慢(约 1000-2000 ops/s)
everysec(默认)每秒 fsync较好(丢失最多 1 秒数据)
no由 OS 决定最快(丢失可能多)

aofRewrite(AOF 重写)

AOF 文件会不断增大。Redis 定期执行 AOF 重写:

原始 AOF(100 万条命令,100 MB)
SET counter 1
SET counter 2
SET counter 3
...
SET counter 1000000

重写后:
SET counter 1000000 ← 只用一条命令代表最终状态

aofRewrite 也是通过 fork() 子进程实现的。

混合持久化(Redis 4.0+)

aof-use-rdb-preamble yes:AOF 文件开头是 RDB 快照,后面追加增量操作。综合了 RDB 的恢复速度(RDB 部分直接加载)+ AOF 的持久性(增量部分不丢数据)。

VoltDB 方案

VoltDB 的命令日志(Command Logging):

  • 每条事务在提交前先把事务的 SQL 命令写入日志(不是修改后的数据)
  • 崩溃恢复时重新执行这些命令
  • 每秒异步写入磁盘(sync interval 可配置)

加上周期性快照(Snapshot,类似 RDB)来减少日志重放长度。


现代趋势:混合架构

"纯内存"还是"纯磁盘"的二分法在现实中很少成立。现代数据库越来越倾向于混合架构。

DuckDB

DuckDB 不是一个"内存数据库"——但它在架构上大量借鉴了内存数据库的设计:

  1. 列式存储:数据按列在内存中连续存放,CPU 缓存友好
  2. 向量化执行:不是逐行处理,而是按批(向量,1024 行一批)处理
  3. MMAP 支持:可以直接 mmap 数据文件,用操作系统的虚拟内存管理代替自己的 Buffer Pool
  4. 单进程模型:没有复杂的客户端-服务器架构

DuckDB 定位在"嵌入式 OLAP"——数据可以大于内存,但工作集尽量在内存中。

DuckDB 的执行流程:
SQL → 计划 → 编译(LLVM JIT) → 向量化执行(逐个批)

 mmap 数据文件(需要时)
 Buffer Pool = OS 虚拟内存

SAP HANA

SAP HANA 是真正的混合架构:

  • 行存储:用于频繁更新的 OLTP 数据(主表)
  • 列存储:用于 OLAP 分析查询(主表 + 增量合并)
  • 所有数据在内存中磁盘上各有一份
  • 写操作先写日志(WAL),日志刷盘后标记事务提交
  • 内存中的数据定期写入到磁盘的列存储段
SAP HANA 存储架构:

 内存(DRAM) 

 行存储(L1缓存) 列存储(主 + 增量) 

 
 

 持久化层 

 日志卷(redo) 数据卷(列式段文件)

内存是主存储,磁盘是持久化备份。读到磁盘是异常而不是常态。

选型指南

场景推荐理由
KV 缓存、计数器、队列Redis数据类型多样、操作原子性
高吞吐 OLTP、金融交易VoltDB / 内存 SQL单分区无锁、确定性执行
交互式 OLAP、分析DuckDB / ClickHouse向量化、列存、可大于内存
企业级混合负载SAP HANA / Oracle TimesTen行+列混合、完整 SQL 支持
会话管理、排行榜Redis数据量小、轻松支撑数万 QPS

深入冒险

实验:用 Redis 的原子计数器模拟事务

bash
# 启动 Redis
redis-server --port 6379 &

# 连接 Redis
redis-cli
redis
-- 宝库物品库存
SET item:flame_sword 10
SET item:frost_staff 5

-- 模拟移库(用 WATCH 做乐观锁)
WATCH item:flame_sword item:frost_staff

-- 管理员盘点库存
GET item:flame_sword -- "10"
GET item:frost_staff -- "5"

MULTI
DECR item:flame_sword 1
INCR item:frost_staff 1
EXEC
-- 如果其他管理员在 WATCH 和 EXEC 之间修改了炎之剑或冰霜法杖的库存,
-- EXEC 返回 nil(失败),应用重试

性能对比:磁盘 SQLite vs 内存 SQLite

SQLite 支持将数据库完全放到内存中:

bash
# 磁盘模式
time sqlite3 :memory: "
 CREATE TABLE vault_log (item_count INT);
 INSERT INTO vault_log VALUES (42);
 SELECT count(*) FROM vault_log;
"

# 内存模式(同样的表,但不在磁盘上)
time sqlite3 /tmp/fortress.db "
 PRAGMA synchronous = OFF;
 PRAGMA journal_mode = OFF;
 CREATE TABLE vault_log (item_count INT);
 INSERT INTO vault_log VALUES (42);
 SELECT count(*) FROM vault_log;
"

在批量插入场景下,内存模式比磁盘模式快 10-100 倍——去掉 fsync、去掉 WAL、去掉 B+树页的磁盘写回。


常见陷阱

陷阱 1:把"内存数据库"等同于"缓存"

Redis 常被称为"缓存",但它的持久化(AOF+RDB)和数据结构使其远不止于此。反过来,VoltDB 和 SAP HANA 也不能被当作缓存——它们是完整的数据库系统。

陷阱 2:认为内存数据库不需要持久化

没有持久化的内存数据库 = 不可靠的数据存储。意外断电会导致丢失所有数据。所有生产级内存数据库都有持久化策略(AOF、RDB、Command Logging、Snapshot),区别仅在于备份频率和恢复时间。

陷阱 3:认为数据全在内存 = 查询就一定快

查询性能取决于执行计划和数据结构,不仅仅是数据在哪里。一个全表扫描的慢 SQL,在内存里也只是"慢但少了一个磁盘 I/O"——从 50 ms 变成 5 ms,但仍然可能是不可接受的。

陷阱 4:Redis 的 KEYS *

KEYS * 遍历所有键,时间复杂度 O(N)。在单线程模型里,这个命令执行期间阻塞所有其他操作。生产环境中请使用 SCAN 替代。

陷阱 5:认为内存数据库可以替换关系型数据库

每个内存数据库牺牲了一些东西换取性能:

  • Redis:不支持复杂 JOIN、不支持 SQL(准确说是支持有限的 SQL 子集)
  • VoltDB:强制存储过程、不支持即席查询
  • DuckDB:单进程、嵌入式(不适用于高并发 OLTP)

选择前,先明确你的场景需要什么能力。


通关挑战

动手试试

  1. Redis 持久化对比
  • 启动 Redis,设置一个 key-value
  • 分别配置 save ""(关闭 RDB)和 appendonly yes
  • kill Redis 进程,重新启动,观察数据是否恢复
  1. DuckDB 向量化体验
bash
# 安装 DuckDB CLI
# 从 https://duckdb.org/docs/installation/ 获取
duckdb
sql
CREATE TABLE fortress_items AS SELECT * FROM read_csv_auto('items.csv');
SELECT category sum(quantity) count(*) FROM fortress_items GROUP BY category;
-- 观察 DuckDB 的向量化执行效率
  1. SQLite 内存 vs 磁盘对比
  • sqlite3 :memory:sqlite3 /tmp/test.db 分别运行:
sql
CREATE TABLE t (id INT val TEXT);
INSERT INTO t VALUES (1 'hello') (2 'world') (3 'foo');
SELECT * FROM t WHERE id = 2;
  • 对比执行时间

验收标准

  • 能列举内存数据库的三大架构特点(无 Buffer Pool、定长/直接指针、编译执行)
  • 能解释 VoltDB 为什么采用分区+单线程模型
  • 能根据场景选择合适的 Redis 数据类型
  • 能描述 Redis 的 RDB、AOF、混合持久化的区别
  • 能判断一个场景是否适合用内存数据库解决

常见卡点

  • COW 内存膨胀fork() 后如果没有修改,子进程共享父进程内存页。但如果有大量修改(写时复制),峰值内存可能是基准的 2 倍。大数据量 Redis 实例的 fork 操作需要预留足够内存。
  • AOF vs RDB 选择:不能兼得。如果既要快速恢复又要最小数据丢失,用混合持久化(Redis 4.0+)。
  • VoltDB 的跨分区事务:跨分区事务使用两阶段提交协议,延迟会增加一个数量级。数据设计时应尽量让事务访问的数据落在同一个分区内。

现在不需要理解

  • DuckDB 的向量化执行引擎的具体实现(压缩、消除分支)
  • SAP HANA 的增量合并(Delta Merge)算法
  • 内存数据库的 NVM(非易失性内存,如 Intel Optane / PMem)支持

旅人笔记

内存数据库去掉"磁盘慢"的假设后,整个架构重新设计:无 Buffer Pool、定长记录、直接指针、编译执行。VoltDB 用分区+单线程彻底避免并发控制。Redis 的类型系统和单线程模型把延迟压到了很低的水平。现代趋势走向混合:DuckDB 的向量化列存、SAP HANA 的行列共存——不是"内存还是磁盘"的二选一,而是把内存当作主存储、磁盘作为持久化备份。


下一站预告

Part 4 你走完了数据库并发控制的从理论(ACID、可串行化)到实践(MVCC、隔离级别、锁管理器)再到极限(内存数据库)的全路径。在 Part 5(查询处理与优化)中,你将站到查询引擎的背后,看 SQL 从字符串变成执行计划的完整旅程——解析器、优化器、执行器,以及 Cost-Based Optimization 如何选择"最好的坏计划"。

Built with VitePress | Software Systems Atlas