跳转到主要内容

数据类型和抽象简介

KeyDB 不是一个简单的键值存储,它实际上是一个数据结构服务器,支持不同类型的值。这意味着,在传统的键值存储中,您将字符串键与字符串值关联,而在 KeyDB 中,值不限于简单的字符串,还可以容纳更复杂的数据结构。以下是 KeyDB 支持的所有数据结构的列表,本教程将分别介绍它们:

  • 二进制安全的字符串。
  • 列表(Lists):根据插入顺序排序的字符串元素集合。它们基本上是链表
  • 集合(Sets):唯一、无序的字符串元素集合。
  • 有序集合(Sorted sets),类似于集合,但每个字符串元素都与一个称为分数的浮点数值相关联。元素总是按其分数排序,因此与集合不同,可以检索元素的范围(例如,您可以问:给我前 10 名,或后 10 名)。
  • 哈希(Hashes),是由与值相关联的字段组成的映射。字段和值都是字符串。这与 Ruby 或 Python 的哈希非常相似。
  • 位数组(或简称位图):通过使用特殊命令,可以将字符串值像位数组一样处理:您可以设置和清除单个位,计算所有设置为 1 的位,找到第一个设置或未设置的位,等等。
  • HyperLogLogs:这是一种概率性数据结构,用于估算集合的基数。别害怕,它比看起来简单……请参阅本教程后面的 HyperLogLog 部分。
  • 流(Streams):仅追加的类映射条目集合,提供抽象的日志数据类型。在KeyDB 流简介中有深入介绍。

命令参考中理解这些数据类型如何工作以及如何使用它们来解决特定问题并不总是那么容易,因此本文档是关于 KeyDB 数据类型及其最常见模式的速成课程。

对于所有示例,我们将使用 keydb-cli 实用程序,这是一个简单但方便的命令行工具,用于向 KeyDB 服务器发出命令。

KeyDB 键#

KeyDB 键是二进制安全的,这意味着您可以使用任何二进制序列作为键,从像 "foo" 这样的字符串到 JPEG 文件的内容。空字符串也是一个有效的键。

关于键的其他一些规则:

  • 非常长的键不是一个好主意。例如,一个 1024 字节的键不仅在内存方面是个坏主意,而且因为在数据集中查找该键可能需要多次昂贵的键比较。即使任务是匹配一个大值的存在,对其进行哈希处理(例如使用 SHA1)也是一个更好的主意,特别是从内存和带宽的角度来看。
  • 非常短的键通常也不是一个好主意。如果您可以写 "user:1000:followers" 而不是 "u1000flw" 作为键,那么写后者几乎没有意义。前者更具可读性,增加的空间与键对象本身和值对象使用的空间相比是微不足道的。虽然短键显然会消耗更少的内存,但您的工作是找到正确的平衡点。
  • 尽量遵循一种模式。例如,“object-type:id”是一个好主意,如“user:1000”。点或破折号通常用于多词字段,如“comment🔢reply.to”或“comment🔢reply-to”。
  • 允许的最大键大小为 512 MB。

KeyDB 字符串#

KeyDB 字符串类型是您可以与 KeyDB 键关联的最简单类型的值。它是 Memcached 中唯一的数据类型,因此对于新手来说在 KeyDB 中使用它也非常自然。

由于 KeyDB 键是字符串,当我们也使用字符串类型作为值时,我们正在将一个字符串映射到另一个字符串。字符串数据类型对于许多用例都很有用,例如缓存 HTML 片段或页面。

让我们使用 keydb-cli 来玩一下字符串类型(本教程中的所有示例都将通过 keydb-cli 执行)。

> set mykey somevalue
OK
> get mykey
"somevalue"

正如您所看到的,使用 SETGET 命令是我们设置和检索字符串值的方式。请注意,如果键已经存在,SET 将替换已存储在该键中的任何现有值,即使该键与非字符串值相关联。所以 SET 执行的是赋值操作。

值可以是任何类型的字符串(包括二进制数据),例如,您可以将 jpeg 图像存储在值中。一个值不能大于 512 MB。

SET 命令有一些有趣的选项,它们作为附加参数提供。例如,我可以要求 SET 在键已存在时失败,或者相反,仅在键已存在时才成功:

> set mykey newval nx
(nil)
> set mykey newval xx
OK

尽管字符串是 KeyDB 的基本值,但您仍可以对它们执行有趣的操作。例如,原子增量:

> set counter 100
OK
> incr counter
(integer) 101
> incr counter
(integer) 102
> incrby counter 50
(integer) 152

