Redis详解(2)内存使用与管理

2022-04-14 15:12:58 浏览数 (1)

一、内存使用情况

可以通过info memory命令查看内存使用情况

used_memory: Redis分配的内存总量,即存储的所有数据占用的内存。包括redis进程内部开销和使用的虚拟内存(即swap),单位byte。

used_memory_human: 以可读格式返回使用的内存量(只是显示更友好).

used_memory_rss:从系统角度,显示Redis进程占用的物理内存总量,与top及ps命令看到的值是一致的;除了分配器分配的内存之外,used_memory_rss还包括进程运行本身需要的内存、内存碎片等,但是不包括虚拟内存。

used_memory_rss_human:以可读格式返回Redis进程占用的物理内存总量

used_memory_peak:内存使用的最大值,表示used_memory峰值

used_memory_peak_human:以可读格式返回内存使用的最大值

total_system_memory:系统总内存

total_system_memory_human:以可读格式返回系统总内存

used_memory_lua:Lua进程使用内存

used_memory_lua_human:以可读格式返回Lua进程使用内存

mem_fragmentation_ratio:内存碎片率,等价于(used_memory_rss /used_memory)

mem_allocator:redis使用的内存分配器:在编译时指定;可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc;

重点关注used_memory以及used_memory_rss和碎片率mem_fragmentation_ratio

1) used_memory和used_memory_rss: 前者是从Redis角度得到的量,后者是从操作系统角度得到的量。二者之所以有所不同,一方面是因为内存碎片和Redis进程运行需要占用内存,使得前者可能比后者小,另一方面虚拟内存的存在,使得前者可能比后者大。

2) mem_fragmentation_ratio :

由于在实际应用中,Redis的数据量会比较大,此时进程运行占用的内存与Redis数据量和内存碎片相比,都会小得多;因此used_memory_rss和used_memory的比例,便成了衡量Redis内存碎片率的参数;这个参数就是mem_fragmentation_ratio。

mem_fragmentation_ratio =表示(used_memory_rss/used_memory)的比值。

mem_fragmentation_ratio > 1 碎片率过大,导致内存资源浪费;,如果值越大,说明碎片越严重。

mem_fragmentation_ratio < 1: 一般出现在操作系统把Redis内存交换到硬盘导致,redis已使用swap分区。由于虚拟内存的媒介是磁盘,比内存速度要慢很多,当这种情况出现时,应该及时排查,如果内存不足应该及时处理,如增加Redis节点、增加Redis服务器的内存、优化应用等。

一般来说,mem_fragmentation_ratio在1.03左右是比较健康的状态(对于jemalloc来说);上面截图中的mem_fragmentation_ratio值很大,是因为还没有向Redis中存入数据,Redis进程本身运行的内存使得used_memory_rss 比used_memory大得多。

二、内存占用分析

Redis的内存主要包括:对象内存 缓冲内存 自身内存 内存碎片。

1、Redis进程内的内存

其中Redis自身内存消耗的很少,这部分内存大约几兆。通常占用used_memory_rss在3MB左右,used_memory在800k左右。

在大多数生产环境中与Redis数据占用的内存相比可以忽略。这部分内存不是由jemalloc分配,因此不会统计在used_memory中。

补充说明:除了主进程外,Redis创建的子进程运行也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。当然,这部分内存不属于Redis进程,也不会统计在used_memory和used_memory_rss中。

2、 数据对象内存占用

对象内存是Redis中最占内存的一块,存储着用户所有数据。Redis所有数据都采用key-value,每次你创建key-value,都是创建2个对象,即key对象和value对象,即 key的size value的size.

Key对象:都是字符串,我们应当避免使用过长的key.

Value对象:根据数据类型不同(五种数据类型–String,List,Hash,Set,Zset),则占用的内存就不同。这5种类型是Redis对外提供的,实际上,在Redis内部,每种类型可能有2种或更多的内部编码实现;此外,Redis在存储对象时,并不是直接将数据扔进内存,而是会对对象进行各种包装:如redisObject、SDS等;这篇文章后面将重点介绍Redis中数据存储的细节。

测试表明:string类型需要90字节的额外代价,就是说key 1个字节,value 1个字节时,还是需要占用92字节的长度,而上面的 使用jemalloc分配内存,删除数据后,内存并不会乖乖还给操作系统而是被Redis截留下来重用到新的数据上,直到Redis重启。因此进程实际占用内存是看INFO里返回的used_memory_peak_human。 Redis内部用了ziplist/intset这样的压缩结构来减少hash/list/set/zset的存储,默认当集合的元素少于512个且最长那个值不超过64字节时使用,可配置。 用make 32bit可以编译出32位的版本,每个指针占用的内存更小,但只支持最大4GB内存。

3、缓冲内存:

缓冲内存涉及到客户端缓冲区,复制积压缓冲区和AOF缓冲区。这部分内存由jemalloc分配,因此会统计在used_memory中。

