简介
Redis是一个基于内存的键值对
的数据库,其内存管理是非常重要的。下面从Redis的内存消耗、内存管理、内存优化
三个角度对Redis的内存机制进行剖析和学习。
内存消耗
内存查看
127.0.0.1:6379> info memory 通过Redis客户端命令info memory
查看内存情况
参数 | 含义 |
---|---|
used_memory | Redis内部存储所用的内存使用量 |
used_memory_human | 以可读形式返回 used_memory |
used_memory_rss | 以系统角度返回Redis进程的内存使用量,Redis向系统申请的内存量 |
used_memory_rss_human | 以可读形式返回 used_memory_rss |
used_memory_peak | 内存使用的峰值,used_memory的峰值 |
used_memory_peak_human | 以可读形式返回used_memory_peak |
mem_fragmentation_ratio | 内存碎片率,used_memory_rss / used_memory |
mem_allocator | Redis使用的内存分配器。默认是jemalloc |
下面是一个本机命令输出情况: | |
```c | |
127.0.0.1:6379> info memory | |
used_memory:709320 | |
used_memory_human:692.70K | |
used_memory_rss:672384 | |
used_memory_rss_human:656.63K | |
used_memory_peak:787384 | |
used_memory_peak_human:768.93K | |
total_system_memory:0 | |
total_system_memory_human:0B | |
used_memory_lua:37888 | |
used_memory_lua_human:37.00K | |
maxmemory:0 | |
maxmemory_human:0B | |
maxmemory_policy:noeviction | |
mem_fragmentation_ratio:0.95 | |
mem_allocator:jemalloc-3.6.0 | |
``` | |
## 内存碎片 | |
内存碎片率通过mem_fragmentation_ratio = used_memory_rss / used_memory 计算获得 | |
内存碎片率 | 含义 |
---- | ---- |
1 | 说明used_memory_rss-used_memory多出 的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很 大,说明碎片率严重 | 及时进行碎片清理,一般
1~1.5
属于正常范围
<1 |这种情况一般出现在操作系统把Redis 内存交换(Swap)到硬盘导致,出现这种情况时要格外关注,由于硬盘速度远远慢于内存,Redis性能会变得很差,甚至僵死 | 已经开始使用硬盘进行存储,需要考虑扩容内存
为何会有碎片问题产生?
- Redis内部有自己的内存管理器,为了提高内存使用的效率,来对内存的申请和释放进行管理。
- Redis中的值删除的时候,并没有把内存直接释放,交还给操作系统,而是交给了Redis内部有内存管理器。
- Redis中申请内存的时候,也是先看自己的内存管理器中是否有足够的内存可用。
- Redis的这种机制,提高了内存的使用率,但是会使Redis中有部分自己没在用,却不释放的内存,导致了内存碎片的发生。
内存构成
Redis的内存主要由自身内存、对象内存、缓冲内存、内存碎片
几部分构成。
- 自身内存 自身进程内存占用很小,可以忽略不计
- 对象内存
对象内存
是Redis中内存消耗的主要部分,所有用户数据都存储在对象内存中,Redis支持字符串、列表、哈希、集合、有序集合
五种数据类型,不同数据类型又由不同的数据结构提供底层实现的,可以参考 [Redis]数据结构与对象
- 缓冲内存
缓冲缓存主要包括客户端缓冲、复制积压缓冲区、AOF缓冲区
- 内存碎片 Redis中默认的内存分配器是
jemalloc
,其他内存分配器还有glibc、tcmalloc
。 内存管理器一般会以块形式
对内存进行管理和空间分配。首先按照不同的大小范围进行范围内块大小的定义,根据实际对象的大小来进行内存空间的分配
内存碎片产生原因
- 数据不对齐 由于以
块形式
存储,内存块空间大小是固定标准,当对象与块大小不对齐时无法充分利用该内存空间 - 频繁更新操作
append
等频繁变更操作有可能会导致内存碎片产生 - 大量键过期删除 当出现大量键对象
过期
后,释放的空间未充分利用会导致碎片率上升
内存碎片规避方式
- 数据对齐 尽量使用定长数据类型,保持内存块对齐,也就是说尽可能物尽其用,用多少占用多少
- 安全重启 在集群环境下,可以主从节点切换,通过重启服务来重载内存以提高内存使用率
子进程内存消耗
这里主要是指Redis在AOF/RDB
时需要fork()
创建出的子进程的内存消耗。 由于Linux
中有写时复制(copy-on-write)
技术来使父子进程共享物理内存页,只有当数据发生变更时才产生实际复制,因此无需预留占用和父进程一样的内存空间。
这里需要关注下Linux提供的Transport Huge Page开启状态下产生的问题,
Linux kernel
在2.6.38内核增加了THP特性,支持大内存页(2MB)分配,默 认开启。当开启时可以降低fork子进程的速度,但fork操作之后,每个内存 页从原来4KB
变为2MB
,会大幅增加重写期间父进程内存消耗。同时每次写 命令引起的复制内存页单位放大了512倍,会拖慢写操作的执行时间,导致大量写操作慢查询,例如简单的incr命令也会出现在慢查询中。因此Redis日志中建议将此特性进行禁用。
内存管理
Redis主要通过控制内存上限
和内存回收策略
进行内存管理和控制。可通过config set maxmemory
命令进行动态修改。
内存上限
通过maxmemory
来设置内存使用上限,也就是限制used_memory
中各项内存大小的总和,由于存在内存碎片,Redis在系统中实际使用的内存要大于maxmemory
内存回收
内存回收策略
内存回收主要在以下两个方面:
- 删除过期的键对象 为了避免过多消耗资源性能维护大量的过期键,采用
惰性删除、定期任务
进行过期键对象内存回收。- 惰性删除 当执行命令访问到键对象具有超时属性时,则进行移除。该方式存在的问题是若过期对象一直不被访问则永远无法回收。
- 定期任务 通过自定义的
自适应算法
执行慢模式、快模式
两种进行删除,间隔时间默认为10s(参数hz
控制)。感兴趣可以自行查看源码剖析,不再赘述。
- 当内存使用到达
maxmemory
时,触发内存回收策略
对内存空间进行回收处理
内存回收策略主要有noeviction、volatile-lru、allkeys-lru、allkeys-random、volatile-random、volatile-ttl
六种,可以通过config set maxmemory-policy
进行动态配置控制。
回收策略 | 执行方式 |
---|---|
noeviction | 默认策略,不会删除任何数据,拒绝所有写入操作并返 回客户端错误信息(error)OOM command not allowed when used memory,此 时Redis只响应读操作。 |
volatile-lru | 根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。 |
allkeys-lru | 根据LRU算法删除键,不管数据有没有设置超时属性, 直到腾出足够空间为止。 |
allkeys-random | 随机删除所有键,直到腾出足够空间为止。 |
volatile-random | 随机删除过期键,直到腾出足够空间为止。 |
volatile-ttl | 根据键值对象的ttl属性,删除最近将要过期数据。如果 没有,回退到noeviction策略。 |
内存回收源码
以下是内存回收源码
int freeMemoryIfNeeded(void) {
size_t mem_used, mem_tofree, mem_freed;
int slaves = listLength(server.slaves);
/* Remove the size of slaves output buffers and AOF buffer from the
* count of used memory. */
// 计算出 Redis 目前占用的内存总数,但有两个方面的内存不会计算在内:
// 1)从服务器的输出缓冲区的内存
// 2)AOF 缓冲区的内存
mem_used = zmalloc_used_memory();
if (slaves) {
listIter li;
listNode *ln;
listRewind(server.slaves,&li);
while((ln = listNext(&li))) {
redisClient *slave = listNodeValue(ln);
unsigned long obuf_bytes = getClientOutputBufferMemoryUsage(slave);
if (obuf_bytes > mem_used)
mem_used = 0;
else
mem_used -= obuf_bytes;
}
}
if (server.aof_state != REDIS_AOF_OFF) {
mem_used -= sdslen(server.aof_buf);
mem_used -= aofRewriteBufferSize();
}
/* Check if we are over the memory limit. */
// 如果目前使用的内存大小比设置的 maxmemory 要小,那么无须执行进一步操作
if (mem_used <= server.maxmemory) return REDIS_OK;
// 如果占用内存比 maxmemory 要大,但是 maxmemory 策略为不淘汰,那么直接返回
if (server.maxmemory_policy == REDIS_MAXMEMORY_NO_EVICTION)
return REDIS_ERR; /* We need to free memory, but policy forbids. */
/* Compute how much memory we need to free. */
// 计算需要释放多少字节的内存
mem_tofree = mem_used - server.maxmemory;
// 初始化已释放内存的字节数为 0
mem_freed = 0;
// 根据 maxmemory 策略,
// 遍历字典,释放内存并记录被释放内存的字节数
while (mem_freed < mem_tofree) {
int j, k, keys_freed = 0;
// 遍历所有字典
for (j = 0; j < server.dbnum; j++) {
long bestval = 0; /* just to prevent warning */
sds bestkey = NULL;
dictEntry *de;
redisDb *db = server.db+j;
dict *dict;
if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM)
{
// 如果策略是 allkeys-lru 或者 allkeys-random
// 那么淘汰的目标为所有数据库键
dict = server.db[j].dict;
} else {
// 如果策略是 volatile-lru 、 volatile-random 或者 volatile-ttl
// 那么淘汰的目标为带过期时间的数据库键
dict = server.db[j].expires;
}
// 跳过空字典
if (dictSize(dict) == 0) continue;
/* volatile-random and allkeys-random policy */
// 如果使用的是随机策略,那么从目标字典中随机选出键
if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_RANDOM)
{
de = dictGetRandomKey(dict);
bestkey = dictGetKey(de);
}
/* volatile-lru and allkeys-lru policy */
// 如果使用的是 LRU 策略,
// 那么从一集 sample 键中选出 IDLE 时间最长的那个键
else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
{
struct evictionPoolEntry *pool = db->eviction_pool;
while(bestkey == NULL) {
evictionPoolPopulate(dict, db->dict, db->eviction_pool);
/* Go backward from best to worst element to evict. */
for (k = REDIS_EVICTION_POOL_SIZE-1; k >= 0; k--) {
if (pool[k].key == NULL) continue;
de = dictFind(dict,pool[k].key);
/* Remove the entry from the pool. */
sdsfree(pool[k].key);
/* Shift all elements on its right to left. */
memmove(pool+k,pool+k+1,
sizeof(pool[0])*(REDIS_EVICTION_POOL_SIZE-k-1));
/* Clear the element on the right which is empty
* since we shifted one position to the left. */
pool[REDIS_EVICTION_POOL_SIZE-1].key = NULL;
pool[REDIS_EVICTION_POOL_SIZE-1].idle = 0;
/* If the key exists, is our pick. Otherwise it is
* a ghost and we need to try the next element. */
if (de) {
bestkey = dictGetKey(de);
break;
} else {
/* Ghost... */
continue;
}
}
}
}
/* volatile-ttl */
// 策略为 volatile-ttl ,从一集 sample 键中选出过期时间距离当前时间最接近的键
else if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_TTL) {
for (k = 0; k < server.maxmemory_samples; k++) {
sds thiskey;
long thisval;
de = dictGetRandomKey(dict);
thiskey = dictGetKey(de);
thisval = (long) dictGetVal(de);
/* Expire sooner (minor expire unix timestamp) is better
* candidate for deletion */
if (bestkey == NULL || thisval < bestval) {
bestkey = thiskey;
bestval = thisval;
}
}
}
/* Finally remove the selected key. */
// 删除被选中的键
if (bestkey) {
long long delta;
robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
propagateExpire(db,keyobj);
/* We compute the amount of memory freed by dbDelete() alone.
* It is possible that actually the memory needed to propagate
* the DEL in AOF and replication link is greater than the one
* we are freeing removing the key, but we can't account for
* that otherwise we would never exit the loop.
*
* AOF and Output buffer memory will be freed eventually so
* we only care about memory used by the key space. */
// 计算删除键所释放的内存数量
delta = (long long) zmalloc_used_memory();
dbDelete(db,keyobj);
delta -= (long long) zmalloc_used_memory();
mem_freed += delta;
// 对淘汰键的计数器增一
server.stat_evictedkeys++;
notifyKeyspaceEvent(REDIS_NOTIFY_EVICTED, "evicted",
keyobj, db->id);
decrRefCount(keyobj);
keys_freed++;
/* When the memory to free starts to be big enough, we may
* start spending so much time here that is impossible to
* deliver data to the slaves fast enough, so we force the
* transmission here inside the loop. */
if (slaves) flushSlavesOutputBuffers();
}
}
if (!keys_freed) return REDIS_ERR; /* nothing to free... */
}
return REDIS_OK;
}
复制代码
上图是Redis内存回收源码执行路径,具体步骤为:
- 内存空间定时任务轮询
- 检查
used_memory > max_memory
,满足则继续 - 判断
maxmemory-policy
内存回收策略 - 循环遍历数据库根据策略进行内存回收操作
- 如果存在
slave
从节点,则进行同步
综上,通过内存回收执行进行解读可以看到,内存回收过程需要
循环遍历
所有Redis数据库空间,且当存在SLAVE从节点时会有写放大
问题,因此一般要尽量避免内存回收情况的出现。
内存优化
Redis是一个基于内存的Key-Value型数据库,因此内存是至关重要的,Redis在内存的使用上也做了很多优化。
redisObject对象
Redis在对象结构体设计上,通过type
来指定对象数据类型(string 、list、hash、set、zset
),encoding
来指定对象编码实现,也就是底层数据结构实现(int、intset、ziplist、skiplist、linkedlist、hashtable
)。 不同的底层数据结构实现也继承了这种灵活适配的设计思想,根据不同数据对象类型在不同存储空间要求的情况下进行底层数据结构的替换和适配,目的就是根据实际需要灵活分配内存空间,尽可能减少内存使用。
键值空间压缩
- 对于
键对象(Key)
来说只能存储字符串对象
,因此进来使用较短长度的字符串可以减少内存空间占用 - 对于
键对象(Value)
来说可以存储各种复杂数据类型对象
,但是最终存储值形式还是字符串
或数值
,因此场景形式是对业务数据进行序列化存储以达到数据压缩存储的目的
上图是一个Java中一些序列化工具的存储字节的对比,可以参考https://github.com/eishay/jvm-serializers/wiki
共享对象池
共享对象池
是指Redis内部维护[0-9999]
的整数对象池。 创建大量的整数类型redisObject
存在内存开销,每个redisObject
内部结构至少占16字节,甚至超过了整数自身空间消耗。所以Redis内存维护一个[0-9999]
的整数对象 池,用于节约内存。除了整数值对象,其他类型如list、hash、set、zset
内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。整数对象池在Redis中通过变量REDIS_SHARED_INTEGERS
定义,不能通过配置修改
共享对象池失效的场景:
- 当设置
maxmemory
并启用 LRU相关淘汰策略如:volatile-lru
,allkeys-lru
时,Redis禁止使用共享对象池 - 对于
ziplist
编码的值对象,即使内部数据为整数也无法使用共享对象池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高
为什么只有整数对象池?
数据类型 | 判断相等时间复杂度 |
---|---|
整数 | O(1) |
字符串 | O(n) |
复杂数据结构对象 | O(n2) 甚至更多 |
综上可知,除了整数外,其他数据类型判断对象相等的时间复杂度非常高是非常耗费性能的操作。 |
字符串优化
关于字符串的优化有两个地方要说明,一是Redis内部实现的SDS,可以参考 [Redis]数据结构与对象
数据类型 | 存储形式 | 空间占用 |
---|---|---|
字符串 | {"name":"zhangsan","age":30} | 占用小 |
哈希 | "key" : "name" "value" : "zhangsan" "key" : "age" "value" : "30" | 占用大 |
另外就是可以变化字符串的存储,Redis提供了固定的数据结构和实现,但是同样的数据在一般情况下可以进行数据结构变化,比如JSON串既可以存hash ,也可以使用string ,在数据结构都支持的情况下,可以先转换业务数据成最节省内存占用的数据结构方式来进行存储,个人习惯称之为降维打击 。 |
编码优化
不同类型对象具体底层实现的数据结构也不同, Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率,如下
类型 | 编码 | 对象 | OBJECT ENCODING命令输出 |
---|---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象 | "int" |
REDIS_STRING | REDIS_ENCODING_EMBSTR | 使用 embstr 编码的简单动态字符串实现的字符串对象 | "embstr" |
REDIS_STRING | REDIS_ENCODING_RAW | 使用简单动态字符串实现的字符串对象 | "raw" |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的列表对象 | "ziplist" |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 使用双端链表实现的列表对象 | "linkedlist" |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的哈希对象 | "ziplist" |
REDIS_HASH | REDIS_ENCODING_HT | 使用字典实现的哈希对象 | "hashtable" |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 | "intset" |
REDIS_SET | REDIS_ENCODING_HT | 使用字典实现的集合对象 | "hashtable" |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的有序集合对象 | "ziplist" |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 使用跳跃表和字典实现的有序集合对象 | "skiplist" |
不支持编码回退 主要是数据增删频繁时,数据向压缩编码转换非常消耗CPU,得不偿失