内存优化

优化 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 更节约内存。

让我们先从一些事实开始:一些键比包含几个字段的单个哈希的键占用更多的内存。这是怎么实现的?我们使用了一个技巧。理论上,为了保证我们在恒定时间内执行查找(在 Big 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() 实现的工作方式。例如,如果您用 5 GB 的数据填充一个实例,然后删除相当于 2 GB 的数据,驻留集大小(也称为 RSS,即进程占用的内存页数)可能仍然约为 5 GB,即使 Redis 会声称用户内存约为 3 GB。发生这种情况是因为底层分配器无法轻松地释放内存。例如,通常大部分已删除的键分配在与仍然存在的其他键相同的页面上。
  • 上一条意味着您需要根据 **峰值内存使用量** 来配置内存。如果您的工作负载偶尔需要 10 GB,即使大部分时间 5 GB 就可以,您也需要为 10 GB 进行配置。
  • 但是,分配器很聪明,能够重用空闲的内存块,因此在您释放 5 GB 数据集中 2 GB 的数据后,当您再次开始添加更多键时,您会看到 RSS(驻留集大小)保持稳定,并且不会增长更多,因为您添加了多达 2 GB 的其他键。分配器基本上正在尝试重用之前(逻辑上)释放的 2 GB 内存。
  • 由于所有这些原因,当您的内存使用量在峰值时远大于当前使用的内存时,碎片率不可靠。碎片率的计算方法是实际使用的物理内存(RSS 值)除以当前使用的内存量(作为 Redis 执行的所有分配的总和)。由于 RSS 反映的是峰值内存,当(虚拟)使用的内存很低,因为很多键/值被释放,但是 RSS 很高时,比率 RSS / mem_used 将非常高。

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

这使得 Redis 在达到限制时,对于写入命令返回内存不足错误——这反过来会导致应用程序出现错误,但不会因为内存不足而导致整个机器死机。

RATE THIS PAGE
Back to top ↑