INCR 命令将字符串值解析为整数,将其加一,最后将获得的值设置为新值。还有其他类似的命令,如 INCRBYDECRDECRBY。在内部,它们始终是同一个命令,只是行为略有不同。

INCR 是原子性的意味着什么?这意味着即使多个客户端对同一个键发出 INCR,也绝不会进入竞争状态。例如,绝不会发生客户端 1 读取“10”,客户端 2 同时读取“10”,两者都递增到 11,并将新值设置为 11。最终的值将始终是 12,并且读-增-写操作在所有其他客户端同时不执行命令时执行。

有许多用于操作字符串的命令。例如,GETSET 命令将一个键设置为新值,并返回旧值作为结果。例如,如果您的系统每次网站有新访问者时都使用 INCR 递增一个 KeyDB 键,您可能希望每小时收集一次此信息,而不会丢失任何一次递增。您可以 GETSET 该键,将其新值赋为“0”并读回旧值。

在单个命令中设置或检索多个键的值的能力对于降低延迟也很有用。因此,有 MSETMGET 命令:

> mset a 10 b 20 c 30
OK
> mget a b c
1) "10"
2) "20"
3) "30"

当使用 MGET 时,KeyDB 返回一个值数组。

更改和查询键空间#

有些命令不是针对特定类型定义的,但对于与键空间进行交互很有用,因此可以与任何类型的键一起使用。

例如,EXISTS 命令返回 1 或 0,以表示给定键是否存在于数据库中,而 DEL 命令删除一个键及其关联的值,无论该值是什么。

> set mykey hello
OK
> exists mykey
(integer) 1
> del mykey
(integer) 1
> exists mykey
(整数) 0

从示例中您还可以看到,DEL 本身会根据键是否被移除(它存在)或未被移除(没有该名称的键)而返回 1 或 0。

有许多与键空间相关的命令,但以上两个是必不可少的,连同 TYPE 命令,它返回指定键存储的值的类型:

> set mykey x
OK
> type mykey
string
> del mykey
(integer) 1
> type mykey
none

KeyDB 过期:具有有限生存时间的键#

在继续介绍更复杂的数据结构之前,我们需要讨论另一个不依赖于值类型的功能,称为KeyDB 过期。基本上,您可以为一个键设置一个超时,这是一个有限的生存时间(time to live)。当生存时间过去后,该键会自动被销毁,就像用户使用该键调用 DEL 命令一样。

关于 KeyDB 过期的一些快速信息:

  • 它们可以以秒或毫秒的精度设置。
  • 然而,过期时间的解析度始终为 1 毫秒。
  • 有关过期的信息会被复制并持久化到磁盘上,当您的 KeyDB 服务器停止时,时间实际上仍在流逝(这意味着 KeyDB 会保存键将过期的日期)。

设置过期非常简单:

> set key some-value
OK
> expire key 5
(integer) 1
> get key (立即)
"some-value"
> get key (一段时间后)
(nil)

在两次 GET 调用之间,键消失了,因为第二次调用的延迟超过了 5 秒。在上面的例子中,我们使用 EXPIRE 来设置过期时间(它也可以用来为一个已经有过期时间的键设置不同的过期时间,就像 PERSIST 可以用来移除过期时间并使键永久存在一样)。然而,我们也可以使用其他 KeyDB 命令来创建带有过期时间的键。例如,使用 SET 选项:

> set key 100 ex 10
OK
> ttl key
(integer) 9

上面的例子设置了一个字符串值为 100 的键,其过期时间为十秒。之后调用 TTL 命令来检查该键的剩余生存时间。

要以毫秒为单位设置和检查过期时间,请查看 PEXPIREPTTL 命令,以及 SET 选项的完整列表。

KeyDB 列表#

为了解释列表数据类型,最好从一点理论开始,因为“列表”这个词经常被信息技术人员不恰当地使用。例如,“Python 列表”并非其名称所暗示的(链表),而是数组(在 Ruby 中,相同的数据类型实际上被称为数组)。

从一个非常普遍的角度来看,列表只是一系列有序的元素:10,20,1,2,3 是一个列表。但是使用数组实现的列表的属性与使用链表实现的列表的属性非常不同。

KeyDB 列表是通过链表实现的。这意味着即使列表内有数百万个元素,在列表的头部或尾部添加新元素的操作也是在常数时间内完成的。使用 LPUSH 命令向一个有十个元素的列表头部添加新元素的速度,与向一个有一千万个元素的列表头部添加元素的速度是相同的。

