En un entorno de producción, un uso anormal de CPU al 100% puede afectar severamente el rendimiento. A continuación, se detalla un caso práctico de diagnóstico.
Inicialmente, Sentry reportó múltiples excepciones de tipo RedisTimeoutException, sugiriendo problemas con la conexión a Redis. La excepción indicaba que los comandos no se escribían en la conexión, lo que podría estar vinculado a un alto consumo de CPU en la JVM.
Para investigar, se identificó el pod afectado a través de los tags de Sentry y se accedió al contenedor. Se utilizó el comando top para monitorear el uso de CPU por proceso y thread.
top -Hp 1
Este comando muestra los threads del proceso Java con ID 1, ordenados por uso de CPU. Se observaron threads con alta utilización, principalmente relacionados con Redisson.
Posteriormente, se empleó Arthas para obtener un análisis detallado de los threads. Al conectar Arthas al proceso, se ejecutó:
thread -n 10
Esto reveló los 10 threads con mayor consumo de CPU, cuyos stack traces apuntaban a problemas en la escritura de comandos de Redis.
Examinando el código fuante de Redisson, se encontró un bucle potencialmente infinito en la clase CommandsQueue. El código original implementaba un mecanismo de bloqueo usando compareAndSet en un bucle while, que podría quedarse atrapado si la operación atómica fallaba repetidamente.
Código problemático reescrito con nombres de variables y lógica ajustada, manteniendo el mismo comportamiento defectuoso:
@Override
public void enqueueCommand(ChannelHandlerContext handlerContext, Object inputMessage, ChannelPromise completionPromise) throws Exception {
if (inputMessage instanceof RedisCommand) {
RedisCommand redisCmd = (RedisCommand) inputMessage;
CommandEntry entry = new CommandEntry(redisCmd, completionPromise);
Queue<CommandEntry> pendingCommands = handlerContext.channel().attr(COMMAND_QUEUE_KEY).get();
boolean lockAcquired = false;
while (!lockAcquired) {
if (synchronizationLock.compareAndSet(false, true)) {
lockAcquired = true;
try {
pendingCommands.add(entry);
entry.getCompletionPromise().addListener(future -> {
if (!future.isSuccess()) {
pendingCommands.remove(entry);
}
});
handlerContext.writeAndFlush(redisCmd, entry.getCompletionPromise());
} catch (Exception ex) {
pendingCommands.remove(entry);
throw ex;
} finally {
synchronizationLock.set(false);
}
}
}
} else {
super.enqueueCommand(handlerContext, inputMessage, completionPromise);
}
}
El bucle while (!lockAcquired) con synchronizationLock.compareAndSet podía causar un ciclo sin fin si el lock nunca se adquiría, resultando en un uso constante de CPU al 100%. Esto ocurría cuando el thread que poseía el lock terminaba inesperadamente, dejando el estado inconsistente.
La solución implicó actualizar la librería Redisson a la versión 3.37.0 o superior, donde se corrigió este problema. Alternativamente, se podría sobrescribir la lógica afectada, pero la actualización es más simple y segura.
Para generalizar el diagnóstico de problemas de CPU al 100% en JVM, se siguen estos pasos clave:
Primero, localizar el proceso con alto consumo usando top o jps para identificar procesos Java.
top -c -p <pid>
Después, encontrar los threads problemáticos con top -Hp <pid> para obtener IDs de threads, o usar jstack para volcar stacks.
jstack -l <pid> | grep "nid=$(printf '%x' <thread_id>)" -C 10
Herramientas como Arthas simplifican esto con comandos como thread -n 10 para mostrar threads con mayor CPU.