存储相关技术

MemTable
MemTable 介绍
MemTable,顾名思议,就是内存表。每个 LevelDB 实例最多会维护两个 MemTable: mem_ 和 imm_。mem_ 可以读写,imm_ 只读。
在 LevelDB 中,最新写入的数据都会保存到 mem_ 中。当 mem_ 的大小超过 write_buffer_size 时,LevelDB 就会将其切换成 imm_,并生成新的 mem_。 LevelDB 的后台线程会将 imm_ compact 成 SSTable 保存在磁盘上。 如果前台的写入速度很快,有可能出现 mem_ 的大小已经超过 write_buffer_size,但是前一个 imm_ 还没有被 compact 到磁盘上,无法切换 MemTable,此时就会出现 stall write(阻塞写请求)。
leveldb::MemTable 主要支持的操作有:
- 插入单条记录:Add。
- 查询单条记录:Get。
- 遍历(范围查询):MemTableIterator。
LevelDB 的 MemTable 的主要功能是将内部编码、内存分配(Arena)和 SkipList 封装在一起。
MemTable 的内部编码

MemTable 中保存的数据是 key 和 value 编码成的一个字符串,由四个部分组成:
klength: 变长的 32 位整数(varint 的编码),表示 internal key 的长度。
internal key: 长度为 klength 的字符串。
vlength: 变长的 32 位整数,表示 value 的长度。
value: 长度为 vlength 的字符串。因为 value 没有参与排序,所以相关的讨论暂时可以忽略。
MemTable 的 KeyComparator 负责从 memkey 中提取出 internalkey,最终排序逻辑是使用 InternalKeyComparator 进行比较,排序规则如下:
优先按照 user key 进行排序。
User key 相同的按照 seq 降序排序。
User key 和 seq 相同的按照 type 降序排序(逻辑上不会达到这一步,因为一个 LevelDB 的 sequence 是单调递增的)。
所以,在一个 MemTable 中,相同的 user key 的多个版本,新的排在前面,旧的排在后面。
MemTable 的内存分配
MemTable 通过 Arena 进行内存分配和使用统计。Arena 其实就是一个简化的内存池。它只提供分配内存的接口,不提供释放内存的接口。只有当整个 Arena 对象销毁的时候才会将之前申请的内存释放掉。 Arena 提供了两个内存分配接口:
Allocate(size_t bytes)
AllocateAligned(size_t bytes)
一般情况下,Allocate 每次从操作系统申请一块大小为 kBlockSize 的内存,默认是 4KB。之后,在 block 剩余内存足够的情况下,内存申请都可以直接从这个 block 划一部分出去(参考代码)。如果 block 剩余的内存不够,并且申请的内存大于 kBlockSize / 4,则直接 new 一块内存给这个请求(参考代码),避免造成太多的内部碎片。否则,就直接再申请一个 block,并从这个 block 划分一部分(参考代码)。
因为 SkipList 中会涉及一些原子操作,所以 AllocateAligned 分配的内存需要和指针的大小(一般是 8 字节)对齐。其他逻辑和 Allocate 一样。
Skip List
Skip List 简介

1990 年,William Pugh 发表了 skip list 的论文:Skip Lists: A Probabilistic Alternative to Balanced Trees。
Skip list 是一种可以用于替代查找树的内存数据结构,其基本原理是在一个有序的链表之上增加一些索引,通过一个随机规则(保证一定概率)来“模拟”二分查找。 直观上可以将整个 skip list 看成是一条地铁线。每个节点就是一个地铁站,比如上图的 1~10。假设 (1) 是始发站,(NIL) 是终点站。这条地铁线有多种快车、慢车的线路,每个条线路经停的地铁站都不一样。比如上图就有四种不同线路(用向右的箭头表示),从上往下: 线路1:只停始发站(1) 和终点站(NIL) 线路2:始发站(1) => (4) => (6) => 终点站(NIL) 线路3:始发站(1) => (3) => (4) => (6) => (9) =>终点站(NIL) 线路4:每一站都停。 注意,skip list 这里的主要成本是经过的站点数量,而在某个站点进行转线的成本是零。我们可以合理转换不同线路来减少经过的站点数量。假如我们要去地铁站(9),可以先坐线路 2 到地铁站(6),再转线路 3 到地铁站(9):经过的站点有 (1)、(4)、(6)、(9)。
Skip List 原理

