MVCC-3. Versiones de cadena

Entonces, hemos considerado cuestiones relacionadas con aislamiento, e hizo una retirada sobre organizar datos a bajo nivel. Y finalmente llegamos a la parte más interesante: las versiones de cuerdas.

Título

Como ya hemos dicho, cada fila puede existir simultáneamente en varias versiones en la base de datos. Hay que distinguir de alguna manera una versión de otra, para ello cada versión tiene dos marcas que determinan el “tiempo” de acción de esta versión (xmin y xmax). Entre comillas, porque no se utiliza el tiempo como tal, sino un contador creciente especial. Y este contador es el número de transacción.

(Como siempre, la realidad es más complicada: el número de transacción no puede aumentar todo el tiempo debido a la capacidad limitada de bits del contador. Pero veremos estos detalles en detalle cuando lleguemos a la congelación).

Cuando se crea una fila, xmin se establece en el número de transacción que emitió el comando INSERT y xmax se deja en blanco.

Cuando se elimina una fila, el valor xmax de la versión actual se marca con el número de la transacción que realizó la DELETE.

Cuando se modifica una fila mediante un comando ACTUALIZAR, en realidad se realizan dos operaciones: ELIMINAR e INSERTAR. La versión actual de la fila establece xmax igual al número de la transacción que realizó la ACTUALIZACIÓN. Luego se crea una nueva versión de la misma cadena; su valor xmin coincide con el valor xmax de la versión anterior.

Los campos xmin y xmax se incluyen en el encabezado de la versión de la fila. Además de estos campos, el encabezado contiene otros, por ejemplo:

  • infomask es una serie de bits que definen las propiedades de esta versión. Hay muchos de ellos; Poco a poco consideraremos los principales.
  • ctid es un enlace a la siguiente versión más nueva de la misma línea. Para la versión más nueva y actual de una cadena, el ctid se refiere a esta versión misma. El número tiene la forma (x,y), donde x es el número de página, y es el número de índice de la matriz.
  • Mapa de bits nulo: marca aquellas columnas de una versión determinada que contienen un valor nulo (NULL). NULL no es uno de los valores de tipo de datos normales, por lo que el atributo debe almacenarse por separado.

Como resultado, el encabezado es bastante grande: al menos 23 bytes para cada versión de la línea y, por lo general, más debido al mapa de bits NULL. Si la tabla es "estrecha" (es decir, contiene pocas columnas), la sobrecarga puede ocupar más que la información útil.

insertar

Echemos un vistazo más de cerca a cómo se realizan las operaciones de cadenas de bajo nivel, comenzando con la inserción.

Para experimentos, creemos una nueva tabla con dos columnas y un índice en una de ellas:

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

Insertemos una fila después de iniciar una transacción.

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

Aquí está nuestro número de transacción actual:

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

Veamos el contenido de la página. La función heap_page_items de la extensión pageinspect le permite obtener información sobre punteros y versiones 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

Tenga en cuenta que la palabra montón en PostgreSQL se refiere a tablas. Éste es otro uso extraño del término: se conoce un montón estructura de datos, que no tiene nada que ver con la mesa. Aquí la palabra se utiliza en el sentido de "todo está unido", en contraposición a los índices ordenados.

La función muestra los datos “tal cual”, en un formato difícil de entender. Para resolverlo, dejaremos solo una parte de la información y la descifraremos:

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

Esto es lo que hicimos:

  • Se agregó un cero al número de índice para que se vea igual que t_ctid: (número de página, número de índice).
  • Descifrado el estado del puntero lp_flags. Aquí es "normal": esto significa que el puntero en realidad se refiere a la versión de la cadena. Veremos otros significados más adelante.
  • De todos los bits de información, hasta ahora sólo se han identificado dos pares. Los bits xmin_committed y xmin_aborted indican si la transacción número xmin está confirmada (abortada). Dos bits similares se refieren al número de transacción xmax.

