Redis的内存管理和优化

Redis中的内存使用主要是数据使用内存+客户端连接使用内存+内存碎片。
其中数据内存占用的最多,优化的常用手段是合理的控制对象的生命周期以及正确的使用数据结构。客户端使用内存主要包含输出缓冲区等一些数据传输缓存。

Redis的数据结构

只有熟悉的了解数据结构的组成、特性、性能、边界条件等因素后,我们还能更好的分析该数据结构的使用场景和资源消耗情况。

在Redis当中,所有的对象都是通过(redisObject+具体的对象)形式存在的,所有的对象都被封装在redisObject中,redisObject有五个成员:对象类型(type)、底层编码(encoding)、lru(最近访问时间)、refcount(引用数)、*ptr(指向具体对象的指针)。该结构一共会占用16字节。

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;

Redis一共有五种基本类型(其他的特殊类型都是此基础上形成的)string、list、hash、set、zset。
V7Jart.md.jpg

每一种数据类型在底层的存储实现存在多种选择,根据实际情况选择合适的编码类型。在效率和资源之间做出合适的权衡。
V7JtxA.md.jpg

SDS

String类型是redis中使用最为广泛的一个类型,所有的key都是字符串类型;它使用的是redis自己设计的SDS结构:包含len、free以及字符串数组。可以O(1)获取字符串长度和空闲空间。
通过buf数组存储数据可以保证二进制安全,以及SDS的预分配机制可以避免频繁的进行数组扩容行为。
在创建的字符串长度小于等于39(3.2版本后为44)的时候,Redis会使用embstr编码来存储字符串;在这种编码下,只会进行一次内存分配,会把redisObject和sds一起分配;所以embstr编码的字符串是不能改变的,如果对该字符串进行修改,则该编码会直接变成raw。
此外,如果是整型字符串的话,String会使用int格式,直接将整数的值赋值给指针对象。这样就省去了sds的开销。

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

V7JY2d.md.jpg

ziplist

压缩列表是将所有的元素紧凑的连接在一起,相当于把所有的成员都叠在一起,没有额外的数据结构,空间占用比较小。缺点就是在读写的时候需要修改整个压缩列表,所以在数据量比较小的时候才使用,一般能够达到5-10倍的压缩比。

  • ziplist:记录了整个压缩列表所占用的内存直接数
  • ziplist:记录压缩表尾节点举例压缩列表起始地址的字节偏移量,通过这个偏移量可以直接确定表尾节点的地址
  • zllen:记录了压缩列表包含的节点数量
  • entryX:节点列表,压缩列表包含的各个节点,里面记录了前一个节点的长度、本节点的长度、编码等信息。可以方便的实现逆序遍历。
  • zlend:特殊值,用于标记压缩列表的末尾。
    1
    2
    3
    4
    5
    6
    7
    typedef struct zlentry {
    unsigned int prevrawlensize, prevrawlen;
    unsigned int lensize, len;
    unsigned int headersize;
    unsigned char encoding;
    unsigned char *p;
    } zlentry;

V7JJ8H.md.jpg

共享对象

在Redis启动的时候,就直接创建了10000个redisObject,代表1-10000的整型,这些整型会一直保存在内存当中,当其他对象需要用到时,就会共享使用这些整型。这也意味着这些整型LRU成员没有用,后续不会有其他的一些开销。

键值对管理

1
2
3
4
5
6
7
8
9
10
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP) */
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;

在redis当中使用redisDb表示一个数据库结构,单个redis可以设置16个这样的DB;通过select命令进行选择,不同DB之间互不影响。

过期Key清理

通过一个dict结构(dict)来存储具体键值对对象;一个dict结构(expires)来记录redis中设置了过期时间的键值对,这个结构会复用dict中的Key、Value对象,所以并不会造成额外的内存开销。

在Key过期的时候,redis会自动执行一个del操作对过期的Key进行清理操作。这个行为并不是立即去执行的,而是会在几个条件下触发。一般只会在必要或者CPU空闲的时候做过期清理动作;如访问Key的时候、事件循环结束进入事件侦听前、后台定期任务。

Redis后台清理任务默认1s执行10次,也就是100ms一次。相当于如果没有其他操作,全部用来做后台任务的话,一次后台任务可以执行100ms;其中最高25%的时间用来删除过期Key的操作,即100ms中有25ms可以用来做Key清理。其他时间用来做Redis管理的任务。

