MVCC en PostgreSQL-2. Tenedores, archivos, páginas.

La última vez que hablamos sobre la consistencia de los datos, observamos la diferencia entre los niveles de aislamiento de transacciones desde el punto de vista del usuario y descubrimos por qué es importante saberlo. Ahora estamos comenzando a explorar cómo PostgreSQL implementa el aislamiento de instantáneas y la concurrencia multiversion.

En este artículo, veremos cómo se disponen físicamente los datos en archivos y páginas. Esto nos aleja de discutir el aislamiento, pero tal digresión es necesaria para entender lo que sigue. Tendremos que descubrir cómo se organiza el almacenamiento de datos en un nivel bajo.

Relaciones


Si observa las tablas e índices, resulta que están organizados de manera similar. Ambos son objetos de base de datos que contienen algunos datos que consisten en filas.

No hay duda de que una tabla consta de filas, pero esto es menos obvio para un índice. Sin embargo, imagine un árbol B: consta de nodos que contienen valores indexados y referencias a otros nodos o filas de tabla. Son estos nodos los que pueden considerarse filas de índice y, de hecho, lo son.

En realidad, algunos objetos más se organizan de manera similar: secuencias (esencialmente tablas de una sola fila) y vistas materializadas (esencialmente, tablas que recuerdan la consulta). Y también hay vistas regulares, que no almacenan datos por sí mismas, pero en todos los demás sentidos son similares a las tablas.

Todos estos objetos en PostgreSQL se denominan la relación de palabra común. Esta palabra es extremadamente impropia porque es un término de la teoría relacional. Puede dibujar un paralelo entre una relación y una tabla (vista), pero ciertamente no entre una relación y un índice. Pero sucedió: el origen académico de PostgreSQL se manifiesta. Me parece que primero se llamaron las tablas y las vistas, y el resto aumentó con el tiempo.

Para ser más simple, discutiremos más las tablas e índices, pero las otras relaciones se organizan exactamente de la misma manera.

Tenedores y limas


Por lo general, varias horquillas corresponden a cada relación. Las bifurcaciones pueden tener varios tipos, y cada uno de ellos contiene un cierto tipo de datos.

Si hay una bifurcación, primero se representa con el único archivo . El nombre de archivo es un identificador numérico, que se puede agregar con un final que corresponde al nombre de la bifurcación.

El archivo crece gradualmente y cuando su tamaño alcanza 1 GB, se crea un nuevo archivo de la misma bifurcación (los archivos como estos a veces se denominan segmentos ). El número ordinal del segmento se agrega al final del nombre del archivo.

La limitación de 1 GB del tamaño del archivo surgió históricamente para admitir diferentes sistemas de archivos, algunos de los cuales no pueden manejar archivos de mayor tamaño. Puede cambiar esta limitación al ./configure --with-segsize PostgreSQL ( ./configure --with-segsize ).

Por lo tanto, varios archivos en el disco pueden corresponder a una relación. Por ejemplo, para una mesa pequeña habrá tres de ellos.

Todos los archivos de objetos que pertenecen a un espacio de tabla y una base de datos se almacenarán en un directorio. Debe tener esto en cuenta ya que los sistemas de archivos generalmente no funcionan bien con una gran cantidad de archivos en un directorio.

Tenga en cuenta que los archivos, a su vez, se dividen en páginas (o bloques ), generalmente por 8 KB. Discutiremos la estructura interna de las páginas un poco más.



Ahora veamos los tipos de horquillas.

La bifurcación principal son los datos en sí: las filas de la tabla y el índice. La bifurcación principal está disponible para cualquier relación (excepto las vistas que no contienen datos).

Los nombres de los archivos de la bifurcación principal consisten en el único identificador numérico. Por ejemplo, esta es la ruta a la tabla que creamos la última vez:

 => SELECT pg_relation_filepath('accounts'); 
  pg_relation_filepath ---------------------- base/41493/41496 (1 row) 

¿De dónde surgen estos identificadores? El directorio "base" corresponde al espacio de tabla "pg_default". El siguiente subdirectorio, correspondiente a la base de datos, es donde se encuentra el archivo de interés:

 => SELECT oid FROM pg_database WHERE datname = 'test'; 
  oid ------- 41493 (1 row) 

 => SELECT relfilenode FROM pg_class WHERE relname = 'accounts'; 
  relfilenode ------------- 41496 (1 row) 

