PG Phriday: 10 cosas que Postgres podría mejorar - Parte 3

November 08, 2020

A la base del blog "10 cosas que odio de PostgreSQL", se encuentra una observación específica que a primera vista podría interpretarse como una simple molestia. MVCC aparece sólo como el número 4 de la lista, pero al igual que los XID, actúa como una estrella en la que convergen toda una constelación de objetos estelares relacionados. Mientras que la replicación (tanto física como lógica) es una parte importante del futuro de Postgres, MVCC es en gran medida su pasado y su presente, pese a algunos contratiempos a lo largo del camino.

En este post iremos directamente al corazón pulsante de Postgres para analizar detenidamente cómo maneja el almacenamiento. Este artículo no pretende ser un tratado exhaustivo sobre MVCC; existen mejores lugares para obtener esa información. Pero, lo que sabemos sobre Postgres y el almacenamiento de datos en general, nos permite identificar posibles áreas problemáticas.

Y quién sabe, puede que incluso nos quedemos agradablemente sorprendidos.

Una baraja apilada de forma perpetua

Imagínense una baraja, cuyas cartas, ordenadas según un criterio específico, deben permanecer perpetuamente en esa configuración. Si alguien toma una carta, tiene que volver a colocarla exactamente donde se encontraba. Tras unos cuantos de estos cambios, la carta se vuelve inservible y debe ser reemplazada por una nueva del mismo palo y cara.

Definitivamente no es así como funciona MVCC en Postgres. En lugar de reemplazar la carta de inmediato, simplemente colocamos una nueva en la parte superior de la baraja y dibujamos una gran X sobre la vieja carta para que todos sepan que deben ignorarla. Tal vez con el tiempo añadamos una nueva carta, y en ese momento reemplazaremos de forma aleatoria una carta previamente “cancelada”.

Sin duda, este es un enfoque extremadamente eficiente en muchos sentidos. La carta existente sigue ahí, en caso de que la necesitemos. Puede que, al final, la nueva carta contenga algún error de impresión. Prácticamente, la carta vieja será visible para cualquiera hasta que la tachemos con la X. ¿Existe algún aspecto negativo en este método?

Cambios continuos

Estos constantes cambios presentan dos efectos colaterales:

  1. La cantidad de cartas “extra” en la baraja se ve limitada únicamente por la frecuencia con la que se reemplazan las cartas tachadas.
  2. Se pierde el orden deseado de las cartas en el momento en que se descarta la primera.

Postgres soluciona el primer problema con el comando VACUUM y con el correspondiente servicio autovacuum que funciona en segundo plano. La configuración por defecto añade esencialmente alrededor de un 20% de espacio extra, el cual representa un punto de equilibrio estable entre la cantidad de datos entrantes y salientes.

Es este cambio el que puede eventualmente transformar nuestra baraja, previamente mezclada, en un resultado aleatorio muy poco parecido al original. También es la razón por la que algunos usuarios expresan su frustración con respecto al almacenamiento de datos en Postgres.

Índice como Heap

Sin embargo, esta clase de barajada no es algo muy común. Aunque los usuarios de Postgres lo den por sentado, no todos los motores de bases de datos implementan una separación tan fuerte entre los roles del heap y del índice. Claro, Postgres ofrece la posibilidad de escanear únicamente los índices, y permite trabajar con ellos utilizando la sintaxis INCLUDE (). De todas formas, el heap y el índice siempre están separados. Y aunque en determinadas ocasiones podemos evitar recurrir al heap, pero es imprescindible que exista.

10 cosas que odio de PostgreSQL” básicamente lo describió como un desperdicio de espacio. En realidad, la queja iba mucho más allá. Como hemos señalado anteriormente, en el mejor de los casos el orden del heap es poco fiable. Por consiguiente, usar el comando CLUSTER para ordenar el contenido de una tabla en función de un índice específico resulta una medida temporal, a menos que la tabla sea enteramente estática.

Suponiendo que no existan cláusulas de ordenamiento, obtener filas del heap resulta ser la acción más sencilla, ya que puede hacerse de forma secuencial. En el caso de que el índice es el heap, y en ausencia de parámetros de ordenamiento, no tenemos que seguir las referencias de los nodos índice. Los resultados ordenados requieren el uso del índice clustered, uno de los índices secundarios, o del ordenamiento en memoria. Así que todas nuestras operaciones habituales funcionan de la manera esperada.

