MVCC en PostgreSQL-3. Versiones de fila

Bueno, ya hemos discutido el aislamiento y hemos hecho una digresión sobre la estructura de datos de bajo nivel . Y finalmente hemos llegado a lo más fascinante, es decir, las versiones de fila (tuplas).

Encabezado de tupla


Como ya se mencionó, varias versiones de cada fila pueden estar disponibles simultáneamente en la base de datos. Y tenemos que distinguir de alguna manera una versión de otra. Para este fin, cada versión está etiquetada con su "tiempo" xmin ( xmin ) y su "tiempo" de vencimiento ( xmax ). Las comillas indican que se utiliza un contador incremental especial en lugar del tiempo en sí. Y este contador es el identificador de la transacción .

(Como de costumbre, en realidad esto es más complicado: el ID de la transacción no siempre puede incrementarse debido a una profundidad limitada del contador. Pero exploraremos más detalles de esto cuando nuestra discusión llegue a congelarse).

Cuando se crea una fila, el valor de xmin se establece igual al ID de la transacción que realizó el comando INSERT, mientras que xmax no se completa.

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

Un comando UPDATE realmente realiza dos operaciones posteriores: DELETE e INSERT. En la versión actual de la fila, xmax se establece igual al ID de la transacción que realizó ACTUALIZACIÓN. Luego se crea una nueva versión de la misma fila, en la que el valor de xmin es el mismo que xmax de la versión anterior.

xmin campos xmin y xmax se incluyen en el encabezado de una versión de fila. Además de estos campos, el encabezado de tupla contiene otros, como:

  • infomask : varios bits que determinan las propiedades de una tupla dada. Hay bastantes de ellos, y discutiremos cada uno con el tiempo.
  • ctid : una referencia a la siguiente versión, más reciente, de la misma fila. ctid de la versión de fila más nueva y actualizada hace referencia a esa misma versión. El número está en la forma (x,y) , donde x es el número de la página e y es el número de orden del puntero en la matriz.
  • El mapa de bits NULL, que marca las columnas de una versión dada que contienen un NULL. NULL no es un valor regular de los tipos de datos y, por lo tanto, tenemos que almacenar esta característica por separado.

Como resultado, el encabezado parece bastante grande: 23 bytes por cada tupla como mínimo, pero generalmente más grande debido al mapa de bits NULL. Si una tabla es "estrecha" (es decir, contiene pocas columnas), los bytes superiores pueden ocupar más espacio que la información útil.

Insertar


Veamos con más detalle cómo se realizan las operaciones en filas en un nivel bajo, y comenzamos con una inserción.

Para experimentar, crearemos 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); 

Comenzamos una transacción para insertar una fila.

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

Esta es la identificación de nuestra 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" nos permite obtener información sobre los punteros y las versiones de fila:

 => 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 denota tablas. Este es un uso extraño más de un término: un montón es una estructura de datos conocida, que no tiene nada que ver con una tabla. Esta palabra se usa aquí en el sentido de que "todo está lleno", a diferencia de los índices ordenados.

Esta función muestra los datos "tal cual", en un formato que es difícil de comprender. Para aclarar las cosas, dejamos solo una parte de la información y la interpretamos:

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

Hicimos lo siguiente:

  • Se agregó un cero al número de puntero para que parezca un t_ctid : (número de página, número de puntero).
  • Interpretó el estado del puntero lp_flags . Aquí es "normal", lo que significa que el puntero realmente hace referencia a una versión de fila. Discutiremos otros valores más adelante.
  • De todos los bits de información, hemos seleccionado solo dos pares hasta ahora. xmin_committed bits xmin_committed y xmin_aborted muestran si la transacción con el ID xmin está confirmada (revertida). Un par de bits similares se relaciona con la transacción con el ID xmax .

¿Qué observamos? Cuando se inserta una fila, en la página de la tabla aparece un puntero que tiene el número 1 y hace referencia a la primera y única versión de la fila.

