LevelDB 之MemTable

前言

在 LevelDB 中,写完 WAL 日志以后,就可以将数据写入到 MemTable 了。MemTable 是 LSM-Tree必不可缺的一个组件,主要作用如下:

  1. 写入的时候作为随机写转换为顺序写的buffer也是对数据进行排序的处理器
  2. 读取的时候作为热点数据(刚写入的数据)的cache,加快读取速度
  3. SSTable 的数据来源,初始的SSTable都是一个MemTable的持久化

在具体的使用中,MemTable 需要在内存中开辟堆空间,所以需要内存管理。客户端一般写入MemTable后就可以返回成功。

本文将针对MemTable 在 LevelDB 中的实现做一个简单的介绍,一起将客户端的写入过程也做了个介绍,会涉及到LevelDB如何控制并发等。

MemTable的实现在db/memtable.hdb/memtable.cc。内存管理的是实现是在util/arena.h

数据结构

MemTable 数据结构就是前面提到过的SkipList。LevelDB 是将key和value组合在一起成为一个SkipList中的Node,所以MemTable中还包含了一个比较器。这里对SkipList不再做具体的介绍,如果想深入了解,可以看下我前面写的SkipList原理和Java实现。

db/memtable.h中的具体实现为:

arduino 复制代码
class MemTable {
 public:
  // MemTables are reference counted.  The initial reference count
  // is zero and the caller must call Ref() at least once.
  explicit MemTable(const InternalKeyComparator& comparator);
​
  MemTable(const MemTable&) = delete;
  MemTable& operator=(const MemTable&) = delete;
​
  // Increase reference count.
  void Ref() { ++refs_; }
​
  // Drop reference count.  Delete if no more references exist.
  void Unref() {
    --refs_;
    assert(refs_ >= 0);
    if (refs_ <= 0) {
      delete this;
    }
  }
​
  // Returns an estimate of the number of bytes of data in use by this
  // data structure. It is safe to call when MemTable is being modified.
  size_t ApproximateMemoryUsage();
​
  // Return an iterator that yields the contents of the memtable.
  //
  // The caller must ensure that the underlying MemTable remains live
  // while the returned iterator is live.  The keys returned by this
  // iterator are internal keys encoded by AppendInternalKey in the
  // db/format.{h,cc} module.
  Iterator* NewIterator();
​
  // Add an entry into memtable that maps key to value at the
  // specified sequence number and with the specified type.
  // Typically value will be empty if type==kTypeDeletion.
  void Add(SequenceNumber seq, ValueType type, const Slice& key,
           const Slice& value);
​
  // If memtable contains a value for key, store it in *value and return true.
  // If memtable contains a deletion for key, store a NotFound() error
  // in *status and return true.
  // Else, return false.
  bool Get(const LookupKey& key, std::string* value, Status* s);
​
 private:
  friend class MemTableIterator;
  friend class MemTableBackwardIterator;
​
  struct KeyComparator {
    const InternalKeyComparator comparator;
    explicit KeyComparator(const InternalKeyComparator& c) : comparator(c) {}
    int operator()(const char* a, const char* b) const;
  };
​
  typedef SkipList<const char*, KeyComparator> Table;
​
  ~MemTable();  // Private since only Unref() should be used to delete it
​
  KeyComparator comparator_;
  int refs_;
  Arena arena_;
  Table table_;
  void PrintBuffer(char* buf, size_t i);
};
​

上面的实现中可以看到,MemTable 拒绝复制当前内部的数据,或者说拒绝使用拷贝构造函数和赋值来拷贝当前的数据。

arduino 复制代码
MemTable(const MemTable&) = delete;
MemTable& operator=(const MemTable&) = delete;
这两个方法的代码指的是禁止了MemTable的复制,也就是说一个MemTable不能通过拷贝构造函数构创建新对象的方式复制当前MemTable对象。也不能使用赋值运算符来创建一个新的对象并且指向当前的对象,赋值运算符也是会拷贝数据的。

能够创建一个MemTable的方式只有使用传入一个InternalKeyComparator。也不能够复制当前MemTable中的数据。

MemTable中还有一个Ref,这个和gc中的引用是一个意思,如果这个refs中的数据没有到0,说明有人还在使用这个MemTable,那么就不能进行内存释放。