缺点是什么?在用数组实现的列表中,通过索引访问元素非常快(常数时间索引访问),而在用链表实现的列表中则不那么快(其中操作需要与被访问元素的索引成比例的工作量)。

KeyDB 列表是用链表实现的,因为对于一个数据库系统来说,能够以非常快的方式向一个非常长的列表添加元素至关重要。另一个强大的优势,正如你稍后会看到的,是 KeyDB 列表可以在常数时间内被截取为常数长度。

当快速访问大量元素集合的中间部分很重要时,可以使用一种不同的数据结构,称为有序集合。本教程稍后将介绍有序集合。

KeyDB 列表入门#

LPUSH 命令将一个新元素添加到列表的左侧(头部),而 RPUSH 命令将一个新元素添加到列表的右侧(尾部)。最后,LRANGE 命令从列表中提取元素范围。

> rpush mylist A
(integer) 1
> rpush mylist B
(整数) 2
> lpush mylist first
(整数) 3
> lrange mylist 0 -1
1) "first"
2) "A"
3) "B"

请注意,LRANGE 接受两个索引,即要返回范围的第一个和最后一个元素。两个索引都可以是负数,告诉 KeyDB 从末尾开始计数:所以 -1 是最后一个元素,-2 是列表的倒数第二个元素,依此类推。

如您所见,RPUSH 将元素附加到列表的右侧,而最后的 LPUSH 将元素附加到左侧。

这两个命令都是可变参数命令,这意味着您可以在一次调用中自由地将多个元素推入列表:

> rpush mylist 1 2 3 4 5 "foo bar"
(integer) 9
> lrange mylist 0 -1
1) "first"
2) "A"
3) "B"
4) "1"
5) "2"
6) "3"
7) "4"
8) "5"
9) "foo bar"

KeyDB 列表上定义的一个重要操作是能够弹出元素。弹出元素是指从列表中检索元素并同时将其从列表中删除的操作。您可以从左侧和右侧弹出元素,类似于您可以在列表的两侧推入元素:

> rpush mylist a b c
(整数) 3
> rpop mylist
"c"
> rpop mylist
"b"
> rpop mylist
"a"

我们添加了三个元素并弹出了三个元素,因此在这系列命令结束时,列表是空的,没有更多元素可以弹出。如果我们尝试再弹出一个元素,得到的结果是:

> rpop mylist
(nil)

KeyDB 返回一个 NULL 值,表示列表中没有元素。

列表的常见用例#

列表在许多任务中都很有用,两个非常有代表性的用例是:

  • 记住用户在社交网络中发布的最新更新。
  • 使用消费者-生产者模式进行进程间通信,其中生产者将项目推入列表,而消费者(通常是工作者)消费这些项目并执行操作。KeyDB 有专门的列表命令,使这种用例更可靠、更高效。

例如,流行的 Ruby 库 resquesidekiq 在底层使用 KeyDB 列表来实现后台作业。

流行的社交网络 Twitter 将用户发布的最新推文存入 KeyDB 列表中。

为了逐步描述一个常见的用例,想象一下您的主页显示了照片分享社交网络上发布的最新照片,您希望加快访问速度。

  • 每当用户发布新照片时,我们都使用 LPUSH 将其 ID 添加到列表中。
  • 当用户访问主页时,我们使用 LRANGE 0 9 来获取最新发布的 10 个项目。

有上限的列表#

在许多用例中,我们只想用列表来存储最新的项目,无论它们是什么:社交网络更新、日志或其他任何东西。

KeyDB 允许我们使用列表作为有上限的集合,只记住最新的 N 个项目,并使用 LTRIM 命令丢弃所有最旧的项目。

LTRIM 命令与 LRANGE 类似,但不是显示指定范围的元素,而是将此范围设置为新的列表值。所有给定范围之外的元素都会被移除。

一个例子会让它更清楚:

> rpush mylist 1 2 3 4 5
(整数) 5
> ltrim mylist 0 2
OK
> lrange mylist 0 -1
1) "1"
2) "2"
3) "3"

上面的 LTRIM 命令告诉 KeyDB 只保留从索引 0 到 2 的列表元素,其他所有元素都将被丢弃。这允许一个非常简单但有用的模式:将列表推入操作和列表修剪操作结合在一起,以添加一个新元素并丢弃超过限制的元素。

LPUSH mylist <某个元素>
LTRIM mylist 0 999

