最近在6.3版本的ES上出现了堆外内存使用过高,导致节点内存被打满,引发oom-killer问题。针对这个问题,我深入进行分析,找到了问题原因。
问题描述
问题出现在协调节点,由于协调节点无需使用pagecache,所以我们会把协调节点的堆内存配置配置成节点内存的2/3左右。
出现问题的是一个16cpu 32g的节点上,我们堆内存配置成21g。
业务在凌晨有大量写入,写入时可以看到节点内存在不断上涨。
top看内存占用,可以看到es进程占用了28G内存:
问题分析
oom-killer触发的原因,分析下来主要是以下原因:
- 客户协调节点16c 32g规格,es设置的堆内存为21g,docker节点内存在29.5g左右,这样留给堆外的内存在8.5g,es未设置MaxDirectMemorySize,这样默认值为跟堆内存一样的21g,超过了节点可用的堆外内存。
- netty使用了DirectByteBuffer分配堆外内存处理网络请求,而且有些cache机制,在释放DirectByteBuffer时,未清掉DirectByteBuffer内存,由于这些对象在old,这依赖触发CMS回收DirectByteBuffer对象,但是业务节点GC压力很小,一直无法触发CMS gc,导致DirectByteBuffer对象越堆越多,最终触发了oom-killer。
关于netty的问题,接下来展开说明下:
jmap histo查看full gc前后的对比情况:
full gc前:
num #instances #bytes class name
----------------------------------------------
1: 584249 4372220968 [B
2: 182391 160642272 [I
3: 843882 60280192 [C
4: 222069 40572000 [Ljava.lang.Object;
5: 803940 19294560 java.lang.String
6: 63530 11689520 com.fasterxml.jackson.core.json.UTF8StreamJsonParser
7: 396785 10656352 [Ljava.lang.String;
8: 190930 9164640 com.fasterxml.jackson.core.json.JsonReadContext
9: 212105 8484200 io.netty.buffer.UnpooledSlicedByteBuf
10: 150361 7217328 java.nio.HeapByteBuffer
11: 220993 7071776 io.netty.buffer.PoolThreadCache$MemoryRegionCache$Entry
12: 66563 6922552 org.elasticsearch.action.index.IndexRequest
13: 205541 6577312 java.util.HashMap$Node
14: 190930 6109760 com.fasterxml.jackson.core.json.DupDetector
15: 64268 5141440 io.netty.buffer.PooledHeapByteBuf
16: 64500 4128000 org.elasticsearch.action.index.IndexResponse
17: 63536 4066304 com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer
18: 41378 3976016 [Ljava.util.HashMap$Node;
19: 7488 3953664 io.netty.util.internal.shaded.org.jctools.queues.atomic.MpscAtomicArrayQueue
20: 63697 3567032 com.fasterxml.jackson.core.io.IOContext
21: 63533 3557848 com.fasterxml.jackson.core.util.TextBuffer
22: 67201 3225648 java.util.HashMap
23: 64992 2599680 io.netty.buffer.UnpooledDuplicatedByteBuf
24: 63530 2541200 com.fasterxml.jackson.core.json.ByteSourceJsonBootstrapper
25: 78830 2522560 io.netty.handler.codec.CodecOutputList
26: 37953 2428992 java.nio.DirectByteBuffer
27: 30298 2423840 io.netty.buffer.PooledDirectByteBuf
28: 409 2317712 [Lio.netty.buffer.PoolSubpage;
29: 72332 2314624 java.util.AbstractList$Itr
30: 68912 2205184 org.elasticsearch.transport.netty4.ByteBufBytesReference
31: 36514 2106728 [Ljava.nio.ByteBuffer;
32: 64992 2079744 org.elasticsearch.transport.netty4.ByteBufStreamInput
33: 64500 2064000 org.elasticsearch.action.bulk.BulkItemResponse
34: 64166 2053312 org.elasticsearch.cluster.metadata.IndexNameExpressionResolver$Context
......
7221: 1 16 sun.util.resources.LocaleData$LocaleDataResourceBundleControl
Total 7752044 4860938104
full gc后:
num #instances #bytes class name
----------------------------------------------
1: 49511 1163477560 [B
2: 28751 15856176 [Ljava.lang.Object;
3: 128493 12294800 [C
4: 143253 4584096 java.util.HashMap$Node
5: 7488 3953664 io.netty.util.internal.shaded.org.jctools.queues.atomic.MpscAtomicArrayQueue
6: 117522 2820528 java.lang.String
7: 40173 1928304 java.util.HashMap
8: 15662 1879024 [Ljava.util.HashMap$Node;
9: 15463 1698760 java.lang.Class
10: 12616 1482040 [I
11: 43724 1399168 java.util.concurrent.ConcurrentHashMap$Node
12: 34622 1107904 java.util.Collections$UnmodifiableMap
13: 40684 976416 org.elasticsearch.painless.Definition$MethodKey
14: 618 957008 [Ljava.nio.ByteBuffer;
15: 3873 707568 [J
16: 203 626864 [Lio.netty.buffer.PoolSubpage;
17: 35759 572144 java.lang.Object
18: 9786 548016 org.elasticsearch.painless.Definition$Struct
19: 8335 466760 java.lang.invoke.MemberName
20: 10342 413680 java.util.LinkedHashMap$Entry
21: 6795 326728 [Ljava.lang.String;
22: 1373 310984 [Z
23: 202 306728 [Ljava.util.concurrent.ConcurrentHashMap$Node;
24: 4575 292800 io.netty.buffer.PoolSubpage
25: 8694 278208 java.util.concurrent.atomic.LongAdder
26: 10519 252456 java.util.ArrayList
27: 6113 244520 java.lang.invoke.MethodType
28: 7491 239712 java.util.Hashtable$Entry
29: 6912 221184 io.netty.buffer.PoolThreadCache$SubPageMemoryRegionCache
30: 4900 196000 org.elasticsearch.painless.Definition$Type
31: 6122 195904 java.lang.invoke.MethodType$ConcurrentWeakInternSet$WeakEntry
32: 8119 194856 java.util.Collections$UnmodifiableRandomAccessList
33: 4600 184000 java.lang.ref.SoftReference
34: 9887 158192 java.util.HashMap$EntrySet
35: 4900 156800 java.lang.invoke.DirectMethodHandle
36: 4890 156480 io.netty.buffer.PoolThreadCache$MemoryRegionCache$Entry
37: 4818 154176 org.objectweb.asm.Type
38: 1233 146888 [Ljava.util.Hashtable$Entry;
6641: 1 16 sun.util.resources.LocaleData$LocaleDataResourceBundleControl
Total 1095045 1229886024
full gc后,io.netty.buffer.PooledDirectByteBuf对象直接清空了。java.nio.DirectByteBuffer对象也从从之前30000多,到gc后基本都没了。es进程的内存也直接降低到22GB。
问题复现
通过构造大的bulk请求(84MB),连续nohup执行bulk请求2次,在16c 32g节点上复现了该问题。
在bulk小于10MB之后,再去验证,就发现堆外内存不怎么上涨。
我们最新的7.10.0也尝试复现,发现运行脚本后,堆外内存没有太多变化。
ES在7.2版本之后开始默认添加了MaxDirectMemorySize,默认MaxDirectMemorySize值是jvm堆内存的一半。具体PR见:https://github.com/elastic/elasticsearch/pull/42006
所以尝试在6.3.2加上MaxDirectMemorySize进行验证,加上MaxDirectMemorySize=7g后,再运行复现脚本,节点在堆外内存打满后,会触发一次full gc,内存就顺利降低下来了。
对比6.3.2和7.10.0的netty参数配置,发现7.10.0多了一个-Dio.netty.allocator.numDirectArenas=0参数,在6.3.2加上该参数,但是发现堆外内存仍然会持续上涨。这块后面结合netty原理再展开说明。
根因分析
netty内存分配会调用这个方法:io.netty.channel.nio.AbstractNioChannel的newDirectBuffer:
protected final ByteBuf newDirectBuffer(ByteBuf buf) {
final int readableBytes = buf.readableBytes();
if (readableBytes == 0) {
ReferenceCountUtil.safeRelease(buf);
return Unpooled.EMPTY_BUFFER;
}
final ByteBufAllocator alloc = alloc();
if (alloc.isDirectBufferPooled()) {
ByteBuf directBuf = alloc.directBuffer(readableBytes);
directBuf.writeBytes(buf, buf.readerIndex(), readableBytes);
ReferenceCountUtil.safeRelease(buf);
return directBuf;
}
final ByteBuf directBuf = ByteBufUtil.threadLocalDirectBuffer();
if (directBuf != null) {
directBuf.writeBytes(buf, buf.readerIndex(), readableBytes);
ReferenceCountUtil.safeRelease(buf);
return directBuf;
}
// Allocating and deallocating an unpooled direct buffer is very expensive; give up.
return buf;
}
这部分代码在6.3.2和7.10.0使用的netty版本是一样的。这里6.3.2由于没设置-Dio.netty.allocator.numDirectArenas=0 ,所以alloc.isDirectBufferPooled()为true,会走堆外内存分配。
然后6.3.2加上-Dio.netty.allocator.numDirectArenas=0没生效的原因跟ByteBufUtil.threadLocalDirectBuffer()的结果在6.3.2和7.10.0不一样导致的。以下是threadLocalDirectBuffer()代码:
public static ByteBuf threadLocalDirectBuffer() {
if (THREAD_LOCAL_BUFFER_SIZE <= 0) {
return null;
}
if (PlatformDependent.hasUnsafe()) {
return ThreadLocalUnsafeDirectByteBuf.newInstance();
} else {
return ThreadLocalDirectByteBuf.newInstance();
}
}
其中THREAD_LOCAL_BUFFER_SIZE的默认值,在6.3.2是64*1024,7.10.0默认是0。所以7.10.0不会使用threadLocalDirectBuffer,newDirectBuffer()方法返回的是原buf对象,即堆内的对象。而6.3.2会使用threadLocalDirectBuffer,这个会申请堆外内存。
netty的这个修改是在4.1.22.Final版本,刚好是在6.3.2版本之后,具体PR:https://github.com/netty/netty/pull/7704
6.3.2:
7.10.0:
7.10这样配置,会导致无法使用到堆外内存进行网络请求的处理,ES在最新版本又去掉了-Dio.netty.allocator.numDirectArenas=0 的配置,看这个配置,本来在这个PR中优化掉了:https://github.com/elastic/elasticsearch/pull/48310,这个发布在7.6版本,但是由于bug,在8.2版本才修复:https://github.com/elastic/elasticsearch/pull/76593 ,所以目前7.10的版本未使用到堆外内存。
6.3.2加上-Dio.netty.threadLocalDirectBufferSize=0 配置后,再验证复现脚本,实际效果跟7.10.0是一样的,堆外并未出现明显上涨。
6.3.2版本会进入ByteBuf directBuf = alloc.directBuffer(readableBytes);的逻辑,这里会分配出PooledDirectByteBuf对象。
然后由于ES加上了配置“-Dio.netty.recycler.maxCapacityPerThread=0”,导致每次都会new出一个新的PooledDirectByteBuf,相关代码如下:
final class PooledDirectByteBuf extends PooledByteBuf<ByteBuffer> {
private static final Recycler<PooledDirectByteBuf> RECYCLER = new Recycler<PooledDirectByteBuf>() {
@Override
protected PooledDirectByteBuf newObject(Handle<PooledDirectByteBuf> handle) {
return new PooledDirectByteBuf(handle, 0);
}
};
static PooledDirectByteBuf newInstance(int maxCapacity) {
PooledDirectByteBuf buf = RECYCLER.get();
buf.reuse(maxCapacity);
return buf;
}
......
public abstract class Recycler<T> {
private static final InternalLogger logger = InternalLoggerFactory.getInstance(Recycler.class);
@SuppressWarnings("rawtypes")
private static final Handle NOOP_HANDLE = new Handle() {
@Override
public void recycle(Object object) {
// NOOP
}
};
private static final AtomicInteger ID_GENERATOR = new AtomicInteger(Integer.MIN_VALUE);
private static final int OWN_THREAD_ID = ID_GENERATOR.getAndIncrement();
private static final int DEFAULT_INITIAL_MAX_CAPACITY_PER_THREAD = 32768; // Use 32k instances as default.
private static final int DEFAULT_MAX_CAPACITY_PER_THREAD;
private static final int INITIAL_CAPACITY;
private static final int MAX_SHARED_CAPACITY_FACTOR;
private static final int MAX_DELAYED_QUEUES_PER_THREAD;
private static final int LINK_CAPACITY;
private static final int RATIO;
static {
// In the future, we might have different maxCapacity for different object types.
// e.g. io.netty.recycler.maxCapacity.writeTask
// io.netty.recycler.maxCapacity.outboundBuffer
int maxCapacityPerThread = SystemPropertyUtil.getInt("io.netty.recycler.maxCapacityPerThread",
SystemPropertyUtil.getInt("io.netty.recycler.maxCapacity", DEFAULT_INITIAL_MAX_CAPACITY_PER_THREAD));
if (maxCapacityPerThread < 0) {
maxCapacityPerThread = DEFAULT_INITIAL_MAX_CAPACITY_PER_THREAD;
}
DEFAULT_MAX_CAPACITY_PER_THREAD = maxCapacityPerThread;
......
}
@SuppressWarnings("unchecked")
public final T get() {
if (maxCapacityPerThread == 0) {
return newObject((Handle<T>) NOOP_HANDLE);
}
Stack<T> stack = threadLocal.get();
DefaultHandle<T> handle = stack.pop();
if (handle == null) {
handle = stack.newHandle();
handle.value = newObject(handle);
}
return (T) handle.value;
}
......
}
如果bulk小的时候,正常PooledDirectByteBuf对象就被young gc掉了,但是在bulk大的时候,PooledDirectByteBuf会进入old区,这个就依赖CMS gc清除。随着PooledDirectByteBuf对象越来越多,它引用的DirectByteBuffer对象也越来越多,最终造成了堆外内存被打满。
大bulk有PooledDirectByteBuf进入old区,目前看可能跟http pipeline机制有关,或者netty到等到整个bulk请求接收完才能处理,相对时间变长,young gc处理不过来,有部分对象就进入了old区。
所以之前问题分析时,手动执行full gc后,可以看到PooledDirectByteBuf对象都被情况了,堆外内存也直接下降了。
加上MaxDirectMemorySize后,内存在上涨到堆外内存上限后,就会触发full gc。清掉这部分堆外内存。
总结
总结下来,6.3的oom问题,主要来自于netty使用的堆外内存,由于进入了old区,又无法触发cms gc,所以堆外内存越对越多。需要加上MaxDirectMemorySize控制下。
最新版本加上了MaxDirectMemorySize,且分配了一半的堆内存,所以节点jvm内存不要设置超过物理内存的2/3。