Solución de problemas que causan el aumento vertiginoso de la memoria dinámica causado por netty en jdk17 | Equipo técnico de JD Cloud

fondo:

introducir

Skynet Risk Control Lingji System es un servicio informático en línea de alto rendimiento y baja latencia basado en cálculos de memoria. Proporciona servicios informáticos estadísticos en línea de conteo, distintoCout, máximo, mínimo, promedio, suma, estándar y distribución de intervalos dentro de ventanas deslizantes o móviles. . . La capa inferior del cliente y el servidor se comunican directamente con TCP a través de netty, y el servidor también realiza una copia de seguridad de los datos en el clúster esclavo correspondiente basado en netty.

Cuello de botella de baja latencia

La primera versión de Lingji ha sido objeto de una amplia optimización y el sistema puede proporcionar un mayor rendimiento. Si se establece un tiempo de espera de 10 ms para el cliente, solo se puede garantizar que la tasa de disponibilidad sea de alrededor del 98,9 % con un tráfico de 1 wqps/núcleo en el lado del servidor. En situaciones de alta concurrencia, es principalmente GC el que hace que la tasa de disponibilidad disminuya. . Si se basa en el recolector de basura cms. Cuando el rendimiento de una máquina 8c16g supera los 20wqps después de la optimización de la segunda versión, se generará un gc aproximadamente cada 4 segundos. Si un gc es igual a 30 ms. Entonces, al menos la granularidad mínima representa al menos (15*30/1000/60)=0,0075 en tiempo gc. Esto significa que el nivel de minutos tp992 es de al menos 30 ms. No satisface las necesidades de los negocios relacionados.

jdk17+ZGC

Para resolver los problemas relacionados con la latencia excesiva mencionados anteriormente, JDK 11 comenzó a introducir un recolector de basura de baja latencia ZGC. ZGC utiliza algunas tecnologías nuevas y algoritmos de optimización para controlar el tiempo de pausa del GC en 10 milisegundos. Con el soporte de JDK 17, el tiempo de pausa de ZGC se puede controlar incluso en un nivel inferior al milisegundo. El tiempo de pausa promedio medido real es de aproximadamente 10 us. Se basa principalmente en un puntero de tinte y una barrera de lectura para lograr concurrencia en la mayoría de las etapas de gc. Los estudiantes interesados ​​pueden aprender sobre esto, y jdk17 es una versión lts.

pregunta:

Después de usar jdk17+zgc y pasar las pruebas de estrés relevantes, todo iba en buena dirección, sin embargo, durante una prueba de estrés de escenario especial, cuando era necesario sincronizar los datos desde el centro de datos de Beijing al centro de datos de Suqian, se descubrieron algunas cosas extrañas. .

  • La memoria del contenedor del servidor se disparó y, después de detener la prueba de esfuerzo, la memoria solo disminuyó muy lentamente.

  • La CPU de la máquina correspondiente se ha mantenido al 20 % (no hay solicitud de tráfico)

  • He estado haciendo algunos gcs. Aproximadamente una vez cada 10 segundos

Viaje de solución de problemas

Solución de problemas de pérdida de memoria

La primera reacción es que cuando me encuentro con el problema del aumento vertiginoso de la memoria y la imposibilidad de liberarla, primero se resume como un problema de pérdida de memoria. Siento que esta pregunta es simple y clara. Inicie la verificación de pérdida de memoria relacionada: primero descargue el análisis de la memoria del montón y descubra que los objetos relacionados con Netty ocupan la memoria del montón. Hace algún tiempo, un compañero de clase también compartió la pérdida de memoria causada por el uso irrazonable de netty byteBuf en netty, lo que aumentó aún más el impacto en netty. memoria Fuga Sospecha. Así que activé el modo estricto de detección de fugas de memoria de Netty (más el parámetro jvm Dio.netty.leakDetection.level=PARANOID), volví a realizar la prueba y no encontré registros de pérdidas de memoria relevantes. ¡Está bien ~! El juicio preliminar es que no se trata de una pérdida de memoria insignificante.

Solución de problemas de errores de las versiones jdk y netty

