11.9. Escaneos de solo índice e índices de cobertura (covering indexes) #

Todos los índices en PostgreSQL son índices secundarios, lo que significa que cada índice se almacena por separado del área principal de datos de la tabla (que se llama el montón o heap de la tabla en la terminología de PostgreSQL). Esto significa que en un escaneo de índice ordinario, cada recuperación de fila requiere obtener datos tanto del índice como del montón. Además, mientras que las entradas del índice que coinciden con una condición WHERE indexable dada suelen estar cerca en el índice, las filas de la tabla a las que hacen referencia podrían estar en cualquier lugar del montón. Por lo tanto, la parte de acceso al montón de un escaneo de índice implica una gran cantidad de acceso aleatorio al montón, lo que puede ser lento, particularmente en los medios rotativos tradicionales. (Como se describe en Section 11.5, los escaneos de mapas de bits intentan mitigar este costo realizando los accesos al montón en orden ordenado, pero eso solo llega hasta cierto punto).

Para resolver este problema de rendimiento, PostgreSQL admite escaneos de solo índice (index-only scans), que pueden responder consultas solo desde el índice sin ningún acceso al montón. La idea básica es devolver los valores directamente de cada entrada del índice en lugar de consultar la entrada del montón asociada. Existen dos restricciones fundamentales sobre cuándo se puede usar este método:

  1. El tipo de índice debe admitir escaneos de solo índice. Los índices B-tree siempre lo hacen. Los índices GiST y SP-GiST admiten escaneos de solo índice para algunas clases de operadores, pero no para otras. Otros tipos de índice no tienen soporte. El requisito subyacente es que el índice debe almacenar físicamente, o bien ser capaz de reconstruir, el valor de datos original para cada entrada de índice. Como contraejemplo, los índices GIN no pueden admitir escaneos de solo índice porque cada entrada de índice normalmente contiene solo una parte del valor de datos original.

  2. La consulta debe hacer referencia únicamente a las columnas almacenadas en el índice. Por ejemplo, dado un índice en las columnas x e y de una tabla que también tiene una columna z, estas consultas podrían usar escaneos de solo índice:

    SELECT x, y FROM tab WHERE x = 'key';
    SELECT x FROM tab WHERE x = 'key' AND y < 42;
    

    pero estas consultas no podrían:

    SELECT x, z FROM tab WHERE x = 'key';
    SELECT x FROM tab WHERE x = 'key' AND z < 42;
    

    (Los índices de expresiones y los índices parciales complican esta regla, como se analiza a continuación).

Si se cumplen estos dos requisitos fundamentales, entonces todos los valores de datos requeridos por la consulta están disponibles en el índice, por lo que un escaneo de solo índice es físicamente posible. Pero hay un requisito adicional para cualquier escaneo de tabla en PostgreSQL: debe verificar que cada fila recuperada sea visible para la captura instantánea (snapshot) MVCC de la consulta, como se analiza en el Chapter 13. La información de visibilidad no se almacena en las entradas del índice, solo en las entradas del montón; por lo que, a primera vista, parecería que cada recuperación de fila requeriría un acceso al montón de todos modos. Y este es de hecho el caso si la fila de la tabla se ha modificado recientemente. Sin embargo, para los datos que cambian raramente hay una forma de evitar este problema. PostgreSQL realiza un seguimiento, para cada página en el montón de una tabla, de si todas las filas almacenadas en esa página son lo suficientemente antiguas como para ser visibles para todas las transacciones actuales y futuras. Esta información se almacena en un bit en el mapa de visibilidad (visibility map) de la tabla. Un escaneo de solo índice, tras encontrar una entrada de índice candidata, comprueba el bit del mapa de visibilidad para la página del montón correspondiente. Si está activado, se sabe que la fila es visible y, por lo tanto, los datos se pueden devolver sin más trabajo. Si no está activado, la entrada del montón debe ser visitada para averiguar si es visible, por lo que no se obtiene ninguna ventaja de rendimiento sobre un escaneo de índice estándar. Incluso en el caso de éxito, este enfoque intercambia los accesos al mapa de visibilidad por accesos al montón; pero como el mapa de visibilidad es cuatro órdenes de magnitud más pequeño que el montón que describe, se necesita mucha menos E/S física para acceder a él. En la mayoría de las situaciones, el mapa de visibilidad permanece almacenado en la caché de memoria todo el tiempo.

En resumen, aunque un escaneo de solo índice es posible dados los dos requisitos fundamentales, solo será una ganancia si una fracción significativa de las páginas del montón de la tabla tienen activados sus bits de mapa de visibilidad total (all-visible). Pero las tablas en las que una gran fracción de las filas no cambian son lo suficientemente comunes como para hacer que este tipo de escaneo sea muy útil en la práctica.

Para hacer un uso eficaz de la función de escaneo de solo índice, puedes optar por crear un índice de cobertura (covering index), que es un índice diseñado específicamente para incluir las columnas necesarias para un tipo particular de consulta que ejecutas con frecuencia. Dado que las consultas suelen necesitar recuperar más columnas que las que buscan, PostgreSQL te permite crear un índice en el que algunas columnas son solo carga útil (payload) y no forman parte de la clave de búsqueda. Esto se hace añadiendo una cláusula INCLUDE que enumera las columnas adicionales. Por ejemplo, si ejecutas habitualmente consultas como:

SELECT y FROM tab WHERE x = 'key';

