Postgres: bloat, pg_repack e restricións diferidas

Postgres: bloat, pg_repack e restricións diferidas

O efecto do inchazo nas táboas e índices é amplamente coñecido e está presente non só en Postgres. Hai xeitos de tratar con isto fóra da caixa, como VACUUM FULL ou CLUSTER, pero bloquean as táboas durante o funcionamento e, polo tanto, non sempre se poden usar.

O artigo conterá unha pequena teoría sobre como se produce o inchazo, como pode combatilo, sobre as restricións diferidas e os problemas que traen ao uso da extensión pg_repack.

Este artigo está escrito en base a o meu discurso na PgConf.Russia 2020.

Por que se produce inchazo?

Postgres está baseado nun modelo multi-versión (MVCC). A súa esencia é que cada fila da táboa pode ter varias versións, mentres que as transaccións non ven máis que unha destas versións, pero non necesariamente a mesma. Isto permite que varias transaccións funcionen simultáneamente e practicamente non teñan ningún impacto entre si.

Obviamente, todas estas versións deben ser almacenadas. Postgres traballa coa memoria páxina por páxina e unha páxina é a cantidade mínima de datos que se poden ler desde o disco ou escribir. Vexamos un pequeno exemplo para entender como ocorre isto.

Digamos que temos unha táboa á que engadimos varios rexistros. Apareceron novos datos na primeira páxina do ficheiro onde se almacena a táboa. Estas son versións en directo de filas que están dispoñibles para outras transaccións despois dunha confirmación (para simplificar, asumiremos que o nivel de illamento é Read Committed).

Postgres: bloat, pg_repack e restricións diferidas

Despois actualizamos unha das entradas, marcando así a versión antiga como xa non relevante.

Postgres: bloat, pg_repack e restricións diferidas

Paso a paso, actualizando e eliminando versións de filas, acabamos cunha páxina na que aproximadamente a metade dos datos son "lixo". Estes datos non son visibles para ningunha transacción.

Postgres: bloat, pg_repack e restricións diferidas

Postgres ten un mecanismo VACUUM, que limpa versións obsoletas e deixa espazo para novos datos. Pero se non está configurado o suficientemente agresivo ou está ocupado traballando noutras táboas, entón permanecen "datos de lixo" e temos que usar páxinas adicionais para novos datos.

Polo tanto, no noso exemplo, nalgún momento a táboa constará de catro páxinas, pero só a metade conterá datos en directo. Como resultado, ao acceder á táboa, leremos moitos máis datos dos necesarios.

Postgres: bloat, pg_repack e restricións diferidas

Aínda que VACUUM elimine agora todas as versións de filas irrelevantes, a situación non mellorará drasticamente. Teremos espazo libre en páxinas ou mesmo páxinas enteiras para novas filas, pero seguiremos lendo máis datos dos necesarios.
Por certo, se unha páxina completamente en branco (a segunda do noso exemplo) estivese ao final do ficheiro, entón VACUUM podería recortala. Pero agora está no medio, así que non se pode facer nada con ela.

Postgres: bloat, pg_repack e restricións diferidas

Cando o número de páxinas baleiras ou moi escasas se fai grande, o que se chama inchar, comeza a afectar o rendemento.

Todo o descrito anteriormente é a mecánica da aparición de inchazo nas táboas. Nos índices isto ocorre do mesmo xeito.

Teño inchazo?

Hai varias formas de determinar se tes inchazo. A idea do primeiro é usar estatísticas internas de Postgres, que contén información aproximada sobre o número de filas nas táboas, o número de filas "en directo", etc. Podes atopar moitas variacións de scripts preparados en Internet. Tomamos como base guión de PostgreSQL Experts, que poden avaliar táboas de bloat xunto cos índices de bloat btree. Na nosa experiencia, o seu erro é do 10-20%.

Outra forma é usar a extensión pgstattuple, o que che permite mirar dentro das páxinas e obter un valor estimado e un valor de inflación exacto. Pero no segundo caso, terás que escanear toda a táboa.

