MVCC-3. Versións de cadea

Entón, consideramos cuestións relacionadas co illamento, e fixo unha retirada sobre organizando os datos a un nivel baixo. E finalmente chegamos á parte máis interesante: as versións de cordas.

Título

Como xa dixemos, cada fila pode existir simultaneamente en varias versións na base de datos. Unha versión debe distinguirse dalgún xeito doutra.Para iso, cada versión ten dúas marcas que determinan o "tempo" de acción desta versión (xmin e xmax). Entre comiñas, porque non se usa o tempo como tal, senón un contador crecente especial. E este contador é o número de transacción.

(Como é habitual, a realidade é máis complicada: o número de transacción non pode aumentar todo o tempo debido á limitada capacidade de bits do contador. Pero veremos estes detalles en detalle cando cheguemos á conxelación).

Cando se crea unha fila, xmin establécese no número de transacción que emitiu o comando INSERT e xmax queda en branco.

Cando se elimina unha fila, o valor xmax da versión actual márcase co número da transacción que realizou a DELETE.

Cando se modifica unha fila mediante un comando UPDATE, realízanse dúas operacións: DELETE e INSERT. A versión actual da fila establece xmax igual ao número da transacción que realizou a ACTUALIZACIÓN. A continuación, créase unha nova versión da mesma cadea; o seu valor xmin coincide co valor xmax da versión anterior.

Os campos xmin e xmax están incluídos na cabeceira da versión da fila. Ademais destes campos, a cabeceira contén outros, por exemplo:

  • infomask é unha serie de bits que definen as propiedades desta versión. Hai bastantes; Consideraremos aos poucos os principais.
  • ctid é unha ligazón á seguinte versión máis nova da mesma liña. Para a versión máis recente e actual dunha cadea, o ctid refírese a esta versión en si. O número ten a forma (x,y), onde x é o número de páxina, y é o número de índice da matriz.
  • mapa de bits nulo: marca as columnas dunha versión determinada que conteñen un valor nulo (NULL). NULL non é un dos valores normais do tipo de datos, polo que o atributo debe almacenarse por separado.

Como resultado, a cabeceira é bastante grande: polo menos 23 bytes para cada versión da liña, e normalmente máis debido ao mapa de bits NULL. Se a táboa é "estreita" (é dicir, contén poucas columnas), a sobrecarga pode ocupar máis que a información útil.

inserir

Vexamos máis de cerca como se realizan as operacións de cadea de baixo nivel, comezando pola inserción.

Para os experimentos, imos crear unha nova táboa con dúas columnas e un índice nunha delas:

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

Imos inserir unha fila despois de iniciar unha transacción.

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

Este é o noso número de transacción actual:

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

Vexamos o contido da páxina. A función heap_page_items da extensión pageinspect permítelle obter información sobre punteiros e versións de filas:

=> 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

Teña en conta que a palabra montón en PostgreSQL refírese a táboas. Este é outro uso estraño do termo: coñécese un montón estrutura de datos, que non ten nada en común coa táboa. Aquí a palabra úsase no sentido de "todo se xunta", en oposición aos índices ordenados.

A función mostra os datos "tal como están", nun formato difícil de entender. Para descubrilo, deixaremos só parte da información e descifrala:

=> 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)

Aquí está o que fixemos:

  • Engadiuse un cero ao número de índice para que teña o mesmo aspecto que t_ctid: (número de páxina, número de índice).
  • Descifrado o estado do punteiro lp_flags. Aquí é "normal" - isto significa que o punteiro refírese realmente á versión da cadea. Veremos outros significados máis adiante.
  • De todos os bits de información, só se identificaron dous pares ata agora. Os bits xmin_committed e xmin_aborted indican se o número de transacción xmin está confirmado (abortado). Dous bits similares refírense ao número de transacción xmax.

Que vemos? Cando insira unha fila, aparecerá un número de índice 1 na páxina da táboa, sinalando a primeira e única versión da fila.

