读LevelDB源码——数据结构之SkipList

杏奴
• 阅读 11601

概览

今天开始看LevelDB的源码,看了几个大大小小的数据结构,印象深刻的应该是SkipList了,作为一个典型的以空间换时间的有序链表 相比平衡二叉树而言,还是简单了不少的(对于大多数操作需要O(log n)平均时间)。
SkipList是一个二维空间的链表。

找了个比较形象的图:

读LevelDB源码——数据结构之SkipList

Skip List的定义

SkipList的定义:
1. 一个跳表应该有几个层(level)组成;
2. 跳表的第一层包含所有的元素;
3. 每一层都是一个有序的链表;
4. 如果元素x出现在第i层,则所有比i小的层都包含x;
5. 第i层的元素通过一个down指针指向下一层拥有相同值的元素;
6. 在每一层中,-1和1两个元素都出现(分别表示INT_MIN和INT_MAX);
7. Top指针指向最高层的第一个元素。

然后我们看看LevelDB中是如何实现它的。
首先看下层级的定义,LevelDB中定义了一个SkipList最高层级为12。

enum { kMaxHeight = 12 };

然后层级越高的链中数据越少,也就是说,从下面数上去,最底下一层我们定义为第0层,它拥有所有的数据,它是一条严格递增的链表, 也是我们传统意义上的链表。如果我们在里面找数据,那需要花去O(n)的时间。

结点Node的定义

在LevelDB中,每一个节点用一个Node对象进行存储。Node的定义很简单,抛去一些原子操作,实际上它就是二维链上某个结点,其中包含了所有层级的信息,我们看下它的定义:

template<typename Key, class Comparator>
struct SkipList<Key,Comparator>::Node {
  explicit Node(const Key& k) : key(k) { }

  Key const key;

  // Accessors/mutators for links.  Wrapped in methods so we can
  // add the appropriate barriers as necessary.
  Node* Next(int n) {
    assert(n >= 0);
    // Use an 'acquire load' so that we observe a fully initialized
    // version of the returned Node.
    return reinterpret_cast<Node*>(next_[n].Acquire_Load());
  }
  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].Release_Store(x);
  }

  // No-barrier variants that can be safely used in a few locations.
  Node* NoBarrier_Next(int n) {
    assert(n >= 0);
    return reinterpret_cast<Node*>(next_[n].NoBarrier_Load());
  }
  void NoBarrier_SetNext(int n, Node* x) {
    assert(n >= 0);
    next_[n].NoBarrier_Store(x);
  }

 private:
  // Array of length equal to the node height.  next_[0] is lowest level link.
  port::AtomicPointer next_[1];
};

template<typename Key, class Comparator>
typename SkipList<Key,Comparator>::Node*
SkipList<Key,Comparator>::NewNode(const Key& key, int height) {
  char* mem = arena_->AllocateAligned(
      sizeof(Node) + sizeof(port::AtomicPointer) * (height - 1));
  return new (mem) Node(key);
}

这块其实特别少,它最后用了弹性指针的方式来对不同高度的Node进行不同内存的分配(这里的arena是LevelDB中的内存池,附带了对齐的特性,以后介绍),从而达到节省内存和优化对CPU缓存的目的。

插入结点

我们来简单看一个插入结点的操作。

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);

  assert(x == NULL || !Equal(key, x->key));

  int height = RandomHeight();
  if (height > GetMaxHeight()) {
    for (int i = GetMaxHeight(); i < height; i++) {
      prev[i] = head_;
    }
    //fprintf(stderr, "Change height from %d to %d\n", max_height_, height);

    // 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_ (NULL), or a new value set in
    // the loop below.  In the former case the reader will
    // immediately drop to the next level since NULL sorts after all
    // keys.  In the latter case the reader will use the new node.
    max_height_.NoBarrier_Store(reinterpret_cast<void*>(height));
  }

  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);
  }
}

这段代码的幽默感其实蛮强= = 我一开始不太理解RandomHeight的意义,后来查了一些资料才知道SkipList就是概率性的进行分层——我获取一个height的随机数,当然它是有要求的,就是在0和kMaxHeight之间,然后,我插入的这个结点就在0-height层上都分布了,同时需要修改前缀的指针(如果超越了当前的max_height_,那么同时也要修改max_height_)。

这里耗费的空间可能是O(n*kMaxHeight)了,然后浪费了这么多资源,目的当然是为了高效的读嘛,我们来看下它的读取操作是怎么做的。

读取Node

这段代码是读取大于等于key的第一个结点(并把所有层级上在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 != NULL) prev[level] = x;
      if (level == 0) {
        return next;
      } else {
        // Switch to next list
        level--;
      }
    }
  }
}

我们的逻辑从最高层开始,我们已知最高层的结点分布是稀疏的,那么利用KeyIsAfterNode函数进行比较。
1. 在当前level,如果下一个结点的key比我要比较的key大,到步骤2。
2. 如果到底层了,返回下一个结点,否则降级,再执行1。

因为当前的x结点的下一个层级必然还是x结点,但是它的下一个结点的情况却是未知,因此把低层级的next结点和key进行比较,如此循环,我们就能利用稀疏的链迅速的在密集的链表中找到我们要的元素。

经过整体分析和理解,其实SkipList还是很简单的,最终也是利用了空间换时间的方法。只是它的生成有概率性,但是和平衡二叉树(AVL Tree)比起来,它整体的性价比还是非常可观的~

PS: Google的cpp代码真的很浅显易懂,稍微思考下,做一下笔记,就能明白其中的道理,真的很棒!(也许是LevelDB本身就很简单= =)

欢迎关注我Github 以及 @Gemini

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
4年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
双十一预售活动分析
2022年双十一促销活动已经开始,大家应该都提前开始关注今年双十一活动的时间表了吧?2022年10月24日晚8:00天猫双11预售时间,第一波销售时间10月31日晚8:0,第二波销售时间11月10日晚8:00;天猫双11的优惠力度是跨店每满30050
Wesley13 Wesley13
4年前
VBox 启动虚拟机失败
在Vbox(5.0.8版本)启动Ubuntu的虚拟机时,遇到错误信息:NtCreateFile(\\Device\\VBoxDrvStub)failed:0xc000000034STATUS\_OBJECT\_NAME\_NOT\_FOUND(0retries) (rc101)Makesurethekern
Wesley13 Wesley13
4年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Stella981 Stella981
4年前
Python之time模块的时间戳、时间字符串格式化与转换
Python处理时间和时间戳的内置模块就有time,和datetime两个,本文先说time模块。关于时间戳的几个概念时间戳,根据1970年1月1日00:00:00开始按秒计算的偏移量。时间元组(struct_time),包含9个元素。 time.struct_time(tm_y
Easter79 Easter79
4年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
4年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
4年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
4年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0