客户端缓冲:指的是所有连接到Redis的服务器tcp连接输入输出缓冲,输入缓冲无法控制,最大空间1G;输出缓冲可通过client-output-buffer-limit控制。

1)普通客户端的连接:client-output-buffer-limit normal 0 0 0

普通客户端默认并没有对输出缓冲区做限制。但是如果当有大量的慢连接客户端接入时,这部分消耗就不能忽略了,因为消费的很慢,在成输出缓冲区数据积压。所以可以设置maxclients做限制。

2)从客户端:client-output-buffer-limit slave 256mb 64mb 60

主节点会每一个从节点单独建立一条连接用于命令复制。当主节点网络延迟较高或主节点挂载大量的从节点时,这部分内存消耗将占用很大一部分,建议主节点挂载从节点最好不要超过2个。

3) 订阅客户端:client-output-buffer-limit pubsub 32mb 8mb 60 当生产消息的速度快于消费的速度时,输出缓冲区容易积压消息

复制积压缓冲区:一个可重用的固定大小缓冲区用于实现部分复制功能,根据repl-backlog-size参数控制,默认1MB. 对于复制积压缓区,主节点有一个,所有从节点啊共享这个缓冲区,因此可以设置较大的值,比如100MB,这部分投入是有价值的,可以有效避免全量复制。

AOF缓冲区:用于AOF重写期间保存最近写入的命令,等待被刷到磁盘。会先写入到缓冲区,然后根据响应的策略向磁盘进行同步,消耗的内存取决于写入的命令量和重写时间,通常很小。

4、内存碎片

Redis进程需要用内存的话,会先通过OS向device申请,然后才能够使用。一般进程在不需要使用的时候,会释放掉这部分内存并返回给device。但是redis可能为了更高的性能,所以在redis中实现了自己的内存分配器来管理内存,不会马上返还内存,不用每次都向OS申请了,从而实现高性能。

Redis默认的内存分配器是jemalloc,内存分配器的作用就是为了更好的管理和重复利用内存。内存碎片是Redis在分配、回收物理内存过程中产生的。

出现高内存碎片问题的情况:当存储的数据长短差异较大的时候,以下场景容易出现高内存碎片问题

1)、频繁更新,对已经存在的key进行append setrange操作

2)、大量过期键删除,键对象过期删除后,释放的 空间无法得到拆分利用

这些操作可能导致redis释放的空间在物理内存中并没有释放,但redis又无法有效利用,这就形成了内存碎片。内存碎片不会统计在used_memory中。

解决办法:

内存碎片的产生与对数据进行的操作、数据的特点等都有关;此外,与使用的内存分配器也有关系:如果内存分配器设计合理,可以尽可能的减少内存碎片的产生。

如果Redis服务器中的内存碎片已经很大,可以通过安全重启的方式减小内存碎片:

1)、尽量数据对齐,视业务情况而定

2)、安全重启:重启可以做到内存碎片重新整理。

redis4.0以上可以使用新增指令来手动回收内存碎片。

5、子进程内存消耗

子进程内存主要是指AOF/RDB重写时Redis创建的子进程内存消耗。Redis执行fork操作产生子进程内存占用对外表现为与父进程相同,理论上需要一倍的相同物理内存来完成重写操作。但是Linux的copy-on-write机制使得父子进程共享相同的物理内存页,当父进程处理写请求时会对需要修改的页复制出一份副本完成写操作,而子进程依旧读取fork时整个父进程内存快照。

# Redis子进程并不需要消耗1倍 的父进程内存,但是依然要预留一些内存防止内存溢出

# 需要设置sysctl vm.overcommit_memory=1允许内核可以分配所有的物理内存,防止Redis进程执行fork时因系统剩余内存不足而失败。

# 排查当前系统是否支持并开启THP,如果开启,建议关闭。

三、内存管理

1 、最大内存

Redis通过maxmemory参数限制最大可用内存。限制内存目的主要有:

用于缓存场景,当超出内存上限maxmemory时候使用LRU等删除策略释放空间,防止所用内存超过服务器物理内存。

所有的数据都必须在内存中,原来2.0版的VM策略(将Value放到磁盘,Key仍然放在内存),2.4版后嫌麻烦又不支持了。 一定要设置最大内存,否则物理内存用爆了就会大量使用Swap,写RDB文件时的速度慢得你想死。我们可以通过配置redis.conf中的maxmemory这个值设置最大内存

# maxmemory <bytes>

若是启用了Redis快照功能,应该设置“maxmemory”值为系统可使用内存的45%,因为快照时需要一倍的内存来复制整个数据集,也就是说如果当前已使用45%,在快照期间会变成95%(45% 45% 5%),其中5%是预留给其他的开销。 如果没开启快照功能,maxmemory最高能设置为系统可用内存的95%。

