Teletransportando toneladas de datos a PostgreSQL

Hoy compartiré algunas soluciones arquitectónicas útiles que surgieron durante el desarrollo de nuestra herramienta para el análisis masivo del rendimiento de los servidores PostgeSQL , y que ahora nos ayudan a "encajar" el monitoreo y análisis completo de más de mil hosts en el mismo hardware, que al principio apenas alcanzaba para cien ...





Intro



Permítanme recordarles algunas notas introductorias:



  • estamos construyendo un servicio que recibe información de los registros de los servidores PostgreSQL
  • recopilar registros, queremos hacer algo con ellos (analizar, analizar, solicitar información adicional) en línea
  • todo lo recopilado y "analizado" debe guardarse en algún lugar


Hablemos del último punto: cómo se puede entregar todo esto al almacenamiento de PostgreSQL . En nuestro caso, estos datos son múltiplos de los originales: estadísticas de carga en el contexto de una aplicación específica y plantilla de plan, consumo de recursos y cálculo de problemas derivados con precisión para un solo nodo de plan, bloqueos de supervisión y mucho más.

Más información sobre los principios del servicio se puede ver en el informe de video y leer en el artículo "Optimización masiva de consultas PostgreSQL" .


empujar vs tirar



Hay dos modelos principales para obtener registros u otras métricas que llegan constantemente:



  • push - hay muchos receptores peer-to-peer en el servicio , en los servidores monitoreados - algún agente local vierte periódicamente la información acumulada en el servicio
  • pull - en el servicio, cada proceso / hilo / corrutina / ... procesa información de una sola fuente "propia" , cuya recepción de datos es iniciada por sí mismo


Cada uno de estos modelos tiene lados positivos y negativos.



empujar



La interacción es iniciada por el nodo observado:



... es beneficioso si:



  • tienes muchas fuentes (cientos de miles)
  • la carga sobre ellos no difiere mucho entre ellos y no excede ~ 1 rps
  • no se necesita algún procesamiento complicado




Ejemplo: el receptor del operador OFD que recibe cheques de la caja registradora de cada cliente.



... causa problemas:



  • bloqueos / interbloqueos al intentar escribir diccionarios / análisis / agregados en el contexto del objeto de monitoreo de diferentes flujos
  • la peor utilización de la memoria caché de cada proceso BL / conexión a la base de datos; por ejemplo, la misma conexión a la base de datos debe escribirse primero en una tabla o segmento de índice e inmediatamente en otro
  • Se requiere que se coloque un agente especial en cada fuente, lo que aumenta la carga sobre ella.
  • alta sobrecarga en la interacción de la red : los encabezados deben "vincular" el envío de cada paquete, y no la conexión completa a la fuente en su conjunto


Halar



El iniciador es un host / proceso / subproceso específico del recopilador, que "une" el nodo a sí mismo y extrae datos de forma independiente del "objetivo":



... es beneficioso si:



  • tienes pocas fuentes (cientos-miles)
  • casi siempre hay una carga de ellos, a veces llega a 1Krps
  • requiere un procesamiento complejo con segmentación por fuente




Ejemplo: cargador / analizador de operaciones en el contexto de cada plataforma de negociación.



... causa problemas:



  • limitación de recursos para procesar una fuente por un proceso (núcleo de CPU), ya que no se puede "manchar" entre dos destinatarios
  • se necesita un coordinador para redistribuir dinámicamente la carga de las fuentes a los procesos / subprocesos / recursos existentes


Dado que nuestro modelo de carga al monitorear PostgreSQL claramente gravitó hacia el algoritmo de extracción, y los recursos de un proceso y el kernel de una CPU moderna son suficientes para nosotros para una fuente, nos detuvimos en eso.



Troncos de arrastre



Nuestra comunicación con el servidor proporcionado por mucho el número de operaciones de red y el trabajo con cadenas de texto slaboformatirovannymi , así como un núcleo colector JavaScript fue perfecto en su encarnación como un servidor Node.js .