La ruta es relativa, se especifica a partir del directorio de datos (PGDATA). Además, prácticamente todas las rutas en PostgreSQL se especifican a partir de PGDATA. Gracias a esto, puede mover PGDATA de forma segura a una ubicación diferente; nada lo limita (excepto que puede ser necesario establecer la ruta a las bibliotecas en LD_LIBRARY_PATH).

Además, buscando en el sistema de archivos:

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41496 
 -rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41496 

La bifurcación de inicialización solo está disponible para tablas no registradas (creadas con UNLOGGED especificado) y sus índices. Los objetos como estos no son diferentes de los objetos normales, excepto que las operaciones con ellos no se registran en el registro de escritura anticipada (WAL). Debido a esto, es más rápido trabajar con ellos, pero es imposible recuperar los datos en el estado consistente en caso de falla. Por lo tanto, durante una recuperación, PostgreSQL simplemente elimina todas las bifurcaciones de dichos objetos y escribe la bifurcación de inicialización en lugar de la bifurcación principal. Esto da como resultado un objeto vacío. Discutiremos el registro en detalle, pero en otra serie.

La tabla "cuentas" se registra y, por lo tanto, no tiene una bifurcación de inicialización. Pero para experimentar, podemos desactivar el cierre de sesión:

 => ALTER TABLE accounts SET UNLOGGED; => SELECT pg_relation_filepath('accounts'); 
  pg_relation_filepath ---------------------- base/41493/41507 (1 row) 

El ejemplo aclara que la posibilidad de activar y desactivar el inicio de sesión sobre la marcha está asociada con la reescritura de los datos en archivos con diferentes nombres.

Una bifurcación de inicialización tiene el mismo nombre que la bifurcación principal, pero con el sufijo "_init":

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_init 
 -rw------- 1 postgres postgres 0 /var/lib/postgresql/11/main/base/41493/41507_init 

El mapa de espacio libre es una bifurcación que realiza un seguimiento de la disponibilidad de espacio libre dentro de las páginas. Este espacio cambia constantemente: disminuye cuando se agregan nuevas versiones de filas y aumenta durante la aspiración. El mapa de espacio libre se usa durante la inserción de nuevas versiones de fila para encontrar rápidamente una página adecuada, donde se ajusten los datos que se agregarán.

El nombre del mapa de espacio libre tiene el sufijo "_fsm". Pero este archivo aparece no inmediatamente, sino solo cuando surge la necesidad. La forma más fácil de lograr esto es aspirar una mesa (explicaremos por qué cuando llegue el momento):

 => VACUUM accounts; 

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_fsm 
 -rw------- 1 postgres postgres 24576 /var/lib/postgresql/11/main/base/41493/41507_fsm 

El mapa de visibilidad es una bifurcación donde las páginas que solo contienen versiones actualizadas de filas están marcadas por un bit. Aproximadamente, significa que cuando una transacción intenta leer una fila de dicha página, la fila se puede mostrar sin verificar su visibilidad. En los próximos artículos, discutiremos en detalle cómo sucede esto.

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_vm 
 -rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41507_vm 

Páginas


Como ya se mencionó, los archivos se dividen lógicamente en páginas.

Una página generalmente tiene el tamaño de 8 KB. El tamaño se puede cambiar dentro de ciertos límites (16 KB o 32 KB), pero solo durante la compilación ( ./configure --with-blocksize ). Una instancia construida y ejecutada solo puede funcionar con páginas del mismo tamaño.

Independientemente de la bifurcación a la que pertenecen los archivos, el servidor los usa de una manera bastante similar. Las páginas se leen primero en la memoria caché del búfer, donde los procesos pueden leerlas y cambiarlas; luego, cuando surge la necesidad, son expulsados ​​de nuevo al disco.

