Un estudio de las actualizaciones HOT en tablas pequeñas de PostgreSQL. Estrategias de fillfactor y particionamiento hash.

Comparación del rendimiento y el uso de búferes en una tabla con actualizaciones frecuentes que cabe en shared_buffers.

- 9 mins read

¿Puede mejorar el rendimiento de una tabla pequeña de PostgreSQL con actualizaciones frecuentes si reservamos más espacio para esas actualizaciones? Comparé el esquema predeterminado, un fillfactor menor y el particionamiento hash en una tabla de aproximadamente 500 MB. La carga se centró en actualizaciones, con especial interés en las actualizaciones HOT (heap-only tuples).

Mi expectativa inicial era que dejar más espacio en cada página del heap favorecería las actualizaciones HOT y aumentaría la tasa de operaciones. Sin embargo, en la ejecución registrada, establecer el fillfactor en 50 tanto en la tabla como en su índice produjo un menor rendimiento y un mayor uso de búferes que el esquema predeterminado. El particionamiento hash mostró un comportamiento muy similar al predeterminado.

Entorno de prueba y alcance: Realicé estas pruebas localmente utilizando contenedores Docker. Los resultados son exploratorios y corresponden a ese entorno; no constituyen un benchmark definitivo para producción. Los recursos compartidos del equipo anfitrión, los límites de los contenedores y la configuración del almacenamiento pueden influir en las mediciones. Puede ser necesario repetir las pruebas en un entorno aislado antes de extraer una conclusión definitiva para esta carga de trabajo.

Por qué importan las actualizaciones HOT y el fillfactor

Una actualización crea una nueva versión de la fila. PostgreSQL puede utilizar HOT cuando esa versión cabe en la misma página del heap y la actualización no modifica columnas referenciadas por índices que no sean de resumen. HOT evita crear nuevas entradas en esos índices y permite recuperar el espacio de algunas versiones obsoletas durante el acceso habitual a las páginas. Consulta la documentación de PostgreSQL sobre HOT.

Un fillfactor de tabla menor reserva más espacio en las páginas del heap al llenarlas, lo que aumenta las oportunidades de actualizar filas dentro de la misma página. Esto intercambia densidad de almacenamiento por espacio para actualizaciones: las mismas filas iniciales pueden ocupar más páginas. Consulta los parámetros de almacenamiento de tablas.

El fillfactor de la tabla y el del índice son parámetros independientes. Reservar espacio en un índice no crea espacio para actualizaciones HOT en el heap. Como este experimento redujo ambos valores a la vez, no permite aislar el efecto del fillfactor de la tabla.

Qué comparé

VarianteConfiguraciónEtiqueta original de la serie
PredeterminadaEsquema original sin particionar, con los valores predeterminados de fillfactorori
Particionamiento hashVersión de la tabla particionada por hashby_hash
Fillfactor 50Fillfactor de la tabla y del índice establecido en 50onlyff

Las notas originales del benchmark indican PostgreSQL 18.2. Aquí no se incluyen la imagen exacta de Docker, el esquema, el número de particiones, el script de carga, la concurrencia, las especificaciones del equipo ni la configuración de PostgreSQL; estos detalles son necesarios para reproducir la comparación. También conviene contrastar la versión con los metadatos originales de la ejecución antes de publicar.

Los gráficos abarcan aproximadamente catorce minutos e incluyen un período de arranque. La tasa de operaciones continúa aumentando durante el registro, por lo que no se trata de una comparación en un estado estable demostrado. Solo los gráficos de rendimiento incluyen las tres variantes; los demás comparan fillfactor 50 con el esquema predeterminado.

Los únicos parámetros de configuración modificados fueron shared_buffers y max_wal_size, ambos establecidos en 4GB. El objetivo era que ambas relaciones cupieran en la caché de búferes.

CREATE TABLE small_table (
    group_id       integer NOT NULL,
    user_id         integer NOT NULL,
    team_id         integer NOT NULL,
    activity_log_id integer NOT NULL,
    edited_at       timestamp without time zone NOT NULL,
    PRIMARY KEY (group_id, user_id)
)
-- Establecer fillfactor solo en el heap
-- WITH (FILLFACTOR=50)
;

