Redis所有的数据都存在内存中, 当前内存虽然越来越便宜, 但跟廉价的硬盘相比成本还是比较昂贵, 因此如何高效利用Redis内存变得非常重要。 高效利用Redis内存首先需要理解Redis内存消耗在哪里, 如何管理内存, 最后才能考虑如何优化内存。 掌握这些知识后能够实现用更少的内存存储更多的数据, 从而降低成本。
本篇内容包括
1. 内存消耗分析
2. 管理内存的原理与方法
3. 内存优化技巧
1. 内存消耗
理解Redis内存, 首先需要掌握Redis内存消耗在哪些方面。 有些内存消耗是必不可少的, 而有些可以通过参数调整和合理使用来规避内存浪费。 内存消耗可以分为进程自身消耗和子进程消耗。
内存使用统计
首先需要了解Redis自身使用内存的统计数据, 可通过执行info memory命令获取内存相关指标。 读懂每个指标有助于分析Redis内存使用情况:
属性名 | 属性说明 |
---|---|
used_memory | Redis分配器分配的内存总量,内存存储的所有数据内存占用量 |
used_memory_human | 以可读的格式返回used_memory |
used_memory_rss | 以操作系统的角度显示Redis进程占用的物理内存总量 |
used_memory_peak | 内存使用的最大值 |
used_memory_peak_human | 以可读的格式返回used_memory_peak |
used_memory_lua | Lua引擎消耗的内存大小 |
mem_fragmentation_ratio | used_memory_rss/used_memory比值,表示内存碎片率 |
mem_allocator | Redis所使用的内存分配器,默认为jemalloc |
需要重点关注的指标有: used_memory_rss和used_memory以及它们的比值mem_fragmentation_ratio。
当mem_fragmentation_ratio>1时, 说明used_memory_rss - used_memory多出的部分内存并没有用于数据存储, 而是被内存碎片所消耗, 如果两者相差很大, 说明碎片率严重。
当mem_fragmentation_ratio<1时, 这种情况一般出现在操作系统把Redis内存交换(Swap)到硬盘导致, 出现这种情况时要格外关注, 由于硬盘速度远远慢于内存, Redis性能会变得很差, 甚至僵死。
内存消耗划分
Redis进程内消耗主要包括: 自身内存+对象内存+缓冲内存+内存碎片,其中Redis空进程自身内存消耗非常少, 通常used_memory_rss在3MB左右,used_memory在800KB左右, 一个空的Redis进程消耗内存可以忽略不计。Redis主要内存消耗如图所示。
对象内存
对象内存是Redis内存占用最大的一块, 存储着用户所有的数据。Redis所有的数据都采用key-value数据类型, 每次创建键值对时, 至少创建两个类型对象: key对象和value对象。 对象内存消耗可以简单理解为sizeof(keys)+sizeof(values) 。 键对象都是字符串, 在使用Redis时很容易忽略键对内存消耗的影响, 应当避免使用过长的键。 value对象更复杂些, 主要包含5种基本数据类型: 字符串、 列表、 哈希、 集合、 有序集合。 其他数据类型都是建立在这5种数据结构之上实现的, 如: Bitmaps和HyperLogLog使用字符串实现, GEO使用有序集合实现等。每种value对象类型根据使用规模不同, 占用内存不同。 在使用时一定要合理预估并监控value对象占用情况, 避免内存溢出。缓冲内存
缓冲内存主要包括: 客户端缓冲、 复制积压缓冲区、 AOF缓冲区。
客户端缓冲指的是所有接入到Redis服务器TCP连接的输入输出缓冲。输入缓冲无法控制, 最大空间为1G, 如果超过将断开连接。 输出缓冲通过参数client-output-buffer-limit控制, 如下所示:
- 普通客户端
除了复制和订阅的客户端之外的所有连接, Redis的默认配置是: client-output-buffer-limit normal000, Redis并没有对普通客户端的输出缓冲区做限制, 一般普通客户端的内存消耗可以忽略不计, 但是当有大量慢连接客户端接入时这部分内存消耗就不能忽略了, 可以设置maxclients做限制。 特别是当使用大量数据输出的命令且数据无法及时推送给客户端时,如monitor命令, 容易造成Redis服务器内存突然飙升。 - 从客户端
主节点会为每个从节点单独建立一条连接用于命令复制,默认配置是: client-output-buffer-limit slave256mb64mb60。 当主从节点之间网络延迟较高或主节点挂载大量从节点时这部分内存消耗将占用很大一部分, 建议主节点挂载的从节点不要多于2个, 主从节点不要部署在较差的网络环境下, 如异地跨机房环境, 防止复制客户端连接缓慢造成溢出。 - 订阅客户端
当使用发布订阅功能时, 连接客户端使用单独的输出缓冲区, 默认配置为: client-output-buffer-limit pubsub32mb8mb60, 当订阅服务的消息生产快于消费速度时, 输出缓冲区会产生积压造成输出缓冲区空间溢出。
输入输出缓冲区在大流量的场景中容易失控, 造成Redis内存的不稳定, 需要重点监控。
复制积压缓冲区:Redis在2.8版本之后提供了一个可重用的固定大小缓冲区用于实现部分复制功能, 根据repl-backlog-size参数控制, 默认1MB。对于复制积压缓冲区整个主节点只有一个, 所有的从节点共享此缓冲区, 因此可以设置较大的缓冲区空间, 如100MB, 这部分内存投入是有价值的, 可以有效避免全量复制。
AOF缓冲区: 这部分空间用于在Redis重写期间保存最近的写入命令,AOF缓冲区空间消耗用户无法控制, 消耗的内存取决于AOF重写时间和写入命令量, 这部分空间占用通常很小。
- 内存碎片
Redis默认的内存分配器采用jemalloc, 可选的分配器还有: glibc、tcmalloc。 内存分配器为了更好地管理和重复利用内存, 分配内存策略一般采用固定范围的内存块进行分配。 例如jemalloc在64位系统中将内存空间划分为: 小、 大、 巨大三个范围。 每个范围内又划分为多个小的内存块单位,如下所示:
- 小: [8byte], [16byte, 32byte, 48byte, ..., 128byte], [192byte,256byte, ..., 512byte], [768byte, 1024byte, ..., 3840byte]
- 大: [4KB, 8KB, 12KB, ..., 4072KB]
- 巨大: [4MB, 8MB, 12MB, ...]
比如当保存5KB对象时jemalloc可能会采用8KB的块存储, 而剩下的3KB空间变为了内存碎片不能再分配给其他对象存储。 内存碎片问题虽然是所有内存服务的通病, 但是jemalloc针对碎片化问题专门做了优化, 一般不会存在过度碎片化的问题, 正常的碎片率(mem_fragmentation_ratio) 在1.03左右。 但是当存储的数据长短差异较大时, 以下场景容易出现高内存碎片问题:
- 频繁做更新操作, 例如频繁对已存在的键执行append、 setrange等更新操作。
- 大量过期键删除, 键对象过期删除后, 释放的空间无法得到充分利用, 导致碎片率上升。
出现高内存碎片问题时常见的解决方式如下:
- 数据对齐: 在条件允许的情况下尽量做数据对齐, 比如数据尽量采用数字类型或者固定长度字符串等, 但是这要视具体的业务而定, 有些场景无法做到。
- 安全重启: 重启节点可以做到内存碎片重新整理, 因此可以利用高可用架构, 如Sentinel或Cluster, 将碎片率过高的主节点转换为从节点, 进行安全重启。
子进程内存消耗
子进程内存消耗主要指执行AOF/RDB重写时Redis创建的子进程内存消耗。 Redis执行fork操作产生的子进程内存占用量对外表现为与父进程相同,理论上需要一倍的物理内存来完成重写操作。 但Linux具有写时复制技术(copy-on-write) , 父子进程会共享相同的物理内存页, 当父进程处理写请求时会对需要修改的页复制出一份副本完成写操作, 而子进程依然读取fork时整个父进程的内存快照。
Linux Kernel在2.6.38内核增加了Transparent Huge Pages(THP) 机制, 而有些Linux发行版即使内核达不到2.6.38也会默认加入并开启这个功能, 如Redhat Enterprise Linux在6.0以上版本默认会引入THP。 虽然开启THP可以降低fork子进程的速度, 但之后copy-on-write期间复制内存页的单位从4KB变为2MB, 如果父进程有大量写命令, 会加重内存拷贝量, 从而造成过度内存
消耗。 例如, 以下两个执行AOF重写时的内存消耗日志:
// 开启THP:
C * AOF rewrite: 1039 MB of memory used by copy-on-write
// 关闭THP:
C * AOF rewrite: 9 MB of memory used by copy-on-write
这两个日志出自同一Redis进程, used_memory总量为1.5GB, 子进程执行期间每秒写命令量都在200左右。 当分别开启和关闭THP时, 子进程内存消耗有天壤之别。 如果在高并发写的场景下开启THP, 子进程内存消耗可能是父进程的数倍, 极易造成机器物理内存溢出, 从而触发SWAP或OOM killer。
子进程内存消耗总结如下:
- Redis产生的子进程并不需要消耗1倍的父进程内存, 实际消耗根据期间写入命令量决定, 但是依然要预留出一些内存防止溢出。
- 需要设置sysctl vm.overcommit_memory=1允许内核可以分配所有的物理内存, 防止Redis进程执行fork时因系统剩余内存不足而失败。
- 排查当前系统是否支持并开启THP, 如果开启建议关闭, 防止copy-onwrite期间内存过度消耗。
2. 内存管理
Redis主要通过控制内存上限和回收策略实现内存管理, 本节将围绕这两个方面来介绍Redis如何管理内存。
设置内存上限
Redis使用maxmemory参数限制最大可用内存。 限制内存的目的主要有:
- 用于缓存场景, 当超出内存上限maxmemory时使用LRU等删除策略释放空间。
- 防止所用内存超过服务器物理内存。
需要注意, maxmemory限制的是Redis实际使用的内存量, 也就是used_memory统计项对应的内存。 由于内存碎片率的存在, 实际消耗的内存可能会比maxmemory设置的更大, 实际使用时要小心这部分内存溢出。 通过设置内存上限可以非常方便地实现一台服务器部署多个Redis进程的内存控制。 比如一台24GB内存的服务器, 为系统预留4GB内存, 预留4GB空闲内存给其他进程或Redis fork进程, 留给Redis16GB内存, 这样可以部署4个maxmemory=4GB的Redis进程。 得益于Redis单线程架构和内存限制机制, 即使没有采用虚拟化, 不同的Redis进程之间也可以很好地实现CPU和内存的隔离性。
动态调整内存上限
Redis的内存上限可以通过config set maxmemory进行动态修改, 即修改最大可用内存。 例如之前的示例, 当发现Redis-2没有做好内存预估, 实际只用了不到2GB内存, 而Redis-1实例需要扩容到6GB内存才够用, 这时可以分别执行如下命令进行调整:
Redis-1>config set maxmemory 6GB
Redis-2>config set maxmemory 2GB
如果此时Redis-3和Redis-4实例也需要分别扩容到6GB, 这时超出系统物理内存限制就不能简单的通过调整maxmemory来达到扩容的目的, 需要采用在线迁移数据或者通过复制切换服务器来达到扩容的目的。
Redis默认无限使用服务器内存, 为防止极端情况下导致系统内存耗尽, 建议所有的Redis进程都要配置maxmemory。
在保证物理内存可用的情况下, 系统中所有Redis实例可以调整maxmemory参数来达到自由伸缩内存的目的。
内存回收策略
Redis的内存回收机制主要体现在以下两个方面:
删除到达过期时间的键对象。
内存使用达到maxmemory上限时触发内存溢出控制策略。
- 删除过期键对象
Redis所有的键都可以设置过期属性, 内部保存在过期字典中。 由于进程内保存大量的键, 维护每个键精准的过期删除机制会导致消耗大量的CPU, 对于单线程的Redis来说成本过高, 因此Redis采用惰性删除和定时任务删除机制实现过期键的内存回收。
- 惰性删除
惰性删除用于当客户端读取带有超时属性的键时, 如果已经超过键设置的过期时间, 会执行删除操作并返回空, 这种策略是出于节省CPU成本考虑, 不需要单独维护TTL链表来处理过期键的删除。 但是单独用这种方式存在内存泄露的问题, 当过期键一直没有访问将无法得到及时删除, 从而导致内存不能及时释放。 正因为如此, Redis还提供另一种定时任务删除机制作为惰性删除的补充。 - 定时任务删除
Redis内部维护一个定时任务, 默认每秒运行10次(通过配置hz控制) 。 定时任务中删除过期键逻辑采用了自适应算法, 根据键的过期比例、 使用快慢两种速率模式回收键。
流程说明:
1)定时任务在每个数据库空间随机检查20个键, 当发现过期时删除对应的键。
2)如果超过检查数25%的键过期, 循环执行回收逻辑直到不足25%或运行超时为止, 慢模式下超时时间为25毫秒。
3)如果之前回收键逻辑超时, 则在Redis触发内部事件之前再次以快模式运行回收过期键任务, 快模式下超时时间为1毫秒且2秒内只能运行1次。
4)快慢两种模式内部删除逻辑相同, 只是执行的超时时间不同
- 内存溢出控制策略
当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。具体策略受maxmemory-policy参数控制, Redis支持6种策略。
1) noeviction: 默认策略, 不会删除任何数据, 拒绝所有写入操作并返回客户端错误信息( error) OOM command not allowed when used memory, 此时Redis只响应读操作。
2) volatile-lru: 根据LRU算法删除设置了超时属性( expire) 的键, 直到腾出足够空间为止。 如果没有可删除的键对象, 回退到noeviction策略。
3) allkeys-lru: 根据LRU算法删除键, 不管数据有没有设置超时属性,直到腾出足够空间为止。
4) allkeys-random: 随机删除所有键, 直到腾出足够空间为止。
5) volatile-random: 随机删除过期键, 直到腾出足够空间为止。
6) volatile-ttl: 根据键值对象的ttl属性, 删除最近将要过期数据。 如果没有, 回退到noeviction策略。
内存溢出控制策略可以采用config set maxmemory-policy{policy}动态配置。 Redis支持丰富的内存溢出应对策略, 可以根据实际需求灵活定制, 比如当设置volatile-lru策略时, 保证具有过期属性的键可以根据LRU剔除, 而未设置超时的键可以永久保留。 还可以采用allkeys-lru策略把Redis变为纯缓存服务器使用。 当Redis因为内存溢出删除键时, 可以通过执行info stats命令查看evicted_keys指标找出当前Redis服务器已剔除的键数量。
每次Redis执行命令时如果设置了maxmemory参数, 都会尝试执行回收内存操作。 当Redis一直工作在内存溢出(used_memory>maxmemory) 的状态下且设置非noeviction策略时, 会频繁地触发回收内存的操作, 影响Redis服务器的性能。 回收内存逻辑伪代码如下:
频繁执行回收内存成本很高, 主要包括查找可回收键和删除键的开销, 如果当前Redis有从节点, 回收内存操作对应的删除命令会同步到从节点, 导致写放大的问题。
建议线上Redis内存工作在maxmemory>used_memory状态下, 避免频繁内存回收开销。
对于需要收缩Redis内存的场景, 可以通过调小maxmemory来实现快速回收。 比如对一个实际占用6GB内存的进程设置maxmemory=4GB, 之后第一次执行命令时, 如果使用非noeviction策略, 它会一次性回收到maxmemory指定的内存量, 从而达到快速回收内存的目的。 注意, 此操作会导致数据丢失和短暂的阻塞问题, 一般在缓存场景下使用。
内存优化
Redis所有的数据都在内存中, 而内存又是非常宝贵的资源。 如何优化内存的使用一直是Redis用户非常关注的问题。 本节深入到Redis细节中, 探索内存优化的技巧。
-
redisObject对象
Redis存储的所有值对象在内部定义为redisObject结构体, 内部结构如图
Redis存储的数据都使用redisObject来封装, 包括string、 hash、 list、set、 zset在内的所有数据类型。 理解redisObject对内存优化非常有帮助, 下面针对每个字段做详细说明:
type字段: 表示当前对象使用的数据类型, Redis主要支持5种数据类型: string、 hash、 list、 set、 zset。 可以使用type{key}命令查看对象所属类型, type命令返回的是值对象类型, 键都是string类型。
encoding字段: 表示Redis内部编码类型, encoding在Redis内部使用,代表当前对象内部采用哪种数据结构实现。 理解Redis内部编码方式对于优化内存非常重要, 同一个对象采用不同的编码实现内存占用存在明显差异。
lru字段: 记录对象最后一次被访问的时间, 当配置了maxmemory和maxmemory-policy=volatile-lru或者allkeys-lru时, 用于辅助LRU算法删除键数据。 可以使用object idletime{key}命令在不更新lru字段情况下查看当前键的空闲时间。可以使用scan+object idletime命令批量查询哪些键长时间未被访问, 找出长时间不访问的键进行清理, 可降低内存占用。
refcount字段: 记录当前对象被引用的次数, 用于通过引用次数回收内存, 当refcount=0时, 可以安全回收当前对象空间。 使用object refcount{key}获取当前对象引用。 当对象为整数且范围在[0-9999]时, Redis可以使用共享对象的方式来节省内存。
*ptr字段: 与对象的数据内容相关, 如果是整数, 直接存储数据; 否则表示指向数据的指针。 Redis在3.0之后对值对象是字符串且长度<=39字节的数据, 内部编码为embstr类型, 字符串sds和redisObject一起分配, 从而只要一次内存操作即可。高并发写入场景中, 在条件允许的情况下, 建议字符串长度控制在39字节以内, 减少创建redisObject内存分配次数, 从而提高性能。
- 缩减键值对象
降低Redis内存使用最直接的方式就是缩减键(key) 和值(value) 的长度。
- key长度: 如在设计键时, 在完整描述业务情况下, 键值越短越好。 如user: {uid}: friends: notify: {fid}可以简化为u: {uid}: fs: nt: {fid}。
- value长度: 值对象缩减比较复杂, 常见需求是把业务对象序列化成二进制数组放入Redis。 首先应该在业务上精简业务对象, 去掉不必要的属性避免存储无效数据。 其次在序列化工具选择上, 应该选择更高效的序列化工具来降低字节数组大小。 以Java为例, 内置的序列化方式无论从速度还是压缩比都不尽如人意, 这时可以选择更高效的序列化工具, 如: protostuff、
kryo等,以下是Java常见序列化工具空间压缩对比。
其中java-built-in-serializer表示Java内置序列化方式, 更多数据见jvm-serializers项目: https://github.com/eishay/jvm-serializers/wiki, 其他语言也有各自对应的高效序列化工具。值对象除了存储二进制数据之外, 通常还会使用通用格式存储数据比如: json、 xml等作为字符串存储在Redis中。 这种方式优点是方便调试和跨语言, 但是同样的数据相比字节数组所需的空间更大, 在内存紧张的情况下, 可以使用通用压缩算法压缩json、 xml后再存入Redis, 从而降低内存占用, 例如使用GZIP压缩后的json可降低约60%的空间。
当频繁压缩解压json等文本数据时, 开发人员需要考虑压缩速度和计算开销成本, 这里推荐使用Google的Snappy压缩工具, 在特定的压缩率情况下效率远远高于GZIP等传统压缩工具, 且支持所有主流语言环境。
- 共享对象池
共享对象池是指Redis内部维护[0-9999]的整数对象池。 创建大量的整数类型redisObject存在内存开销, 每个redisObject内部结构至少占16字节, 甚至超过了整数自身空间消耗。 所以Redis内存维护一个[0-9999]的整数对象池, 用于节约内存。 除了整数值对象, 其他类型如list、 hash、 set、 zset内部元素也可以使用整数对象池。 因此开发中在满足需求的前提下, 尽量使用整数对象以节省内存。
整数对象池在Redis中通过变量REDIS_SHARED_INTEGERS定义, 不能通过配置修改。 可以通过object refcount命令查看对象引用数验证是否启用整数对象池技术, 如下:
redis> set foo 100
OK
redis> object refcount foo
(integer) 2
redis> set bar 100
OK
redis> object refcount bar
(integer) 3
设置键foo等于100时, 直接使用共享池内整数对象, 因此引用数是2,再设置键bar等于100时, 引用数又变为3。
使用整数对象池究竟能降低多少内存? 让我们通过测试来对比对象池的内存优化效果
使用共享对象池后, 相同的数据内存使用降低30%以上。 可见当数据大量使用[0-9999]的整数时, 共享对象池可以节约大量内存。 需要注意的是对象池并不是只要存储[0-9999]的整数就可以工作。 当设置maxmemory并启用LRU相关淘汰策略如: volatile-lru, allkeys-lru时, Redis禁止使用共享对象池, 测试命令如下:
redis> set key:1 99
OK // 设置key:1=99
redis> object refcount key:1
(integer) 2 // 使用了对象共享,引用数为2
redis> config set maxmemory-policy volatile-lru
OK // 开启LRU淘汰策略
redis> set key:2 99
OK // 设置key:2=99
redis> object refcount key:2
(integer) 3 // 使用了对象共享,引用数变为3
redis> config set maxmemory 1GB
OK // 设置最大可用内存
redis> set key:3 99
OK // 设置key:3=99
redis> object refcount key:3
(integer) 1 // 未使用对象共享,引用数为1
redis> config set maxmemory-policy volatile-ttl
OK // 设置非LRU淘汰策略
redis> set key:4 99
OK // 设置key:4=99
redis> object refcount key:4
(integer) 4 // 又可以使用对象共享,引用数变为4
为什么开启maxmemory和LRU淘汰策略后对象池无效?
LRU算法需要获取对象最后被访问时间, 以便淘汰最长未访问数据, 每个对象最后访问时间存储在redisObject对象的lru字段。 对象共享意味着多个引用共享同一个redisObject, 这时lru字段也会被共享, 导致无法获取每个对象的最后访问时间。 如果没有设置maxmemory, 直到内存被用尽Redis也不会触发内存回收, 所以共享对象池可以正常工作。
综上所述, 共享对象池与maxmemory+LRU策略冲突, 使用时需要注意。 对于ziplist编码的值对象, 即使内部数据为整数也无法使用共享对象池, 因为ziplist使用压缩且内存连续的结构, 对象共享判断成本过高, ziplist编码细节后面内容详细说明。
为什么只有整数对象池?
首先整数对象池复用的几率最大, 其次对象共享的一个关键操作就是判断相等性, Redis之所以只有整数对象池, 是因为整数比较算法时间复杂度为O(1) , 只保留一万个整数为了防止对象池浪费。 如果是字符串判断相等性, 时间复杂度变为O(n) , 特别是长字符串更消耗性能(浮点数在Redis内部使用字符串存储) 。 对于更复杂的数据结构如hash、 list等, 相等性判断需要O(n2) 。 对于单线程的Redis来说, 这样的开销显然不合理, 因此Redis只保留整数共享对象池。
- 字符串优化
字符串对象是Redis内部最常用的数据类型。 所有的键都是字符串类型, 值对象数据除了整数之外都使用字符串存储。 比如执行命令: lpush cache: type "redis" "memcache" "tair" "levelDB", Redis首先创建"cache: type"键字符串, 然后创建链表对象, 链表对象内再包含四个字符串对象, 排除Redis内部用到的字符串对象之外至少创建5个字符串对象。 可见字符串对象在Redis内部使用非常广泛, 因此深刻理解Redis字符串对于内存优化非常有帮助。
1)字符串结构
Redis没有采用原生C语言的字符串类型而是自己实现了字符串结构, 内部简单动态字符串(simple dynamic string, SDS)
Redis自身实现的字符串结构有如下特点:
- O(1) 时间复杂度获取: 字符串长度、 已用长度、 未用长度。
- 可用于保存字节数组, 支持安全的二进制数据存储。
- 内部实现空间预分配机制, 降低内存再分配次数。
- 惰性删除机制, 字符串缩减后的空间不释放, 作为预分配空间保留。
2)预分配机制
因为字符串(SDS) 存在预分配机制, 日常开发中要小心预分配带来的内存浪费
从测试数据可以看出, 同样的数据追加后内存消耗非常严重, 下面我们结合图来分析这一现象。 阶段1每个字符串对象空间占用如图
阶段1插入新的字符串后, free字段保留空间为0, 总占用空间=实际占用空间+1字节, 最后1字节保存‘\0’标示结尾, 这里忽略int类型len和free字段消耗的8字节。 在阶段1原有字符串上追加60字节数据空间占用如图
追加操作后字符串对象预分配了一倍容量作为预留空间, 而且大量追加操作需要内存重新分配, 造成内存碎片率(mem_fragmentation_ratio) 上升。
直接插入与阶段2相同数据的空间占用, 如图
阶段3直接插入同等数据后, 相比阶段2节省了每个字符串对象预分配的空间, 同时降低了碎片率。
字符串之所以采用预分配的方式是防止修改操作需要不断重分配内存和字节数据拷贝。 但同样也会造成内存的浪费。 字符串预分配每次并不都是翻倍扩容, 空间预分配规则如下:
1) 第一次创建len属性等于数据实际大小, free等于0, 不做预分配。
2) 修改后如果已有free空间不够且数据小于1M, 每次预分配一倍容量。 如原有len=60byte, free=0, 再追加60byte, 预分配120byte, 总占用空间: 60byte+60byte+120byte+1byte。
3) 修改后如果已有free空间不够且数据大于1MB, 每次预分配1MB数据。 如原有len=30MB, free=0, 当再追加100byte, 预分配1MB, 总占用空间: 1MB+100byte+1MB+1byte。
尽量减少字符串频繁修改操作如append、 setrange, 改为直接使用set修改字符串, 降低预分配带来的内存浪费和内存碎片化。
- 字符串重构
字符串重构: 指不一定把每份数据作为字符串整体存储, 像json这样的数据可以使用hash结构, 使用二级结构存储也能帮我们节省内存。 同时可以使用hmget、 hmset命令支持字段的部分读取修改, 而不用每次整体存取。 例如下面的json数据:
{
"vid": "413368768",
"title": "搜狐屌丝男士",
"videoAlbumPic":"http://photocdn.sohu.com/60160518/vrsa_ver8400079_ae433_pic26.jpg",
"pid": "6494271",
"type": "1024",
"playlist": "6494271",
"playTime": "468"
}
分别使用字符串和hash结构测试内存表现,
根据测试结构, 第一次默认配置下使用hash类型, 内存消耗不但没有降低反而比字符串存储多出2倍, 而调整hash-max-ziplist-value=66之后内存降低为535.60M。 因为json的videoAlbumPic属性长度是65, 而hash-max-ziplistvalue默认值是64, Redis采用hashtable编码方式, 反而消耗了大量内存。 调整配置后hash类型内部编码方式变为ziplist, 相比字符串更省内存且支持属性的部分操作。 下一节将具体介绍ziplist编码优化细节。
编码优化
- 了解编码
Redis对外提供了string、 list、 hash、 set、 zet等类型, 但是Redis内部针对不同类型存在编码的概念, 所谓编码就是具体使用哪种底层数据结构来实现。 编码不同将直接影响数据的内存占用和读写效率。 使用object encoding{key}命令获取编码类型。 如下所示:
redis> set str:1 hello
OK
redis> object encoding str:1
"embstr" // embstr编码字符串
redis> lpush list:1 1 2 3
(integer) 3
redis> object encoding list:1
"ziplist" // ziplist编码列表
Redis针对每种数据类型(type) 可以采用至少两种编码方式来实现,如下所示:
了解编码和类型对应关系之后, 我们不禁疑惑Redis为什么对一种数据结构实现多种编码方式?
主要原因是Redis作者想通过不同编码实现效率和空间的平衡。 比如当我们的存储只有10个元素的列表, 当使用双向链表数据结构时, 必然需要维护大量的内部字段如每个元素需要: 前置指针, 后置指针, 数据指针等, 造成空间浪费, 如果采用连续内存结构的压缩列表(ziplist) , 将会节省大量内存, 而由于数据长度较小, 存取操作时间复杂度即使为O(n2) 性能也可满足需求。
- 控制编码类型
编码类型转换在Redis写入数据时自动完成, 这个转换过程是不可逆的, 转换规则只能从小内存编码向大内存编码转换。 例如:
redis> lpush list:1 a b c d
(integer) 4 // 存储4个元素
redis> object encoding list:1
"ziplist" // 采用ziplist压缩列表编码
redis> config set list-max-ziplist-entries 4
OK // 设置列表类型ziplist编码最大允许4个元素
redis> lpush list:1 e
(integer) 5 // 写入第5个元素e
redis> object encoding list:1
"linkedlist" // 编码类型转换为链表
redis> rpop list:1
"a" // 弹出元素a
redis> llen list:1
(integer) 4 // 列表此时有4个元素
redis> object encoding list:1
"linkedlist" // 编码类型依然为链表, 未做编码回退
以上命令体现了list类型编码的转换过程, 其中Redis之所以不支持编码回退, 主要是数据增删频繁时, 数据向压缩编码转换非常消耗CPU, 得不偿失。 以上示例用到了list-max-ziplist-entries参数, 这个参数用来决定列表长度在多少范围内使用ziplist编码。 当然还有其他参数控制各种数据类型的编码:
续
掌握编码转换机制, 对我们通过编码来优化内存使用非常有帮助。 下面以hash类型为例, 介绍编码转换的运行流程
理解编码转换流程和相关配置之后, 可以使用config set命令设置编码相关参数来满足使用压缩编码的条件。 对于已经采用非压缩编码类型的数据如hashtable、 linkedlist等, 设置参数后即使数据满足压缩编码条件, Redis也不会做转换, 需要重启Redis重新加载数据才能完成转换。
-
ziplist编码
ziplist编码主要目的是为了节约内存, 因此所有数据都是采用线性连续的内存结构。 ziplist编码是应用范围最广的一种, 可以分别作为hash、 list、zset类型的底层数据结构实现。 首先从ziplist编码结构开始分析, 它的内部结构类似这样: <zlbytes><zltail><zllen><entry-1><entry-2><....><entry-n><zlend>。 一个ziplist可以包含多个entry(元素) , 每个entry保存具体的数据(整数或者字节数组) , 内部结构如图
ziplist结构字段含义:
1) zlbytes: 记录整个压缩列表所占字节长度, 方便重新调整ziplist空间。 类型是int-32, 长度为4字节。
2) zltail: 记录距离尾节点的偏移量, 方便尾节点弹出操作。 类型是int-32, 长度为4字节。
3) zllen: 记录压缩链表节点数量, 类型是int-16, 长度为2字节。
4) entry: 记录具体的节点, 长度根据实际存储的数据而定。
a) prev_entry_bytes_length: 记录前一个节点所占空间, 用于快速定位上一个节点, 可实现列表反向迭代。
b) encoding: 标示当前节点编码和长度, 前两位表示编码类型: 字符串/整数, 其余位表示数据长度。
c) contents: 保存节点的值, 针对实际数据长度做内存占用优化。
5) zlend: 记录列表结尾, 占用一个字节。
根据以上对ziplist字段说明, 可以分析出该数据结构特点如下:
- 内部表现为数据紧凑排列的一块连续内存数组。
- 可以模拟双向链表结构, 以O( 1) 时间复杂度入队和出队。
- 新增删除操作涉及内存重新分配或释放, 加大了操作的复杂性。
- 读写操作涉及复杂的指针移动, 最坏时间复杂度为O( n2) 。
- 适合存储小对象和长度有限的数据。
下面通过测试展示ziplist编码在不同类型中内存和速度的表现
测试数据采用100W个36字节数据, 划分为1000个键, 每个类型长度统一为1000。 从测试结果可以看出:
1) 使用ziplist可以分别作为hash、 list、 zset数据类型实现。
2) 使用ziplist编码类型可以大幅降低内存占用。
3) ziplist实现的数据类型相比原生结构, 命令操作更加耗时, 不同类型耗时排序: list<hash<zset。
ziplist压缩编码的性能表现跟值长度和元素个数密切相关, 正因为如此Redis提供了{type}-max-ziplist-value和{type}-max-ziplist-entries相关参数来做控制ziplist编码转换。 最后再次强调使用ziplist压缩编码的原则: 追求空间和时间的平衡。
针对性能要求较高的场景使用ziplist, 建议长度不要超过1000, 每个元素大小控制在512字节以内。
命令平均耗时使用info Commandstats命令获取, 包含每个命令调用次数、 总耗时、 平均耗时, 单位为微秒
- intset编码
intset编码是集合(set) 类型编码的一种, 内部表现为存储有序、 不重复的整数集。 当集合只包含整数且长度不超过set-max-intset-entries配置时被启用。 执行以下命令查看intset表现:
redis> sadd set:test 3 4 2 6 8 9 2
(integer) 6 // 乱序写入6个整数
Redis> object encoding set:test
"intset" // 使用intset编码
Redis> smembers set:test
"2" "3" "4" "6" "8" "9" // 排序输出整数结合
redis> config set set-max-intset-entries 6
OK // 设置intset最大允许整数长度
redis> sadd set:test 5
(integer) 1 // 写入第7个整数 5
redis> object encoding set:test
"hashtable" // 编码变为hashtable
redis> smembers set:test
"8" "3" "5" "9" "4" "2" "6" // 乱序输出
以上命令可以看出intset对写入整数进行排序,通过O(log(n))时间复杂度实现查找和去重操作, intset编码结构如图
intset的字段结构含义:
1) encoding: 整数表示类型, 根据集合内最长整数值确定类型, 整数类型划分为三种: int-16、 int-32、 int-64。
2) length: 表示集合元素个数。
3) contents: 整数数组, 按从小到大顺序保存。
intset保存的整数类型根据长度划分, 当保存的整数超出当前类型时,将会触发自动升级操作且升级后不再做回退。 升级操作将会导致重新申请内存空间, 把原有数据按转换类型后拷贝到新数组。
使用intset编码的集合时, 尽量保持整数范围一致, 如都在int-16范围内。 防止个别大整数触发集合升级操作, 产生内存浪费。
下面通过测试查看ziplist编码的集合内存和速度表现
根据以上测试结果发现intset表现非常好, 同样的数据内存占用只有不到hashtable编码的十分之一。 intset数据结构插入命令复杂度为O(n) , 查询命令为O(log(n) ) , 由于整数占用空间非常小, 所以在集合长度可控的基础上, 写入命令执行速度也会非常快, 因此当使用整数集合时尽量使用intset编码。 测试第三行把ziplist-hash类型也放入其中, 主要因为intset编码必须存储整数, 当集合内保存非整数数据时, 无法使用intset实现内存优化。 这时可以使用ziplist-hash类型对象模拟集合类型, hash的field当作集合中的元素, value设置为1字节占位符即可。 使用ziplist编码的hash类型依然比使用hashtable编码的集合节省大量内存。
- 控制键的数量
当使用Redis存储大量数据时, 通常会存在大量键, 过多的键同样会消耗大量内存。 Redis本质是一个数据结构服务器, 它为我们提供多种数据结构, 如hash、 list、 set、 zset等。 使用Redis时不要进入一个误区, 大量使用get/set这样的API, 把Redis当成Memcached使用。 对于存储相同的数据内容利用Redis的数据结构降低外层键的数量, 也可以节省大量内存。 如图所示, 通过在客户端预估键规模, 把大量键分组映射到多个hash结构中降低键的数量。
hash结构降低键数量分析:
- 根据键规模在客户端通过分组映射到一组hash对象中, 如存在100万个键, 可以映射到1000个hash中, 每个hash保存1000个元素。
- hash的field可用于记录原始key字符串, 方便哈希查找。
- hash的value保存原始值对象, 确保不要超过hash-max-ziplist-value限制。
通过这个测试数据, 可以说明:
- 同样的数据使用ziplist编码的hash类型存储比string类型节约内存。
- 节省内存量随着value空间的减少越来越明显。
- hash-ziplist类型比string类型写入耗时, 但随着value空间的减少, 耗时逐渐降低。