La solución más sencilla para obtener datos del registro del servidor resultó ser "duplicar" todo el archivo de registro en la consola mediante un simple comando de Linux tail -F <current.log>. Solo nuestra consola no es simple, sino virtual, dentro de una conexión segura al servidor extendida sobre el protocolo SSH .



Por lo tanto, sentado en el segundo lado de la conexión SSH, el recopilador recibe una copia completa de todo el tráfico de registros en la entrada. Y si es necesario, solicita al servidor información ampliada del sistema sobre el estado actual de las cosas.



Por qué no syslog



Hay dos razones principales:



  1. syslogfunciona en un modelo push, por lo que es imposible administrar rápidamente la carga de procesamiento del flujo generado por él en el punto de recepción. Es decir, si algún par de hosts de repente comenzó a "verter" miles de planes de solicitudes lentas, entonces es extremadamente difícil separar su procesamiento en diferentes nodos.



    Procesar aquí significa no tanto una recepción / análisis "estúpido" del registro, sino analizar planes y calcular la intensidad de recursos real de cada uno de los nodos .
  2. PostgreSQL, , «» (relation/page/tuple/...).

    «DBA: ».


-



En principio, se podrían utilizar otras soluciones como DBMS para almacenar datos analizados del registro, pero el volumen de información entrante de 150-200GB / día no deja demasiado margen de maniobra. Por lo tanto, también elegimos PostgreSQL como almacenamiento.



- ¿PostgreSQL para almacenar registros? ¿Seriamente?

- En primer lugar, hay lejos de ser únicos y no tanto registros como diversas representaciones analíticas . En segundo lugar, "¡simplemente no sabes cómo cocinarlos!" :)






Configuración del servidor



Este punto es subjetivo y depende en gran medida de su hardware, pero hemos elaborado los siguientes principios para configurar el host PostgreSQL para la grabación activa.



Configuración del sistema de archivos

El factor más importante que afecta el rendimiento de escritura es el montaje [incorrecto] de la partición de datos. Hemos elegido las siguientes reglas:



  • el directorio PGDATA está montado (en el caso de ext4) con parámetrosnoatime,nodiratime,barrier=0,errors=remount-ro,data=writeback,nobh
  • el directorio PGDATA / pg_stat_tmp se mueve atmpfs
  • el directorio PGDATA / pg_wal se mueve a otro medio, si es razonable


consulte Ajuste del sistema de archivos PostgreSQL



Elección del programador de E / S óptimo

De forma predeterminada, muchas distribuciones han seleccionado el programador de E / Scfq , perfeccionado para uso de "escritorio", en RedHat y CentOS - noop. Pero resultó ser más útil para nosotros deadline.



ver PostgreSQL vs. Programadores de E / S (cfq, noop y fecha límite)



Reducción del tamaño de la caché "sucia"

Este parámetro vm.dirty_background_bytesestablece el tamaño de la caché en bytes, al llegar al cual el sistema inicia el proceso en segundo plano de vaciarlo en el disco. Existe un parámetro similar, pero mutuamente excluyentevm.dirty_background_ratio : establece el mismo valor como porcentaje del tamaño total de la memoria, de forma predeterminada, está establecido y no "... bytes".



En la mayoría de las distribuciones es del 10%, en CentOS es del 5%. Esto significa que con una memoria total del servidor de 16 GB, el sistema puede intentar escribir más de 850 MB en el disco una vez, lo que da como resultado una carga máxima de IOps.



Lo disminuimos experimentalmente hasta que los picos de grabación comienzan a suavizarse. Por experiencia, para evitar picos, el tamaño debe ser menor que el rendimiento máximo de medios (en IOps) multiplicado por el tamaño de la página de memoria. Es decir, por ejemplo, para 7K IOps (~ 7000 x 4096), aproximadamente 28 MB.