el enfoque tradicional para acelerar tales consultas sería crear un índice solo en x. Sin embargo, un índice definido como:

CREATE INDEX tab_x_y ON tab(x) INCLUDE (y);

podría manejar estas consultas como escaneos de solo índice, porque y se puede obtener del índice sin visitar el montón.

Debido a que la columna y no forma parte de la clave de búsqueda del índice, no tiene por qué ser de un tipo de datos que el índice pueda manejar; simplemente se almacena en el índice y no es interpretada por la maquinaria del índice. Además, si el índice es un índice único, es decir:

CREATE UNIQUE INDEX tab_x_y ON tab(x) INCLUDE (y);

la condición de unicidad se aplica solo a la columna x, no a la combinación de x e y. (También se puede escribir una cláusula INCLUDE en las restricciones UNIQUE y PRIMARY KEY, proporcionando una sintaxis alternativa para configurar un índice como este).

Es aconsejable ser conservador a la hora de añadir columnas de carga útil que no sean clave a un índice, especialmente columnas anchas. Si una tupla de índice supera el tamaño máximo permitido para el tipo de índice, la inserción de datos fallará. En cualquier caso, las columnas que no son clave duplican los datos de la tabla del índice y aumentan el tamaño del índice, lo que puede ralentizar las búsquedas. Y recuerda que no tiene mucho sentido incluir columnas de carga útil en un índice a menos que la tabla cambie lo suficientemente despacio como para que sea probable que un escaneo de solo índice no necesite acceder al montón. Si la tupla del montón debe ser visitada de todos modos, no cuesta nada más obtener el valor de la columna desde allí. Otras restricciones son que actualmente no se admiten expresiones como columnas incluidas, y que actualmente solo los índices B-tree, GiST y SP-GiST admiten columnas incluidas.

Antes de que PostgreSQL tuviera la función INCLUDE, la gente a veces creaba índices de cobertura escribiendo las columnas de carga útil como columnas de índice ordinarias, es decir, escribiendo:

CREATE INDEX tab_x_y ON tab(x, y);

aunque no tuvieran la intención de utilizar nunca y como parte de una cláusula WHERE. Esto funciona bien siempre y cuando las columnas adicionales sean columnas finales; hacer que sean columnas iniciales es poco prudente por las razones explicadas en el Section 11.3. Sin embargo, este método no admite el caso en el que se desea que el índice garantice la unicidad en la columna o columnas de la clave.

La truncación de sufijos (suffix truncation) siempre elimina las columnas que no son clave de los niveles superiores de B-Tree. Como columnas de carga útil, nunca se utilizan para guiar los escaneos de índice. El proceso de truncamiento también elimina una o más columnas clave finales cuando el prefijo restante de las columnas clave resulta ser suficiente para describir las tuplas en el nivel inferior de B-Tree. En la práctica, los índices de cobertura sin una cláusula INCLUDE a menudo evitan almacenar columnas que son efectivamente de carga útil en los niveles superiores. Sin embargo, definir explícitamente las columnas de carga útil como columnas que no son clave mantiene de forma fiable pequeñas las tuplas en los niveles superiores.

En principio, los escaneos de solo índice se pueden usar con índices de expresiones. Por ejemplo, dado un índice en f(x) donde x es una columna de la tabla, debería ser posible ejecutar:

SELECT f(x) FROM tab WHERE f(x) < 1;

como un escaneo de solo índice; y esto es muy atractivo si f() es una función costosa de calcular. Sin embargo, el planificador de PostgreSQL actualmente no es muy inteligente en estos casos. Considera que una consulta es potencialmente ejecutable mediante un escaneo de solo índice solo cuando todas las columnas necesarias para la consulta están disponibles en el índice. En este ejemplo, x no es necesaria excepto en el contexto f(x), pero el planificador no lo nota y concluye que un escaneo de solo índice no es posible. Si un escaneo de solo índice parece valer la pena, esto se puede solucionar añadiendo x como una columna incluida, por ejemplo:

CREATE INDEX tab_f_x ON tab (f(x)) INCLUDE (x);

Una advertencia adicional, si el objetivo es evitar volver a calcular f(x), es que el planificador no necesariamente emparejará los usos de f(x) que no estén en cláusulas WHERE indexables con la columna del índice. Normalmente lo hará bien en consultas sencillas como la que se muestra arriba, pero no en consultas que involucren uniones (joins). Estas deficiencias podrían remediarse en futuras versiones de PostgreSQL.

Los índices parciales también tienen interacciones interesantes con los escaneos de solo índice. Considera el índice parcial mostrado en el Example 11.3:

CREATE UNIQUE INDEX tests_success_constraint ON tests (subject, target)
    WHERE success;

En principio, podríamos hacer un escaneo de solo índice en este índice para satisfacer una consulta como:

SELECT target FROM tests WHERE subject = 'some-subject' AND success;

Pero hay un problema: la cláusula WHERE hace referencia a success, que no está disponible como columna de resultado del índice. No obstante, un escaneo de solo índice es posible porque el plan no necesita volver a comprobar esa parte de la cláusula WHERE en tiempo de ejecución: todas las entradas encontradas en el índice tienen necesariamente success = true, por lo que esto no necesita ser comprobado explícitamente en el plan. Las versiones de PostgreSQL 9.6 y posteriores reconocerán estos casos y permitirán generar escaneos de solo índice, mientras que las versiones anteriores no lo harán.