Consideramos un valor de inchazo pequeno, ata o 20%, aceptable. Pódese considerar como un análogo do factor de recheo para táboas и índices. A partir do 50 %, poden comezar os problemas de rendemento.

Formas de combater o inchazo

Postgres ten varias formas de tratar o inchazo fóra da caixa, pero non sempre son axeitados para todos.

Configure AUTOVACUUM para que non se produza inchazo. Ou, máis precisamente, para mantelo nun nivel aceptable para vostede. Este parece un consello do "capitán", pero en realidade non sempre é fácil de conseguir. Por exemplo, tes un desenvolvemento activo con cambios regulares no esquema de datos ou está a ter lugar algún tipo de migración de datos. Como resultado, o teu perfil de carga pode cambiar con frecuencia e normalmente variará dunha táboa a outra. Isto significa que cómpre traballar constantemente un pouco por diante e axustar AUTOVACUUM ao perfil cambiante de cada táboa. Pero, obviamente, isto non é fácil de facer.

Outra razón común pola que AUTOVACUUM non pode estar ao día coas táboas é porque hai transaccións de longa duración que lle impiden limpar os datos que están dispoñibles para esas transaccións. A recomendación aquí tamén é obvia: desfacerse das transaccións "colgantes" e minimizar o tempo das transaccións activas. Pero se a carga da túa aplicación é un híbrido de OLAP e OLTP, podes ter simultaneamente moitas actualizacións frecuentes e consultas curtas, así como operacións a longo prazo, por exemplo, a creación dun informe. En tal situación, paga a pena pensar en repartir a carga entre diferentes bases, o que permitirá un maior axuste de cada unha delas.

Outro exemplo - aínda que o perfil é homoxéneo, pero a base de datos está baixo unha carga moi alta, entón incluso o AUTOVACUUM máis agresivo pode non facer fronte e producirase inchazo. A escala (vertical ou horizontal) é a única solución.

Que facer nunha situación na que configuraches AUTOVACUUM, pero o inchazo segue crecendo.

Equipo VACÍO CHEO reconstruí o contido das táboas e índices e deixa neles só datos relevantes. Para eliminar o inchazo, funciona perfectamente, pero durante a súa execución captúrase un bloqueo exclusivo na táboa (AccessExclusiveLock), que non permitirá executar consultas nesta táboa, nin sequera selecciona. Se pode permitir deter o seu servizo ou parte del durante algún tempo (de decenas de minutos a varias horas, dependendo do tamaño da base de datos e do seu hardware), esta opción é a mellor. Desafortunadamente, non temos tempo para executar VACUUM FULL durante o mantemento programado, polo que este método non é axeitado para nós.

Equipo CLUSTER Reconstrúe o contido das táboas do mesmo xeito que VACUUM FULL, pero permite especificar un índice segundo o cal os datos serán ordenados fisicamente no disco (pero no futuro non se garante a orde das novas filas). En determinadas situacións, esta é unha boa optimización para varias consultas, coa lectura de varios rexistros por índice. A desvantaxe do comando é a mesma que a de VACUUM FULL: bloquea a táboa durante a operación.

Equipo REINDICE similar aos dous anteriores, pero reconstruí un índice específico ou todos os índices da táboa. Os bloqueos son lixeiramente máis débiles: ShareLock na mesa (impide modificacións, pero permite a selección) e AccessExclusiveLock no índice que se está reconstruíndo (bloquea consultas usando este índice). Non obstante, na versión 12 de Postgres apareceu un parámetro ACTUALMENTE, que lle permite reconstruír o índice sen bloquear a adición, modificación ou eliminación simultánea de rexistros.

Nas versións anteriores de Postgres, podes conseguir un resultado similar ao de REINDEX CONCURRENTLY CREAR ÍNDICE AO MISMO. Permítelle crear un índice sen bloqueo estrito (ShareUpdateExclusiveLock, que non interfire coas consultas paralelas), despois substituír o índice antigo por un novo e eliminar o índice antigo. Isto permítelle eliminar o inchazo do índice sen interferir coa súa aplicación. É importante ter en conta que ao reconstruír índices haberá unha carga adicional no subsistema de discos.

