最近看到了一个issue(https://github.com/openresty/lua-nginx-module/issues/1692)。现象比较奇怪,issue里是通过random的方法生成了不同长度的值并写入shdict中,写入的方式是shdict:add,而不是shdit:safe_add,但是运行会出现 no memory 错误。
如果按照官方文档中的描述,当共享内存空间不足时,会通过LRU算法删除存储中的现有项,再尝试写入。所以按照issue中的用法通常是不会遇到 no memory 错误的。
阅读shdict源码中add的相关部分:
for (i = 0; i < 30; i++) {
if (ngx_http_lua_shdict_expire(ctx, 0) == 0) {
break;
}
*forcible = 1;
node = ngx_slab_alloc_locked(ctx->shpool, n);
if (node != NULL) {
goto allocated;
}
}
ngx_shmtx_unlock(&ctx->shpool->mutex);
*errmsg = "no memory";
return NGX_ERROR;
从代码中可以看到shdict:add会多次执行释放内存的操作,30次之后仍然没有成功分配空间,才会返回no memory。为什么经过多次释放之后,已经有了足够的空闲空间了,还是写入失败呢?
要弄清楚这个问题,那就需要先知道shdict是如何实现的,shdict是基于Nginx的共享内存实现的,共享内存会通过UNIX系统调用mmap()来申请,这里的申请实际上只是创建了共享内存与物理内存的映射关系,当有真实写入的时候才会将数据写入物理内存中去。 分配得到的共享内存在Nginx里会通过ngx_slab来进行分配管理的。
关于ngx_slab的详细资料网上也比较多,这里简单地了解一下ngx_slab对于内存块管理的工作机制:
- ngx_slab的内存分配方式分为两级,整段共享内存会先以页(page)为单位进行划分。而页内也可以再继续分割成块(chunk)。这里分割块的原则是不同页的块之间大小可以不等,但页内的块大小必须相等。
- 申请的内存大小将会以2的幂次方向上做取整处理,比如申请22字节会分配32字节,申请108字节会分配128字节,当申请大于等于1/2页大小就会分配整页。
- ngx_slab会定义一个exact_size,在将页内分割成块的情况下,如果块大小大于exact_size时,使用一个slab变量存储块大小(实际上是块大小的移位数,比如8字节对应的移位数是3)和位图(bitmap,用来记录每个块的是否使用)。当块大小等于exact_size时,slab变量只存储位图。当分配的块数量超出了这个变量的范围,会使用前面的几个块来做位图,slab变量只存储块的大小。
- 内存使用完之后进行释放,当释放的是块时,通过位图标记为可用状态。释放的是页,则将该页加入到可用链表中。当整页中的块都为可用状态时,也会将整页加入到可用链表里。
分割成块时,共享内存的简化示意图如下:
了解ngx_slab的工作机制之后,再回到这个issue。 这里我将测试用例简化了一下,可以让问题暴露地更明显一点。
=== TEST 连续写入30000次32字节长度的值后,再写入一次128字节的值。
location = /test {
content_by_lua_block {
local dogs = ngx.shared.dogs
local value_small = string.rep("1", 32)
local value_large = string.rep("1", 128)
local ok, err
for i = 1, 30000 do
ok, err = dogs:add(i, value_small)
if not ok then
ngx.log(ngx.ERR, "failed add small, err = "..err)
end
end
for i = 30001, 30002 do
ok, err = dogs:add(i, value_large)
if not ok then
ngx.log(ngx.ERR, "failed add large, err = "..err)
end
end
};
}
经过实际测试之后,这段测试用例会有failed add large value,err = no memory 的错误输出。
结合上面提到的共享内存的ngx_slab分配机制,很容易地就能分析出测试报错原因了。因为前面写入的30000次都是32字节长度的值,共享内存中已经都是32字节的小块内存了,即使释放30次后得到加起来大于128字节的空间了,但这些空闲的内存小块对新写入值来说其实都是不可用的,所以最后返回的是no memory。
而issue里的问题就在于,写入值的长度是随机生成的,长度介于10-20之间,所以申请的内存块大小向上取整后分别为16字节和32字节,而且更多的是16字节的。长时间运行之后,共享内存里将会充满大量的小块内存,一旦遇到较大的值,如果很不幸的释放出来的都是小块内存,而ngx_slab也没有合并这些小块内存的机制,没有可用的大块内存释放,最后还是会返回no memory的。这种情况只能加上重试逻辑,等待大的内存块释放或者整页内存释放了。
同样地,如果共享内存里充满了大量的大块内存,写入较小的值虽然会成功,但是会导致有大量的空闲内存空间不能利用。
最后总结一下,OpenResty的shdict的共享内存通过mmap申请分配,由ngx_slab进行分配管理。在使用的时候应该需要注意以下三点:
- 写入项的大小最好保持一致或者近似,避免内存碎片问题。
- 如果无法保证写入项大小相近,使用时应加上重试逻辑,以确保成功写入。
- 由于共享内存是通过mmap分配的,写入时尽量设置过期时间,既可以节省系统内存资源,也可以减少强制回收的次数。