ApproximateMemoryUsage当前已经使用的内存,这个命名是说这个是一个大概的值,我觉得挺神奇的,后面看下为什么是大概的值,是否可能会有并发访问结果不一样的情况。

NewIterator创建一个迭代器

提供的读写接口就两个,一个是Get 一个是Add:

  • Add 返回值是void 入参分别为

    • SequenceNumber seq 当前写入的sequence
    • ValueType type // 类型,该类型只有两种,一个是删除,一个是写入
    • const Slice& key // 写入的key
    • const Slice& value)//写入的Value
  • Get 返回值是true 入参分别为:

    • const LookupKey& key // 查询的Key值
    • std::string* value 如果有数据就会写在value里面,也就是如果返回为true,那么值就在value里面
    • Status* s 如果查询的值已经被删除,那么status会有一个error但是函数会返回true

私有域中主要包含了两个迭代器,Key的比较器,内存分配的Arena 和Table,这个Table就是SkipList。在析构函数里说的很清楚的就是只有在Unref里面可以调用,也就是当refs_的值小于等于0 的时候就可以释放当前的内存。

Add

add 是将当前的值写入到MemTable中,为了更好理解里面的并发控制和sequence,直接看db的Write操作,是在db/db_impl.cc中的DBImpl::Write中,具体实现如下:

scss 复制代码
Status DBImpl::Write(const WriteOptions& options, WriteBatch* updates) {
  Writer w(&mutex_); // 每次写入都会封装为一个Writer,这个在前面的Log中有了解,注意的是,Writer中传入了互斥锁
  w.batch = updates; // 写入操作在Put的是已经封装为了WriteBatch
  w.sync = options.sync; // 判断当前是否同步刷盘,也是Log中的操作
  w.done = false; // 当前写入状态初始为false
    // 锁a
  MutexLock l(&mutex_); // 首先根据信号量初始化锁
  writers_.push_back(&w); // 将写入操作放入一个writers队列中,注意因为有锁,所以同一时间只会有一个线程进入到writers中
  while (!w.done && &w != writers_.front()) {
    w.cv.Wait(); // 需要注意的是,在Wait阶段是会释放锁的
  } // 使用队头的writer作为本次写入的writer
  if (w.done) { // 如果本次的写入已经被完成,则直接返回写入的状态
    return w.status;
  }
​
  // May temporarily unlock and wait.
  Status status = MakeRoomForWrite(updates == nullptr); // 创建空间用于写入磁盘或者memTable
  uint64_t last_sequence = versions_->LastSequence(); // 从当前的version中获取到最后使用的sequence
  Writer* last_writer = &w; //本次写入的writer赋值给last_writer,注意为什么这里明明&w是队头却是lastwriter,是因为在后面的BuildBatchGroup方法中,会将本次批量写入的最后一个writer赋值给他
  if (status.ok() && updates != nullptr) {  // nullptr batch is for compactions
    WriteBatch* write_batch = BuildBatchGroup(&last_writer); // 将当前队列中所有的writer里面的数据合并为一次写入
    WriteBatchInternal::SetSequence(write_batch, last_sequence + 1);// 设置本次批量写入的sequence,sequence每次写入都是递增的,保证了写入的顺序,也能够进行读取的MVVC
    last_sequence += WriteBatchInternal::Count(write_batch); // 更新当前的lastSequence,write_batch 中包含了当前数据的大大小
    // Add to log and apply to memtable.  We can release the lock
    // during this phase since &w is currently responsible for logging
    // and protects against concurrent loggers and concurrent writes
    // into mem_.
    {
      mutex_.Unlock(); // 释放队列锁,此时可以继续写入writers队列了。但是由于当前的writer 没有从队头移除,所以此时仍然等待在 w.cv.Wait();中
      status = log_->AddRecord(WriteBatchInternal::Contents(write_batch)); // 写入Log文件
      bool sync_error = false; 
      if (status.ok() && options.sync) { // 写入成功,是否同步刷盘
        status = logfile_->Sync();
        if (!status.ok()) {
          sync_error = true;
        }
      }
      if (status.ok()) {
        status = WriteBatchInternal::InsertInto(write_batch, mem_); // 此处写入mem
      }
      mutex_.Lock(); // 再次获取到锁,暂停线程写入writers,注意的是,这个锁的释放是等到本次线程退出方法,调用MutexLock的析构函数达到释放锁的目的
      if (sync_error) {
        // The state of the log file is indeterminate: the log record we
        // just added may or may not show up when the DB is re-opened.
        // So we force the DB into a mode where all future writes fail.
        RecordBackgroundError(status);
      }
    }
    if (write_batch == tmp_batch_) tmp_batch_->Clear(); // 清理tmp_batch
    versions_->SetLastSequence(last_sequence);// 设置sequence 到version中
  }
​
  while (true) {
    Writer* ready = writers_.front();
    writers_.pop_front();
    if (ready != &w) {
      ready->status = status;
      ready->done = true;
      ready->cv.Signal();
    }
    if (ready == last_writer) break;
  } // 依次唤醒本次写入的后续writer,此时会从上面的while中继续调用,如果是头节点,而且已经被写入则直接返回,否则就继续执行上面的代码,该循环一直到本次写入的最后一个writer位置
​
  // Notify new head of write queue
  if (!writers_.empty()) { // 唤醒下一次调用
    writers_.front()->cv.Signal();
  }
​
  return status;
}

