Redis 实战 —— 12. 降低内存占用
简介
降低 Redis 的内存占用有助于减少创建快照和加载快照所需的时间、提升载入 AOF 文件和重写 AOF 文件时的效率、缩短从服务器进行同步所需的时间(快照、 AOF 文件重写在 持久化选项 中进行了介绍,从服务器同步在 复制、处理故障、事务及性能优化 中进行了介绍),并且能让 Redis 存储更多的数据而无需添加额外的硬件。 P208
短结构 (short structure) P208
Redis 为列表、集合、散列和有序集合提供了一组配置选项,这些选项可以让 Redis 以更节约空间的方式存储长度较短的结构(后面简称“短结构”)。 P208
在列表、散列和有序集合的长度较短或者体积较小的时候, Redis 可以选择使用一种名为压缩列表 (ziplist) 的紧凑存储方式来存储这些机构。压缩列表是列表、散列和有序集合这 3 种不同类型的对象的一种非结构化 (unstructured) 表示:与 Redis 在通常情况下使用双向链表表示列表、使用散列表表示散列、使用散列表加上跳表 (skiplist) 表示有序集合的做法不同,压缩列表会以序列化的方式存储数据,这些序列化数据每次被读取的时候都要进行解码,每次被写入的时候也要进行局部的重新编码,并且可能需要对内存里面的数据进行移动。 P209
压缩列表表示 P209
本节以最简单的列表进行观察对比。
双向链表 P209
列表不进行压缩时使用双向链表 (doubly linked list) 进行存储,链表的每个结点都有三个指针: P209
- 指向前一个结点的指针
- 指向后一个结点的指针
- 指向结点包含的字符串值的指针
其中字符串值又分为三个部分: P209
- 字符串的长度
- 字符串剩余可用的字节数
- 以空字符结尾的字符串本身
可以发现未压缩前,每存储一个字符串,最少需要 21 字节的额外开销 (overhead) 。(三个指针每个占 4 个字节,两个整数每个占 4 个字节,字符串结尾的空字符占 1 个字节) P209
压缩列表 P209
压缩列表是由结点(非真实结点)组成的序列 (sequence) ,每个结点都由两个长度值和一个字符串组成。 P209
- 第一个长度值:前一个结点的长度,用于从后向前的遍历(一般以一个字节存储)
- 第二个长度值:当前结点的长度(一般以一个字节存储)
- 字符串:长度等于字节数,没有空字符
可以发现压缩后,每存储一个字符串,最少需要 2 字节的额外开销。 P210
使用压缩列表编码 P210
不同结构关于使用压缩列表的配置选项 P210
# 列表使用压缩列表表示的限制条件
list-max-ziplist-entries 512
list-max-ziplist-value 64
# 散列使用压缩列表表示的限制条件
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
# 有序集合使用压缩列表表示的限制条件
zset-max-ziplist-entries 512
zset-max-ziplist-value 64
其中, ...-entries
选项说明列表、散列和有序集合在被编码为压缩列表的情况下,允许包含的最大元素数量; ...-value
选项说明了压缩列表每个结点的最大体积是多少字节。当这些选项设置的限制条件中的任意一个被突破时, Redis 就会将对应的列表、散列和有序集合从压缩列表编码转换为其他结构,而内存占用也会因此增加,并且即使其将来重新满足限制条件,也不会再转换回压缩列表。 P210
调试 P210
OBJECT
命令允许从内部查看给定 key 的 Redis 对象, 它通常用在调试(debugging) 或者了解为了节省空间而对 key 使用特殊编码的情况。当将Redis 用于进行缓存时,也可以通过 OBJECT
命令中的信息,决定 key 的驱逐策略 (eviction policies) 。
-
OBJECT REFCOUNT <key>
: 返回给定 key 引用所储存的值的次数。主要用于调试 -
OBJECT ENCODING <key>
: 返回给定 key 所储存的值所使用的内部表示(representation) -
OBJECT IDLETIME <key>
: 返回给定 key 自储存以来的空闲时间(idle, 没有被读取也没有被写入),以秒为单位
集合的整数集合编码 P211
如果集合的所有成员都可以被解释为十进制整数(在平台的有符号整数范围内),并且集合成员的数量足够少,那么 Redis 就会以有序整数数组的方式存储集合,这种存储方式又被称为整数集合 (intset) 。整数集合不仅可以降低内存消耗,还可以提升所有标准集合操作的执行速度。 P211
整数集合的配置选项 P211
# 集合使用整数集合表示的限制条件
set-max-intset-entries 512
当整数集合包含当元素数量超过配置选项设定的限制时,整数集合将被转换为散列表表示。 P212
长压缩列表和大整数集合带来的性能问题 P212
压缩列表结点数 | 性能 |
---|---|
< 1000 | 差别不大 |
5000 ~ 10000 | 开始下降 |
50000 | 下降明显 |
> 100000 | 低到无法使用 |
推荐将压缩列表的长度限制在 1024 个元素内,并且每个元素的体积不能超过 64 字节,对于大多数散列应用来说,这种配置可以同时兼顾低内存占用和高性能这两方面优点。 P214
注
Redis 在 3.2 版本后的列表底层默认使用 quicklist ,这种数据结构兼顾了双向链表和压缩列表的优点,因此列表目前来说已使用最优配置。
我们在设计 Redis 时同时也要保持键名简短(包括数据键、散列的域、集合和有序集合的成员以及所有列表的结点),当存储结点的数据量达到上百万个或者数十亿个时,将能节省 MB 升至 GB 级的空间。 P214
分片结构 P214
分片 (sharding) 本质上就是基于某些简单的规则将数据划分为更小的部分,然后根据数据所属的部分来决定将数据发送到哪个位置上面。这种技术可以扩展存储空间并提高所能处理的负载量。 P214
接下来将把分片的概念应用到散列、集合和有序集合上,并在讲解实现这些数据结构的其中一部分标准功能的方法。这种情况下,程序不再是将值 X
存储到键 Y
里面,而是将值 X
存储到键 Y:<shardid>
里面。 P214
对列表进行分片 P214
在不使用 Lua 脚本的情况下对列表进行分配非常困难,因此将在后面介绍使用 Lua 脚本构建一个分片式的列表,并支持以阻塞和非阻塞两种方式从列表的两端进行推入和弹出操作。
对有序集合进行分片 P215
因为 ZRANGE
, ZRANGEBYSCORE
, ZRANK
, ZCOUNT
, ZREMRANGE
, ZREMRANGEBYSCORE
这类命令的分片版本需要对有序集合的所有分片进行操作才能计算出命令的最终结果,所以这些操作无法运行得像普通的有序集合操作那么快,因此对有序集合进行分片的作用不大。
如果需要将完整的信息存储到一个体积较大的有序集合中,但只会对分值排名前 n 位和后 n 位对元素进行操作,那么可以使用下面介绍对散列分片对方法对有序集合进行分片,并维护额外对最高分值对有序集合和最低分值对有序集合,然后通过 ZADD
命令为这两个有序集合添加新元素,并通过 ZREMRANGEBYRANK
命令确保元素对数量不会超过限制。 P215
分片式散列 P215
对散列的键进行划分时,可以把散列存储的键作为一个信息源,并使用散列函数为键计算出一个数字散列值,然后根据需要存储的键的总数量以及每个分片需要存储的键数量,计算出所需的分片数,最后使用分片数和散列只决定应把键存储到哪个分片里面。 P215
所思
其实我们平时在考虑分片这种形式的时候是不太会考虑到键的总数量的这种条件,基本上是根据现有的数据进行分析后设定一个分片数量 shard_num
,这样当有一个键 key
需要计算对应的分片时,只需要 cal_hash(key) % shard_num
即可得到对应的 shard_id
。但类似 CRC32
和 MD5
这种方式进行散列值时有一个问题,就是书中提到的当分片数量改变时,会有大量键的新旧散列值不同,就需要将数据迁移至新散列值对应的 shard_id
。为了避免这样的情况,就需要一致性哈希算法,使得分片数量改变时需要迁移的数据尽量小一点,并保证迁移后的数据仍能够较为均匀的在每个分片上。
将字符串存储到散列里面 P217
如果发现将很多相关联的短字符串或者数字存储到了字符串键里面,并且持续地将这些键命名为 namespace:id
这样的形式,那么可以考虑将这些值存储到分片散列里面,在某些情况下,这种做法可以明显减少内存占用。 P217
分片集合 P218
集合一样可以通过类似散列的方式处理键获得分片 id ,进而改造相应的命令支持分片式操作。
如果键是整数且最大值相对较小,那么除了直接使用键获取分片 id ,还可以使用位图 (bitmap) 记录每个键是否在“集合”中。 P221
如果键是整数,数量非常多,无法全部存下,但又能容忍一定的误差,那么可以使用布隆过滤器记录每个键是否在“集合”中(判断为不存在时,则必定不存在;判断为存在时,有极低概率不存在)。
打包存储二进制位和字节 P221
前面提到当使用类似 namespace:id
这样当字符串键去存储短字符串或者计数器时,使用分片散列可以有效降低存储这些数据所需当内存。但是,如果被存储的是一些简短并且长度固定当连续 id ,那么我们还有使用比分片散列更为节约内存当数据存储方法可用。 P221
Redis 数据结构常用命令简介 中介绍过可用于高效打包和更新 Redis 字符串的四个命令: P221
-
GETRANGE
: 用于读取被存储字符串的其中一部分内容 -
SETRANGE
: 用于对存储在字符串里面的其中一部分内容进行设置 -
GETBIT
: 用于获取字符串里面某个二进制位的值 -
SETBIT
: 用于对字符串里面某个二进制位进行设置
通过这四个命令,我们可以在不对数据进行压缩的情况下,使用 Redis 字符串以尽可能紧凑的格式去存储计数器、定长字符串、布尔值等数据。 P221
决定被存储位置信息的格式 P221
我们以存储的信息是用户的位置信息为例,不同内存的使用量决定了不同的位置精度: P221
- 1 字节:精确到国家
- 2 字节:精确到国家及所在州/省
- 3 字节:精确到邮政编码
- 4 字节:精确到经纬度(2 米)
这里我们用 2 字节存储位置信息,首先我们可以使用一个数组存储所有国家(或地区)的 ISO3 国家(或地区)编码,然后用第一个字节存储所在国家(或地区)在数组中的下标。然后我们可以使用一个 map ,同样使用数组存储每个国家(或地区)的州/省信息,用第二个字节存储所在州/省在对应数组中的下标。 P222
存储打包后的数据 P223
获取到位置信息对应到两个字节到数据后,就可以使用 SETRANGE
命令将其存储到字符串键里面去了。但是还需要考虑用户的总量,假如用户数量达到 7.5 亿,那么需要 1.5 GB 内存存储所有用户的数据,但 Redis 的字符串键最大只能存储 512 MB 数据,并且 Redis 在对现有的字符串进行设置的时候,如果被设置的部分超过了现有字符串的末尾,那么 Redis 可能需要分配更多的内存以存储新数据,因此对一个长字符串的末尾进行设置,耗时要比执行一个简单的 SETBIT
调用多得多。为了解决上述问题,我们需要将数据分片到多个字符串键里面。 P223
我们可以在每个字符串里面存储 2^20 个用户的位置信息,这相当于在字符串里面构建 100 多万个节点,而这样的字符串需要占 2 MB 的内存。 P223
对分片字符串进行聚合计算 P224
对所有用户的位置信息进行聚合计算 P224
找到提前存储的最大的用户 id ,然后计算最大分片 id ,遍历每个字符串分片中的每个用户的数据(使用 GETRANGE
分块获取数据),根据两个字节对应的下标找到对应的国家(或地区)及州/省信息,然后统计即可。
对指定用户的位置信息进行聚合计算 P226
遍历每个指定的用户 id ,计算其对应的分片 id 和分片中的偏移量,使用 GETRANGE
获取对应的两个字节,根据两个字节对应的下标找到对应的国家(或地区)及州/省信息,然后统计即可。
本文首发于公众号:满赋诸机(点击查看原文) 开源在 GitHub :reading-notes/redis-in-action