内存碎片是计算机系统中的一个常见问题,尽管已经存在许多解决这个问题的巧妙算法。 内存碎片会浪费内存区中空闲的内存块。这些空闲的内存块无法合并成更大的内存区以满足应用未来对较大内存块的申请,也无法重新释放到操作系统用于其他用途[1]。 这会导致内存泄露现象,因为对大块的内存申请越来越多,满足这些请求所需要的总内存大小会无限增加。 这种内存使用量的无限增加通常不会被视为内存泄漏,因为未使用的内存块实际上被释放并标记为空闲内存,只是这些内存块无法被重新用于满足更大的内存块申请,同时也无法重新被返还给操作系统供其他进程使用。

在 OpenResty 或 Nginx 的共享内存区内,如果被申请的内存 slab 或内存块的大小差别很大,也容易出现内存碎片问题。 例如,ngx_lua 模块的 lua_shared_dict 区域支持写入任意长度的任意用户数据项。这些数据项可以小到一个数值,也可以大到很长的字符串。 如果不注意的话,用户的 Lua 程序很容易在共享内存区中产生严重的内存碎片问题,浪费大量的空闲内存。 本文中将列举几个独立的例子,来详细演示这个问题以及相关行为细节。 本文将使用 OpenResty XRay 动态追踪平台产品,直接通过生动的可视化方法来观察内存碎片。 我们将在文章结尾中介绍减少共享内存区内存碎片问题的最佳实践。

与本博客网站中的几乎所有技术类文章类似,我们使用 OpenResty XRay 这款动态追踪产品对未经修改的 OpenResty 或 Nginx 服务器和应用的内部进行深度分析和可视化呈现。 因为 OpenResty XRay 是一个非侵入性的分析平台,所以我们不需要对 OpenResty 或 Nginx 的目标进程做任何修改 – 不需要代码注入,也不需要在目标进程中加载特殊插件或模块。 这样可以保证我们通过 OpenResty XRay 分析工具所看到的目标进程内部状态,与没有观察者时的状态是完全一致的。

如果您还不熟悉 OpenResty 或 Nginx 共享内存区内的内存分配和使用,请参阅上一篇博客文章《OpenResty 和 Nginx 的共享内存区是如何消耗物理内存的》

空的共享内存区

首先,我们以一个没有写入用户数据的空的共享内存区为例,通过查看这个内存区内部的 slab 或内存块,了解“基准数据”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
master_process on;
worker_processes 1;

events {
worker_connections 1024;
}

http {
lua_shared_dict cats 40m;

server {
listen 8080;
}
}

我们在这里配置了一个 40MB 的共享内存区,名为 cats。 我们在配置里完全没有触及这个 cats 区,所以这个区是空的。 但从前一篇博客文章 《OpenResty 和 Nginx 的共享内存区是如何消耗物理内存的》 中,我们已经知道空的共享内存区依旧有 160 个预分配的 slab,用于存放 slab 分配器使用的元数据。 下面这张虚拟内存空间内 slab 的分布图也证实了这一点:

共享内存区 cats 中的 slab 布局

这个图同样由 OpenResty XRay 自动生成,用于分析实际运行中的进程。 从图中可以看到,有 3 个已经使用的 slab,还有 100 多个空闲 slab。

填充类似大小的条目

我们在上面的例子 nginx.conf 中增加下列指令 location = /t

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
location = /t {
content_by_lua_block {
local cats = ngx.shared.cats
local i = 0
while true do
local ok, err = cats:safe_set(i, i)
if not ok then
if err == "no memory" then
break
end
ngx.say("failed to set: ", err)
return
end
i = i + 1
end
ngx.say("inserted ", i, " keys.")
}
}

这里我们在 location = /t 中定义了一个 Lua 内容处理程序,在 cats 区中写入很小的键值对,直到没有空闲的内存区为止。 因为我们写入的键和值都是数值(number),而 cats 共享内存区又很小,所以在这个区中写入的各个键值对的大小应该都是彼此很接近的。在启动 Nginx 服务器进程后,我们使用下面的命令查询这个 /t 位置:

1
2
$ curl 'localhost:8080/t'
inserted 255 keys.

从响应体结果中可以看到,我们在这个共享内存区中可以写入了 255 个键。

我们可以再次查看这个共享内存区内的 slab 布局:

被填满的共享内存区'cats'中的slab布局

如果将这个图与上文那个空的共享内存区分布图进行对比,我们可以看到新增的那些更大的 slab 都是红色的(即表示已被使用)。 有趣的是,中间那些空闲 slab (绿色)无法被更大的 slab 重新使用,尽管两者彼此接近。 显然,这些原本预留的空 slab 并不会被自动合并成更大的空闲 slab。

我们再通过 OpenResty XRay 来查看这些 slab 的大小分布情况:

被填满的共享内存区 'cats' 中的slab大小分布

我们看到几乎所有已使用的 slab 的大小都是 128 个字节。

删除奇数键

接下来,我们尝试在现有的 Lua 处理程序后面追增下面这个 Lua 代码片段,以删除共享内存区内的奇数键:

1
2
3
for j = 1, i, 2 do
cats.delete(j)
end

重启服务器后,再次查询 /t,我们得到了新的 cats 共享内存区 slab 布局图:

删除奇数键后的共享内存区 'cats' 中的 slab 布局