多留一倍内存是最安全的。重写AOF文件和RDB文件的进程(即使不做持久化,复制到Slave的时候也要写RDB)会fork出一条新进程来,采用了操作系统的Copy-On-Write策略(子进程与父进程共享Page。如果父进程的Page-每页4K有修改,父进程自己创建那个Page的副本,不会影响到子进程,父爱如山)。留意Console打出来的报告,如”RDB: 1215 MB of memory used by copy-on-write”。在系统极度繁忙时,如果父进程的所有Page在子进程写RDB过程中都被修改过了,就需要两倍内存。 按照Redis启动时的提醒,设置 vm.overcommit_memory = 1 ,使得fork()一条10G的进程时,因为COW策略而不一定需要有10G的free memory。

需要注意的是:

maxmeory限制的是Redis实际使用的内存量,也就是used_memory统计项对应的内存。由于有内存碎片的存在,所以实际的内存使用比used_memory要大。需要考虑的内存包括: 1.AOF rewrite过程中对新写入命令的缓存(rewrite结束后会merge到新的aof文件),留意”Background AOF buffer size: 80 MB”的字样。 2.负责与Slave同步的Client的缓存,默认设置master需要为每个slave预留不高于256M的缓存(见5.1持久化)。

通过设置上限,可以方便实现一台服务器部署多个Redis进程的内存控制。比如一台32G的内存的服务器,预留4GB内存给系统,预留4GB给Redis fork进程,留给Redis24GB内存,这样就可以部署3个maxmemory=8GB的redis进程。

2、动态调整内存上限

可以通过命令config set maxmemory8GB

3、 内存回收策略

内存回收机制主要体现在以下两个方面:

1)删除过期的key:Redis采用惰性删除和定时任务删除机制实现过期key的内存回收

惰性删除:

用于当客户端读取带有超时属性的key的时候,如果已经超过设置的过期时间,会执行删除操作并返回空。但是有一个问题,当过期键一直没有访问将无法得到及时删除,从而导致内存 不能及时释放

定时任务删除:

Redis内部维护一个定时任务,默认每秒运行10次,通过配置hz属性控制。

2)内存使用达到maxmeory上限时触发内存溢出的控制策略:

当Redis所用内存达到maxmeory上限时,会触发相应的溢出控制策略。即按照配置的Policy进行处理, 默认策略为volatile-lru,对设置了expire time的key进行LRU清除(不是按实际expire time)。如果沒有数据设置了expire time或者policy为noeviction,则直接报错,但此时系统仍支持get之类的读操作。

具体策略受maxmeory-policy参数控制,Redis支持6种策略:

volatile-lru :根据LRU算法删除设置了超时属性的键,直到腾出足够空间为止

allkeys-lru: 根据LRU算法删除键,不管有没有设置超时属性,直到腾出足够空间为止

volatile-random: 随即删除过期键,直到腾出足够空间为止

allkeys-random :随即删除所有键,直到腾出足够空间为止

volatile-ttl:根据ttl属性,删除最近将要过期的数据,如果没有回退到noeviction策略

noeviction: 不会删除任何数据,拒绝所有写入操作,并返回错误信息,此时只是响应读

四、内存淘汰清理机制(回收机制)

1、删除过期的key

官方文档 与 《Redis设计与实现》中的详述,过期数据的清除从来不容易,为每一条key设置一个timer,到点立刻删除的消耗太大,每秒遍历所有数据消耗也大,Redis使用了一种相对务实的做法:

删除过期的key:Redis采用惰性删除和定时任务删除机制实现过期key的内存回收

惰性删除:

用于当client读取带有超时属性的key的时候,如果已经超过设置的过期时间,会执行删除操作并返回空。

但是有一个问题,当过期键一直没有访问将无法得到及时删除,从而导致内存 不能及时释放。

定时任务删除:

Redis内部维护一个定时任务,默认每秒运行10次,通过配置hz属性控制。

每秒10次的执行如下操作: 随机选取100个key校验是否过期,如果有25个以上的key过期了,立刻额外随机选取下100个key(不计算在10次之内)。可见,如果过期的key不多,它最多每秒回收200条左右,如果有超过25%的key过期了,它就会做得更多,但只要key不被主动get,它占用的内存什么时候最终被清理掉只有天知道。

2、内存使用达到maxmeory上限时触发内存溢出的控制策略:

redis为了更好地实现这个功能,必须为不同的应用场景提供不同的策略,内存淘汰策略讲的是为实现内存淘汰我们具体怎么做,要解决的问题包括淘汰键空间如何选择?在键空间中淘汰键如何选择?

淘汰策略的选择可以通过下面的配置指定:

# maxmemory-policy noeviction

Redis提供了下面几种淘汰策略供用户选择,其中默认的策略为noeviction。

策略:

  • noeviction:当内存使用达到阈值的时候,所有引起申请内存的命令会报错。
  • allkeys-lru:在主键空间中,优先移除最近未使用的key。
  • volatile-lru:在设置了过期时间的键空间中,优先移除最近未使用的key。
  • allkeys-random:在主键空间中,随机移除某个key。
  • volatile-random:在设置了过期时间的键空间中,随机移除某个key。
  • volatile-ttl:在设置了过期时间的键空间中,具有更早过期时间的key优先移除。

