本章任务重在理解Mini-LSM Memtable数据结构,以及如何实现高效并发控制
要点解析
force_freeze_memtable的实现需要权衡性能和语义,理论上当这个函数调用时, Memtable就被转换为ImmMemtable,被立刻冻结,从而禁止写入;那么正确的实现范式应该是先加锁,再创建新的Memtable,但是由于带有WAL的Memtable创建可能会长达几毫秒,此时的锁定会导致延迟峰值。
根据mini-lsm-book任务要求中描述的范式:
1 | fn freeze_memtable(&self) { |
这会导致语义的违背,但是只要实现memtable的原子更新,就不会有数据丢失。为了性能,权衡之下,略微放宽语义限制。
如果不放宽语义限制, LLM + Concurrency Skill 检测竞态条件就会发出警告
报错如下:
1 | let size = { |
思考
- 为什么内存表不提供
deleteAPI?LSM-Tree使用追加写 与 墓碑机制来表示删除,如果内存表提供deleteAPI,考虑如下情况: 此前写入KV并被刷盘,目前使用新的内存表,用户调用delete API, 无法达到删除目的, 如果更改磁盘内容,那么既违背模块划分理念,又违背LSM-Tree设计理念。
- 内存表存储所有写操作而不是仅存储键的最新版本是否有意义?例如,用户将 a->1、a->2 和 a->3 放入同一个内存表。
- 无意义, KV是1:1对应关系, 如果要访问不同版本的KV,
LSM-Tree会通过时间戳提供快照机制
- 无意义, KV是1:1对应关系, 如果要访问不同版本的KV,
- 是否可以使用其他数据结构作为 LSM 中的内存表?使用跳表的优缺点是什么?
- 当然可以选用其他数据结构, 只要能够存储唯一的KV就行,比如B+ Tree, 红黑树
- 使用跳表的优点: 实现简单,并发控制方便友好, 范围查询高效
- 使用跳表的缺点: 最坏复杂度可达O(n), 缓存局部性差
- 为什么我们需要
state和state_lock的组合?我们能否只使用state.read()和state.write()?- 快慢路径分离,正常写入走快路径,获取读锁并发写入,这是大概率时间;触发
freeze是慢路径, 小概率事件,用于序列化并发操作并且最小化写锁的颗粒度,从而获得最高性能。 - 我认为可以只用读写锁,只是性能会下降,写锁的临界区会大大地变长。
- 快慢路径分离,正常写入走快路径,获取读锁并发写入,这是大概率时间;触发
- 存储和探测内存表的顺序为什么重要?如果一个键出现在多个内存表中,你应该向用户返回哪个版本?
- 为了有序存储,返回最新版本
- 我们在这个课程中使用
parking_lot锁。它的读写锁是公平锁吗?如果有一个写者等待现有读者停止,尝试获取锁的读者可能会发生什么?- 不是公平锁;读者可能会抢占锁,导致写者饥饿,但是小概率事件,因为读多写少
- 冻结内存表后,是否可能某些线程仍然持有旧的 LSM 状态并写入这些不可变内存表?你的解决方案如何防止这种情况发生?
- 不可能,因为写入前需要获取读锁,但是freeze线程此时持有写锁,阻塞其他线程的写入,等到原子地更新完后,其他线程才能写入新的Memtable。
- 有几个地方你可能首先获取状态的读锁,然后释放它并获取写锁(这两个操作可能在不同的函数中,但由于一个函数调用另一个而顺序发生)。这与直接将读锁升级为写锁有何不同?是否有必要升级而不是获取和释放,升级的成本是什么?
- 锁的颗粒度更细,不会阻塞其他读者;升级的成本当然是性能啦