Na versión de cadea, o campo xmin énchese co número de transacción actual. A transacción aínda está activa, polo que os bits xmin_committed e xmin_aborted non están configurados.

O campo ctid da versión da fila fai referencia á mesma fila. Isto significa que non existe unha versión máis nova.

O campo xmax énchese cun número ficticio 0 porque esta versión da fila non se eliminou e é actual. As transaccións non prestarán atención a este número porque o bit xmax_aborted está configurado.

Imos dar un paso máis para mellorar a lexibilidade engadindo bits de información aos números de transacción. E imos crear unha función, xa que necesitaremos a solicitude máis dunha 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 formulario, está moito máis claro o que está a suceder na cabeceira da versión da fila:

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

Pódese obter información semellante, pero significativamente menos detallada da propia táboa, utilizando as pseudocolumnas xmin e xmax:

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

Fixación

Se unha transacción se completa con éxito, cómpre lembrar o seu estado; teña en conta que está comprometida. Para iso utilízase unha estrutura chamada XACT (e antes da versión 10 chamábase CLOG (commit log) e este nome aínda se pode atopar en diferentes lugares).

XACT non é unha táboa de catálogo do sistema; estes son os ficheiros do directorio PGDATA/pg_xact. Teñen dous bits para cada transacción: confirmada e abortada, igual que na cabeceira da versión de fila. Esta información está dividida en varios ficheiros unicamente por comodidade; volveremos sobre este problema cando consideremos a conxelación. E o traballo con estes ficheiros realízase páxina por páxina, como con todos os demais.

Entón, cando se confirma unha transacción en XACT, o bit confirmado establécese para esta transacción. E isto é todo o que ocorre durante a confirmación (aínda que aínda non estamos a falar do rexistro de pre-gravación).

Cando outra transacción accede á páxina da táboa que acabamos de mirar, terá que responder a varias preguntas.

  1. Completouse a transacción xmin? Se non, a versión creada da cadea non debería estar visible.
    Esta comprobación realízase mirando outra estrutura, que está situada na memoria compartida da instancia e que se chama ProcArray. Contén unha lista de todos os procesos activos, e para cada un indícase o número da súa transacción (activa) actual.
  2. Se se completa, entón como - comprometéndose ou cancelando? Se se cancela, a versión da fila tampouco debería estar visible.
    Para iso é exactamente XACT. Pero, aínda que as últimas páxinas de XACT están almacenadas en búfers na RAM, aínda é caro comprobar XACT cada vez. Polo tanto, unha vez que se determina o estado da transacción, escríbese nos bits xmin_committed e xmin_aborted da versión de cadea. Se se establece un destes bits, entón o estado da transacción xmin considérase coñecido e a seguinte transacción non terá que acceder a XACT.

Por que non se establecen estes bits pola propia transacción facendo a inserción? Cando se produce unha inserción, a transacción aínda non sabe se terá éxito. E no momento de comprometerse xa non está claro que liñas en que páxinas se cambiaron. Pode haber moitas páxinas deste tipo e memorizalas non é rendible. Ademais, algunhas páxinas poden ser expulsadas da caché do búfer ao disco; léndoos de novo para cambiar os bits ralentizaría significativamente a commit.

A desvantaxe do aforro é que despois dos cambios, calquera transacción (mesmo unha que realice unha lectura simple - SELECT) pode comezar a cambiar as páxinas de datos na caché do búfer.

Entón, imos corrixir o cambio.

=> COMMIT;

Non cambiou nada na páxina (pero sabemos que o estado da transacción xa está rexistrado en 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 transacción que accede primeiro á páxina terá que determinar o estado da transacción xmin e escribilo nos bits de información:

=> 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)

Eliminar

Cando se elimina unha fila, o número da transacción de eliminación actual escríbese no campo xmax da versión actual e borra o bit xmax_aborted.