下面看看几种策略的适用场景:

  • allkeys-lru:如果我们的应用对缓存的访问符合幂律分布(也就是存在相对热点数据),或者我们不太清楚我们应用的缓存访问分布状况,我们可以选择allkeys-lru策略。
  • allkeys-random:如果我们的应用对于缓存key的访问概率相等,则可以使用这个策略。
  • volatile-ttl:这种策略使得我们可以向Redis提示哪些key更适合被eviction。

另外,volatile-lru策略和volatile-random策略适合我们将一个Redis实例既应用于缓存和又应用于持久化存储的时候,然而我们也可以通过使用两个Redis实例来达到相同的效果,值得一提的是将key设置过期时间实际上会消耗更多的内存,因此我们建议使用allkeys-lru策略从而更有效率的使用内存。

提示:主键空间和设置了过期时间的键空间,举个例子,假设我们有一批键存储在Redis中,则有那么一个哈希表用于存储这批键及其值,如果这批键中有一部分设置了过期时间,那么这批键还会被存储到另外一个哈希表中,这个哈希表中的值对应的是键被设置的过期时间。设置了过期时间的键空间为主键空间的子集。

3、非精准的LRU

上面提到的LRU(Least Recently Used)策略,实际上Redis实现的LRU并不是可靠的LRU,也就是名义上我们使用LRU算法淘汰键,但是实际上被淘汰的键并不一定是真正的最久没用的,这里涉及到一个权衡的问题,如果需要在全部键空间内搜索最优解,则必然会增加系统的开销,Redis是单线程的,也就是同一个实例在每一个时刻只能服务于一个客户端,所以耗时的操作一定要谨慎。为了在一定成本内实现相对的LRU,早期的Redis版本是基于采样的LRU,也就是放弃全部键空间内搜索解改为采样空间搜索最优解。自从Redis3.0版本之后,Redis作者对于基于采样的LRU进行了一些优化,目的是在一定的成本内让结果更靠近真实的LRU。

五、数据存储的细节

5.1、概述

关于Redis数据存储的细节,涉及到内存分配器(如jemalloc)、简单动态字符串(SDS)、5种对象类型及内部编码、redisObject。在讲述具体内容之前,先说明一下这几个概念之间的关系。

下图是执行set hello world时,所涉及到的数据模型。

(1)dictEntry:Redis是Key-Value数据库,因此对每个键值对都会有一个dictEntry,里面存储了指向Key和Value的指针;next指向下一个dictEntry,与本Key-Value无关。

(2)Key:图中右上角可见,Key(”hello”)并不是直接以字符串存储,而是存储在SDS结构中。

(3)redisObject:Value(“world”)既不是直接以字符串存储,也不是像Key一样直接存储在SDS中,而是存储在redisObject中。实际上,不论Value是5种类型的哪一种,都是通过redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。

实际上,redisObject除了type和ptr字段以外,还有其他字段图中没有给出,如用于指定对象内部编码的字段;后面会详细介绍。

(4)jemalloc:无论是DictEntry对象,还是redisObject、SDS对象,都需要内存分配器(如jemalloc)分配内存进行存储。以DictEntry对象为例,有3个指针组成,在64位机器下占24个字节,jemalloc会为它分配32字节大小的内存单元。

下面来分别介绍jemalloc、redisObject、SDS、对象类型及内部编码。

5.2、jemalloc

Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。

jemalloc作为Redis的默认内存分配器,在减小内存碎片方面做的相对比较好。jemalloc在64位系统中,将内存空间划分为小、大、巨大三个范围;每个范围内又划分了许多小的内存块单位;当Redis存储数据时,会选择大小最合适的内存块进行存储。

jemalloc划分的内存单元如下图所示:

例如,如果需要存储大小为130字节的对象,jemalloc会将其放入160字节的内存单元中。

5.3、redisObject

前面说到,Redis对象有5种类型;无论是哪种类型,Redis都不会直接存储,而是通过redisObject对象进行存储。

redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持,下面将通过redisObject的结构来说明它是如何起作用的。

redisObject的定义如下(不同版本的Redis可能稍稍有所不同):

redisObject的每个字段的含义和作用如下:

5.3.1、type

type字段表示对象的类型,占4个比特;目前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。

当我们执行type命令时,便是通过读取RedisObject的type字段获得对象的类型;如下图所示:

5.3.2、encoding

encoding表示对象的内部编码,占4个比特。

对于Redis支持的每种类型,都有至少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。通过encoding属性,Redis可以根据不同的使用场景来为对象设置不同的编码,大大提高了Redis的灵活性和效率。以列表对象为例,有压缩列表和双端链表两种编码方式;如果列表中的元素较少,Redis倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更适合存储大量元素的双端链表。

通过object encoding命令,可以查看对象采用的编码方式,如下图所示:

5种对象类型对应的编码方式以及使用条件,将在后面介绍。

5.3.3、lru

lru记录的是对象最后一次被命令程序访问的时间,占据的比特数不同的版本有所不同(如4.0版本占24比特,2.6版本占22比特)。

