Redis OOM 的那点事

3,258 阅读10分钟

Hello 大家好,我是虎珀!

今天跟大家分享 「Redis OOM 的那点事」,Redis 作为炙手可热的内存数据库,对内存依赖极高。但如果内存使用不当,容易引发 「OOM」,影响业务正常运行。

注:本文源码基于 Redis 6.2


01 什么是 OOM

早期在 Linux 操作系统中引入,是一种当内存不足时,通过 Kill 进程,保护系统的机制。

Redis OOM 可分为两类:

1.Redis 自身判断内存使用超过上限,返回 OOM 错误。此时无法执行 「耗费内存的命令」。如 SET、LPUSH 等等。

2.操作系统自身内存不足。选择内存占用高的进程 Kill。而 Redis 是内存占用大户,被 Kill 的风险极高。你需要冗余一定的内存来避免此状况。

本篇文章我们重点分析 「Redis 自身发生 OOM」


02 Redis OOM 触发时机

通常,我们会在 redis.conf 中,配置 Redis 使用内存上限。以避免内存无限使用被操作系统 Kill 掉。如下

maxmemory 1073741824  // 单位 bytes,表示最大内存 1G

当 Redis 使用内存超过 「maxmemory」 时,请求会报错 OOM。

127.0.0.1:6379> set foo bar
(error) OOM command not allowed when used memory > 'maxmemory'.

该 OOM 报错,是在执行命令函数 「processCommand」 中产生。代码如下

int processCommand(client *c) {
    ...
    // is_denyoom_command 为该命令是否消耗内存的标记
    int is_denyoom_command = (c->cmd->flags & CMD_DENYOOM) ||
                             (c->cmd->proc == execCommand && (c->mstate.cmd_flags & CMD_DENYOOM));
    ...
    if (server.maxmemory && !server.lua_timedout) {
        // 判断是否 OOM 并执行内存回收策略(需开启,默认 noeviction 关闭回收策略)
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        int reject_cmd_on_oom = is_denyoom_command;
        // 在事务上下文中,会给「命令队列」增加待执行的子命令消耗内存
        // 所以,事务中即使是读命令,也会检查 OOM 状态
        if (c->flags & CLIENT_MULTI &&
            c->cmd->proc != execCommand &&
            c->cmd->proc != discardCommand &&
            c->cmd->proc != resetCommand) {
            reject_cmd_on_oom = 1;
        }
         
        // 判断是否 OOM
        if (out_of_memory && reject_cmd_on_oom) {
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }
        // lua 脚本特殊处理。参见 issue 5250/6565
        if (c->cmd->proc == evalCommand || c->cmd->proc == evalShaCommand) {
            server.lua_oom = out_of_memory;
        }
    }
    ...
}

其中,is_denyoom_command 变量,是从每个 redisCommand sflag 中获取。Redis 给消耗内存的命令设置了标记 「use-memory」

如下 get 命令不会消耗内存,所有没有 use-memory 标记。而 set 命令会消耗内存,所以有 「use-memory」 标记。

struct redisCommand redisCommandTable[] = {
    ...
    {"get",getCommand,2,
     "read-only fast @string",
     0,NULL,1,1,1,0,0,0},

    {"set",setCommand,-3,
     "write use-memory @string",
     0,NULL,1,1,1,0,0,0},
    ...
}

performEvictions 函数,内部会调用 getMaxmemoryState 获取内存状态。如果使用内存高于 maxmemory,则返回 C_ERR。

int performEvictions(void) {
    ...
    // 获取内存使用状态
    if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
        return EVICT_OK;

    // 如果没有配置内存回收策略,则报错 OOM
    if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
        return EVICT_FAIL;

    ...
}

而从 getMaxmemoryState 函数可以得知。和 server.maxmemory 比较的 mem_used,实际是 zmalloc 分配的内存 mem_reported,减去 overhead(slave 副本 buffer 和 AOF buffer)。

int getMaxmemoryState(size_t *total, size_t *logical, size_t *tofree, float *level) {
    size_t mem_reported, mem_used, mem_tofree;

    mem_reported = zmalloc_used_memory();
    if (total) *total = mem_reported;

    // 没有配置 maxmemory 或 zmalloc 分配内存 <= maxmemory,则快速返回
    int return_ok_asap = !server.maxmemory || mem_reported <= server.maxmemory;
    if (return_ok_asap && !level) return C_OK;

    // mem_used 扣除 slave 结点 buffer 和 AOF buffer
    mem_used = mem_reported;
    size_t overhead = freeMemoryGetNotCountedMemory();
    mem_used = (mem_used > overhead) ? mem_used-overhead : 0;
    ...
    if (mem_used <= server.maxmemory) return C_OK;
    ...
    return C_ERR;
}