Cuando no existe una restricción de unicidad, las bases de datos que utilizan este enfoque imponen un tipo de atributo de unicidad permanente vinculado al identificador de fila. Aunque no pueden utilizarse para las claves foráneas, constituyen un perfecto sustituto hasta disponer de un índice único más adecuado. En este caso, la tabla actuaría de forma casi idéntica al heap de Postgres.

¿Existen inconvenientes con este tipo de enfoque? Se podría discutir sobre el uso del heap como copia de datos “prístina” (sin la influencia de algoritmos interpretativos de agrupación y paginación) para la creación de otros objetos. Con el índice fuera del heap, es posible corregir casos aislados de corrupción de los datos mediante una simple reconstrucción del índice. Por otra parte, también puede producirse ese tipo de corrupción en el heap.

Quizás esto sea simplemente un anacronismo que ha sido incorporado al motor en términos de impulso. Desde la introducción del almacenamiento de tabla enchufable en Postgres 12, existe el potencial para corregir por lo menos este descuido sin tener que recurrir enteramente a Zheap.

Compresión en línea

Contrariamente a la creencia popular, el almacenamiento TOAST en Postgres no consiste en “almacenar datos comprimibles en una ubicación externa”. De hecho, existen varios umbrales y mecanismos configurables para manejar la compresión. En este caso, nos remitiremos a la documentación de TOAST para explicar por qué este aspecto es relevante.

Cabe destacar la función del tipo de almacenamiento de tupla denominado MAIN , el cual intentará comprimir el valor de la columna y almacenarlo en línea con el resto de los datos de la tupla presentes en el heap. Si esta compresión excede el tamaño de la página de Postgres (8 KB), el contenido de la columna será almacenado externamente. En el caso de los objetos trivialmente comprimibles, el almacenamiento MAIN puede evitar costosas búsquedas externas en la tabla TOAST.

De todos modos, es cierto que Postgres no admite lo que algunos denominan compresión “a nivel de bloque”. Esto significa que no existe un algoritmo que pueda contraer valores idénticos compartidos entre las tuplas convirtiéndolos en referencias dentro de una página. Para las tablas que contienen muchos valores repetidos como fechas, referencias de claves foráneas, etc., esto puede resultar en un considerable ahorro de espacio.

En cierto sentido, esta es la consecuencia lógica de que Postgres dependa de un almacenamiento heap natural y no interpretado como capa de datos básicos. El heap no es más que una serie de registros de tupla que caben en una página de 8 KB, excepto por las limitaciones del factor de relleno. Una vez que inyectamos efectos de compresión entre tuplas, se convierte más en un tipo de almacenamiento de índices. Por otra parte, siempre y cuando restrinjamos la compresión a una sola página, cada página leída podría pasar a través de la capa transparente de descompresión en línea antes de que el resto del motor interactúe con ella.

En este caso, el principal inconveniente es que, esencialmente se alteraría el formato de almacenamiento de Postgres de tal manera que la actualización requeriría un volcado/recuperación entre versiones. Como alternativa, puesto que los archivos comprimidos serían fácilmente distinguibles, las tablas podrían simplemente marcarse como UNCOMPRESSED hasta su posterior modificación con algún tipo de declaración ALTER TABLE.

En casos como este, puede que se trate simplemente de la falta de demanda para esa característica. Por lo tanto, la respuesta de la comunidad suele ser: “bienvenidas las revisiones”.

VACUUM como ‘recolector de basura’

Este tema nos lleva a la necesidad de implementar de forma efectiva la integridad del heap. A primera vista, el concepto de VACUUM en sí mismo es muy sencillo: consiste en marcar las filas obsoletas como espacio reutilizable. Sin embargo, las complejas operaciones que realiza, los controles que regulan su comportamiento, y los detalles correspondientes, son suficientes para desconcertar incluso a los profesionales más experimentados.

Considere la necesaria tarea de congelar las filas. El ID de transacción de las tuplas visibles que contengan una referencia XID obsoleta, terminará siendo negado por un atributo de encabezamiento de fila que marcará la fila como congelada. Esto permite el movimiento continuo del horizonte XID a 32 bits de Postgres que esencialmente elimina los ID de transacción que ya no son relevantes para la visibilidad de la sesión corriente.

Pero FREEZE y VACUUM, aunque se relacionan de forma tangencial debido a la inherente manipulación de la visibilidad de las filas, no son en absoluto la misma cosa. Pese a esto, no existe un comando FREEZE independiente. Por lo tanto, esta crítica tarea de mantenimiento se encuentra detrás de una especie de paywall, ya que no puede desvincularse del proceso de recolección de las tuplas muertas.

