MVCC-3. Versões de string

Então, consideramos questões relacionadas a isolamento, e fez um retiro sobre organizando dados em um nível baixo. E finalmente chegamos à parte mais interessante – as versões em cordas.

Título

Como já dissemos, cada linha pode existir simultaneamente em diversas versões no banco de dados. Uma versão deve ser de alguma forma diferenciada da outra, para isso cada versão possui duas marcas que determinam o “tempo” de atuação desta versão (xmin e xmax). Entre aspas - porque não é o tempo em si que é usado, mas um contador crescente especial. E este contador é o número da transação.

(Como sempre, a realidade é mais complicada: o número da transação não pode aumentar o tempo todo devido à capacidade limitada de bits do contador. Mas examinaremos esses detalhes em detalhes quando chegarmos ao congelamento.)

Quando uma linha é criada, xmin é definido como o número da transação que emitiu o comando INSERT e xmax é deixado em branco.

Quando uma linha é excluída, o valor xmax da versão atual é marcado com o número da transação que executou o DELETE.

Quando uma linha é modificada por um comando UPDATE, duas operações são realmente executadas: DELETE e INSERT. A versão atual da linha define xmax igual ao número da transação que executou o UPDATE. Uma nova versão da mesma string é então criada; seu valor xmin coincide com o valor xmax da versão anterior.

Os campos xmin e xmax estão incluídos no cabeçalho da versão da linha. Além desses campos, o cabeçalho contém outros, por exemplo:

  • infomask é uma série de bits que definem as propriedades desta versão. Existem muitos deles; Consideraremos gradualmente os principais.
  • ctid é um link para a próxima versão mais recente da mesma linha. Para a versão mais recente e atual de uma linha, o ctid refere-se a esta própria versão. O número tem a forma (x,y), onde x é o número da página, y é o número do índice na matriz.
  • bitmap nulo - Marca as colunas de uma determinada versão que contêm um valor nulo (NULL). NULL não é um dos valores normais de tipo de dados, portanto o atributo deve ser armazenado separadamente.

Como resultado, o cabeçalho é bastante grande - pelo menos 23 bytes para cada versão da linha e geralmente mais devido ao bitmap NULL. Se a tabela for "estreita" (ou seja, contém poucas colunas), a sobrecarga poderá ocupar mais do que as informações úteis.

inserir

Vamos dar uma olhada mais de perto em como as operações de string de baixo nível são executadas, começando com a inserção.

Para experimentos, vamos criar uma nova tabela com duas colunas e um índice em uma delas:

=> CREATE TABLE t(
  id serial,
  s text
);
=> CREATE INDEX ON t(s);

Vamos inserir uma linha após iniciar uma transação.

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

Aqui está o nosso número de transação atual:

=> SELECT txid_current();
 txid_current 
--------------
         3664
(1 row)

Vejamos o conteúdo da página. A função heap_page_items da extensão pageinspect permite obter informações sobre ponteiros e versões de linha:

=> SELECT * FROM heap_page_items(get_raw_page('t',0)) gx
-[ RECORD 1 ]-------------------
lp          | 1
lp_off      | 8160
lp_flags    | 1
lp_len      | 32
t_xmin      | 3664
t_xmax      | 0
t_field3    | 0
t_ctid      | (0,1)
t_infomask2 | 2
t_infomask  | 2050
t_hoff      | 24
t_bits      | 
t_oid       | 
t_data      | x0100000009464f4f

Observe que a palavra heap no PostgreSQL se refere a tabelas. Este é outro uso estranho do termo - um monte é conhecido estrutura de dados, que não tem nada em comum com a tabela. Aqui a palavra é usada no sentido de “tudo está misturado”, em oposição a índices ordenados.

A função mostra os dados “como estão”, em um formato de difícil compreensão. Para descobrir, vamos deixar apenas parte da informação e decifrá-la:

=> SELECT '(0,'||lp||')' AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin as xmin,
       t_xmax as xmax,
       (t_infomask & 256) > 0  AS xmin_commited,
       (t_infomask & 512) > 0  AS xmin_aborted,
       (t_infomask & 1024) > 0 AS xmax_commited,
       (t_infomask & 2048) > 0 AS xmax_aborted,
       t_ctid
FROM heap_page_items(get_raw_page('t',0)) gx
-[ RECORD 1 ]-+-------
ctid          | (0,1)
state         | normal
xmin          | 3664
xmax          | 0
xmin_commited | f
xmin_aborted  | f
xmax_commited | f
xmax_aborted  | t
t_ctid        | (0,1)