算得上逐行解释了上面的代码。接下来看下如何使用一个互斥锁实现兼顾多线程顺序写入和效率的。

Add中的锁

先来看最开始的while循环中的cv.Wait()方法,在port/port_stdcxx.h中,实现为:

c 复制代码
void Wait() {
  std::unique_lock<std::mutex> lock(mu_->mu_, std::adopt_lock);//ustd::unique_lock能实现自动加锁与解锁功能,第一个参数是指的传入的参数进行上锁,如果有第二个参数即 std::adopt_lock:表示这个互斥量已经被lock()过了,无需在本次构造函数中加锁,否则会报错。
​
  cv_.wait(lock); // wait 方法会对互斥锁解锁,然后阻塞等待,一直到被notify唤醒。唤醒之后会再次获取锁,一直到获取锁成功后才继续往下执行。 
  lock.release(); // 检测当前锁是否没有释放,如果是则释放掉
}

也就是说每次写入在Wait过程中,会释放当前获取的锁,允许后面的writer 写入到writers中。

下面举例说明:

如果当前有t1,t2,t3,并发写入。假设初始阶段writers为空:

  1. t1,t2,t3 写入的时候,首先假设t1 首先执行MutexLock l(&mutex ); 即获取到了mutex 的锁,t1 被写入到writers中,而且是作为队头,所以不需要进入Wait状态,直接开始写,此时t2,t3 在等待获取锁。
  2. t1 执行到了mutex_.Unlock();此时t1的writer 还在队头,所以t2,t3 被写入到writers中等待唤醒。
  3. 此时writers中包含了t2,t3,但是t1只会写自己的数据,因为他是在合并后才释放锁让t2,t3进入的.t1先写入Log,然后写入MemTable,此时不存在并发写的情况,因为其他的并发都会被放到writers中。
  4. 等到t1将自己的数据写入到Log 和MemTable后,t1 再次获取到锁,此时阻塞writers的中继续新增writer。
  5. 因为t1 的没有进行操作合并,所以他不会唤醒其他的写,只会唤醒下一次的队头,但是此时并没有释放锁。只是唤醒了t2,t2在等待t1释放锁。
  6. t1 本次写入返回,方法栈中的MutexLock 被释放,然后t2获取到锁,此时writers中的数据有t3t2
  7. t2 的操作相比t1 多了一个合并数据和唤醒的动作。这里就不赘述了。

上文中一个就涉及到1个锁,却能够有效的将数据的顺序和并发全部完成。使用一个队列,将多线程写入转换为单线程写入,保证了顺序,也有效地保证了效率。

校验空间容量

在写入之前,还需要看下当前的内存空间,和level0的文件数,实现就在db/db_impl.cc的MakeRoomForWrite方法中:

