En el artículo anterior , exploramos cómo el planificador de PostgreSQL elige la estrategia óptima de ejecución. El planificador produce un árbol de plan abstracto—nodos como “Sequential Scan,” “Hash Join,” “Sort”—que describen qué hacer. Ahora el motor de ejecución necesita hacer el trabajo real: leer páginas del disco, seguir índices, unir tablas y producir resultados.
El ejecutor usa el modelo de ejecución Volcano—un patrón elegantemente simple donde cada operación implementa la misma interfaz: “dame la siguiente fila.” Una operación sort no se preocupa de si su entrada viene de un escaneo de tabla o de un join—simplemente pide filas y ordena lo que recibe. Este enfoque uniforme permite construir consultas arbitrariamente complejas desde piezas simples y componibles.
Pero debajo de esta elegante interfaz yace la compleja realidad de acceder a datos almacenados en páginas de 8KB (configurable al compilar, aunque 8KB es el estándar). Entender cómo funcionan las operaciones a nivel de página revela por qué algunas consultas vuelan y otras se arrastran.
Comencemos con el principio organizador detrás de todo esto.
El Modelo Volcano: Patrón Simple de Iterador
El motor de ejecución de PostgreSQL se organiza alrededor de una idea engañosamente simple: cada operación en el árbol de plan implementa la misma interfaz básica. Ya sea leyendo de una tabla, ordenando filas o uniendo dos entradas—cada operación actúa como un iterador que produce filas bajo demanda.
Esto significa que las operaciones complejas pueden construirse combinando otras más simples. Un hash join no necesita saber si su entrada viene de un escaneo de tabla, un escaneo de índice u otro join. Simplemente pide la siguiente fila y procesa lo que recibe.
Esta componibilidad es lo que permite a PostgreSQL manejar consultas arbitrariamente complejas usando un framework de ejecución relativamente simple.
Cómo Funciona: Ciclo de Vida de Tres Pasos
Cada operación sigue el mismo ciclo de vida de tres pasos. Primero viene la configuración—la operación se inicializa y prepara cualquier recurso que necesite. Un escaneo secuencial abre el archivo de tabla y se prepara para leer páginas. Un hash join se prepara para construir una tabla hash desde su entrada.
Luego viene la ejecución, el corazón del patrón iterador. La operación devuelve repetidamente la siguiente fila cuando se le pide, o señala que ha terminado. Cada operación actúa como un generador que produce filas bajo demanda—no hace ningún trabajo hasta que alguien le pide datos. Veremos un ejemplo concreto de esto en acción muy pronto.
Finalmente, la limpieza—la operación libera cualquier recurso que estaba usando. Los archivos se cierran, la memoria se libera, los datos temporales se limpian.
Ahora que entendemos el ciclo de vida, veamos cómo estas operaciones coordinan entre sí para procesar una consulta.
Flujo de Datos a Través del Árbol de Plan
A medida que las operaciones solicitan datos de sus hijas, las filas fluyen hacia arriba a través del árbol de plan desde las fuentes de datos (como escaneos de tabla) hasta las operaciones que las transforman (como joins y sorts) y finalmente hasta tu cliente.
Esta es la clave: el plan describe qué hacer, mientras que el estado de ejecución rastrea dónde estás. El plan dice “escanea esta tabla”—eso nunca cambia. El estado rastrea qué página estás leyendo actualmente, qué filas has procesado, qué datos temporales has construido—eso cambia constantemente a medida que la ejecución progresa.
¿Pero cómo pasan las operaciones los datos de fila entre sí? PostgreSQL usa un contenedor estandarizado llamado tuple slot. Piensa en él como una caja genérica que puede contener los valores de columna de una fila, sin importar de dónde vino esa fila—ya sea que se leyó de una página en disco, se computó mediante una expresión o se construyó combinando valores de múltiples fuentes.
Esta interfaz uniforme es lo que hace la magia—una operación sort no necesita conocimiento especial sobre de dónde viene su entrada. Simplemente pide filas y ordena lo que recibe.
Veamos cómo funciona esto con un ejemplo concreto del capítulo anterior. Considera esta consulta que encuentra todos los alquileres de un cliente específico:
SELECT *
FROM customer c
JOIN rental r ON c.customer_id = r.customer_id
WHERE c.customer_id = 1;
Como vimos en el artículo del planificador, PostgreSQL produce este plan:
Nested Loop (cost=0.28..359.16 rows=32 width=114)
-> Index Scan using customer_pkey on customer c (cost=0.28..8.29 rows=1 width=74)
Index Cond: (customer_id = 1)
-> Seq Scan on rental r (cost=0.00..350.55 rows=32 width=40)
Filter: (customer_id = 1)
Así es como la ejecución fluye realmente a través de este árbol de plan:
El cliente pide la primera fila: El nodo Nested Loop recibe la petición
Nested Loop pide a Index Scan en customer: “Dame tu primera fila”
- Index Scan: Escanea el índice para encontrar la ubicación de customer_id = 1 en los datos de la tabla
- Index Scan: Obtiene la fila de los datos de la tabla
- Index Scan devuelve a Nested Loop: Envía la única fila de cliente coincidente
Nested Loop pide a Seq Scan en rental: “Dame tu primera fila”
- Seq Scan: Lee páginas de la tabla rental, verificando cada fila para customer_id = 1
- Seq Scan devuelve la primera coincidencia: Envía el primer alquiler coincidente a Nested Loop
Nested Loop combina y devuelve: Combina las filas de cliente y alquiler, envía al cliente
El cliente pide la segunda fila: El proceso continúa
- Nested Loop todavía tiene la misma fila de cliente
- Nested Loop pide a Seq Scan en rental: “Dame tu siguiente fila”
- Seq Scan devuelve la segunda coincidencia: Envía el siguiente alquiler coincidente a Nested Loop
- Nested Loop devuelve: Envía la segunda fila unida al cliente
Esto continúa hasta que Seq Scan encuentra todos los 32 alquileres coincidentes. Cuando el cliente pide otra fila, Nested Loop vuelve a Index Scan pidiendo el siguiente cliente. Como solo hay un cliente con id=1, Index Scan devuelve NULL (no más filas), y Nested Loop señala que ha terminado.
Nota cómo el lado externo (Index Scan) controla el flujo general—el lado interno (Seq Scan) se ejecuta completamente para cada fila externa. Este modelo basado en pull significa que el Nested Loop coordina los dos escaneos, combinando filas a medida que llegan, y se detiene tan pronto como cualquiera de los lados se queda sin datos.
Ahora que entendemos el principio organizador, veamos cómo las operaciones realmente acceden a datos almacenados en disco.
Cómo Funciona Realmente el Acceso a Datos
Detrás de la simple interfaz de iterador yace la compleja realidad de acceder a datos almacenados en disco. PostgreSQL organiza todos los datos en páginas de 8KB, y entender cómo funcionan los diferentes métodos de acceso a nivel de página revela por qué algunas consultas son rápidas y otras son lentas.
Los dos enfoques fundamentales son escaneos secuenciales (leer páginas en orden) y escaneos de índice (usar una estructura de índice para encontrar páginas específicas). Cada uno tiene características de rendimiento dramáticamente diferentes.
Escaneos Secuenciales: Leyendo Páginas en Orden
Un escaneo secuencial es el método de acceso a datos más simple—leer todas las páginas de una tabla de principio a fin, examinando cada fila para ver si coincide con las condiciones de la consulta.
Piensa en ello como leer un libro de portada a portada. PostgreSQL abre el archivo de datos de la tabla en disco y comienza a leer desde la página 0. Cada página de 8KB contiene múltiples filas, así que PostgreSQL escanea cada fila en la página, verificando si coincide con las condiciones de tu cláusula WHERE. Cuando una fila coincide, se devuelve a la operación padre. Una vez que todas las filas en una página se procesan, PostgreSQL pasa a la siguiente página y repite el proceso.
Esta es la clave: los escaneos secuenciales leen cada página de la tabla, incluso si solo unas pocas filas son realmente necesarias. Esto los hace eficientes cuando necesitas la mayoría de los datos de la tabla—¿para qué molestarse con un índice si vas a leer el 80% de las filas de todas formas? Pero son ineficientes para encontrar pequeñas cantidades de filas específicas—todavía estás leyendo la tabla completa incluso si solo necesitas 10 filas de un millón.
Implementación: src/backend/executor/nodeSeqscan.c
Escaneos de Índice: Siguiendo Punteros a Datos
Los escaneos de índice usan una estructura de índice separada para encontrar filas específicas sin leer la tabla completa. El índice actúa como el índice de un libro—te dice qué páginas contienen los datos que buscas.
El proceso involucra dos niveles de acceso a página. Primero, PostgreSQL navega la estructura del índice en sí, siguiendo la estructura de páginas interna del índice para encontrar la ubicación correcta. Luego lee las páginas hoja del índice para encontrar los identificadores de fila reales (tuple IDs) que coinciden con tus criterios de búsqueda. Con esos tuple IDs en mano, PostgreSQL obtiene las páginas heap—las páginas de datos reales de la tabla principal—y extrae las filas coincidentes.
PostgreSQL soporta diferentes tipos de índices—B-tree, hash, GiST, GIN y otros—cada uno con su propia forma de almacenar y buscar claves. Exploraremos estas estructuras de índice en detalle en el próximo artículo de esta serie.
Los escaneos de índice son eficientes cuando necesitas solo una pequeña fracción de las filas de la tabla. Pero aquí está el truco: si necesitas un gran porcentaje de filas, los escaneos de índice pueden ser ineficientes porque resultan en patrones de acceso aleatorio a páginas en lugar de lecturas secuenciales.
Por esto el planificador de PostgreSQL a veces elige un escaneo secuencial incluso cuando existe un índice—leer la tabla completa secuencialmente puede ser más rápido que miles de búsquedas aleatorias en el índice.
Implementación: src/backend/executor/nodeIndexscan.c
Ahora que hemos visto cómo los escaneos secuenciales y de índice acceden a páginas, veamos la capa crucial que se sitúa entre estas operaciones y el disco.
Patrones de Acceso a Páginas y Gestión del Buffer
Ya sea leyendo páginas secuencialmente o saltando a través de índices, todo el acceso a datos se reduce a leer páginas de 8KB del disco al shared buffer pool de PostgreSQL (un área de memoria compartida que cachea páginas accedidas frecuentemente). Entender cómo funciona este buffer pool y cómo los diferentes patrones de acceso interactúan con él explica por qué los escaneos secuenciales y los escaneos de índice tienen características de rendimiento tan diferentes.
Gestión del Buffer Pool
El shared buffer pool de PostgreSQL actúa como un caché entre el motor de ejecución y el disco. Cuando una operación necesita una página, primero verifica si esa página ya está en memoria—si es así, la usa directamente. Si no, PostgreSQL necesita cargarla del disco. Si hay espacio libre en el buffer pool, carga la página ahí. Si el buffer pool está lleno, PostgreSQL elige una página para eliminar (una que no esté siendo usada actualmente) para hacer espacio para la nueva página. Mientras una operación está usando activamente una página, PostgreSQL la marca para que no sea eliminada.
Esta gestión de buffer es crucial para el rendimiento—mantener páginas accedidas frecuentemente en memoria puede hacer las consultas órdenes de magnitud más rápidas que tener que leer del disco cada vez.
Implementación: src/backend/storage/buffer/
Ahora que entendemos cómo funciona el buffer pool, veamos cómo los diferentes patrones de acceso interactúan con él.
Patrones de Acceso Secuencial
Los escaneos secuenciales leen páginas en orden, lo cual es muy eficiente. El sistema operativo puede pre-cargar las próximas páginas porque el patrón de acceso es predecible. Los discos duros no necesitan mover el cabezal de lectura entre ubicaciones distantes, minimizando la búsqueda. Y las páginas recientemente leídas podrían estar todavía en el buffer pool para que otras operaciones las usen.
PostgreSQL implementa una estrategia inteligente de ring buffer para escaneos secuenciales grandes. En lugar de usar todo el buffer pool, los escaneos grandes usan un pequeño anillo de buffers que se reutiliza a medida que el escaneo progresa. Esto evita que un solo escaneo grande de tabla expulse todas las demás páginas y destruya el caché para todas las demás consultas—un diseño brillante que mantiene el buen rendimiento del sistema incluso durante escaneos grandes.
Patrones de Acceso Aleatorio
Los escaneos de índice a menudo resultan en acceso aleatorio a páginas porque las tuplas del heap que necesitan pueden estar dispersas en muchas páginas diferentes. Esto crea desafíos de rendimiento. El I/O es impredecible, haciendo difícil pre-cargar las páginas correctas. Los discos duros necesitan más búsqueda, lo cual es especialmente costoso en discos duros tradicionales. Y los patrones de acceso aleatorio no se benefician tanto del cacheo ya que las páginas recientemente accedidas tienen menos probabilidad de ser necesarias de nuevo pronto.
Sin embargo, PostgreSQL tiene varias optimizaciones para el acceso aleatorio. Los index-only scans evitan el acceso heap completamente cuando todas las columnas necesarias están en el índice. Los bitmap index scans recopilan muchos tuple IDs del índice, los ordenan por ubicación de página, luego acceden a las páginas heap en orden—convirtiendo el acceso aleatorio en secuencial. Y las tablas pueden ordenarse físicamente para coincidir con un índice a través de clustering, haciendo los escaneos de índice más secuenciales.
El buffer pool ayuda con las páginas que caben en memoria, pero ¿qué pasa con las operaciones que necesitan más memoria de la que provee el buffer pool?
Manejando Operaciones Grandes: Cuando los Datos no Caben en Memoria
Algunas operaciones como ordenar tablas grandes o construir tablas hash para joins necesitan más memoria de la que PostgreSQL está configurado para usar. Cuando esto sucede, PostgreSQL automáticamente vuelca datos a archivos temporales en disco, permitiendo que las operaciones se completen incluso con memoria limitada. Este comportamiento de volcado está controlado por la configuración work_mem.
Para hash joins, si la tabla hash no cabe en memoria, PostgreSQL particiona los datos en lotes y procesa cada lote por separado. Para sorts grandes, crea trozos ordenados que caben en memoria, los escribe a archivos temporales, luego los fusiona de nuevo. Ambos enfoques son mucho más lentos que las operaciones en memoria, razón por la cual un dimensionamiento apropiado de work_mem importa para el rendimiento.
Más allá de gestionar datos y memoria, el ejecutor también necesita computar la lógica real de tu consulta.
Evaluación de Expresiones: Computando Cláusulas WHERE y Listas SELECT
Cada consulta necesita evaluar expresiones—condiciones de cláusula WHERE, condiciones de join y llamadas a funciones. Estas se evalúan para cada fila que fluye a través del ejecutor.
Considera una expresión simple como WHERE price * 1.1 > 100. Para cada fila, PostgreSQL necesita:
- Obtener el valor de la columna
pricede la fila actual - Multiplicarlo por 1.1
- Comparar el resultado con 100
- Devolver verdadero o falso
Durante la planificación, PostgreSQL compila estas expresiones en una secuencia de pasos simples que pueden ejecutarse eficientemente. En lugar de navegar un árbol de expresiones complejo para cada fila, crea una lista directa de operaciones—cargar esta columna, multiplicar por esta constante, comparar, devolver resultado.
PostgreSQL también puede optimizar durante la compilación. Por ejemplo, si parte de una expresión solo involucra constantes, evalúa esa parte una vez durante la planificación en lugar de repetirla para cada fila. Para expresiones muy complejas en datasets grandes, PostgreSQL puede incluso usar compilación JIT para generar código máquina optimizado.
Implementación: src/backend/executor/execExpr.c
Todas las optimizaciones que hemos discutido hasta ahora suceden dentro de un solo proceso. Pero para consultas realmente grandes, PostgreSQL puede aprovechar una optimización aún más poderosa.
Ejecución Paralela: Usando Múltiples Núcleos de CPU
Para consultas grandes, PostgreSQL puede dividir el trabajo entre múltiples procesos worker para usar múltiples núcleos de CPU. Esto puede acelerar significativamente operaciones como escaneos grandes de tabla, sorts y agregaciones.
La ejecución paralela funciona teniendo un proceso líder que coordina con varios procesos worker. El líder lanza workers y comparte el plan de consulta con ellos a través de memoria compartida. Los workers reclaman dinámicamente trozos de trabajo (como qué páginas escanear), procesan sus datos asignados y envían resultados de vuelta a través de colas de memoria compartida. El líder recopila resultados de todos los workers y envía los resultados finales al cliente.
Para escaneos de tabla, los workers reclaman páginas dinámicamente a medida que terminan su trabajo actual. Para operaciones como hash joins, los workers cooperan para construir estructuras de datos compartidas antes de procesar sus porciones. Cuando las consultas necesitan resultados ordenados, el líder fusiona los flujos ordenados de cada worker para mantener el orden general.
La ejecución paralela puede proporcionar aceleraciones significativas (2-4x o más) para consultas grandes. Sin embargo, añade sobrecarga de coordinación, así que es más beneficiosa para consultas que procesan cantidades sustanciales de datos.
Implementación: src/backend/executor/execParallel.c
, src/backend/executor/nodeGather.c
Ahora demos un paso atrás y veamos qué hemos cubierto.
Resumen
Así que ese es el motor de ejecución de PostgreSQL—la maquinaria que transforma planes de consulta abstractos en operaciones concretas que acceden a datos y producen resultados.
Comenzamos con el modelo de ejecución Volcano—el principio organizador donde cada operación implementa la misma interfaz simple de iterador. Este enfoque uniforme permite construir consultas complejas desde piezas componibles, cada una pidiendo a sus hijas la siguiente fila y procesando lo que recibe.
A nivel de almacenamiento, exploramos cómo las operaciones realmente acceden a datos almacenados en páginas de 8KB. Los escaneos secuenciales leen páginas en orden—eficientes para conjuntos de resultados grandes. Los escaneos de índice usan estructuras de índice para encontrar páginas específicas—eficientes para búsquedas pequeñas pero potencialmente mucho I/O aleatorio. Entender estos diferentes métodos de acceso explica por qué el planificador elige diferentes estrategias para diferentes consultas.
El buffer pool se sitúa entre el ejecutor y el disco, cacheando páginas accedidas frecuentemente en memoria. Los patrones de acceso secuencial se benefician de I/O predecible y pre-carga, mientras que los patrones de acceso aleatorio enfrentan desafíos del I/O impredecible. PostgreSQL usa estrategias inteligentes como ring buffers para evitar que escaneos grandes destruyan el caché.
Cuando las operaciones necesitan más memoria de la disponible, PostgreSQL automáticamente vuelca a disco, particionando datos en lotes que caben en memoria. La evaluación de expresiones está fuertemente optimizada a través de compilación y JIT para velocidad. La ejecución paralela distribuye trabajo entre múltiples núcleos de CPU para aceleraciones significativas en consultas grandes.
El motor de ejecución es donde la elegancia de PostgreSQL se encuentra con la realidad desordenada de I/O de disco, límites de memoria y restricciones de CPU. Entender estas mecánicas revela cómo PostgreSQL convierte planes de consulta lógicos en resultados reales y por qué las consultas se comportan como lo hacen.
A continuación: exploraremos las estructuras de índice de PostgreSQL—B-tree, hash, GiST, GIN y otros—profundizando en cómo cada tipo organiza y busca claves para permitir acceso eficiente a datos.
