内存优化

优化 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 位目标时,它每个键使用的内存要少得多,因为指针很小,但这样的实例将被限制为最多使用 4 GB 的内存。要将 Redis 编译为 32 位二进制文件,请使用make 32bit。RDB 和 AOF 文件在 32 位和 64 位实例之间(当然,小端和大小端之间)是兼容的,因此你可以毫无问题地在 32 位和 64 位之间切换。

位和字节级操作

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

尽可能使用哈希

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

如果您想了解更多信息,请阅读下一节。

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

我知道本节的标题有点吓人,但我将详细解释这是关于什么的。

基本上,可以使用 Redis 对纯键值存储进行建模,其中值可以只是字符串,这不仅比 Redis 纯键更节省内存,而且比 memcached 更节省内存。

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

但很多时候哈希只包含几个字段。当哈希很小时,我们反而可以将其编码为 O(N) 数据结构,例如具有长度前缀键值对的线性数组。由于我们仅在 N 较小时执行此操作,因此 HGETHSET 命令的摊销时间仍然为 O(1):哈希将被转换为真正的哈希表,只要它包含的元素数量增长过大(您可以在 redis.conf 中配置限制)。

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

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

因此,哈希是内存高效的。当使用哈希来表示对象或在存在相关字段组时对其他问题进行建模时,这非常有用。但是,如果我们有简单的键值业务该怎么办?

想象一下,我们想将 Redis 用作许多小对象的缓存,这些对象可以是 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”。

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

我使用了以下 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

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

  • 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 为写入命令返回内存不足错误 - 这反过来可能导致应用程序中的错误,但不会因为内存不足而使整个机器死机。

对本页进行评分