Optimización de la aplicación node.js

Dado: antigua aplicación http node.js y mayor carga en ella.

Soluciones estándar al problema: salga de los servidores, reescriba todo desde 0, optimice lo que ya se ha escrito.

Intentemos pasar por la optimización y descubrir cómo encontrar y mejorar las debilidades de la aplicación. Y tal vez acelerar sin tocar una sola línea de código :)

Todos los interesados ​​bienvenidos debajo del gato!

Primero, decidamos una técnica de prueba de rendimiento. Estaremos interesados ​​en la cantidad de solicitudes atendidas en 1 segundo: rps.

Ejecutaremos la aplicación en modo 1 de trabajador (1 proceso), midiendo el rendimiento del código antiguo y el código con optimizaciones: el rendimiento absoluto no es importante, el rendimiento comparativo es importante.

En una aplicación típica con muchas rutas diferentes, es lógico encontrar primero las solicitudes más cargadas, cuyo procesamiento lleva la mayor parte del tiempo. Las utilidades como request-log-analizer o muchas similares le permitirán extraer esta información de los registros.

Por otro lado, puede tomar una lista real de solicitudes y viñetarlas todas (por ejemplo, utilizando yandex-tank): obtenemos un perfil de carga confiable.

Pero haciendo muchas iteraciones de optimización de código, es mucho más conveniente usar una herramienta más simple y rápida y un tipo específico de solicitud (y después de optimizar una solicitud, estudiar la siguiente, etc.). Mi elección es wrk . Además, en mi caso, el número de rutas no es grande, no es difícil verificar todo uno por uno.

Cabe señalar de inmediato que, en términos de consultas de bloqueo, expectativas de la base de datos, etc. la aplicación ya está optimizada, todo depende de la CPU: durante las pruebas, el trabajador consume 100% de CPU.

Los servidores vendidos usan node.js versión 6 - comencemos con él:

Solicitudes / seg: 1210

Probamos en el octavo nodo:
Solicitudes / seg: 2308
10ma nota:
Solicitudes / seg: 2590

La diferencia es obvia. El papel clave aquí se juega mediante la actualización de la versión v8: una gran cantidad de código v8 mal optimizado se encuentra en el pasado. Y para no lidiar con los molinos de viento que desaparecieron en node.js v8, es mejor actualizar de inmediato y luego hacer la optimización del código.

Pasamos a la búsqueda real de cuellos de botella: en mi opinión, la mejor herramienta para esto es flamegraph. Y con el advenimiento del proyecto 0x , obtener un gráfico de fuego fue muy simple: iniciar 0x en lugar de nodo: 0x -o yourscript.js, hacer una prueba, detener el script, ver el resultado en el navegador.

El gráfico de llamas del código probado se ve así antes de las optimizaciones:


Debajo de los filtros, deje la aplicación, los departamentos: solo el código de la aplicación y los módulos de terceros.

Cuanto más ancha es la tira, más tiempo se dedica a realizar esta función (incluidas las llamadas anidadas).

Nos ocuparemos de la parte central más grande.

En primer lugar, destacamos las funciones no optimizadas. Encontré algunos de estos en la aplicación.

Además, las funciones principales son candidatos típicos para la optimización. Las funciones restantes están alineadas con pasos relativamente uniformes: cada función contribuye con una pequeña fracción de los retrasos, no hay un líder obvio.

Entonces es posible un algoritmo simple de acciones: optimizar las funciones más amplias, moviéndose de una a otra. Pero elegí un enfoque diferente: optimizar desde el punto de entrada a la aplicación (controlador de solicitud en http.createServer). Al final de la función en estudio, en lugar de llamar a las siguientes funciones, completo el procesamiento de la solicitud con una respuesta ficticia y estudio el rendimiento de esta función en particular. Después de su optimización, la respuesta ficticia se mueve más a lo largo de la pila de llamadas a la siguiente función, etc.