过期Key清理算法:

  1. 依次遍历所有的DB
  2. 从DB中随机取20个键值对,判断是否过期,如果过期,则删除。
  3. 如果取出的键值对中有大于5个过期,则重复上一步;否则遍历下一个DB
  4. 在清理过程中,如果达到了时间限制,退出清理过程。

//TODO
redis的过期Key清理是在redis.c/activeExpireCycle()函数实现的。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
void activeExpireCycle(int type) {
/* This function has some global state in order to continue the work
* incrementally across calls. */
// 静态变量,用来累积函数连续执行时的数据
static unsigned int current_db = 0; /* Last DB tested. */
static int timelimit_exit = 0; /* Time limit hit in previous call? */
static long long last_fast_cycle = 0; /* When last fast cycle ran. */

unsigned int j, iteration = 0;
// 默认每次处理的数据库数量
unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
// 函数开始的时间
long long start = ustime(), timelimit;

// 快速模式
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
/* Don't start a fast cycle if the previous cycle did not exited
* for time limt. Also don't repeat a fast cycle for the same period
* as the fast cycle total duration itself. */
// 如果上次函数没有触发 timelimit_exit ,那么不执行处理
if (!timelimit_exit) return;
// 如果距离上次执行未够一定时间,那么不执行处理
if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
// 运行到这里,说明执行快速处理,记录当前时间
last_fast_cycle = start;
}

/* We usually should test REDIS_DBCRON_DBS_PER_CALL per iteration, with
* two exceptions:
*
* 一般情况下,函数只处理 REDIS_DBCRON_DBS_PER_CALL 个数据库,
* 除非:
*
* 1) Don't test more DBs than we have.
* 当前数据库的数量小于 REDIS_DBCRON_DBS_PER_CALL
* 2) If last time we hit the time limit, we want to scan all DBs
* in this iteration, as there is work to do in some DB and we don't want
* expired keys to use memory for too much time.
* 如果上次处理遇到了时间上限,那么这次需要对所有数据库进行扫描,
* 这可以避免过多的过期键占用空间
*/
if (dbs_per_call > server.dbnum || timelimit_exit)
dbs_per_call = server.dbnum;

/* We can use at max ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC percentage of CPU time
* per iteration. Since this function gets called with a frequency of
* server.hz times per second, the following is the max amount of
* microseconds we can spend in this function. */
// 函数处理的微秒时间上限
// ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默认为 25 ,也即是 25 % 的 CPU 时间
timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
timelimit_exit = 0;
if (timelimit <= 0) timelimit = 1;

// 如果是运行在快速模式之下
// 那么最多只能运行 FAST_DURATION 微秒
// 默认值为 1000 (微秒)
if (type == ACTIVE_EXPIRE_CYCLE_FAST)
timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */

// 遍历数据库
for (j = 0; j < dbs_per_call; j++) {
int expired;
// 指向要处理的数据库
redisDb *db = server.db+(current_db % server.dbnum);

/* Increment the DB now so we are sure if we run out of time
* in the current DB we'll restart from the next. This allows to
* distribute the time evenly across DBs. */
// 为 DB 计数器加一,如果进入 do 循环之后因为超时而跳出
// 那么下次会直接从下个 DB 开始处理
current_db++;

/* Continue to expire if at the end of the cycle more than 25%
* of the keys were expired. */
do {
unsigned long num, slots;
long long now, ttl_sum;
int ttl_samples;

/* If there is nothing to expire try next DB ASAP. */
// 获取数据库中带过期时间的键的数量
// 如果该数量为 0 ,直接跳过这个数据库
if ((num = dictSize(db->expires)) == 0) {
db->avg_ttl = 0;
break;
}
// 获取数据库中键值对的数量
slots = dictSlots(db->expires);
// 当前时间
now = mstime();

/* When there are less than 1% filled slots getting random
* keys is expensive, so stop here waiting for better times...
* The dictionary will be resized asap. */
// 这个数据库的使用率低于 1% ,扫描起来太费力了(大部分都会 MISS)
// 跳过,等待字典收缩程序运行
if (num && slots > DICT_HT_INITIAL_SIZE &&
(num*100/slots < 1)) break;

/* The main collection cycle. Sample random keys among keys
* with an expire set, checking for expired ones.
*
* 样本计数器
*/
// 已处理过期键计数器
expired = 0;
// 键的总 TTL 计数器
ttl_sum = 0;
// 总共处理的键计数器
ttl_samples = 0;

// 每次最多只能检查 LOOKUPS_PER_LOOP 个键
if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

// 开始遍历数据库
while (num--) {
dictEntry *de;
long long ttl;

// 从 expires 中随机取出一个带过期时间的键
if ((de = dictGetRandomKey(db->expires)) == NULL) break;
// 计算 TTL
ttl = dictGetSignedIntegerVal(de)-now;
// 如果键已经过期,那么删除它,并将 expired 计数器增一
if (activeExpireCycleTryExpire(db,de,now)) expired++;
if (ttl < 0) ttl = 0;
// 累积键的 TTL
ttl_sum += ttl;
// 累积处理键的个数
ttl_samples++;
}

/* Update the average TTL stats for this database. */
// 为这个数据库更新平均 TTL 统计数据
if (ttl_samples) {
// 计算当前平均值
long long avg_ttl = ttl_sum/ttl_samples;

// 如果这是第一次设置数据库平均 TTL ,那么进行初始化
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
/* Smooth the value averaging with the previous one. */
// 取数据库的上次平均 TTL 和今次平均 TTL 的平均值
db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
}

/* We can't block forever here even if there are many keys to
* expire. So after a given amount of milliseconds return to the
* caller waiting for the other active expire cycle. */
// 我们不能用太长时间处理过期键,
// 所以这个函数执行一定时间之后就要返回

// 更新遍历次数
iteration++;

// 每遍历 16 次执行一次
if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
(ustime()-start) > timelimit)
{
// 如果遍历次数正好是 16 的倍数
// 并且遍历的时间超过了 timelimit
// 那么断开 timelimit_exit
timelimit_exit = 1;
}

// 已经超时了,返回
if (timelimit_exit) return;

/* We don't repeat the cycle if there are less than 25% of keys
* found expired in the current DB. */
// 如果已删除的过期键占当前总数据库带过期时间的键数量的 25 %
// 那么不再遍历
} while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
}
}