上面的组合添加了一个新元素,并只保留列表中最新的 1000 个元素。使用 LRANGE,您可以访问顶部的项目,而无需记住非常旧的数据。

注意:虽然 LRANGE 技术上是一个 O(N) 命令,但访问列表头部或尾部的小范围是一个常数时间操作。

列表上的阻塞操作#

列表有一个特殊功能,使它们适合实现队列,并且通常作为进程间通信系统的构建块:阻塞操作。

想象一下,你想用一个进程向一个列表推入项目,并用另一个进程来实际处理这些项目。这是通常的生产者/消费者设置,可以通过以下简单方式实现:

  • 要将项目推入列表,生产者调用 LPUSH
  • 要从列表中提取/处理项目,消费者调用 RPOP

然而,有时列表可能是空的,没有什么可处理的,所以 RPOP 只返回 NULL。在这种情况下,消费者被迫等待一段时间,然后再次使用 RPOP 重试。这被称为轮询,在这种情况下不是一个好主意,因为它有几个缺点:

  1. 强制 KeyDB 和客户端处理无用的命令(当列表为空时,所有请求都不会完成实际工作,它们只会返回 NULL)。
  2. 增加了项目处理的延迟,因为工作者收到 NULL 后,会等待一段时间。为了减少延迟,我们可以在调用 RPOP 之间等待更少的时间,但这会加剧问题 1,即对 KeyDB 的无用调用更多。

因此,KeyDB 实现了名为 BRPOPBLPOP 的命令,它们是 RPOPLPOP 的版本,能够在列表为空时阻塞:它们只有在新元素被添加到列表或达到用户指定的超时时才会返回给调用者。

这是一个我们可以在工作者中使用的 BRPOP 调用示例:

> brpop tasks 5
1) "tasks"
2) "do_something"

它的意思是:“等待列表 tasks 中的元素,但如果 5 秒后没有可用元素则返回”。

请注意,您可以使用 0 作为超时来永远等待元素,并且您还可以指定多个列表而不仅仅是一个,以便同时等待多个列表,并在第一个列表接收到元素时得到通知。

关于 BRPOP 需要注意的几点:

  1. 客户端按顺序服务:第一个阻塞等待列表的客户端,在某个其他客户端推送元素时,会首先被服务,依此类推。
  2. 返回值与 RPOP 不同:它是一个双元素数组,因为它还包括键的名称,因为 BRPOPBLPOP 能够阻塞等待来自多个列表的元素。
  3. 如果达到超时,则返回 NULL。

关于列表和阻塞操作,您还需要了解更多。我们建议您阅读以下内容:

  • 可以使用 RPOPLPUSH 构建更安全的队列或循环队列。
  • 该命令还有一个阻塞变体,称为 BRPOPLPUSH

键的自动创建和删除#

到目前为止,在我们的示例中,我们从未需要在推送元素之前创建空列表,或者在列表不再有元素时删除空列表。当列表变空时删除键,或者如果键不存在并且我们尝试向其添加元素(例如,使用 LPUSH)时创建空列表,这是 KeyDB 的责任。

这并非列表所特有,它适用于所有由多个元素组成的 KeyDB 数据类型——流、集合、有序集合和哈希。

基本上,我们可以用三条规则来概括这种行为:

  1. 当我们向聚合数据类型添加元素时,如果目标键不存在,则在添加元素之前会创建一个空的聚合数据类型。
  2. 当我们从聚合数据类型中删除元素时,如果值变为空,键会自动被销毁。流数据类型是此规则的唯一例外。
  3. 调用只读命令(如 LLEN,返回列表长度)或删除元素的写命令,对一个空键进行操作,其结果总是与该键持有一个命令期望找到的类型的空聚合类型相同。

规则 1 的示例:

> del mylist
(integer) 1
> lpush mylist 1 2 3
(整数) 3

但是,如果键存在,我们不能对其执行错误类型的操作:

> set foo bar
OK
> lpush foo 1 2 3
(error) WRONGTYPE Operation against a key holding the wrong kind of value
> type foo
string

规则 2 的示例:

> lpush mylist 1 2 3
(整数) 3
> exists mylist
(integer) 1
> lpop mylist
"3"
> lpop mylist
"2"
> lpop mylist
"1"
> exists mylist
(整数) 0

在所有元素被弹出后,键不再存在。

规则 3 的示例:

> del mylist
(整数) 0
> llen mylist
(整数) 0
> lpop mylist
(nil)

KeyDB 哈希#