Cada página tiene particiones internas y, en general, contiene las siguientes particiones:

        0 + ----------------------------------- +
           El |  cabecera |
       24 + ----------------------------------- +
           El |  matriz de punteros a versiones de fila |
    inferior + ----------------------------------- +
           El |  espacio libre |
    superior + ----------------------------------- +
           El |  versiones de fila |
  especial + ----------------------------------- +
           El |  espacio especial |
 tamaño de página + ----------------------------------- +

Puede conocer fácilmente los tamaños de estas particiones utilizando la página de extensión "investigación" inspeccionar:

 => CREATE EXTENSION pageinspect; => SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts',0)); 
  lower | upper | special | pagesize -------+-------+---------+---------- 40 | 8016 | 8192 | 8192 (1 row) 

Aquí estamos mirando el encabezado de la primera página (cero) de la tabla. Además de los tamaños de otras áreas, el encabezado tiene información diferente sobre la página, que aún no nos interesa.

En la parte inferior de la página hay un espacio especial , que en este caso está vacío. Solo se usa para índices, e incluso no para todos. "En la parte inferior" aquí refleja lo que está en la imagen; puede ser más exacto decir "en direcciones altas".

Después del espacio especial, se ubican las versiones de fila , es decir, esos mismos datos que almacenamos en la tabla más información interna.

En la parte superior de una página, justo después del encabezado, está la tabla de contenido: la matriz de punteros a las versiones de fila disponibles en la página.

Se puede dejar espacio libre entre las versiones de fila y los punteros (este espacio libre se registra en el mapa de espacio libre). Tenga en cuenta que no hay fragmentación de memoria dentro de una página: todo el espacio libre está representado por un área contigua.

Punteros


¿Por qué se necesitan los punteros a las versiones de fila? La cuestión es que las filas de índice deben de alguna manera hacer referencia a las versiones de fila en la tabla. Está claro que la referencia debe contener el número de archivo, el número de la página en el archivo y alguna indicación de la versión de la fila. Podríamos usar el desplazamiento desde el comienzo de la página como indicador, pero es inconveniente. No podríamos mover una versión de fila dentro de la página, ya que rompería las referencias disponibles. Y esto resultaría en la fragmentación del espacio dentro de las páginas y otras consecuencias problemáticas. Por lo tanto, el índice hace referencia al número de puntero y el puntero hace referencia a la ubicación actual de la versión de fila en la página. Y este es un direccionamiento indirecto.

Cada puntero ocupa exactamente cuatro bytes y contiene:

  • una referencia a la versión de fila
  • el tamaño de esta versión de fila
  • varios bytes para determinar el estado de la versión de la fila

Formato de datos


El formato de datos en el disco es exactamente el mismo que el de la representación de datos en la RAM. La página se lee en la memoria caché del búfer "tal cual", sin ninguna conversión. Por lo tanto, los archivos de datos de una plataforma resultan incompatibles con otras plataformas.

Por ejemplo, en la arquitectura X86, el orden de bytes es de bytes menos significativos a bytes más significativos (little-endian), z / Architecture utiliza el orden inverso (big-endian), y en ARM el orden puede intercambiarse.

Muchas arquitecturas proporcionan la alineación de datos en los límites de las palabras de máquina. Por ejemplo, en un sistema x86 de 32 bits, los números enteros (tipo "entero", que ocupa 4 bytes) se alinearán en un límite de palabras de 4 bytes, de la misma manera que los números de precisión doble (tipo "precisión doble" , que ocupa 8 bytes). Y en un sistema de 64 bits, los números de doble precisión se alinearán en un límite de palabras de 8 bytes. Esta es una razón más de incompatibilidad.

Debido a la alineación, el tamaño de la fila de la tabla depende del orden del campo. Por lo general, este efecto no es muy notable, pero a veces, puede dar lugar a un crecimiento significativo del tamaño. Por ejemplo, si los campos de los tipos “char (1)” y “integer” están intercalados, generalmente se desperdician 3 bytes entre ellos. Para obtener más detalles sobre esto, puede consultar la presentación de Nikolay Shaplov " Tuple internos ".

Versiones de fila y TOSTADA


