21个稳定版本 (9个主要)

15.0.7 2023年7月11日
15.0.4 2023年6月25日
15.0.2 2022年11月27日
14.0.3 2022年10月2日
0.0.0 2020年3月14日

#4 in #storage-engine

每月下载 21次
3 个crate(2个直接) 中使用

MIT/Apache

95KB
1.5K SLoC

Marble

基于磁盘的对象存储的垃圾回收。请参考 examples/kv.rs 了解如何构建基于此的最小键值存储。

Marble 是 sled 的未来存储引擎。

支持4种方法

  • read:设计用于对象低延迟、并发读取
  • write_batch:设计用于大量、高吞吐量的对象批量写入,理想用于将预写日志压缩成特定对象的更新集。除了在更新元数据时短暂的阻塞外,不会阻塞 read 调用。
  • maintenance:压缩已碎片化的后端存储文件。阻塞对 write_batch 的并发调用,但不会比 write_batch 更多阻塞读者。返回成功重写的对象数量。
  • stats:返回后端存储文件中活动对象和总对象的数量统计信息。

Marble 是一个低级对象存储,可用于构建自己的存储引擎和数据库。

从高层次来看,它支持原子批量写入和单对象读取。垃圾回收是手动的。所有操作都是阻塞的。除了 zstd 字典和所有存储文件的手柄外,内存中不缓存任何内容。可以通过提供自定义的 Config::partition_function 来在 GC 时对对象进行分片。在写入批量时不会执行分片,因为写入批量必须存储在单个文件中才能原子化。但是在后续调用 Marble::maintenance 通过重写仍然活动中的对象来整理存储文件时,它将使用此函数将重写的对象分配到特定的分区。

您应该将Marble视为定期将写前日志冲洗进去的堆。它将为每个写批创建一个新的文件,并且在垃圾回收后,如果批处理比Config::target_file_size大得多,实际上可能会扩展到更多的文件。

在任何条件下,Marble都不会自动创建任何线程或调用Marble::maintenance。您可能需要创建一个后台线程,定期调用此方法。

Marble几乎唯一的“高级”功能是可以配置为创建一个专门针对您的写批配置的zstd字典。默认情况下,此功能是禁用的,可以通过设置Config::zstd_compression_level为非None(级别直接传递给zstd进行压缩)来配置。如果批处理包含少于8个项目或平均项目长度小于或等于8,则将绕过压缩。

示例

let marble = marble::open("heap").unwrap();

// Write new data keyed by a `u64` object ID.
// Batches contain insertions and deletions
// based on whether the value is a Some or None.
marble.write_batch([
    (0_u64, Some(&[32_u8] as &[u8])),
    (4_u64, None),
]).unwrap();

// read it back
assert_eq!(marble.read(0).unwrap(), Some(vec![32].into_boxed_slice()));
assert_eq!(marble.read(4).unwrap(), None);
assert_eq!(marble.read(6).unwrap(), None);

// after a few more batches that may have caused fragmentation
// by overwriting previous objects, perform maintenance which
// will defragment the object store based on `Config` settings.
let objects_defragmented = marble.maintenance().unwrap();

// print out system statistics
dbg!(marble.stats());

打印出类似的内容

marble.stats() = Stats {
    live_objects: 1048576,
    stored_objects: 1181100,
    dead_objects: 132524,
    live_percent: 88,
    files: 11,
}

如果您想自定义传递给Marble的设置,您可以指定自己的Config

let config = marble::Config {
    path: "my_path".into(),
    zstd_compression_level: Some(7),
    fsync_each_batch: true,
    target_file_size: 64 * 1024 * 1024,
    file_compaction_percent: 50,
    ..Default::default()
};

let marble = config.open().unwrap();

可以提供一个自定义的GC分片函数,用于根据对象ID和大小对对象进行分区。如果您的更高级系统为某些类型的对象分配了特定的对象ID范围,并且您希望将它们分组在一起,那么这可能很有用,希望将这些具有相似碎片属性(相似预期寿命等)的项目分组在一起。这将仅在通过Marble::maintenance方法进行碎片整理时进行分片,因为每个新的写批必须在单个文件中一起写入,以保持面对崩溃时的写批原子性。

// This function shards objects into partitions
// similarly to a slab allocator that groups objects
// into size buckets based on powers of two.
fn shard_by_size(object_id: u64, object_size: usize) -> u8 {
    let next_po2 = object_size.next_power_of_two();
    u8::try_from(next_po2.trailing_zeros()).unwrap()
}

let config = marble::Config {
    path: "my_sharded_path".into(),
    partition_function: shard_by_size,
    ..Default::default()
};

let marble = config.open().unwrap();

碎片整理始终是代际的,并将重写的对象分组在一起。可以根据配置的partition_function进一步根据配置分片写入的对象,该函数允许您根据ObjectId和对象原始字节的长度来分片对象。

Marble解决了数据库存储中的一个相当基本的问题:在磁盘上存储任意字节,获取它们,并整理文件。

您可以将其视为一个KV,其中键是非零u64,值是任意原始字节的块。

写入应由某个后台进程批量执行。对Marble::write_batch的每次调用至少创建一个新文件,用于存储正在写入的对象。对write_batch的每次调用都会发生多次fsync。它是阻塞的。对象元数据逐步添加到后端无锁的页表中,而不是原子性地添加,因此如果您依赖于批处理原子性,您应该直接从自己的缓存中提供批处理的对象,直到write_batch返回。然而,在崩溃后,批处理将原子性地恢复。

在处理批写入和维护时,读取可以继续大部分不受阻塞。

您负责

  • 在适当的间隔调用Marble::maintenance以整理存储文件。
  • 选择适当的配置可调参数以实现所需的存储空间和写入放大。
  • 确保将 Config.partition_function 设置为一个函数,该函数根据对象的 ObjectId 和/或大小适当地分割对象。理想情况下,具有预期死亡时间的对象将位于同一个分片中,这样可以最小化复制活动对象所需的工作。
  • 分配和管理空闲的 ObjectId

如果您想在Marble之上创建一个工业数据库,您可能还希望添加

  • 日志和写入缓存,以累积偶尔通过 write_batch 写入Marble的更新。请记住,每次调用 write_batch 都至少创建一个新文件并多次fsync,因此您应该适当地批处理调用。一旦日志或写入缓存达到适当的大小,您可以有一个后台线程将相应的一批对象写入其存储,一旦写入批处理返回,相应的日志段和写入缓存可以被删除,因为对象将通过 Marble::read 可用。
  • 适当的读取缓存。 Marble::read 总是直接从磁盘读取。
  • 为了最大程度地兼容SSD,您自己的日志应可配置为写入单独的存储设备,以避免混合具有截然不同的预期死亡时间的写入操作。
  • 基于字典的压缩,用于高效压缩可能小于64k的对象。

获得出色的垃圾回收性能的想法

  • 给某些类型的对象一个特定的ObjectId范围。例如,树索引节点可以在1<<63以上,而树叶子节点可以在那个点以下。 Config.partition_function 可以返回叶子节点的分片0,索引节点为1,并且它们总是写入单独的文件。
  • 基于对象大小的WiscKey样式的大项目和其他项目的分割。根据对象大小分配一个分片ID。
  • 基本上任何将具有某种程度的预期突变或整体生命周期局部性的项目分组在一起的分割策略。

简而言之,您可以将注意力集中在构建自己的数据库的许多有趣的部分上,而不必花费太多精力在无聊的文件垃圾回收上。

依赖关系

~3.5–8.5MB
~68K SLoC