El campo xmin en la tupla se llena con la ID de la transacción actual. Debido a que la transacción todavía está activa, los bits xmin_committed y xmin_aborted están sin establecer.

El campo ctid de la versión de la fila hace referencia a la misma fila. Significa que no hay una versión más nueva disponible.

El campo xmax se llena con el número convencional 0 ya que la tupla no se elimina, es decir, está actualizada. Las transacciones ignorarán este número debido al xmax_aborted bits xmax_aborted .

Avancemos un paso más para mejorar la legibilidad agregando bits de información a las ID de transacciones. Y creemos la función ya que necesitaremos la consulta 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; 

Lo que está sucediendo en el encabezado de la versión de fila es mucho más claro en esta forma:

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

Podemos obtener información similar, pero mucho menos detallada, de la tabla en sí usando xmin y xmax :

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

Comprometerse


Cuando una transacción es exitosa, su estado debe recordarse, es decir, la transacción debe marcarse como confirmada. Para este fin, se utiliza la estructura XACT. (Antes de la versión 10 se llamaba CLOG (commit log), y aún es probable que encuentre este nombre).

XACT no es una tabla del catálogo del sistema, sino archivos en el directorio PGDATA / pg_xact. Se asignan dos bits en estos archivos para cada transacción, "comprometido" y "abortado", exactamente de la misma manera que en el encabezado de tupla. Esta información se distribuye en varios archivos solo por conveniencia; Volveremos a esto cuando discutamos el congelamiento. PostgreSQL funciona con estos archivos página por página, como con todos los demás.

Entonces, cuando se confirma una transacción, el bit "comprometido" se establece para esta transacción en XACT. Y esto es todo lo que sucede cuando se confirma la transacción (aunque todavía no mencionamos el registro de escritura anticipada).

Cuando alguna otra transacción accede a la página de la tabla que estábamos viendo, la primera tendrá que responder algunas preguntas.

  1. ¿ xmin completó la transacción xmin ? De lo contrario, la tupla creada no debe ser visible.
    Esto se verifica mirando a través de otra estructura, que se encuentra en la memoria compartida de la instancia y se llama ProcArray. Esta estructura contiene una lista de todos los procesos activos, junto con el ID de la transacción actual (activa) para cada uno.
  2. Si la transacción se completó, ¿se confirmó o se revertió? Si se hizo retroceder, la tupla tampoco debe ser visible.
    Esto es justo para lo que se necesita XACT. Pero es costoso verificar XACT cada vez, aunque las últimas páginas de XACT se almacenan en buffers en la memoria compartida. Por lo tanto, una vez descubierto, el estado de la transacción se escribe en los bits xmin_committed y xmin_aborted de la tupla. Si se establece alguno de estos bits, el estado de la transacción se trata como conocido y la próxima transacción no necesitará verificar XACT.

¿Por qué la transacción que realiza la inserción no establece estos bits? Cuando se realiza una inserción, la transacción aún desconoce si se completará con éxito. Y en el momento de la confirmación ya no está claro qué filas y en qué páginas se cambiaron. Puede haber muchas de esas páginas, y no es práctico hacer un seguimiento de ellas. Además, algunas de las páginas pueden ser expulsadas al disco de la memoria caché del búfer; leerlos nuevamente para cambiar los bits significaría una desaceleración significativa de la confirmación.

El reverso del ahorro de costos es que después de las actualizaciones, cualquier transacción (incluso la que realiza SELECT) puede comenzar a cambiar las páginas de datos en la memoria caché del búfer.

Entonces, cometemos el cambio.

 => COMMIT; 

Nada ha cambiado en la página (pero sabemos que el estado de las transacciones ya está escrito 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, una transacción que primero accede a la página necesitará determinar el estado de la transacción xmin y la escribirá 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) 

Eliminar


Cuando se elimina una fila, el ID de la transacción de eliminación actual se escribe en el campo xmax de la versión actualizada y se restablece el bit xmax_aborted .