Así, se para os índices hai formas de eliminar o inchazo "sobre a marcha", entón non hai ningunha para as táboas. Aquí é onde entran en xogo varias extensións externas: pg_repack (anteriormente pg_reorg), pgcompact, pgcompactable e outros. Neste artigo, non os compararei e só falarei de pg_repack, que, tras algunha modificación, utilizamos nós mesmos.

Como funciona pg_repack

Postgres: bloat, pg_repack e restricións diferidas
Digamos que temos unha táboa completamente normal, con índices, restricións e, por desgraza, con inchazo. O primeiro paso de pg_repack é crear unha táboa de rexistro para almacenar datos sobre todos os cambios mentres se executa. O disparador replicará estes cambios para cada inserción, actualización e eliminación. Despois créase unha táboa, similar á orixinal na estrutura, pero sen índices nin restricións, para non ralentizar o proceso de inserción de datos.

A continuación, pg_repack transfire os datos da táboa antiga á táboa nova, filtrando automaticamente todas as filas irrelevantes e, a continuación, crea índices para a táboa nova. Durante a execución de todas estas operacións, os cambios acumúlanse na táboa de rexistro.

O seguinte paso é transferir os cambios á nova táboa. A migración realízase en varias iteracións e, cando quedan menos de 20 entradas na táboa de rexistro, pg_repack adquire un bloqueo forte, migra os datos máis recentes e substitúe a táboa antiga pola nova nas táboas do sistema Postgres. Este é o único e moi curto tempo no que non poderás traballar coa mesa. Despois diso, elimínanse a táboa antiga e a táboa con rexistros e liberase espazo no sistema de ficheiros. O proceso está completo.

Todo parece xenial en teoría, pero que pasa na práctica? Probamos pg_repack sen carga e baixo carga, e comprobamos o seu funcionamento en caso de parada prematura (noutras palabras, usando Ctrl+C). Todas as probas resultaron positivas.

Fomos á tenda de alimentos e despois todo non saíu como esperabamos.

Primeira filloa á venda

No primeiro clúster recibimos un erro sobre unha violación dunha restrición única:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

Esta limitación tiña un nome xerado automaticamente index_16508; foi creada por pg_repack. En función dos atributos incluídos na súa composición, determinamos a "nosa" restrición que lle corresponde. O problema resultou ser que esta non é unha limitación completamente ordinaria, senón unha limitación diferida (restrición diferida), é dicir. a súa verificación realízase máis tarde que o comando sql, o que leva a consecuencias inesperadas.

Restricións diferidas: por que son necesarias e como funcionan

Unha pequena teoría sobre as restricións diferidas.
Consideremos un exemplo sinxelo: temos un libro de referencia de táboas de coches con dous atributos: o nome e a orde do coche no directorio.
Postgres: bloat, pg_repack e restricións diferidas

create table cars
(
  name text constraint pk_cars primary key,
  ord integer not null constraint uk_cars unique
);



Digamos que necesitabamos cambiar o primeiro e o segundo coche. A solución sinxela é actualizar o primeiro valor ao segundo e o segundo ao primeiro:

begin;
  update cars set ord = 2 where name = 'audi';
  update cars set ord = 1 where name = 'bmw';
commit;

Pero cando executamos este código, esperamos unha violación da restrición porque a orde dos valores na táboa é única:

[23305] ERROR: duplicate key value violates unique constraint “uk_cars”
Detail: Key (ord)=(2) already exists.

Como podo facelo doutro xeito? Opción 1: engade unha substitución de valor adicional a un pedido que se garante que non existe na táboa, por exemplo "-XNUMX". Na programación, isto denomínase "intercambiar os valores de dúas variables a través dunha terceira". O único inconveniente deste método é a actualización adicional.