¿Podría ser un error causado por la mala compatibilidad entre netty y jdk17? Después de revertir jdk8, la prueba encontró que este problema no existía. En ese momento se usaba la versión jdk17.0.7. Dio la casualidad de que la versión jdk17.0.8 se lanzó oficialmente y vi varias correcciones de errores en la introducción de la versión. Entonces actualicé una versión pequeña de jdk, pero descubrí que el problema aún existía. ¿Será que la versión de netty es demasiado baja? Vi un problema similar en gitup# https://github.com/netty/netty/issues/6125WriteBufferWaterMark's y se sospechaba que el problema se había solucionado en una versión superior. Modifiqué varias versiones de netty y volví a probar, pero Descubrí que el problema todavía existe.

Localización de causa directa y solución.

Después de las dos investigaciones anteriores, descubrimos que el problema era más complicado de lo que imaginamos, por lo que debemos realizar un análisis en profundidad del porqué y reorganizar las pistas relevantes:

  • Se descubrió que al volver a jdk8, la cantidad de datos de respaldo recibidos por el clúster correspondiente al centro de Suqian era mucho menor que la cantidad de datos enviados por el centro de Beijing.

  • ¿Por qué todavía hay gc cuando no hay tráfico? La alta CPU debería ser causada por gc (se pensaba que eran algunas características de la memoria de zgc en ese momento)

  • Análisis de memoria: por qué MpscUnboundedArrayQueue de Netty hace referencia a una gran cantidad de objetos AbstractChannelHandlerContext$WriteTask. MpscUnboundedArrayQueue es la cola de tareas writeAndFlush de producción y consumo, y WriteTask es el objeto de tarea writeAndFlush relacionado. Es precisamente debido a la gran cantidad de objetos WriteTask y sus referencias que el uso de memoria es demasiado alto.

  • Este problema solo ocurre en todos los centros de datos y no ocurre en las pruebas de estrés de datos en el mismo centro de datos.

Después del análisis, tenemos una conjetura básica: debido a que la demora en las salas de computadoras en los centros de datos es mayor, la capacidad de sincronización de datos no se puede satisfacer en un solo canal, lo que resulta en un consumo insuficiente del eventLoop de Netty, lo que genera una acumulación.

Solución: agregue una conexión de canal al nodo de datos de respaldo, use ConnectionPool y seleccione aleatoriamente un canal superviviente para la comunicación de datos cada vez que se realice una sincronización por lotes de datos. Después de las modificaciones pertinentes, se encontró que el problema estaba resuelto.

Ubicación de la causa raíz y solución.

Ubicación de la causa raíz

Aunque puede parecer que las modificaciones anteriores han resuelto el problema, aún no se ha descubierto la causa raíz del problema.

  • 1. Si la capacidad de consumo de eventLoop es insuficiente, ¿por qué la memoria relevante solo disminuye lentamente después de detener la prueba de esfuerzo? Lógicamente, debería ser una disminución loca de la memoria.

  • 2. ¿Por qué la CPU siempre está alrededor del 23%? Según los datos de las pruebas de estrés habituales, la sincronización de datos es una operación de transferencia por lotes, que consume aproximadamente el 5% de la CPU como máximo. La CPU adicional debería ser causada por gc, pero los datos la sincronización no debería ser No mucha, no debería causar tanta presión de GC.

  • 3. ¿Por qué este problema no existe en jdk8?

Se especula que existe un netty eventLoop que consume mucho tiempo y bloquea operaciones, lo que resulta en una disminución significativa en la capacidad de consumo. Entonces sentí que todavía era un problema con Netty, así que abrí el registro de depuración relacionado con Netty. Encontré una línea de registro de claves

[2023-08-23 11:16:16.163] DEBUG [] - io.netty.util.internal.PlatformDependent0 - direct buffer constructor: unavailable: Reflective setAccessible(true) disabled

Siguiendo este registro, encontré la causa raíz de este problema. ¿Por qué no se puede utilizar un constructor de memoria directo, lo que provocará que se bloquee el consumo de WriteTask de nuestro sistema? Con este propósito, fuimos a ver el código fuente relevante.

Análisis de código fuente

  • Netty utilizará PooledByteBufAllocator para asignar memoria directa de forma predeterminada, utilizando un mecanismo de grupo de memoria similar a jmelloc. Cada vez que no haya memoria suficiente, se creará io.netty.buffer.PoolArena.DirectArena#newChunk para ocupar previamente la memoria solicitada.