Aqui está o que fizemos:

  • Adicionado um zero ao número do índice para torná-lo igual a t_ctid: (número da página, número do índice).
  • Decifrou o estado do ponteiro lp_flags. Aqui é "normal" - isso significa que o ponteiro na verdade se refere à versão da string. Veremos outros significados mais tarde.
  • De todos os bits de informação, apenas dois pares foram identificados até agora. Os bits xmin_commited e xmin_aborted indicam se o número de transação xmin foi confirmado (abortado). Dois bits semelhantes referem-se ao número de transação xmax.

O que vemos? Ao inserir uma linha, um número de índice 1 aparecerá na página da tabela, apontando para a primeira e única versão da linha.

Na versão string, o campo xmin é preenchido com o número da transação atual. A transação ainda está ativa, portanto os bits xmin_commited e xmin_aborted não estão definidos.

O campo ctid da versão da linha refere-se à mesma linha. Isso significa que não existe uma versão mais recente.

O campo xmax é preenchido com um número fictício 0 porque esta versão da linha não foi excluída e é atual. As transações não prestarão atenção a esse número porque o bit xmax_aborted está definido.

Vamos dar mais um passo para melhorar a legibilidade adicionando bits de informação aos números das transações. E vamos criar uma função, já que precisaremos da requisição mais de uma vez:

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text, t_ctid tid)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin || CASE
         WHEN (t_infomask & 256) > 0 THEN ' (c)'
         WHEN (t_infomask & 512) > 0 THEN ' (a)'
         ELSE ''
       END AS xmin,
       t_xmax || CASE
         WHEN (t_infomask & 1024) > 0 THEN ' (c)'
         WHEN (t_infomask & 2048) > 0 THEN ' (a)'
         ELSE ''
       END AS xmax,
       t_ctid
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE SQL;

Neste formulário, fica muito mais claro o que está acontecendo no cabeçalho da versão da linha:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

Informações semelhantes, mas significativamente menos detalhadas, podem ser obtidas na própria tabela, usando pseudocolunas xmin e xmax:

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3664 |    0 |  1 | FOO
(1 row)

Fixação

Se uma transação for concluída com sucesso, você precisa se lembrar de seu status - observe que ela foi confirmada. Para isso, é utilizada uma estrutura chamada XACT (e antes da versão 10 ela se chamava CLOG (commit log) e esse nome ainda pode ser encontrado em diversos locais).

XACT não é uma tabela de catálogo do sistema; estes são os arquivos no diretório PGDATA/pg_xact. Eles têm dois bits para cada transação: confirmada e abortada - assim como no cabeçalho da versão da linha. Essas informações estão divididas em vários arquivos apenas por conveniência; voltaremos a esse assunto quando considerarmos o congelamento. E o trabalho com esses arquivos é feito página por página, como todos os outros.

Portanto, quando uma transação é confirmada no XACT, o bit confirmado é definido para esta transação. E isso é tudo o que acontece durante o commit (embora ainda não estejamos falando sobre o log de pré-gravação).

Quando outra transação acessar a página da tabela que acabamos de ver, ela terá que responder diversas perguntas.

  1. A transação xmin foi concluída? Caso contrário, a versão criada da string não deverá estar visível.
    Essa verificação é realizada observando outra estrutura, que está localizada na memória compartilhada da instância e se chama ProcArray. Ele contém uma lista de todos os processos ativos, e para cada um é indicado o número de sua transação atual (ativa).
  2. Se concluído, como - confirmando ou cancelando? Se cancelado, a versão da linha também não deverá estar visível.
    É exatamente para isso que serve o XACT. Mas, embora as últimas páginas do XACT sejam armazenadas em buffers na RAM, ainda é caro verificar o XACT todas as vezes. Portanto, uma vez determinado o status da transação, ele é gravado nos bits xmin_committed e xmin_aborted da versão da string. Se um desses bits for definido, o estado da transação xmin será considerado conhecido e a próxima transação não precisará acessar o XACT.

Por que esses bits não são definidos pela própria transação que faz a inserção? Quando ocorre uma inserção, a transação ainda não sabe se será bem-sucedida. E no momento do commit, não está mais claro quais linhas e quais páginas foram alteradas. Pode haver muitas dessas páginas e não é lucrativo memorizá-las. Além disso, algumas páginas podem ser despejadas do cache do buffer para o disco; lê-los novamente para alterar os bits retardaria significativamente o commit.

A desvantagem da economia é que após as alterações, qualquer transação (mesmo uma que execute uma leitura simples - SELECT) pode começar a alterar as páginas de dados no cache do buffer.

Então, vamos corrigir a mudança.

=> COMMIT;

Nada mudou na página (mas sabemos que o status da transação já está registrado no XACT):

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

Agora a transação que acessa a página primeiro terá que determinar o status da transação xmin e gravá-lo nos bits de informação:

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 0 (a) | (0,1)
(1 row)

Remoção

Quando uma linha é excluída, o número da transação de exclusão atual é gravado no campo xmax da versão atual e o bit xmax_aborted é limpo.

