缓存时间:
2026/04/21 04:56
# Rust 中的零拷贝页:我是如何学会放下顾虑并拥抱生命周期的 来源: https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html *你可以在这里找到项目的源码 (https://github.com/redixhumayun/simpledb/)*
零拷贝(Zero-Copy)是一种用于消除内核与用户空间缓冲区之间 CPU 数据拷贝的技术,在数据库引擎等高吞吐应用中尤为关键。在高负载下,尤其是当工作集不再驻留在 CPU 缓存中时,它能带来巨大的性能提升。
## 什么是零拷贝
下面是一个典型数据库引擎的结构示意图。在本文中,我们重点关注两个拷贝边界:操作系统边界,以及从缓冲区池向上跨越至各处理层的传输路径。
```
┌─────────────────────────────────────────────────────────┐
│ 查询层 │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ 执行引擎 │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ 事务管理器 │
└──────────┬─────────────┴─────────────────┬──────────────┘
│ │
┌──────────▼──────────┐ ┌────────────▼────────────┐
│ 锁管理器 │ │ 日志管理器 │
└─────────────────────┘ └─────────────────────────┘
│ │
│ 向高层传递全新拷贝 │
┌────────────────────────▼────────────────────────────────┐
│ 缓冲区池 │
└────────────────────────┬────────────────────────────────┘
│ │
│ 在操作系统边界发生拷贝 │
┌────────────────────────▼────────────────────────────────┐
│ 磁盘 │
└─────────────────────────────────────────────────────────┘
```
构建高性能引擎需要尽可能省略所有无用的操作,而数据拷贝正属于这一范畴。可以将每次拷贝操作视为等价的`memcpy()`调用。实际上,`memcpy()`可能会导致流水线停顿(https://www.intel.com/content/www/us/en/developer/articles/technical/performance-optimization-of-memcpy-in-dpdk.html),这是高性能应用必须避免的情况。这种操作要求 CPU 将数据从源地址读取并写入目标地址。你正在浪费 CPU 周期在非核心任务上,并且这可能导致热数据被挤出 CPU 缓存。下面是一个绝佳示例(https://www.linuxjournal.com/article/6345),展示了典型读或写操作的完整生命周期。所有这些 CPU 拷贝都是消耗周期的无用功。*图片来源于 https://www.linuxjournal.com/article/6345*
现在,让我们首先专注于消除缓冲区池与磁盘层之间的拷贝。
## 缓冲区池与直接 I/O ⊕
这里有一篇(https://lkml.org/lkml/2002/5/11/58)Linus Torvalds 关于直接 I/O 接口的著名“吐槽”。他对数据库开发者似乎颇有微词。缓冲区池通过`open()`系统调用打开并存储文件描述符。当我们对这些文件描述符调用`read()`和`write()`时,就会经历前面提到的完整流程,在用户态、内核态和 DMA 之间产生数据拷贝。
这里有一个显而易见的优化方案:使用带`O_DIRECT`(https://man7.org/linux/man-pages/man2/open.2.html)标志的直接 I/O。大多数现代数据库都采用此方案,尽管也有像 PostgreSQL 这样的例外。这将强制应用程序绕过操作系统的页缓存。此处假设硬件支持 64 位 DMA。在配备 32 位 DMA 设备或机密计算虚拟机(如 AMD SEV、Intel TDX)的系统上,内核可能会静默引入 SWIOTLB 回跳缓冲区,从而重新引入 CPU 拷贝。详情请参阅 Linux 内核文档关于 swiotlb 的部分(https://docs.kernel.org/core-api/swiotlb.html)。
使用`O_DIRECT`要求提交的缓冲区指针地址对齐,同时 I/O 长度和文件偏移量也必须对齐。在 Rust 中,我们通过为持有数据的缓冲区添加`\#\[repr(align(4096))\]`来保证前者,并使用按页面对齐偏移量进行的 4 KiB 页大小读写来满足其余要求。若不如此,`O_DIRECT`的读写操作通常会因`EINVAL`失败。这是一个 Gist(https://gist.github.com/redixhumayun/8f402d30ffc8437e043394b9c003698b)用 C 语言演示了该问题:第一个程序使用`malloc`(未对齐到 4096)导致写入失败;第二个程序使用`posix_memalign`则成功。
由于绕过了内核页缓存,我们将失去预读(https://lwn.net/Articles/888715/)或写合并(https://www.thomas-krenn.com/en/wiki/Linux_Page_Cache_Basics)等有益的性能加成,但这恰恰解释了为什么数据库中的缓冲区池如此重要。缓冲区池本质上是专为特定负载设计的 OS 页缓存替代品。理解它时,将其拆分为“机制 + 策略”总是很有帮助。
**机制:** 一个固定大小的页表,负责为上层提供页面请求服务,并通过驱逐部分页面为新页面腾出空间。
**策略:** 决定驱逐哪个页面的方法(即淘汰策略)。
```
┌─────────────────────────────────────────────────────────┐
│ 查询层 │
│ │
│ TableScan / IndexScan / BTreeScan │
│ (遍历行,调用 next(), get_value()) │
└────────────────────────┬────────────────────────────────┘
│ 使用
┌────────────────────────▼────────────────────────────────┐
│ 事务管理 │
│ │
│ tx_id | ConcurrencyManager | RecoveryManager │
└────────────────────────┬────────────────────────────────┘
│ pin() / unpin()
┌────────────────────────▼────────────────────────────────┐
│ 缓冲区池 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Frame 0 │ │ Frame 1 │ │ Frame 2 │ ... │
│ │ │ │ │ │ │ │
│ │ file:3 │ │ file:7 │ │ empty │ │
│ │ pins: 1 │ │ pins: 0 │ │ pins: 0 │ │
│ │ │ │ │ │ │ │
│ │ 4KB 数据 │ │ 4KB 数据 │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 策略: LRU | CLOCK | SIEVE │
└────────────────────────┬────────────────────────────────┘
│
┌────────────────────────▼────────────────────────────────┐
│ 磁盘 │
│ │
│ [ 文件:1 ] [ 文件:3 ] [ 文件:7 ] [ 文件:9 ] │
└─────────────────────────────────────────────────────────┘
```
为系统选择合适的策略取决于你的工作负载特征,但大多数系统通常会选择 CLOCK(https://www.cs.cornell.edu/courses/cs4410/2018su/lectures/lec15-thrashing.html)算法,它是一种近似 LRU 的实现。这是一份缓冲区池中使用的替换策略的(非详尽)列表(https://en.wikipedia.org/wiki/Cache_replacement_policies)。
使用`O_DIRECT`可以消除操作系统边界处的拷贝。接下来要解决的问题是:一旦页面已经位于缓冲区池中,如何避免再次产生新的拷贝。
## 消除读路径中的拷贝
到目前为止,“零拷贝”指的是消除内核与缓冲区池之间的拷贝。从此刻起,我将稍微扩展它的定义,意指同时消除引擎内部的冗余拷贝。Rust 提供了一套既优秀又令人头疼的方式来避免处理数据拷贝——引用(references)。它之所以优秀,是因为只需要一个字符(`&`);但它之所以令人头疼,是因为我们必须学习如何处理生命周期(lifetimes)(https://doc.rust-lang.org/rust-by-example/scope/lifetime.html)。
理解生命周期最简单的方式是:向编译器证明类型 A 持有的任何引用都不会超过它所指向数据的存活时间。让我们先从定义单个页面的原始字节结构开始:
```rust
pub struct PageBytes {
bytes: [u8; PAGE_SIZE_BYTES as usize],
}
```
接下来,我们定义存在于单个缓冲区帧(BufferFrame)内部的数据。这里的`RwLock`类型就是我们的页面闩锁(page latch)。
```rust
#[derive(Debug)]
pub struct BufferFrame {
page: RwLock<PageBytes>,
}
```
现在,我们将把这个帧的数据存储在`PageReadGuard`中。
```rust
// 为了保持示例简洁,以下类型为示意性质而非真实实现。
// 这里仅用于展示所有权权衡,并非`RwLockReadGuard`的确切实现细节。
/// 提供对已锁定页面的共享访问权的只读守卫。
pub struct PageReadGuard {
page: PageBytes,
}
```
这种版本建模简单,但却将拷贝逻辑硬编码到了设计中。如果每个高层级页面对象都拥有自己独立的`PageBytes`,那么从缓冲区池存储中构造这些对象就意味着必须实例化全新的所有权值。而我们实际想要的并不是所有权,而是对已存在于别处字节的借用视图(borrowed view)。我们可以通过引入生命周期来实现这一点。
```rust
pub struct PageReadGuard<'a> {
page: &'a PageBytes,
}
```
通过添加此生命周期注解,我们向编译器证明`PageReadGuard`的生命周期不会超过`PageBytes`,这意味着高层级的页面对象将成为现有字节的视图,而非独立的所有权副本。在实际实现中,该字段类型为`RwLockReadGuard<'a, PageBytes>`而非`&'a PageBytes`,但所有权逻辑相同:守卫(guard)借用了页面字节而非拥有它们,我们的包装器将这种借用关系继续向下传递。
```rust
pub struct PageReadGuard<'a> {
page: RwLockReadGuard<'a, PageBytes>
}
```
典型的数据库引擎主要包含两种核心页面类型:堆页(heap page)和 B 树页(btree page)。因此我们聚焦于前者。页面的结构将采用标准的槽位页(slotted page)布局(https://siemens.blog/posts/database-page-layout/)。此时,字节已经通过`PageReadGuard<'a>`被借用。接下来的问题是:这个守卫应该存放在哪里,而解析后的页面引用又应该存放在哪里?
最自然的尝试是将所有内容放在同一个结构体中。
```rust
struct HeapPage<'a> {
guard: PageReadGuard<'a>,
header: &'a [u8],
line_pointers: &'a [u8],
record_space: &'a [u8],
}
```
这便引出了 Rust 中经典的自引用结构体(self-referential struct)问题(https://quinedot.github.io/rust-learning/pf-meta.html),使得指针失效的处理变得极其困难。想象一下,你有一个结构体包含两个字段 A 和 B,且 B 指向 A。现在,整个结构体发生了移动。B 会指向哪里?它会继续指向 A 原来的位置,但这已经无效了,会导致未定义行为(UB)。在 Rust 中,可以通过`Pin`(https://without.boats/blog/pin/)、不安全的裸指针、`Arc`指针以及外部 crate 如`ouroboros`等途径来解决这个问题。然而,所有这些方案都会带来一定的开销。因此,下一步的尝试是将守卫的所有权与页面的解析视图分离。
```rust
pub struct HeapPage<'a> {
guard: PageReadGuard<'a>,
}
pub struct HeapPageView<'a> {
header: &'a [u8],
line_pointers: &'a [u8],
record_space: &'a [u8],
layout: &'a Layout,
}
let page = HeapPage::new(guard);
let view = HeapPageView::new(&'a page, layout); // 借用自 page,page 在栈上保持活跃
```
这种方式可行,但当我们想要修改字节时就会遇到问题。假设我们要向堆页中插入一条记录。这需要同时修改`record_space`和`line_pointers`。此时,两者的边界可能都已改变,意味着我们的结构体中存在了悬空引用。我们需要丢弃结构体并重新创建它。虽然这成本不高,但这依然是一种泄漏抽象(leaky abstraction)(https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/)。更优雅的做法如下所示:
```rust
pub struct HeapPage<'a> {
guard: PageReadGuard<'a>,
}
pub struct HeapPageView<'a> {
header: &'a [u8],
body_bytes: &'a [u8],
layout: &'a Layout,
}
let page = HeapPage::new(guard);
let view = HeapPageView::new(&'a page, layout); // 借用自 page,page 在栈上保持活跃
```
在槽位页中,头部大小是固定的。只要我们想执行某些操作,就可以利用头部信息重新解析主体字节,从而获得准确的字节视图。然而,上述做法要求页面和视图必须在栈上保持活跃,并将实现细节泄露给了查询层。这同样构成了泄漏抽象(https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/)。
最终采用的版本反转了这一逻辑。正如计算机科学的那句老话:“任何计算机科学问题都可以通过增加一层间接性来解决”(https://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering)。具体而言,槽位页被划分为头部、行指针和记录空间,因此我们将这些解析后的引用存储在`HeapPage`中,而将`PageReadGuard`保留在`HeapPageView`中。
```rust
pub struct HeapHeaderRef<'a> {
bytes: &'a [u8],
}
struct LinePtrBytes<'a> {
bytes: &'a [u8],
}
struct LinePtrArray<'a> {
bytes: LinePtrBytes<'a>,
len: usize,
capacity: usize,
}
struct HeapRecordSpace<'a> {
bytes: &'a [u8],
base_offset: usize,
}
struct HeapPage<'a> {
header: HeapHeaderRef<'a>,
line_pointers: LinePtrArray<'a>,
record_space: HeapRecordSpace<'a>,
}
pub struct HeapPageView<'a> {
guard: PageReadGuard<'a>,
layout: &'a Layout,
}
```
它们都共享完全相同的`'a`生命周期,这意味着任何其他类型持有的这些引用都不会超越其所属类型的生命周期。而且,所有这些类型都是指向`BufferFrame`中`PageBytes`所持有的同一组字节的引用。
```
PageBytes(由 BufferFrame 持有)
┌─────────────────────────────────────────────────────┐
│ 头部(34 字节) │
│ page_type | slot_count | free_lower | free_upper .. │
├─────────────────────────────────────────────────────┤
│ 行指针(向右增长 →) │
│ [ slot 0 ] [ slot 1 ] [ slot 2 ] ... │
├─────────────────────────────────────────────────────┤
│ 空闲空间 │
├─────────────────────────────────────────────────────┤
│ 记录空间(向左增长 ←) │
│ ... [ tuple 2 ] [ tuple 1 ] [ tuple 0 ] │
└─────────────────────────────────────────────────────┘
│ │
│ HeapHeaderRef<'a> LinePtrArray<'a> HeapRecordSpace<'a> │
│ ───────────────────────┴────────────────────┘ │
│ HeapPage<'a> ←────────────────────┐ │
│ 构建自 │ │
│ HeapPageView<'a> ┌──────────────────────────┐ │
│ │ guard: PageReadGuard<'a>│ │
│ │ layout: &'a Layout │ │
│ └──────────────────────────┘ │
│ 持有 PageBytes 的锁 │
```
连接这两者的桥梁是`HeapPageView`上的一个方法,每当某个操作需要解读页面字节时,它就会构造出一个`HeapPage`。
```rust
impl<'a> HeapPageView<'a> {
fn build_page(&'a self) -> HeapPage<'a> {
HeapPage::new(self.guard.bytes()).unwrap()
}
pub fn row(&self, slot: SlotId) -> Option<TupleRef<'a>> {
let view = self.build_page();
let mut current = slot;
loop {
match view.tuple_ref(current)? {
// 为简化起见省略代码
}
}
}
}
impl<'a> HeapPage<'a> {
fn new(bytes: &'a [u8]) -> SimpleDBResult<Self> {
// 使用 PageKind trait 中的共享解析逻辑
let layout = Self::parse_layout(bytes)?;
let header = HeapHeaderRef::new(layout.header);
// 额外的堆特定验证
let free_upper = header.free_upper() as usize;
let page_size = PAGE_SIZE_BYTES as usize;
if free_upper < header.free_lower() as usize || free_upper > page_size {
return Err("heap page free_upper out of bounds".into());
}
let page = Self::from_parts(header, layout.line_ptrs, layout.records, layout.base_offset);
assert_eq!(
page.slot_count(),
header.slot_count() as usize,
"slot directory length must match header slot_count"
);
Ok(page)
}
}
```
当查询层需要从数据页中读取内容时,它会获取一个`HeapPageView`。而在`HeapPageView`上执行的任何需要访问特定逻辑数据段的操作,都会构造出一个`HeapPage`,后者清楚页面字节的布局方式。现在,你可能会好奇每次重建`HeapPage`的成本是多少。实际上成本极低,因为它完全由算术运算构成,仅在部分不变量未满足时触发少量 panic。算术操作对 CPU 来说*极其*廉价,尤其是与`memcpy()`操作相比。*CPU 的所有指令耗时并不相同。图片来源: ithare.com (https://ithare.com/infographics-operation-costs-in-cpu-clock-cycles/),摘自 Andrew Kelley 关于数据导向设计(Data Oriented Design)的演讲 (https://youtu.be/IroPQ150F6c?t=409)*
## 消除写路径中的拷贝
在前一节中,我们介绍了`PageReadGuard`、`HeapPage`和`HeapPageView`,它们共同构成了页面的读路径。然而,Rust 遵循“别名与可变性互斥”(aliasing XOR mutability)原则(https://cmpt-479-982.github.io/week1/safety_features_of_rust.html#the-borrow-checker-and-the-aliasing-xor-mutability-principle),这意味着我们要么获得多