scss 复制代码
Status DBImpl::MakeRoomForWrite(bool force) {
  mutex_.AssertHeld(); // 确认当前的线程获取到了锁
  assert(!writers_.empty()); // 有writer 操作
  bool allow_delay = !force; // 是否运行缓冲,默认是1ms
  Status s; // 返回的状态
  while (true) {
    if (!bg_error_.ok()) { // 这个bg_error是后台合并level0 的时候的一个操作
      // Yield previous error
      s = bg_error_;
      break;
      //1 如果允许等待(正常写入可以等待。force==updates==nullptr),并且当前的0层
      // 文件触发了需要等待的条件(0 层文件大于等于8)
    } else if (allow_delay && versions_->NumLevelFiles(0) >=
                                  config::kL0_SlowdownWritesTrigger) {
      // We are getting close to hitting a hard limit on the number of
      // L0 files.  Rather than delaying a single write by several
      // seconds when we hit the hard limit, start delaying each
      // individual write by 1ms to reduce latency variance.  Also,
      // this delay hands over some CPU to the compaction thread in
      // case it is sharing the same core as the writer.
      mutex_.Unlock();// 首先会释放锁,因为此时会等待操作进行完成,没必要不让后续的写入进入
      env_->SleepForMicroseconds(1000);// 等到1ms
      allow_delay = false;  // Do not delay a single write more than once,每次写入最多运行等待一次
      mutex_.Lock(); // 加锁,说明要开始干活了
      //2 如果当前的内存足够,而且level0 的文件数量没有超过最大,说明有足够的内存和文件,直接返回stats
        // write_buffer_size 大小为4MB,也就是说一个内存文件大小一般在大于4MB的时候就需要切换了
    } else if (!force &&
               (mem_->ApproximateMemoryUsage() <= options_.write_buffer_size)) {
      // There is room in current memtable
      break;
      //3 如果正在执行内存文件的合并,则等待内存文件合并完成
    } else if (imm_ != nullptr) { 
      // We have filled up the current memtable, but the previous
      // one is still being compacted, so we wait.
      Log(options_.info_log, "Current memtable full; waiting...\n");
      background_work_finished_signal_.Wait();
      //4 如果有太多的level0层文件(默认12)。则等待文件合并完成
    } else if (versions_->NumLevelFiles(0) >= config::kL0_StopWritesTrigger) {
      // There are too many level-0 files.
      Log(options_.info_log, "Too many L0 files; waiting...\n");
      background_work_finished_signal_.Wait();
    } else {
      //5 如果当前的文件数量小于8,内存资源不够,而且没有进行合并,则说明需要创建一个新的内存文件
      // Attempt to switch to a new memtable and trigger compaction of old
      assert(versions_->PrevLogNumber() == 0);
       // 文件的名称也就是num 也是version提供的
      uint64_t new_log_number = versions_->NewFileNumber();
      WritableFile* lfile = nullptr;
        // 创建可写文件,创建失败则说明岗前的num可以重复使用
      s = env_->NewWritableFile(LogFileName(dbname_, new_log_number), &lfile);
      if (!s.ok()) {
        // Avoid chewing through file number space in a tight loop.
        versions_->ReuseFileNumber(new_log_number);
        break;
      }
    // 释放当前的Log文件
      delete log_;
    // 关闭当前的Log文件
      s = logfile_->Close();
      if (!s.ok()) {
        // We may have lost some data written to the previous log file.
        // Switch to the new log file anyway, but record as a background
        // error so we do not attempt any more writes.
        //
        // We could perhaps attempt to save the memtable corresponding
        // to log file and suppress the error if that works, but that
        // would add more complexity in a critical code path.
        RecordBackgroundError(s);
      }
        // 释放内存
      delete logfile_;
    // 将上面创建的文件复制写Log,成为新的Log日志文件
      logfile_ = lfile;
        // 设置num
      logfile_number_ = new_log_number;
        // 将创建的文件赋值给Log中的writer
      log_ = new log::Writer(lfile);
      // 将当前mem_ 的指针复制给imm_,说明当前的mem已经准备刷到level0 了。
      imm_ = mem_;
       // 设置是has_imm_ 为true,这里的 memory_order_release 前面说过,就是不允许指令重排
      has_imm_.store(true, std::memory_order_release);
        // 创建新的MemTable,传入当前的比较器
      mem_ = new MemTable(internal_comparator_);
      // 给当前的mem 添加引用
      mem_->Ref();
      force = false;  // Do not force another compaction if have room
        //尝试调用后台合并
      MaybeScheduleCompaction();
    }
  }
  return s;
}

