从 memcpy 开始理解存储系统中的数据

15 阅读6分钟

一个简单的内存复制操作,为什么会成为理解数据库存储的起点?

目录

一、背景与问题

在 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));

它解决的是:

如何快速复制一段内存。

而存储系统解决的是:

如何让数据跨越时间持续存在。

内存:面向当前进程、依赖地址、生命周期有限。

存储:面向长期保存、地址无关、支持版本演进。

因此:序列化不是额外增加复杂度,而是将内存对象中的隐含状态转换为明确的数据格式。

当我们准备把一个对象写入磁盘时,需要考虑:

  1. 是否包含指针或运行时状态?
  2. 数据格式是否需要跨平台?
  3. 未来版本是否还能读取?

这些问题,也是数据库和存储系统设计中一直需要解决的问题。


—— 内存漫游