Discutiremos los detalles de la estructura interna de las versiones de fila la próxima vez. En este punto, solo es importante para nosotros saber que cada versión debe caber completamente en una página: PostgreSQL no tiene forma de "extender" la fila a la página siguiente. En su lugar, se utiliza la técnica de almacenamiento de atributos de gran tamaño (TOAST). El nombre en sí sugiere que una fila se puede cortar en tostadas.

Bromas aparte, TOAST implica varias estrategias. Podemos transmitir valores de atributos largos a una tabla interna separada después de dividirlos en pequeños trozos de pan tostado. Otra opción es comprimir un valor para que la versión de la fila se ajuste a una página normal. Y podemos hacer ambas cosas: primero comprimir y luego romper y transmitir.

Para cada tabla primaria, se puede crear una tabla TOAST separada si es necesario, una para todos los atributos (junto con un índice). La disponibilidad de atributos potencialmente largos determina esta necesidad. Por ejemplo, si una tabla tiene una columna de tipo "numérico" o "texto", la tabla TOAST se creará inmediatamente incluso si no se utilizarán valores largos.

Dado que una tabla TOAST es esencialmente una tabla normal, tiene el mismo conjunto de horquillas. Y esto duplica el número de archivos que corresponden a una tabla.

Las estrategias iniciales están definidas por los tipos de datos de columna. Puede verlos usando el comando \d+ en psql, pero dado que además genera mucha otra información, consultaremos el catálogo del sistema:

 => SELECT attname, atttypid::regtype, CASE attstorage WHEN 'p' THEN 'plain' WHEN 'e' THEN 'external' WHEN 'm' THEN 'main' WHEN 'x' THEN 'extended' END AS storage FROM pg_attribute WHERE attrelid = 'accounts'::regclass AND attnum > 0; 
  attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | extended client | text | extended amount | numeric | main (4 rows) 

Los nombres de las estrategias significan:

  • plain: TOAST no se usa (se usa para tipos de datos que se sabe que son cortos, como "entero").
  • extendido: tanto la compresión como el almacenamiento en una tabla TOAST separada están permitidos
  • externo: los valores largos se almacenan en la tabla TOAST sin compresión.
  • main: los valores largos se comprimen primero y solo entran en la tabla TOAST si la compresión no ayudó.

En general, el algoritmo es el siguiente. PostgreSQL tiene como objetivo tener al menos cuatro filas en una página. Por lo tanto, si el tamaño de la fila excede un cuarto de la página, se debe tener en cuenta el encabezado (2040 bytes para una página 8K normal), TOAST debe aplicarse a una parte de los valores. Seguimos el orden que se describe a continuación y nos detenemos en cuanto la fila ya no supera el umbral:

  1. Primero revisamos los atributos con las estrategias "externas" y "extendidas" desde el atributo más largo hasta el más corto. Los atributos "extendidos" se comprimen (si es efectivo) y si el valor en sí excede un cuarto de la página, inmediatamente entra en la tabla TOAST. Los atributos "externos" se procesan de la misma manera, pero no se comprimen.
  2. Si después de la primera pasada, la versión de la fila aún no se ajusta a la página, transmitimos los atributos restantes con las estrategias "externas" y "extendidas" a la tabla TOAST.
  3. Si esto tampoco ayudó, intentamos comprimir los atributos con la estrategia "principal", pero los dejamos en la página de la tabla.
  4. Y solo si después de eso, la fila no es lo suficientemente corta, los atributos "principales" entran en la tabla TOAST.

A veces puede ser útil cambiar la estrategia para ciertas columnas. Por ejemplo, si se sabe de antemano que los datos en una columna no se pueden comprimir, podemos establecer la estrategia "externa" para ello, lo que nos permite ahorrar tiempo al evitar intentos de compresión inútiles. Esto se hace de la siguiente manera:

 => ALTER TABLE accounts ALTER COLUMN number SET STORAGE external; 

Al volver a ejecutar la consulta, obtenemos:

  attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | external client | text | extended amount | numeric | main 

Las tablas e índices TOAST se encuentran en el esquema pg_toast separado y, por lo tanto, generalmente no son visibles. Para las tablas temporales, el esquema "pg_toast_temp_ N " se usa de manera similar al "pg_temp_ N " habitual.