Opción dúas: redeseñar a táboa para usar un tipo de datos de coma flotante para o valor da orde en lugar de enteiros. Entón, ao actualizar o valor de 1, por exemplo, a 2.5, a primeira entrada "se situará" automaticamente entre a segunda e a terceira. Esta solución funciona, pero hai dúas limitacións. En primeiro lugar, non che funcionará se o valor se usa nalgún lugar da interface. En segundo lugar, dependendo da precisión do tipo de datos, terá un número limitado de posibles insercións antes de recalcular os valores de todos os rexistros.

Opción tres: facer que a restrición sexa diferida para que se comprobe só no momento do commit:

create table cars
(
  name text constraint pk_cars primary key,
  ord integer not null constraint uk_cars unique deferrable initially deferred
);

Dado que a lóxica da nosa solicitude inicial garante que todos os valores son únicos no momento da confirmación, terá éxito.

O exemplo comentado anteriormente é, por suposto, moi sintético, pero revela a idea. Na nosa aplicación, usamos restricións diferidas para implementar a lóxica que se encarga de resolver conflitos cando os usuarios traballan simultaneamente con obxectos de widget compartidos no taboleiro. Usar tales restricións permítenos simplificar un pouco o código da aplicación.

En xeral, dependendo do tipo de restrición, Postgres ten tres niveis de granularidade para verificalos: niveis de fila, transacción e expresión.
Postgres: bloat, pg_repack e restricións diferidas
Fonte: mendigos

CHECK e NOT NULL sempre se marcan a nivel de fila; para outras restricións, como se pode ver na táboa, hai diferentes opcións. Podes ler máis aquí.

Para resumir brevemente, as restricións diferidas en varias situacións proporcionan un código máis lexible e menos comandos. Non obstante, hai que pagar por iso complicando o proceso de depuración, xa que o momento en que se produce o erro e o momento en que se decata del están separados no tempo. Outro posible problema é que o planificador non sempre pode construír un plan óptimo se a solicitude implica unha restrición diferida.

Mellora de pg_repack

Cubrimos cales son as restricións diferidas, pero como se relacionan co noso problema? Lembremos o erro que recibimos anteriormente:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

Ocorre cando se copian os datos dunha táboa de rexistro a unha táboa nova. Isto parece raro porque... os datos da táboa de rexistro commítense xunto cos datos da táboa de orixe. Se satisfacen as restricións da táboa orixinal, como poden violar as mesmas restricións na nova?

Como se ve, a raíz do problema está no paso anterior de pg_repack, que só crea índices, pero non restricións: a táboa antiga tiña unha restrición única e a nova creou un índice único.

Postgres: bloat, pg_repack e restricións diferidas

É importante notar aquí que se a restrición é normal e non diferida, entón o índice único creado é equivalente a esta restrición, porque As restricións únicas en Postgres impléntanse creando un índice único. Pero no caso dunha restrición diferida, o comportamento non é o mesmo, porque o índice non se pode aprazar e sempre se verifica no momento en que se executa o comando sql.

Así, a esencia do problema reside no "atraso" da comprobación: na táboa orixinal prodúcese no momento da confirmación e na nova táboa no momento en que se executa o comando sql. Isto significa que debemos asegurarnos de que as comprobacións se realizan igual en ambos os casos: sempre con retraso ou sempre inmediatamente.

Entón, que ideas tiñamos?

Crea un índice similar ao diferido

A primeira idea é realizar ambas comprobacións en modo inmediato. Isto pode xerar varias restricións de falsos positivos, pero se son poucas delas, isto non debería afectar o traballo dos usuarios, xa que estes conflitos son unha situación normal para eles. Prodúcense, por exemplo, cando dous usuarios comezan a editar o mesmo widget ao mesmo tempo e o cliente do segundo usuario non ten tempo para recibir información de que o widget xa está bloqueado para a súa edición polo primeiro usuario. En tal situación, o servidor rexeita o segundo usuario e o seu cliente retrocede os cambios e bloquea o widget. Un pouco máis tarde, cando o primeiro usuario complete a edición, o segundo recibirá información de que o widget xa non está bloqueado e poderá repetir a súa acción.

Postgres: bloat, pg_repack e restricións diferidas

