内存优化
优化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 引入了新的位和字节级别操作:GETRANGE
、SETRANGE
、GETBIT
和 SETBIT
。
使用这些命令,您可以将 Redis 字符串类型视为随机访问数组。
例如,如果您有一个应用程序,其中用户由唯一的递增整数标识,
您可以使用位图来保存用户在邮件列表中的订阅信息,
为订阅的用户设置位,为未订阅的用户清除位,或者反之亦然。
对于 1 亿用户,这些数据在 Redis 实例中仅占用 12 MB 的内存。
您可以使用 GETRANGE
和 SETRANGE
为每个用户存储一个字节的信息来实现相同的效果。
这只是一个示例,但使用这些新的原语可以在非常小的空间内建模多个问题。
尽可能使用哈希
小哈希值在非常小的空间内编码,因此您应尽可能尝试使用哈希值来表示您的数据。 例如,如果您在Web应用程序中有代表用户的对象, 不要为姓名、姓氏、电子邮件、密码使用不同的键,而是使用包含所有必需字段的单个哈希。
如果你想了解更多关于这个的内容,请阅读下一节。
使用哈希在Redis之上抽象出一个非常内存高效的纯键值存储
我理解这个部分的标题有点吓人,但我会详细解释这是关于什么的。
基本上,可以使用Redis来模拟一个简单的键值存储,其中值可以是字符串,这不仅比Redis的普通键更节省内存,而且比memcached更节省内存。
让我们从一些事实开始:几个键使用的内存比一个包含几个字段的哈希的单个键要多得多。这是怎么可能的?我们使用了一个技巧。理论上,为了保证我们在常数时间内执行查找(在大O表示法中也被称为O(1)),需要使用一个在平均情况下具有常数时间复杂度的数据结构,比如哈希表。
但很多时候哈希只包含几个字段。当哈希很小时,我们可以改用O(N)的数据结构来编码它们,比如带有长度前缀的键值对的线性数组。因为我们只在N很小时这样做,所以HGET
和HSET
命令的平摊时间仍然是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达到内存限制时,它会返回一个内存不足的错误给写命令——这可能会导致应用程序中出现错误,但不会因为内存不足而使整个机器崩溃。