理解Redis内存,首先需要掌握Redis内存消耗在哪些方面。
有些内存消耗是必不可少的,而有些可以通过参数调整和合理使用来规避内存浪费。
内存消耗可以分为进程自身消耗和子进程消耗。
首先需要了解Redis自身使用内存的统计数据,可通过执行info memory命令获取内存相关指标。
读懂每个指标有助于分析Redis内存使用情况,表8-1列举出内存统计指标和对应解释。
需要重点关注的指标有: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主要内存消耗如图8-1所示。下面介绍另外三种内存消耗。
对象内存是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做限制。
从客户端:主节点会为每个从节点单独建立一条连接用于命令复制,默认配置是:client-output-buffer-limit slave256mb64mb60。
订阅客户端:当使用发布订阅功能时,连接客户端使用单独的输出缓冲区,默认配置为:client-output-buffer-limit pubsub32mb8mb60,当订阅服务的消息生产快于消费速度时,输出缓冲区会产生积压造成输出缓冲区空间溢出。
输入输出缓冲区在大流量的场景中容易失控,造成Redis内存的不稳定,需要重点监控,具体细节见4.4节中客户端管理部分。
复制积压缓冲区:Redis在2.8版本之后提供了一个可重用的固定大小缓冲区用于实现部分复制功能,根据repl-backlog-size参数控制,默认1MB。
对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间,如100MB,这部分内存投入是有价值的,可以有效避免全量复制,更多细节见第6.4节。
AOF缓冲区:这部分空间用于在Redis重写期间保存最近的写入命令,具体细节见5.2节。AOF缓冲区空间消耗用户无法控制,消耗的内存取决于AOF重写时间和写入命令量,这部分空间占用通常很小。
Redis默认的内存分配器采用jemalloc,可选的分配器还有:glibc、tcmalloc。
内存分配器为了更好地管理和重复利用内存,分配内存策略一般采用固定范围的内存块进行分配。
例如jemalloc在64位系统中将内存空间划分为:小、大、巨大三个范围。每个范围内又划分为多个小的内存块单位, 如下所示:
·小:[8byte],[16byte,32byte,48byte,...,128byte],[192byte, 428 256byte,...,512byte],[768byte,1024byte,...,3840byte] ·大:[4KB,8KB,12KB,...,4072KB] ·巨大:[4MB,8MB,12MB,...]
比如当保存5KB对象时jemalloc可能会采用8KB的块存储,而剩下的3KB空间变为了内存碎片不能再分配给其他对象存储。
内存碎片问题虽然是所有内存服务的通病,但是jemalloc针对碎片化问题专门做了优化,一般不会存在过度碎片化的问题,正常的碎片率(mem_fragmentation_ratio)在1.03左右。
但是当存储的数据长短差异较大时,以下场景容易出现高内存碎片问题:
出现高内存碎片问题时常见的解决方式如下:
子进程内存消耗主要指执行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或OOMkiller,更多关于THP细节见12.1节“Linux配置优化”。
子进程内存消耗总结如下:
Redis主要通过控制内存上限和回收策略实现内存管理,本节将围绕这两个方面来介绍Redis如何管理内存。
Redis使用maxmemory参数限制最大可用内存。限制内存的目的主要有:
由于内存碎片率的存在,实际消耗的内存可能会比maxmemory设置的更大,实际使用时要小心这部分内存溢出。
通过设置内存上限可以非常方便地实现一台服务器部署多个Redis进程的内存控制。
比如一台24GB内存的服务器,为系统预留4GB内存,预留4GB空闲内存给其他进程或Redis fork进程,留给Redis16GB内存,这样可以部署4个maxmemory=4GB的Redis进程。
得益于Redis单线程架构和内存限制机制,即使没有采用虚拟化,不同的Redis进程之间也可以很好地实现CPU和内存的隔离性,如图8-2所示。
Redis的内存上限可以通过config set maxmemory进行动态修改,即修改最大可用内存。
例如之前的示例,当发现Redis-2没有做好内存预估,实际只用了不到2GB内存,而Redis-1实例需要扩容到6GB内存才够用,这时可以分别执行如下命令进行调整:
Redis-1>config set maxmemory 6GB Redis-2>config set maxmemory 2GB
通过动态修改maxmemory,可以实现在当前服务器下动态伸缩Redis内存的目的,如图8-3所示。
这个例子过于理想化,如果此时Redis-3和Redis-4实例也需要分别扩容到6GB,这时超出系统物理内存限制就不能简单的通过调整maxmemory来达到扩容的目的,需要采用在线迁移数据或者通过复制切换服务器来达到扩容 的目的。
具体细节见第9章“哨兵”和第10章“集群”部分。
运维提示
Redis默认无限使用服务器内存,为防止极端情况下导致系统内存耗尽,建议所有的Redis进程都要配置maxmemory。
在保证物理内存可用的情况下,系统中所有Redis实例可以调整maxmemory参数来达到自由伸缩内存的目的。
Redis的内存回收机制主要体现在以下两个方面:
Redis所有的键都可以设置过期属性,内部保存在过期字典中。
由于进程内保存大量的键,维护每个键精准的过期删除机制会导致消耗大量的CPU,对于单线程的Redis来说成本过高,因此Redis采用惰性删除和定时任务删除机制实现过期键的内存回收。
流程说明:
当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。
具体策略受maxmemory-policy参数控制,Redis支持6种策略,如下所示:
内存溢出控制策略可以采用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服务器的性能。
回收内存逻辑伪代码如下:
def freeMemoryIfNeeded() : int mem_used, mem_tofree, mem_freed; // 计算当前内存总量,排除从节点输出缓冲区和AOF缓冲区的内存占用 int slaves = server.slaves; mem_used = used_memory()-slave_output_buffer_size(slaves)-aof_rewrite_buffer_ size(); // 如果当前使用小于等于maxmemory退出 if (mem_used <= server.maxmemory) : return REDIS_OK; // 如果设置内存溢出策略为noeviction(不淘汰),返回错误。 if (server.maxmemory_policy == 'noeviction') : return REDIS_ERR; // 计算需要释放多少内存 mem_tofree = mem_used - server.maxmemory; // 初始化已释放内存量 mem_freed = 0; // 根据maxmemory-policy策略循环删除键释放内存 while (mem_freed < mem_tofree) : // 迭代Redis所有数据库空间 for (int j = 0; j < server.dbnum; j++) : String bestkey = null; dict dict; if (server.maxmemory_policy == 'allkeys-lru' || server.maxmemory_policy == 'allkeys-random'): // 如果策略是 allkeys-lru/allkeys-random // 回收内存目标为所有的数据库键 dict = server.db[j].dict; else : // 如果策略是volatile-lru/volatile-random/volatile-ttl // 回收内存目标为带过期时间的数据库键 dict = server.db[j].expires; // 如果使用的是随机策略,那么从目标字典中随机选出键 if (server.maxmemory_policy == 'allkeys-random' || server.maxmemory_policy == 'volatile-random') : // 随机返回被删除键 bestkey = get_random_key(dict); else if (server.maxmemory_policy == 'allkeys-lru' || server.maxmemory_policy == 'volatile-lru') : // 循环随机采样maxmemory_samples次(默认5次),返回相对空闲时间最长的键 bestkey = get_lru_key(dict); else if (server.maxmemory_policy == 'volatile-ttl') : // 循环随机采样maxmemory_samples次,返回最近将要过期的键 bestkey = get_ttl_key(dict); // 删除被选中的键 if (bestkey != null) : long delta = used_memory(); deleteKey(bestkey); // 计算删除键所释放的内存量 delta -= used_memory(); mem_freed += delta; // 删除操作同步给从节点 if (slaves): flushSlavesOutputBuffers(); return REDIS_OK;
从伪代码可以看到,频繁执行回收内存成本很高,主要包括查找可回收键和删除键的开销,如果当前Redis有从节点,回收内存操作对应的删除命令会同步到从节点,导致写放大的问题,如图8-5所示。
运维提示
建议线上Redis内存工作在maxmemory>used_memory状态下,避免频繁内存回收开销。
对于需要收缩Redis内存的场景,可以通过调小maxmemory来实现快速回收。
比如对一个实际占用6GB内存的进程设置maxmemory=4GB,之后第一次执行命令时,如果使用非noeviction策略,它会一次性回收到maxmemory指定的内存量,从而达到快速回收内存的目的。
注意,此操作会导致数据丢失和短暂的阻塞问题,一般在缓存场景下使用。
Redis所有的数据都在内存中,而内存又是非常宝贵的资源。如何优化内存的使用一直是Redis用户非常关注的问题。
本节深入到Redis细节中,探索内存优化的技巧。
Redis存储的所有值对象在内部定义为redisObject结构体,内部结构如图 8-6所示。
Redis存储的数据都使用redisObject来封装,包括string、hash、list、set、zset在内的所有数据类型。
理解redisObject对内存优化非常有帮助,下面针对每个字段做详细说明:
开发提示
可以使用scan+object idletime命令批量查询哪些键长时间未被访问,找 出长时间不访问的键进行清理,可降低内存占用。
开发提示
高并发写入场景中,在条件允许的情况下,建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数,从而提高性能。
降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。
其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。以Java为例,内置的序列化方式无论从速度还是压缩比都不尽如人意,这时可以选择更高效的序列化工具,如:protostuff、kryo等,图8-7是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,如图8-8所示。
使用整数对象池究竟能降低多少内存?让我们通过测试来对比对象池的内存优化效果,如表8-2所示。
注意
本章所有测试环境都保持一致,信息如下: 服务器信息:cpu=Intel-Xeon E5606@2.13GHz memory=32GB Redis版本:Redis server v=3.0.7sha=00000000:0malloc=jemalloc- 3.6.0bits=64
使用共享对象池后,相同的数据内存使用降低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内部最常用的数据类型。所有的键都是字符串类型,值对象数据除了整数之外都使用字符串存储。比如执行命令:lpushcache:type"redis""memcache""tair""levelDB",Redis首先创建"cache:type"键 字符串,
然后创建链表对象,链表对象内再包含四个字符串对象,排除Redis内部用到的字符串对象之外至少创建5个字符串对象。
可见字符串对象在Redis内部使用非常广泛,因此深刻理解Redis字符串对于内存优化非常有帮助。
Redis没有采用原生C语言的字符串类型而是自己实现了字符串结构,内部简单动态字符串(simple dynamic string,SDS)。结构如图8-9所示。
Redis自身实现的字符串结构有如下特点:
因为字符串(SDS)存在预分配机制,日常开发中要小心预分配带来的内存浪费,例如表8-3的测试用例。
从测试数据可以看出,同样的数据追加后内存消耗非常严重,下面我们结合图来分析这一现象。
阶段1每个字符串对象空间占用如图8-10所示。
阶段1插入新的字符串后,free字段保留空间为0,总占用空间=实际占用空间+1字节,最后1字节保存‘\0’标示结尾,这里忽略int类型len和free字段消耗的8字节。
在阶段1原有字符串上追加60字节数据空间占用如图8-11所示。
追加操作后字符串对象预分配了一倍容量作为预留空间,而且大量追加操作需要内存重新分配,造成内存碎片率(mem_fragmentation_ratio)上升。直接插入与阶段2相同数据的空间占用,如图8-12所示。
阶段3直接插入同等数据后,相比阶段2节省了每个字符串对象预分配的空间,同时降低了碎片率。
字符串之所以采用预分配的方式是防止修改操作需要不断重分配内存和字节数据拷贝。
但同样也会造成内存的浪费。字符串预分配每次并不都是翻倍扩容,空间预分配规则如下:
开发提示
尽量减少字符串频繁修改操作如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结构测试内存表现,如表8-4所示。
根据测试结构,第一次默认配置下使用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内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实现。
编码不同将直接影响数据的内存占用和读写效率。使用objectencoding{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)可以采用至少两种编码方式来实现,表8-5表示type和encoding的对应关系。
了解编码和类型对应关系之后,我们不禁疑惑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编码。当然还有其他参数控制各种数据类型的编码,如表8-6所示。
掌握编码转换机制,对我们通过编码来优化内存使用非常有帮助。下面以hash类型为例,介绍编码转换的运行流程,如图8-13所示。
理解编码转换流程和相关配置之后,可以使用config set命令设置编码相关参数来满足使用压缩编码的条件。
对于已经采用非压缩编码类型的数据如hashtable、linkedlist等,设置参数后即使数据满足压缩编码条件,Redis也不会做转换,需要重启Redis重新加载数据才能完成转换。
ziplist编码主要目的是为了节约内存,因此所有数据都是采用线性连续的内存结构。
ziplist编码是应用范围最广的一种,可以分别作为hash、list、zset类型的底层数据结构实现。首先从ziplist编码结构开始分析,它的内部结构类似这样:
<zlbytes><zltail><zllen><entry-1><entry-2><....><entry-n><zlend>。
一个ziplist可以包含多个entry(元素),每个entry保存具体的数据(整数或者字节数组),内部结构如图8-14所示。
ziplist结构字段含义:
a) prev_entry_bytes_length:记录前一个节点所占空间,用于快速定位上一个节点,可实现列表反向迭代。
b)encoding:标示当前节点编码和长度,前两位表示编码类型:字符串/整数,其余位表示数据长度。
c)contents:保存节点的值,针对实际数据长度做内存占用优化。
根据以上对ziplist字段说明,可以分析出该数据结构特点如下:
测试数据采用100W个36字节数据,划分为1000个键,每个类型长度统一为1000。从测试结果可以看出:
ziplist压缩编码的性能表现跟值长度和元素个数密切相关,正因为如此
Redis提供了{type}-max-ziplist-value和{type}-max-ziplist-entries相关参数来做控制ziplist编码转换。
最后再次强调使用ziplist压缩编码的原则:追求空间和时间的平衡。
开发提示
针对性能要求较高的场景使用ziplist,建议长度不要超过1000,每个元素大小控制在512字节以内。
命令平均耗时使用info Commandstats命令获取,包含每个命令调用次数、总耗时、平均耗时,单位为微秒。
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编码结构如图8-15所示。
intset的字段结构含义:
intset保存的整数类型根据长度划分,当保存的整数超出当前类型时,将会触发自动升级操作且升级后不再做回退。
升级操作将会导致重新申请内存空间,把原有数据按转换类型后拷贝到新数组。
开发提示
使用intset编码的集合时,尽量保持整数范围一致,如都在int-16范围 内。防止个别大整数触发集合升级操作,产生内存浪费。
下面通过测试查看ziplist编码的集合内存和速度表现,如表8-8所示。
根据以上测试结果发现intset表现非常好,同样的数据内存占用只有不到hashtable编码的十分之一。
intset数据结构插入命令复杂度为O(n),查询命令为O(log(n)),由于整数占用空间非常小,所以在集合长度可控的基础上,写入命令执行速度也会非常快,因此当使用整数集合时尽量使用intset编码。
表8-8测试第三行把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的数据结构降低外层键的数量,也可以节省大量内存。
如图8-16所示,通过在客户端预估键规模,把大量键分组映射到多个hash结构中降低键的数量。
hash结构降低键数量分析:
通过这个测试数据,可以说明:
使用hash重构后节省内存量效果非常明显,特别对于存储小对象的场景,内存只有不到原来的1/5。下面分析这种内存优化技巧的关键点:
hash类型节省内存的原理是使用ziplist编码,如果使用hashtable编码方式反而会增加内存消耗。
ziplist长度需要控制在1000以内,否则由于存取操作时间复杂度在O(n)到O(n2)之间,长列表会导致CPU消耗严重,得不偿失。
ziplist适合存储小对象,对于大对象不但内存优化效果不明显还会增加命令操作耗时。
需要预估键的规模,从而确定每个hash结构需要存储的元素数量。
根据hash长度和元素大小,调整hash-max-ziplist-entries和hash-maxziplist-value参数,确保hash类型使用ziplist编码。
关于hash键和field键的设计:
不过瑕不掩瑜,对于大量小对象的存储场景,非常适合使用ziplist编码的hash类型控制键的规模来降低内存。
开发提示
使用ziplist+hash优化keys后,如果想使用超时删除功能,
开发人员可以存储每个对象写入的时间,再通过定时任务使用hscan命令扫描数据,找出hash内超时的数据项删除即可。
本节主要讲解Redis内存优化技巧,
Redis的数据特性是“all in memory”,优化内存将变得非常重要。
对于内存优化建议读者先要掌握Redis内存存储的特性比如字符串、压缩编码、整数集合等,再根据数据规模和所用命令需求去调整,从而达到空间和效率的最佳平衡。
建议使用Redis存储大量数据时,把内存优化环节加入到前期设计阶段,否则数据大幅增长后,开发人员需要面对重新优化内存所带来开发和数据迁移的双重成本。
当Redis内存不足时,首先考虑的问题不是加机器做水平扩展,应该先尝试做内存优化,当遇到瓶颈时,再去考虑水平扩展。
即使对于集群化方案,垂直层面优化也同样重要,避免不必要的资源浪费和集群化后的管理成本。
本文作者:Eric
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!