Redis内存优化之: 小体量聚集类型数据的编码优化(译)

899 阅读8分钟

聚合类数据类型的内存优化

从Redis2.2开始,许多数据类型都被优化到一定大小,从而减少了占用内存空间。Hash,List,由整数组成的Set,和Sorted set,当他们的元素数量和单个元素所占内存分别小于给定值时,这些聚集类型会被以非常高效利用内存的方式存储,最高可以节省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位实例

使用32位target编译的redis下,每个key能少用很多内存,因为指针会小很多,但是最大使用内存也会被限制在4G。要想编译成32位二进制的redis,可以使用make 32bitRDBAOF在32位和64位实例上是兼容的(当然在大端和小段之间上也是兼容的),所以你可以从32位切换到64位,反过来也一样。

位和字节级别的操作

Redis 2.2 引进了新的字节和位级别的操作: GETRANGE ,SETRANGE, GETBIT,SETBIT。使用这些指令,你可以把string类型当做可以随机访问的数组。比如说,你有一个应用,应用里用户通过一个唯一的渐进整数来识别,你可以使用位图来保存用户的性别信息,可以在位上女性置1,男性置0,或者用其他方式。在Redis实例里,10亿用户的性别数据只需要12M RAM。你可以使用GETRANGE ,SETRANGE来存储一个字节长度的每个用户信息。这里只是个例子,但是我们有可能使用这些新指令解决很多小空间时面临的问题。

如有可能,尽量使用Hash

小的hash会使用占用很小空间的编码格式,所以你应该可能的使用hash来存储你的数据。比如说你有一个网站用户对象,你应该使用单个hash来存储所有的字段,而非把用户的名字,性别,年龄等都存储成不同的k-v。

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

我理解这节的话题有点吓人,但是我会详细解释它。

基本上,可以使用redis对普通的键值存储进行建模,其中的值可以只是字符串,这不仅比redis普通的键值更节省内存,而且比memcached更节省内存。

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

但是很多时候,哈希只包含很少的字段。当哈希比较小的时候,我们可以使用O(N)复杂度的数据结构对其编码,比如带有长度前缀的键值对线性数组。因为我们只在N比较小的时候才这么做,HGETHSET的均摊时间仍然是O(1),当哈希包含的元素个数增长到足够大时 (你可以在redis.conf里配置这个限制),它将被转换为真正的哈希表。

从时间复杂度的角度看,这并不能很好的工作,但从常量时间的角度来看却相反,因为通过CPU缓存,一个键值对的线性数组恰好能够工作的很好。

但是,因为哈希的字段(field)和值(value)(通常)不能像Redis对象那样表现出所有的特性,哈希的字段没有类似Redis键那样与之关联的生存时间(过期时间),仅仅包含一个字符串。但这对我们来说是没问题的,这就是设计哈希数据结构API时的意图(我们相信简单优于特性,所以不运行内嵌数据结构,单个字段的过期属性也是不支持的)。

所以,哈希是内存高效的。所以,在表示对象或者对包含一组相关字段的问题建模时使用哈希是很有用的。但是对于只有普通键值对的业务又该如何呢?

比如我们使用Redis存储一些小对象,可以是json编码的对象、小的HTML块,简单的键->布尔值对等等。本质上,都是小型键和小型值的字符串->字符串的映射。

现在我们假设要缓存的对象是数字的,比如:

  • object:102393
  • object:1234
  • object:5 这就是我们可以做的。每当我们执行一个set操作来设置一个新value的时候,我们实际上把键分为两部分,一个用来做键,一个用来当作哈希的字段名称。比如对象的名称为Ojbect:1234最终会被分为:
  • 一个键名: Ojbect:12
  • 字段名称为: 34

所以我们用处理最后两个字符以外的字符作为key,而最后两个字符作为哈希的字段名,为了设置我们的键,可以使用如下命令: HSET object:12 34 somevalue

正如你看到的,所有的哈希最终都能包含100个(00-99)字段(field),这是CPU和内存之间的最佳折中。

还有另外一个很重要的事需要注意,使用这个模式时,无论我们有多少数量的对象要缓存,我们都只能缓存100个左右的字段。这是因为,我们的对象最终都是以数字结尾,而不是随机字符串。在某种程度上,最后的数字可以看作是隐式预分片(pre-sharding)的一种方式。

那么小数字呢?比如Object:2? 我们可以把Object:当作键名,整个数字当作哈希的字段名。所以Object:10Object:2都以Ojbect:为键名,但是前者以``0当作字段名,后者则是2`。

这种方式下我们能节省多少内存?

我使用下面的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位实例上运行的结果:

  • UseOptimization 选项为true的时候: 使用了1.7 MB的内存
  • UseOptimization 选项为false的时候: 使用了11 MB的内存

这是一个数量级(an order of magnitude)的差距,我认为这或多或少的使Redis成为最省内存的普通键值存储。

警告 为了使其工作,你需要保证在redis.conf文件里存在如下设置:

hash-max-zipmap-entries 256

还要记得根据你键值对的最大值来设置下面的字段:

hash-max-zipmap-value 1024

每当哈希超出设置的最大数量,或者值超出了设置的最大体量,哈希都会被转化为真正的哈希表,内存节省的特性也就随之丢失。

你也许会问,为什么不在普通键空间上显示的做这些,有两个原因:一个是我们倾向于显式地权衡,而这里正是一个显式地权衡:CPU,内存,最大元素大小。另一个原因就是顶级的键空间需要支持许多有趣的特性,比如过期时间,LRU数据等等,所以普世地使用这种方法并不实用。

但是redis的方式是用户必须理解Reids是如何工作的,从而能够更好的选择折衷方案,以及能够更好的了解系统确切行为方式。

原文

Special encoding of small aggregate data types

补充知识

  • redis 底层数据结构 压缩列表 ziplist

  • 内存消耗分析

    • 指令 info memory
    • 指标:
      • used_memory_rss: redis进程所占用内存
      • used_memory: redis内部存储的所有数据内存占有量
      • used_fragmentation_ratio: used_memory_rss/used_memory,表示内存碎片率,大于1表示有碎片,小于1则存在内存交互到硬盘的情况
    • 内存消耗划分
      • used_memory
        • 自身内存
        • 对象内存(存储着用户所有的数据)
        • 缓冲内存
      • use_memory_rss-use_memory
        • 内存碎片