设想在一个大型的Redis实例中所有的Key在同一时间过期了会出现什么样的结果?

答案就是:会导致线上读写请求出现明显的卡顿现象。
首先,Redis会持续扫描过期字典(多次循环),直到过期字典中的Key变得稀疏才会减缓清理的节奏。另外一个原因就是内存管理器需要频繁的回收内存页,会产生一定的CPU消耗。

当客户端请求到来时,服务器如果正好过期扫描,客户端的请求将会等待至少25ms后才会进行处理,如果客户端将超时时间设置的比较短,比如10ms,那么就会出现大量的链接因为超时而关闭,业务端就会出现很多异常。
并且这时还无法从Redis的slowlog中看到慢查询记录,因为慢查询指的是逻辑处理过程慢,不包含等待时间。

所以业务开发人员一定要注意过期时间,如果有大批量的key过期,要给过期时间设置一个随机范围,分散过期处理的压力。

##淘汰机制
实例的内存是有上限的,当使用的内存超过了允许的最大内存时,Redis会按照设定的淘汰策略清理内存,以保证实例的正常运行。

  • volatile-lru:从已设置过期时间的数据中挑选最近最少使用的数据淘汰
  • volatile-ttl:从已设置过期时间的数据中挑选将要过期的数据淘汰
  • volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
  • allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
  • allkeys-random:从数据集中任意选择数据淘汰
  • no-enviction:禁止淘汰数据

淘汰算法:

  1. 遍历所有DB
  2. 按照设置的淘汰策略挑选一个Key进行淘汰
  3. 若策略是lru或ttl,采用近似算法随机取n个样本(默认为5,可配置),从中挑选出最佳值进行淘汰
  4. 计算内存是否超过允许值,若是,重复1~3

最佳实践

最佳实践就是平时遇到的一些好案例,或者从前面的原理导出的一些结论。首先,最重要的就是选择正确的数据类型,主要以满足业务,性能和场景为优先考虑。一般数据量不大的业务,没必要花太大的精力;但是对于一些主要业务,就需要做比较细致的优化。如String类型对象可以考虑使用整数,浮点型可以改成整数。对于数据量比较大,比较重要的业务,可以深入优化,根据业务场景对字符串类型进行合理的拆分,以符合使用压缩列表的(可以适当的对压缩列表的转换条件进行放宽)。

V7JUKI.md.jpg
这是官网上的一个例子,在4存Key-Value的时候,以object后面加一个整数作为ID,整个数据库可能都是这种类型,数据量又特别大,想对它优化的时候,比较通用的优化方法就是取模,相同前缀的Key单独做hash。整个db空间相当于一个大的哈希表,这样就把本来分配给整个db空间的Key,按照不同的前缀分成小的哈希表,每个哈希表里面保证数据比较小,那这个哈希表就会用压缩列表来保存。