前言

在上一篇文章Redis保存JSON结构体之String VS HASH 中,我们介绍了Redis中的String和Hash两种数据结构,以及它们各自的优缺点。本文将介绍Redis的内存优化。

内存优化

优化Redis内存使用的策略

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

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

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

Redis <= 6.2

1
2
3
4
5
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

1
2
3
4
5
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

还有以下指令可用:

1
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位之间切换,或者反之,而不会出现问题。

位(Bit)和字节(byte)级操作

Redis 2.2引入了新的位和字节级操作:GETRANGE,SETRANGE,GETBITSETBIT。使用这些命令,您可以将Redis字符串类型视为随机访问数组。例如,如果您有一个应用程序,用户通过唯一的递增整数编号进行标识,您可以使用位图来保存有关用户在邮件列表中的订阅信息,设置已订阅的位并清除未订阅的位,
或者反之亦然。对于1亿用户,这些数据在Redis实例中只需要占用12兆字节的内存。您也可以使用GETRANGESETRANGE来为每个用户存储一个字节的信息。这只是一个示例,但是使用这些新的基本操作,可以在非常小的空间中对多个问题进行建模。

尽可能使用哈希

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

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

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

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

让我们从一些事实开始:一些键使用的内存比包含几个字段的哈希键要多得多。这是怎么可能的呢?

我们使用一个技巧。理论上,为了保证我们在常数时间内进行查找(也称为$ \mathcal{O} $符号中的 $( \mathcal{O}(1) )$ ),需要使用一个在平均情况下具有常数时间复杂度的数据结构,比如哈希表。

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

这段话可能很难理解,让我们来分解一下,逐步理解其中含义。

  1. 当哈希包含的字段较少时:即哈希数据结构中只有少量的键值对。

  2. 我们可以将其编码为$ \mathcal{O}(N) $的数据结构:这里的$ \mathcal{O}(N) $表示操作的时间复杂度是线性的,即与数据结构中的元素数量成正比。

  3. 例如,一个带有长度前缀的键值对的线性数组:这意味着我们不是使用传统的哈希表来存储这些小的哈希,而是使用一个简单的数组。在这个数组中,每对键值都有一个前缀来表示其长度。

  4. 我们只在N较小时这么做,因此HGETHSET命令的摊销时间仍然是$ \mathcal{O}(1) $:尽管我们使用线性的数据结构,但由于这种情况只发生在哈希很小的时候,所以大多数情况下,获取和设置哈希中的值的操作仍然是常数时间复杂度(平均情况下)。

  5. 一旦哈希中的元素数量增长到一定程度,它就会转换为真正的哈希表:这意味着当我们向这个小哈希添加更多的字段,使其达到一定的大小时,Redis会自动将其从线性数组转换为更加高效的哈希表结构。

举个例子:

假设你有一个哈希user:123,其中只存储了两个字段:nameage。在这种情况下,由于这个哈希很小,Redis可能会选择将其存储为一个简单的线性数组,例如:

1
[4, name, John, 3, age, 28]

其中数字43分别是nameage这两个键的长度。

当我们继续向这个哈希添加更多字段时,例如addressphone等,这个哈希的大小逐渐增长。当其大小达到redis.conf中配置的某个阈值时,Redis会自动将其从上述的线性数组结构转换为一个真正的哈希表结构,以提高存取的效率。

这种策略使得Redis能够在小哈希的情况下节省内存,同时在哈希变大时仍然保持高效的性能。

这不仅在时间复杂度上表现出色,而且在常数时间上也表现出色,因为线性的键值对数组与CPU缓存非常匹配(它比哈希表具有更好的缓存局部性)。

然而,由于哈希字段和值并不总是以完整的Redis对象形式表示,哈希字段无法像真正的键一样具有关联的生存时间(过期),并且只能包含一个字符串。

但我们对此感到满意,这正是哈希数据类型 API 设计时的意图(我们更相信简洁而不是功能,因此不允许嵌套数据结构,也不允许单个字段的过期)。

所以哈希表在内存使用上非常高效。当使用哈希表来表示对象或者模拟其他问题时,尤其是涉及到一组相关字段时,这非常有用。但是如果我们只是处理简单的键值对业务呢?

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

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

  • object:102393
  • object:1234
  • object:5

这就是我们能做的。每次执行SET操作设置新值时,我们实际上将键分成两部分,一部分用作键,另一部分用作哈希的字段名。例如,名为object:1234的对象实际上被分成:

一个名为object:12的key

一个名为34的哈希字段

所以我们使用除了最后两个字符以外的所有字符作为key,最后两个字符作为哈希字段的名称。要设置我们的key,我们使用以下命令:

1
HSET object:12 34 somevalue

正如您所看到的,每个哈希最终将包含 100 个字段(因为2个字符的取值范围 0~99),这是 CPU 和节省的内存之间的最佳折衷。

在某种程度上,最终的数字可以被视为一种隐式的预分片形式。

小数字怎么办?比如object:2?我们使用”object:”作为键名,将整个数字作为哈希字段名来处理这种情况。
因此,object:2和object:10都会存储在键”object:”中,但一个作为字段名”2”,另一个作为”10”。

我们这样能节省多少内存?

我使用了以下的Ruby程序来测试这个是如何工作的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 文件中有类似以下的配置:
1
hash-max-zipmap-entries 256

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

1
hash-max-zipmap-value 1024

每当哈希超过 指定的元素数量或元素大小时,它将被转换为真正的HashMap,从而失去 了内存节省的效果。

你可能会问,为什么不在正常的键空间中隐式地执行这个操作,这样我就不需要关心了?
有两个原因:

  1. 是我们倾向于将权衡明确化,而这是许多方面之间的明确权衡:CPU、内存和最大元素大小。
  2. 顶层键空间必须支持许多有趣的功能,如过期时间、LRU(Least Recently Used-最近最少使用)数据等等,因此通常情况下不太实际。

但是 Redis 的方式是用户必须理解事物的运作方式,这样他才能做出最好的折衷方案,并且准确地了解系统将如何行为。

内存分配

为了存储用户keys,Redis分配的内存最多不超过maxmemory设置所允许的大小(但可能会有一些小额外分配)

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

  • Redis在删除键时,并不总是会将内存释放给操作系统。这并不是Redis特有的问题,而是大多数malloc()实现的工作方式。例如,如果你用5GB的数据填充一个实例,然后删除相当于2GB的数据,进程所消耗的Resident Set Size(也称为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返回一个内存不足错误以进行写入命令 - 这反过来可能导致应用程序中出现错误,但不会因为内存耗尽而使整个机器崩溃。