通过对比lru时间与当前时间,可以计算某个对象的空转时间;object idletime命令可以显示该空转时间(单位是秒)。object idletime命令的一个特殊之处在于它不改变对象的lru值。

lru值除了通过object idletime命令打印之外,还与Redis的内存回收有关系:如果Redis打开了maxmemory选项,且内存回收算法选择的是volatile-lru或allkeys—lru,那么当Redis内存占用超过maxmemory指定的值时,Redis会优先选择空转时间最长的对象进行释放。

5.3.4、refcount

(3.4.1)refcount与共享对象

refcount记录的是该对象被引用的次数,类型为整型。refcount的作用,主要在于对象的引用计数和内存回收。当创建新对象时,refcount初始化为1;当有新程序使用该对象时,refcount加1;当对象不再被一个新程序使用时,refcount减1;当refcount变为0时,对象占用的内存会被释放。

Redis中被多次使用的对象(refcount>1),称为共享对象。Redis为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。这个被重复使用的对象,就是共享对象。目前共享对象仅支持整数值的字符串对象。

(3.4.2)共享对象的具体实现

Redis的共享对象目前只支持整数值的字符串对象。之所以如此,实际上是对内存和CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,判断操作复杂度为O(1);对于普通字符串,判断复杂度为O(n);而对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。

虽然共享对象只能是整数值的字符串对象,但是5种类型都可能使用共享对象(如哈希、列表等的元素可以使用)。

就目前的实现来说,Redis服务器在初始化时,会创建10000个字符串对象,值分别是0~9999的整数值;当Redis需要使用值为0~9999的字符串对象时,可以直接使用这些共享对象。10000这个数字可以通过调整参数REDIS_SHARED_INTEGERS(4.0中是OBJ_SHARED_INTEGERS)的值进行改变。

共享对象的引用次数可以通过object refcount命令查看,如下图所示。命令执行的结果页佐证了只有0~9999之间的整数会作为共享对象。

5.3.5、ptr

ptr指针指向具体的数据,如前面的例子中,set hello world,ptr指向包含字符串world的SDS。

5.3.6、总结

综上所述,redisObject的结构与对象类型、编码、内存回收、共享对象都有关系;一个redisObject对象的大小为16字节:

4bit 4bit 24bit 4Byte 8Byte=www.leyou1178.cn 16Byte。

5.4、SDS

在Redis中,字符串对象时经常用到的,Redis没有直接使用C字符串(即以空字符’’结尾的字符数组)作为默认的字符串表示,而是使用了SDS。SDS是简单动态字符串(Simple Dynamic String)的缩写。

举个例子:执行一个list的命令,lpush queue "redis" "list" "queue",首先会创建queue键字符串,然后创建链表对象,链表对象内在包含三个字符串对象。

5.4.1、SDS结构

sds的结构如下:

其中

buf:表示字节数组,用来存储字符串;

len:表示buf已使用的长度,

free:表示buf未使用的长度。下面是两个例子。

通过SDS的结构可以看出,buf数组的长度=free len 1(其中1表示字符串结尾的空字符);所以,一个SDS结构占据的空间为:free所占长度 len所占长度 buf数组的长度=4 4 free len 1=free len 9。

SDS有几个特点:

时间复杂度为O(1),因为有已知长度,未知长度,字符串长度 支持安全的二进制数据存储,用于保存字节数组 内部实现空间预分配机制,降低内存再分配次数 惰性删除机制,字符串缩减后的空间不释放,作为与分配空间保留

所以字符串在使用的时候,尽量减少追加操作,避免大量的追加操作需要内存重新分配,造成内存碎片率上升。而是尽量使用直接插入。 字符串预分配每次都不是翻倍扩容,空间的预分配规则如下:

第一次创建len属性等于数据实际大小,free等于0,不做预分配。 修改后,如果已有free空间不够且数据小于1MB,每次与分配一倍容量。 修改后如果已有free空间不够且数据大于1MB,每次预分配1MB数据空间。如果数据量太大,也翻一倍的话,很有可能会造成内存不足,所有大于1MB的数据,每次预分配1MB空间,也是基于此原因。

一个简单的例子,比如在Redis中set一个简单点的值,在Redis内存中的结构图如下(参考样例):

5.4.2、SDS与C字符串的比较

SDS在C字符串的基础上加入了free和len字段,带来了很多好处:

  • 获取字符串长度:SDS是O(1),C字符串是O(n)
  • 缓冲区溢出:使用C字符串的API时,如果字符串长度增加(如strcat操作)而忘记重新分配内存,很容易造成缓冲区的溢出;而SDS由于记录了长度,相应的API在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
  • 修改字符串时内存的重分配:对于C字符串,如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而对于SDS,由于可以记录len和free,因此解除了字符串长度和空间数组长度之间的关联,可以在此基础上进行优化:空间预分配策略(即分配内存时比实际需要的多)使得字符串长度增大时重新分配内存的概率大大减小;惰性空间释放策略使得字符串长度减小时重新分配内存的概率大大减小。
  • 存取二进制数据:SDS可以,C字符串不可以。因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而SDS以字符串长度len来作为字符串结束标识,因此没有这个问题。

