小型聚合数据类型的特殊编码
许多数据类型都经过优化,可在达到一定大小时使用更少空间。哈希、列表、仅由整数组成的集合和有序集合,当元素数量小于给定值且最大元素大小未超出限制时,会以一种内存效率极高的方式编码,从而使用至多减少10倍内存(平均减少5倍内存)。
从用户和API的角度来看,这是完全透明的。由于这是CPU/内存之间的权衡,因此可以使用以下valkey.conf指令来调整特殊编码类型的最大元素数量和最大元素大小(显示的是Valkey 7.2的默认值)
hash-max-listpack-entries 512
hash-max-listpack-value 64
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512
set-max-listpack-entries 128
set-max-listpack-value 64
如果一个特殊编码的值超出了配置的最大大小,Valkey会自动将其转换为正常编码。对于小值,此操作非常快,但如果您更改设置以将特殊编码的值用于更大的聚合类型,建议运行一些基准测试和性能测试以检查转换时间。
使用32位实例
当Valkey编译为32位目标时,由于指针较小,每个键使用的内存会少很多,但此类实例的最大内存使用将限制在4 GB。要将Valkey编译为32位二进制文件,请使用make 32bit。RDB和AOF文件在32位和64位实例之间兼容(当然,也兼容大小端),因此您可以轻松地从32位切换到64位,反之亦然。
位和字节级操作
Valkey具有位和字节级操作:GETRANGE
、SETRANGE
、GETBIT
和SETBIT
。使用这些命令,您可以将字符串类型视为随机访问数组。例如,如果您的应用程序中用户通过唯一的递增整数标识,您可以使用位图来保存用户在邮件列表中的订阅信息,设置位表示已订阅,清除位表示已取消订阅,反之亦然。对于1亿用户,这些数据在Valkey实例中仅占用12兆字节的RAM。您可以使用GETRANGE
和SETRANGE
为每个用户存储一个字节的信息。这只是一个例子,但利用这些新原语,可以在极小的空间内解决许多问题。
尽可能使用哈希
小型哈希以非常小的空间编码,因此您应该尽可能尝试使用哈希来表示您的数据。例如,如果您有一个在Web应用程序中表示用户的对象,与其为姓名、姓氏、电子邮件、密码等使用不同的键,不如使用一个包含所有所需字段的哈希。
如果您想了解更多信息,请阅读下一节。
在Valkey之上使用哈希来抽象一个内存效率极高的普通键值存储
我知道本节的标题可能有点吓人,但我将详细解释这是什么。
基本上,可以使用Valkey建模一个普通键值存储,其中值可以是字符串,这不仅比Valkey的普通键更节省内存,而且比memcached的内存效率高得多。
让我们从一些事实开始:几个键比一个包含少量字段的哈希键使用更多的内存。这怎么可能?我们使用了一个技巧。理论上,为了保证我们在常量时间内(在大O表示法中也称为O(1))执行查找,需要使用一种在平均情况下具有常量时间复杂度的 G数据结构,例如哈希表。
但很多时候哈希只包含几个字段。当哈希很小时,我们可以将它们编码在一个O(N)数据结构中,例如带有长度前缀的键值对的线性数组。由于我们只在N很小时这样做,因此HGET
和HSET
命令的摊销时间仍为O(1):一旦其包含的元素数量增长过大,哈希就会被转换为真正的哈希表(您可以在valkey.conf中配置此限制)。
这不仅在时间复杂度方面表现良好,而且在常数时间方面也表现出色,因为键值对的线性数组与CPU缓存配合得非常好(它比哈希表具有更好的缓存局部性)。
然而,由于哈希字段和值并非(总是)以功能齐全的Valkey对象表示,哈希字段不能像真实键那样具有关联的生存时间(expire),并且只能包含字符串。但我们对此表示接受,这在哈希数据类型API设计之初就是我们的意图(我们更信任简洁性而非功能,因此不允许嵌套数据结构,也不允许单个字段的过期)。
因此,哈希是内存高效的。这在表示对象或在存在相关字段组时建模其他问题时非常有用。但是,如果我们有一个普通的键值业务呢?
想象一下,我们想使用Valkey作为许多小对象的缓存,这些对象可以是JSON编码的对象、小的HTML片段、简单的键 -> 布尔值等等。基本上,任何东西都是键值对为字符串,且键和值都很小的映射。
现在我们假设要缓存的对象是编号的,例如
- object:102393
- object:1234
- object:5
我们可以这样做。每次执行SET操作设置新值时,我们实际上将键分成两部分:一部分用作键,另一部分用作哈希的字段名。例如,名为“object:1234”的对象实际上被拆分为
- 一个名为object:12的键
- 一个名为34的字段
因此,我们使用除了最后两个字符之外的所有字符作为键,最后两个字符作为哈希字段名。要设置我们的键,我们使用以下命令
HSET object:12 34 somevalue
如您所见,每个哈希最终将包含100个字段,这是CPU和内存节省之间的最佳折衷。
还有另一点值得注意,使用此方案,无论我们缓存的对象数量如何,每个哈希都将或多或少地包含100个字段。这是因为我们的对象总是以数字而不是随机字符串结尾。在某种程度上,最终数字可以被视为一种隐式预分片形式。
那么小数字呢?比如object:2?我们通过只使用“object:”作为键名,并将整个数字作为哈希字段名来处理这种情况。因此,object:2和object:10都将存储在键“object:”中,但一个的字段名为“2”,另一个的字段名为“10”。
每当哈希超过指定的元素数量或元素大小时,它将被转换为真正的哈希表,此时内存节省将不复存在。
您可能会问,为什么不在普通的键空间中隐式地这样做,这样我就不用操心了?原因有二:一是我们倾向于明确权衡,而这在许多方面都是一个明显的权衡:CPU、内存和最大元素大小。二是顶级键空间必须支持许多有趣的功能,如过期、LRU数据等,因此无法以通用方式实现此功能。
但Valkey的方式是用户必须理解事物如何运作,这样他才能选择最佳折衷方案并准确理解系统的行为方式。
内存分配
为了存储用户键,Valkey最多分配maxmemory
设置允许的内存量(但可能存在少量额外分配)。
确切的值可以在配置文件中设置,或之后通过CONFIG SET
设置(更多信息请参阅将内存用作LRU缓存)。关于Valkey如何管理内存,有几点需要注意
- 当键被移除时,Valkey不总是会释放(归还)内存给操作系统。这并不是Valkey的特殊之处,而是大多数malloc()实现的工作方式。例如,如果您用5GB的数据填充一个实例,然后移除等同于2GB的数据,即使Valkey声称用户内存约为3GB,常驻内存集大小(也称为RSS,即进程消耗的内存页数)可能仍然在5GB左右。发生这种情况是因为底层分配器无法轻易释放内存。例如,通常大多数被移除的键都与仍然存在的其他键分配在相同的内存页上。
- 前一点意味着您需要根据您的峰值内存使用量来配置内存。如果您的工作负载有时需要10GB,即使大多数时候5GB就足够了,您也需要为10GB进行配置。
- 然而,分配器是智能的,能够重用空闲的内存块,因此在您释放了5GB数据集中2GB的内存后,当您再次开始添加更多键时,您会看到RSS(常驻内存集大小)保持稳定不再增长,直到您添加了多达2GB的额外键。分配器基本上是在尝试重用之前(逻辑上)已释放的2GB内存。
- 由于所有这些原因,当您的峰值内存使用量远大于当前使用的内存时,碎片率并不可靠。碎片率的计算方式是实际使用的物理内存(RSS值)除以当前正在使用的内存量(Valkey执行的所有分配的总和)。由于RSS反映的是峰值内存,当(虚拟)使用的内存因大量键/值被释放而较低,但RSS很高时,
RSS / mem_used
的比率将非常高。
如果未设置maxmemory
,Valkey将根据需要继续分配内存,从而可能(逐渐)耗尽您的所有可用内存。因此,通常建议配置一些限制。您可能还希望将maxmemory-policy
设置为noeviction
(这在某些旧版本的Valkey中不是默认值)。
它使得Valkey在达到内存限制时,对写入命令返回内存不足错误——这反过来可能导致应用程序出现错误,但不会因为内存耗尽而导致整个机器崩溃。