这里分配的空间核心还是内存是否超过4MB,以及当前level0 的数据是否超过配置的阈值。如果说调大上面的值肯定可以提高一定的吞吐量。但是后期合并的数据量也对应会增加,个人觉得如果key,value 都比较小,则4MB就足够了,但是如果每次都是超大的key和value,就可以考虑调大方法中的参数,避免频繁合并。

合并操作

合并操作个人觉得没有什么好了解的,里面有个小tips就是直接对writers的迭代iter++ 这样可以有效的避免其实只有一个还需要走下面的合并操作。

开始写入MemTable

在写入完Log 后就开始执行写入MemTable了。

ini 复制代码
 status = WriteBatchInternal::InsertInto(write_batch, mem_);
Status WriteBatchInternal::InsertInto(const WriteBatch* b, MemTable* memtable) {
  MemTableInserter inserter;
  inserter.sequence_ = WriteBatchInternal::Sequence(b);
  inserter.mem_ = memtable;
  return b->Iterate(&inserter);
}

首先是创建了一个inserter的对象,赋值mem和sequence,然后调用WriteBatch的迭代器写入。这么封装有什么好处呢?为什么不在MemTable里做一个循环直接往里面写呢?个人觉得是为了解耦,MemTable插入的数据就是简单的Slice 对象,而不用去考虑里面的batch,通过迭代器解析然后插入,能够将WriteBatch的职责和MemTable的职责做一个很好的区分。

在正式进入迭代方法之前,先来看下此时一个的WriteBatch 数据结构。这里就不贴全部的源码了,只是说下WriteBatch里面主要是一个Slice的key和Slice 的value。这些值最后都会被挡在一个string类型名为rep_参数中。

每次写入都会将当前操作类型即ValueType放入到req中,写入之后的seq为:

css 复制代码
如果是kTypeValue则数据是 [seq预留8字节,全部为0][count][kTypeValue(1字节)][key_length][key_value][value_length][value_value]
如果是kTypeDeletion 则数据为[seq预留8字节,全部为0][count][kTypeDeletion(1字节)][key_length][key_value]

然后在BuildBatchGroup 中也只是对这个值进行一个追加,最后是一个大的WriteBatch,包含了n个写入,这个在WriteBatch 中是一个append,每次都会将需要append的数据截取12字节后面的数据,然后将count重新设置到被append的count中。执行完BuildBatchGroup 后,就会在前面的4个字节中写入sequence。

结合Log来看,这里的key,value的值都是使用的Varint32位,这也是为什么需要在Log中写入Fragement的原因了。

了解到了WriteBatch 的数据结构就不在去看WriteBatch::Iterate里面的源码里,其实就是根据数据结果解析为key和value,然后分为delete方法或者Put方法。但是需要注意的是,WriteBatch::Iterate的里面的Put方法最后走到了class MemTableInserter : public WriteBatch::Handler这个类里面的Put方法里。这个方法会对核心就是对当前批次写入的sequence进行解析和插入到MemTable中,说明虽然我们合并了数据的写入,但是在写入Mem中的时候sequence 还是按照写入顺序+1 的。

arduino 复制代码
  void Put(const Slice& key, const Slice& value) override {
    mem_->Add(sequence_, kTypeValue, key, value);
    sequence_++;
  }
  void Delete(const Slice& key) override {
    mem_->Add(sequence_, kTypeDeletion, key, Slice());
    sequence_++;
  }

LevelDB里面的迭代器目前已经了解了两个了,一个是MemTable中的Iterator,用来查询数据,还有一个就是当前的写入了,其实迭代器在LevelDB中使用比较多,后面全部过一遍再回头来看迭代器的使用。

Add 方法

看到这里,终于开始往MemTable 写数据了。具体实现比较简单,主要就是Key值的创建也就是SkipList中Node 的创建。具体代码实现在db/mem_table.cc

