小的聚合数据类型的特殊编码
从2.2版本的Redis开始,很多数据类型都得到了优化以使用更少的空间。散列、列表和集合都仅由整数组成,而有序集合,当其元素都小于一个给定的数值,且元素数量小于某个值时,会以一种非常节省内存的方式进行编码,这种方式可以节省至少10倍的内存(平均可以节省5倍的内存)。
从用户和API的角度来看这是完全透明的。由于这是一个CPU/内存的交易,我们完全可以使用如下redis.conf中的配置为这种特殊的编码类型调整最大元素的数量和元素的最大值。
hash-max-zipmap-entries 512 (hash-max-ziplist-entries for Redis >= 2.6)
hash-max-zipmap-value 64 (hash-max-ziplist-value for Redis >= 2.6)
list-max-ziplist-entries 512
list-max-ziplist-value 64
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
set-max-intset-entries 512
如果一个特殊编码的某个值超过了配置的最大值,Redis会自动将它转换成正常的编码。对较小的数值这个操作非常快,但是如果你为了对大得多的聚合类型使用这种特殊的编码方式而改变了配置文件,我们建议先运行一些基准测试来检查转换时间。
使用32位的实例
使用Redis编译32位的目标可以为每个键使用少得多的内存,因为指针很小,但是这样的实例将会被限制为最大使用4GB的内存。使用make 32bit命令编译生成32位的Redis。RDB和AOF文件在32位和64位Redis之间是兼容的(包括低位和高位的字节顺序),因此你可以从32位切换至64位,或者反过来,都是没有问题的。
比特和字节层面的操作
2.2版本的Redis引入了新的比特和字节层面的操作命令:GETRANGE、SETRANGE、GETBIT和SETBIT.使用这些命令你可以将Redis字符串当做随机数组进行访问。举个例子:如果你有一个应用程序,用户通过一个渐进的整数来标识,你可以使用一个位图来保存用户的性别信息,设置该比特位表示女性,清除比特位表示男性,或者反过来。在1亿用户量的条件下,存储这些数据只需要使用12M内存。你可以使用GETRANGE和SETRANGE来做同样的事情,为每个用户存储1个字节的信息。这只是一个样例,但是使用这些原语,你可以只花费很少的空间来为很多问题进行建模。
尽可能使用散列
小的散列被编码之后只会占用很小的内存空间,因此只要有可能,你都应该尝试使用散列来表示你的数据。比如说,如果你有一个web应用用户的数据,不要为用户的名字、姓氏、邮件、密码使用不同的键,相反,而是应该使用一个简单的散列来包含所有的信息。想了解更多相关信息,请继续阅读下一节。
使用散列来抽象一个建立在Redis之上的非常节省内存的键值对存储方式
我知道这部分的标题有点吓人,但是我将会详细解释这部分内容。
基本上我们可以使用Redis来对简单的键值对进行建模,其中值只能是字符串,这不但比简单的键存储更加节省内存,也比memcached更加节省内存。
让我们从一些事实开始:几个键比一个包含几个字段的散列占用的内存大得多。这怎么可能?理论上为了保证我们的查找时间是固定的(也就是我们熟知的时间复杂度为O(1)),我们需要一种数据结构,它的平均查找时间复杂度是固定的,比如说散列表。
但是大多数情况下散列只包含了少数几个字段。当散列很小时,我们可以将它编码成时间复杂度为O(N)的数据结构,比如说一个线性数组。只有当N比较小时我们才会采用这种结构,HGET和HSET的时间开销复杂度依然是O(1):一旦散列包含的元素个数增长太快(超过了在redis.conf配置的限制),该散列就会被转换成正常编码的散列表。
无论是从时间复杂度的角度来看,还是从固定时间的角度来看,采用这种编码方式的效果都不是很好,因为通过使用CPU缓存(它比散列表有更好的缓存局部性),一个线性数组就可以工作得很好。
然而,由于散列的字段和值并不是用一个全特性的Redis对象来表示的,所以散列的字段不能像真正的Redis键一样关联存活时间,并且它只能包含一个字符串。但这对我们而言是ok的,这就是散列类型API设计的初衷(我们认为简单性比多功能性更加重要,因此嵌套的数据结构是不被允许的,为单个字段设置过期时间也是不被允许的)。
所以散列可以高效利用内存。当使用散列去表示一个对象或者对拥有很多关联字段的问题进行建模时,这一点是非常有用的。但是如果我们有一个普通的键值业务需求怎么办?
想象一下我们相拥Redis作为很多小对象的缓存系统,这些对象可能被编码成JSON对象、小的HTML片段、简单的键->布尔值等等。基本上一切都可以使用小的键和值,并使用string->string这种映射关系来表示。
现在让我们假设我们想缓存的对象是使用了数字进行编码,就像这样的:
- 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'
UseOptimization = 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..100000).each{|id|
key = "object:#{id}"
if UseOptimization
hash_set(r,key,"val")
else
r.set(key,"val")
end
}
这是在Redis2.2的64位版本上的测试结果:
- 将优化选项设置为true:一共使用了1.7M内存
- 将优化选项设置为false:一共使用了11M内存
这是一个数量级的优化,我认为这将或多或少使得Redis成为最节省内存的键值对存储方案。
警告:为了达到这个效果,请确保在你的redis.conf文件中有类似这样的配置:
hash-max-zipmap-entries 256
同时记得将下面这个配置项设置为键和值的最大值:
hash-max-zipmap-value 1024
每次当散列超过了指定的元素最大值或元素数量时,它将会被转换成正常编码的散列表,这将不再会节省内存。
你可能会问,为什么你不将这些隐含条件设置在正常的键空间中,这样一来我就不用在乎这些事情了呢?这么做有两个原因:首先我们倾向于显式平衡,这是在很多事情之间的明确平衡:CPU、内存、最大元素的大小。第二个原因则是顶层的键空间必须支持很多有趣的特性,比如说过期、LRU算法等等,所以总体上来说这么做是不现实的。
但是Redis的做法是,用户必须了解它是工作机制是怎样的,这样他才能做出最好的这种方案,并且准确了解系统是如何运行的。
内存分配
为了保存用户数据,Redis最多分配如maxmemory设置的那样大的内存(虽然也有可能额外分配比较小的内存)。
具体的数字可以在配置文件中设置或者启动后通过CONFIG SET设置(参见Using memory as an LRU cache for more info获取更多信息)。关于Redis是如何管理内存的,有几点值得注意。
- 当键被移除时,Redis并不总是会释放内存给操作系统。这并不是Redis特殊的地方,而是大多数malloc()实现的工作机制。比如说你在Redis中存放了5GB的数据,然后删除了其中的2GB数据,剩下的数据集大小(也就是我们熟知的RSS,它是被进程占用的内存页的数量)有可能还是在5GB左右,即使Redis声称用户占用的内存在3GB左右。发生这种情况是因为底层的内存分配器想要释放内存并不是一件容易的事。这可能是因为被删除的键和仍然存在的键被分配在内存的同一个页面。
- 前面一点意味着你需要根据你的内存使用峰值来准备内存。如果你的工作时不时需要10GB内存,即使大多数时候都只需要5GB,你也需要准备10GB的内存。
- 然而内存分配器是很智能的,它可以重新利用已经释放的内存块,因此当你从5GB的数据集中释放了2GB的内存后,当你再次开始添加更多的数据时,你会发现RSS(剩余数据集大小)会保持稳定而不再增长,一直到你增加了2GB的数据。这是因为内存分配器在尝试重新利用之前释放的2GB内存。
- 正式因为这个原因,当你的内存使用峰值比当前使用的内存大得多时,碎片比并不可信。碎片比是用当前实际正在使用的内存(所有Redis分配的内存总和)除以当前实际被占用的物理内存(也就是RSS的值)来计算的。因为RSS反映的是内存使用峰值,当很多键值对被删除后,实际使用的内存会小很多,而RSS还处在高位,这会导致mem_used/RSS就会偏高。
如果没有设置maxmemory,Redis就会一直分配它觉得合适的内存直到逐渐用完你所有的空闲内存。因此一般而言我们都建议设置一个内存限制。你可能还想maxmemory-policy设置为noeviction(在老版本的Redis中,这并不是默认值)。
这会使得当内存达到内存限制后,在想Redis写命令时会返回内存不足的错误,而这可能会导致应用程序的错误,但是不会因为内存不足导致整个机器宕机。
2017-10-03
原文链接:https://redis.io/topics/memory-optimization#special-encoding-of-small-aggregate-data-types