11 | “万金油”的String,为什么不好用了?

437 阅读6分钟

1.举个栗子

  • 需求

    • 记录图片 ID(10 位数) 与 图片在存储系统中保存时的 ID(10 位数)
  • 分析

    • String 类型提供的“一个键对应一个值的数据”的保存形式刚好契合\

    • String 类型可以保存二进制字节流,就像“万金油”一样,只要把数据转成二进制字节数组,就可以保存了\

  • 使用string

    • 保存了 1 亿张图片,大约用了 6.4GB 的内存
  • 出现问题

    • 大内存 Redis 实例因为生成 RDB 而响应变慢的问题\
  • 分析原因

    • string保存数据时所消耗的内存空间较多
  • 解决办法

    • 不使用string存储
    • 改用存储效率更高的list,集合类型有非常节省内存空间的底层实现结构,
  • 新的问题

    • 集合类型保存的数据模式,是一个键对应一系列值,并不适合直接保存单值的键值对

2.为什么 String 类型内存开销大?

  • 分析 为什么1 亿张图片,大约用了 6.4GB 的内存  6.4GB/1亿约等于64字节

    • 一组图片 ID 及其存储对象 ID 的记录,实际只需要 16 字节(2个long类型 8+8=16)就可以了

    • String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据\

      • 当数据较小时,元数据显得占用空间大
    • string的存储方式

      • String 类型会把它保存为一个 8 字节的 Long 类型整数,这种保存方式通常也叫作 int 编码方式

      • 数据中包含字符时,String 类型就会用简单动态字符串 SDS\

        • buf 字节数组,保存实际数据\

        • len 占 4 个字节,表示 buf 的已用长度(额外开销)\

        • alloc 也占个 4 字节,表示 buf 的实际分配长度,一般大于 len(额外开销)\

      • 还有一个来自于 RedisObject 结构体的开销\

        • 8 字节的元数据\

        • 8 字节指针 指向实际数据\

      • long\

        • 如果是Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销
      • embstr\

        • 另一方面,当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片\
      • raw

        • 当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式
    • 因为 10 位数的图片 ID 和图片存储对象 ID 是 Long 类型整数,所以可以直接用 int 编码的 RedisObject 保存

    • 每个 int 编码的 RedisObject 元数据部分占 8 字节,指针部分被直接赋值为 8 字节的整数了(8+8)*2=32字节\

    • 哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对\

    • dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节\

    • 最后的8字节是因为jemalloc分配会分配2的整数倍

  • 有效的信息只有16字节,最后却存储了64字节

\

3.用什么数据结构可以节省内存?

  • Redis 有一种底层数据结构,叫压缩列表(ziplist),这是一种非常节省内存的结构\

  • 压缩列表\

    • zlbytes  列表长度\

    • zltail      列表尾的偏移量\

    • zllen      列表中的 entry 个数\

  • 每个 entry 的元数据\

    • prev_len 表示前一个 entry 的长度(1 字节或 5 字节)\

    • len:表示自身长度,4 字节;\

    • encoding:表示编码方式,1 字节;\

    • content:保存实际数据\

    • entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间(指针也会占据空间)\

  • 分析

    • 每个 entry 保存一个图片存储对象 ID(8 字节),此时,每个 entry 的 prev_len 只需要 1 个字节就行\

    • Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销\

    • 但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存\

  • 存在的问题

    • \

\

4.如何用集合类型保存单值的键值对?

  • 在保存单值的键值对时,可以采用基于 Hash 类型的二级编码方法

    • 把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value,这样一来,我们就可以把单值数据保存到 Hash 集合中了\

    • 以图片 ID 1101000060 和图片存储对象 ID 3302000080 为例,我们可以把图片 ID 的前 7 位(1101000)作为 Hash 类型的键,把图片 ID 的最后 3 位(060)和图片存储对象 ID 分别作为 Hash 类型值中的 key 和 value\

    • 相同前7位的图片就保存在一起了,并且通过hash key有效区分了别的数据(最后还控制在1000个,压缩了存储,不过查找效率就很低了)

  • 增加一条记录后,内存占用只增加了 16 字节\

  • Redis Hash 类型的两种底层实现结构,分别是压缩列表和哈希表\

  • Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了\

    • hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数**
      **

      • 写入的元素个数超过了 hash-max-ziplist-entries\
    • hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度**
      **

      • 写入的单个元素大小超过了 hash-max-ziplist-value\
  • 我们只用图片 ID 最后 3 位作为 Hash 集合的 key,也就保证了 Hash 集合的元素个数不超过 1000\

5.总结

  • 但是,在保存的键值对本身占用的内存空间不大时(例如这节课里提到的的图片 ID 和图片存储对象 ID),String 类型的元数据开销就占据主导了

    • RedisObject 结构\

    • SDS 结构\

    • dictEntry 结构