Redis学习(1)内存模型

一、Redis内存统计

客户端通过redis-cli命令连接服务器后,可通过info memory命令查看内存使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# Memory
used_memory:722264
used_memory_human:705.34K
used_memory_rss:682264
used_memory_rss_human:666.27K
used_memory_peak:723216
used_memory_peak_human:706.27K
used_memory_peak_perc:99.87%
used_memory_overhead:711318
used_memory_startup:661368
used_memory_dataset:10946
used_memory_dataset_perc:17.97%
allocator_allocated:39440016
allocator_active:511705088
allocator_resident:520093696
total_system_memory:0
total_system_memory_human:0B
used_memory_lua:37888
used_memory_lua_human:37.00K
used_memory_scripts:0
used_memory_scripts_human:0B
number_of_cached_scripts:0
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
allocator_frag_ratio:12.97
allocator_frag_bytes:472265072
allocator_rss_ratio:1.02
allocator_rss_bytes:8388608
rss_overhead_ratio:0.00
rss_overhead_bytes:-519411432
mem_fragmentation_ratio:1.00
mem_fragmentation_bytes:0
mem_not_counted_for_evict:0
mem_replication_backlog:0
mem_clients_slaves:0
mem_clients_normal:49950
mem_aof_buffer:0
mem_allocator:jemalloc-5.2.1-redis
active_defrag_running:0
lazyfree_pending_objects:0

重要参数介绍:

  1. used_memory:Redis分配器分配的内存总量(单位字节),包括使用的虚拟内存。used_memory_human方便人查看。

  2. used_memory_rss:Redis进程占用操作系统大的内存(单位字节)。除了包含分配器分配的内存外,还包括进程运行本身需要的内存,但不包含虚拟内存。

  3. used_memory_rss:内存碎片比率。计算方式:used_memory_rss / used_memory。

  4. mem_allocator:Redis使用的内存分配器,编译时指定,存在三种libc 、jemalloc或者tcmalloc,默认是jemalloc。

二、Redis内存划分

1. 数据

Redis作为数据库,数据是最主要的部分;这部分占用的内存会统计在used_memory中。

Redis使用键值对存储数据,其中的值包括5种类型,即字符串、哈希、列表、集合、有序集合。在Redis内部,每种类型可能有2种或更多的内部编码实现;此外,Redis在存储对象时,并不是直接将数据扔进内存,而是会对对象进行包装:例如redisObject、SDS等。

2. 进程本身运行需要的内存

Redis主进程本身运行肯定需要占用内存,如代码、常量池等等。这部分内存大约几兆,在大多数生产环境中与Redis数据占用的内存相比可以忽略。这部分内存不是由jemalloc分配,因此不会统计在used_memory中。

除主进程外,Redis创建的子进程也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。同样这部分占用的内存也不会统计在used_memory和used_memory_rss中。

3. 缓冲内存

缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF缓冲区等,这部分内存由jemalloc分配,因此会统计在used_memory中。

  • 客户端缓冲区:每个客户端连接在 Redis 服务器端对应的输入缓冲区和输出缓冲区。
  • 复制挤压缓冲区:主节点专门为从节点准备的写命令历史记录缓存。主节点执行写命令时,不仅应用到自身数据,也会把这些写操作序列化成复制流,并写入这块缓冲区。
  • AOF缓冲区:Redis 在开启AOF持久化时,用来暂存最近写命令的内存缓冲区。

4. 内存碎片

内存碎片是在 Redis 分配和回收内存过程中产生的。当数据频繁修改、且对象大小差异较大时,Redis 释放的内存可能仍保留在内存分配器的内存池中,未立即归还给操作系统,也可能无法被后续对象有效利用,从而形成内存碎片。

三、Redis数据存储的细节

1. 概述

执行set hello world命令时涉及到的数据模型:

1174710-20180327001055927-1896197804

  1. dictEntry:是Redis字典中存储键值对的基本单元。

    • key:指向键的指针,键的类型是sds(redis自定义的字符串结构)。
    • val:指向值的指针,这里值的类型是redisObject(redis所有数据类型的统一封装对象)。
    • next:指向另一个dictEntry的指针,用于处理哈希冲突。
  2. redisObject:Redis中所有数据(字符串、列表、哈希等)都通过redisObejct封装。

    • type:标识对象的类型,这里是string。
    • ptr:指向时机数据的指针。
  3. Sds:Redis自定义的字符串实现,相比C原生字符串更高效(支持动态扩容、记录长度等)。

2. Jemalloc

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

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

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

img

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

3. RedisObject

前面有提到Redis中所有数据(字符串、列表、哈希等)都通过redisObejct封装。

redisObject的定义如下:

