内存优化

优化Redis内存使用的策略

小型聚合数据类型的特殊编码

自 Redis 2.2 以来,许多数据类型都被优化以在特定大小内使用更少的空间。 当哈希、列表、仅由整数组成的集合和有序集合的元素数量小于给定数量,并且元素大小不超过最大限制时,它们会以一种非常节省内存的方式进行编码,最多可节省 10 倍的内存(平均节省 5 倍内存)。

从用户和API的角度来看,这是完全透明的。 由于这是CPU/内存的权衡,可以使用以下redis.conf指令(显示的是默认值)来调整特殊编码类型的最大元素数量和最大元素大小:

Redis <= 6.2

hash-max-ziplist-entries 512
hash-max-ziplist-value 64
zset-max-ziplist-entries 128 
zset-max-ziplist-value 64
set-max-intset-entries 512

Redis >= 7.0

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

Redis >= 7.2

以下指令也可用:

set-max-listpack-entries 128
set-max-listpack-value 64

如果特殊编码的值超过了配置的最大大小, Redis 会自动将其转换为普通编码。 对于小值来说,这个操作非常快, 但如果你为了在更大的聚合类型中使用特殊编码值而更改设置, 建议运行一些基准测试和测试来检查转换时间。

使用32位实例

当Redis被编译为32位目标时,每个键使用的内存会少很多,因为指针较小, 但这样的实例将被限制在最大4GB的内存使用量。 要将Redis编译为32位二进制文件,请使用make 32bit。 RDB和AOF文件在32位和64位实例之间是兼容的 (当然也包括小端和大端),因此你可以从32位切换到64位,或者相反,而不会出现问题。

位和字节级别的操作

Redis 2.2 引入了新的位和字节级别操作:GETRANGESETRANGEGETBITSETBIT。 使用这些命令,您可以将 Redis 字符串类型视为随机访问数组。 例如,如果您有一个应用程序,其中用户由唯一的递增整数标识, 您可以使用位图来保存用户在邮件列表中的订阅信息, 为订阅的用户设置位,为未订阅的用户清除位,或者反之亦然。 对于 1 亿用户,这些数据在 Redis 实例中仅占用 12 MB 的内存。 您可以使用 GETRANGESETRANGE 为每个用户存储一个字节的信息来实现相同的效果。 这只是一个示例,但使用这些新的原语可以在非常小的空间内建模多个问题。

尽可能使用哈希

小哈希值在非常小的空间内编码,因此您应尽可能尝试使用哈希值来表示您的数据。 例如,如果您在Web应用程序中有代表用户的对象, 不要为姓名、姓氏、电子邮件、密码使用不同的键,而是使用包含所有必需字段的单个哈希。

如果你想了解更多关于这个的内容,请阅读下一节。

使用哈希在Redis之上抽象出一个非常内存高效的纯键值存储

我理解这个部分的标题有点吓人,但我会详细解释这是关于什么的。

基本上,可以使用Redis来模拟一个简单的键值存储,其中值可以是字符串,这不仅比Redis的普通键更节省内存,而且比memcached更节省内存。

让我们从一些事实开始:几个键使用的内存比一个包含几个字段的哈希的单个键要多得多。这是怎么可能的?我们使用了一个技巧。理论上,为了保证我们在常数时间内执行查找(在大O表示法中也被称为O(1)),需要使用一个在平均情况下具有常数时间复杂度的数据结构,比如哈希表。

但很多时候哈希只包含几个字段。当哈希很小时,我们可以改用O(N)的数据结构来编码它们,比如带有长度前缀的键值对的线性数组。因为我们只在N很小时这样做,所以HGETHSET命令的平摊时间仍然是O(1):当哈希包含的元素数量增长过大时,它将被转换为真正的哈希表(你可以在redis.conf中配置这个限制)。

这不仅从时间复杂度的角度来看效果很好,而且从常数时间的角度来看也是如此,因为键值对的线性数组恰好与CPU缓存配合得非常好(它比哈希表具有更好的缓存局部性)。

然而,由于哈希字段和值并不(总是)表示为全功能的Redis对象,哈希字段不能像真正的键那样具有关联的生存时间(过期),并且只能包含字符串。但我们对此感到满意,这本来就是哈希数据类型API设计时的意图(我们更信任简单性而非功能,因此不允许嵌套数据结构,也不允许单个字段的过期)。

所以哈希是内存高效的。这在用哈希表示对象或模拟其他问题时非常有用,尤其是在有一组相关字段的情况下。但如果我们有一个简单的键值业务呢?