小结下,当 Redis 使用内存 mem_used 大于你配置的 maxmemory 时,会造成以下后果

  1. Redis 无法执行 「耗费内存命令」, 如 SET、LPUSH,这些带 「use-memory」 flag 的命令。
  2. Redis 会拒绝所有事务请求,即使事务中只包含「读命令」。

可见,Redis 自身 OOM 影响是非常大的。 

那么 Redis 自身使用内存「mem_used」,由哪些组成呢。


03 MEMORY STATS 命令

MEMORY STATS 命令,可以让你获取 Redis 当前内存开销。更好的分析、监控内存的增长原因。
执行结果如下:

127.0.0.1:6379> memory stats
 1"peak.allocated".
 2) (integer) 630469680
 3"total.allocated"
 4) (integer) 630409184
 5"startup.allocated"
 6) (integer) 1028192
 7"replication.backlog"
 8) (integer) 0
 9"clients.slaves"
10) (integer) 0
11"clients.normal"
12) (integer) 17440
13"aof.buffer"
14) (integer) 0
15"lua.caches"
16) (integer) 0
17"db.0"
18) 1"overhead.hashtable.main"
    2) (integer) 10097272
    3"overhead.hashtable.expires"
    4) (integer) 32
19"overhead.total"
20) (integer) 11142936
21"keys.count"
22) (integer) 200003
23"keys.bytes-per-key"
24) (integer) 3146
25"dataset.bytes"
26) (integer) 619266248
27"dataset.percentage"
28"98.392906188964844"
29"peak.percentage"
30"99.990409851074219"
31"allocator.allocated"
32) (integer) 630343600
33"allocator.active"
34) (integer) 634752000
35"allocator.resident"
36) (integer) 634752000
37"allocator-fragmentation.ratio"
38"1.0069936513900757"
39"allocator-fragmentation.bytes"
40) (integer) 4408400
41"allocator-rss.ratio"
42"1"
43"allocator-rss.bytes"
44) (integer) 0
45"rss-overhead.ratio"
46"1.0000597238540649"
47"rss-overhead.bytes"
48) (integer) 37888
49"fragmentation"
50"1.0070537328720093"
51"fragmentation.bytes"
52) (integer) 4446288

指标很多,我们来一个个解释

1.peak.allocated: Redis 内存使用峰值。该值等同于 info 命令 used_memory_peak。会在 3 个地方触发更新。

  • 每次事件循环 serverCron 中
  • 每次执行命令后
  • 执行 memory 相关命令后,如 「memory usage key」

2.total_allocated:Redis 使用分配器分配的总内存,通过函数 zmalloc_used_memory() 获取分配的内存总量(比如 libc、jemalloc 或者 tcmalloc)。等同于 info 命令 total.allocated。

3.startup.allocated:Redis 启动时,初始内存大小,InitServerLast 函数中获取。不包含 RDB 和 AOF 加载的数据,因为加载 RDB 和 AOF 在 InitServerLast 函数之后。该值等同于 info 命令 startup.allocated。

int main(){
    InitServerLast();
    loadDataFromDisk();
}

4.replication.backlog:主从复制积压缓冲区,当主从复制断连时,master 发送给 slave 的写命令,会暂存到该 backlog 缓冲区中,当 slave 再次连接时,仅发送部分命令即可完成同步。该缓冲区可通过 repl-backlog-size 配置,默认 1M。通常我们会配置大一些,比如 256M。避免缓冲区溢出导致全量数据同步。

5.clients.slaves:主从复制,所有副本结点的总开销。包括副本读写缓冲区、连接上下文大小。可参考函数 clientsCronTrackClientsMemUsage

6.clients.normal:除所有副本结点,其他客户端读写缓存区、连接上下文大小。这里需要注意,如果读写 big key,可能导致 clients.normal 增长,最终也会 OOM。可以通过 redis.conf 「client-output-buffer-limit」 限制客户端缓冲区大小。

