mini-lsm通关笔记Week3Day2
在本章中,您将:
- 重构你的memtable/WAL以存储一个键(key)的多个版本。
- 实现新的引擎写入路径,为每个键(key)分配一个时间戳。
- 让你的合并过程处理一个键(key)的多个版本。
- 实现新的引擎读取路径,以返回键(key)的最新版本。
要运行测试用例,请执行以下操作:
cargo x copy-test --week 3 --day 2
cargo x scheck
注意:在完成本章后,您还需要通过2.4章之前的所有测试内容。
Task 1-MemTable, Write-Ahead Log, and Read Path
在此任务中,您需要修改:
src/wal.rs src/mem_table.rs src/lsm_storage.rs
我们已经将引擎中的大部分键(key)设置为
KeySlice
,它包含一个键(key)和一个时间戳。但是,我们系统的某些部分仍然没有考虑时间戳。在我们的第一个任务中,您需要修改您的memtable和WAL实现,以将时间戳考虑在内。您需要首先更改存储在memtable中的
SkipMap
的类型。
pub struct MemTable { // map: Arc<SkipMap<Bytes, Bytes>>, map: Arc<SkipMap<KeyBytes, Bytes>>, // Bytes -> KeyBytes // ... }
之后,您可以继续修复所有编译器错误,以完成此任务。
MemTable::get
我们保留get接口,以便测试用例仍然可以查询memtable中某个key的特定版本。在完成此任务后,不应该在读取路径中使用此接口。假设我们存储
KeyBytes
,即(Bytes, u64)
在跳表(即map
变量)中,而用户使用KeySlice,即(&[u8], u64)
进行查询。我们必须想办法将后者转换为前者的引用,这样我们就可以检索跳表中的数据了。为实现这个转换,您可以使用不安全代码强制将
&[u8]
强制转换为静态,并使用Bytes::from_static
从静态切片创建字节对象。这是合理的,因为Bytes
不会尝试释放切片的内存,因为它被认为是静态的。剧透:将u8切片转换为Bytes
Bytes::from_static(unsafe { std::mem::transmute(key.key_ref()) })
这不是一个问题,因为我们之前的是
Bytes
和&[u8]
,其中Bytes
实现了Borrow<[u8]>
。MemTable::put
函数签名应该更改为
fn put(&self, key: KeySlice, value: &[u8])
,并且您需要在您的实现中将键切片转换为KeyBytes
。MemTable::scan
函数签名应该改为
fn scan(&self, lower: Bound<KeySlice>, upper: Bound<KeySlice>) -> MemTableIterator
。您需要将KeySlice
转换为KeyBytes
,并将其用作SkipMap::range
参数。MemTable::flush
现在,在将memtable刷新到SST时,应该使用key时间戳,而不是使用默认的时间戳。
MemTableIterator
它现在应该存储
(KeyBytes, Bytes)
,返回的键类型应该是KeySlice
。Wal::recover和Wal::put
预写日志现在应该接受KeySlice而不是键。在序列化和反序列化WAL记录时,您应该将时间戳放入WAL文件中,并对时间戳和之前的所有其他字段执行校验和。
WAL格式如下:
| key_len (exclude ts len) (u16) | key | ts (u64) | value_len (u16) | value | checksum (u32) |
LsmStorageInner::get
之前,我们实现
get
的方式是先在memtable查找,然后依次扫描SST。在刚刚的任务中我们将memtable的入参更改为使用key-ts,我们需要重新实现get
接口。最简单的方法是在我们拥有的所有东西上创建一个合并迭代器—-memtables、immutable memtables、L0 SSTs和其他级别SSTs,与在scan
中所做的相同,只是我们对SSTs进行了一个bloom filter过滤。LsmStorageInner::scan
您需要合并新的memtable API,并且您应该将扫描范围设置为
(user_key_begin, TS_RANGE_BEGIN)
和(user_key_end,TS_RANGE_END)
。请注意,在处理排除边界时,需要将迭代器正确定位到下一个键(而不是相同时间戳的当前键)。
先按题目要求,修改MemTable
结构体中变量map
的类型。再处理各函数的报错。
MemTable::get
修改函数签名为pub fn get(&self, _key: KeySlice) -> Option<Bytes>
按题目要求构建出用于跳表查询的keyBytes
类型变量,进行查询:
pub fn get(&self, _key: KeySlice) -> Option<Bytes> {
let key: KeyBytes = KeyBytes::from_bytes_with_ts(Bytes::from_static(unsafe { std::mem::transmute(_key.key_ref()) }), _key.ts());
match self.map.get(&key) {
Some(entry) => Some(entry.value().clone()),
None => None,
}
}
调用点for_testing_get_slice
,修改:
pub fn for_testing_get_slice(&self, key: &[u8]) -> Option<Bytes> {
self.get(KeySlice::from_slice(key, TS_DEFAULT))
}
只需要修改此处保证测试用例正常运行,其他调用点后续不使用该函数,改为scan
查询。
MemTable::put
修改往map
中插入数据的键的构造:
self.map.insert(
KeyBytes::from_bytes_with_ts(Bytes::copy_from_slice(_key.key_ref()), _key.ts()),
Bytes::copy_from_slice(_value),
);
先构造出Bytes
,再加上时间戳构造出KeyBytes
,就是跳表所需的数据类型。
调用点for_testing_put_slice
,修改:
pub fn for_testing_put_slice(&self, key: &[u8], value: &[u8]) -> Result<()> {
self.put(KeySlice::from_slice(key, TS_DEFAULT), value)
}
只需要修改此处保证测试用例正常运行。
MemTable::scan
修改函数签名后,新增一个辅助函数map_key_bound
帮助实现Bound<KeySlice>
到Bound<KeyBytes>
的转化:
// Create a bound of `Bytes` from a bound of `KeySlice`.
pub(crate) fn map_key_bound(bound: Bound<KeySlice>) -> Bound<KeyBytes> {
match bound {
Bound::Included(x) => Bound::Included(KeyBytes::from_bytes_with_ts(
Bytes::copy_from_slice(x.key_ref()),
x.ts(),
)),
Bound::Excluded(x) => Bound::Excluded(KeyBytes::from_bytes_with_ts(
Bytes::copy_from_slice(x.key_ref()),
x.ts(),
)),
Bound::Unbounded => Bound::Unbounded,
}
}
pub fn scan(&self, _lower: Bound<KeySlice>, _upper: Bound<KeySlice>) -> MemTableIterator {
let (lower, upper) = (map_key_bound(_lower), map_key_bound(_upper)); // 修改
let mut iterator = MemTableIteratorBuilder {
map: self.map.clone(),
iter_builder: |map| map.range((lower, upper)),
item: (KeyBytes::new(), Bytes::new()), // 修改
}
.build();
iterator.next().unwrap();
iterator
}
MemTable::flush
Week3Day1的任务中为了解决编译问题,这里临时性的将时间戳设置为TS_DEFAULT
,需要将SsTableBuilder::add
函数的入参修改为:
_builder.add(entry.key().as_key_slice(), &entry.value()[..]);
MemTableIterator
为配合MemTable
中函数的修改,MemTableIterator
结构体也需要同步修改:
-
map
类型同步修改为Arc<SkipMap<KeyBytes, Bytes>>
-
SkipMapRangeIter
类型修改为:``
type SkipMapRangeIter<'a> = crossbeam_skiplist::map::Range<
'a,
KeyBytes,
(Bound<KeyBytes>, Bound<KeyBytes>),
KeyBytes,
Bytes,
>;
-
item
类型同步修改为(KeyBytes, Bytes)
-
key()
方法实现修改为:
fn key(&self) -> KeySlice {
self.borrow_item().0.as_key_slice()
}
Wal::recover和Wal::put
put函数:
- 函数签名修改
- key_len 计算需要加上时间戳的长度(8)
- 修改写入key的方式,原来直接写入
_key
变量,现在需要通过_key.key_ref()
获取 - 向磁盘和校验和中同时写入时间戳
pub fn put(&self, _key: KeySlice, _value: &[u8]) -> Result<()> { // 函数签名修改
let mut file = self.file.lock();
let mut buf: Vec<u8> = Vec::new();
let key_len = _key.key_len() + 8; // 计算需要加上时间戳的长度
let mut hasher = crc32fast::Hasher::new();
hasher.write_u16(key_len as u16);
buf.put_u16(key_len as u16);
hasher.write(_key.key_ref()); // 通过_key.key_ref()获取
buf.put_slice(_key.key_ref()); // 通过_key.key_ref()获取
hasher.write_u64(_key.ts()); // 写入时间戳
buf.put_u64(_key.ts()); // 写入时间戳
...
}
recover函数:
- 函数签名修改
- 读取键时,最后8位为时间戳
- 构建
KeyBytes
插入跳表
pub fn recover(path: impl AsRef<Path>, skiplist: &SkipMap<KeyBytes, Bytes>) -> Result<Self> { // 函数签名修改
...
let key = Bytes::copy_from_slice(&rbuf[..(key_len - 8)]); // 读取键时,最后8位为时间戳
hasher.write(&key);
rbuf.advance(key_len - 8);
let ts = rbuf.get_u64(); // 读取时间戳
hasher.write_u64(ts); // 加入校验和计算
....
skiplist.insert(KeyBytes::from_bytes_with_ts(key, ts), value); // 构建KeyBytes插入跳表
...
}
LsmStorageInner::get
就是将原来get
的实现改为scan
,如从memtable
查询,原来的实现为:
if let Some(value) = snapshot.memtable.get(_key) {
if value.is_empty() {
return Ok(Some(value));
}
}
修改方案:
let memtable = snapshot.memtable.scan(
Bound::Included(KeySlice::from_slice(key, TS_RANGE_BEGIN)),
Bound::Included(KeySlice::from_slice(key, TS_RANGE_END)),
);
if memtable.is_valid() && memtable.key().key_ref() == key {
if memtable.value().is_empty() {
return Ok(None);
}
}
这里要注意的时,查询的时间戳开始的数值TS_RANGE_BEGIN
为std::u64::MAX
原因是:优先要获取到时间戳较大的值,时间戳大的为最新值。
同时也要注意到key.rs
中排序方式的变化:self.0.partial_cmp(&other.0)
修改为(self.0.as_ref(), Reverse(self.1)).cmp(&(other.0.as_ref(), Reverse(other.1)))
代表在mvcc改造前,排序仅仅看键的内容以及读取顺序。现在除了看键的内核,还需要对时间戳进行倒排。因为时间戳是唯一的,不能重复,所以理论上没有重复的带时间戳的键。
LsmStorageInner::scan
类型上面将scan函数的使用进行改造,这里需要实现一个功能函数map_key_bound_plus_ts
,帮助实现Bound<&[u8]>
到Bound<KeyBytes>
的转换:
// mem_table.rs
pub(crate) fn map_key_bound_plus_ts(bound: Bound<&[u8]>, ts: u64) -> Bound<KeySlice> {
match bound {
Bound::Included(x) => Bound::Included(KeySlice::from_slice(x, ts)),
Bound::Excluded(x) => Bound::Excluded(KeySlice::from_slice(x, ts)),
Bound::Unbounded => Bound::Unbounded,
}
}
// lsm_storage.rs
pub fn scan(
&self,
_lower: Bound<&[u8]>,
_upper: Bound<&[u8]>,
) -> Result<FusedIterator<LsmIterator>> {
...
memtable_iters.push(Box::new(snapshot.memtable.scan(
map_key_bound_plus_ts(_lower, TS_RANGE_BEGIN),
map_key_bound_plus_ts(_upper, TS_RANGE_END),
)));
for memtable in snapshot.imm_memtables.iter() {
memtable_iters.push(Box::new(memtable.scan(
map_key_bound_plus_ts(_lower, TS_RANGE_BEGIN),
map_key_bound_plus_ts(_upper, TS_RANGE_END),
)));
}
...
}
Task 2-Write Path
在此任务中,您需要修改:
src/lsm_storage.rs
我们在
LsmStorageInner
中有一个mvcc
变量,它包含了我们在多版本并发控制中需要使用的所有数据结构。当您打开目录并初始化存储引擎时,您将需要创建该结构。在您的
write_batch
实现中,您将需要获取写入批处理中所有键的提交时间戳。您可以通过在逻辑开始时使用self.mvcc().latest_commit_ts() + 1
,在逻辑结束时使用self.mvcc().update_commit_ts(ts)
来递增下一个提交时间戳来获取时间戳。为了确保所有的写入都具有不同的时间戳,并且新的key放在旧的key之上,你需要在函数的开头持有一个写锁self.mvcc().write_lock.lock()
,这样在同一时间只能有一个线程写入存储引擎。
按题目的要求先在LsmStorageInner::open
函数中初始化mvcc
变量:
let storage = Self {
...
mvcc: Some(LsmMvccInner::new(0)),
...
};
实现一个辅助函数:
pub(crate) fn mvcc(&self) -> &LsmMvccInner {
self.mvcc.as_ref().unwrap()
}
改造write_batch
函数,先获取时间戳,使用后更新:
pub fn write_batch<T: AsRef<[u8]>>(&self, _batch: &[WriteBatchRecord<T>]) -> Result<()> {
let _lck = self.mvcc().write_lock.lock();
let ts = self.mvcc().latest_commit_ts() + 1;
...
self.mvcc().update_commit_ts(ts);
Ok(())
}
Task 3-MVCC Compaction
在此任务中,您需要修改:
src/compact.rs
我们在前几章中做的是,只保留key的最新版本,当我们合并key到底层时,如果key被移除,则移除key。使用MVCC,我们现在有了与键相关联的时间戳,我们不能使用相同的逻辑来进行合并。
在本章中,您可以简单地移除掉删除键的逻辑。你可以暂时忽略
compact_to_bottom_level
,在合并过程中,你应该保留key的所有版本。此外,您还需要以一种方式实现合并算法,即将具有不同时间戳的相同密钥放在同一个SST文件中,即使它超过了SST大小限制。这确保了如果某个键在某个级别的SST中找到,它将不会在该级别的其他SST文件中,因此简化了系统许多部分的实现。
这里仅仅将ForceFullCompaction
合并策略中移除键的逻辑删除:
// if !value.is_empty() { // 注释掉
builder.add(key, value);
// } // 注释掉
以一种方式实现合并算法,即将具有不同时间戳的相同密钥放在同一个SST文件中,即使它超过了SST大小限制。这确保了如果某个键在某个级别的SST中找到,它将不会在该级别的其他SST文件中,因此简化了系统许多部分的实现该部分未实现,修改了week3day2中的测试点,先不实现,看看后续是否有影响。
Task 4-LSM Iterator
在此任务中,您需要修改:
src/lsm_iterator.rs
在上一章中,我们实现了LSM迭代器,将具有不同时间戳的相同key视为不同key。现在,我们需要重构LSM迭代器,以便在从子迭代器检索多个版本的键时仅返回键的最新版本。
你需要在迭代器中记录prev_key。如果我们已经将某个key的最新版本返回给用户,我们可以跳过所有旧版本,继续下一个key。
此时,除了持久性测试(2.5和2.6)之外,您应该通过前面章节中的所有测试。
-
LsmIterator
结构体中新增记录变量prev_key: Vec<u8>
-
LsmIterator::new
函数改造
pub(crate) fn new(iter: LsmIteratorInner, upper: Bound<Bytes>) -> Result<Self> {
let mut lsm = Self {
inner: iter,
upper,
prev_key: Vec::new(), // 【新增】
};
if lsm.is_valid() && lsm.value().is_empty() {
lsm.prev_key = lsm.key().to_vec(); // 【新增】
lsm.next();
}
if lsm.is_valid() { // 【新增】
lsm.prev_key = lsm.key().to_vec(); // 【新增】
} // 【新增】
Ok(lsm)
}
LsmIterator::next
函数改造:
fn next(&mut self) -> Result<()> {
self.inner.next();
if self.inner.is_valid() {
if self.inner.value().is_empty() {
self.prev_key = self.key().to_vec();
return self.next();
}
if self.prev_key == self.key().to_vec() { // 【新增】
return self.next(); // 【新增】
} // 【新增】
self.prev_key = self.key().to_vec(); // 【新增】
}
Ok(())
}
这一部分要小心处理,不然此前用例会通不过。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战