1
2
3
4
5
6
7
typedef struct redisObject {
  unsigned type:4;
  unsigned encoding:4;
  unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
  int refcount;
  void *ptr;
} robj;
  • unsigned type:4:用于标识当前对象的数据类型,Redis支持的核心类型包括:REDIS_STRING(字符串)、REDIS_LIST(列表)、REDIS_HASH(哈希)、REDIS_SET(集合)。4位二进制可表示16种类型,预留了拓展空间。
  • unsigned encoding:4:标识对象的底层存储结构(Redis 对同一种数据类型可能采用多种编码以优化性能)。例如:
    • 字符串类型(type=REDIS_STRING)的编码可能是:
      • REDIS_ENCODING_RAW(原生 SDS,存储长字符串)
      • REDIS_ENCODING_INT(直接存储整数,避免 SDS 开销)
    • 列表类型(type=REDIS_LIST)的编码可能是:
      • REDIS_ENCODING_ZIPLIST(压缩列表,适合小数据)
      • REDIS_ENCODING_LINKEDLIST(双向链表,适合大数据)。
  • unsigned lru:REDIS_LRU_BITS:用于记录对象的最近访问时间,配合 Redis 的内存淘汰策略。
  • int refcount:引用计数,用于内存管理的垃圾回收机制。
  • void *ptr:指向对象的实际存储数据,具体指向的结构由 encoding 决定:
    • 若 encoding=REDIS_ENCODING_INT,ptr 直接指向整数(通过指针强转存储,节省空间)。
    • 若 encoding=REDIS_ENCODING_RAW,ptr 指向 sds 结构体(字符串)。
    • 若 encoding=REDIS_ENCODING_ZIPLIST,ptr 指向压缩列表的起始地址。

因此,每个redisObject所占大小是4bit+4bit+24bit+4Byte+8Byte=16Byte。

4. SDS

sds用于存储字符串,结构如下:

1
2
3
4
5
struct sdshdr {
int len;
int free;
char buf[];
};

参数说明:len表示buf已使用的长度、free表示buf未使用的长度、buf表示字节数组。

因此,每个sds所占的大小是4+4+len+free+1(Byte)

对比C字符串:

  • 获取字符串长度:时间复杂度SDS是O(1),C字符串是O(n)。
  • 缓冲区溢出:对于C字符串,用strcat方法往字符串中添加字符,如原先分配给字符串的空间不够,会导致缓冲区溢出;对于SDS会检测空间是否足够,如空间不足会自动扩容。
  • 字符串修改内存重分配:对于C字符串,如果字符串变长,那么需要重新分配内存,字符串变短,不重新分配内存造成内存浪费;对于SDS,在扩容时会分配更多的空间,以减少realloc的次数,并且遵循惰性空间释放策略,减少缩容的频率,以提高性能。
  • 存取二进制数据:对于C字符串,通常以空字符作为字符串结束的标志,对于一些二进制文件,内容可能包含空字符,因而C字符串无法正确读取;对于SDS则是根据len作为字符串结束标志,很好的避免了这个问题。

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

前面已经提到,Redis支持5种对象类型,每种结构都有至少两种编码。好处是对于开发者来说,使用Redis只需要面对统一的命令和数据类型(如字符串、列表、哈希),不用关心其底层采用哪种编码;另一方面,可以根据不同的应用场景选用不同的内部编码,以提高效率。

1. 字符串

1.1 概况

字符串是最基础的类型,所有的键都是字符串类型,其长度不能超过512MB。

1.2 内部编码

字符串类型的内部编码有3种:

  • int:8个字节的长整型(注意不是4字节)。字符串值是整型时,这个值使用long整型表示。
  • embstr:<=39字节的字符串。
  • raw:>39字节的字符串。

embstr与raw都使用redisObject和sds保存数据,区别在于,embstr只分配一次内存空间,只读,而raw需要分配两次内存空间,但可修改。

注意:如果采用embstr编码,Redis会一次性申请一块连续空间,例如:

1
| redisObject | sds 头部 | "Tom\0" |

如果采用raw编码,则会分别申请redisObject与sds两块内存,例如:

1
redisObject ---> 指向 ---> | sds头部 | "This is a very very long string ..." |

1.3 编码转换

  • 当int数据不再是整数或者大小超过long的范围时,则自动转换为raw。
  • 对于embstr,由于其是只读的,因此在修改时,需要先将其转换为raw再修改。

2. 列表

2.1 概况

列表用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储2^32-1个元素。Redis中的列表支持两端插入与删除。

2.2 内部编码

列表内部编码有两种:

  • 双端链表。
  • 压缩列表,是Redis为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构,因而修改与增删操作的效率低,常用于节点数量少的情况。

2.3 编码转换

使用压缩列表的条件(需同时满足):

  1. 列表中所有字符串对象都不超过64字节。
  2. 列表中元素数量小于512个。

如果不满足则采用双端链表,编码只可能由压缩列表转换为双端链表,反方向则不可能。

3. 哈希

3.1 概况

哈希不仅是Redis对外提供的5种对象类型的一种,也是Redis作为Key-Value数据库所使用的数据结构。为方便区分,“内层的哈希”表示redis对外提供的5种对象类型的一种,“外层的哈希”表示Redis作为Key-Value数据库使用的数据结构。

3.2 内部编码

内层的哈希使用的内部编码可以是压缩列表和哈希表两种,外层的哈希只使用了哈希表。

