Escribimos en PostgreSQL en sublight: 1 host, 1 day, 1TB
Hai pouco díxenche como, usando receitas estándar aumentar o rendemento das consultas de lectura SQL de base de datos PostgreSQL. Hoxe falaremos de como a gravación pódese facer de forma máis eficiente na base de datos sen usar ningún tipo de "torsión" na configuración, simplemente organizando correctamente os fluxos de datos.
Inicialmente, como calquera MVP, o noso proxecto comezou cunha carga bastante lixeira: a monitorización levouse a cabo só para os dez servidores máis críticos, todas as táboas eran relativamente compactas... Pero co paso do tempo, o número de anfitrións monitorizados foi cada vez máis. , e unha vez máis tentamos facer algo cun de táboas de 1.5 TB de tamaño, decatámonos de que aínda que era posible seguir vivindo así, era moi incómodo.
Os tempos eran case como tempos épicos, as diferentes versións de PostgreSQL 9.x eran relevantes, polo que todas as particións tiñan que facerse "manual" - a través de herdanza de táboas e disparadores enrutamento con dinámica EXECUTE.
A solución resultante resultou ser o suficientemente universal como para poder traducirse a todas as táboas:
Declarouse unha táboa principal de "cabeceira" baleira, que describiu todas índices e disparadores necesarios.
O rexistro desde o punto de vista do cliente realizouse na táboa "raíz", e utilizando internamente disparador de enrutamentoBEFORE INSERT o rexistro foi inserido "fisicamente" na sección requirida. Se aínda non houbese tal cousa, captamos unha excepción e...
… mediante o uso CREATE TABLE ... (LIKE ... INCLUDING ...) creouse a partir do modelo da táboa principal sección cunha restrición na data desexadade xeito que cando se recuperan datos, a lectura realízase só nel.
PG10: primeiro intento
Pero a partición a través da herdanza non foi historicamente moi adecuada para xestionar un fluxo de escritura activo ou un gran número de particións fillas. Por exemplo, pode lembrar que o algoritmo para seleccionar a sección requirida tiña complexidade cuadrática, que funciona con máis de 100 seccións, vostede mesmo comprende como...
No PG10 esta situación foi moi optimizada mediante a implementación do soporte partición nativa. Polo tanto, intentamos aplicalo inmediatamente despois de migrar o almacenamento, pero...
Como se viu despois de explorar o manual, a táboa particionada de forma nativa nesta versión é:
non admite descricións de índice
non admite disparadores nel
non pode ser o "descendente" de ninguén
non admite INSERT ... ON CONFLICT
non pode xerar unha sección automaticamente
Despois de recibir un doloroso golpe na fronte cun anciño, decatámonos de que sería imposible prescindir de modificar a aplicación e aprazamos a investigación durante seis meses.
PG10: segunda oportunidade
Entón, comezamos a resolver os problemas que xurdiron un por un:
Porque desencadea e ON CONFLICT Comprobamos que aínda os necesitabamos aquí e alí, así que fixemos unha etapa intermedia para elaboralos táboa de proxy.
Desfíxose do "routing" en disparadores - é dicir, de EXECUTE.
Sacárono por separado táboa modelo con todos os índicespara que nin sequera estean presentes na táboa de proxy.
Finalmente, despois de todo isto, particionamos a táboa principal de forma nativa. A creación dunha nova sección aínda queda á conciencia da aplicación.
Dicionarios de “serrado”.
Como en calquera sistema analítico, tamén tivemos "feitos" e "recortes" (dicionarios). No noso caso, nesta calidade actuaron, por exemplo, corpo da plantilla consultas lentas similares ou o propio texto da consulta.
Os "feitos" foron seccionados por día xa desde hai moito tempo, polo que eliminamos con calma as seccións obsoletas e non nos molestaron (rexistros!). Pero houbo un problema cos dicionarios...
Non quere dicir que fosen moitos, senón aproximadamente 100 TB de "feitos" resultaron nun dicionario de 2.5 TB. Non podes eliminar nada convenientemente desta táboa, non podes comprimilo no tempo adecuado e escribir nela foi gradualmente máis lenta.
Como un dicionario... nel, cada entrada debe ser presentada exactamente unha vez... e isto é correcto, pero!.. Ninguén nos impide ter un dicionario separado para cada día! Si, isto trae unha certa redundancia, pero permite:
escribir/le máis rápido debido ao menor tamaño da sección
consumir menos memoria traballando con índices máis compactos
almacenar menos datos debido á capacidade de eliminar rapidamente os obsoletos
Como resultado de todo o complexo de medidas A carga da CPU reduciuse nun ~30 %, a carga do disco nun ~50 %:
Ao mesmo tempo, seguimos escribindo exactamente o mesmo na base de datos, só con menos carga.
#2. Evolución e refactorización de bases de datos
Así que decidimos co que temos cada día ten a súa propia sección con datos. En realidade, CHECK (dt = '2018-10-12'::date) — e hai unha clave de partición e a condición para que un rexistro caia nunha sección específica.
Dado que todos os informes do noso servizo están construídos no contexto dunha data específica, os índices para eles desde "horas non particionadas" foron de todos os tipos (Servidor, Data, Modelo de plan), (Servidor, Data, nodo Plan), (Data, clase de erro, servidor), ...
Pero agora viven en todas as seccións as súas copias cada un destes índices... E dentro de cada sección a data é unha constante... Resulta que agora estamos en cada un destes índices simplemente introduza unha constante como un dos campos, que aumenta tanto o seu volume como o tempo de busca do mesmo, pero non trae ningún resultado. Deixáronlles o rastrillo para eles, oops...
A dirección da optimización é obvia, sinxela eliminar o campo de data de todos os índices en táboas particionadas. Tendo en conta os nosos volumes, a ganancia é sobre 1 TB/semana!
Agora imos notar que este terabyte aínda tiña que ser gravado dalgún xeito. É dicir, nós tamén agora o disco debería cargar menos! Esta imaxe mostra claramente o efecto obtido da limpeza, á que dedicamos unha semana:
#3. "Difundir" a carga máxima
Un dos grandes problemas dos sistemas cargados é sincronización redundante algunhas operacións que non o requiren. Ás veces “porque non se decataron”, outras “era máis doado así”, pero tarde ou cedo hai que desfacerse del.
Acheguemos a imaxe anterior e vexamos que temos un disco "bombas" baixo a carga con dobre amplitude entre mostras adxacentes, o que claramente "estatisticamente" non debería ocorrer con tal número de operacións:
Isto é bastante fácil de conseguir. Xa comezamos o seguimento case 1000 servidores, cada un é procesado por un fío lóxico separado, e cada fío restablece a información acumulada que se enviará á base de datos cunha frecuencia determinada, algo así:
setInterval(sendToDB, interval)
O problema aquí reside precisamente no feito de que todos os fíos comezan aproximadamente ao mesmo tempo, polo que os seus tempos de envío case sempre coinciden "ata o punto". Vaia #2...
Afortunadamente, isto é bastante fácil de solucionar, engadindo un avance "aleatorio". por tempo:
O terceiro problema tradicional de alta carga é sen caché onde está podería ser.
Por exemplo, fixemos posible analizar en termos de nodos de plano (todos estes Seq Scan on users), pero inmediatamente pense que son, na súa maioría, iguais - esquecéronse.
Non, por suposto, nada se escribe na base de datos de novo, isto corta o disparador con INSERT ... ON CONFLICT DO NOTHING. Pero estes datos aínda chegan á base de datos e son innecesarios ler para comprobar se hai conflitos ter que facer. Vaia #3...
A diferenza no número de rexistros enviados á base de datos antes/despois de activar o caché é obvia:
E esta é a caída que acompaña na carga de almacenamento:
En total
"Terabyte-per-day" só parece asustado. Se fai todo ben, isto é só 2^40 bytes/86400 segundos = ~12.5 MB/sque ata os parafusos IDE do escritorio sostiñan. 🙂
Pero en serio, mesmo cunha "sesgada" dez veces da carga durante o día, podes cumprir facilmente as capacidades dos SSD modernos.