此外,由于SDS中的buf仍然使用了C字符串(即以’’结尾),因此SDS可以使用C字符串库中的部分函数;但是需要注意的是,只有当SDS用来存储文本数据时才可以这样使用,在存储二进制数据时则不行(’’不一定是结尾)。

5.4.3、SDS与C字符串的应用

Redis在存储对象时,一律使用SDS代替C字符串。例如set hello world命令,hello和world都是以SDS的形式存储的。而sadd myset member1 member2 member3命令,不论是键(”myset”),还是集合中的元素(”member1”、 ”member2”和”member3”),都是以SDS的形式存储。除了存储对象,SDS还用于存储各种缓冲区。

只有在字符串不会改变的情况下,如打印日志时,才会使用C字符串。

六、Redis的对象类型与内部编码

Redis支持5种对象类型(String,List,Hash,Set,Zset),而每种结构都有至少两种编码;这样做的好处在于:一方面接口与实现分离,当需要增加或改变内部编码时,用户使用不受影响,另一方面可以根据不同的应用场景切换内部编码,提高效率。

Redis各种对象类型支持的内部编码如下图所示(图中版本是Redis3.0,www.baohuayule.cn Redis后面版本中又增加了内部编码,略过不提;本章所介绍的内部编码都是基于3.0的):

关于Redis内部编码的转换,都符合以下规律:编码转换在Redis写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换。

但是不同的类型,在存储到Redis中时会有不同的底层数据结构实现。类似集合ArrayList的底层实现是数组,而Linkedlist的底层实现是链表结构一样,编码不同,则存储是占用的内存以及读写的效率也是不同的。

embstr与raw的区别 在讲两种编码格式的区别之前,先讲点其他的,现代的计算机的结构上边在CPU和内存之间存在一个缓存结构,用来协调CPU的高效和访存的相对缓慢的矛盾。平时听到的L1 Cache,L2 Cache,L3 Cache就是这个缓存。当CPU要访问内存之前会先在缓存里面找一找看有没有,如果没有,就去内存找,找到之后放到缓存里面。这个缓存的最小单位一般是64字节,一次性缓存连续的64个字节,这个最小的单位称为缓存行。

在Redis中,每个value对象都有一个redisObject对象头,对于Redis的字符串对象,当读取数据时,拿到*ptr指针,然后再去找到指向的SDS对象,如果这个对象距离很远,就会影响Redis读取的效率。因此在Redis中设计了一种特殊的编码结构,这种结构就是embstr,它把redisObject请求头和SDS对象紧紧地挨到一起,然后整体放到一个缓存行中去,这样,在查询的时候就可以直接从缓存中获取到相关的数据,提高了查询的效率。

在上边也讲了,缓存行一般的长度为64字节,如果想要把对象存到缓存行中,首先整体的长度不得超过64字节,每个请求头redisObject占用16字节,而SDS至少有3个自己被占用,同时Redis中会以来作为结尾的标志,也占用一个字节,因此,留给可输入的数据的长度就成了(64-16-3-1)=44字节,当存储的数据长度超过了44字节,就会变成raw的编码形式。

6.1、字符串

字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他几种复杂类型的元素也是字符串。

字符串长度不能超过512MB。

6.1.1、内部编码

字符串类型的内部编码有3种,它们的应用场景如下:

  • int:8个字节的长整型。字符串值是整型时,这个值使用long整型表示。
  • embstr:www.006665.cn <=39字节的字符串。embstr与raw都使用www.baohuayule.com redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。
  • raw:大于39个字节的字符串

示例如下图所示:

embstr和raw进行区分的长度,是39;是因为redisObject的长度是16字节,sds的长度是9 字符串长度;因此当字符串长度是39时,embstr的长度正好是16 9 39=64,jemalloc正好可以分配64字节的内存单元。

6.1.2、编码转换

当int数据不再是整数,或大小超过了long的范围时,自动转化为raw。

而对于embstr,由于其实现是只读的,www.leyouzaixan.cn 因此在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了39个字节。示例如下图所示:

6.2、列表LIST

列表(list)用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储2^32-1个元素。Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。

6.2.1 内部编码

列表的内部编码可以是压缩列表(ziplist)或双端链表(linkedlist)。

双端链表:由一个list结构和多个listNode结构组成;典型结构如下图所示:

通过图中可以看出,双端链表同时保存了表头指针和表尾指针,并且每个节点都有指向前和指向后的指针;链表中保存了列表的长度;dup、free和match为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。而链表中每个节点指向的是type为字符串的redisObject。

压缩列表:压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块(而不是像双端链表一样每个节点是指针)组成的顺序型数据结构;具体结构相对比较复杂,略。与双端链表相比,压缩列表可以节省内存空间,但是进行修改或增删操作时,复杂度较高;因此当节点数量较少时,可以使用压缩列表;但是节点数量多时,还是使用双端链表划算。