Teña en conta que o valor establecido de xmax correspondente á transacción activa actúa como un bloqueo de fila. Se outra transacción quere actualizar ou eliminar esta fila, obrigarase a esperar a que se complete a transacción xmax. Máis adiante falaremos sobre o bloqueo. Polo momento, só observamos que o número de bloqueos de filas é ilimitado. Non ocupan espazo na memoria RAM e o rendemento do sistema non se ve afectado polo seu número. É certo que as transaccións "longas" teñen outras desvantaxes, pero sobre iso máis tarde.

Imos eliminar a liña.

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

Vemos que o número de transacción está escrito no campo xmax, pero os bits de información non están configurados:

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

Cancelar

Abortar os cambios funciona de forma similar á confirmación, só en XACT se establece o bit abortado para a transacción. Desfacer é tan rápido como cometer. Aínda que o comando chámase ROLLBACK, os cambios non se retrotraen: todo o que a transacción conseguiu modificar nas páxinas de datos permanece sen cambios.

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

Cando se acceda á páxina, comprobarase o estado e o bit de suxestión xmax_aborted establecerase na versión de fila. O número xmax en si permanece na páxina, pero ninguén o mirará.

=> 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)

Actualizar

A actualización funciona coma se primeiro eliminase a versión actual da fila e despois inserise unha nova.

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

A consulta produce unha liña (nova versión):

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

Pero na páxina vemos as dúas versións:

=> 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 versión eliminada está marcada co número de transacción actual no campo xmax. Ademais, este valor escríbese sobre o antigo, xa que a transacción anterior foi cancelada. E o bit xmax_aborted bótase porque aínda non se coñece o estado da transacción actual.

A primeira versión da liña agora refírese á segunda (campo t_ctid) como a máis nova.

Un segundo índice aparece na páxina de índice e unha segunda fila fai referencia á segunda versión na páxina da táboa.

Do mesmo xeito que coa eliminación, o valor xmax na primeira versión da fila é unha indicación de que a fila está bloqueada.

Ben, imos completar a transacción.

=> COMMIT;

Índices

Ata agora só falamos das páxinas da táboa. Que pasa dentro dos índices?

A información das páxinas de índice varía moito dependendo do tipo específico de índice. E mesmo un tipo de índice ten diferentes tipos de páxinas. Por exemplo, unha árbore B ten unha páxina de metadatos e páxinas "regulares".

Non obstante, a páxina adoita ter unha matriz de punteiros ás filas e ás propias filas (igual que unha páxina de táboa). Ademais, ao final da páxina hai espazo para datos especiais.

As filas dos índices tamén poden ter estruturas moi diferentes segundo o tipo de índice. Por exemplo, para unha árbore B, as filas relacionadas coas páxinas follas conteñen o valor da clave de indexación e unha referencia (ctid) á fila da táboa correspondente. En xeral, o índice pódese estruturar dun xeito completamente diferente.

O punto máis importante é que non hai versións de filas en índices de ningún tipo. Ben, ou podemos supoñer que cada liña está representada por exactamente unha versión. Noutras palabras, non hai campos xmin e xmax na cabeceira da fila do índice. Podemos supoñer que as ligazóns do índice levan a todas as versións da táboa das filas, polo que só podes descubrir que versión verá a transacción mirando a táboa. (Como sempre, esta non é toda a verdade. Nalgúns casos, o mapa de visibilidade pode optimizar o proceso, pero verémolo máis en detalle máis adiante).

Ao mesmo tempo, na páxina de índice atopamos punteiros a ambas as versións, tanto a actual como a antiga:

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

Transaccións virtuais

Na práctica, PostgreSQL usa optimizacións que lle permiten "gardar" os números de transacción.

Se unha transacción só le datos, non afectará á visibilidade das versións das filas. Polo tanto, o proceso de servizo emite primeiro un xid virtual para a transacción. O número consta dun ID de proceso e dun número de secuencia.