¿Qué vemos? Cuando inserta una fila, aparecerá un número de índice 1 en la página de la tabla, que apunta a la primera y única versión de la fila.

En la versión de cadena, el campo xmin se completa con el número de transacción actual. La transacción aún está activa, por lo que los bits xmin_committed y xmin_aborted no están configurados.

El campo ctid de versión de fila hace referencia a la misma fila. Esto significa que no existe una versión más nueva.

El campo xmax se completa con un número ficticio 0 porque esta versión de la fila no se ha eliminado y está actualizada. Las transacciones no prestarán atención a este número porque el bit xmax_aborted está establecido.

Demos un paso más para mejorar la legibilidad agregando bits de información a los números de transacción. Y creemos una función, ya que necesitaremos la solicitud más de una 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;

De esta forma, queda mucho más claro lo que sucede en el encabezado de la versión de fila:

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

Se puede obtener información similar, pero significativamente menos detallada, de la propia tabla, utilizando pseudocolumnas xmin y xmax:

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

Fijación

Si una transacción se completa con éxito, debe recordar su estado; tenga en cuenta que está confirmada. Para ello se utiliza una estructura llamada XACT (y antes de la versión 10 se llamaba CLOG (commit log) y este nombre todavía se puede encontrar en diferentes lugares).

XACT no es una tabla de catálogo del sistema; estos son los archivos en el directorio PGDATA/pg_xact. Tienen dos bits para cada transacción: confirmada y abortada, tal como en el encabezado de la versión de la fila. Esta información se divide en varios archivos únicamente por conveniencia; volveremos a este tema cuando consideremos congelarla. Y el trabajo con estos archivos se realiza página por página, como con todos los demás.

Entonces, cuando se confirma una transacción en XACT, el bit comprometido se establece para esta transacción. Y esto es todo lo que sucede durante la confirmación (aunque todavía no estamos hablando del registro previo a la grabación).

Cuando otra transacción acceda a la página de la tabla que acabamos de ver, deberá responder varias preguntas.

  1. ¿Se ha completado la transacción xmin? De lo contrario, la versión creada de la cadena no debería ser visible.
    Esta verificación se realiza observando otra estructura, que se encuentra en la memoria compartida de la instancia y se llama ProcArray. Contiene una lista de todos los procesos activos y para cada uno se indica el número de su transacción actual (activa).
  2. Si se completa, ¿cómo? ¿Commitiendo o cancelando? Si se cancela, la versión de la fila tampoco debería ser visible.
    Esto es exactamente para lo que sirve XACT. Pero, aunque las últimas páginas de XACT se almacenan en buffers en la RAM, sigue siendo costoso verificar XACT cada vez. Por lo tanto, una vez que se determina el estado de la transacción, se escribe en los bits xmin_committed y xmin_aborted de la versión de cadena. Si se establece uno de estos bits, entonces el estado de la transacción xmin se considera conocido y la siguiente transacción no tendrá que acceder a XACT.

¿Por qué estos bits no los establece la propia transacción al realizar la inserción? Cuando se produce una inserción, la transacción aún no sabe si tendrá éxito. Y en el momento de confirmar, ya no está claro qué líneas en qué páginas se cambiaron. Puede que haya muchas páginas de este tipo y memorizarlas no es rentable. Además, algunas páginas se pueden desalojar del caché del búfer al disco; leerlos nuevamente para cambiar los bits ralentizaría significativamente la confirmación.

La desventaja del ahorro es que después de los cambios, cualquier transacción (incluso una que realice una lectura simple - SELECT) puede comenzar a cambiar las páginas de datos en el buffer cache.

Entonces, arreglemos el cambio.

=> COMMIT;

Nada ha cambiado en la página (pero sabemos que el estado de la transacción ya está registrado en XACT):

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

Ahora la transacción que acceda primero a la página tendrá que determinar el estado de la transacción xmin y escribirlo en los 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)

Eliminación