protected PoolChunk<ByteBuffer> newChunk() {  
     // 关键代码  
        ByteBuffer memory = allocateDirect(chunkSize);  
    }  
}  
  • allocateDirect () es la lógica para solicitar memoria directa. En términos generales, si puede usar inseguro subyacente para solicitar y liberar memoria directa y reflexionar para crear objetos ByteBuffer, entonces use inseguro. De lo contrario, llame directamente a Api ByteBuffer.allocateDirect de Java para asignar memoria directamente y use el limpiador incorporado para liberar la memoria. PlatformDependent.useDirectBufferNoCleaner es un punto clave aquí, en realidad es la configuración del parámetro USE_DIRECT_BUFFER_NO_CLEANER.
PlatformDependent.useDirectBufferNoCleaner() ?  
     PlatformDependent.allocateDirectNoCleaner(capacity) :       ByteBuffer.allocateDirect(capacity); 
  • La lógica del parámetro USE_DIRECT_BUFFER_NO_CLEANER está configurada en el {} estático de la clase PlatformDependent.

    Lógica clave: maxDirectMemory==0 y! hasUnsafe () no cumple las condiciones sin una configuración especial en jdk17. La clave es la lógica de juicio de PlatformDependent0.hasDirectBufferNoCleanerConstructor

if (maxDirectMemory == 0 || !hasUnsafe() || !PlatformDependent0.hasDirectBufferNoCleanerConstructor()) {  
    USE_DIRECT_BUFFER_NO_CLEANER = false;  
} else {  
    USE_DIRECT_BUFFER_NO_CLEANER = true; 
  • El juicio de PlatformDependent0.hasDirectBufferNoCleanerConstructor () es ver si DIRECT_BUFFER_CONSTRUCTOR de PlatformDependent0 es NULL. Volviendo al registro de depuración que acabamos de abrir, podemos ver que el constructor DIRECT_BUFFER_CONSTRUCTOR no está disponible de forma predeterminada (no disponible es NULL). El siguiente código contiene juicios lógicos específicos y su pseudocódigo.

1. Condición de apertura 1: jdk9 y superiores deben habilitar el parámetro jvm-io.netty.tryReflectionSetAccessible

2. Condición de apertura dos: se puede obtener un constructor DirectByteBuffer privado mediante reflexión, que construye un DirectByteBuffer a través de la dirección y el tamaño de la memoria (Nota: si hay restricciones de permisos de módulo en java.nio en jdk9 o superior, debe agregar jvm Parámetro de inicio --add-opens=java.base/java.nio=ALL-UNNAMED; de lo contrario, no se puede hacer accesible java.nio.DirectByteBuffer privado (long,int): el módulo java.base no "abre java.nio" ser reportado al módulo sin nombre)

Entonces, aquí no habilitamos estos dos parámetros jvm de forma predeterminada, por lo que DIRECT_BUFFER_CONSTRUCTOR es nulo y la segunda parte correspondiente PlatformDependent.useDirectBufferNoCleaner () es falsa.

    // 伪代码,实际与这不一致  
 ByteBuffer direct = ByteBuffer.allocateDirect(1);  
  
    if(SystemPropertyUtil.getBoolean("io.netty.tryReflectionSetAccessible",  
        javaVersion() < 9 || RUNNING_IN_NATIVE_IMAGE)) {  
         DIRECT_BUFFER_CONSTRUCTOR =  
         direct.getClass().getDeclaredConstructor(long.class, int.class)  
        }  
  • Ahora regrese al paso 2 y descubra que el valor predeterminado de PlatformDependent.useDirectBufferNoCleaner () en versiones superiores de jdk es falso. Luego, cada aplicación de memoria directa se crea a través de ByteBuffer.allocateDirect. Luego, en este momento , se ha localizado la causa raíz relevante y se solicita memoria directa a través de ByteBuffer.allocateDirect. Si la memoria es insuficiente, el sistema se verá obligado a System.Gc () y esperará sincrónicamente a que DirectByteBuffer recuperar la memoria a través de la referencia virtual de Cleaner . El siguiente es el código clave para que ByteBuffer.allocateDirect reserve memoria (reserveMemory). La lógica probablemente sea alcanzar la memoria directa máxima solicitada -> determinar si gc está reciclando objetos relacionados -> si no se están reciclando, active activamente System.gc () para activar el reciclaje -> espere como máximo MAX_SLEEPS veces en el bucle de sincronización para ver si hay suficiente memoria directa. En pruebas personales, toda la lógica de espera de sincronización puede durar hasta 1 segundo en la versión jdk17.

Entonces, la razón más fundamental: si nuestro consumidor netty EventLoop procesa el consumo en este momento porque solicita memoria directa y alcanza la memoria máxima, entonces se sincronizará una gran cantidad de consumo de tareas para esperar la solicitud de memoria directa. Y si no hay suficiente memoria directa, se convertirá en una gran área de bloqueo del consumo.

static void reserveMemory(long size, long cap) {  
  
    if (!MEMORY_LIMIT_SET && VM.initLevel() >= 1) {  
        MAX_MEMORY = VM.maxDirectMemory();  
        MEMORY_LIMIT_SET = true;  
    }  
  
    // optimist!  
    if (tryReserveMemory(size, cap)) {  
        return;  
    }  
  
    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();  
    boolean interrupted = false;  
    try {  
  
        do {  
            try {  
                refprocActive = jlra.waitForReferenceProcessing();  
            } catch (InterruptedException e) {  
                // Defer interrupts and keep trying.  
                interrupted = true;  
                refprocActive = true;  
            }  
            if (tryReserveMemory(size, cap)) {  
                return;  
            }  
        } while (refprocActive);  
  
        // trigger VM's Reference processing  
        System.gc();  
  
        int sleeps = 0;  
        while (true) {  
            if (tryReserveMemory(size, cap)) {  
                return;  
            }  
            if (sleeps >= MAX_SLEEPS) {  
                break;  
            }  
            try {  
                if (!jlra.waitForReferenceProcessing()) {  
                    Thread.sleep(sleepTime);  
                    sleepTime <<= 1;  
                    sleeps++;  
                }  
            } catch (InterruptedException e) {  
                interrupted = true;  
            }  
        }  
  
        // no luck  
        throw new OutOfMemoryError  
            ("Cannot reserve "  
             + size + " bytes of direct buffer memory (allocated: "  
             + RESERVED_MEMORY.get() + ", limit: " + MAX_MEMORY +")");  
  
    } finally {  
        if (interrupted) {  
            // don't swallow interrupts  
            Thread.currentThread().interrupt();  
        }  
    }  
}  
  • Aunque hemos visto el motivo del bloqueo, ¿por qué no se bloquea en jdk8 ? En los 4 pasos, podemos ver que Java 9 está configurando DIRECT_BUFFER_CONSTRUCTOR, por lo que PlatformDependent.allocateDirectNoCleaner se usa para la asignación de memoria. La siguiente es una introducción específica y un código clave.

Paso 1: Antes de solicitar memoria: use el contador de memoria global DIRECT_MEMORY_COUNTER y llame a incrementMemoryCounter para aumentar el tamaño relevante cada vez que solicite memoria. Si se alcanza el parámetro DIRECT_MEMORY_LIMIT (el valor predeterminado es - XX: MaxDirectMemorySize), se generará una excepción . directamente sin ir a la espera de gc síncrona genera mucho tiempo. .

Paso 2: asignar memoria allocateDirectNoCleaner : solicite memoria a través de inseguro y luego use el constructor DIRECT_BUFFER_CONSTRUCTOR para construir DirectBuffer a través de la dirección y el tamaño de la memoria. La liberación también se puede realizar a través de unsafe.freeMemory para liberar la memoria relevante según la dirección de la memoria en lugar de utilizar el propio limpiador de Java para liberar la memoria.

public static ByteBuffer allocateDirectNoCleaner(int capacity) {  
    assert USE_DIRECT_BUFFER_NO_CLEANER;  
  
    incrementMemoryCounter(capacity);  
    try {  
        return PlatformDependent0.allocateDirectNoCleaner(capacity);  
    } catch (Throwable e) {  
        decrementMemoryCounter(capacity);  
        throwException(e);  
        return null;    }  
}  
  
private static void incrementMemoryCounter(int capacity) {  
    if (DIRECT_MEMORY_COUNTER != null) {  
        long newUsedMemory = DIRECT_MEMORY_COUNTER.addAndGet(capacity);  
        if (newUsedMemory > DIRECT_MEMORY_LIMIT) {  
            DIRECT_MEMORY_COUNTER.addAndGet(-capacity);  
            throw new OutOfDirectMemoryError("failed to allocate " + capacity  
                    + " byte(s) of direct memory (used: " + (newUsedMemory - capacity)  
                    + ", max: " + DIRECT_MEMORY_LIMIT + ')');  
        }  
    }  
}  
  
static ByteBuffer allocateDirectNoCleaner(int capacity) {  
  return newDirectBuffer(UNSAFE.allocateMemory(Math.max(1, capacity)), capacity);  
}  
  
  • Después del análisis del código fuente anterior, hemos visto la causa raíz, que es causada por ByteBuffer.allocateDirect gc esperando sincrónicamente la liberación directa de la memoria, lo que resulta en una grave falta de capacidad de consumo. Además, cuando la memoria directa máxima es insuficiente, grandes -El bloqueo del consumo de área requiere tiempo para solicitar memoria directa, lo que hace que la capacidad de consumo de WriteTask sea cercana a 0 y la memoria no se pueda reducir.

Resumir

1. Diagrama de flujo:

2. Causa directa:

  • Sincronización de datos entre centros de datos La canalización de un solo canal tiene capacidades de sincronización de datos insuficientes, lo que provoca congestión en el anillo TCP. Como resultado, la capacidad de escritura en la tarea WriteTask de consumo (WriteAndFlush) de netty eventLoop es mayor que la capacidad de vaciado, por lo que la gran cantidad de memoria directa solicitada se almacena en la lista vinculada ChannelOutboundBuffer#unflushedEntry y no se puede vaciar.

3. Causa raíz:

  • Netty necesita agregar manualmente los parámetros jvm -add-opens=java.base/java.nio=ALL-UNNAMED y -io.netty.tryReflectionSetAccessible en versiones superiores de jdk para habilitarlo y llamar directamente al inseguro subyacente para solicitar memoria. Si no está habilitado, se aplica netty. La memoria usa ByteBuffer.allocateDirect para solicitar memoria directa. Si la memoria directa solicitada por la tarea de consumo de EventLoop alcanza el escenario máximo de memoria directa, una gran cantidad de tareas consumidas estarán esperando sincrónicamente para solicitar memoria directa. Y si no se libera suficiente memoria directa, provocará un bloqueo de consumo a gran escala y también provocará que una gran cantidad de objetos se acumulen en la cola ilimitada de Netty MpscUnboundedArrayQueue.

4. Razones de la lenta reflexión y posicionamiento de los problemas:

  • De forma predeterminada, los datos sincronizados no serán un cuello de botella del sistema. No se juzgan los niveles de agua lowWaterMark y highWaterMark (socketChannel.isWritable ()). Si los datos sincronizados alcanzan el cuello de botella del sistema, se debe lanzar una excepción por adelantado.

  • Al llamar a writeAndFlush al sincronizar datos, se debe agregar el detector de excepciones relevante (código 2 a continuación). Si la excepción OutOfMemoryError se puede detectar de antemano, será más conveniente solucionar problemas relacionados.

(1)ChannelFuture writeAndFlush(Object msg)  
(2)ChannelFuture writeAndFlush(Object msg, ChannelPromise promise);  
  • El monitoreo de memoria no dinámico visto por el sistema de monitoreo en jdk17 no es consistente con las estadísticas de memoria directa realmente utilizadas por el sistema . Como resultado, es imposible localizar el problema al localizar el problema y la memoria directa ha alcanzado el máximo. valor, por lo que esta solución no se considera.

  • La comunicación subyacente del middleware referenciado relacionado también se basa en la comunicación netty . Si hay una sincronización de datos similar, se pueden desencadenar problemas similares. En particular, ump se sombrea y empaqueta cuando las versiones superiores y titan usan netty, y los parámetros relevantes de jvm también se modifican. Aunque este error no se activará, también puede activar el gc del sistema.

ump高版本:jvm参数修改(低版本直接采用了底层socket通信,未使用netty和创建ByteBuffer) io.netty.tryReflectionSetAccessible->ump.profiler.shade.io.netty.tryReflectionSetAccessible  
  
titan:jvm参数修改:io.netty.tryReflectionSetAccessible->titan.profiler.shade.io.netty.tryReflectionSetAccessible  

Supongo que te gusta

Origin blog.csdn.net/weiweiqiao/article/details/132793119
Recomendado
Clasificación