Tenga en cuenta que el valor de xmax correspondiente a la transacción activa funciona como un bloqueo de fila. Si otra transacción va a actualizar o eliminar esta fila, tendrá que esperar hasta que se complete la transacción xmax . Hablaremos sobre las cerraduras con más detalle más adelante. En este punto, solo tenga en cuenta que el número de bloqueos de fila no está limitado en absoluto. No ocupan memoria, y el rendimiento del sistema no se ve afectado por ese número. Sin embargo, las transacciones duraderas tienen otros inconvenientes, que también se discutirán más adelante.

Borremos una fila.

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

Vemos que la ID de la transacción se escribe en el campo xmax , pero los bits de información no están establecidos:

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

Abortar


La cancelación de una transacción funciona de manera similar a la confirmación, excepto que el bit "abortado" se establece en XACT. Un aborto se realiza tan rápido como un commit. Aunque el comando se llama ROLLBACK, los cambios no se revierten: todo lo que la transacción ya ha cambiado, permanece intacto.

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

Al acceder a la página, se comprobará el estado y se establecerá el bit de sugerencia xmax_aborted . Aunque el número xmax sí seguirá en la página, no se 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) 

Actualización


Una actualización funciona como si la versión actual se elimina primero y luego se inserta una nueva.

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

La consulta devuelve una fila (la nueva versión):

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

Pero podemos ver ambas versiones en la página:

 => 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á etiquetada con el ID de la transacción actual en el campo xmax . Además, este valor ha sobrescrito el anterior desde que se revierte la transacción anterior. Y el bit xmax_aborted se restablece ya que el estado de la transacción actual aún se desconoce.

La primera versión de la fila ahora hace referencia a la segunda, como una más nueva.

La página de índice ahora contiene el segundo puntero y la segunda fila, que hace referencia a la segunda versión en la página de la tabla.

De la misma manera que para una eliminación, el valor de xmax en la primera versión indica que la fila está bloqueada.

Por último, comprometemos la transacción.

 => COMMIT; 

Índices


Hasta ahora solo hablábamos de páginas de tablas. ¿Pero qué pasa dentro de los índices?

La información en las páginas de índice depende en gran medida del tipo de índice específico. Además, incluso un tipo de índices puede tener diferentes tipos de páginas. Por ejemplo: un árbol B tiene la página de metadatos y las páginas "normales".

Sin embargo, una página de índice generalmente tiene una matriz de punteros a las filas y a las filas mismas (al igual que las páginas de la tabla). Además, se asigna algo de espacio al final de una página para datos especiales.

Las filas en los índices también pueden tener diferentes estructuras según el tipo de índice. Por ejemplo: en un árbol B, las filas pertinentes a las páginas de hoja contienen el valor de la clave de indexación y una referencia ( ctid ) a la fila de tabla adecuada. En general, un índice puede estructurarse de una manera bastante diferente.

El punto principal es que en los índices de cualquier tipo no hay versiones de fila. O podemos considerar que cada fila está representada por una sola versión. En otras palabras, el encabezado de la fila de índice no contiene los campos xmin y xmax . Por ahora podemos suponer que las referencias del índice apuntan a todas las versiones de las filas de la tabla. Entonces, para determinar cuáles de las versiones de fila son visibles para una transacción, PostgreSQL debe buscar en la tabla. (Como de costumbre, esta no es toda la historia. A veces, el mapa de visibilidad permite optimizar el proceso, pero lo discutiremos más adelante).

Aquí, en la página de índice, encontramos punteros a ambas versiones: la actualizada y la anterior:

 => 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 aprovecha una optimización que permite gastar "con moderación" los ID de transacción.

Si una transacción solo lee datos, no afecta la visibilidad de la tupla en absoluto. Por lo tanto, primero el proceso de backend asigna una ID virtual (xid virtual) a la transacción. Esta identificación consta del identificador de proceso y un número secuencial.