Por supuesto, si lo desea, nadie le impedirá espiar la mecánica interna del proceso. Digamos que en la tabla "cuentas" hay tres atributos potencialmente largos y, por lo tanto, debe haber una tabla TOAST. Aquí esta:

 => SELECT relnamespace::regnamespace, relname FROM pg_class WHERE oid = ( SELECT reltoastrelid FROM pg_class WHERE relname = 'accounts' ); 
  relnamespace | relname --------------+---------------- pg_toast | pg_toast_33953 (1 row) 

 => \d+ pg_toast.pg_toast_33953 
 TOAST table "pg_toast.pg_toast_33953" Column | Type | Storage ------------+---------+--------- chunk_id | oid | plain chunk_seq | integer | plain chunk_data | bytea | plain 

Es razonable que la estrategia "simple" se aplique a las tostadas en las que se corta la fila: no hay una TOSTADA de segundo nivel.

PostgreSQL oculta mejor el índice, pero tampoco es difícil de encontrar:

 => SELECT indexrelid::regclass FROM pg_index WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'pg_toast_33953' ); 
  indexrelid ------------------------------- pg_toast.pg_toast_33953_index (1 row) 

 => \d pg_toast.pg_toast_33953_index 
 Unlogged index "pg_toast.pg_toast_33953_index" Column | Type | Key? | Definition -----------+---------+------+------------ chunk_id | oid | yes | chunk_id chunk_seq | integer | yes | chunk_seq primary key, btree, for table "pg_toast.pg_toast_33953" 

La columna "cliente" utiliza la estrategia "extendida": sus valores se comprimirán. Vamos a ver:

 => UPDATE accounts SET client = repeat('A',3000) WHERE id = 1; => SELECT * FROM pg_toast.pg_toast_33953; 
  chunk_id | chunk_seq | chunk_data ----------+-----------+------------ (0 rows) 

No hay nada en la tabla TOAST: los caracteres que se repiten se comprimen bien y, después de la compresión, el valor se ajusta a una página de tabla habitual.

Y ahora deje que el nombre del cliente consista en caracteres aleatorios:

 => UPDATE accounts SET client = ( SELECT string_agg( chr(trunc(65+random()*26)::integer), '') FROM generate_series(1,3000) ) WHERE id = 1 RETURNING left(client,10) || '...' || right(client,10); 
  ?column? ------------------------- TCKGKZZSLI...RHQIOLWRRX (1 row) 

Dicha secuencia no se puede comprimir y entra en la tabla TOAST:

 => SELECT chunk_id, chunk_seq, length(chunk_data), left(encode(chunk_data,'escape')::text, 10) || '...' || right(encode(chunk_data,'escape')::text, 10) FROM pg_toast.pg_toast_33953; 
  chunk_id | chunk_seq | length | ?column? ----------+-----------+--------+------------------------- 34000 | 0 | 2000 | TCKGKZZSLI...ZIPFLOXDIW 34000 | 1 | 1000 | DDXNNBQQYH...RHQIOLWRRX (2 rows) 

Podemos ver que los datos se dividen en fragmentos de 2000 bytes.

Cuando se accede a un valor largo, PostgreSQL automáticamente y de forma transparente para la aplicación restaura el valor original y lo devuelve al cliente.

Ciertamente, es bastante intensivo en recursos comprimir y romper y luego restaurar. Por lo tanto, almacenar datos masivos en PostgreSQL no es la mejor idea, especialmente si se usan con frecuencia y el uso no requiere lógica transaccional (por ejemplo: escaneos de documentos contables originales). Una alternativa más beneficiosa es almacenar dichos datos en un sistema de archivos con los nombres de archivo almacenados en el DBMS.

La tabla TOAST solo se usa para acceder a un valor largo. Además, su propia concurrencia de mutiversion es compatible con una tabla TOAST: a menos que una actualización de datos toque un valor largo, una nueva versión de fila hará referencia al mismo valor en la tabla TOAST, y esto ahorra espacio.

Tenga en cuenta que TOAST solo funciona para tablas, pero no para índices. Esto impone una limitación en el tamaño de las claves a indexar.
Para obtener más detalles sobre la estructura de datos interna, puede leer la documentación .

Sigue leyendo .

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


All Articles