Una consecuencia conveniente de este enfoque: puede ver rps en condiciones ideales (con solo una función de inicio, rps está cerca de los rps máximos de la aplicación hellow world node.js), y con un mayor movimiento del trozo de respuesta en la aplicación, observe la contribución de la función estudiada a la caída del rendimiento en rps-ah.

Entonces, dejamos solo la función de inicio, obtenemos:

Solicitudes / seg: 16176



Al conectar el núcleo, los filtros v8, puede ver que casi toda la función bajo investigación consiste en enviar una respuesta, iniciar sesión y otras cosas mal optimizadas: vamos más allá.

Pasamos a la siguiente función:

Solicitudes / seg: 16111
Nada ha cambiado, sumérgete más:
Solicitudes / seg: 13330


Nuestro cliente! Se puede ver que la función getByUrl involucrada ocupa una parte significativa de la función de inicio, que se correlaciona bien con la subsidencia rps.

Observamos cuidadosamente lo que está sucediendo en él (enciende core, v8):

Están sucediendo muchas cosas ... fumamos el código, optimizamos:

for (var i in this.data) { if (this[i]._options.regexp_obj.test(url)) return this[i]; } return null; 

convertirse en

 let result = null; for (let i=0; i<this.length && !result; i++) { if (this[i]._options.regexp_obj.test(url)) result = this[i]; } 

En este caso, un simple for es mucho más rápido que for..in

Obtener solicitudes / seg: 16015



Visualmente, la función se "desinfló" ​​y ocupa una fracción mucho menor de la función inicial.
En la información detallada sobre la función, todo también se simplificó enormemente:

Pasamos a la siguiente función.

Solicitudes / seg: 13316



Esta función tiene muchas funciones de matriz y, a pesar de la aceleración significativa en las versiones recientes de node.js, aún son más lentas que los bucles simples: change [] .map and filter. regular para y obtener

Solicitudes / seg: 15067



Y así, una y otra vez, para cada función posterior.

Algunas optimizaciones más útiles: para los hash con un conjunto de claves que cambian dinámicamente, el nuevo Map () puede ser un 40% más rápido que el normal {};

Math.round (el * 100) / 100 es 2 veces más rápido que toFixed (2).

En el gráfico de llamas para las funciones principales y v8, puede ver tanto entradas oscuras como StringPrototypeSplit o v8 :: internal :: Runtime_StringToNumber, y, si esto es una parte importante de la ejecución del código, intente optimizar, por ejemplo, simplemente reescriba el código que no ejecuta estos operaciones

Por ejemplo, reemplazar la división con múltiples llamadas indexOf y substring puede dar un doble aumento de rendimiento.

Un tema grande y complejo por separado es la optimización de jit, o más bien, las funciones desoptimizadas.
Si hay una gran proporción de tales funciones, será necesario ocuparse de ellas.

Un estudio detallado de la salida del nodo --trace_file_names --trace_opt_verbose --trace-deopt --trace_opt puede ayudar aquí.

Por ejemplo, líneas del formulario.

desoptimización (DEOPT soft): comenzar 0x2bcf38b2d079 <Función JS getTime ... La retroalimentación de tipo insuficiente para la operación binaria condujo a la línea

retorno val> = 10? val: '0' + val;

Reemplazo para

return (val> = 10? '': '0') + val;

corrigió la situación.

Hay mucha información para el viejo motor v8 por razones y formas de combatir la desoptimización de funciones:

github.com/P0lip/v8-deoptimize-reasons - list,
www.netguru.co/blog/tracing-patterns-hinder-performance - análisis de causas típicas,
www.html5rocks.com/en/tutorials/speed/v8 : sobre las optimizaciones para v8, creo que lo mismo es cierto para el motor v8 actual.

Pero muchos de los problemas ya no son relevantes para el nuevo v8.

De todos modos, después de todas las optimizaciones, logré obtener Solicitudes / segundo: 9971 , es decir se acelerará aproximadamente 2 veces debido a la transición a la última versión de node.js, y otras 4 veces debido a la optimización del código.

Espero que esta experiencia sea útil para alguien más.

Source: https://habr.com/ru/post/es414541/


All Articles