KeyDB 哈希看起来正像人们所期望的“哈希”的样子,带有字段-值对:

> hmset user:1000 username john birthyear 1977 verified 1
OK
> hget user:1000 username
"john"
> hget user:1000 birthyear
"1977"
> hgetall user:1000
1) "username"
2) "john"
3) "birthyear"
4) "1977"
5) "verified"
6) "1"

虽然哈希很方便用来表示对象,但实际上你可以在一个哈希中放入的字段数量没有实际限制(除了可用内存),所以你可以在你的应用程序中以多种不同的方式使用哈希。

HMSET 命令设置哈希的多个字段,而 HGET 检索单个字段。HMGETHGET 类似,但返回一个值数组:

> hmget user:1000 username birthyear no-such-field
1) "john"
2) "1977"
3) (nil)

还有一些命令能够对单个字段执行操作,比如 HINCRBY

> hincrby user:1000 birthyear 10
(integer) 1987
> hincrby user:1000 birthyear 10
(integer) 1997

值得注意的是,小哈希(即,少数元素具有小值)在内存中以特殊方式编码,使其非常节省内存。

KeyDB 集合#

KeyDB 集合是无序的字符串集合。SADD 命令向集合中添加新元素。还可以对集合进行许多其他操作,例如测试给定元素是否已存在,执行多个集合之间的交集、并集或差集等。

> sadd myset 1 2 3
(整数) 3
> smembers myset
1. 3
2. 1
3. 2

这里我向我的集合添加了三个元素,并告诉 KeyDB 返回所有元素。如您所见,它们没有排序——KeyDB 可以在每次调用时以任何顺序返回元素,因为没有关于元素排序的用户协议。

KeyDB 有用于测试成员资格的命令。例如,检查一个元素是否存在:

> sismember myset 3
(integer) 1
> sismember myset 30
(整数) 0

“3”是集合的成员,而“30”不是。

集合很适合用来表示对象之间的关系。例如,我们可以很容易地使用集合来实现标签功能。

解决此问题的一个简单方法是为我们想要标记的每个对象创建一个集合。该集合包含与对象关联的标签的 ID。

一个例子是给新闻文章打标签。如果文章 ID 1000 被标记为标签 1、2、5 和 77,一个集合可以将这些标签 ID 与新闻条目关联起来:

> sadd news:1000:tags 1 2 5 77
(整数) 4

我们可能还想有反向关系:所有带有给定标签的新闻列表。

> sadd tag:1:news 1000
(integer) 1
> sadd tag:2:news 1000
(integer) 1
> sadd tag:5:news 1000
(integer) 1
> sadd tag:77:news 1000
(integer) 1

获取给定对象的所有标签非常简单:

> smembers news:1000:tags
1. 5
2. 1
3. 77
4. 2

注意:在示例中,我们假设您有另一个数据结构,例如 KeyDB 哈希,它将标签 ID 映射到标签名称。

还有一些其他非凡的操作,使用正确的 KeyDB 命令仍然很容易实现。例如,我们可能想要一个包含标签 1、2、10 和 27 的所有对象的列表。我们可以使用 SINTER 命令来完成,它执行不同集合之间的交集。我们可以使用:

> sinter tag:1:news tag:2:news tag:10:news tag:27:news
... 结果在这里 ...

除了交集,您还可以执行并集、差集、提取随机元素等等。

提取元素的命令叫做 SPOP,它很方便用来模拟某些问题。例如,为了实现一个基于网络的扑克游戏,你可能想用一个集合来表示你的牌堆。想象我们用一个单字符前缀来表示(C)梅花、(D)方块、(H)红心、(S)黑桃:

> sadd deck C1 C2 C3 C4 C5 C6 C7 C8 C9 C10 CJ CQ CK
D1 D2 D3 D4 D5 D6 D7 D8 D9 D10 DJ DQ DK H1 H2 H3
H4 H5 H6 H7 H8 H9 H10 HJ HQ HK S1 S2 S3 S4 S5 S6
S7 S8 S9 S10 SJ SQ SK
(integer) 52

现在我们想给每个玩家发5张牌。SPOP 命令会随机移除一个元素并返回给客户端,所以在这个场景下它是完美的操作。

然而,如果我们直接对我们的牌堆调用它,在下一局游戏中,我们将需要再次填充牌堆,这可能并不理想。因此,首先,我们可以将存储在 deck 键中的集合复制到 game:1:deck 键中。

