使用 KeyDB 实现二级索引
KeyDB 不完全是一个键值存储,因为值可以是复杂的数据结构。然而,它有一个外部的键值外壳:在 API 层面,数据是通过键名来寻址的。可以说,KeyDB 原生只提供*主键访问*。但是,由于 KeyDB 是一个数据结构服务器,它的功能可以被用来进行索引,以创建不同类型的二级索引,包括复合(多列)索引。
本文档解释了如何使用以下数据结构在 KeyDB 中创建索引:
- 使用有序集合(Sorted sets)按 ID 或其他数值字段创建二级索引。
- 使用带有字典序范围的有序集合来创建更高级的二级索引、复合索引和图遍历索引。
- 使用集合(Sets)创建随机索引。
- 使用列表(Lists)创建简单的可迭代索引和最后 N 个项目的索引。
在 KeyDB 中实现和维护索引是一个高级主题,因此大多数需要对数据执行复杂查询的用户应该了解他们是否更适合使用关系型存储。然而,通常情况下,特别是在缓存场景中,明确需要在 KeyDB 中存储索引数据,以加速那些需要某种形式索引才能执行的常见查询。
#
使用有序集合实现简单的数值索引在 KeyDB 中可以创建的最简单的二级索引是使用有序集合数据类型,它是一个数据结构,表示一个由浮点数(即每个元素的*分数*)排序的元素集合。元素按从小到大的分数排序。
由于分数是双精度浮点数,因此使用普通有序集合构建的索引仅限于索引字段是给定范围内数字的情况。
构建这类索引的两个命令是 `ZADD` 和 `ZRANGEBYSCORE`,分别用于添加项目和在指定范围内检索项目。
例如,可以通过将元素添加到有序集合中,来按年龄索引一组人名。元素将是人名,分数将是年龄。
为了检索所有年龄在 20 到 40 岁之间的人,可以使用以下命令:
通过使用 `ZRANGEBYSCORE` 的 **WITHSCORES** 选项,也可以获取返回元素关联的分数。
`ZCOUNT` 命令可用于检索给定范围内的元素数量,而无需实际获取元素,这在很多情况下也很有用,特别是考虑到该操作的执行时间是对数级的,与范围的大小无关。
范围可以是包含边界的或不包含边界的,请参考 `ZRANGEBYSCORE` 命令文档以获取更多信息。
**注意**:使用 `ZREVRANGEBYSCORE` 可以按相反顺序查询范围,这在数据按特定方向(升序或降序)索引但我们想以相反方式检索信息时通常很有用。
#
使用对象 ID 作为关联值在上面的例子中,我们将名字与年龄关联起来。然而,在一般情况下,我们可能想要索引存储在别处的对象的某个字段。与其直接使用有序集合的值来存储与索引字段关联的数据,不如只存储对象的 ID。
例如,我可能有一些 KeyDB 哈希(hash)来表示用户。每个用户由一个单独的键表示,可以通过 ID 直接访问:
如果我想创建一个索引来按年龄查询用户,我可以这样做:
这一次,有序集合中与分数关联的值是对象的 ID。因此,当我使用 `ZRANGEBYSCORE` 查询索引后,我还需要使用 `HGETALL` 或类似的命令来检索我需要的信息。这样做有一个明显的优势,即只要我们不更改被索引的字段,对象就可以在不触及索引的情况下被修改。
在接下来的例子中,我们几乎总是使用 ID 作为与索引关联的值,因为这通常是更合理的设计,只有少数例外。
#
更新简单的有序集合索引我们经常索引随时间变化的东西。在上面的例子中,用户的年龄每年都会变化。在这种情况下,使用出生日期作为索引而不是年龄本身会更有意义,但在其他情况下,我们只是希望某个字段能够时常变化,并且索引能够反映这种变化。
`ZADD` 命令使得更新简单索引成为一个非常微不足道的操作,因为用不同的分数和相同的值重新添加一个元素,只会更新分数并将元素移动到正确的位置。因此,如果用户 `john` 变成了 39 岁,为了更新代表用户的哈希中的数据以及索引中的数据,我们需要执行以下两个命令:
该操作可以用 `MULTI`/`EXEC` 事务包装起来,以确保两个字段要么都更新,要么都不更新。
#
将多维数据转换为线性数据使用有序集合创建的索引只能索引单个数值。因此,你可能会认为用这种索引来索引具有多个维度的东西是不可能的,但实际上这并非总是如此。如果你能有效地将多维的东西以线性方式表示,那么通常可以使用简单的有序集合进行索引。
例如,KeyDB 地理空间索引 API 使用有序集合,通过一种名为 Geohash 的技术,按纬度和经度索引地点。有序集合的分数表示经度和纬度的交错位,这样我们就将有序集合的线性分数映射到地球表面的许多小*方块*上。通过执行 8+1 风格的中心加邻域搜索,可以按半径检索元素。
#
分数的限制有序集合元素的分数是双精度浮点数。这意味着它们可以用不同的误差表示不同的十进制或整数值,因为它们内部使用指数表示法。然而,对于索引目的而言,有趣的是,分数总是能够毫无误差地表示 -9007199254740992 到 9007199254740992 之间的数字,即 `-/+ 2^53`。
当表示大得多的数字时,你需要一种不同的索引形式,能够以任何精度索引数字,称为字典序索引。
#
字典序索引KeyDB 有序集合有一个有趣的特性。当元素以相同的分数添加时,它们会按字典序排序,使用 `memcmp()` 函数将字符串作为二进制数据进行比较。
对于不了解 C 语言或 `memcmp` 函数的人来说,这意味着具有相同分数的元素是按其字节的原始值逐字节比较来排序的。如果第一个字节相同,则检查第二个字节,以此类推。如果两个字符串的共同前缀相同,则较长的字符串被认为较大,所以 "foobar" 大于 "foo"。
有一些命令,如 `ZRANGEBYLEX` 和 `ZLEXCOUNT`,能够在假设它们用于所有元素分数相同的有序集合的情况下,以字典序方式查询和计数范围。
这个 KeyDB 特性基本上等同于一个 `b-tree` 数据结构,这种结构通常用于传统数据库中实现索引。如你所料,正因为如此,可以使用这个 KeyDB 数据结构来实现相当复杂的索引。
在我们深入使用字典序索引之前,让我们先看看有序集合在这种特殊操作模式下的行为。由于我们需要以相同的分数添加元素,我们将始终使用特殊的分数 0。
从有序集合中获取所有元素,会立即显示它们是按字典序排列的。
现在我们可以使用 `ZRANGEBYLEX` 来执行范围查询。
请注意,在范围查询中,我们在标识范围的 `min` 和 `max` 元素前加上了特殊字符 `[` 和 `(`。这些前缀是强制性的,它们指定范围的边界是包含还是不包含。所以范围 `[a (b` 表示给我所有字典序上在 `a` (包含) 和 `b` (不包含) 之间的元素,也就是所有以 `a` 开头的元素。
还有两个更特殊的字符,表示无限小的字符串和无限大的字符串,它们是 `-` 和 `+`。
基本上就是这样。让我们看看如何使用这些特性来构建索引。
#
第一个例子:自动补全索引的一个有趣应用是自动补全。自动补全就是当你在搜索引擎中开始输入查询时发生的事情:用户界面会预测你可能正在输入的内容,提供以相同字符开头的常见查询。
一种简单的自动补全方法是,将我们从用户那里得到的每一个查询都添加到索引中。例如,如果用户搜索 `banana`,我们只需执行:
对于遇到的每个搜索查询都依此类推。然后,当我们想要补全用户输入时,我们使用 `ZRANGEBYLEX` 执行一个范围查询。想象一下,用户在搜索框中输入 "bit",我们想提供可能以 "bit" 开头的搜索关键词。我们向 KeyDB 发送一个类似这样的命令:
基本上,我们使用用户当前正在输入的字符串作为范围的开始,并以相同的字符串加上一个设置为 255 的尾随字节(在例子中是 `\xff`)作为范围的结束。这样,我们就能得到所有以用户正在输入的字符串开头的字符串。
注意,我们不希望返回太多项目,所以我们可以使用 **LIMIT** 选项来减少结果数量。
#
加入频率因素上述方法有点天真,因为所有用户的搜索在这种方式下都是一样的。在一个真实的系统中,我们希望根据频率来补全字符串:非常流行的搜索词被推荐的概率会比很少被输入的搜索词更高。
为了实现依赖于频率,并且能自动适应未来的输入,清除不再流行的搜索词,我们可以使用一个非常简单的*流式算法*。
首先,我们修改我们的索引,使其不仅存储搜索词,还存储与该词关联的频率。所以,我们不再只添加 `banana`,而是添加 `banana:1`,其中 1 是频率。
我们还需要逻辑来在搜索词已存在于索引中时增加其频率,所以我们实际会做的事情类似于这样:
如果 `banana` 的条目存在,这将返回单个条目。然后我们可以增加关联的频率并发送以下两个命令:
请注意,因为可能存在并发更新,上述三个命令应该通过 Lua 脚本来发送,这样 Lua 脚本就可以原子性地获取旧计数并用增加后的分数重新添加该项目。
所以结果是,每当用户搜索 `banana` 时,我们的条目都会被更新。
还有更多:我们的目标是只保留被频繁搜索的项目。所以我们需要某种形式的清理机制。当我们实际查询索引以补全用户输入时,我们可能会看到这样的情况:
显然,例如,没有人搜索 "banaooo",但这个查询被执行过一次,所以我们最终会把它呈现给用户。
我们可以这样做。从返回的项目中,我们随机选择一个,将其分数减一,然后用新的分数重新添加它。然而,如果分数达到 0,我们只需从列表中移除该项目。你可以使用更高级的系统,但想法是,从长远来看,索引将包含热门搜索,如果热门搜索随时间变化,它会自动适应。
对该算法的一个改进是根据列表中的权重来选择条目:分数越高,条目被选中以递减其分数或被驱逐的可能性就越小。
#
为大小写和重音符号规范化字符串在自动补全的例子中,我们总是使用小写字符串。然而,现实要复杂得多:语言有大写名称、重音符号等等。
处理这些问题的一个简单方法是实际规范化用户搜索的字符串。无论用户搜索 "Banana"、"BANANA" 还是 "Ba'nana",我们都可以总是把它变成 "banana"。
然而,有时我们可能希望向用户呈现他们输入的原始项目,即使我们为索引规范化了字符串。为此,我们改变索引的格式,不再只存储 `词条:频率`,而是存储 `规范化词条:频率:原始词条`,如下例所示:
基本上,我们添加了另一个字段,我们只会为了可视化而提取和使用它。范围将始终使用规范化的字符串来计算。这是一个具有多种应用的常见技巧。
#
在索引中添加辅助信息当直接使用有序集合时,我们为每个对象提供了两个不同的属性:分数(我们用作索引)和一个关联的值。然而,当使用字典序索引时,分数总是设置为 0,基本上完全没有使用。我们只剩下一个字符串,也就是元素本身。
就像我们在之前的自动补全示例中所做的那样,我们仍然可以使用分隔符来存储关联数据。例如,我们使用冒号来添加频率和原始词以进行补全。
通常,我们可以将任何类型的关联值添加到我们的索引键中。为了使用字典序索引实现一个简单的键值存储,我们只需将条目存储为 `键:值` 的形式:
然后用以下命令搜索键:
然后我们提取冒号之后的部分来获取值。然而,在这种情况下需要解决的一个问题是冲突。冒号字符本身可能就是键的一部分,所以必须选择一个永远不会与我们添加的键冲突的分隔符。
由于 KeyDB 中的字典序范围是二进制安全的,你可以使用任何字节或任何字节序列。然而,如果你接收到不受信任的用户输入,最好使用某种形式的转义来保证分隔符永远不会成为键的一部分。
例如,如果你使用两个空字节作为分隔符 `"\0\0"`,你可能希望在你的字符串中总是将空字节转义成两个字节的序列。
#
数值填充字典序索引看起来似乎只在处理索引字符串的问题时才好用。实际上,使用这种索引来对任意精度的数字进行索引非常简单。
在 ASCII 字符集中,数字按 0 到 9 的顺序出现,所以如果我们用前导零对数字进行左填充,结果是,将它们作为字符串进行比较时,会按其数值大小排序。
我们有效地使用了一个数值字段创建了索引,这个字段可以任意大。这对于任意精度的浮点数也同样有效,方法是确保我们用前导零填充整数部分,用尾随零填充小数部分,如下面的数字列表所示:
#
使用二进制形式的数字以十进制存储数字可能会占用太多内存。另一种方法是直接以二进制形式存储数字,例如 128 位整数。然而,为了使其正常工作,你需要以*大端格式*存储数字,这样最高有效字节就会存储在最低有效字节之前。这样,当 KeyDB 用 `memcmp()` 比较字符串时,它会有效地按数值对数字进行排序。
请记住,以二进制格式存储的数据对于调试来说可观察性较差,更难解析和导出。所以这绝对是一种权衡。
#
复合索引到目前为止,我们探讨了索引单个字段的方法。然而我们都知道 SQL 存储能够使用多个字段创建索引。例如,我可以按房间号和价格来索引一个大型商店中的产品。
我需要运行查询来检索给定房间内、在给定价格范围内的所有产品。我可以按以下方式索引每个产品:
这里的字段是 `房间号:价格:产品ID`。为简单起见,我在示例中只使用了四位数字填充。辅助数据(产品 ID)不需要任何填充。
有了这样的索引,要获取 56 号房间内价格在 10 到 30 美元之间的所有产品就非常容易了。我们只需运行以下命令:
上述索引被称为复合索引。其有效性取决于字段的顺序和我想要运行的查询。例如,上述索引无法有效地用于获取特定价格范围内的所有产品,而不管房间号。但是,我可以使用主键来运行不考虑价格的查询,比如*给我 44 号房间的所有产品*。
复合索引非常强大,在传统存储中用于优化复杂查询。在 KeyDB 中,它们既可以用于实现存储在传统数据存储中的东西的超快内存 KeyDB 索引,也可以用于直接索引 KeyDB 数据。
#
更新字典序索引字典序索引中的索引值可能会变得非常花哨,并且从我们存储的对象信息中重建会很困难或很慢。因此,一种简化索引处理的方法是,以消耗更多内存为代价,除了代表索引的有序集合外,还维护一个将对象 ID 映射到当前索引值的哈希表。
因此,例如,当我们进行索引时,我们还向一个哈希表添加内容:
这并非总是必要的,但它简化了更新索引的操作。为了移除我们为对象 ID 90 索引的旧信息,而不管对象的*当前*字段值如何,我们只需通过对象 ID 检索哈希值,并在有序集合视图中用 `ZREM` 将其移除。
#
使用六分存储(Hexastore)表示和查询图复合索引一个很酷的地方是,它们很适合用来表示图,使用一种叫做 六分存储 的数据结构。
六分存储提供了一种表示对象之间关系的方法,这种关系由一个*主语*、一个*谓语*和一个*宾语*组成。一个简单的对象间关系可以是:
为了表示这种关系,我可以在我的字典序索引中存储以下元素:
注意,我用字符串 **spo** 作为我的项目前缀。它表示该项目代表一个主语、谓语、宾语的关系。
我可以为同一关系添加另外 5 个条目,但顺序不同:
现在事情开始变得有趣了,我可以用许多不同的方式查询图。例如,`john` 的所有朋友是谁?
或者,`john` 和 `matteocollina` 之间有哪些关系,其中前者是主语,后者是宾语?
通过组合不同的查询,我可以提出一些复杂的问题。例如:*我所有喜欢啤酒、住在巴塞罗那、并且也被 matteocollina 认为是朋友的朋友是谁?* 为了获取这些信息,我首先用一个 `spo` 查询找到我所有的朋友。然后对得到的每个结果,我执行一个 `spo` 查询来检查他们是否喜欢啤酒,移除那些我找不到这种关系的人。我再做一次来按城市过滤。最后,我执行一个 `ops` 查询,在我得到的列表中找出谁被 matteocollina 认为是朋友。
确保查看 Matteo Collina 关于 Levelgraph 的幻灯片,以更好地理解这些想法。
#
多维索引一种更复杂的索引类型是允许您同时对两个或多个变量的特定范围进行查询的索引。例如,我可能有一个表示人的年龄和薪水的数据集,我想要检索所有年龄在 50 到 55 岁之间且薪水在 70000 到 85000 之间的人。
这种查询可以用多列索引来执行,但这需要我们先选择第一个变量,然后扫描第二个变量,这意味着我们可能做了很多不必要的工作。可以使用不同的数据结构来执行涉及多个变量的这类查询。例如,有时会使用多维树,如 *k-d 树* 或 *r-树*。这里我们将描述一种不同的方法来索引多维数据,使用一种表示技巧,使我们能够使用 KeyDB 的字典序范围非常高效地执行查询。
让我们从可视化问题开始。在这张图片中,我们在空间中有一些点,这些点代表我们的数据样本,其中 `x` 和 `y` 是我们的坐标。两个变量的最大值都是 400。
图片中的蓝色框代表我们的查询。我们想要所有 `x` 在 50 到 100 之间,且 `y` 在 100 到 300 之间的点。
为了表示能让这类查询快速执行的数据,我们首先用 0 填充我们的数字。例如,想象我们想在索引中添加点 (10, 25) (x, y)。鉴于示例中的最大范围是 400,我们可以只填充到三位数,于是我们得到:
现在我们要做的是交错这些数字,取 x 的最左边的数字,和 y 的最左边的数字,以此类推,来创建一个单一的数字。
这是我们的索引,然而,为了更容易地重建原始表示(如果我们想要的话,以牺牲空间为代价),我们也可以将原始值作为额外的列添加进来。
现在,让我们来思考一下这种表示方法,以及为什么它在范围查询的上下文中很有用。例如,让我们取蓝色框的中心,它在 `x=75` 和 `y=200`。我们可以像之前那样通过交错数字来编码这个数,得到:
如果我们将最后两位数字分别替换为 00 和 99 会发生什么?我们会得到一个字典序上连续的范围。
这对应一个正方形,代表所有 `x` 变量在 70 到 79 之间,且 `y` 变量在 200 到 209 之间的值。我们可以在这个区间内写入随机点,来标识这个特定的区域。
所以,上述字典序查询使我们能够轻松地查询图片中特定正方形内的点。然而,这个正方形对于我们正在搜索的框来说可能太小了,以至于需要太多的查询。所以我们可以做同样的事情,但不是用 00 和 99 替换最后两位数字,而是对最后四位数字这样做,得到以下范围:
这一次,这个范围代表了所有 `x` 在 0 到 99 之间,`y` 在 200 到 299 之间的点。在这个区间内绘制随机点,向我们展示了这个更大的区域。
糟糕,现在我们的区域对于我们的查询来说太大了,而且我们的搜索框仍然没有被完全包含。我们需要更细的粒度,但我们可以通过用二进制形式表示我们的数字来轻松实现。这一次,当我们替换数字时,我们得到的不是十倍大的正方形,而是两倍大的正方形。
我们的数字以二进制形式表示,假设每个变量只需要 9 位(为了表示高达 400 的值),将会是:
所以,通过交错数字,我们在索引中的表示将是:
让我们看看,当我们在交错表示中用 0 和 1 替换最后 2、4、6、8...位时,我们的范围是什么样的:
以此类推。现在我们有了更好的粒度!如您所见,从索引中替换 N 位会给我们边长为 `2^(N/2)` 的搜索框。
所以我们要做的是检查我们的搜索框在哪个维度上更小,并检查最接近这个数字的 2 的幂。我们的搜索框是 50,100 到 100,300,所以它的宽度是 50,高度是 200。我们取两者中较小的 50,并检查最接近的 2 的幂,即 64。64 是 2^6,所以我们将处理通过从交错表示中替换最后 12 位(这样我们最终只替换了每个变量的 6 位)获得的索引。
然而,单个方块可能无法覆盖我们所有的搜索范围,所以我们可能需要更多。我们要做的是,从我们搜索框的左下角(50,100)开始,通过将每个数字的最后 6 位替换为 0 来找到第一个范围。然后我们对右上角也做同样的操作。
通过两个简单的嵌套 for 循环,我们只增加有效位,就可以找到这两个点之间的所有方块。对于每个方块,我们将两个数字转换为我们的交错表示,并使用转换后的表示作为我们的起始,以及使用相同的表示但最后 12 位都为 1 的作为结束范围来创建范围。
对于找到的每个方块,我们执行我们的查询并获取内部的元素,移除那些在我们搜索框之外的元素。
将此转化为代码很简单。这是一个 Ruby 示例:
虽然不是马上就能理解,但这是一种非常有用的索引策略,未来可能会以原生方式在 KeyDB 中实现。目前,好处是其复杂性可以很容易地封装在一个库中,用于执行索引和查询。
#
包含负数或浮点数的多维索引表示负值的最简单方法是只使用无符号整数,并通过一个偏移量来表示它们。这样,在您进行索引时,在将数字转换为索引表示之前,您需要加上最小负整数的绝对值。
对于浮点数,最简单的方法可能是将它们转换为整数,方法是将整数乘以一个与您希望保留的小数点后位数成比例的 10 的幂。
#
非范围索引到目前为止,我们检查了对按范围或按单个项目查询有用的索引。然而,其他 KeyDB 数据结构,如集合(Sets)或列表(Lists),可以用来构建其他类型的索引。它们非常常用,但我们可能并不总是意识到它们实际上是一种索引形式。
例如,我可以将对象 ID 索引到集合数据类型中,以便通过 `SRANDMEMBER` 使用*获取随机元素*操作来检索一组随机对象。当所有我需要做的只是测试某个给定项目是否存在,或者是否具有单个布尔属性时,也可以使用集合来检查存在性。
同样,列表可以用来将项目索引成一个固定的顺序。我可以将我所有的项目添加到一个 KeyDB 列表中,并使用与源和目标相同的键名,用 `RPOPLPUSH` 旋转该列表。这在我想以相同的顺序永远反复处理一组给定的项目时很有用。想想一个需要定期刷新本地副本的 RSS feed 系统。
KeyDB 中经常使用的另一种流行索引是**有上限的列表(capped list)**,其中项目用 `LPUSH` 添加,并用 `LTRIM` 进行修剪,以创建一个只包含最新遇到的 N 个项目的视图,其顺序与它们被看到时的顺序相同。
#
索引不一致保持索引更新可能具有挑战性,在数月或数年内,由于软件错误、网络分区或其他事件,可能会出现不一致的情况。
可以采用不同的策略。如果索引数据在 KeyDB 外部,*读修复*可以是一个解决方案,即在请求数据时以惰性方式修复数据。当我们索引存储在 KeyDB 本身的数据时,可以使用 `SCAN` 系列命令来增量地验证、更新或从头重建索引。