压缩列表相较于哈希表,适用于元素个数少,元素长度小的场景,优势在于集中存储,节省空间;当元素较少时,与哈希表相比没有明显劣势。

哈希表:一个哈希表由一个dict结构,两个dictht结构,一个dictEntry指针数组和多个dictEntry结构组成,可见下图:

img

dictEntry

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

1
2
3
4
5
6
7
8
9
typedef struct dictEntry{
void *key;
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
struct dictEntry *next;
}dictEntry;
  • key:键。
  • val:值,基于union实现,存储的内容可能是:一个指向值的指针,64位整型,无符号64位整型。
  • next:指向的下一个dictEntry,主要用于解决哈希冲突。

在64位的操作系统中,一个dictEntry对象占24字节。

bucket

bucket是一个数组,存储这dictEntry结构的指针。bucket大小按照大于dictEntry的、最小的2^n的规则计算。

dictht

dictht结构如下:

1
2
3
4
5
6
typedef struct dictht{
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
}dictht;
  • table:指针,指向bucket。
  • size:记录bucket的大小。
  • sizemask:size-1。
  • used:记录已使用的dictEntry数量。

dict

一般来说,使用dictht和dictEntry已经足够实现哈希表的功能,但在Redis实现中,dictht上层还存在dict结构,dict结构定义如下:

1
2
3
4
5
6
typedef struct dict{
dictType *type;
void *privdata;
dictht ht[2];
int trehashidx;
} dict;
  • type:dictType定义了一簇用于操作特定类型键值对的函数。
  • privdata:
  • ht[2]:字典的核心,包含两个dictht结构的数组。使用两个哈希表的目的是支持渐进式rehash。
    • ht[0]:平时使用的哈希表。
    • ht[1]:仅在rehash过程中使用。
  • trehashidx:用于指示当前是否在进行rehash。-1表示没有进行rehash操作,非-1表示正在进行,其值表示下一个需要从ht[0]迁移到ht[1]的哈希表数组索引。

当ht[0]的负载因子超过某个阈值时,字典需要扩容以避免哈希冲突过于频繁,这时Redis会分配一个更大的ht[1](通常是ht[0]的两倍),之后将 rehashidx 设置为 0,表示开始 rehash。在后续的每次字典操作(如 dictAdd, dictFind, dictDelete 等)中,除了执行指定的操作外,还会顺带将 ht[0] 中 rehashidx 索引上的所有 dictEntry 迁移到 ht[1]。迁移完成后,rehashidx 自增。当所有 ht[0] 的数据都迁移到 ht[1] 后,ht[0] 和 ht[1] 互换角色,ht[1] 变为新的空哈希表,rehashidx 设为 -1,表示 rehash 结束。

3.3 编码转换

内层的哈希可使用压缩列表也可使用哈希表。

使用压缩列表需满足的条件如下:

  1. 哈希中元素数量小于512个;
  2. 哈希中所有键值对的键和值字符串长度都小于64字节。

任何一项不满足则使用哈希表,编码只可能从压缩列表转换为哈希表。

4. 集合

4.1 概况

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

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

4.2 内部编码

集合的内部编码可以是整数集合(intset)或哈希表(hashtable)。哈希表前面已经提到,下面介绍整数集合。

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

1
2
3
4
5
typedef struct intset{
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
  • encoding:记录contents数组中元素的编码方式,它决定了每个元素占用的字节数。Redis支持三种编码:
    • INTSET_ENC_INT16
      • 每个元素是一个 16 位有符号整数(int16_t)。
      • 当集合中所有元素都在这个范围内时,使用此编码。
    • INTSET_ENC_INT32
      • 每个元素是一个 32 位有符号整数(int32_t)。
      • 当集合中出现超出int16_t范围,但仍在int32_t范围内的元素时,会触发 “升级”,将所有元素都转换为int32_t编码。
    • INTSET_ENC_INT64
      • 每个元素是一个 64 位有符号整数(int64_t)。
      • 当集合中出现超出 int32_t 范围的元素时,会再次触发升级,将所有元素转换为int64_t编码。
  • length:表示元素个数。
  • contents[]:实际存储的类型是int16_t、int32_t、int64_t,由encoding决定。

4.3 编码转换

使用整数集合需同时满足以下条件:

  1. 集合中元素数量小于512。
  2. 集合中所有元素都是整数值。

任何一个条件不满足则使用哈希表,只可能能从整数集合转换为哈希表,反方向则不可能。

5. 有序集合

5.1 概况

有序集合与集合一样,元素不能重复,但元素是有序的,其排序方式取决于每个元素的分数。

5.2 内部编码

有序集合的内部编码可以是压缩列表(ziplist)或跳跃表(skiplist)。ziplist前面已经提到过,这里主要介绍跳跃表。

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

5.3 编码转换

使用压缩列表需同时满足以下条件:

  1. 有序集合中元素数量小于128个。
  2. 有序集合中所有成员长度都不足64字节。

如果有一个条件不满足,则使用跳跃表,且编码只可能由压缩列表转化为跳跃表,反方向则不可能。