压缩列表不仅用于实现列表,也用于实现哈希、有序列表;使用非常广泛。

6.2.2 编码转换

只有同时满足下面两个条件时,才会使用压缩列表:列表中元素数量小于512个;列表中所有字符串对象都不足64字节。如果有一个条件不满足,则使用双端列表;且编码只可能由压缩列表转化为双端链表,反方向则不可能。

下图展示了列表编码转换的特点:

其中,单个字符串不能超过64字节,是为了便于统一分配每个节点的长度;这里的64字节是指字符串的长度,不包括SDS结构,因为压缩列表使用连续、定长内存块存储字符串,不需要SDS结构指明长度。后面提到压缩列表,也会强调长度不超过64字节,原理与这里类似。

6.3、哈希

哈希(作为一种数据结构),不仅是redis对外提供的5种对象类型的一种(与字符串、列表、集合、有序结合并列),也是Redis作为Key-Value数据库所使用的数据结构。为了说明的方便,在本文后面当使用“内层的哈希”时,代表的是redis对外提供的5种对象类型的一种;使用“外层的哈希”代指Redis作为Key-Value数据库所使用的数据结构。

6.3.1 内部编码

内层的哈希使用的内部编码可以是压缩列表(www.255055.cn ziplist)和哈希表(hashtable)两种;Redis的外层的哈希则只使用了hashtable。

压缩列表前面已介绍。与哈希表相比,压缩列表用于元素个数少、元素长度小的场景;其优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(n)变为了O(1),但由于哈希中元素数量较少,因此操作的时间并没有明显劣势。

hashtable:一个hashtable由1个dict结构、2个dictht结构、1个dictEntry指针数组(称为bucket)和多个dictEntry结构组成。

正常情况下(即hashtable没有进行rehash时)各部分关系如下图所示:

下面从底层向上依次介绍各个部分:

dictEntry

dictEntry结构用于保存键值对,结构定义如下:

其中,各个属性的功能如下:

  • key:键值对中的键;
  • val:键值对中的值,使用union(即共用体)实现,存储的内容既可能是一个指向值的指针,也可能是64位整型,或无符号64位整型;
  • next:指向下一个dictEntry,用于解决哈希冲突问题

在64位系统中,一个dictEntry对象占24字节(key/val/next各占8字节)。

bucket

bucket是一个数组,数组的每个元素都是指向dictEntry结构的指针。redis中bucket数组的大小计算规则如下:大于dictEntry的、最小的2^n;例如,如果有1000个dictEntry,那么bucket大小为1024;如果有1500个dictEntry,则bucket大小为2048。

dictht

dictht结构如下:

其中,各个属性的功能说明如下:

  • table属性是一个指针,指向bucket;
  • size属性记录了哈希表的大小,即www.wanmeiyuele.cn bucket的大小;
  • used记录了已使用的dictEntry的数量;
  • sizemask属性的值总是为size-1,这个属性和哈希值一起决定一个键在table中存储的位置。

dict

一般来说,通过使用dictht和dictEntry结构,便可以实现普通哈希表的功能;但是Redis的实现中,在dictht结构的上层,还有一个dict结构。下面说明dict结构的定义及作用。

dict结构如下:

其中,type属性和privdata属性是为了适应不同类型的键值对,用于创建多态字典。

ht属性和trehashidx属性则用于rehash,即当哈希表需要扩展或收缩时使用。ht是一个包含两个项的数组,每项都指向一个dictht结构,这也是Redis的哈希会有1个dict、2个dictht结构的原因。通常情况下,所有的数据都是存在放dict的ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。然后将ht[1]赋值给ht[0],并清空ht[1]。

因此,Redis中的哈希之所以在dictht和dictEntry结构之外还有一个dict结构,一方面是为了适应不同类型的键值对,另一方面是为了rehash。

6.3.3)编码转换

如前所述,Redis中内层的哈希既可能使用哈希表,也可能使用压缩列表。

只有同时满足下面两个条件时,才会使用压缩列表:哈希中元素数量小于512个;哈希中所有键值对的键和值字符串长度都小于64字节。如果有一个条件不满足,则使用哈希表;且编码只可能由压缩列表转化为哈希表,反方向则不可能。

下图展示了Redis内层的哈希编码转换的特点:

6.4、集合

集合(set)与列表类似,都是用来保存多个字符串,但集合与列表有两点不同:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。

一个集合中最多可以存储2^32-1个元素;除了支持常规的增删改查,Redis还支持多个集合取交集、并集、差集。

6.4.1内部编码

集合的内部编码可以是整数集合(intset)或哈希表(hashtable)。

哈希表前面已经讲过,这里略过不提;需要注意的是,集合在使用哈希表时,值全部被置为null。

整数集合的结构定义如下:

其中,encoding代表contents中存储内容的类型,虽然contents(存储集合中的元素)是int8_t类型,但实际上其存储的值是int16_t、int32_t或int64_t,具体的类型便是由encoding决定的;length表示元素个数。

