最近看到了一个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 连续写入3000032字节长度的值后,再写入一次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进行分配管理。在使用的时候应该需要注意以下三点:

  1. 写入项的大小最好保持一致或者近似,避免内存碎片问题。
  2. 如果无法保证写入项大小相近,使用时应加上重试逻辑,以确保成功写入。
  3. 由于共享内存是通过mmap分配的,写入时尽量设置过期时间,既可以节省系统内存资源,也可以减少强制回收的次数。

看来用sharedict 来缓存一些变长业务数据也不是那么明智

alexf
也不一定变长就不好
如果是缓存的业务场景,最好是给 key 设置 ttl,走 key 过期淘汰的逻辑,既可以减少内存占用,也可以降低 dict 被写满进入强制淘汰的概率

14 days later

XRay 可以统计到共享内存实际使用的 slab 页的大小分布,包括「正在被使用的」和「空闲的」,还可以看到 rbtree 的高度

Write a Reply...