Para garantir que as comprobacións estean sempre en modo non diferido, creamos un novo índice similar á restrición diferida orixinal:

CREATE UNIQUE INDEX CONCURRENTLY uk_tablename__immediate ON tablename (id, index);
-- run pg_repack
DROP INDEX CONCURRENTLY uk_tablename__immediate;

No entorno de proba, recibimos só algúns erros esperados. Éxito! Executamos pg_repack de novo en produción e obtivemos 5 erros no primeiro clúster nunha hora de traballo. Este é un resultado aceptable. Non obstante, xa no segundo clúster o número de erros aumentou significativamente e tivemos que deter pg_repack.

Por que pasou? A probabilidade de que se produza un erro depende de cantos usuarios estean traballando cos mesmos widgets ao mesmo tempo. Ao parecer, nese momento había moitos menos cambios competitivos cos datos almacenados no primeiro clúster que nos outros, é dicir. só tivemos "sorte".

A idea non funcionou. Nese momento, vimos outras dúas solucións: reescribir o código da nosa aplicación para prescindir de restricións diferidas ou "ensinar" a pg_repack a traballar con elas. Escollemos o segundo.

Substitúe os índices da táboa nova por restricións diferidas da táboa orixinal

O propósito da revisión era obvio: se a táboa orixinal ten unha restrición diferida, entón para a nova debes crear esa restrición e non un índice.

Para probar os nosos cambios, escribimos unha proba sinxela:

  • táboa cunha restrición diferida e un rexistro;
  • inserir datos nun bucle que entra en conflito cun rexistro existente;
  • facer unha actualización: os datos xa non entran en conflito;
  • cometer os cambios.

create table test_table
(
  id serial,
  val int,
  constraint uk_test_table__val unique (val) deferrable initially deferred 
);

INSERT INTO test_table (val) VALUES (0);
FOR i IN 1..10000 LOOP
  BEGIN
    INSERT INTO test_table VALUES (0) RETURNING id INTO v_id;
    UPDATE test_table set val = i where id = v_id;
    COMMIT;
  END;
END LOOP;

A versión orixinal de pg_repack sempre fallaba na primeira inserción, a versión modificada funcionou sen erros. Genial.

Pasamos á produción e volvemos aparecer un erro na mesma fase de copiar os datos da táboa de rexistro a unha nova:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

Situación clásica: todo funciona en ambientes de proba, pero non en produción?!

APPLY_COUNT e a unión de dous lotes

Comezamos a analizar o código literalmente liña por liña e descubrimos un punto importante: os datos transfírense da táboa de rexistro a unha nova por lotes, a constante APPLY_COUNT indicaba o tamaño do lote:

for (;;)
{
num = apply_log(connection, table, APPLY_COUNT);

if (num > MIN_TUPLES_BEFORE_SWITCH)
     continue;  /* there might be still some tuples, repeat. */
...
}

O problema é que os datos da transacción orixinal, na que varias operacións poderían violar a restrición, cando se transfiren, poden acabar na unión de dous lotes: a metade dos comandos realizaranse no primeiro lote e a outra metade. no segundo. E aquí, dependendo da túa sorte: se os equipos non violan nada na primeira tanda, todo está ben, pero se o fan, prodúcese un erro.

APPLY_COUNT é igual a 1000 rexistros, o que explica por que as nosas probas foron exitosas: non abarcaron o caso da "unión por lotes". Usamos dous comandos: inserir e actualizar, polo que sempre se colocaron exactamente 500 transaccións de dous comandos nun lote e non experimentamos ningún problema. Despois de engadir a segunda actualización, a nosa edición deixou de funcionar:

FOR i IN 1..10000 LOOP
  BEGIN
    INSERT INTO test_table VALUES (1) RETURNING id INTO v_id;
    UPDATE test_table set val = i where id = v_id;
    UPDATE test_table set val = i where id = v_id; -- one more update
    COMMIT;
  END;
END LOOP;

Entón, a seguinte tarefa é asegurarse de que os datos da táboa orixinal, que se cambiou nunha transacción, rematen na nova táboa tamén dentro dunha transacción.