ini 复制代码
void MemTable::Add(SequenceNumber s, ValueType type, const Slice& key,
                   const Slice& value) {
  // Format of an entry is concatenation of:
  //  key_size     : varint32 of internal_key.size()
  //  key bytes    : char[internal_key.size()]
  //  tag          : uint64((sequence << 8) | type) // 将type 放在seq后面
  //  value_size   : varint32 of value.size()
  //  value bytes  : char[value.size()]
  size_t key_size = key.size();
  size_t val_size = value.size();
  size_t internal_key_size = key_size + 8;
  const size_t encoded_len = VarintLength(internal_key_size) +
                             internal_key_size + VarintLength(val_size) +
                             val_size;
  char* buf = arena_.Allocate(encoded_len); //创建当前encode 后的数据大小的内存,ie:指向0x8060f8
  char* p = EncodeVarint32(buf, internal_key_size);// 将internal_key_size的值放入到buf中,返回指向 //0x8060f9
  std::memcpy(p, key.data(), key_size);// 将key的值放入到p指针
  p += key_size; //因为key的值有6个
  EncodeFixed64(p, (s << 8) | type);
  p += 8; //0x8060ff 仍然指向
  p = EncodeVarint32(p, val_size);//0x806107
  std::memcpy(p, value.data(), val_size);
  assert(p + val_size == buf + encoded_len);
//  for (size_t i = 0; i < encoded_len; ++i) {
//    unsigned char c = static_cast<unsigned char>(buf[i]);
//    std::bitset<8> binary(c); // 将字符转换为 8 位二进制
//    std::cout << "Character: " << buf[i] << "  Binary: " << binary << std::endl;
//  }
  table_.Insert(buf);
}
​

ValueType的值就是上面提到的MemTableInserter 中put或者delete来传入的。

然后就是写入MemTable的Key的的构建了,最后构建的结果如下:

css 复制代码
[internal_key_size_length Varint64][key_length][key_value][tag[sequence<<8|type] 8字节][value_length][value_value]

然后就是开辟内存空间,传入的参数为上面的所有值长度之和:

scss 复制代码
inline char* Arena::Allocate(size_t bytes) {
  // The semantics of what to return are a bit messy if we allow
  // 0-byte allocations, so we disallow them here (we don't need
  // them for our internal use).
  assert(bytes > 0);
  // 如果当前的内存小于剩下的内存,则直接在剩下的内存中进行分配
  if (bytes <= alloc_bytes_remaining_) {
    char* result = alloc_ptr_;
    alloc_ptr_ += bytes;
    alloc_bytes_remaining_ -= bytes;
    return result;
  }
  return AllocateFallback(bytes);
}

如果上次开辟的空间未使用大于本次使用的空间,直接使用,否则就新创建空间:

scss 复制代码
char* Arena::AllocateFallback(size_t bytes) {
  // 当前的额分配的是否大于1k,如果大于1k直接开通当前的数量
  if (bytes > kBlockSize / 4) {
    // Object is more than a quarter of our block size.  Allocate it separately
    // to avoid wasting too much space in leftover bytes.
    char* result = AllocateNewBlock(bytes);
    return result;
  }
  // 如果超过1k,创建4k的内存
  // We waste the remaining space in the current block.
  alloc_ptr_ = AllocateNewBlock(kBlockSize);
  alloc_bytes_remaining_ = kBlockSize;
​
  char* result = alloc_ptr_;
  alloc_ptr_ += bytes;
  alloc_bytes_remaining_ -= bytes;
  return result;
}

在Log中提到,每次读取都是4kb,所以这里每一个Block也是4kb。仅仅分为两种情况,超过1kb的直接分配需要的内存数,否则直接分配4kb,具体的内存分配在AllocateNewBlock:

c 复制代码
// 将创建的内存方在blocks_的链表中,然后给memory_usage_ 的值添加一下
//memory_usage_  记录的就是当前memtable 使用的内存大小,如果超过配置的最大缓存值,会将内存数据写入到磁盘上
// 内存分配就是很简单的创建一个char数组,然后push到队列中。
char* Arena::AllocateNewBlock(size_t block_bytes) {
  char* result = new char[block_bytes];
  blocks_.push_back(result);
  memory_usage_.fetch_add(block_bytes + sizeof(char*),
                          std::memory_order_relaxed);
  return result;
}

可以看到内存分配非常简单,就是将分配一个char数组,然后放入到blocks的vector里面。为什么可以这么简单的管理呢?这个和Area的生命周期相关,因为一个MemTable包含了一个Area独占的对象,当前MemTable如果释放了空间,说明当前的MemTable没有被其他人引用,而且已经写入到level0的sstable里了,所以area里面的空间可以直接释放。因为不会有人正在访问该空间了。