Cuando se elimina una fila, el número de la transacción de eliminación actual se escribe en el campo xmax de la versión actual y se borra el bit xmax_aborted.

Tenga en cuenta que el valor establecido de xmax correspondiente a la transacción activa actúa como un bloqueo de fila. Si otra transacción desea actualizar o eliminar esta fila, se verá obligada a esperar a que se complete la transacción xmax. Hablaremos más sobre el bloqueo más adelante. Por ahora, solo observamos que la cantidad de bloqueos de fila es ilimitada. No ocupan espacio en la RAM y el rendimiento del sistema no se ve afectado por su número. Es cierto que las transacciones “largas” tienen otras desventajas, pero hablaremos de eso más adelante.

Borremos la línea.

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

Vemos que el número de transacción está escrito en el campo xmax, pero los bits de información no 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)

cancelación

Abortar cambios funciona de manera similar a confirmar, solo que en XACT el bit de cancelación está configurado para la transacción. Deshacer es tan rápido como comprometerse. Aunque el comando se llama ROLLBACK, los cambios no se revierten: todo lo que la transacción logró cambiar en las páginas de datos permanece sin cambios.

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

Cuando se accede a la página, se verificará el estado y el bit de sugerencia xmax_aborted se establecerá en la versión de fila. El número xmax permanece en la página, pero nadie lo verá.

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

La actualización funciona como si primero eliminara la versión actual de la fila y luego insertara una nueva.

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

La consulta produce una línea (nueva versión):

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

Pero en la página vemos ambas versiones:

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

La versión eliminada está marcada con el número de transacción actual en el campo xmax. Además, este valor se escribe sobre el anterior, ya que se canceló la transacción anterior. Y el bit xmax_aborted se borra porque aún no se conoce el estado de la transacción actual.

La primera versión de la línea ahora se refiere a la segunda (campo t_ctid) como la más nueva.

Aparece un segundo índice en la página de índice y una segunda fila hace referencia a la segunda versión en la página de la tabla.

Al igual que con la eliminación, el valor xmax en la primera versión de la fila es una indicación de que la fila está bloqueada.

Bueno, completemos la transacción.

=> COMMIT;

Índices

Hasta ahora sólo hemos hablado de páginas de tablas. ¿Qué sucede dentro de los índices?

La información de las páginas de índice varía mucho según el tipo específico de índice. E incluso un tipo de índice tiene diferentes tipos de páginas. Por ejemplo, un árbol B tiene una página de metadatos y páginas "normales".

Sin embargo, la página normalmente tiene una serie de punteros a las filas y a las filas mismas (como una página de tabla). Además, al final de la página hay espacio para datos especiales.

Las filas de los índices también pueden tener estructuras muy diferentes según el tipo de índice. Por ejemplo, para un árbol B, las filas relacionadas con las páginas hoja contienen el valor de la clave de indexación y una referencia (ctid) a la fila de la tabla correspondiente. En general, el índice se puede estructurar de forma completamente diferente.

El punto más importante es que no hay versiones de filas en índices de ningún tipo. Bueno, o podemos suponer que cada línea está representada por exactamente una versión. En otras palabras, no hay campos xmin y xmax en el encabezado de la fila del índice. Podemos suponer que los enlaces del índice conducen a todas las versiones de las filas de la tabla, por lo que puede determinar qué versión verá la transacción con solo mirar la tabla. (Como siempre, esto no es toda la verdad. En algunos casos, el mapa de visibilidad puede optimizar el proceso, pero lo veremos con más detalle más adelante).

Al mismo tiempo, en la página de índice encontramos indicaciones de ambas versiones, tanto la actual como la antigua:

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

Transacciones virtuales

En la práctica, PostgreSQL utiliza optimizaciones que le permiten "guardar" números de transacciones.

Si una transacción solo lee datos, no tiene ningún efecto en la visibilidad de las versiones de las filas. Por lo tanto, el proceso de servicio primero emite un xid virtual para la transacción. El número consta de un ID de proceso y un número de secuencia.