CREATE INDEX ON small_table (team_id);
\set user_id_rand random(1, 100000)

DO $$
DECLARE
    user_id_ran integer;
    group_id_ran integer;
    team_id_ran integer;
    last_user_id integer;
BEGIN

    SELECT user_id, group_id, team_id
        FROM users WHERE user_id = :user_id_rand
     INTO user_id_ran, group_id_ran, team_id_ran;

    INSERT INTO small_table ("group_id","user_id","team_id","activity_log_id","edited_at") 
        VALUES (group_id_ran,user_id_ran,team_id_ran,nextval('activity_log_id_seq'),now()) 
        ON CONFLICT (group_id, user_id) 
        DO UPDATE SET "activity_log_id"=EXCLUDED."activity_log_id","edited_at"=EXCLUDED."edited_at" ;

END $$ LANGUAGE plpgsql;

Comparación de TPS y bloat (versión 13.23)

TablaTamañoTPS
Original938MB278K
Fillfactor=501.40GB275K

Qué muestra esta ejecución

  • El particionamiento hash mantuvo un rendimiento cercano al predeterminado. Esta ejecución no muestra una mejora clara ni una penalización importante. Tampoco demuestra que el coste adicional del particionamiento sea insignificante para otras consultas o cargas.
  • Fillfactor 50 produjo, en general, una menor tasa de operaciones. En la última muestra de TPS, la variante predeterminada alcanzó 3940 TPS; el particionamiento hash, 3840 TPS; y fillfactor 50, 3340 TPS. Son muestras individuales, no promedios de la ejecución ni diferencias demostradas estadísticamente.
  • El uso de búferes divergió considerablemente. En la última muestra, la variante con fillfactor reducido tenía 78 571 búferes frente a 10 527 de la predeterminada: aproximadamente 7,5 veces más. Los búferes sucios mostraron una proporción similar al final. Estos recuentos describen el estado registrado de la caché, no un aumento del tamaño configurado de shared_buffers ni una medición directa de la E/S física.
  • Una mayor actividad HOT inicial no se tradujo en un mayor rendimiento sostenido. La variante con fillfactor reducido comienza con más actividad HOT registrada, pero la predeterminada la supera posteriormente. Los recuentos o tasas absolutos de HOT deben interpretarse junto con el total de actualizaciones.
  • Una menor estimación de tuplas muertas no coincidió con un mayor rendimiento. La variante con fillfactor reducido termina con una estimación menor, pero eso por sí solo no demuestra un menor coste de limpieza ni un mejor rendimiento global.

Un conjunto de trabajo mayor es una posible explicación del menor rendimiento. Estos gráficos por sí solos no permiten distinguir ese efecto del de la distribución física del índice, la actividad de los checkpoints, el comportamiento de vacuum o la competencia por recursos en el equipo local.

Cómo interpretar las mediciones

A continuación se conservan las muestras numéricas originales. Los TPS y la tasa de actualizaciones miden cosas distintas: una transacción puede contener más de una operación. El número de búferes describe la ocupación de la caché en un momento dado, mientras que los búferes sucios son páginas modificadas en memoria. Ninguno de esos gráficos mide directamente la latencia del almacenamiento ni el ancho de banda de escritura.

Para analizar HOT, compara el incremento de n_tup_hot_upd con el de n_tup_upd durante el mismo intervalo. Un valor absoluto de HOT mayor no implica necesariamente un porcentaje HOT mayor. PostgreSQL proporciona n_dead_tup como una estimación, y los aciertos de bloques de índice representan accesos resueltos desde la caché de búferes de PostgreSQL. Aquí no se han registrado las consultas ni las transformaciones originales del exportador, de modo que los gráficos de HOT y de aciertos de índice no deben interpretarse como tasas por segundo verificadas. Consulta la documentación de estadísticas de PostgreSQL.

Resultados registrados

Comparación de TPS entre v13 y v18