整数集合适用于集合所有元素都是整数且集合元素数量较小的时候,与哈希表相比,整数集合的优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(n)变为了O(1),但由于集合数量较少,因此操作的时间并没有明显劣势。

6.4.2 编码转换

只有同时满足下面两个条件时,集合才会使用整数集合:集合中元素数量小于512个;集合中所有元素都是整数值。如果有一个条件不满足,则使用哈希表;且编码只可能由整数集合转化为哈希表,反方向则不可能。

下图展示了集合编码转换的特点:

6.5、有序集合

有序集合与集合一样,元素都不能重复;但与集合不同的是,有序集合中的元素是有顺序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。

6.5.1内部编码

有序集合的内部编码可以是压缩列表(ziplist)或跳跃表(skiplist)。ziplist在列表和哈希中都有使用,前面已经讲过,这里略过不提。

跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。除了跳跃表,实现有序数据结构的另一种典型实现是平衡树;大多数情况下,跳跃表的效率可以和平衡树媲美,且跳跃表实现比平衡树简单很多,因此redis中选用跳跃表代替平衡树。跳跃表支持平均O(logN)、最坏O(N)的复杂点进行节点查找,并支持顺序操作。Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃表节点。具体结构相对比较复杂,略。

6.5.2 编码转换

只有同时满足下面两个条件时,才会使用压缩列表:有序集合中元素数量小于128个;有序集合中所有成员长度都不足64字节。如果有一个条件不满足,则使用跳跃表;且编码只可能由压缩列表转化为跳跃表,反方向则不可能。

下图展示了有序集合编码转换的特点:

七、内存优化

Redis内部有很多的数据类型,这些在官方文档上都可以看到,下面是其内部优化的一些细节点:

1、缩减键值对象

降低Redis内存使用最直接的方式就是缩减key和value的长度

# key的设计:越短越好,如user:{userid}:friends:notify:{fid},可以简化为u:{uid}:fs:nt:{fid}

# value: 值对象缩减比较复杂,常见的需求是把业务对象序列化放入Redis。

2. String 和 数字:

如果是整型/长整型,Redis会使用int类型(8字节)存储来代替字符串,可以节省更多空间。因此在可以使用长整型/整型代替字符串的场景下,尽量使用长整型/整型。

在Redis中如果存储的是“123”Redis是能够识别出来这是一个数字并且按照数字来存储,节省存储空间,当然除了这个优化之外,Redis内部会构建一个数字池,默认是10000,那么如果是在这个池子的数字就只需要用一个简单的索引来引用进来就可以,而不需要把重复的数字都分开存储。这个数值可以调整源代码的宏:REDIS_SHARED_INTEGERS来扩大和缩小池子的大小。

3.复杂类型的存储优化:

比如Map,List,Set等,这些集合都有一个特点可大可小,根据实际场景来定,一般情况下如果这些集合所包含的Entry不多,并且每个Entry所包含的Value不是很长的情况下,Redis内部使用紧凑格式来存储数据,紧凑格式存储数据在查询场景的算法复杂度是O(N),而类似Map或者Set他们的查询算法复杂度都是O(1)那为什么要这么做呢 ?为了能够节省内存空间,在N很小的时候其实和O(1)没什么区别。所以这里不的不介绍紧凑格式的代表ZIPMap,他的数据结构是这样:

可以看出,这个结构中初始情况只有2个字节,随着操作的增加它会变长,其中最关键的是一个关于Free这个字段的理解,以Map为例,如果新插入一个Key,那么对应ZipMap就会多出来一长串数据:<len><key><len><free><value>。从图中可以看到插入key1的时候只有绿色的一串,当key2插入的时候就会又出来一个类似的黄色结构串。free的功能是在插入的时候用来冗余空间的,当key所对应的数值发生变化的时候,如果数据变的比之前短了,那么free的长度就变大,这个时候不需要做ZipMap的resize操作,如果数据长度变长了,并且在free能够足以支持新数据的范围之内,那么free就被利用起来,并且也不需要做Resize。这个时候会有空间的浪费或者说碎片。空间换时间吧,没什么好说的。当然Redis的代码中还有另外一个参数ZIPMAP_VALUE_MAX_FREE,这个参数可以用来设置如果Free的大小超过了这个值,那么ZipMap会发生Resize(收缩),从而节约空间。

内存优化总结:

1、首先最重要的一点是不要开启Redis的VM选项,即虚拟内存功能,这个本来是作为Redis存储超出物理内存数据的一种数据在内存与磁盘换入换出的一个持久化策略,但是其内存管理成本也非常的高,并且我们后续会分析此种持久化策略并不成熟,所以要关闭VM功能,请检查你的redis.conf文件中 vm-enabled 为 no。

2、其次最好设置下redis.conf中的maxmemory选项,该选项是告诉Redis当使用了多少物理内存后就开始拒绝后续的写入请求,该参数能很好的保护好你的Redis不会因为使用了过多的物理内存而导致swap,最终严重影响性能甚至崩溃。

0 人点赞