这是通过 SUNIONSTORE 完成的,它通常执行多个集合之间的并集,并将结果存储到另一个集合中。但是,由于单个集合的并集是其本身,我可以用以下方式复制我的牌堆:

> sunionstore game:1:deck deck
(integer) 52

现在我准备好给第一个玩家发五张牌了:

> spop game:1:deck
"C6"
> spop game:1:deck
"CQ"
> spop game:1:deck
"D1"
> spop game:1:deck
"CJ"
> spop game:1:deck
"SJ"

一对J,不太好……

现在是介绍一个集合命令的好时机,这个命令可以提供集合中元素的数量。在集合论的语境中,这通常被称为集合的基数,所以 KeyDB 命令被称为 SCARD

> scard game:1:deck
(integer) 47

数学计算正确:52 - 5 = 47。

当您只需要获取随机元素而不从集合中移除它们时,有 SRANDMEMBER 命令适合该任务。它还具有返回重复和非重复元素的能力。

KeyDB 有序集合#

有序集合是一种类似于集合和哈希混合的数据类型。像集合一样,有序集合由唯一的、不重复的字符串元素组成,因此在某种意义上,有序集合也是一个集合。

然而,虽然集合内的元素是无序的,但有序集合中的每个元素都与一个浮点值相关联,称为分数(这就是为什么该类型也类似于哈希,因为每个元素都映射到一个值)。

此外,有序集合中的元素是按顺序排列的(因此它们不是按需排序,顺序是表示有序集合的数据结构的特性)。它们根据以下规则排序:

  • 如果 A 和 B 是两个分数不同的元素,那么如果 A.score > B.score,则 A > B。
  • 如果 A 和 B 的分数完全相同,那么如果 A 字符串在字典序上大于 B 字符串,则 A > B。A 和 B 字符串不能相等,因为有序集合只包含唯一元素。

让我们从一个简单的例子开始,将一些选定的黑客名字作为有序集合的元素添加进去,以他们的出生年份作为“分数”。

> zadd hackers 1940 "Alan Kay"
(integer) 1
> zadd hackers 1957 "Sophie Wilson"
(integer) 1
> zadd hackers 1953 "Richard Stallman"
(integer) 1
> zadd hackers 1949 "Anita Borg"
(integer) 1
> zadd hackers 1965 "Yukihiro Matsumoto"
(integer) 1
> zadd hackers 1914 "Hedy Lamarr"
(integer) 1
> zadd hackers 1916 "Claude Shannon"
(integer) 1
> zadd hackers 1969 "Linus Torvalds"
(integer) 1
> zadd hackers 1912 "Alan Turing"
(integer) 1

如您所见,ZADD 类似于 SADD,但它多接受一个参数(放在要添加的元素之前),即分数。ZADD 也是可变参数的,所以您可以自由指定多个分数-值对,尽管在上面的示例中没有使用。

使用有序集合,返回按出生年份排序的黑客列表非常简单,因为实际上它们已经排好序了

实现说明:有序集合是通过一个包含跳表和哈希表的双端口数据结构实现的,所以每次我们添加一个元素,KeyDB 都会执行一个 O(log(N)) 操作。这很好,但是当我们请求排序的元素时,KeyDB 根本不需要做任何工作,它们已经全部排序好了:

> zrange hackers 0 -1
1) "Alan Turing"
2) "Hedy Lamarr"
3) "Claude Shannon"
4) "Alan Kay"
5) "Anita Borg"
6) "Richard Stallman"
7) "Sophie Wilson"
8) "Yukihiro Matsumoto"
9) "Linus Torvalds"

注意:0 和 -1 表示从元素索引 0 到最后一个元素(-1 在这里的作用与 LRANGE 命令中的一样)。

如果我想以相反的顺序排列它们,从最年轻到最年长呢?使用 ZREVRANGE 而不是 ZRANGE

> zrevrange hackers 0 -1
1) "Linus Torvalds"
2) "Yukihiro Matsumoto"
3) "Sophie Wilson"
4) "Richard Stallman"
5) "Anita Borg"
6) "Alan Kay"
7) "Claude Shannon"
8) "Hedy Lamarr"
9) "Alan Turing"

也可以返回分数,使用 WITHSCORES 参数:

> zrange hackers 0 -1 withscores
1) "Alan Turing"
2) "1912"
3) "Hedy Lamarr"
4) "1914"
5) "Claude Shannon"
6) "1916"
7) "Alan Kay"
8) "1940"
9) "Anita Borg"
10) "1949"
11) "Richard Stallman"
12) "1953"
13) "Sophie Wilson"
14) "1957"
15) "Yukihiro Matsumoto"
16) "1965"
17) "Linus Torvalds"
18) "1969"

