---恢复内容开始---
LevelDB虽然支持多线程, 但本质上并没有使用一些复杂到爆炸的数据结构来达成无锁多写多读, 而是坚持自然朴实的有锁单写多读. 那么是不是只有对时间线产生变动的操作(Put, Compaction etc.)才需要上锁? 不是的. 所有操作几乎都要在某一时间上锁来确保结果是线性的符合预期的. 怎么讲? 用户在t1建立了快照, 那就一定不能得到t2时才写入的数据. 在t1建立快照这件事对数据库来说没有改变时间线(没有副作用, 不需要锁), 但为了让快照成功建立, 那就要上锁, 不能有两个线程同时建立快照. 所以多线程在很多情况下就是个伪命题, 真正需要的是协程. 反正最后也会用各种锁模拟出顺序时间线, 那还不如event loop呢.
LevelDB算是NoSQL, 但不能说没有ACID. 事实上, 单机数据库做到ACID几乎是没什么成本的. 因为硬盘就一个, 天然是线性的. 既然提到了, 那就再说下, 分布式数据库的CAP,
C = Consistency 一致性
A = Availability 可用性
P = Partition tolerance 分区容错性
三者最多取其二, 这个我从来没看过证明论文, 但几乎是不言自明的. 要P就要有副本在不同的机器上, 那更新一个数据, 就要同步到副本. 这时候只能在C和A之中选一个. 如果选C, 那么必须等所有副本确认同步完成之后, 才能再次提供服务, 系统就锁死(不可用)了. 如果选A, 那么就存在着副本版本不同步的问题.
------
回到源码上来, http://db_impl.cc 1110-1130,
1 Status DBImpl::Get(const ReadOptions& options, 2 const Slice& key, 3 std::string* value) { 4 Status s; 5 MutexLock l(&mutex_); 6 SequenceNumber snapshot; 7 if (options.snapshot != NULL) { 8 snapshot = reinterpret_cast<const SnapshotImpl*>(options.snapshot)->number_; 9 } else { 10 snapshot = versions_->LastSequence(); 11 } 12 13 MemTable* mem = mem_; 14 MemTable* imm = imm_; 15 Version* current = versions_->current(); 16 mem->Ref(); 17 if (imm != NULL) imm->Ref(); 18 current->Ref(); 19 20 bool have_stat_update = false; 21 Version::GetStats stats;
以上代码在锁的保护下完成了两件事,
- 生成一个SequenceNumber作为标记, 后续不管线程会不会被切出去, 结果都要相当于在这个时间点瞬间完成
- memtable, immemtable, Version, 由于采用了引用计数, 这里Ref()一下
快照建立完了, 接下来的操作只会有单纯的读, 可以把锁暂时释放, 1132-1146,
1 // Unlock while reading from files and memtables 2 { 3 mutex_.Unlock(); 4 // First look in the memtable, then in the immutable memtable (if any). 5 LookupKey lkey(key, snapshot); // 黑科技 6 if (mem->Get(lkey, value, &s)) { 7 // Done 8 } else if (imm != NULL && imm->Get(lkey, value, &s)) { 9 // Done 10 } else { 11 s = current->Get(options, lkey, value, &stats); 12 have_stat_update = true; 13 } 14 mutex_.Lock(); 15 }
查询先找memtable, 再immemtable, 最后是SSTable, 这都很正常.
请注意我标注了黑科技那行的"LookupKey", 工程师用了些特别的技巧. 这个类主要的功能是把输入的key转换成用于查询的key. 比如key是"Sherry", 实际在数据库中的表达可能会是"6Sherry", 6是长度. 这样比对key是否相等时速度会更快.
http://dbformat.cc 121-138,
1 LookupKey::LookupKey(const Slice& user_key, SequenceNumber s) { 2 size_t usize = user_key.size(); 3 size_t needed = usize + 13; // A conservative estimate 4 char* dst; 5 if (needed <= sizeof(space_)) { 6 dst = space_; 7 } else { 8 dst = new char[needed]; 9 } 10 start_ = dst; 11 dst = EncodeVarint32(dst, usize + 8); // 黑科技 12 kstart_ = dst; 13 memcpy(dst, user_key.data(), usize); 14 dst += usize; 15 EncodeFixed64(dst, PackSequenceAndType(s, kValueTypeForSeek)); 16 dst += 8; 17 end_ = dst; 18 }
LookupKey格式 = 长度 + key + SequenceNumber + type
tricks:
- 在栈上分配一个200长度的数组, 如果运行时发现长度不够用再从堆上new一个, 可以极大避免内存分配
- 黑科技函数"EncodeVarint32", 一般key的长度不可能用满32bit. 大量很短的Key却要用32bit来描述长度无疑是很浪费的. 这个函数让小数值用更少的空间, 代价是最糟要多花一字节(8bit)
快来欣赏一下, http://coding.cc 47-73,
1 char* EncodeVarint32(char* dst, uint32_t v) { 2 // Operate on characters as unsigneds 3 unsigned char* ptr = reinterpret_cast<unsigned char*>(dst); 4 static const int B = 128; 5 if (v < (1<<7)) { 6 *(ptr++) = v; 7 } else if (v < (1<<14)) { 8 *(ptr++) = v | B; 9 *(ptr++) = v>>7; 10 } else if (v < (1<<21)) { 11 *(ptr++) = v | B; 12 *(ptr++) = (v>>7) | B; 13 *(ptr++) = v>>14; 14 } else if (v < (1<<28)) { 15 *(ptr++) = v | B; 16 *(ptr++) = (v>>7) | B; 17 *(ptr++) = (v>>14) | B; 18 *(ptr++) = v>>21; 19 } else { // 最多用5字节 20 *(ptr++) = v | B; 21 *(ptr++) = (v>>7) | B; 22 *(ptr++) = (v>>14) | B; 23 *(ptr++) = (v>>21) | B; 24 *(ptr++) = v>>28; 25 } 26 return reinterpret_cast<char*>(ptr); 27 }
这篇分析如何在SSTable中找到KV, 工程师同样花了不少心思去优化.
在Get刚开始的时候, 线程就在锁的保护下取得了当前Version的指针. 每个Version都是只读的. 大脉络上, 只要遍历那个Version所有跟key有关的SSTable文件就能得到value.
判断是否与key相关的代码在http://version_set.cc 349-390,
1 std::vector<FileMetaData*> tmp; 2 FileMetaData* tmp2; 3 for (int level = 0; level < config::kNumLevels; level++) { 4 size_t num_files = files_[level].size(); 5 if (num_files == 0) continue; 6 7 // Get the list of files to search in this level 8 FileMetaData* const* files = &files_[level][0]; // 不用iterator 9 if (level == 0) { 10 // Level-0 files may overlap each other. Find all files that 11 // overlap user_key and process them in order from newest to oldest. 12 tmp.reserve(num_files); 13 for (uint32_t i = 0; i < num_files; i++) { 14 FileMetaData* f = files[i]; 15 if (ucmp->Compare(user_key, f->smallest.user_key()) >= 0 && 16 ucmp->Compare(user_key, f->largest.user_key()) <= 0) { 17 tmp.push_back(f); 18 } 19 } 20 if (tmp.empty()) continue; 21 22 std::sort(tmp.begin(), tmp.end(), NewestFirst); 23 files = &tmp[0]; // 注意 24 num_files = tmp.size(); 25 } else { 26 // Binary search to find earliest index whose largest key >= ikey. 27 uint32_t index = FindFile(vset_->icmp_, files_[level], ikey); 28 if (index >= num_files) { 29 files = NULL; 30 num_files = 0; 31 } else { 32 tmp2 = files[index]; 33 if (ucmp->Compare(user_key, tmp2->smallest.user_key()) < 0) { 34 // All of "tmp2" is past any data for user_key 35 files = NULL; 36 num_files = 0; 37 } else { 38 files = &tmp2; // 注意 39 num_files = 1; 40 } 41 } 42 }
除了level0, 别的level最多只有一张可能包含key的SSTable. 但工程师为了统一这两种情况, 用了一个很黑的写法, 不知道是不是对所有版本的STL都兼容.
std::vector<FileMetaData*> tmp;
FileMetaData* const* files = &tmp[0];
那么能不能永远安全地用这个files去访问vector容器内的数据呢? 比如, files[0], files[1].
假如vector内部的实现不是连续内存, 这就糟了, 但标准好像又有规定vector的复杂度啊?
------
真正查询SSTable的代码在392-409,
1 for (uint32_t i = 0; i < num_files; ++i) { 2 if (last_file_read != NULL && stats->seek_file == NULL) { 3 // We have had more than one seek for this read. Charge the 1st file. 4 stats->seek_file = last_file_read; 5 stats->seek_file_level = last_file_read_level; 6 } 7 8 FileMetaData* f = files[i]; 9 last_file_read = f; 10 last_file_read_level = level; 11 12 Saver saver; 13 saver.state = kNotFound; 14 saver.ucmp = ucmp; 15 saver.user_key = user_key; 16 saver.value = value; 17 s = vset_->table_cache_->Get(options, f->number, f->file_size, 18 ikey, &saver, SaveValue);
LevelDB毕竟完成年代比较久远, 除了以前吐槽的异常和智能指针外, 没有lambda也让我这种Python, JS背景的人看调用看到瞎眼... 各种回调(SaveValue)根本找不到在哪里. 不过这些写法说是笨笨的, 可读性还是很高的. 我估摸着对代码有追求的程序员写汇编应该也会写得很好看吧.
------
对SSTable的查询就是对table_cache_的查询, 这个cache是不可取消的, 解决了什么问题呢?
LevelDB的数据库"文件"是一个文件夹, 里面包含大量的文件. 这是把复杂度甩锅给操作系统的做法, 但很多系统资源是有限的. 比如, file handle(文件句柄). 一个程序如果开了1W个file handle会浪费大量资源. 这里做个LRU cache, 只有常用的SSTable才会开一个活跃的file handle.
另外就是索引的问题. LSMT是没有主索引的, 只有在各个SSTable内有微缩版索引. 所以, 最最优的情况下也需要2次硬盘读写. 第一张SSTable就存着key, 先读微型索引, 然后二分法找到具体位置, 再读value.
TableCache把热点SSTable的微型索引预先放在内存里, 这样只要1次硬盘读取就能取到key. 这个优化对于LSMT的数据库来说尤为重要, 因为很可能会不止查询一张SSTable. 情况会劣化非常快.
总结, TableCache既承担管理资源(file handle)的作用, 又加速索引的读取.
------
TableCache的实现有点出人意料, LRU, hash, 这都很平常. 但让你不敢相信的是工程师对这个cache做了sharding... 我当时一直以为是sharing而不是sharding... 看了半天都不知道哪里share了.
http://cache.cc 361-369,
virtual Handle* Insert(const Slice& key, void* value, size_t charge,
void (*deleter)(const Slice& key, void* value)) {
const uint32_t hash = HashSlice(key);
return shard_[Shard(hash)].Insert(key, hash, value, charge, deleter);
}
virtual Handle* Lookup(const Slice& key) {
const uint32_t hash = HashSlice(key);
return shard_[Shard(hash)].Lookup(key, hash);
}
就是这个hash table做了两遍hash, 先把key分片一遍, 然后再扔给真正的hash table cache(有锁)去lookup.
这么做的逻辑是可以减少锁的使用率和提升并发, 我当时觉得这个太取巧了.
于是得到了万能的数据结构无锁改造法(大雾). 开了10个线程就把key sharding到10个同样的数据结构上面. 从统计上来说, 这个数据结构就多线程无锁了啊. ( ̄△ ̄;)
------
cache会返回一个Table*(SSTable的内存对应), http://table_cache.cc 105-119,
Status TableCache::Get(const ReadOptions& options,
uint64_t file_number,
uint64_t file_size,
const Slice& k,
void* arg,
void (*saver)(void*, const Slice&, const Slice&)) {
Cache::Handle* handle = NULL; // Cache::Handle* 相当于 void*, Cache::Handle什么都没定义
Status s = FindTable(file_number, file_size, &handle);
if (s.ok()) {
Table* t = reinterpret_cast<TableAndFile*>(cache_->Value(handle))->table;
s = t->InternalGet(options, k, arg, saver);
cache_->Release(handle);
}
return s;
}
然后就调用InternalGet(其中用到了Bloom Filter, BlockCache)得到value啦. SSTable的整个存储格式也考虑了很多细节, 压缩了数据. 这个等我分析到Compaction的时候细说.
---恢复内容结束---