Emitir este número non require sincronización entre todos os procesos e, polo tanto, é moi rápido. Coñeceremos outro motivo para usar números virtuais cando falemos de conxelación.

Os números virtuais non se teñen en conta de ningún xeito nas instantáneas de datos.

En diferentes momentos, pode haber transaccións virtuais no sistema con números que xa foron utilizados, e isto é normal. Pero tal número non se pode escribir nas páxinas de datos, porque a próxima vez que se acceda á páxina pode perder todo o significado.

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

Se unha transacción comeza a cambiar os datos, dáselle un número de transacción real e único.

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

=> COMMIT;

Transaccións anidadas

Gardar puntos

Definido en SQL gardar puntos (punto de salvamento), que permiten cancelar parte dunha transacción sen interrompela por completo. Pero isto non encaixa no diagrama anterior, xa que a transacción ten o mesmo estado para todos os seus cambios e fisicamente non se retrotrae ningún dato.

Para implementar esta funcionalidade, unha transacción cun punto de salvamento divídese en varios separados transaccións aniñadas (subtransacción), cuxo estado se pode xestionar por separado.

As transaccións anidadas teñen o seu propio número (maior que o número da transacción principal). O estado das transaccións aniñadas rexístrase do xeito habitual en XACT, pero o estado final depende do estado da transacción principal: se se cancela, todas as transaccións aniñadas tamén se cancelan.

A información sobre a anidación de transaccións gárdase en ficheiros do directorio PGDATA/pg_subtrans. Accédese aos ficheiros a través dos búfers da memoria compartida da instancia, organizados do mesmo xeito que os búfers de XACT.

Non confundas transaccións aniñadas con transaccións autónomas. As transaccións autónomas non dependen unhas das outras de ningún xeito, pero si as transaccións aniñadas. Non hai transaccións autónomas en PostgreSQL normal, e, quizais, o mellor: son necesarias moi, moi poucas veces, e a súa presenza noutros DBMS provoca abusos, dos que todos padecen entón.

Limpemos a táboa, iniciemos unha transacción e insiramos a fila:

=> 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 imos poñer un punto de gardar e inserir outra liña.

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

Teña en conta que a función txid_current() devolve o número de transacción principal, non o número de transacción aniñada.

=> 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)

Volvamos ao punto de gardar e inserimos a terceira liña.

=> 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áxina seguimos vendo a fila engadida pola transacción aniñada cancelada.

Arranxamos os cambios.

=> 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 podes ver claramente que cada transacción aniñada ten o seu propio estado.

Teña en conta que as transaccións aniñadas non se poden usar explícitamente en SQL, é dicir, non pode iniciar unha nova transacción sen completar a actual. Este mecanismo actívase implicitamente cando se usan puntos de salvamento, así como cando se manexan excepcións PL/pgSQL e noutros casos máis 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 operacións

Que ocorre se se produce un erro ao realizar unha operación? Por exemplo, así:

=> 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

Produciuse un erro. Agora a transacción considérase abortada e non se permiten operacións nela:

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

E aínda que intentes confirmar os cambios, PostgreSQL informará dun aborto:

=> COMMIT;
ROLLBACK

Por que non pode continuar unha transacción despois dun fallo? O feito é que podería xurdir un erro de tal xeito que teriamos acceso a parte dos cambios: a atomicidade nin sequera da transacción, pero o operador sería violado. Como no noso exemplo, onde o operador conseguiu actualizar unha liña 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)

Hai que dicir que psql ten un modo que aínda permite que a transacción continúe despois dun fallo coma se se anulasen as accións do operador erróneo.

=> 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;

Non é difícil adiviñar que neste modo, psql realmente pon un punto de gardado implícito antes de cada comando e, en caso de falla, inicia un retroceso. Este modo non se usa de forma predeterminada, xa que establecer puntos de salvamento (aínda sen volver a eles) implica unha sobrecarga significativa.

Continuación.

Fonte: www.habr.com

Engadir un comentario