Entonces, ¿cómo configuramos los diversos controles de vacuum para asegurarnos, entre otras cosas, de que el proceso de congelamiento se produzca a tiempo? Ese tema por sí solo puede ser (y de hecho ha sido) el tema de varios posts de blog, artículos y webinars. Managing Freezing in PostgreSQL de Andrew Dunstan constituye un buen resumen, al igual que el webinar Tuple Freezing & Transaction Wrap around Through Pictures co-presentado por Andrew Dunstan (de nuevo) y Tom Kincaid. Hay también Autovacuum Tuning Basics, otro post de Tomas Vondra que analiza más a fondo cómo establecer parámetros de configuración específicos.

La gran cantidad de información disponible es en realidad parte de la espada de doble filo. Es un tema profundo y variado que debe ser entendido con gran exactitud, para evitar que alguna mala interpretación conduzca a una sobrecarga de IO en el disco o a una limpieza irregular. Las configuraciones inadecuadas pueden llevar a un sobreconsumo de espacio en el disco o incluso al riesgo de un XID wraparound. Por otra parte, configuraciones demasiado ambiciosas pueden ocasionar una sobrecarga perjudicial en el almacenamiento.

En definitiva, un usuario de Oracle (por ejemplo) siempre considerará esto como un defecto de diseño. Cualquier tipo de mantenimiento que puede aplazarse conlleva el riesgo de problemas de planificación y consideraciones sobre el rendimiento. Además, es necesario encontrar un prudente equilibrio para prevenir los efectos intrínsecos de la sobrecarga y mantener las estructuras funcionales. Con ese fin, existe la opinión de que una estructura de datos que no requiera este tipo de mantenimiento constante, en algunos casos de uso resulte superior.

En realidad, Postgres implementa varias optimizaciones que facilitan el mantenimiento. Si se congelan todas las tuplas de una página, la misma quedará totalmente marcada. Esto puede acelerar enormemente las operaciones de congelamiento de la tabla. Combinado con las habituales invocaciones por medio de autovacuum,  en la mayoría de los sistemas la sobrecarga resultaría casi indistinguible del ruido de fondo.

Las complejidades del almacenamiento

En muchos sentidos, esta es una discusión filosófica. La decisión inicial de desvincular el heap y los incrementos externos (como los índices) puede haber comenzado como una limitación técnica. Sin embargo, otros motores de bases de datos han demostrado claramente que esta distinción no es necesaria y que puede carecer de muchos beneficios implícitos. De hecho, una mejora que aún no hemos mencionado es que una capa integrada de índice/heap reduciría drásticamente el tráfico del WAL.

En el enfoque de Postgres respecto a MVCC incide el costo de mantenimiento. Sin embargo, a diferencia de los segmentos de reversión que son commit-optimistic, las tuplas de Postgres son transaction agnostic en reposo. Todo lo que Postgres en todo momento necesita saber es si una transacción está comprometida (committed) o no. Los motores de bases de datos que dependen de mecanismos externos de reversión deben volver a colocar físicamente las filas revertidas en el almacenamiento de tabla para que puedan ser utilizadas. Esto hace que, en comparación, la recuperación tras una caída sea extremadamente costosa y lenta.

Sin embargo, sería genial poder contar con la compresión de bloques en línea. Asumiendo que el motor interactúe exclusivamente con la página descomprimida, únicamente las operaciones de lectura y escritura tendrían que preocuparse de las llamadas de compresión. Entre los diferentes temas que hemos abordado, esta es probablemente una de las modificaciones más fáciles de realizar. Definitivamente es mucho más sencillo que reemplazar el sistema de almacenamiento heap por un índice-heap híbrido.

Aún así, estas son las razones por las que cualquier DBA de Postgres debería familiarizarse con la implementación de MVCC. Determinan qué tipo de mantenimiento es necesario y por qué lo es. Nos explican exactamente cómo Postgres interactúa con los datos que almacena. Basándonos en estas razones, podemos entender las limitaciones y los puntos de fuerza implícitos. Es poco probable que detalles esotéricos como este sean elementos clave absolutamente decisivos.

A pesar de que Postgres haya aprovechado considerablemente sus fortalezas, todavía hay margen para mejorar. En última instancia, sólo el tiempo dirá en qué dirección irán las cosas.

Share this

More Blogs