如何以最节省内存的方式在Redis中缓存百亿数据?

1,271 阅读7分钟

先讲一个曾经处理过的真实案例!

**业务场景:**根据人群标签进行互联网广告的定向投放,我们期望每个请求都能足够快递获取到用户的标签信息,尽快完成处理逻辑并响应。

**缓存方案:**由于数据量是10亿级别,将来可能会是百亿级别,进程内缓存直接不考虑。全都是key-value数据,所以决定采用redis集群作为缓存。

方案很符合常理!于是我们开始向测试的redis集群疯狂输出,很快写了3000万条数据进去,以为一切都会很顺利。但天有不测风云!监控显示内存不足!测试集群三台(4核8G)机器,每台机器上一主一从,这才存了3000万数据而已,真存百亿数据的话内存成本就太高了。

怎么办呢?显然key太多,内存膨胀明显!改用Hash类型存储这些数据,使用一致性哈希取余的方法,将海量用户分配到2^n个Hash对象上,把用户标签存储在Hash对象的field中。于是key的数量有了数量级的下降,且由于Redis对Hash数据的压缩编码,实际节省内存将近80%。

以上是Redis内存优化的方法之一,也是业界很多人都会用的方案。Redis本身提供了很多内存优化方法,继续往下看,我结合官方文章来做一个全面介绍!

1.针对聚合数据类型进行的特殊编码

Redis从2.2版本开始,对许多数据类型都进行了优化,小于一定大小的情况下可以使用更少的存储空间,包括Hash、List、仅由整数组成的Set以及Sorted Set。当不超过最大值时,会以内存高效的方式对数据进行编码,最高可以减少90%的内存占用(平均节省80%),这对于用户和API来说是完全透明的。

**马克思主义哲学告诉我们一个真理:任何事物总是存在矛盾的。所以我们经常面临着权衡的问题,不能两全其美的时候,就要寻求平衡。**Redis的这种优化就是在内存和CPU之间的权衡,用户可以使用redis.conf中的配置去调整可支持特殊编码的元素最大数量和最大大小,下面是几个相关的配置项:

hash-max-ziplist-entries 512 //hash中最大的field数量
hash-max-ziplist-value 64    //hash中的每个field-name和field-value最大不超过64 bytes
list-max-ziplist-size -2     // -2 8kb,-1 4kb
zset-max-ziplist-entries 128 //zset类型支持压缩编码的最大元素数量
zset-max-ziplist-value 64    //zset中每个元素最大不超过64 bytes
set-max-intset-entries 512   //由64位有符号int组成的set 支持压缩编码的最大元素数量

以上配置在redis官方的redis.conf文件模板中有详细地说明,有兴趣可以去看看。

如果超过了配置的上限,Redis会自动将其转换为普通编码。对于较小的值,这种转换是非常快的。但要是想通过修改配置,对更大的值进行特殊编码,建议做好基准测试,确认转换的耗时,以免影响服务稳定性。

2.使用32位Redis实例

使用32位目标编译的Redis,每个键使用的内存非常少,因为指针比较小。但是,32位Redis实例的内存使用量上限是4GB(指的是key使用的内存,寻址空间只有2的32次方,也就是4GB)。RDB和AOF文件在32位和64位Redis实例上是兼容的(而且也兼容高位字节序和低位字节序),你可以从32位切换为64位,或者相反,都没有问题。

3.位和字节级操作

从Redis2.2开始,引入了一些新的位、字节级操作:GETRANGE、SETRANGE、GETBIT和SETBIT。使用这些命令,你可以将Redis的string类型当成可以随机访问的数组。

假设你有一个应用,使用递增的唯一整数来标识用户,你可以使用bitmap(位图)来保存某个邮件列表的用户订阅情况,每一个bit都代表一个用户的订阅状态。设置指定位代表订阅,清除指定位代表取消订阅。在一个Redis实例中,1亿用户的订阅信息只需要12M内存空间。

4.官方建议:尽可能使用Hash

将数据抽象成内存高效的Hash结构存储在Redis中,这也是上面案例中使用的方法。

对于较小的Hash数据,在编码后会占用很少的空间。所以,尽可能把数据组织成Hash。例如:如果在web应用程序中有表示用户的对象,那么不要为名称、姓氏、电子邮件、密码使用不同的键,而是使用一个包含所有必需字段的Hash。

在Redis中,一定数量的key占用的内存要大于单个key包含一定数量的Hash字段。

如何做到的呢?为了保证查询操作是常数时间,Redis使用了常数时间复杂度的数据结构,比如Hash Table。大多时候Hash只包含少数的几个字段,比较小,此时使用O(N)复杂度的数据结构,就像以键值对组成的线性数组。因为只有N比较小的时候才会这样做,所以HGET和HSET操作所花费的时间平摊下来也是O(1),线性数组可以比哈希表更好地利用CPU缓存(如果不太明白,那你需要复习下计算机原理了)。当Hash所包含元素的数量增长太大,超过最大限制时(可以在redis.conf中修改这个限制),它会被转换成一个真正的哈希表。这里再次体现了两个字:权衡!

不过,Hash的字段并不是拥有完整特性的Redis对象,无法像一个真正的key一样设置过期时间,而且值只能是字符串。但这并没有什么问题,简单比特性丰富更重要,这是Redis官方的设计意图和哲学

5.关于内存分配

为了保存用户的key,Redis最多分配maxmemory设置所允许的内存,但实际上可能会有少量的额外内存。关于Redis的内存管理,以下几点值得关注:

  • 当某些key被删除后,Redis并不总是把内存返还给操作系统,并不是因为Redis故意这样做,这是大部分内存分配函数的实现方式所导致的。例如一个被5GB数据填充的Redis实例,当我们删除2GB的数据后,RSS(Resident Set Size,驻留集大小,即进程消费的内存页大小,这涉及到操作系统的页式虚拟内存管理)可能依然是5GB左右,即使Redis显示用户使用的内存有3GB左右。这是因为底层操作系统的内存分配器无法轻易地释放内存,被删除的key有可能和其它未被删除的key位于同一个内存页。操作系统是以页为单位给进程分配内存的。

  • 基于上一点,我们需要根据内存使用的峰值来分配内存,假设大部分时间只用5GB左右数据,偶尔需要10GB内存,那也要按照10GB来提供。

  • 内存分配器是很机智的,可以有效利用空闲的内存块,因此当你释放5GB数据中的2GB后,再添加更多key时,会发现RSS是保持稳定的,并不会增长太多。分配器会尝试复用之前被逻辑上释放的2GB内存。

如果maxmemory没有设置,Redis将会在找到合适的内存时继续进行分配,这样会逐步消耗掉所有空闲内存,建议配置一下这个上限。当内存达到上限时再执行写命令,Redis会返回一个内存不足的错误。这可能会导致应用程序的错误,但不会因为内存不足导致整个机器宕机。

以上就是Redis内存优化的一些方法,抛砖引玉,希望对你有所启发。官方的文档里有很多干货,建议大家多看看。