11 | “万金油”的String,为什么不好用了?
11 | “万金油”的String,为什么不好用了?
讲述:蒋德钧
时长16:21大小14.97M
为什么 String 类型内存开销大?
用什么数据结构可以节省内存?
如何用集合类型保存单值的键值对?
小结
每课一问
赞 161
提建议
精选留言(142)
- Kaito2020-08-31保存图片的例子,除了用String和Hash存储之外,还可以用Sorted Set存储(勉强)。 Sorted Set与Hash类似,当元素数量少于zset-max-ziplist-entries,并且每个元素内存占用小于zset-max-ziplist-value时,默认也采用ziplist结构存储。我们可以把zset-max-ziplist-entries参数设置为1000,这样Sorted Set默认就会使用ziplist存储了,member和score也会紧凑排列存储,可以节省内存空间。 使用zadd 1101000 3302000080 060命令存储图片ID和对象ID的映射关系,查询时使用zscore 1101000 060获取结果。 但是Sorted Set使用ziplist存储时的缺点是,这个ziplist是需要按照score排序的(为了方便zrange和zrevrange命令的使用),所以在插入一个元素时,需要先根据score找到对应的位置,然后把member和score插入进去,这也意味着Sorted Set插入元素的性能没有Hash高(这也是前面说勉强能用Sorte Set存储的原因)。而Hash在插入元素时,只需要将新的元素插入到ziplist的尾部即可,不需要定位到指定位置。 不管是使用Hash还是Sorted Set,当采用ziplist方式存储时,虽然可以节省内存空间,但是在查询指定元素时,都要遍历整个ziplist,找到指定的元素。所以使用ziplist方式存储时,虽然可以利用CPU高速缓存,但也不适合存储过多的数据(hash-max-ziplist-entries和zset-max-ziplist-entries不宜设置过大),否则查询性能就会下降比较厉害。整体来说,这样的方案就是时间换空间,我们需要权衡使用。 当使用ziplist存储时,我们尽量存储int数据,ziplist在设计时每个entry都进行了优化,针对要存储的数据,会尽量选择占用内存小的方式存储(整数比字符串在存储时占用内存更小),这也有利于我们节省Redis的内存。还有,因为ziplist是每个元素紧凑排列,而且每个元素存储了上一个元素的长度,所以当修改其中一个元素超过一定大小时,会引发多个元素的级联调整(前面一个元素发生大的变动,后面的元素都要重新排列位置,重新分配内存),这也会引发性能问题,需要注意。 另外,使用Hash和Sorted Set存储时,虽然节省了内存空间,但是设置过期变得困难(无法控制每个元素的过期,只能整个key设置过期,或者业务层单独维护每个元素过期删除的逻辑,但比较复杂)。而使用String虽然占用内存多,但是每个key都可以单独设置过期时间,还可以设置maxmemory和淘汰策略,以这种方式控制整个实例的内存上限。 所以在选用Hash和Sorted Set存储时,意味着把Redis当做数据库使用,这样就需要务必保证Redis的可靠性(做好备份、主从副本),防止实例宕机引发数据丢失的风险。而采用String存储时,可以把Redis当做缓存使用,每个key设置过期时间,同时设置maxmemory和淘汰策略,控制整个实例的内存上限,这种方案需要在数据库层(例如MySQL)也存储一份映射关系,当Redis中的缓存过期或被淘汰时,需要从数据库中重新查询重建缓存,同时需要保证数据库和缓存的一致性,这些逻辑也需要编写业务代码实现。 总之,各有利弊,我们需要根据实际场景进行选择。展开共 47 条评论497
- Wangxi2020-08-31实测老师的例子,长度7位数,共100万条数据。使用string占用70mb,使用hash ziplist只占用9mb。效果非常明显。redis版本6.0.6
作者回复: 赞 实践的态度!
共 11 条评论154 - 注定非凡2020-09-09一,作者讲了什么? Redis的String类型数据结构,及其底层实现 二,作者是怎么把这事给说明白的? 1,通过一个图片存储的案例,讲通过合理利用Redis的数据结构,降低资源消耗 三,为了讲明白,作者讲了哪些要点?有哪些亮点? 1,亮点1:String类型的数据占用内存,分别是被谁占用了 2,亮点2:可以巧妙的利用Redis的底层数据结构特性,降低资源消耗 3,要点1: Simple Dynamic String结构体( buf:字节数组,为了表示字节结束,会在结尾增加“\0” len: 占4个字节,表示buf的已用长度 alloc:占4个字节,表示buf实际分配的长度,一般大于len) 4,要点2: RedisObject 结构体( 元数据:8字节(用于记录最后一次访问时间,被引用次数。。。) 指针:8字节,指向具体数据类型的实际数据所在 ) 5,要点3:dicEntry 结构体( key:8个字节指针,指向key value:8个字节指针,指向value next:指向下一个dicEntry) 6,要点4:ziplist(压缩列表)( zlbytes:在表头,表示列表长度 zltail:在表头,表示列尾偏移量 zllen:在表头,表示列表中 entry:保存数据对象模型 zlend:在表尾,表示列表结束) entry:( prev_len:表示一个entry的长度,有两种取值方式:1字节或5字节。 1字节表示一个entry小于254字节,255是zlend的默认值,所以不使用。 len:表示自身长度,4字节 encodeing:表示编码方式,1字节 content:保存实际数据) 5,要点4:String类型的内存空间消耗 ①,保存Long类型时,指针直接保存整数数据值,可以节省空间开销(被称为:int编码) ②,保存字符串,且不大于44字节时,RedisObject的元数据,指针和SDS是连续的,可以避免内存碎片(被称为:embstr编码) ③,当保存的字符串大于44字节时,SDS的数据量变多,Redis会给SDS分配独立的空间,并用指针指向SDS结构(被称为:raw编码) ④,Redis使用一个全局哈希表保存所以键值对,哈希表的每一项都是一个dicEntry,每个dicEntry占用32字节空间 ⑤,dicEntry自身24字节,但会占用32字节空间,是因为Redis使用了内存分配库jemalloc。 jemalloc在分配内存时,会根据申请的字节数N,找一个比N大,但最接近N的2的幂次数作为分配空间,这样可以减少频繁分配内存的次数 4,要点5:使用什么数据结构可以节省内存? ①, 压缩列表,是一种非常节省内存的数据结构,因为他使用连续的内存空间保存数据,不需要额外的指针进行连接 ②,Redis基于压缩列表实现List,Hash,Sorted Set集合类型,最大的好处是节省了dicEntry开销 5,要点6:如何使用集合类型保存键值对? ①,Hash类型设置了用压缩列表保存数据时的两个阀值,一旦超过就会将压缩列表转为哈希表,且不可回退 ②,hash-max-ziplist-entries:表示用压缩列表保存哈希集合中的最大元素个数 ③,hash-max-ziplist-value:表示用压缩列表保存时,哈希集合中单个元素的最大长度 四,对于作者所讲,我有哪些发散性思考? 看了老师讲解,做了笔记,又看了黄建宏写的《Redis 设计与实现》 有这样的讲解: 当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist 编码: 1. 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节; 2. 哈希对象保存的键值对数量小于 512 个; 五,在将来的哪些场景中,我能够使用它? 这次学习Redis数据结构特性有了更多了解,在以后可以更加有信心根据业务需要,选取特定的数据结构展开共 4 条评论94
- super BB💨🐷2020-09-11老师,我之前看到《redis设计与实现》中提出SDS 的结构体的中没有alloc字段,书中的提到的是free,用来表示buf数组未使用的字节长度
作者回复: 学习的很仔细! 《Redis设计与实现》这本书分析的代码是Redis3.0的源码,在Redis3.0.4源码中,SDS结构体里还是用的free表示未使用空间。 但是应该差不多是Redis3.2.0开始,SDS结构体开始使用alloc字段了。
共 4 条评论61 - 吕2020-09-01我有一个疑惑,老师,文中的案例,这么大的数据量,为什么采用redis这种内存数据库来存储数据么呢,感觉它的业务场景还是不很清楚?直接采用mysql存储会有什么问题么?
作者回复: 这是个好问题。 其实这个图片ID和存储对象ID对应关系的存储,就是用在分布式存储系统中的一个小的元数据服务,访问模式也比较简单,key-value的PUT、GET就行,但是要求请求响应快。Redis很轻量级,而且速度也快,所以用的Redis。 MySQL用在这个场景中显得有些太重了,这个场景里面没有关系模型,也没有事务需求和复杂查询,上MySQL不太需要。图片数量再增加时,MySQL的表就太大了,插入效率会降低。
共 5 条评论43 - 吴永祺2020-10-14hset 1101000 060 3302000080 操作为何只占用16字节?哈希键(060)、值(3302000080)两者各占用一个entry,按文中介绍,应该至少占用28字节。 其中原因,我认为很可能是文中对ziplist entry的介绍有误,参考下面GitHub文章,entry中并没有len字段,entry长度由encoding表示。所以例子中虽然创建两个entry,但总长度是小于16的。 参考:https://github.com/zpoint/Redis-Internals/blob/5.0/Object/hash/hash_cn.md展开共 9 条评论27
- zhou2020-09-01hset 1101000 060 3302000080 这条记录只消耗 16 字节没明白,压缩列表保存一个对象需要 14 字节,060、3302000080 都需要保存,那应该至少大于 28 字节共 36 条评论19
- 蓝魔丶2020-09-02老师,测试环境:redis5.0.4 1.实践采用String方案:set 1101000052 3301000051,查看内存增加的72,而不是64,是为什么? 2.实践采用Hash方案:hset 1101000 060 3302000080 查看内存增加88,再次添加key-value,才是满足增加16共 6 条评论12
- Hm_2020-12-14老师有个地方不是很理解,文中讲到String需要dictEntry来保存键值关系,那么hash结构最外层的那个key没有dictEntry来维护吗?因为我记得之前讲得Redis是用一个大的hash来维护所有的键值对的,所以感觉这和dictEntry所占用的内存是一样的吧
作者回复: dictEntry是Redis全局哈希表中的表项,包含了key和value的指针,这里的value可以是String,List,Hash等数据类型。 你说的hash结构最外层的key,如果是指全局哈希表中的key的话,指向key的指针是已经包含在dictEntry这个结构中了,同时key本身的数据结构是RedisObject。
共 4 条评论11 - aworker2021-05-06看好多小伙伴里面对embstr的临界点是44字节的算法有疑问,给大家解释下: 首先说下redisObject的数据结构包含如下字段: type:表明redisobjct的类型如果string,hash,set等,占4字节。 encoding:编码类型,占4字节。 lru:记录此object最近被访问的时间数据和过期逻辑有关,4字节。 refcount:记录指向此object的指针数量,用来表示不同指针相同数据的情况,4个字节。 ptr:真正指向具体数据的指针,8字节。 在3.2版本以前的sds结构如下: len:表示sds中buf已经使用的长度,4个字节。 free:表示sds中buf没有使用的长度,4个字节。 buf:数组,真正存储数据的地方,会有1字节的‘\0’,表示数据的结尾。 如果想让string类型用embstr编码那么需要满足如下条件: 64-16(redisObjct除去ptr后最小使用的内存)-(4+4+1)(sds最小内存需求)=39 。 redis 3.2及其以后的版本sds会根据实际使用的buf长度,采用不同的sds对象表示,这里只说下小于等64字节的sds对象结构: len:表示buf的使用长度,1字节。 alloc:表示分配给buf的总长度,1字节。 flag:表示具体的sds类型,1个字节。 buf:真正存储数据的地方,肯定有1字节的‘\0’表示结束符。 如果想让redis3.2以后的版本使用embstr编码的string需要buf满足的最大值为: 64-16(redisObject最小值)-(1+1+1+1)(sds最小用的内存)= 44字节。 这里需要澄清一点:相较于raw编码,当redis采用 embstr编码的时候,redis会吧redisObjct的元数据和sds连续存在,这就节约了ptr指针的内存使用,这也是redis要分embstr和raw的原因。老师的图中embstr编码也有8字节的指针,这个应该是不准确的。 随着版本升级也能看出redis开发者对高效数据结构的极致追求:在3.2版本以前的sds中用4个字节表示buf的已用长度和未使用长度,但对于embstr编码的sds是有些浪费的,因为buf最大值是39字节,1字节就可以表示255的长度了,所以3.2以后的embstr编码的sds的 len和alloc都是一个字节。展开共 3 条评论10
- yyl2020-09-03“在节省内存空间方面,哈希表就没有压缩列表那么高效了” 在内存空间的开销上,也许哈希表没有压缩列表高效 但是哈希表的查询效率,要比压缩列表高。 在对查询效率高的场景中,可以考虑空间换时间
作者回复: 其实,在Redis的设计和使用上,是一个典型的“系统”思维,也就是权衡(trade-off),根据自己的业务场景、数据量、访问特征,来进行选择。 我们自己做系统研发,这是个核心思想 :)
共 3 条评论10 - 小喵喵2020-09-01老师,请教下,这样拆分的话,如何重复了咋办呢? 以图片 ID 1101000060 和图片存储对象 ID 3302000080 为例,我们可以把图片 ID 的前 7 位(1101000)作为 Hash 类型的键,把图片 ID 的最后 3 位(060)和图片存储对象 ID 分别作为 Hash 类型值中的 key 和 value。 比如:两张图片分别为:图片 ID 1101000060 图片存储对象 ID 3302000080; 图片 ID 1101001060 图片存储对象 ID 3302000081 这个时候最后 3 位(060)的key是冲突的的,但是它的图片存储对象 ID不同。展开
作者回复: 我们是会把图片 ID 的前7位作为键值对的key,Hash集合是键值对的value,在你举的例子中,图片ID 1101000060和1101001060。它们的前7位分别是1101000和1101001,对应了两个键值对。所以,它们图片ID的后3位虽然相同,都是060,也是在两个Hash集合中的,不会冲突的。 你看看是不是呢?
共 10 条评论10 - test2020-09-02虽然压缩列表可以节约内存,但是set和get的时间复杂度为O(N),一个时间换空间的方法。共 3 条评论9
- 一步2020-09-01使用 http://www.redis.cn/redis_memory/ 这个网站来计算 文章中 一亿张图片ID消耗的内存, 为什么得出来 9269.71M, 9点多个 G呢? 1亿个 string , string 的 key 和 value 分别是 8个 字节9
- HappyHasson2022-03-03hset 1101000 060 3302000080 为什么这条语句执行之后内存增加了16B? 老师的前提是执行命令之前已经有了hash key 1101000。然后插入fieldkey:060 fieldvalue:3302000080 fieldkey和fieldvalue各分配一个ziplist entry,hset时,会调用ziplistPush函数先把fieldkey放到ziplist表尾,然后再放fieldvalue。之所以是16字节,是老师讲解的有点问题。ziplist entry包含三个字段previous_entry_length、encoding、content。没有老师说的len这个固定4字节的字段 previous_entry_length取值规则:https://github.com/zpoint/Redis-Internals/blob/5.0/Object/hash/prevlen.png encoding取值规则:https://github.com/zpoint/Redis-Internals/blob/5.0/Object/hash/encoding.png 所以这个命令的fieldkey占用字节:1+1+1=3、fieldvalue占用字节:1+1+8(64位整数表示3302000080)展开共 1 条评论7
- wwj2020-09-27这样是不是有点本位倒置了,缓存本来就是以空间换时间的,这样节省了空间,但是时间复杂度也上去了共 3 条评论6
- MClink2020-08-31老师,底层数据结构的转换是怎么实现的呢?是单纯的开一个新的数据结构再把数据复制过去吗?再释放之前的数据结构的内存,复制过程中有修改值的话要怎么处理,复制过程中不就两倍内存消耗了6
- 慎独明强2020-08-31看了Redis设计与实现,有讲SDS这一块,对于老师分析的内容,自己心里有印象,再结合老师今天的实践案例,前面的知识还没有吃透 😂😂6
- 我不用网名2020-09-19看了很久, 一直没有静下心来仔细品味, 今天又重新将课程内容梳理了一遍,有几个问题, 希望老师能解答一下: 1. redis把sds使用raw编码还是embstr编码的临界值是44, 这个44是如何计算出来的呢? 按文档中sds len(4) + alloc(4) + 1(\0) + redisObject(元素数据8+指针8) = 25, jemalloc 将超过64字节的使用raw编码, 这样的话, buff 的最大值 64-25=39. 这也是我看网上其它资料时写的reids.3.2版本之前使用的值. 3.2及以后使用44. 老师文档中sds各字段的大小是不是标错了? 2. hash类型使用ziplist存储数据时, key/value的映射关系是发何保持的呢? 我自己有如下猜想,存储结构是不是这样? zlbytes zltail zllen key1 value1 key2 value2 ... zlend 如果是这样存储的话, 又有以下两个问题困绕着我: 在hash中通过某个key获取对应value时,需要遍历整个压缩列表吧. 这会不会有性能影响? key与value都紧挨着存储, entry里面并没有字段用于标记该entry是key还是value, 假如 key与value是相同的字段串时, 即有两个相同的entry, reids如何识别哪个是key,哪个是value呢? 对于后面一个问题,我自己可以猜想一下的, 就是key与value是成对出现的, key 永远是在寄数位 value永远是在偶数位. 这样也可以分辨, 如果是我来实现的话,可能会这样. 但我不懂c,没看过源码, 请老师专业解答我才放心. 感谢!展开共 1 条评论5
- zachary2021-06-25通过查看redis的源码,sds数据结构这块老师讲的不是很对。可能跟redis的版本有关吧。redis在高版本对sds做了优化,sds将不再直接使用结构体。sds底层通过sdshdr_5, sdshdr_8, sdshdr_16, sdshdr_32, sdshdr_64来实现。这么做的目的明显是为了更加节省空间。以下是源码,版本4.0.9, 文件sds.h。 typedef char *sds; /* Note: sdshdr5 is never used, we just access the flags byte directly. * However is here to document the layout of type 5 SDS strings. */ struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ char buf[]; };展开共 2 条评论4