一个简单的内存复制操作,为什么会成为理解数据库存储的起点?
目录
一、背景与问题
在 C++ 开发中,我们经常能看到这样的代码:
memcpy(buffer, &object, sizeof(object));
这行代码可以将对象在内存中的二进制表示复制到一块连续空间。
于是,一个自然的问题出现了:
既然对象在内存中就是一段连续的字节,为什么不直接把这些字节保存到磁盘,需要时再读取回来恢复对象?
这个思路看似简单,但真实的存储系统并不会直接保存对象的内存布局。
原因在于:
内存中的对象,是为了程序运行服务的;磁盘中的数据,是为了长期保存服务的。
两者的目标并不相同。
二、核心原理
计算机中的数据,在不同场景下具有不同的表达形式。
程序运行时,数据以内存对象的形式存在。
例如:
struct User {
uint64_t id;
char* name;
};
这个对象在内存中并不仅仅包含用户数据。
它还包含运行环境相关的信息:
name保存的是字符串地址,而不是字符串内容;- 对象布局由编译器决定;
- 内存地址只在当前进程生命周期内有效;
- 结构体可能包含对齐产生的填充空间。
这些信息对于程序执行非常重要,但对于数据保存没有意义。
而磁盘中的数据,需要满足:
- 数据格式稳定;
- 不依赖进程地址;
- 不依赖编译器实现;
- 能够被未来版本读取。
因此:
内存对象关注的是“如何高效运行”,存储数据关注的是“如何长期存在”。
两者关注点不同,所以不能简单复制内存布局完成持久化。
三、设计思路
既然内存对象不能直接保存,那么需要设计一种独立的数据表示。
核心思想是:
不保存对象本身,而保存对象所表达的数据。
因此,需要增加一个转换过程:
内存对象
|
| 序列化
↓
存储格式
|
| 写入磁盘
↓
持久化数据
这个过程叫:序列化(Serialize)
恢复对象的过程叫:反序列化(Deserialize)
例如:
原始对象:
struct User {
uint64_t id;
char* name;
};
内存中:
id = 1001
name
|
↓
0x7ffd12345678
0x7ffd12345678
|
↓
"Memory"
序列化后:
+-------------+-------------+-------------+
| id (8 Byte) | length(4B) | name data |
+-------------+-------------+-------------+
| 1001 | 6 | Memory |
+-------------+-------------+-------------+
此时保存的是数据内容,而不是内存地址。
四、设计挑战
直接复制内存布局的问题,本质上来自:
内存对象包含大量隐含信息,而存储格式必须显式描述数据。
1. 地址无法持久化
指针保存的是地址:
char* name;
执行:
memcpy(buffer, &user, sizeof(User));
复制的是:
0x7ffd12345678
而不是:
"Memory"
程序重新启动后,该地址已经不存在。
因此:
指针只属于当前进程,不能作为持久化数据。
2. 内存布局无法保证稳定
例如:
struct Example {
char c;
int value;
};
实际布局可能:
char
padding
padding
padding
int
这些填充空间也会被复制。
但不同:
- 编译器;
- 平台;
- 编译参数;
可能产生不同布局。
3. 字节序影响解析
不同 CPU 架构可能采用不同字节序。
直接保存整数二进制表示,会导致跨平台解析错误。
4. 复杂对象依赖运行环境
例如:
- 虚函数表指针;
std::string内部结构;- 继承关系;
这些都依赖编译器和运行时环境。
复制内存无法恢复完整对象。
因此:
复制内存布局,只保存了对象当前状态,并没有保存对象真正的数据结构。
五、解决方案
前面的问题,本质上都是因为:
内存对象包含了运行时信息,而磁盘需要保存稳定的数据结构。
因此,存储系统不会直接保存对象,而是设计一种独立的存储格式(Storage Format) 。
一个典型的数据保存流程如下:
内存对象
↓
Serialize()
↓
Binary Format
↓
写入磁盘
其中:
- 内存对象负责程序运行;
- 存储格式负责数据保存;
- 序列化负责两者之间的转换。
例如:
内存中的对象:
struct User {
uint64_t id;
std::string name;
};
程序运行时:
User Object
id
|
1001
name
|
std::string对象
|
堆内存
|
"Memory"
存储时不会保存 std::string 本身,而是重新定义数据布局:
+-------------+-------------+----------------+
| id (8 Byte) | length(4B) | name bytes |
+-------------+-------------+----------------+
| 1001 | 6 | Memory |
这种格式具有几个特点:
1. 数据自描述
读取数据时,可以知道:
- 当前字段是什么;
- 长度是多少;
- 如何解析。
2. 与内存布局无关
无论:
- 32 位系统;
- 64 位系统;
- 不同编译器;
只要遵循相同格式,都可以正确读取。
3. 支持版本演进
真实数据库中,数据结构会不断变化。
例如:
旧版本:
User {
id,
name
}
新版本:
User {
id,
name,
age
}
通过增加:版本号、字段标识、Schema 信息,达到兼容历史数据。
因此,存储系统真正保存的不是:C++对象
而是:符合存储规范的数据格式
这也是数据库、文件系统、消息队列等系统普遍采用的设计方式。
六、实现与优化
完成序列化后,真正的存储系统还需要解决性能和可靠性问题。
1. Page 管理
数据库通常不会逐条记录写磁盘。
而是:
Record
↓
Page
↓
Disk
通过 Page 聚合多个数据,减少 I/O 次数。
2. 减少数据复制
高性能系统会使用:
- mmap;
- 零拷贝;
- DMA。
减少用户态和内核态之间的数据搬运。
3. 压缩与校验
存储系统通常会增加:
- LZ4/Zstd 压缩;
- CRC 校验。
提升:
- 空间利用率;
- 数据可靠性。
七、总结思考
回到最开始:
memcpy(buffer, &object, sizeof(object));
它解决的是:
如何快速复制一段内存。
而存储系统解决的是:
如何让数据跨越时间持续存在。
内存:面向当前进程、依赖地址、生命周期有限。
存储:面向长期保存、地址无关、支持版本演进。
因此:序列化不是额外增加复杂度,而是将内存对象中的隐含状态转换为明确的数据格式。
当我们准备把一个对象写入磁盘时,需要考虑:
- 是否包含指针或运行时状态?
- 数据格式是否需要跨平台?
- 未来版本是否还能读取?
这些问题,也是数据库和存储系统设计中一直需要解决的问题。
—— 内存漫游