consulte Configuración de las opciones del kernel de Linux para las opciones de optimización de PostgreSQL



en postgresql.conf

Qué parámetros deben verse, retorcidos para acelerar la grabación. Todo aquí es puramente individual, por lo que solo daré algunas ideas sobre el tema:



  • shared_buffers - debe hacerse más pequeño, ya que con el registro específico de datos "comunes" especialmente superpuestos, los procesos no surgen
  • synchronous_commit = off - siempre puede deshabilitar la espera de escritura de confirmación si confía en la batería de su controlador RAID
  • fsync- si los datos no son en absoluto críticos, puede intentar apagarlos - "en el límite" incluso puede obtener una base de datos en memoria


Estructura de la tabla de la base de datos



Ya publiqué algunos artículos sobre la optimización del almacenamiento de datos físicos:





Pero sobre diferentes claves en los datos, todavía no había. Te contaré sobre ellos.



Las claves externas son malas para los sistemas de escritura pesada. De hecho, se trata de "muletas" que no permiten que un programador descuidado escriba en la base de datos lo que supuestamente no debería estar allí.



Muchos desarrolladores están acostumbrados al hecho de que las entidades comerciales relacionadas lógicamente a nivel de descripción de tablas de bases de datos deben estar vinculadas a través de FK. ¡Pero este no es el caso!



Por supuesto, este punto depende en gran medida de los objetivos que establezca al escribir datos en la base de datos. Si no es un banco (y si también es un banco, ¡entonces no procesa!), Entonces la necesidad de FK en una base de datos de escritura pesada es cuestionable.



"Técnicamente" cada FK hace un SELECT independiente al insertar un registrode la tabla referenciada. Ahora mire la tabla donde está escribiendo activamente, donde tiene 2-3 FK colgando, y evalúe si vale la pena para su tarea específica para garantizar una especie de integridad de una caída en el rendimiento de 3-4 veces ... ¿O es suficiente una conexión lógica por valor? Aquí tenemos todo FK - eliminado.



Las claves de UUID son buenas . Dado que la probabilidad de una colisión de UUID generados en diferentes puntos no relacionados es extremadamente pequeña, esta carga (al generar algunos ID sustitutos) se puede eliminar de forma segura de la base de datos para el "consumidor". El uso de UUID es una buena práctica en sistemas distribuidos conectados y no sincronizados.

Puede leer sobre otras variantes de identificadores únicos en PostgreSQL en el artículo "Antipatrones de PostgreSQL: Identificadores únicos ".


Las claves naturales también son buenas , incluso si constan de varios campos. Uno debe temer no a las claves compuestas, sino a un campo PK suplente adicional y un índice en una tabla cargada, que puede prescindir fácilmente.



Al mismo tiempo, nadie prohíbe la combinación de enfoques. Por ejemplo, tenemos un UUID sustituto asignado a un "lote" de entradas de registro secuenciales relacionadas con una transacción original (ya que simplemente no hay una clave natural), pero un par se usa como PK (pack::uuid, recno::int2), donde recnoestá el número de secuencia "natural" del registro dentro del lote.



Transmisiones de COPY "interminables"



PostgreSQL, como OC, "no le gusta" cuando los datos se escriben en grandes lotes ( INSERTcomo 1000 líneas ). Pero COPYes mucho más tolerante con los flujos de escritura equilibrados (a través ). Pero deben poder cocinar con mucho cuidado.



  1. Dado que en la etapa anterior eliminamos todos los FK , ahora podemos escribir información sobre sí mismo packy un conjunto de otros relacionados reorden un orden arbitrario, de forma asincrónica . En este caso, es más eficaz mantener un canal constantemente activoCOPY para cada tabla de destino .
  2. , , «», ( — COPY-) . , — 100, .
  3. , , . . .



    , , «» , . , .
  4. , node-pg, PostgreSQL Node.js, API — stream.write(data) COPY- true, , false, .





    , , « », COPY .
  5. COPY- LRU «». .