想象一下,我们想使用Redis作为许多小对象的缓存,这些对象可以是JSON编码的对象、小的HTML片段、简单的键 -> 布尔值等等。基本上,任何东西都是一个字符串 -> 字符串映射,具有小的键和值。

现在让我们假设我们想要缓存的对象是编号的,例如:

  • 对象:102393
  • 对象:1234
  • 对象: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"。

我们通过这种方式节省了多少内存?

我使用了以下Ruby程序来测试其工作原理:

require 'rubygems'
require 'redis'

USE_OPTIMIZATION = true

def hash_get_key_field(key)
  s = key.split(':')
  if s[1].length > 2
    { key: s[0] + ':' + s[1][0..-3], field: s[1][-2..-1] }
  else
    { key: s[0] + ':', field: s[1] }
  end
end

def hash_set(r, key, value)
  kf = hash_get_key_field(key)
  r.hset(kf[:key], kf[:field], value)
end

def hash_get(r, key, value)
  kf = hash_get_key_field(key)
  r.hget(kf[:key], kf[:field], value)
end

r = Redis.new
(0..100_000).each do |id|
  key = "object:#{id}"
  if USE_OPTIMIZATION
    hash_set(r, key, 'val')
  else
    r.set(key, 'val')
  end
end

这是针对64位Redis 2.2实例的结果:

  • USE_OPTIMIZATION 设置为 true: 1.7 MB 的已用内存
  • USE_OPTIMIZATION 设置为 false; 使用了 11 MB 的内存

这是一个数量级,我认为这使得Redis或多或少成为目前最高效的纯键值存储。

警告:为了使此功能正常工作,请确保在您的 redis.conf 中有类似以下内容:

hash-max-zipmap-entries 256

还要记得根据您的键和值的最大大小相应地设置以下字段:

hash-max-zipmap-value 1024

每次哈希超过指定的元素数量或元素大小时,它将被转换为一个真正的哈希表,内存节省将丢失。

你可能会问,为什么不在普通的键空间中隐式地做这件事,这样我就不用关心了?有两个原因:一是我们倾向于明确权衡,这是CPU、内存和最大元素大小之间的明确权衡。二是顶级键空间必须支持许多有趣的功能,如过期、LRU数据等,因此以通用方式实现这一点是不现实的。

但是Redis的方式是用户必须理解事物的工作原理,以便他能够选择最佳的折衷方案,并准确理解系统的行为。

内存分配

为了存储用户密钥,Redis分配的内存最多不超过maxmemory设置所允许的量(然而可能会有一些小的额外分配)。

确切的值可以在配置文件中设置,或者稍后通过CONFIG SET设置(更多信息,请参见使用内存作为LRU缓存)。关于Redis如何管理内存,有几点需要注意:

  • 当键被删除时,Redis 不会总是将内存释放(返回)给操作系统。 这并不是 Redis 特有的问题,而是大多数 malloc() 实现的工作方式。 例如,如果你在一个实例中填充了 5GB 的数据,然后 删除了相当于 2GB 的数据,驻留集大小(也称为 RSS,即进程消耗的内存页数) 可能仍然在 5GB 左右,即使 Redis 会声称用户 内存大约为 3GB。这是因为底层分配器无法轻易释放内存。 例如,通常大多数被删除的键与仍然存在的其他键分配在相同的页面上。
  • 前一点意味着你需要根据你的峰值内存使用量来配置内存。如果你的工作负载有时需要10GB,即使大多数时候5GB就足够了,你也需要配置10GB。
  • 然而,分配器是智能的,能够重用空闲的内存块, 所以在你释放了5GB数据集中的2GB后,当你再次开始添加更多键时, 你会看到RSS(驻留集大小)保持稳定,不会随着你添加多达2GB的额外键而增长。 分配器基本上是在尝试重用之前(逻辑上)释放的2GB内存。
  • 由于所有这些原因,当您的内存使用量在峰值时远大于当前使用的内存时,碎片率是不可靠的。碎片率的计算方式是实际使用的物理内存(RSS值)除以当前使用的内存量(作为Redis执行的所有分配的总和)。因为RSS反映了峰值内存,当(虚拟)使用的内存由于大量键/值被释放而较低时,但RSS较高,比率RSS / mem_used将会非常高。

如果未设置maxmemory,Redis将根据需要继续分配内存,因此它可能会(逐渐)耗尽所有可用内存。因此,通常建议配置一些限制。您可能还想将maxmemory-policy设置为noeviction(在某些旧版本的Redis中,这不是默认值)。

当Redis达到内存限制时,它会返回一个内存不足的错误给写命令——这可能会导致应用程序中出现错误,但不会因为内存不足而使整个机器崩溃。

RATE THIS PAGE
Back to top ↑