操作范围#

有序集合的功能比这更强大。它们可以对范围进行操作。让我们获取所有在 1950 年及以前出生的个人。我们使用 ZRANGEBYSCORE 命令来做到这一点:

> zrangebyscore hackers -inf 1950
1) "Alan Turing"
2) "Hedy Lamarr"
3) "Claude Shannon"
4) "Alan Kay"
5) "Anita Borg"

我们要求 KeyDB 返回所有分数在负无穷和 1950 之间的元素(两个端点都包括在内)。

也可以删除元素范围。让我们从有序集合中删除所有在 1940 年到 1960 年之间出生的黑客:

> zremrangebyscore hackers 1940 1960
(整数) 4

ZREMRANGEBYSCORE 可能不是最好的命令名,但它非常有用,并返回被删除元素的数量。

为有序集合元素定义的另一个极其有用的操作是获取排名操作。可以询问一个元素在有序元素集合中的位置。

> zrank hackers "Anita Borg"
(整数) 4

ZREVRANK 命令也可用,用于获取排名,但考虑的是元素按降序排列。

字典序分数#

KeyDB 允许按字典序获取范围,前提是假设有序集合中的所有元素都以完全相同的分数插入(元素使用 C 的 memcmp 函数进行比较,因此保证没有排序规则,每个 KeyDB 实例都会返回相同的输出)。

用于操作字典序范围的主要命令是 ZRANGEBYLEXZREVRANGEBYLEXZREMRANGEBYLEXZLEXCOUNT

例如,让我们再次添加我们的著名黑客列表,但这次对所有元素使用零分:

> zadd hackers 0 "Alan Kay" 0 "Sophie Wilson" 0 "Richard Stallman" 0
"Anita Borg" 0 "Yukihiro Matsumoto" 0 "Hedy Lamarr" 0 "Claude Shannon"
0 "Linus Torvalds" 0 "Alan Turing"

由于有序集合的排序规则,它们已经按字典序排序:

> zrange hackers 0 -1
1) "Alan Kay"
2) "Alan Turing"
3) "Anita Borg"
4) "Claude Shannon"
5) "Hedy Lamarr"
6) "Linus Torvalds"
7) "Richard Stallman"
8) "Sophie Wilson"
9) "Yukihiro Matsumoto"

使用 ZRANGEBYLEX 我们可以请求字典序范围:

> zrangebylex hackers [B [P
1) "Claude Shannon"
2) "Hedy Lamarr"
3) "Linus Torvalds"

范围可以是包含的或不包含的(取决于第一个字符),字符串无穷大和负无穷大分别用 +- 字符串指定。更多信息请参阅文档。

这个功能很重要,因为它允许我们使用有序集合作为通用索引。例如,如果你想通过一个128位无符号整数参数来索引元素,你所需要做的就是将元素添加到有序集合中,并使用相同的分数(例如0),但在元素前加上一个由大端序的128位数字组成的16字节前缀。因为大端序的数字在按字典序(原始字节顺序)排序时,实际上也是按数字顺序排序的,所以你可以在128位空间中请求范围,并获取元素的值,同时丢弃前缀。

更新分数:排行榜#

在切换到下一个主题之前,关于有序集合的最后一点说明。有序集合的分数可以随时更新。只需对有序集合中已包含的元素调用 ZADD,就会以 O(log(N)) 的时间复杂度更新其分数(和位置)。因此,当有大量更新时,有序集合是合适的。

由于这个特性,一个常见的用例是排行榜。典型的应用是一个 Facebook 游戏,您将按高分排序用户的功能与获取排名操作相结合,以显示前 N 名用户,以及用户在排行榜中的排名(例如,“您在这里是第 4932 名”)。

位图#

位图不是一个实际的数据类型,而是一组定义在字符串类型上的面向位的操作。由于字符串是二进制安全的二进制大对象,并且其最大长度为 512 MB,因此它们适合设置多达 2^32 个不同的位。

位操作分为两组:恒定时间的单比特操作,如将一个比特设置为 1 或 0,或获取其值;以及对比特组的操作,例如计算给定比特范围内设置的比特数(例如,群体计数)。

位图最大的优点之一是,它们在存储信息时通常能极大地节省空间。例如,在一个用递增用户ID表示不同用户的系统中,仅用 512 MB 的内存就可以记住 40 亿用户的一个比特信息(例如,知道一个用户是否想接收新闻通讯)。