Aquí debe tenerse en cuenta la principal ventaja que obtuvimos con este esquema de lectura y escritura de registros: en nuestra base de datos, los "hechos" están disponibles para su análisis casi en línea , después de unos segundos.



Refinamiento con un archivo



Todo parece ir bien. ¿Dónde está el "rastrillo" en el esquema anterior? Empecemos simple ...



Sincronización excesiva



Uno de los grandes problemas de los sistemas cargados es la sincronización excesiva de algunas operaciones que no lo requieren. A veces “porque no se dieron cuenta”, a veces “así fue más fácil”, pero tarde o temprano tienes que deshacerte de eso.



Esto es fácil de lograr. Ya hemos configurado casi 1000 servidores para monitoreo, cada uno es procesado por un hilo lógico separado, y cada hilo vuelca la información acumulada para enviarla a la base de datos con una cierta frecuencia, así:



setInterval(writeDB, interval)


El problema aquí radica precisamente en el hecho de que todos los flujos comienzan aproximadamente al mismo tiempo, por lo que los momentos de envío casi siempre coinciden "al grano".





Afortunadamente, esto es bastante fácil de solucionar, agregando un intervalo de tiempo "aleatorio" tanto para el momento de inicio como para el intervalo:



setInterval(writeDB, interval * (1 + 0.1 * (Math.random() - 0.5)))






Este método le permite "distribuir" estadísticamente la carga en la grabación, convirtiéndola en casi uniforme.



Escalado por núcleos de CPU



Un núcleo de procesador claramente no es suficiente para toda nuestra carga, pero el módulo de clúster nos ayudará aquí , lo que nos permite administrar fácilmente la creación de procesos secundarios y comunicarnos con ellos a través de IPC.



Ahora tenemos 16 procesos secundarios para 16 núcleos de procesador, y eso es bueno, ¡podemos usar toda la CPU! Pero en cada proceso escribimos en 16 placas de destino , y cuando llega la carga máxima, también abrimos canales de COPIA adicionales. Es decir, basado en constantemente más de 256 hilos de escritura activa ... ¡oh! Tal caos no tiene ningún efecto positivo en el rendimiento del disco y la base comenzó a quemarse.



Esto fue especialmente triste al intentar escribir algunos diccionarios comunes, por ejemplo, el mismo texto de solicitud que provenía de diferentes nodos, bloqueos innecesarios, espera ...





Vamos a "invertir" la situación, es decir, dejar que los procesos secundarios aún recopilen y procesen información de sus fuentes, ¡pero no escriban en la base de datos! En su lugar, déjelos enviar un mensaje a través de IPC al maestro, y él ya escribe algo donde debe estar:





Quien vio inmediatamente el problema en el esquema del párrafo anterior, bien hecho. Se encuentra exactamente en el momento en que master es también un proceso con recursos limitados. Por lo tanto, en algún momento, descubrimos que ya estaba comenzando a quemarse; simplemente dejó de hacer frente al cambio de todos los hilos a la base, ya que también está limitado por los recursos de un núcleo de CPU . Como resultado, dejamos que la mayoría de los flujos de "diccionario" menos cargados se escribieran a través del maestro, y los más cargados, pero que no requerían procesamiento adicional, se devolvían a los trabajadores:





Multicolector



Pero incluso un nodo no es suficiente para dar servicio a toda la carga disponible; es hora de pensar en el escalado lineal. La solución fue un colector múltiple , autoequilibrado según la carga, con un coordinador a la cabeza.





Cada maestro le descarga la carga actual de todos sus trabajadores y, en respuesta, recibe recomendaciones sobre qué monitorización de nodos debe transferirse a otro trabajador o incluso a otro recolector. Habrá un artículo separado sobre dichos algoritmos de equilibrio.