La asignación de esta ID virtual no requiere sincronización entre todos los procesos y, por lo tanto, se realiza muy rápidamente. Aprenderemos otra razón para usar ID virtuales cuando hablemos sobre la congelación.

Las instantáneas de datos no tienen en cuenta la identificación virtual en absoluto.

En diferentes momentos, el sistema puede tener transacciones virtuales con ID que ya se usaron, y esto está bien. Pero esta ID no se puede escribir en las páginas de datos, ya que cuando se accede a la página la próxima vez, la ID puede dejar de tener sentido.

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

Pero si una transacción comienza a cambiar datos, recibe una identificación de transacción verdadera y única.

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

 => COMMIT; 

Subtransacciones


Guardar puntos


En SQL, se definen los puntos de guardado , que permiten revertir algunas operaciones de la transacción sin su aborto completo. Pero esto es incompatible con el modelo anterior ya que el estado de la transacción es uno para todos los cambios y no se revierten físicamente los datos.

Para implementar esta funcionalidad, una transacción con un punto de rescate se divide en varias subtransacciones separadas cuyos estados se pueden administrar por separado.

Las subtransacciones tienen sus propios ID (mayores que el ID de la transacción principal). Los estados de las subtransacciones se escriben en XACT de la manera habitual, pero el estado final depende del estado de la transacción principal: si se revierte, todas las subtransacciones también se revierten.

La información sobre el anidamiento de subtransacciones se almacena en archivos del directorio PGDATA / pg_subtrans. Se accede a estos archivos mediante buffers en la memoria compartida de la instancia, que están estructurados de la misma manera que los buffers XACT.

No confunda las subtransacciones con transacciones autónomas. Las transacciones autónomas de ninguna manera dependen unas de otras, mientras que las subtransacciones sí dependen. No hay transacciones autónomas en el PostgreSQL regular, lo cual es, quizás, para mejor: en realidad se necesitan extremadamente raramente, y su disponibilidad en otros DBMS invita al abuso, que todos sufren.

Vamos a limpiar la tabla, comenzar una transacción e insertar una 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 establecemos un punto de guardado e insertamos otra fila.

 => 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 ID de la transacción principal en lugar de la subtransacción.

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

Retrocedamos al punto de guardado e insertemos la tercera fila.

 => ROLLBACK TO sp; => INSERT INTO t 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 que agregó la subtransacción revertida.

Cometer 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 se ve claramente que cada subtransacción tiene su propio estado.

Tenga en cuenta que SQL no permite el uso explícito de subtransacciones, es decir, no puede iniciar una nueva transacción antes de completar la actual. Esta técnica se involucra implícitamente cuando se usan puntos de guardado y también cuando se manejan excepciones PL / pgSQL, así como en otras situaciones más exóticas.

 => 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 operación


¿Qué sucede si se produce un error mientras se realiza la 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 produjo un error Ahora la transacción se trata como 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 tratamos de confirmar los cambios, PostgreSQL informará la reversión:

 => COMMIT; 
 ROLLBACK 

¿Por qué es imposible continuar la ejecución de la transacción después de una falla? La cuestión es que el error podría ocurrir para que tengamos acceso a parte de los cambios, es decir, la atomicidad se rompería no solo para la transacción, sino incluso para un solo operador. Por ejemplo, en nuestro ejemplo, el operador podría haber actualizado una fila antes de que ocurriera el 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) 

Vale la pena señalar que psql tiene un modo que permite continuar la transacción después de la falla, como si los efectos del operador erróneo fueran revertidos.

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

Es fácil darse cuenta de que en este modo, psql en realidad establece un punto de guardado implícito antes de cada comando e inicia un retroceso en caso de falla. Este modo no se usa de manera predeterminada, ya que establecer puntos de guardado (incluso sin una reversión a ellos) conlleva una sobrecarga significativa.

Sigue leyendo .

Source: https://habr.com/ru/post/477648/


All Articles