位是使用 SETBITGETBIT 命令来设置和检索的:

> setbit key 10 1
(integer) 1
> getbit key 10
(integer) 1
> getbit key 11
(整数) 0

SETBIT 命令的第一个参数是位数,第二个参数是设置该位的值,即 1 或 0。如果寻址的位超出了当前字符串的长度,该命令会自动扩大字符串。

GETBIT 仅返回指定索引处位的值。超出范围的位(寻址存储在目标键中的字符串长度之外的位)始终被认为是零。

有三个命令作用于位组:

  1. BITOP 在不同字符串之间执行位运算。提供的操作有 AND、OR、XOR 和 NOT。
  2. BITCOUNT 执行群体计数,报告设置为 1 的位数。
  3. BITPOS 找到第一个具有指定值 0 或 1 的位。

BITPOSBITCOUNT 都能在字符串的字节范围内操作,而不是对整个字符串长度进行操作。以下是 BITCOUNT 调用的一个简单示例:

> setbit key 0 1
(整数) 0
> setbit key 100 1
(整数) 0
> bitcount key
(整数) 2

位图的常见用例是:

  • 各种实时分析。
  • 存储与对象 ID 关联的节省空间但高性能的布尔信息。

例如,假设您想知道您网站用户的最长每日访问连续记录。您从零开始计算天数,即您的网站公开的那天,并在用户每次访问网站时使用 SETBIT 设置一个位。作为位索引,您只需取当前的 unix 时间,减去初始偏移量,然后除以一天中的秒数(通常是 3600*24)。

这样,每个用户都有一个包含每天访问信息的小字符串。使用 BITCOUNT 可以轻松获取给定用户访问网站的天数,而通过几次 BITPOS 调用,或者简单地在客户端获取和分析位图,就可以轻松计算出最长的连续访问记录。

位图很容易被拆分到多个键中,例如为了对数据集进行分片,也因为通常最好避免使用巨大的键。要将一个位图拆分到不同的键中,而不是将所有位设置到一个键中,一个简单的策略是每个键只存储 M 位,并通过 bit-number/M 获得键名,通过 bit-number MOD M 获得在键中要寻址的第 N 位。

HyperLogLogs#

HyperLogLog 是一种概率性数据结构,用于计算唯一事物的数量(技术上这被称为估算集合的基数)。通常,计算唯一项需要使用与您想计算的项数成比例的内存量,因为您需要记住过去已经见过的元素,以避免重复计算。然而,有一套算法用精度换取内存:您最终得到一个带有标准误差的估计值,在 KeyDB 实现的情况下,这个误差小于 1%。这个算法的神奇之处在于,您不再需要使用与所计数项数成比例的内存量,而是可以使用恒定的内存量!在最坏的情况下是 12k 字节,如果您的 HyperLogLog(我们从现在开始称它们为 HLL)只见过很少的元素,则会少得多。

KeyDB 中的 HLLs,虽然技术上是不同的数据结构,但被编码为 KeyDB 字符串,所以你可以调用 GET 来序列化一个 HLL,并调用 SET 将其反序列化回服务器。

从概念上讲,HLL API 就像使用集合来完成相同的任务。你会将每个观察到的元素 SADD 到一个集合中,并使用 SCARD 来检查集合中元素的数量,这些元素是唯一的,因为 SADD 不会重新添加已存在的元素。

虽然你并不是真的向 HLL 中添加项目,因为该数据结构只包含一个不包括实际元素的状态,但 API 是相同的:

  • 每当您看到一个新元素时,您都使用 PFADD 将其添加到计数中。

  • 每当您想检索到目前为止用 PFADD 添加的唯一元素的当前近似值时,您都使用 PFCOUNT

    > pfadd hll a b c d
    (integer) 1
    > pfcount hll
    (整数) 4

该数据结构的一个用例示例是计算用户每天在搜索表单中执行的唯一查询数。

其他值得注意的功能#

在 KeyDB API 中还有其他一些重要的事情,本文档无法探讨,但值得您注意:

  • 可以使用 scan 命令增量地[迭代大型集合的键空间
  • 可以在服务器端运行 Lua 脚本(eval 命令)以提高延迟和带宽。
  • KeyDB 也是一个发布-订阅服务器。

了解更多#

本教程绝不完整,仅涵盖了 API 的基础知识。请阅读完整的命令参考以发现更多内容。

感谢阅读,祝您使用 KeyDB 编程愉快!