La emisión de este número no requiere sincronización entre todos los procesos y, por tanto, es muy rápida. Conoceremos otra razón para usar números virtuales cuando hablemos de congelación.

Los números virtuales no se tienen en cuenta de ninguna manera en las instantáneas de datos.

En diferentes momentos, es posible que haya transacciones virtuales en el sistema con números que ya han sido utilizados, y esto es normal. Pero ese número no se puede escribir en las páginas de datos, porque la próxima vez que se acceda a la página puede perder todo significado.

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

Si una transacción comienza a cambiar datos, se le asigna un número de transacción único y real.

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

=> COMMIT;

Transacciones anidadas

Guardar puntos

Definido en SQL guardar puntos (savepoint), que le permiten cancelar parte de una transacción sin interrumpirla por completo. Pero esto no encaja en el diagrama anterior, ya que la transacción tiene el mismo estado para todos sus cambios y físicamente no se revierte ningún dato.

Para implementar esta funcionalidad, una transacción con un punto de guardado se divide en varios transacciones anidadas (subtransacción), cuyo estado se puede gestionar por separado.

Las transacciones anidadas tienen su propio número (mayor que el número de la transacción principal). El estado de las transacciones anidadas se registra de la forma habitual en XACT, pero el estado final depende del estado de la transacción principal: si se cancela, todas las transacciones anidadas también se cancelan.

La información sobre el anidamiento de transacciones se almacena en archivos en el directorio PGDATA/pg_subtrans. Se accede a los archivos a través de búferes en la memoria compartida de la instancia, organizados de la misma manera que los búferes XACT.

No confunda transacciones anidadas con transacciones autónomas. Las transacciones autónomas no dependen unas de otras de ninguna manera, pero las transacciones anidadas sí. No hay transacciones autónomas en PostgreSQL normal, y quizás sea lo mejor: son necesarias muy, muy raramente, y su presencia en otros DBMS provoca abusos, que luego todos sufren.

Limpiemos la tabla, iniciemos una transacción e insertemos la 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)

Ahora coloquemos un punto de guardado e insertemos otra línea.

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

Tenga en cuenta que la función txid_current() devuelve el número de transacción principal, no el número de transacción anidado.

=> 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 al punto de guardado e insertemos la tercera línea.

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

En la página seguimos viendo la fila agregada por la transacción anidada cancelada.

Arreglamos los 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)

Ahora puedes ver claramente que cada transacción anidada tiene su propio estado.

Tenga en cuenta que las transacciones anidadas no se pueden utilizar explícitamente en SQL, es decir, no puede iniciar una nueva transacción sin completar la actual. Este mecanismo se activa implícitamente cuando se utilizan puntos de guardado, así como cuando se manejan excepciones de PL/pgSQL y en otros casos más 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

Errores y atomicidad de las operaciones.

¿Qué sucede si ocurre un error al realizar una operación? Por ejemplo, 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

Se ha producido un error. Ahora la transacción se considera abortada y no se permiten operaciones en ella:

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

E incluso si intentas confirmar los cambios, PostgreSQL informará un aborto:

=> COMMIT;
ROLLBACK

¿Por qué una transacción no puede continuar después de un error? El hecho es que podría surgir un error de tal manera que tendríamos acceso a parte de los cambios: se violaría la atomicidad ni siquiera de la transacción, sino del operador. Como en nuestro ejemplo, donde el operador logró actualizar una línea antes del error:

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

Hay que decir que psql tiene un modo que aún permite que la transacción continúe después de una falla como si las acciones del operador erróneo fueran 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;

No es difícil adivinar que en este modo, psql en realidad coloca un punto de guardado implícito antes de cada comando y, en caso de falla, inicia una reversión. Este modo no se utiliza de forma predeterminada, ya que configurar puntos de guardado (incluso sin volver a ellos) implica una sobrecarga significativa.

Continúa.

Fuente: habr.com

Añadir un comentario