Agrupación y limitación de colas



La siguiente pregunta correcta es qué hacer con los flujos de escritura cuando hay un pico de carga repentino .



Después de todo, no podemos abrir más y más conexiones nuevas con la base sin fin; es ineficaz y no ayudará. Una solución trivial: limitémosla para que no tengamos más de 16 subprocesos activos simultáneamente para cada una de las tablas de destino. Pero, ¿qué hacer con los datos que todavía "no tuvimos tiempo" de escribir? ..



Si este "aumento" de la carga es solo pico, es decir, a corto plazo , entonces podemos guardar temporalmente los datos en la cola en la memoria del propio colector. Tan pronto como se libera algún canal a la base, recuperamos el registro de la cola y lo enviamos a la transmisión.



Sí, esto requiere que el recopilador tenga un búfer para almacenar colas, pero es bastante pequeño y se libera rápidamente:





Prioridades de cola



El lector atento, mirando la imagen anterior, se quedó nuevamente perplejo, “¿qué pasará cuando la memoria se agote por completo ? ...” Ya hay pocas opciones - alguien tendrá que sacrificarse.



Pero no todos los registros que queremos enviar a la base de datos son "igualmente útiles". Nos interesa anotar el mayor número posible, cuantitativamente. La primitiva "priorización exponencial" por el tamaño de la cadena escrita nos ayudará con esto:



let priority = Math.trunc(Math.log2(line.length));
queue[priority].push(line);


En consecuencia, cuando escribimos en un canal, siempre comenzamos a recoger de las colas "inferiores" ; es solo que cada línea individual es más corta allí, y podemos enviarles cuantitativamente más:



let qkeys = Object.keys(queue);
qkeys.sort((x, y) => x.valueOf() - y.valueOf()); // - - !


Derrotar bloqueos



Ahora retrocedamos dos pasos. Para cuando decidimos dejar un máximo de 16 hilos a la dirección de una tabla. Si la tabla de destino es "streaming", es decir, los registros no se correlacionan entre sí, todo está bien. Máximo: tendremos bloqueos "físicos" a nivel de disco.



Pero si se trata de una tabla de agregados o incluso un "diccionario", cuando intentemos escribir filas con el mismo PK de diferentes flujos, recibiremos una espera en el bloqueo, o incluso un punto muerto. Es triste ...



Pero después de todo, qué escribir, ¡nos definimos a nosotros mismos! El punto clave es no intentar escribir un PK desde diferentes lugares .



Es decir, al pasar la cola, inmediatamente miramos para ver si algún hilo ya está escribiendo en la misma tabla (recordamos que todos están en el espacio de direcciones común de un proceso) con tal PK. Si no, lo tomamos para nosotros y lo anotamos en el diccionario en memoria "para nosotros", si ya es de otra persona, lo ponemos en la cola.



Al final de la transacción, simplemente "limpiamos" el archivo adjunto "a nosotros mismos" del diccionario.



Una pequeña prueba



Primero, con LRU, las "primeras" conexiones y los procesos de PostgreSQL que las sirven casi siempre están ejecutándose todo el tiempo. Esto significa que el sistema operativo los cambia entre núcleos de CPU con mucha menos frecuencia , lo que minimiza el tiempo de inactividad.





En segundo lugar, si trabaja con los mismos procesos en el lado del servidor casi todo el tiempo, las posibilidades de que dos procesos estén activos al mismo tiempo se reducen drásticamente; en consecuencia, la carga máxima en la CPU en su conjunto disminuye (área gris en el segundo gráfico de la izquierda ) y LA cae porque hay menos procesos esperando su turno.





Eso es todo por hoy.



Y permítame recordarle que con la ayuda de explica.tensor.ru puede ver varias opciones para visualizar el plan de ejecución de consultas, lo que le ayudará a ver visualmente las áreas problemáticas.



All Articles