Comparación de TPS

Transacciones por segundo de las tres variantes. Compara las trayectorias y los valores finales; el aumento continuo de la tasa limita las conclusiones sobre el rendimiento en estado estable.

Tasa de actualizaciones

Actualizaciones por segundo registradas. Las variantes predeterminada y particionada por hash se mantienen próximas, mientras que la de fillfactor reducido suele quedar por debajo.

Total de búferes en caché

Recuentos de búferes de las variantes predeterminada y con fillfactor reducido. La diferencia de aproximadamente 7,5× aparece hacia el final, no durante toda la ejecución. Sin la consulta de recolección, no se puede precisar qué relaciones y forks están incluidos.

Búferes sucios en caché

Los recuentos de búferes sucios divergen en la parte final. Un conjunto grande de páginas sucias puede indicar trabajo de escritura pendiente, pero su tamaño por sí solo no demuestra un cuello de botella en el almacenamiento.

Aciertos de bloques de índice (statio)

Aciertos de caché registrados para el índice de clave primaria. Los valores más bajos no son necesariamente mejores: las variantes completan distintas cantidades de trabajo y se necesita conocer la transformación del exportador para interpretar la unidad con precisión.

Actualizaciones HOT por tabla

Actividad HOT registrada para cada tabla. Para comparar su eficiencia, calcula el porcentaje HOT con muestras del total de actualizaciones correspondientes al mismo intervalo, en lugar de evaluar este gráfico de forma aislada.

Tuplas muertas por tabla

Estimación de tuplas muertas de cada tabla. El momento de la limpieza y el número de actualizaciones completadas afectan a esta comparación. La serie proporcionada también tiene un salto temporal entre las 20:58:50 y las 21:00:00.

Qué probaría a continuación

Para determinar si este resultado se mantiene fuera del entorno local con Docker, el siguiente experimento debería separar los cambios de configuración y controlar el entorno:

  1. Aislar las ejecuciones. Probar una variante a la vez en un equipo dedicado sin otras cargas, o en un entorno controlado equivalente. Registrar los límites de CPU y memoria de Docker, el tipo de volumen, los recursos del equipo y si el generador de carga comparte equipo con la base de datos.
  2. Cambiar un parámetro a la vez. Comparar la configuración predeterminada con cambios de fillfactor solo en la tabla y, después, solo en el índice. Probar valores menos agresivos para la tabla, como 90, 80 y 70, antes de asumir que 50 es adecuado. Crear cada variante a partir de un conjunto de datos inicial equivalente y con la distribución física prevista.
  3. Repetir y variar el orden. Utilizar intervalos consistentes de calentamiento y medición, repetir cada configuración e informar de la variación entre ejecuciones. Ampliar la ventana de medición para observar ciclos de checkpoint y autovacuum, en lugar de depender de un valor final.
  4. Medir el coste por unidad de trabajo. Registrar percentiles de latencia, transacciones completadas, bytes de WAL, tiempos de E/S, actividad de checkpoints, tamaños de las relaciones y porcentajes HOT por intervalo. Agregar las estadísticas de todas las particiones hoja al comparar la tabla particionada con la no particionada.
  5. Publicar los detalles para reproducir la prueba. Incluir la etiqueta o el digest de la imagen, la salida de SELECT version(), el DDL, las consultas de carga y monitorización, la clave y el número de particiones, la concurrencia y los parámetros relevantes del servidor.

Conclusión

En esta ejecución local con Docker, el esquema predeterminado ofreció un mayor rendimiento que la configuración con fillfactor 50 tanto en la tabla como en el índice, mientras que el particionamiento hash se comportó de forma similar al esquema predeterminado. El fillfactor reducido mostró más actividad HOT al principio, pero un uso de búferes mucho mayor en la parte final.

Es una observación útil para seguir investigando, no una recomendación general contra la reducción del fillfactor. Una conclusión definitiva para esta carga requiere mediciones repetidas, separando los cambios de configuración y controlando las interferencias del entorno; puede ser necesario un entorno aislado para establecer si la diferencia observada es reproducible.