分配完成以后,就直接写入到SkiptList里面,MemTable的传入的Comparator 也就是MemTable使用的Comparator了。

Get

首先还是看db/db_impl.cc中的Get方法,实现如下:

scss 复制代码
Status DBImpl::Get(const ReadOptions& options, const Slice& key,
                   std::string* value) {
  Status s;
  MutexLock l(&mutex_); // 加锁,这里加锁的主要原因是需要获取当前的全局变量versions_,然后获取到sequence的值
  SequenceNumber snapshot;
  if (options.snapshot != nullptr) {
    snapshot =
        static_cast<const SnapshotImpl*>(options.snapshot)->sequence_number();
  } else {
    snapshot = versions_->LastSequence();
  }
 
  MemTable* mem = mem_; // 当前正在写的Memtable
  MemTable* imm = imm_;// 准备收入level0的MemTable,只读MemTable
  Version* current = versions_->current(); //当前的version对象
  mem->Ref(); // 给mem 新增一个引用,避免读取过程中该mem被回收
  if (imm != nullptr) imm->Ref();// 给imm 新增一个引用,
  current->Ref(); // version也增加引用
​
  bool have_stat_update = false;
  Version::GetStats stats;
​
  // Unlock while reading from files and memtables
  {
    mutex_.Unlock(); // 释放锁,因为当前影响读取的version 或者sequence已经获取到了,所以不在会访问这些全局变量,而且mem和mem_也不会被回收。这里留下个问题,如果说当前读取操作的时候,mem和imm 都满了,等着写,那么岂不是也要等待数据读完?个人觉得是不会的,因为Imm已经在此处变成了本地变量,所以如果合并的时候已经被清理,那么是否回收这个imm的对象就交给了当前查询过程中的这个方法,即变成了栈空间对象的回收
    // First look in the memtable, then in the immutable memtable (if any).
    LookupKey lkey(key, snapshot);// 创建查看的key,
    if (mem->Get(lkey, value, &s)) { 
      // Done
    } else if (imm != nullptr && imm->Get(lkey, value, &s)) {
      // Done
    } else {
      s = current->Get(options, lkey, value, &stats);
      have_stat_update = true;
    }
    mutex_.Lock();
  }
​
  if (have_stat_update && current->UpdateStats(stats)) {
    MaybeScheduleCompaction();
  }
  mem->Unref();
  if (imm != nullptr) imm->Unref();
  current->Unref();
  return s;
}

这个LookupKey 核心实现就是组装当前查的值

css 复制代码
[key_length][user_key][tag]

总的来说还是通过internal_key 查找,所以查找的比较器就比较重要,这里来看下实现:

ini 复制代码
int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const {
  // Order by:
  //    increasing user key (according to user-supplied comparator)
  //    decreasing sequence number
  //    decreasing type (though sequence# should be enough to disambiguate)
  int r = user_comparator_->Compare(ExtractUserKey(akey), ExtractUserKey(bkey));
  if (r == 0) {
    const uint64_t anum = DecodeFixed64(akey.data() + akey.size() - 8);
    const uint64_t bnum = DecodeFixed64(bkey.data() + bkey.size() - 8);
    if (anum > bnum) {
      r = -1;
    } else if (anum < bnum) {
      r = +1;
    }
  }
  return r;
}

按照key的值和sequence的值进行比较,都是使用uint64_t比较的,因为sequence取的是后24位,所以大小比较key有效过滤key值相等不同seq的值。

总结

本文介绍了MemTable的实现,还包含了具体的写入过程中的锁。LevelDB对锁的处理,内存的处理是十分指的学习的。但是每次都将Value的值也一起放入到内存,写入到SkipList里,个人觉得对大对象的存储和查询造成了很多内存上的浪费和使用。是否可以考虑存储计算分离,将Value的值和Key值分开,然后不断修改Key中的Value的offset就行了。有个初步的设想,首先Key记录的是Value的offset,磁盘或者内存,在合并的时候去查询,查到数据执行更新,比自己旧的数据可以进行删除和回收。

相关推荐
NiNg_1_2341 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
Chrikk2 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*3 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue3 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man3 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
customer084 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml45 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
小码编匠6 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#