内存优化

优化 Redis 内存使用的策略

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 兆字节的 RAM。您也可以使用 GETRANGESETRANGE 为每个用户存储一字节信息。这只是一个示例,但使用这些新原语可以在很小的空间内对多个问题进行建模。

尽可能使用哈希

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

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

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

我知道本节的标题听起来有点吓人,但我将详细解释这是怎么回事。

基本上,可以使用 Redis 构建一个纯粹的键值存储模型,其中值仅为字符串。这种模型不仅比 Redis 的普通键更节省内存,甚至比 memcached 更节省内存。

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

但很多时候哈希只包含少量字段。当哈希很小时,我们可以将它们编码为 O(N) 数据结构,例如带有长度前缀的键值对组成的线性数组。由于我们只在 N 很小时这样做,HGETHSET 命令的摊销时间仍然是 O(1):一旦哈希包含的元素数量变得太大,它就会被转换成一个真正的哈希表(您可以在 redis.conf 中配置此限制)。

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

然而,由于哈希字段和值并非(总是)表示为功能齐全的 Redis 对象,哈希字段不能像真正的键那样拥有关联的生存时间(expire),并且只能包含字符串。但我们对此没有意见,因为哈希数据类型 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

这是针对 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、内存和最大元素大小。二是顶层键空间必须支持许多有趣的功能,如过期(expires)、LRU 数据等,因此无法以通用方式进行此操作。

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

内存分配

为了存储用户键,Redis 分配的内存最多不超过 maxmemory 设置所允许的量(但是可能存在少量额外的分配)。

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

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

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

这使得 Redis 在达到限制时,会对写入命令返回内存不足错误——这可能会导致应用程序出错,但不会因为内存耗尽而导致整个机器崩溃。

评价此页面
返回顶部 ↑