7.aof.buffer:AOF 和 AOF rewrite 缓冲区大小。在 AOF rewrite 期间可能快速升高。

  • AOF 缓冲区:缓存一次事件循环执行的命令。在每次事件循环结束根据 AOF 刷盘策略写入文件。
  • AOF rewrite 缓冲区:当 fork 子进程在进行重写期间,父进程收到的 「写命令」, 会暂存到 aof_rewrite_buf_blocks 中。当子进程 rewrite 结束后,会将该缓冲区追加到 AOF 文件后。

8.lua.caches:lua 脚本和相关信息。通常不会很大。

9.db.0:db 0 内存大小。通常使用几个 db,就会有几个值。可以用来帮助我们分析每个 db 内存使用情况。计算代码如下:

for (j = 0; j < server.dbnum; j++) {
        redisDb *db = server.db+j;
        long long keyscount = dictSize(db->dict);
        if (keyscount==0continue;

        mh->total_keys += keyscount;
        mh->db = zrealloc(mh->db,sizeof(mh->db[0])*(mh->num_dbs+1));
        mh->db[mh->num_dbs].dbid = j;

        mem = dictSize(db->dict) * sizeof(dictEntry) +
              dictSlots(db->dict) * sizeof(dictEntry*) +
              dictSize(db->dict) * sizeof(robj);
        mh->db[mh->num_dbs].overhead_ht_main = mem;
        mem_total+=mem;

        mem = dictSize(db->expires) * sizeof(dictEntry) +
              dictSlots(db->expires) * sizeof(dictEntry*);
        mh->db[mh->num_dbs].overhead_ht_expires = mem;
        mem_total+=mem;

        mh->num_dbs++;
    }

10.overhead.total:3-9 之和。startup.allocated + clients.slaves + clients.normal + aof.buffer + lua.caches + db.0‍。管理 Redis 键空间内部数据结构总和。可以认为除去数据集,剩余管理类内存大小。等价于 info 命令 used_memory_overhead。

11.keys.count:当前存储 key 总数,所有 db hash 表 size 之和。

12.keys.bytes-per-key:除去启动时内存,平均每个 key 内存大小。

13.dataset.bytes:Redis 所有存储数据内存大小。
该值等于 total_allocate(总内存大小) - overhead.total(管理类内存大小)
dataset 是你需要关心的,可以判断 OOM 是否为客户端写入大量数据所导致。等价于 info 命令 used_memory_dateset。

14.dataset.percentage:数据存储 dataset 占比。

15.peak.percentage:当前内存使用,占比最大内存使用比率。

指标 16-18 通过 cronUpdateMemoryStats 函数采样获取,目的计算内存碎片率。

16.allocator.allocated:Rdis 通过 zmalloc 分配的内存大小。通常与 used_memory 相同。

17.allocator.active:Redis 分配器活动页总大小。包含了内存碎片。该值通过 ps 或 top 命令获取,并减去 lua 占用的内存。

18.allocator.resident:Redis 常驻内存 RSS。包含可归还操作系统的内存。

19.allocator-fragmentation.ratio:内存碎片率。
等于 allocator.active / allocator.allocated

20. allocator-fragmentation.bytes:内存碎片大小。
等于 allocator.active - allocator.allocated

21.allocator-rss.ratio:分配器可能很快就会释放回操作系统的页面比率。
等于 allocator.resident / allocator.active

22.allocator-rss.bytes:可能很快就会释放回操作系统的页面大小。
等于 allocator.resident - allocator.active

23.rss-overhead.ratio:于 Redis 分配器无关的内存开销比率。(Redis 6.2 特指 lua_memory)
等于process_rss / allocator_resident

24.rss-overhead.bytes:于 Redis 分配器无关的内存开销(Redis 6.2 特指 lua_memory)
等于process_rss - allocator_resident

25.fragmentation:进程操作系统内存 RSS(process_rss) / Redis 使用内存(zmalloc_used)

26.fragmentation.bytes:进程操作系统内存 RSS(process_rss) - Redis 使用内存(zmalloc_used)

为什么有了 fragmentation 还要增加 allocator-fragmentation.ratio 呢?因为 fragmentation 包含了 Lua 内存使用。而 Lua 通过 malloc 分配内存的。Redis 本身是通过 zmalloc 分配内存的。这会导致内存碎片率过高。allocator.active 是去除 Lua 脚本占用的内存。内存碎片率会更准确。 上述指标较多。建议你对关心的指标进行定时采样,监控报警。


04 哪些操作可能导致内存爆增?

前面我们提到 Redis 判断 OOM,实际使用 zmalloc 分配内存减去 slaves buffer 和  AOF buffer 大小,然后和 maxmemory 比较。

其中有一些,需要我们重点关注,因为可能导致内存突然飙升。

1. datesets: 纯数据大小。如果某时间段,datesets 飙升。有以下几种可能

  • 写命令 qps 暴涨。
  • 客户端在持续写入 big key。

2. client.normal: 客户端缓冲区。如果某时间段,client.normal 飙升。

  • 读命令 qps 暴涨。
  • 客户端在大量读取 big key。
  • 增加了大量新连接。

写命令会导致内存疯长很好理解,增大了数据集。为什么读命令也会导致内存升高呢?

Redis 会根据 key 大小,通过 zmalloc 分配内存,这部分内存计算在 client.normal 中。如果某一时间,big key 访问的 qps 特别高。Redis 内存使用也会迅速飙升。代码如下

void _addReplyProtoToList(client *c, const char *s, size_t len) {
    ...
    if (len) {
        size_t size = len < PROTO_REPLY_CHUNK_BYTES? PROTO_REPLY_CHUNK_BYTES: len;
        // 根据 key 大小分配内存
        tail = zmalloc(size + sizeof(clientReplyBlock));
        /* take over the allocation's internal fragmentation */
        tail->size = zmalloc_usable_size(tail) - sizeof(clientReplyBlock);
        tail->used = len;
        memcpy(tail->buf, s, len);
        listAddNodeTail(c->reply, tail);
        c->reply_bytes += tail->size;

        closeClientOnOutputBufferLimitReached(c, 1);
    }
}

这也是为什么我总是说尽量避免 big key

3.aof.buffer: aof 缓冲区大小。如果某段时间内 aof.buffer 飙升。说明该 Redis 实例正在执行 rewrite,并且写 qps 不低,可能有较多 big key。通常建议在 slave 结点开启 aof。避免影响主节点性能。


05 Redis 内存回收策略

为了避免自身 OOM,Redis 提供了多种内存回收策略。

默认为 noeviction,表示不执行回收。

其余 7 种分别为:

  • volatile-lru:在带过期时间的 key 中,执行 LRU 淘汰策略。
  • allkeys-lru: 在所有 key 中,执行 LRU 淘汰策略。
  • volatile-lfu:在带过期时间的 key 中,执行 LFU 淘汰策略。
  • allkeys-lfu: 在所有 key 中,执行 LFU 淘汰策略。
  • volatile-random: 带过期时间的 key 中,随机淘汰。
  • allkeys-random: 所有 key 中随机淘汰。
  • volatile-ttl: 根据过期时间进行淘汰,从 ttl 最短的开始。

回收策略看着很多,其实可以分为 3 类

  1. 淘汰 key 的范围。所有 key 还是带过期时间的 key。
  2. 使用哪种算法。LRU、LFU、random、ttl
  3. 默认不执行淘汰

我画了张图供你参考

1.png

对于淘汰策略选择,我有以下几点建议给你。

  1. 除非 Redis 缓存的数据无法淘汰,否则不要选择默认回收策略 noeviction。通常我们认为,不带过期时间的 key,不能被淘汰。比如将活动商品缓存到 Redis 中。如果被淘汰,可能导致缓存击穿,引起业务异常。

  2. 选择带过期时间的进行淘汰。如果 Redis 中大部分 key 带过期时间,最好从带过期时间的 key 进行淘汰。

  3. 选择 LFU 策略。根据 key 使用频率进行淘汰。LFU 是 LRU 的改进算法。LRU 有先天的缺陷,即使 key 近期没有被访问,也不能说明访问频率低,可能某时间段突然被大量访问。

  4. 内存淘汰算法不同,消耗耗费的 CPU 也不同,你要根据实际情况去选择算法。我建议你将 Redis 内存维持在一个健康的状态。如果 CPU 使用率过高,建议拆分多个实例或者升级 Cluster。

如果你遇到 Redis OOM,欢迎在评论区一起交流~

-End-


最后,欢迎大家关注我的公众号「虎珀」。

我会继续写出更好的技术文章。

如果我的文章对你有所帮助,还请帮忙点赞、关注一下啦~