Observe que o valor definido de xmax correspondente à transação ativa atua como um bloqueio de linha. Se outra transação quiser atualizar ou excluir esta linha, ela será forçada a aguardar a conclusão da transação xmax. Falaremos mais sobre bloqueio mais tarde. Por enquanto, apenas observamos que o número de bloqueios de linha é ilimitado. Eles não ocupam espaço na RAM e o desempenho do sistema não é prejudicado por seu número. É verdade que as transações “longas” têm outras desvantagens, mas falaremos mais sobre isso mais tarde.

Vamos deletar a linha.

=> BEGIN;
=> DELETE FROM t;
=> SELECT txid_current();
 txid_current 
--------------
         3665
(1 row)

Vemos que o número da transação está escrito no campo xmax, mas os bits de informação não estão definidos:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

Cancelamento

Abortar alterações funciona de forma semelhante ao commit, apenas no XACT o bit abortado é definido para a transação. Desfazer é tão rápido quanto confirmar. Embora o comando seja denominado ROLLBACK, as alterações não são revertidas: tudo o que a transação conseguiu alterar nas páginas de dados permanece inalterado.

=> ROLLBACK;
=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

Quando a página for acessada, o status será verificado e o bit de dica xmax_aborted será definido para a versão da linha. O próprio número xmax permanece na página, mas ninguém vai olhar para ele.

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   |   xmax   | t_ctid 
-------+--------+----------+----------+--------
 (0,1) | normal | 3664 (c) | 3665 (a) | (0,1)
(1 row)

Обновление

A atualização funciona como se primeiro excluísse a versão atual da linha e depois inserisse uma nova.

=> BEGIN;
=> UPDATE t SET s = 'BAR';
=> SELECT txid_current();
 txid_current 
--------------
         3666
(1 row)

A consulta produz uma linha (nova versão):

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | BAR
(1 row)

Mas na página vemos as duas versões:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 3666  | (0,2)
 (0,2) | normal | 3666     | 0 (a) | (0,2)
(2 rows)

A versão excluída é marcada com o número da transação atual no campo xmax. Além disso, este valor é escrito sobre o antigo, pois a transação anterior foi cancelada. E o bit xmax_aborted é apagado porque o status da transação atual ainda não é conhecido.

A primeira versão da linha agora se refere à segunda (campo t_ctid) como a mais recente.

Um segundo índice aparece na página de índice e uma segunda linha faz referência à segunda versão na página da tabela.

Assim como acontece com a exclusão, o valor xmax na primeira versão da linha é uma indicação de que a linha está bloqueada.

Bem, vamos concluir a transação.

=> COMMIT;

Índices

Até agora falamos apenas sobre páginas de tabela. O que acontece dentro dos índices?

As informações nas páginas de índice variam muito dependendo do tipo específico de índice. E mesmo um tipo de índice possui diferentes tipos de páginas. Por exemplo, uma árvore B possui uma página de metadados e páginas “regulares”.

No entanto, a página geralmente possui uma matriz de ponteiros para as linhas e para as próprias linhas (assim como uma página de tabela). Além disso, no final da página há espaço para dados especiais.

As linhas nos índices também podem ter estruturas muito diferentes dependendo do tipo de índice. Por exemplo, para uma árvore B, as linhas relacionadas às páginas folha contêm o valor da chave de indexação e uma referência (ctid) à linha da tabela correspondente. Em geral, o índice pode ser estruturado de uma forma completamente diferente.

O ponto mais importante é que não existem versões de linha em índices de qualquer tipo. Bem, ou podemos assumir que cada linha é representada por exatamente uma versão. Em outras palavras, não há campos xmin e xmax no cabeçalho da linha do índice. Podemos assumir que os links do índice levam a todas as versões da tabela das linhas - portanto, você pode descobrir qual versão a transação verá apenas olhando a tabela. (Como sempre, isso não é toda a verdade. Em alguns casos, o mapa de visibilidade pode otimizar o processo, mas veremos isso com mais detalhes posteriormente.)

Ao mesmo tempo, na página de índice encontramos ponteiros para ambas as versões, tanto a atual quanto a antiga:

=> SELECT itemoffset, ctid FROM bt_page_items('t_s_idx',1);
 itemoffset | ctid  
------------+-------
          1 | (0,2)
          2 | (0,1)
(2 rows)

Transações virtuais

Na prática, o PostgreSQL utiliza otimizações que permitem “salvar” números de transações.

Se uma transação apenas lê dados, ela não afeta a visibilidade das versões de linha. Portanto, o processo de serviço primeiro emite um xid virtual para a transação. O número consiste em um ID de processo e um número de sequência.

A emissão deste número não requer sincronização entre todos os processos e, portanto, é muito rápida. Conheceremos outro motivo para usar números virtuais quando falarmos em congelamento.