Skip list 的基础是一个有序的链表。但是因为链表在内存中是离散存储的,我们无法在一个有序链表上执行二分查找。

所以,我们决定在这个有序的链表上增加一层索引,用于将这个有序链表一分为二。通过第一层索引可以将查找量减少一半。

同理,我们可以对左边的链表(1->2->3->4) 建一层索引,将左边一分为二。对右边的链表(6->7->8->9->10)也可以进行一样的操作。如此递归下去…… 这样通过付出一些指针的开销,可以将有序链表的查找时间复杂度从 O(n) 降低到和二分查找一样的 O(logn)。 如果每次都要“精准地一分为二”,插入、删除某个节点的时候会可能会涉及到调整其它节点的指针索引高度。这会让逻辑变得复杂许多,就像红黑树插入、删除节点可能会涉及子树的“旋转”一样。 Skip list 放弃了精确控制每个节点的索引高度来实现“二分查找”,转而采用一个随机概率规则来确定一个节点的高度。这样,一个节点的插入和删除,只会和它的相邻节点有关,逻辑简单,并且修改范围非常有限(锁粒度可以做到很细),可以实现更高的并发。 要实现随机近似二分查找,我们需要保证一个高度为 h 的节点,有 1/2 的概率是高度为 h+1 的节点 :
高度 >= 1 的节点概率为 1。
高度 >= 2 的节点概率为 1/2。
高度 >= 3 的节点概率为 1/4。
...
高度 >= h 的节点概率为 1 / 2^(h-1)。
更通用一点,假设一个高度为 h 的节点,有概率 p 是高度为 h+1 的节点。那么一个长度为 n 的 skip list,需要的指针数量为:N = p^0 * n + p^1 * n + p^2 * n + ... = n * (p^0 + p^1 + p^2 + ....) = n*(1-p^n) / (1 - p)
因为 p < 1,当 n 趋近无穷的时候,p^n 趋近于 0,因此 N = n/(1-p)。Skip list 的空间复杂度是 O(n)。 如果我们想节省内存空间,可以适当的调低 1/2 这个概率,比如 1/3、1/4,从而减少索引指针个数。
LevelDB 的 SkipList 实现
LevelDB 的 SkipList 支持无锁的一写多读,并且只支持查找和插入。LevelDB 通过维护一个写队列来保证同一时刻只有一个线程会写 MemTable。 根据 RandomHeight 的实现,LevelDB 将上面分析的每个元素高度增长的概率设置为 1/4 ,以节省内存。
template <typename Key, class Comparator>
int SkipList<Key, Comparator>::RandomHeight() {
// Increase height with probability 1 in kBranching
static const unsigned int kBranching = 4;
int height = 1;
while (height < kMaxHeight && ((rnd_.Next() % kBranching) == 0)) {
height++;
}
assert(height > 0);
assert(height <= kMaxHeight);
return height;
}
FindGreaterOrEqual 查找并返回第一个大于等于 key 的节点。如果是查找后需要进行插入,需要记录下这个节点的 prev 指针。
template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node*
SkipList<Key, Comparator>::FindGreaterOrEqual(const Key& key,
Node** prev) const {
Node* x = head_;
int level = GetMaxHeight() - 1;
while (true) {
Node* next = x->Next(level);
if (KeyIsAfterNode(key, next)) {
// Keep searching in this list
x = next;
} else {
if (prev != nullptr) prev[level] = x;
if (level == 0) {
return next;
} else {
// Switch to next list
level--;
}
}
}
}
FindLessThan 查找并返回最后一个小于 key 的节点。 FindLast 查找并返回最后一个节点。 Contains 查找 SkipList 是否包含某个 key。 Insert 插入一个 key。前面说了,LevelDB 保证这里同一时刻只会有一个线程在写入,并通过原子地修改指针(SetNext)来保证不影响并发执行的读请求。
template <typename Key, class Comparator>
void SkipList<Key, Comparator>::Insert(const Key& key) {
// TODO(opt): We can use a barrier-free variant of FindGreaterOrEqual()
// here since Insert() is externally synchronized.
Node* prev[kMaxHeight];
Node* x = FindGreaterOrEqual(key, prev);
// Our data structure does not allow duplicate insertion
assert(x == nullptr || !Equal(key, x->key));
int height = RandomHeight();
if (height > GetMaxHeight()) {
for (int i = GetMaxHeight(); i < height; i++) {
prev[i] = head_;
}
// It is ok to mutate max_height_ without any synchronization
// with concurrent readers. A concurrent reader that observes
// the new value of max_height_ will see either the old value of
// new level pointers from head_ (nullptr), or a new value set in
// the loop below. In the former case the reader will
// immediately drop to the next level since nullptr sorts after all
// keys. In the latter case the reader will use the new node.
max_height_.store(height, std::memory_order_relaxed);
}
x = NewNode(key, height);
for (int i = 0; i < height; i++) {
// NoBarrier_SetNext() suffices since we will add a barrier when
// we publish a pointer to "x" in prev[i].
x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
prev[i]->SetNext(i, x);
}
}
void SetNext(int n, Node* x) {
assert(n >= 0);
// Use a 'release store' so that anybody who reads through this
// pointer observes a fully initialized version of the inserted node.
next_[n].store(x, std::memory_order_release);
}
1.MemTable是什么?
MemTable 是一种用于数据库 和存储系统(如Cassandra和HBase)中的内存数据结构。它暂时存储最近写入的数据,以便快速访问和高效写入,然后定期将其刷写(flush)到磁盘上。
2. 底层实现原理
写入流程:当数据被写入数据库时,它首先被记录在 MemTable 中。MemTable 通常存储在 RAM 中,因此写入操作非常快速。一旦 MemTable 达到预设的大小限制,它会被转换为一个不可变的 MemTable,并且一个新的 MemTable 将被创建来接收新的写入。
不可变化:在 MemTable 变为不可变后,它将被异步地写入到磁盘上,形成一个 SSTable。这个过程称为“刷新”(Flush)。一旦数据被成功地刷新到磁盘,相应的 MemTable 就会被清空或删除。
读取流程:在 MemTable 还在内存中时,所有的读取请求都会首先检查 MemTable 是否包含所需的键。如果找到,就直接从 MemTable 返回数据。如果未找到,系统会在磁盘上的数据结构中继续查找。【先内存,后磁盘】
工作原理
写操作:
当写入请求到达时,数据首先写入MemTable。
写操作在内存中进行,因此非常快。
MemTable 通常实现为跳表、红黑树或其他平衡树结构,以支持快速插入和查找。
刷写操作(Flush):
当MemTable达到一定大小或系统资源限制时,将其内容刷写到磁盘上,生成一个新的存储文件(如SSTable)。
刷写操作通常是异步进行的,以避免阻塞写入操作。
读操作:
读取请求首先检查MemTable,如果找到则直接返回数据。
如果MemTable中没有数据,系统会查找磁盘上的持久化文件。
实现原理
数据结构:
使用有序数据结构(如跳表或红黑树)来实现MemTable,以支持快速插入、删除和范围查询。
刷写机制:
定义一个阈值,当MemTable达到此阈值时,触发刷写操作。
刷写过程中,将MemTable内容写入新的磁盘文件,并创建相应的索引结构。
合并和压缩:
定期将多个刷写的磁盘文件合并,以减少文件数量和碎片。
3. 结构设计
数据组织:MemTable 通常采用树形结构(如红黑树或AVL树)或哈希表来组织数据,以支持快速的键值查找和有序的键值遍历。
日志结构合并树(LSM树):在一些系统中,MemTable 是 LSM树的一部分,LSM树通过在 MemTable 满足一定条件时触发合并和压缩操作来优化读写操作的性能。
Write-Ahead Log (WAL):为了保证数据的持久性和一致性,写入 MemTable 的数据通常也会被写入到 Write-Ahead Log 中。在系统故障的情况下,WAL 可用于恢复 MemTable 的状态。
4.性能分析
读写性能
写性能:高,因为数据首先写入内存。
读性能:高,对于最近写入的数据,因为在内存中可以快速访问。
内存 消耗
内存消耗:取决于MemTable的大小设置。需要权衡内存使用和写入性能。
内存管理:需要监控MemTable大小,并在达到阈值时进行刷写以释放内存。
5. 优缺点
优点
高写入性能:由于写操作在内存中进行,非常快速。
快速读访问:对最近写入的数据提供快速访问。
批量刷写优化:减少磁盘I/O,提高磁盘写入效率。
延长磁盘寿命:通过减少频繁的小写入操作,延长磁盘寿命。
缺点
内存消耗:需要占用大量内存,特别是在高写入负载下。
数据丢失风险:在刷写前,存储在MemTable中的数据存在丢失风险(可通过WAL缓解)。
复杂性:实现和管理MemTable及其刷写机制增加了系统的复杂性。
6. 使用场景
高频写入场景:如日志记录、传感器数据收集。
读多写少的场景:如实时分析系统中,对最近写入数据的频繁读取。
LRU缓存
什么时候会用到缓存(Cache)
缓存(Cache)通常用于两个速度不同的介质之间,以提高数据访问的速度和效率。这里有几个典型的应用场景:
处理器和内存之间:处理器(CPU)的运算速度远快于从内存中读取数据的速度。因此,在CPU和内存之间会有多级缓存(L1、L2、甚至L3缓存),用来临时存储即将被CPU使用的数据和指令。这样做可以大幅减少CPU等待数据的时间,提高整体计算效率。
内存和硬盘之间:内存的访问速度也远快于硬盘(无论是HDD还是SSD)。操作系统会使用一部分内存作为硬盘缓存(有时称为“磁盘缓存”或“缓冲区缓存”),用于临时存储最近访问过的数据和文件。当再次请求这些数据时,可以直接从内存中获得,而不是从较慢的硬盘中读取。
数据库系统中: 数据库管理系统(DBMS)也会使用缓存技术来提高查询速度和数据处理效率。缓存可以存储经常访问的查询结果、数据库索引等信息,从而加速后续相同或相似查询的处理速度。
网络请求: 在网络请求中,缓存也是提高数据访问速度的重要技术。例如,Web浏览器会缓存访问过的网页资源(如HTML文件、图片等),当再次访问这些资源时,可以直接从本地缓存读取,而不需要重新从网络下载。
缓存的关键在于它能够存储一份数据副本,在访问速度较慢的介质之前提供快速访问路径。这样,即使背后的存储介质响应较慢,系统性能也不会受到太大影响。然而,缓存管理(如缓存更新、缓存失效策略等)是实现高效缓存系统的一个挑战。正确和高效地使用缓存可以显著提高系统性能,减少数据处理和响应时间。
缓存满了,怎么办?
缓存空间满了之后,更新数据,我要进去,谁出去呢?
什么是LRUCache
LRU是Least Recently Used的缩写,意思是最近最少使用,它是一种Cache替换算法。 什么是Cache?狭义的Cache指的是位于CPU和主存间的快速RAM, 通常它不像系统主存那样使用 DRAM技术,而使用昂贵但较快速的SRAM技术。 广义上的Cache指的是位于速度相差较大的两种 硬件之间, 用于协调两者数据传输速度差异的结构。除了CPU与主存之间有Cache, 内存与硬盘 之间也有Cache,乃至在硬盘与网络之间也有某种意义上的Cache── 称为Internet临时文件夹或 网络内容缓存等。
Cache的容量有限,因此当Cache的容量用完后,而又有新的内容需要添加进来时, 就需要挑选并舍弃原有的部分内容,从而腾出空间来放新内容。LRUCache 的替换原则就是将最近最少使用的内容替换掉。其实,LRU译成最久未使用会更形象, 因为该算法每次替换掉的就是一段时间内最久没有使用过的内容。
LRUCache的实现
要设计一个LRUCache不难,要设计一个高效的LRUCache有难度,即:任意操作都是O(1)。
使用双向链表和哈希表的搭配是最高效和经典的。使用双向链表是因为双向链表可以实现任意位置0(1)的插入和删除,使用哈希表是因为哈希表的增删查改也是O(1)。
https://pic2.zhimg.com/v2-d4da37baa3a08b10b6a2e54043b53e09_1440w.jpg
别急,我们从实现LRU要用的哈希表和双向链表开始。
哈希表(unordered_map)
在C++中,unordered_map是标准模板库(STL)中的一个关联容器,它基于哈希表的实现。它存储了键值对,允许通过键快速访问和修改值。unordered_map提供了平均常数时间复杂度的访问、插入和删除操作。
主要特性
基于哈希表:通过哈希函数将键映射到存储位置,实现快速查找。
键不重复:每个键在容器中是唯一的。
无序存储:元素的存储顺序不依赖于插入顺序,因此迭代器的遍历顺序可能与插入顺序不同。
实现代码
#include <iostream>
#include <list>
#include <unordered_map>
// 使用 using namespace std; 来简化代码,避免重复书写 std:: 前缀
using namespace std;
// LRUCache 类定义
class LRUCache {
private:
int capacity; // 缓存的容量
list<int> keys; // 使用双向链表存储键,保持访问顺序
unordered_map<int, pair<int, list<int>::iterator>> cache; // 存储键值对和对应的链表迭代器
public:
// 构造函数,初始化缓存容量
LRUCache(int capacity) : capacity(capacity) {}
// 获取缓存中键对应的值
int get(int key) {
auto it = cache.find(key);
if (it == cache.end()) {
return -1; // 如果键不存在,返回 -1
}
// 更新访问顺序,将该键移动到链表头部
keys.erase(it->second.second);
keys.push_front(key);
it->second.second = keys.begin();
return it->second.first; // 返回键对应的值
}
// 插入或更新缓存中的键值对
void put(int key, int value) {
if (cache.size() >= capacity && cache.find(key) == cache.end()) {
// 如果缓存已满且键不存在,淘汰最不常用的键(链表尾部的键)
auto last = keys.back();
cache.erase(cache.find(last));
keys.pop_back();
}
// 插入或更新键值对,并更新访问顺序
cache[key] = {value, keys.insert(keys.begin(), key)};
}
};
int main() {
// 创建一个容量为 2 的 LRU 缓存
LRUCache cache(2);
// 插入键值对 (1, 1)
cache.put(1, 1);
// 访问键 1,输出其值
cout << "get(1) = " << cache.get(1) << endl; // 返回 1
// 插入键值对 (2, 2)
cache.put(2, 2);
// 访问键 2,输出其值
cout << "get(2) = " << cache.get(2) << endl; // 返回 2
// 插入键值对 (3, 3),由于缓存已满,键 1 被淘汰
cache.put(3, 3);
// 访问键 1,由于已被淘汰,返回 -1
cout << "get(1) = " << cache.get(1) << endl; // 返回 -1
// 访问键 3,输出其值
cout << "get(3) = " << cache.get(3) << endl; // 返回 3
// 插入键值对 (4, 4),由于缓存已满,键 2 被淘汰
cache.put(4, 4);
// 访问键 1,由于已被淘汰,返回 -1
cout << "get(1) = " << cache.get(1) << endl; // 返回 -1
// 访问键 3,输出其值
cout << "get(3) = " << cache.get(3) << endl; // 返回 3
// 访问键 2,由于已被淘汰,返回 -1
cout << "get(2) = " << cache.get(2) << endl; // 返回 -1
// 访问键 4,输出其值
cout << "get(4) = " << cache.get(4) << endl; // 返回 4
return 0;
}
Leveldb
https://cloud.tencent.com/developer/article/1602204
布隆过滤器
直观的说,bloom算法类似一个hash set,用来判断某个元素(key)是否在某个集合中。
和一般的hash set不同的是,这个算法无需存储key的值,对于每个key,只需要k个比特位,每个存储一个标志,用来判断key是否在集合中。
算法:
- 首先需要k个hash函数,每个函数可以把key散列成为1个整数
- 初始化时,需要一个长度为n比特的数组,每个比特位初始化为0
- 某个key加入集合时,用k个hash函数计算出k个散列值,并把数组中对应的比特位置为1
- 判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询数组中对应的比特位,如果所有的比特位都是1,认为在集合中。
优点:不需要存储key,节省空间
缺点:
- 算法判断key在集合中时,有一定的概率key其实不在集合中
- 无法删除
典型的应用场景:
某些存储系统的设计中,会存在空查询缺陷:当查询一个不存在的key时,需要访问慢设备,导致效率低下。
比如一个前端页面的缓存系统,可能这样设计:先查询某个页面在本地是否存在,如果存在就直接返回,如果不存在,就从后端获取。但是当频繁从缓存系统查询一个页面时,缓存系统将会频繁请求后端,把压力导入后端。
这是只要增加一个bloom算法的服务,后端插入一个key时,在这个服务中设置一次
需要查询后端时,先判断key在后端是否存在,这样就能避免后端的压力。
布隆过滤器[1](Bloom Filter)是由布隆(Burton Howard Bloom)在1970年提出的。它实际上是由一个很长的二进制向量和一系列随机映射函数组成,布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率(假正例False positives,即Bloom Filter报告某一元素存在于某集合中,但是实际上该元素并不在集合中)和删除困难,但是没有识别错误的情形(即假反例False negatives,如果某个元素确实没有在该集合中,那么Bloom Filter 是不会报告该元素存在于集合中的,所以不会漏报)。
在日常生活中,包括在设计计算机软件时,我们经常要判断一个元素是否在一个集合中。比如在字处理软件中,需要检查一个英语单词是否拼写正确(也就是要判断 它是否在已知的字典中);在 FBI,一个嫌疑人的名字是否已经在嫌疑名单上;在网络爬虫里,一个网址是否被访问过等等。最直接的方法就是将集合中全部的元素存在计算机中,遇到一个新 元素时,将它和集合中的元素直接比较即可。一般来讲,计算机中的集合是用哈希表(hash table)来存储的。它的好处是快速准确,缺点是费存储空间。当集合比较小时,这个问题不显著,但是当集合巨大时,哈希表存储效率低的问题就显现出来 了。比如说,一个象 Yahoo,Hotmail 和 Gmai 那样的公众电子邮件(email)提供商,总是需要过滤来自发送垃圾邮件的人(spamer)的垃圾邮件。一个办法就是记录下那些发垃圾邮件的 email 地址。由于那些发送者不停地在注册新的地址,全世界少说也有几十亿个发垃圾邮件的地址,将他们都存起来则需要大量的网络服务器。如果用哈希表,每存储一亿 个 email 地址, 就需要 1.6GB 的内存(用哈希表实现的具体办法是将每一个 email 地址对应成一个八字节的信息指纹(详见:googlechinablog.com/2006/08/blog-post.html), 然后将这些信息指纹存入哈希表,由于哈希表的存储效率一般只有 50%,因此一个 email 地址需要占用十六个字节。一亿个地址大约要 1.6GB, 即十六亿字节的内存)。因此存贮几十亿个邮件地址可能需要上百 GB 的内存。除非是超级计算机,一般服务器是无法存储的[2]。(该段引用谷歌数学之美: http://www.google.com.hk/ggblog/googlechinablog/2007/07/bloom-filter_7469.html )
基本概念
如果想判断一个元素是不是在一个集合里,一般想到的是将所有元素保存起来,然后通过比较确定。链表,树等等数据结构都是这种思路. 但是随着集合中元素的增加,我们需要的存储空间越来越大,检索速度也越来越慢。不过世界上还有一种叫作散列表(又叫哈希表,Hash table)的数据结构。它可以通过一个Hash函数将一个元素映射成一个位阵列(Bit Array)中的一个点。这样一来,我们只要看看这个点是不是 1 就知道可以集合中有没有它了。这就是布隆过滤器的基本思想。
Hash面临的问题就是冲突。假设 Hash 函数是良好的,如果我们的位阵列长度为 m 个点,那么如果我们想将冲突率降低到例如 1%, 这个散列表就只能容纳 m/100 个元素。显然这就不叫空间有效了(Space-efficient)。解决方法也简单,就是使用多个 Hash,如果它们有一个说元素不在集合中,那肯定就不在。如果它们都说在,虽然也有一定可能性它们在说谎,不过直觉上判断这种事情的概率是比较低的。
优点
相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数。另外, Hash 函数相互之间没有关系,方便由硬件并行实现。布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
布隆过滤器可以表示全集,其它任何数据结构都不能;
k 和 m 相同,使用同一组 Hash 函数的两个布隆过滤器的交并差运算可以使用位操作进行。
缺点
但是布隆过滤器的缺点和优点一样明显。误算率(False Positive)是其中之一。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。
另外,一般情况下不能从布隆过滤器中删除元素. 我们很容易想到把位列阵变成整数数组,每插入一个元素相应的计数器加1, 这样删除元素时将计数器减掉就可以了。然而要保证安全的删除元素并非如此简单。首先我们必须保证删除的元素的确在布隆过滤器里面. 这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。
Google 著名的分布式数据库 Bigtable 使用了布隆过滤器来查找不存在的行或列,以减少磁盘查找的IO次数[3]。
Squid 网页代理缓存服务器在 cache digests 中使用了也布隆过滤器[4]。
Venti 文档存储系统也采用布隆过滤器来检测先前存储的数据[5]。
SPIN 模型检测器也使用布隆过滤器在大规模验证问题时跟踪可达状态空间[6]。
Google Chrome浏览器使用了布隆过滤器加速安全浏览服务[7]。
在很多Key-Value系统中也使用了布隆过滤器来加快查询过程,如 Hbase,Accumulo,Leveldb,一般而言,Value 保存在磁盘中,访问磁盘需要花费大量时间,然而使用布隆过滤器可以快速判断某个Key对应的Value是否存在,因此可以避免很多不必要的磁盘IO操作,只是引入布隆过滤器会带来一定的内存消耗,下图是在Key-Value系统中布隆过滤器的典型使用:

Raft
https://zhuanlan.zhihu.com/p/1977388081637254320
LsmTree
log-structured merge-tree (LSM tree) 是一种被精心设计的数据结构,常用于处理大量写入的场景。通过对写入操作进行顺序写入优化实现性能提升。LSM tree 是很多数据库内部的核心数据结构,包括BigTable, Cassandra, Scylla,和 RocksDB。
SSTables
LSM tree 通过一种叫做 SSTable (Sorted Strings Table) 的格式,持久化到硬盘上。正如其名,SSTable 是一种用来存储有序的键值对的格式,其中键的组织是有序存储的。一个SSTable 会包括多个有序的子文件,被称为 segment 。 这些 segments 一旦被写入硬盘,就不可以再修改了。一个简单的SSTable 例子如下图所示:

我们可以看到,在每个 segment 中的键值对都是按照键的顺序有序组织的。
写入数据
由于 LSM tree 只会进行顺序写入,所以自然而然地就会引出这样一个问题,写入的数据可能是任意顺序的,我们又如何保证数据能够保持 SSTable 要求的有序组织呢?
这就需要引入新的常驻内存 (in-memory) 数据结构: memtable_了, _memtable 的底层数据结构则有点像红黑树,当由新的写入操作则将数据插入到红黑树中。

写入操作会先把数据存储到红黑树中,直至红黑树的大小达到了预先定义的大小。一旦红黑树的大小达到阈值,就会把数据整个刷到磁盘中,这个过程就可以把数据保证有序写入了。经过一层数据结构的承接,就可以保证单向顺序写入的同时,也能保证数据的有序了。

读取数据
那么我们是如何从SSTable中查找数据的呢?一种naive的方法就是遍历所有的 segments,寻找我们需要的key。从最新的 segment 到最老的 segment 一一遍历,知道找到目标key为止。显然,这种方式在寻找刚刚写入的数据是比较快的,但是文件一多就不太行了。因此也有针对这个问题的优化,稀疏索引 就是一种在内存中对数据检索进行优化的技术。

我们可以通过这个索引快速找到所需键的前面和后面的偏移量(就是最近的相邻值),这样我们就只需要扫描很小一部分的 segments 文件就可以了。以如图所示的场景举例,当我们需要搜索 dollar 这个值,我们可以通过二分查找搜索稀疏索引,可以知道 dollar 处于 dog 和 downgrade之间。因此我们只需要搜索 17208 和 19504 之间的 segment 来得到我们所需的值,如果搜索不到则可返回未命中。
译者注:稀疏索引和跳表都是为了解决快速索引的问题,根据不同设计具体选择。
上面优化在查找存在的数据其实已经不错了,但是在搜索不存在的key值的时候还是要遍历所有的 segment 才可以确定。为了解决这个问题,就需要引入 布隆过滤器 。布隆过滤器是一种以空间换时间的数据结构,能够帮助我们快速确定某个值是否不存在(如果布隆过滤器认为该值存在,也可能是实际不存在的)。我们可以在写入数据的时候同时更新布隆过滤器,来加速不存在数据的检索。
数据合并
随着时间的推移,整个存储系统将会存储非常多的segment文件,所以这些文件需要进行一定的整理和合并,避免文件太多无法访问。这个文件整理的过程被称为“数据合并” (compaction)。数据合并是一个后台线程,将会持续地将老的segment 合并到一起变成新的 segment。

如图所示,我们可以看到 segment 1 和 segment 2 都有 key 为 dog 的两个值。合并后的新 segment 将会保留更新的值,因此会保留原有 segment 2 里面的值 84,即segment 4 中的值是 dog => 84。一旦合并过程已经完成新的 segment 写入,那么原有的老 segment 文件将会被删除。
删除数据
我们已经解释了读取数据和写入数据的过程,那么删除数据又是如何处理的呢?我们已经知道 SSTable 是不可变的,所以里面的数据当然也不能够删除。其实删除操作其实和写入数据的操作是一样的,当需要删除数据的时候,我们把一个特定的标记(我们称之为 墓碑(tombstone) )写入到这个key对应的位置,以标记为删除。

上图演示了原来 key 为 dog 的值为 52,而删除之后就会变成一个墓碑的标记。当我们搜索键 dog的时候,将会返回数据无法查询,这就意味着删除操作其实也是占用磁盘空间的,最后墓碑的值将会被压缩,最后将会从磁盘删除。
总结
我们已经基本描述了 LSM tree 引擎是如何工作的:
写入操作是先写入内存的(被成为 memtable)。所有的用于加速查询的数据结构(布隆过滤器和稀疏索引)都会被同时更新;
当内存中的 memtable 太大了,将会被刷到磁盘中,注意是有序的;
当查询时我们先回查询布隆过滤器,如果布隆过滤器返回说键不存在,则实际不存在,如果布隆过滤器说存在,进一步遍历 segment 文件;
对于遍历 segment 文件的过程,我们将会先通过稀疏索引找到最小的文件范围,并开始由新到老开始遍历,找到一个key则直接返回。

浙公网安备 33010602011771号