刚抱怨完没做过什么大数据,算法的同事就给我安排上了。一个约2亿条映射关系和1亿词条的字典,需要访问响应时间低于100ms。而且他没有给我找他讨价还价的机会就离职了。。。看来这个坑势必要我来填。好在Redis的内存优化策略就那么多,万变不离其宗,现在把经验分享出来,希望能够帮到大家。。。
分析
2亿映射关系,1亿词条,原数据大约50GB,100ms的响应要求。首先排除传统关系型数据库,这么多数据是必要分表,增加设计复杂度,响应时间的要求排除了几乎所有基于磁盘I/O的方案。当然,此处可能有杠精提到Mysql就有基于内存的查询引擎,也很快啊。这个问题容我先挖个坑,后面在记述Mysql的存储引擎时会专门分析的。那么基于内存的解决方案中,可以提供比较方便的查询功能并且可以持久化的解决方案就只有ES和Redis了。虽然ES能提供非常方便的查询功能,但要实现响应目标代价太大。为了满足响应要求,必然要为Lucene留够足够的内存来缓存数据,再加上es本身堆空间的占用,内存占用会到一个不合理的水平。
反观Redis,提供丰富的对象类型,高效的内存管理和压缩功能,强大的性能,有限的持久化,完善的高可用和集群拓展方案,完全符合业务需要。在本项目中,重点要解决的问题就是如何在保证响应要求的前提下,让内存需求最小化。
这么多数据用什么对象存?
根据官方介绍,单个实例支持2.5亿key。假如每个词条和映射都以key-value的形式保存,单实例貌似不够。虽然可以考虑分片,但是由于每个key-value都要申请额外的空间用于记录诸如对象空转时长这类属性,再考虑到海量的key将带来指针膨胀问题(考虑超过32位指针可表示空间后,扩容到64位指针所额外占用的空间),整个集群需要的内存空间将远超50GB,这是无论如何不能接受的(毕竟,我申请不到这么大的资源来干这么点事,而且给公司省钱就是体现我们价值的时候)。
针对这个问题,熟悉redis对象的朋友一定想到,使用哈希对象来减少key的数量。利用hset(key, field, value),将海量的key放进合理数量的桶里,这样就能减少key的数量,从而实现节约内存的目标。
利用ziplist进一步优化存储
你可能会迷惑,这个合理数量的桶是怎么回事,如何确定桶的数量。
这里需要先了解ziplist这个底层数据结构,不清楚的可以看黄建宏老师的《Redis设计与实践》或者《Redis使用手册》。哈希对象有两种底层实现,hashtable和ziplist,其中ziplist可以实现很高的压缩比,从而减少内存使用。缺点是ziplist实现的哈希对象,查找field的时间复杂度不是,因为它是是顺序结构。
ziplist不能过长,随着ziplist长度的增加,耗时也会线性增加。
哈希对象使用hashtable还是ziplist由hash-max-ziplist-entries和hash-max-ziplist-value控制。默认值分别是512和64,意味着哈希对象拥有少于512个键值对且每个键值对的大小少于64字节时,哈希对象会使用ziplist实现。我们需要根据自己的业务场景来调整这个两个值,先找出符合性能要求下这两个配置的最大值,然后根据entries的值来计算桶的数量。一般而言:
所以,我们需要通过合理的桶数量来保证每个哈希对象都能采用ziplist实现,从而实现减小内存占用的目标。
通过缩减key和value的长度优化存储
前面提到了哈希对象中保存的键值长度也会影响其实现形式。一方面我们可以通过将hash-max-ziplist-value改大来达到使用ziplist的目的,但另一方面,过大的value会带来性能损失,导致我们得不偿失。所以我们还需要想办法减小key和value的长度。考虑到对于存放在Redis中的数据,我们可能并不需要其有良好的可读性,因此可以采用以下三种策略:
- 简化内容。避免放入无关字段,合理使用缩写代替全称,尽量使用数字。比如在这个项目里,源文件每一行包含synonyms/inchi/标准smiles/异构smiles四个字段,但实际上我们只需要前三个数据,第四个是多余的,所以舍弃第四个值。在标识桶的时候,我们使用sy代替synonyms,i代替inchi作为key的前缀。尽量使用数字可以利用redis共享对象,从而减少内存使用(因为业务特点,字典不会过期,如果要求内容会被淘汰,则共享会失效)。
- 重新编码key。我们需要的可能只是value的值,而作为key的值过长,也不便于简化,此时就可以使用crc32或者murmurhash等信息摘要算法(或者说哈希函数,散列函数)将key压缩到固定长度。之后可以再利用ASCII码将算出来的摘要重新编码,进一步压缩长度。但是这里要注意选择合适的摘要算法,避免碰撞。这个办法可能只对预先知道内容且不会随便更改的场景,比如本例中的字典有用。
- 使用更高效的序列化方案。多用在value中,大多数时候为了方便,我们将对象序列化为json存放,这样也方便调试。但json相较二进制序列化方案(如protobuf/kryo),体积还是过大。此外如果待存放的value是超长字符串,也可以考虑用压缩算法先压缩后再放入redis。
验证
全量数据过多,我也申请不到那么大的机器来做对照组。因此这里只使用了1,000,000条synonyms到inchi的映射来做验证。synonyms大多数是系统命名法的结果,对于有机大分子,其长度可以达100个字符甚至更多,inchi则可达200个字符。所以使用murmurhash3算出128位的摘要之后,再将其转换为128进制,并用ASCII重新编码成长度为19的字符串(2.2亿数据需要28bit,但考虑到哈希碰撞概率应有至少64bit的长度,这里为了省去复核,假定128bit时完全没可能出现碰撞)。每个桶最大1000个元素,每个键值对最大64字节。
实验分三组:
- 对照组,一百万条映射完全按照k-v的形式存储
- A组,一百万条映射经过重编码后按照k-v的形式存储。
- B组,一百万条映射经过重编码后被分到2048个桶中
Redis使用docker-hub上拉取的5.09镜像,使用info memory命令读取内存使用
| 组别 | 对照组 | A | B |
|---|---|---|---|
| 内存占用 | 251MB | 97.45MB | 44.9MB |
单个文件占空间249MB,在redis中我略去了smiles,已经占用251MB,膨胀大约30%。考虑到数据量还未超过32位的可表示范围,因此未能反应出现指针膨胀的问题。据此估算2.2亿数据在redis中至少需要72GB。
经过编码缩减key和value的长度后,A组占用空间97.45MB较对照组减少61%。B组在A组的基础上进一步减少46%,此时只相当于对照组的18%。可见缩减key和valu的长度并使用ziplist之后,对于内存空间的优化还是非常明显的。
结论
综上所述,面对海量数据存放在Redis中的需求,主要思路还是根据业务特点选择合适的对象,然后尽可能利用ziplist这一类可以减小内存使用的底层实现,最后优化我们的key和value的长度。
一些细节问题
因为是公司项目,源码包含内部信息,不能直接贴出来。后续会在GitHub上更新脱敏的Demo,感兴趣可以关注,欢迎批评指正。这里就开发时遇到细节问题和大家一一说明。
注意ziplist的性能
ziplist不是银弹,发生连锁更新时最坏的时间复杂度是。所以千万注意利用ziplist减少内存占用时cpu开销的升高,这直接关系着QPS指标,为了压缩内存结果QPS不达标就本末倒置了。应当先测试出合适的
hash-max-ziplist-value和hash-max-ziplist-entries。一般来说,只查不改的场景,两个值可以高些,需要频繁修改的场景则需要更保守的值。
提前分桶的应用场景有限
分桶后,每个桶里键值对越少ziplist起到的压缩作用越小,越多性能越差,超过阈值会变成hashtable的实现。所以你可能需要提前知晓数据的规模,这个方案不适合不清楚数据规模的场景。如果确实无法估计,那只能使用redis的集群分片功能来拓展了。
注意哈希碰撞
如果你和我一样企图利用摘要算法来压缩过长的key,那么就要注意摘要长度的取舍。过短的摘要碰撞概率会增加,过长的摘要起不到压缩key长度的效果。
注意桶内键值对的数目
如果你参考我的代码分桶,需要注意一个问题。key的哈希值是离散的,利用映射到每个桶的键值对的数量是不均匀的。因此在设置桶数量的时候要留有充分的余量,否则部分桶可能会被转换位
hashtable实现。