Os números virtuais não são levados em consideração de forma alguma nos instantâneos de dados.

Em diferentes momentos, pode haver transações virtuais no sistema com números que já foram utilizados, e isso é normal. Mas tal número não pode ser escrito em páginas de dados, porque na próxima vez que a página for acessada ela poderá perder todo o significado.

=> BEGIN;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                         
(1 row)

Se uma transação começar a alterar dados, ela receberá um número de transação real e exclusivo.

=> UPDATE accounts SET amount = amount - 1.00;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                     3667
(1 row)

=> COMMIT;

Transações aninhadas

Salvar pontos

Definido em SQL salvar pontos (savepoint), que permite cancelar parte de uma transação sem interrompê-la completamente. Mas isso não se enquadra no diagrama acima, pois a transação tem o mesmo status para todas as suas alterações e fisicamente nenhum dado é revertido.

Para implementar esta funcionalidade, uma transação com um ponto de salvamento é dividida em vários transações aninhadas (subtransação), cujo status pode ser gerenciado separadamente.

As transações aninhadas possuem um número próprio (maior que o número da transação principal). O status das transações aninhadas é registrado da maneira usual no XACT, mas o status final depende do status da transação principal: se for cancelada, todas as transações aninhadas também serão canceladas.

As informações sobre o aninhamento de transações são armazenadas em arquivos no diretório PGDATA/pg_subtrans. Os arquivos são acessados ​​por meio de buffers na memória compartilhada da instância, organizados da mesma forma que os buffers XACT.

Não confunda transações aninhadas com transações autônomas. As transações autônomas não dependem umas das outras de forma alguma, mas as transações aninhadas sim. Não há transações autônomas no PostgreSQL regular, e talvez seja melhor assim: elas são necessárias muito, muito raramente, e sua presença em outros SGBDs provoca abusos, dos quais todos sofrem.

Vamos limpar a tabela, iniciar uma transação e inserir a linha:

=> TRUNCATE TABLE t;
=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
(1 row)

Agora vamos colocar um save point e inserir outra linha.

=> SAVEPOINT sp;
=> INSERT INTO t(s) VALUES ('XYZ');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

Observe que a função txid_current() retorna o número da transação principal, não o número da transação aninhada.

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3670 |    0 |  3 | XYZ
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
 (0,2) | normal | 3670 | 0 (a) | (0,2)
(2 rows)

Vamos voltar ao ponto de salvamento e inserir a terceira linha.

=> ROLLBACK TO sp;
=> INSERT INTO t(s) VALUES ('BAR');
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669     | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671     | 0 (a) | (0,3)
(3 rows)

Na página continuamos vendo a linha adicionada pela transação aninhada cancelada.

Nós corrigimos as mudanças.

=> COMMIT;
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
(3 rows)

Agora você pode ver claramente que cada transação aninhada tem seu próprio status.

Observe que as transações aninhadas não podem ser usadas explicitamente no SQL, ou seja, você não pode iniciar uma nova transação sem concluir a atual. Este mecanismo é ativado implicitamente ao usar pontos de salvamento, bem como ao lidar com exceções PL/pgSQL e em vários outros casos mais exóticos.

=> BEGIN;
BEGIN
=> BEGIN;
WARNING:  there is already a transaction in progress
BEGIN
=> COMMIT;
COMMIT
=> COMMIT;
WARNING:  there is no transaction in progress
COMMIT

Erros e atomicidade das operações

O que acontece se ocorrer um erro durante a execução de uma operação? Por exemplo, assim:

=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

Ocorreu um erro. Agora a transação é considerada abortada e nenhuma operação é permitida nela:

=> SELECT * FROM t;
ERROR:  current transaction is aborted, commands ignored until end of transaction block

E mesmo se você tentar confirmar as alterações, o PostgreSQL reportará uma interrupção:

=> COMMIT;
ROLLBACK

Por que uma transação não pode continuar após uma falha? O fato é que poderia surgir um erro de tal forma que teríamos acesso a parte das alterações - a atomicidade nem mesmo da transação, mas do operador seria violada. Como no nosso exemplo, onde o operador conseguiu atualizar uma linha antes do erro:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 3672  | (0,4)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
 (0,4) | normal | 3672     | 0 (a) | (0,4)
(4 rows)

Deve-se dizer que o psql possui um modo que ainda permite que a transação continue após uma falha, como se as ações do operador errôneo tivessem sido revertidas.

=> set ON_ERROR_ROLLBACK on
=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> COMMIT;

Não é difícil adivinhar que neste modo o psql realmente coloca um ponto de salvamento implícito antes de cada comando e, em caso de falha, inicia um rollback para ele. Este modo não é usado por padrão, pois definir pontos de salvamento (mesmo sem reverter para eles) envolve sobrecarga significativa.

Leia mais.

Fonte: habr.com

Adicionar um comentário