(一)背景
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 中:
CREATETABLEsbtest1(
idintNOTNULLAUTO_INCREMENT,
kintNOTNULLDEFAULT'0',
cchar(120)NOTNULLDEFAULT'',
padchar(60)NOTNULLDEFAULT'',
PRIMARYKEY(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 分叉的搜索:
MatchStatusda_match(constbyte*input,size_tlen)constnoexcept{minimize(len,m_fixlen);size_tlo=0,hi=m_size,pos=0,state=0;constautoda=m_da_data;for(;pos<len;pos++){size_tchild=da[state].m_child0+input[pos];if(da[child].m_parent==state){state=child;lo=da[child].m_lo;hi=da[child].m_hi;}elsebreak;}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 占比会更多一些。