MyTopling 索引:双数组 DoubleArray Trie Cache

(一)背景

MyTopling 是基于 ToplingDB 的 MySQL,分叉自 MyRocks,ToplingDB 则分叉自 RocksDB,兼容 RocksDB 接口,从而 MyTopling 可以复用 MyRocks 的大部分成果。

ToplingDB 早已开源,MyTopling 也会于近期开源。

(二)MyTopling 索引结构

MyTopling 索引的逻辑结构继承自 MyRocks,跟一般的方案不同,没有使用自定义 Comparator,而是对 Key 进行编码,使得编码后的格式,使用(默认的) BytewiseComparator ,等效于使用自定义 Comparator 对原始 Key 进行比较。

逻辑上,编码后的 Key 大致是这样的:

其中索引ID用来区分不同的 Table(primary index)及次级索引,是个 32 位整数,所有整数都按 BigEndian 方式编码(从而整数的大小比较等效于 memcmp)。

(三)大部分索引 Key 都是定长的

DB 中的各种索引一般都是整数类型(各种 ID,主键、外键……),映射到存储引擎中,那就是:相同的 索引 ID,落到 ToplingDB 中,其 Key 的长度都是相同的。例如,在 sysbench 中:

CREATE TABLE sbtest1 (
  id int NOT NULL AUTO_INCREMENT,
  k int NOT NULL DEFAULT '0',
  c char(120) NOT NULL DEFAULT '',
  pad char(60) NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY(k)
);

sbtest1 的存储和索引结构,套用(二)中的表格,就是:

显而易见,主键索引 Key 落到引擎中,是固定的 8 字节,次级索引落到引擎中,是固定的 12 字节。

在这里,主键索引会使用 ToplingDB 的 UintIndex,在大多数情况下,这个索引的空间占用是 O(1),即不管有多少条 KV,空间占用就是那么几个字节,在 ToplingDB 的可视化 WebView 中:

这个 168 个字节中,主要是元信息(Header,类名……),这个基本上已经不可能再优化了。

次级索引则会命中 NLT(NestLoudsTrie)索引,因为 4 个字节的前缀完全相同,所以其有效 Key 只有 8 字节(k + id),NLT 可以将这 8 字节压缩到(平均)大约 5 字节。这对 NLT 其实是一个 Bad Case,甚至算得上是 Worst Case,这样的数据,不光压缩率差,而且访问速度慢,特别是遍历(Iteration)的速度。

在 ToplingDB 中,原本有另一种选择:ToplingDB 的 CompositeUintIndex,但是在 sysbench 这个场景中,索引选择时其评分(score)太低——因为 k 的 distinct 值太多。所以最终回到了默认的 NLT。

(四)新的 FixedLenKeyIndex

在使用 NLT 对索引数据进行压缩之前,是难以预判最终的压缩率的,所以,我们最近做了一个改进:在 Key 长度固定的情况下,如果创建出来的 NLT 压缩率太差,我们就使用新的索引:FixedLenKeyIndex。

FixedLenKeyIndex 非常简单,创建时首先移除公共前缀,剩余的部分,放到一个固定长度的数组中,这个固定长度,可能是任何值,例如,3,5,7,9。

搜索时使用二分搜索,当然这个二分搜索我们也做了类似于 ToplingDB 的去虚拟化(devirtualization) 这样的优化。

但是,作为数据库,索引可能是非常大的,比如100亿条,在这么大规模的数组上使用二分搜索,是有性能瓶颈的:内存访问延迟。因为其(精确的)时间复杂度是 O(log2(n)),100亿条,就需要平均 34 次内存访问,当然,因为 CPU Cache 的存在,真正的内存访问可能是 20 多次,在实测中,这比 NLT 的搜索还要慢得多(大约 3 倍),相比 NLT,其优势就只有遍历(iteration)。

(五)DoubleArray Cache

一般的思路,就是创建类似 BTree 这样的数据结构,在这里如果也用 BTree,就相当于是静态 BTree 了。但是 就算使用 BTree,也需要在(包含多个分叉的)结点中(使用二分法)搜索,其实相当于把全局的二分搜索,变成对每层节点内的二分搜索。

有没有更快的办法呢?

在 ToplingZip 的全局压缩 PA-Zip 算法中,对全局字典的搜索使用 DoubleArray Trie 进行加速,使得该算法有了现实可行性。

关于 DoubleArray Trie 的科普介绍,我这里就不多讲了,关键是灵活运用:我们怎样把这个 Double Array 创建出来,又怎么使用。

5.1 创建

我们把 FixedLenKeyVector 看成一个矩阵,每个 Key 是一行,不同 Key 中相同位置是一列,顺着列的方向,执行宽度优先搜索,每拿到一个子矩阵,如果该子矩阵的行数大于指定的阈值,就将其放入搜索队列,否则剪掉对这个子矩阵及其子孙矩阵的搜索。以这样的方式,我们创建了一个临时 Trie 树,然后在此基础上创建 Double Array Trie。

通过改变阈值,我们可以控制 Trie 树的大小,因为只是一个 Cache,所以,这个 DoubleArray Trie 的内存占用一般只有(去除了公共前缀的) KeyVector 的 3% 左右。

5.2 搜索

搜索就是经典的 Double Array 搜索算法,循环中只需要一个判断语句,但是相当于做了一个 256 分叉的搜索:

MatchStatus da_match(const byte* input, size_t len) const noexcept {
    minimize(len, m_fixlen);
    size_t lo = 0, hi = m_size, pos = 0, state = 0;
    const auto da = m_da_data;
    for (; pos < len; pos++) {
        size_t child = da[state].m_child0 + input[pos];
        if (da[child].m_parent == state) {
            state = child;
            lo = da[child].m_lo;
            hi = da[child].m_hi;
        } else
            break;
    }
    return {lo, hi, pos};
}

从这里拿到 {lo,hi} 范围后,继续在 KeyVector 中二分搜索,此时这个范围已经很小了,可以理解成是在 BTree 的叶子结点中搜索,只是我们搜索定位这个 BTree 叶子结点的算法,比传统算法要快得多!

5.3 实测效果

使用简单的二分搜索,FixLenKeyIndex 比 NLT 慢大约 3 倍,加上 Double Array Trie Cache,速度一下提了 5、6 倍,形势就逆转了,现在,FixLenKeyIndex 的搜索性能和遍历性能都得到了保证。在火焰图中:

FixedLenKeyIndex::Iter::Seek 的耗时占比只有 0.83%! 再细化一下:

da_match 和 lower_bound_prefix 的耗时对比大约是 1:2,如果 cache 大一点,da_match 占比会更多一些。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值