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,磁盘或者内存,在合并的时候去查询,查到数据执行更新,比自己旧的数据可以进行删除和回收。

相关推荐
笃行35018 分钟前
基于Rokid CXR-S SDK的智能AR翻译助手技术拆解与实现指南
后端
文心快码BaiduComate25 分钟前
代码·创想·未来——百度文心快码创意探索Meetup来啦
前端·后端·程序员
渣哥35 分钟前
面试官最爱刁难:Spring 框架里到底用了多少经典设计模式?
javascript·后端·面试
疯狂的程序猴41 分钟前
iOS混淆实战全解析,从源码混淆到IPA文件加密,打造苹果应用反编译防护体系
后端
开心就好20251 小时前
iOS 26 文件管理实战,多工具组合下的 App 数据访问与系统日志调试方案
后端
乘风破浪酱524361 小时前
PO、DTO、VO的区别与应用场景详解
后端
盖世英雄酱581362 小时前
分库分表正在被淘汰
数据库·后端
间彧3 小时前
CountDownLatch详解与项目实战
后端
无名之辈J3 小时前
Spring Boot 对接微信支付
后端
junnhwan3 小时前
【苍穹外卖笔记】Day05--Redis入门与店铺营业状态设置
java·数据库·redis·笔记·后端·苍穹外卖