18 个版本

0.2.7 2021 年 12 月 6 日
0.2.6 2021 年 12 月 2 日
0.2.5 2021 年 11 月 30 日
0.1.9 2021 年 11 月 5 日
0.1.6 2021 年 10 月 27 日

#2549 in 数据库接口

MIT/Apache

90KB
2K SLoC

YAKV 是一个非常简单的基于 B-Tree 的持久化键值存储,使用“传统”架构:B-Tree,缓冲区缓存,基于写时复制的 ACID 事务。 YAKV 实现了简单的 MURSIW(多读单写)访问模式,首先面向嵌入式应用程序。

它对其他模块的依赖最小,仅包含 2k 行代码。存储的 API 非常简单:用于更新信息的 put/remove 方法以及用于检索信息的 get/iter/range 范围方法。 put 执行更新或插入操作:如果键不在存储中,则将其插入,否则更新相关值。提供 put/remove 的批量版本,它接受键对的迭代器。批量更新是原子的:即所有操作都成功或失败。

可以使用双向(双端)迭代器和标准 Rust 范围进行迭代,这允许简单地指定任何具有开放/包含/排除边界的范围。迭代器不是原子的:即在迭代过程中,您可以看到存储的最新提交更新。此外,如果并发更新删除了当前迭代器位置的键,则迭代将在处理所有结果之前停止。

另一种分组操作的方法是显式启动事务。事务具有对数据库的独占访问权,可以执行更新和查找操作。事务结束时,应显式提交或中止,如果离开作用域之前未提交,则它将隐式中止。

YAKV 支持对存储的多线程访问。所有线程都可以共享单个存储实例(您应该使用 Arc 来做)。存储实现是线程安全的,所有方法都是不可变的,因此您可以从不同的线程并发调用它们。但在任何时刻只有一个线程可以更新存储,而多个线程可以读取它。读者可以在写入事务开始之前与写入者并发工作,观察数据库状态。

YAKV 需要键和值都是一个字节向量。如果您想存储其他类型,则需要先将它们序列化。如果您需要保留底层类型的自然比较顺序,那么您将不得不使用合适的序列化器。例如,对于无符号整数类型,您需要使用 大端 编码(即 key.to_be_bytes())来使字节向量比较的结果与两个数字的比较结果相同。对于有符号或浮点类型,编写这样的序列化器可能需要更多努力。

YAKV 使用写时复制(COW)机制来提供原子事务。为了保证持久性和提交数据的完整性,它在每次提交时都会执行两次 fsync 系统调用。这对于小型事务(仅插入一对)来说会带来显著的性能惩罚。但是,如果没有 fsync,则在程序异常终止或电源故障的情况下,数据库可能会损坏。要禁用 fsync,请在配置中将 nosync=true 设置为 true

COW 机制允许消除写前日志(WAL)。但是,事务提交需要两次同步:我们首先需要确保数据库的新版本已持久化,然后原子地更新数据库元数据。因此,对于短事务的性能将比 WAL 差。但是,对于长事务,COW 机制将更高效,因为它避免了数据的重复复制。所以,最有效的方式是通过应用层(使用 start_transaction() 方法)进行大(几兆字节)事务的更改。但这并不总是可能的,因为更新结果可能需要其他只读事务。 YAKV 提供了两种将多个事务分组的方式

  1. 子事务。您可以使用 subcommit() 方法完成事务。这将触发子事务提交。其他事务将看到此事务的结果。但是,这种更改只有在事务提交后才会变得持久。回滚将取消所有子事务。
  2. 延迟提交。如果事务以 delay() 方法完成,那么更改在只读快照中将不可见。但是,它们对其他写或只读事务是可见的。只读事务由 read_only_transaction() 方法启动,并在 MURSIW(多读单写)模式下执行。只读事务不能与写事务并发执行。因此,必须使用写时复制来提供隔离。这就是为什么带有延迟事务的 MURSIW 模式可能比带有快照和子事务的标准模式更快,允许并发执行读者和写入者。

以下是 YAKV 使用的示例

let store = Storage::open(data_path, StorageConfig::default())?;

// Simple insert/update:
let key = b"Some key".to_vec();
let value = b"Some value".to_vec();
store.put(key, value)?;

// Simple remove:
let key = b"Some key".to_vec();
store.remove(key)?;

// Bulk update
store.put_all(
	&mut iter::repeat_with(|| {
	    let key = rand.gen::<[u8; KEY_LEN]>().to_vec();
	    let value = rand.gen::<[u8; VALUE_LEN]>().to_vec();
        Ok((key, value))
	} ).take(TRANSACTION_SIZE))?;

// Bulk delete
store.remove_all(
    &mut iter::repeat_with(|| Ok(rand.gen::<[u8; 8]>().to_vec()))
       .take(TRANSACTION_SIZE),

// Explicit transaction:
{
    let trans = store.start_transaction();
    trans.put(&1u64.to_be_bytes().to_vec(), &PAYLOAD)?;
    trans.put(&2u64.to_be_bytes().to_vec(), &PAYLOAD)?;
    trans.remove(&2u64.to_be_bytes().to_vec())?;
    trans.commit()?;
}

// Simple lookup
let key = b"Some key".to_vec();
if let Some(value) = store.get(&key)? {
    println!("key={:?}, value={:?}", &key, &value);
}

// Iterate through all records:
for entry in store.iter() {
    let kv = entry?;
	println!("key={:?}, value={:?}", &kv.0, &kv.1);
}

// Range iterator:
let from_key = b"AAA".to_vec();
let till_key = b"ZZZ".to_vec();
for entry in store.range(from_key..till_key) {
    let kv = entry?;
	println!("key={:?}, value={:?}", &kv.0, &kv.1);
}

// Backward iterartion:
let till_key = b"XYZ".to_vec();
let mut it = store.range(..=till_key);
while let Some(entry) = it.next_back() {
    let kv = entry?;
	println!("key={:?}, value={:?}", &kv.0, &kv.1);
}

// Close storage
store.close()?;

性能比较

以下是两个基准测试的结果(毫秒:越小越好)。

SwayDB 基准测试:使用 8 字节键和 8 字节值插入 1M 条记录。

db seq rnd
SwayDB 5526 14849
LevelDB 1107 7969
yakv 594 1263

LMDB 基准测试:插入和读取 1M 条记录,键为 4 字节,值为 100 字节。

db seq-write rnd-write seq-read rnd-read
Chronicle 836 894 613 634
LevelDB 1962 2089 2223 2476
LMDB 144 896 108 634
MapDB 8876 9304 9676 9707
MVStore 1328 1675 7757 8420
RocksDB 1800 1850 1814 2067
Xodus 674 13981 3486 4978
kv 5626 7546 742 1943
yakv 1079 1617 549 1020

性能依赖于事务大小(LMDB 与 YAKV 或 COW 与 WAL)。此基准测试插入 1M 条随机键(类似于 LMDB 基准测试),但插入分组在事务中(时间以毫秒计)

tx size yakv(WAL) yakv(COW) LMDB
1000000 2252 1626 1853
100000 5860 3941 4709
10000 29285 17989 15718
1000 73223 54388 30050
100 131826 123618 83233

因此,对于大型交易,LMDB 略快;对于小型交易,带有 WAL 的 YAKV 更快;对于中等大小的交易,LMDB 的速度大约是 YAKV 的两倍。

依赖关系

~0.6–1MB
~15K SLoC