Denegación de lotes

E de novo tivemos dúas solucións. Primeiro: abandonemos completamente a partición en lotes e transfiramos datos nunha soa transacción. A vantaxe desta solución era a súa sinxeleza: os cambios de código necesarios eran mínimos (por certo, en versións antigas pg_reorg funcionaba exactamente así). Pero hai un problema: estamos a crear unha transacción de longa duración e isto, como se dixo anteriormente, é unha ameaza para a aparición dun novo inchazo.

A segunda solución é máis complexa, pero probablemente máis correcta: crear unha columna na táboa de rexistro co identificador da transacción que engadiu datos á táboa. Despois, cando copiamos os datos, podemos agrupalos por este atributo e asegurarnos de que os cambios relacionados se transfiran xuntos. O lote formarase a partir de varias transaccións (ou unha grande) e o seu tamaño variará dependendo da cantidade de datos que se modificaron nestas transaccións. É importante ter en conta que, dado que os datos de diferentes transaccións entran na táboa de rexistro nunha orde aleatoria, xa non será posible lelos secuencialmente, como antes. seqscan para cada solicitude con filtrado por tx_id é demasiado caro, é necesario un índice, pero tamén ralentizará o método debido á sobrecarga de actualizalo. En xeral, como sempre, hai que sacrificar algo.

Entón, decidimos comezar pola primeira opción, xa que é máis sinxela. En primeiro lugar, era necesario entender se unha transacción longa sería un problema real. Dado que a transferencia principal de datos da táboa antiga á nova tamén se produce nunha transacción longa, a pregunta transformouse en "canto aumentaremos esta transacción?" A duración da primeira transacción depende principalmente do tamaño da táboa. A duración dun novo depende de cantos cambios se acumulen na táboa durante a transferencia de datos, é dicir. sobre a intensidade da carga. A execución de pg_repack produciuse durante un tempo de carga de servizo mínima e o volume de cambios foi desproporcionadamente pequeno en comparación co tamaño orixinal da táboa. Decidimos que podíamos descoidar o tempo dunha nova transacción (para comparación, en media é de 1 hora e 2-3 minutos).

Os experimentos foron positivos. Lanzamento tamén en produción. Para claridade, aquí tes unha imaxe co tamaño dunha das bases de datos despois de executar:

Postgres: bloat, pg_repack e restricións diferidas

Dado que quedamos completamente satisfeitos con esta solución, non intentamos implementar a segunda, pero estamos considerando a posibilidade de discutilo cos desenvolvedores da extensión. Desafortunadamente, a nosa actual revisión aínda non está lista para a súa publicación, xa que só resolvemos o problema con restricións aprazadas únicas, e para un parche completo é necesario proporcionar soporte para outros tipos. Esperamos poder facelo no futuro.

Quizais teñas unha pregunta, por que incluso nos involucramos nesta historia coa modificación de pg_repack e non usamos, por exemplo, os seus análogos? Nalgún momento tamén pensamos nisto, pero a experiencia positiva de usalo antes, en táboas sen restricións diferidas, motivounos a tentar comprender a esencia do problema e solucionalo. Ademais, utilizar outras solucións tamén require tempo para realizar probas, polo que decidimos que primeiro tentaríamos solucionar o problema nela e, se nos decatamos de que non podíamos facelo nun tempo razoable, comezariamos a buscar análogos. .

Descubrimentos

O que podemos recomendar segundo a nosa propia experiencia:

  1. Monitoriza o teu inchazo. En base aos datos de seguimento, podes comprender o ben que está configurado o baleiro automático.
  2. Axuste AUTOVACUUM para manter o inchazo nun nivel aceptable.
  3. Se o inchazo aínda está crecendo e non podes superalo usando ferramentas listas para usar, non teñas medo de usar extensións externas. O principal é probar todo ben.
  4. Non teñas medo de modificar solucións externas para adaptalas ás túas necesidades; ás veces, isto pode ser máis efectivo e incluso máis sinxelo que cambiar o teu propio código.

Fonte: www.habr.com

Engadir un comentario