About NodeJS and Memory

RETO

Estas últimas semanas hemos tenido que desarrollar un dashboard en Kuaderno donde poder visualizar todos los datos de la DB, para tener una vision global más clara del uso que se le está dando a la plataforma.

Interesaba sacar las métricas por colegios/clases/alumnos (lecturas, ejercicios, puntos, conexiones... por cada uno de ellos las medias de uso etc). Hablando de, a día de hoy - +-1000 colegios, +-10.000 clases y +-180.000 estudiantes - se me ocurrió que la mejor manera de crear el dashboard era calcular todos esos datos al iniciar la app y guardar esos datos agrupados en cada uno de esos tres modelos.

Cada día a la madrugada, copia de seguridad al server -> mongorestore -> reconectar el dashboard a la copia actualizada de la DB -> ejecutar todos los cálculos y consultas para obtener los datos y guardarlos en los modelos -> iniciar server http.

De esta forma es super sencillo y super rápido hacer consultas de, por ejemplo, ordenar de mayor a menos los colegios por conexiones medias al día.

Como es una app que usarán 4-5 personas, tenerla down para hacer los cálculos durante 5 minutos de madrugada, a cambio de después poder hacer este tipo de consultas en milésimas - en una DB que se actualiza a diario - es un buen precio a pagar!

PROBLEMA

Entonces lanzamos la app y empieza a hacer todos los cálculos usando async para controlar el flujo, y no morir en la piramide de callback y bucles asíncronos. Todo se gestiona haciendo parallel, series, eachLimit, waterfall... y durante tres minutos se manejan cientos de miles de datos/consultas y entonces el uso de memoria se dispara (como es normal) hasta los 900MB. La app en reposo, sin pasar por por la carga de datos se coge unos 90MB.

Mi idea era, al igual que me comento pinchito, que la memoria se liberaría sola una vez acabada toda la recolección de datos. Pero la verdad es que no, no se porque después de varías horas el Garbage Collector no me vaciaba la memoria así que me pase el día entero buscando un posible memory leak en alguna parte. Me recorrí linea a linea, el código (por suerte solo son 400 lineas) buscando que había hecho mal, alguna variable declarada como global, callbacks que nunca se ejecutan, querys incorrectas a DB, investigando si async estaba roto por algún sitio... Y dejando a Nazarí desarrollar el plan B por si nunca encontraba una solución al leak, que poca confianza tiene el cabrón en mi.

SOLUCIÓN

Antes de volverme loco del todo, se me ocurrio forzar a mi la llamada al GC. Para poder llamar al GC desde el código -> global.gc() con el flag: --expose-gc. Ej: node --expose-gc app.js

Lo que hice fue después de cada gran bloque de manipulación de datos:

async  
  .series([
    _saveGlobalCache,
    _saveSchoolsCache,
    _saveClassroomsCache
  ], done);

Ejecutar un: if(global.gc) global.gc(); (Importane el if para evitar un crash en caso de no usar el flag)

Después antes de iniciar el server http, lanzar otro, que es justo cuando se han finalizado todas las tareas anteriores. A este último además le doy un empujoncito eliminando todo el objeto donde estaba la lógica y las variables del proceso anterior:

delete services.initialDataCache;  
if(global.gc) global.gc();  

Quizás un poco radical el delete, pero ya no va a volver a usarse más en toda la app. También es verdad que la diferencia de hacer ese delete o no es de unos pocos megas, pero que cojones, estamos on fire que la vida son tres días.

Y wuala, esos 900MB pasan a ser 90MB otra vez (quizás un pelín más suele oscilar entre 85 a 110) pero nada mal.

Resultados de pruebas hechas:

  • Dejar sin gc() -> +- 900MB

  • Llamar gc() después de cada uno de los tres grandes bloques -> +- 500MB

  • Llamar gc() solo al finalizar TODA la carga de datos -> +- 600MB

  • Llamar gc() después de cada bloque y al finalizar todo el proceso -> +- 100MB

Todo esto lo he tirado con node 4.2 y 4.4 y en ambas funciona igual, en Ubuntu y en OSX, y en ambas funciona igual también. Mismos números en todo.

También decir que no usaría el gc() es una aplicación "normal" (API/WEB) porque el gc() es bloqueante, creo que unos 400ms more or less. Aunque quizás el caso de uso hacer un setInterval que lo lanza podría ser útil en determinadas apps? No lo se, la verdad.

Eso si, para momentos concretos, con un poco de cabeza, parece ser un aliado muy bueno.

Link txatxi: https://strongloop.com/strongblog/node-js-performance-garbage-collection/

PD: Cualquier aporte de conocimientos es bienvenido, al final es la solución que he encontrado y me funciona pero puede que no sea la óptima.

PD2: Gracias a Pinchito y a Pedro Palao por dedicarme tiempo ayer!

comments powered by Disqus