现在,我们有许多不相邻的空闲内存块,而这些内存块无法合并成大块,从而无法满足未来大块内存的申请。 我们尝试在 Lua 处理程序之后再添加下面的 Lua 代码,以插入一个更大的键值对条目:

1
2
3
4
local ok, err = cats:safe_add("Jimmy", string.rep("a", 200))
if not ok then
ngx.say("failed to add a big entry: ", err)
end

然后我们重启服务器进程,并查询 /t

1
2
3
$ curl 'localhost:8080/t'
inserted 255 keys.
failed to add a big entry: no memory

如我们所预期的,新添加的大的条目有一个 200 个字节的字符串值,所以相对应的 slab 必然大于共享内存区中可用的最大的空闲 slab(即我们前面看到的 128 字节)。 所以如果不强行驱除某些已使用的键值对条目,则无法满足这个内存块请求(例如在内存区中内存不足时,set() 方法自动删最冷的已使用的条目数据的行为)。

删除前半部分的键

接下来我们来做一个不同的试验。我们不删除上文中的奇数键,而是改为添加下列 Lua 代码以删除共享内存区前半部分里的那些键:

1
2
3
for j = 0, i / 2 do
assert(cats:delete(j))
end

重启服务器进程并查询 /t 之后,我们得到了下面这个虚拟内存空间里的 slab 布局:

内存区中删除键的前半部分之后的 Slab 布局

可以看到在这个共享内存区的中间位置附近,那些相邻的空闲 slab 被自动合并成了 3 个较大的空闲 slab。 实际上,这是 3 个空闲内存页,每个内存页的大小为 4096 个字节:

空闲 slab 大小分布

这些空闲内存页可以进一步形成跨越多个内存页的更大的 slab。

下面,我们再次尝试写入上文中插入失败的大条目:

1
2
3
4
5
6
local ok, err = cats:safe_add("Jimmy", string.rep("a", 200))
if not ok then
ngx.say("failed to add a big entry: ", err)
else
ngx.say("succeeded in inserting the big entry.")
end

这一回,我们终于成功插入了,因为我们有很大的连续空闲空间,足以容纳这个键值对:

1
2
3
$ curl 'localhost:8080/t'
inserted 255 keys.
succeeded in inserting the big entry.

现在,新的 slab 分布图里已经明显可以看到这个新的条目了:

内存区中写入大的条目后的 slab

请注意图中前半部分里的那条最狭长的红色方块。那就是我们的“大条目”。 我们从已使用的 slab 的大小分布图中可以看得更清楚些:

有大 slab 的 slab 大小分布

从图中可以看出,“大条目”实际上是一个 512 个字节的 slab(包含了键大小、值大小、内存补齐和地址对齐的开销)。

缓解内存碎片

在上文中我们已经看到,分散在共享内存区内的小空闲 slab 容易产生内存碎片问题,导致未来大块内存的申请无法被满足,即使所有空闲 slab 加起来的总大小还要大得多。 我们推荐下面两种方法,可更好地重新利用这些空闲的 slab 空间:

  1. 始终使用大小接近的数据条目,这样就不再存在需要满足大得多的内存块申请的问题了。
  2. 让被删除的数据条目邻近,以方便这些条目被自动合并成更大的空闲 slab 块。

对于方法 1),我们可以把一个统一的共享内存区人为分割成多个针对不同数据条目大小分组的共享内存区[2]。例如,我们可以有一个共享内存区只存放大小为 0 ~ 128 个字节的条目,而另一个共享内存内存区只存放大小为 128 ~ 256 个字节的条目。

而对于方法 2)我们可以按照条目的过期时间进行分组。比如过期时间较短的数据条目,可以集中存放在一个专门的共享内存区,而过期时间较长的条目则可以存放在另一个共享内存区。这样我们可以保证同一个共享内存区内的条目都以类似的速度到期,从而提高条目同时到期,以及同时被删除和合并的几率。

结论

OpenResty 或 Nginx 共享内存区内的内存碎片问题在缺少工具的情况下,还是很难被观察和调试的。 幸运的是,OpenResty XRay 提供了强有力的可观察性和可视化呈现,能够迅速地发现和诊断问题。 本文中我们通过一系列小例子,使用 OpenResty XRay 自动生成的图表和数据,揭示了背后到底发生了什么,演示了内存碎片问题以及缓解这个问题的方法。最后,我们介绍了基于 OpenResty 或 Nginx 使用一般配置和编程的共享内存区的最佳实践。

延伸阅读

关于作者

章亦春是开源项目 OpenResty® 的创始人,同时也是 OpenResty Inc. 公司的创始人和 CEO。他贡献了许多 Nginx 的第三方模块,相当多 Nginx 和 LuaJIT 核心补丁,并且设计了 OpenResty XRay 等产品。

关注我们

如果您觉得本文有价值,非常欢迎关注我们 OpenResty Inc. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

我们提供了英文版原文和中译版(本文) 。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!


  1. 对于 OpenResty 和 Nginx 的共享内存区而言,分配和访问过的内存页在进程退出之前都永远不会再返还给操作系统。当然,释放的内存页和内存 slab 仍然可以在共享内存区内被重新使用。

  2. 有趣的是,Linux 内核的 Buddy 内存分配器以及 Memcached 的分配器也使用了类似的策略。