Semana 12 — Optimización: equivalencias y estimación de cardinalidades
Objetivos
- Transformar una expresión de álgebra relacional con reglas de equivalencia (bajar selecciones y proyecciones, reordenar joins) y justificar por qué el resultado es más barato.
- Estimar la cardinalidad de selecciones y joins a partir de estadísticas (n_r, V(A, r), histogramas) y reconocer cuándo el supuesto de independencia falla.
- Explicar cómo un optimizador basado en costos elige un plan: programación dinámica al estilo System R, left-deep trees, órdenes interesantes y heurísticas.
- Leer
EXPLAIN ANALYZEen PostgreSQL, comparar filas estimadas con filas reales y corregir un plan malo con estadísticas, un índice o reescribiendo la consulta.
Sesión A — Optimización: equivalencias y estimación de cardinalidades
Lecturas:
- Silberschatz, secs. 16.1 (Overview), 16.2 (Transformation of Relational Expressions) y 16.3 (Estimating Statistics of Expression Results).
- CS186 (Berkeley), Query Optimization: hasta Common Heuristics (selectividad de condiciones y de joins).
- PostgreSQL, Statistics Used by the Planner y Row Estimation Examples.
Lecturas complementarias:
- CMU 15-445, clase #16 Query Planning & Optimization I: diapositivas y video.
- PostgreSQL,
pg_stats,CREATE STATISTICSy Multivariate Statistics Examples. - V. Leis et al., How Good Are Query Optimizers, Really? (VLDB 2015): cuánto se equivocan los estimadores reales y cuánto importa.
Temas:
- Intuición: una consulta SQL dice qué quiere, no cómo obtenerlo. Para la misma consulta hay miles de planes equivalentes cuyo costo puede variar en varios órdenes de magnitud (lo vimos en la semana 11: de 6,6·10⁹ a 35 000 transferencias para el mismo join). El optimizador genera alternativas, estima su costo y elige la más barata.
- Reglas de equivalencia (sec. 16.2): cascada de selecciones σ_{θ1∧θ2}(E) = σ_{θ1}(σ_{θ2}(E)); conmutatividad de la selección; cascada de proyecciones; σ_θ(E1 × E2) = E1 ⋈θ E2; conmutatividad y asociatividad del join; bajar una selección a través de un join, σ{θ1}(E1 ⋈ E2) = σ_{θ1}(E1) ⋈ E2 si θ1 usa solo atributos de E1; bajar proyecciones; reglas para unión, intersección y diferencia.
- Ejemplo en Chasqui: “envíos de clientes de Arequipa entregados en agencias de la región Cusco”. El árbol directo hace
customer ⋈ shipment ⋈ agencyy filtra al final; el árbol transformado filtracustomerpor ciudad yagencypor región antes de cada join. Los resultados intermedios pasan de millones de tuplas a miles. - Orden de los joins: (r1 ⋈ r2) ⋈ r3 y r1 ⋈ (r2 ⋈ r3) dan el mismo resultado con costos distintos. Con n relaciones hay (2(n − 1))! / (n − 1)! órdenes posibles: 12 para n = 3, 1680 para n = 5, 665 280 para n = 7. No se pueden probar todos.
- Estadísticas del catálogo (sec. 16.3): n_r, b_r, el tamaño de tupla l_r, el factor de bloqueo f_r, V(A, r) (valores distintos de A) y histogramas. Equi-width frente a equi-depth. PostgreSQL guarda en
pg_stats, por columna,null_frac,n_distinct,most_common_valsymost_common_freqs,histogram_bounds(equi-depth, 100 buckets condefault_statistics_target= 100) ycorrelation. Las calculaANALYZEsobre una muestra. - Selectividad: igualdad A = v → 1 / V(A, r) si los valores son uniformes, o la frecuencia del valor si está en la lista de valores más comunes. Rango A ≤ v → (v − min) / (max − min) o, con histograma, la fracción de buckets cubiertos. Conjunción → producto de selectividades (supuesto de independencia). Disyunción → 1 − ∏(1 − s_i). Negación → 1 − s.
- Tamaño de un join: si A es clave de r y clave foránea en s, el join tiene a lo más n_s tuplas. En general, n_r · n_s / max(V(A, r), V(A, s)).
- Ejemplo resuelto:
customertiene 200 000 tuplas y V(city, customer) = 50.σ_{city='Arequipa'}(customer)estima 200 000 / 50 = 4000 tuplas. Comocustomer_ides clave decustomerysender_idla referencia,σ_{city='Arequipa'}(customer) ⋈ shipmentestima 4000 × (2·10⁶ / 200 000) = 40 000 tuplas, suponiendo que todos los clientes envían lo mismo. - Cuándo falla la independencia: en
agency,cityyregionestán correlacionadas (sicity = 'Cusco', la región es Cusco). Supongan 40 ciudades y 25 regiones: el producto da 1/1000 y lo real es 1/40: el estimador se queda corto 25 veces, y ese error se multiplica en cada join que viene después.CREATE STATISTICS ... (dependencies) ON city, region FROM agencyle enseña la relación al planificador. - Error común: tratar las estimaciones como exactas. Son aproximaciones sobre una muestra, y después de una carga masiva quedan desactualizadas hasta el próximo
ANALYZE(manual o de autovacuum).
Sesión B — Elección de planes · EXPLAIN ANALYZE
Lecturas:
- Silberschatz, sec. 16.4 (Choice of Evaluation Plans).
- CS186 (Berkeley), Query Optimization: desde Common Heuristics (Pass 1 de System R, Passes 2..n).
- PostgreSQL, Using EXPLAIN y Planner/Optimizer.
Lecturas complementarias:
- Markus Winand, Use The Index, Luke!: Getting an Execution Plan, Operations y Access vs. filter predicates.
- PostgreSQL, Controlling the Planner with Explicit JOIN Clauses, Genetic Query Optimizer y Planner Cost Constants.
- ✂️ Opcional: Silberschatz, secs. 16.5 (Materialized Views) y 16.6 (Advanced Topics in Query Optimization).
Temas:
- Optimización basada en costos (sec. 16.4): para cada subconjunto de relaciones, guardar el mejor plan que las une; el mejor plan para {r1, …, rk} se arma combinando el mejor de un subconjunto con el resto. Hay 2ⁿ subconjuntos: para 7 tablas son 128, frente a 665 280 árboles. El costo en tiempo es O(3ⁿ) con árboles de cualquier forma.
- System R: solo left-deep trees (la relación de adentro de cada join es una tabla base, lo que permite pipeline y index nested loop), que bajan la búsqueda a O(n · 2ⁿ); para 7 tablas hay 7! = 5040 órdenes left-deep. Órdenes interesantes: se guarda también el mejor plan que produce un orden útil después (para un merge join, un
GROUP BYo unORDER BY), aunque no sea el más barato. - Ejemplo con
customer,shipmentyagency: pasada 1, el mejor acceso a cada tabla (sequential scan o índice); pasada 2, el mejor join para cada par sin productos cartesianos; pasada 3, el plan completo. Se hace en la pizarra con los costos de la semana 11. - Heurísticas: aplicar las selecciones y proyecciones lo antes posible, evitar productos cartesianos, unir primero las relaciones con resultados más pequeños. Optimización de subconsultas anidadas: convertir
EXISTSeINcorrelacionados en semi-joins (decorrelación). - En PostgreSQL: programación dinámica mientras la consulta tenga menos de
geqo_threshold(12) elementos en elFROM; con más, un optimizador genético (GEQO).join_collapse_limit(8) limita cuántos joins explícitos se reordenan. Las constantes de costo están en unidades de “una página leída en secuencia”:seq_page_cost= 1,random_page_cost= 4,cpu_tuple_cost= 0,01. - Leer
EXPLAIN: un árbol de nodos que se lee de adentro hacia afuera;cost=inicio..total,rowsestimadas ywidth.EXPLAIN ANALYZEejecuta la consulta (con unUPDATEoDELETE, envolverla enBEGIN…ROLLBACK) y agregaactual time,rowsreales yloops. En PostgreSQL 18 también muestraBuffers:sin pedirlo:shared hitson páginas encontradas en el buffer pool yreadlas que se pidieron al sistema operativo. - La señal más útil: una diferencia de 10 veces o más entre
rowsestimadas y reales en un nodo bajo. Casi siempre explica por qué arriba se eligió el join equivocado (por ejemplo, un nested loop que esperaba 20 iteraciones y hace 40 000). - Error común: leer
cost=como milisegundos (son unidades arbitrarias, comparables solo entre planes de la misma consulta), u olvidar que en el lado de adentro de un nested looprowsyactual timeson por iteración y hay que multiplicarlos porloops. - ✂️ Vistas materializadas (sec. 16.5): guardar el resultado de una consulta y mantenerlo. En PostgreSQL,
CREATE MATERIALIZED VIEWyREFRESH MATERIALIZED VIEW, sin mantenimiento incremental.
Laboratorio — Leer y mejorar planes de ejecución
Trabajo en chasqui_big. Referencias: Using EXPLAIN, ANALYZE, pg_stats y CREATE STATISTICS.
- Estadísticas:
ANALYZE shipment;y consultar enpg_statslas filas destatus,created_atysender_id. Anotarn_distinct,most_common_vals,most_common_freqs, los primeroshistogram_boundsycorrelation, y explicar qué dice cada uno de los datos. - Estimar a mano: con esos números, calcular cuántas filas devuelven
WHERE status = <el valor menos frecuente de la lista>yWHERE created_at >= <una fecha>(usando los buckets del histograma). Comparar conrowsdeEXPLAINy con las filas reales deEXPLAIN ANALYZE. - Estadísticas desactualizadas:
CREATE TABLE shipment_copy (LIKE shipment) WITH (autovacuum_enabled = off);, cargarla conINSERT INTO shipment_copy SELECT * FROM shipment;y ver el plan de un join entreshipment_copyycustomercon un filtro porstatus. CorrerANALYZE shipment_copy;, repetir y explicar qué cambió en las estimaciones y en el plan. - Columnas correlacionadas: en
shipment, el origen y el destino no son independientes (solo hay envíos entre agencias que tienen ruta). Elijan un par con ruta y comparen filas estimadas y reales enEXPLAIN ANALYZE SELECT * FROM shipment WHERE origin_agency_id = ... AND dest_agency_id = ...;. CreenCREATE STATISTICS shipment_od_dep (dependencies) ON origin_agency_id, dest_agency_id FROM shipment;, haganANALYZE shipment;y repitan; después prueben con(mcv)y con(ndistinct)(este último, mirando la estimación de grupos de unGROUP BY origin_agency_id, dest_agency_id). ¿Qué tipo de estadística arregla cada caso? (Enagencytambién hay correlación entrecityyregion, pero con 29 filas todas las estimaciones se redondean a 1 y no se ve nada.) - Arreglar un plan reescribiendo la consulta:
WHERE created_at::date = <una fecha>no puede usar un índice encreated_at. Crear el índice, verificar que no se usa, reescribir como rango (created_at >= <fecha> AND created_at < <fecha> + 1) y comparar planes y buffers. Hacer lo mismo conlower(last_name) = 'quispe flores'(en Chasqui,last_nameguarda los dos apellidos) encustomerusando un índice sobre la expresión. - Orden de los joins: una consulta que une
customer,shipment,tripyvehiclecon un filtro selectivo sobrevehicle. Ver el orden que elige el planificador; luegoSET join_collapse_limit = 1;, escribir los joins en un orden malo a propósito y comparar.RESET ALL;al terminar. - Para P4: guardar los planes de los pasos 3 y 5 (
EXPLAIN (ANALYZE, BUFFERS), en texto, que es el formato que pideplans/README.md); servirán para comparar con los operadores dequipu.
Entregas
- P3 entrega.
- P4 publicado: Motor: consultas (operadores con iteradores en
quipu: seq scan, index scan, filtro, proyección, block nested loop, index nested loop y hash join, external merge sort y hash aggregation, y comparación con los planes deEXPLAINde PostgreSQL). Entrega en semana 14. Verprojects/README.md.
Preguntas de repaso
- Escriban en álgebra relacional “nombre de los clientes de Arequipa con envíos a agencias de la región Cusco” como un producto seguido de una selección. Apliquen reglas de equivalencia, una por paso, hasta llegar a un árbol con las selecciones abajo y joins en lugar del producto.
customertiene 200 000 tuplas, V(city) = 50 y V(last_name) = 5000. Estimen cuántas tuplas devuelvecity = 'Arequipa' AND last_name = 'Quispe'con el supuesto de independencia, y concity = 'Arequipa' OR last_name = 'Quispe'. ¿En qué caso el supuesto de independencia sería peligroso?shipmenttiene 2·10⁶ tuplas ytriptiene 50 000;trip_ides clave detripy el 2 % de los envíos tienetrip_idnulo. Estimen el tamaño deshipment ⋈ trip. Si además se filtratrippordeparture_aten un mes de un período de 24 meses con distribución uniforme, ¿cuántas tuplas estiman?- Para un join de 5 tablas, ¿cuántos órdenes de join hay con árboles de cualquier forma? ¿Y solo left-deep? ¿Cuántos subconjuntos de relaciones considera la programación dinámica? ¿Por qué PostgreSQL deja de usarla cuando hay muchas tablas?
- En un
EXPLAIN ANALYZE, el lado de afuera de unNested Loopestimarows=20y devuelve 40 000, y elIndex Scande adentro muestraactual time=0.010..0.012 rows=3 loops=40000. ¿Cuántas filas y cuánto tiempo aporta en total el nodo de adentro? ¿Qué plan habría elegido probablemente el optimizador con la estimación correcta, y qué harían para corregirla?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (7)Pruebas (2)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
-- Semana 12: expresiones equivalentes y el tamaño de los resultados
-- intermedios. Se corre sobre chasqui_w12 después de make:
-- psql -d chasqui_w12 -f equivalences.sql
-- No cambia datos.
SET max_parallel_workers_per_gather = 0;
SET jit = off;
-- Consulta del capítulo: envíos de clientes de Arequipa con destino en una
-- agencia de la región Cusco.
-- 1. El árbol "directo": producto cartesiano de las tres tablas y una
-- selección al final. Solo el plan: el producto tiene
-- 1800 × 12 000 × 29 = 626 400 000 tuplas.
-- name: cross_product
EXPLAIN SELECT * FROM customer c, shipment s, agency a;
-- 2. La misma consulta con las condiciones en el WHERE. El optimizador
-- aplica las reglas de equivalencia: convierte el producto y la selección
-- en joins y baja cada filtro a su tabla.
-- name: where_form
EXPLAIN SELECT c.first_name, c.last_name, s.shipment_id, a.name
FROM customer c, shipment s, agency a
WHERE c.customer_id = s.sender_id
AND s.dest_agency_id = a.agency_id
AND c.city = 'Arequipa'
AND a.region = 'Cusco';
-- 3. Escrita con JOIN ... ON: el mismo plan.
-- name: join_form
EXPLAIN SELECT c.first_name, c.last_name, s.shipment_id, a.name
FROM customer c
JOIN shipment s ON s.sender_id = c.customer_id
JOIN agency a ON a.agency_id = s.dest_agency_id
WHERE c.city = 'Arequipa'
AND a.region = 'Cusco';
-- 4. Tamaños de los resultados intermedios con los filtros arriba (después
-- de los joins) y abajo (antes).
-- name: sizes_late
SELECT count(*) AS customer_shipment_agency
FROM customer c
JOIN shipment s ON s.sender_id = c.customer_id
JOIN agency a ON a.agency_id = s.dest_agency_id;
-- name: sizes_early
SELECT (SELECT count(*) FROM customer WHERE city = 'Arequipa') AS customer_arequipa,
(SELECT count(*) FROM agency WHERE region = 'Cusco') AS agency_cusco,
(SELECT count(*)
FROM customer c JOIN shipment s ON s.sender_id = c.customer_id
WHERE c.city = 'Arequipa') AS after_first_join,
(SELECT count(*)
FROM customer c
JOIN shipment s ON s.sender_id = c.customer_id
JOIN agency a ON a.agency_id = s.dest_agency_id
WHERE c.city = 'Arequipa' AND a.region = 'Cusco') AS result;
-- 5. Una subconsulta con EXISTS también se reescribe: el plan es un semi
-- join, no una subconsulta que se ejecuta una vez por cliente.
-- name: exists_semijoin
EXPLAIN SELECT c.customer_id, c.last_name
FROM customer c
WHERE c.city = 'Cusco'
AND EXISTS (SELECT 1 FROM shipment s
WHERE s.sender_id = c.customer_id AND s.status = 'returned');
-- 6. Con un outer join, la selección no se puede bajar como si nada. Una
-- condición en el ON solo decide qué filas de route se pegan; la misma
-- condición en el WHERE descarta las agencias sin rutas largas.
-- name: outer_on
SELECT count(*) AS rows_on
FROM agency a
LEFT JOIN route r ON r.origin_agency_id = a.agency_id AND r.distance_km > 800;
-- name: outer_where
SELECT count(*) AS rows_where
FROM agency a
LEFT JOIN route r ON r.origin_agency_id = a.agency_id
WHERE r.distance_km > 800;
-- 7. En el segundo caso el WHERE rechaza las filas con route en NULL, así
-- que el optimizador convierte el left join en un inner join.
-- name: outer_where_plan
EXPLAIN SELECT a.name, r.route_id
FROM agency a
LEFT JOIN route r ON r.origin_agency_id = a.agency_id
WHERE r.distance_km > 800;-- Semana 12: elegir y leer planes en PostgreSQL. Se corre sobre chasqui_w12
-- después de make:
-- psql -d chasqui_w12 -f plans.sql
-- Crea dos índices y los borra al final; un UPDATE se deshace con ROLLBACK.
SET max_parallel_workers_per_gather = 0;
-- 1. Los parámetros del optimizador que se mencionan en el capítulo.
-- name: settings
SELECT name, setting
FROM pg_settings
WHERE name IN ('geqo_threshold', 'join_collapse_limit', 'from_collapse_limit',
'seq_page_cost', 'random_page_cost', 'cpu_tuple_cost',
'cpu_index_tuple_cost', 'cpu_operator_cost', 'default_statistics_target')
ORDER BY name;
-- 2. Las pasadas de la programación dinámica para la consulta de
-- equivalences.sql (customer, shipment y agency), con los costos que
-- calcula PostgreSQL. Pasada 1: el mejor acceso a cada tabla.
-- name: dp_customer
EXPLAIN SELECT * FROM customer c WHERE c.city = 'Arequipa';
-- name: dp_shipment
EXPLAIN SELECT * FROM shipment s;
-- name: dp_agency
EXPLAIN SELECT * FROM agency a WHERE a.region = 'Cusco';
-- Pasada 2: el mejor plan para cada par.
-- name: dp_cs
EXPLAIN SELECT * FROM customer c
JOIN shipment s ON s.sender_id = c.customer_id
WHERE c.city = 'Arequipa';
-- name: dp_sa
EXPLAIN SELECT * FROM shipment s
JOIN agency a ON a.agency_id = s.dest_agency_id
WHERE a.region = 'Cusco';
-- customer y agency no tienen ninguna condición que las una: es un
-- producto cartesiano (aquí barato, porque agency deja una sola fila).
-- name: dp_ca
EXPLAIN SELECT * FROM customer c, agency a
WHERE c.city = 'Arequipa' AND a.region = 'Cusco';
-- Pasada 3: las tres formas de agregar la tercera tabla. La primera,
-- (customer ⋈ shipment) ⋈ agency, es la que elige PostgreSQL (where_form de
-- equivalences.sql). Las otras dos se fuerzan escribiendo los JOIN en ese
-- orden con join_collapse_limit = 1 (se explica más abajo).
SET join_collapse_limit = 1;
-- name: dp_sa_c
EXPLAIN SELECT c.first_name, c.last_name, s.shipment_id, a.name
FROM shipment s
JOIN agency a ON a.agency_id = s.dest_agency_id
JOIN customer c ON c.customer_id = s.sender_id
WHERE c.city = 'Arequipa' AND a.region = 'Cusco';
-- name: dp_ca_s
EXPLAIN SELECT c.first_name, c.last_name, s.shipment_id, a.name
FROM customer c
CROSS JOIN agency a
JOIN shipment s ON s.sender_id = c.customer_id AND s.dest_agency_id = a.agency_id
WHERE c.city = 'Arequipa' AND a.region = 'Cusco';
RESET join_collapse_limit;
-- 3. Cuatro tablas y un filtro muy selectivo sobre vehicle (uno de los 24
-- vehículos). El optimizador empieza por ese filtro.
-- name: join_order_free
EXPLAIN ANALYZE SELECT c.last_name, s.shipment_id, t.departure_at
FROM customer c
JOIN shipment s ON s.sender_id = c.customer_id
JOIN trip t ON t.trip_id = s.trip_id
JOIN vehicle v ON v.vehicle_id = t.vehicle_id
WHERE v.plate = 'K2U-314';
-- 4. Con join_collapse_limit = 1, PostgreSQL respeta el orden en que están
-- escritos los JOIN: ((customer ⋈ shipment) ⋈ trip) ⋈ vehicle. El filtro
-- llega al final, después de juntar 12 000 envíos.
SET join_collapse_limit = 1;
-- name: join_order_forced
EXPLAIN ANALYZE SELECT c.last_name, s.shipment_id, t.departure_at
FROM customer c
JOIN shipment s ON s.sender_id = c.customer_id
JOIN trip t ON t.trip_id = s.trip_id
JOIN vehicle v ON v.vehicle_id = t.vehicle_id
WHERE v.plate = 'K2U-314';
RESET join_collapse_limit;
-- 5. Un nested loop con un index scan adentro: en el nodo de adentro, rows y
-- actual time son por iteración (loops).
-- name: nested_loop
EXPLAIN ANALYZE SELECT s.shipment_id, e.seq, e.kind
FROM shipment s
JOIN shipment_event e ON e.shipment_id = s.shipment_id
WHERE s.origin_agency_id = 1 AND s.dest_agency_id = 4;
-- 6. Una condición sobre una expresión no usa el índice de la columna.
CREATE INDEX shipment_created_at_idx ON shipment (created_at);
-- name: date_cast
EXPLAIN ANALYZE SELECT * FROM shipment WHERE created_at::date = '2026-03-02';
-- name: date_range
EXPLAIN ANALYZE SELECT * FROM shipment
WHERE created_at >= '2026-03-02' AND created_at < '2026-03-03';
-- 7. Un índice sobre lower(last_name) sirve para la condición con lower(),
-- y ANALYZE junta estadísticas de la expresión indexada.
-- name: lower_no_index
EXPLAIN SELECT * FROM customer WHERE lower(last_name) = 'quispe flores';
CREATE INDEX customer_lower_last_name_idx ON customer (lower(last_name));
-- name: lower_index
EXPLAIN SELECT * FROM customer WHERE lower(last_name) = 'quispe flores';
ANALYZE customer;
-- name: lower_index_analyzed
EXPLAIN ANALYZE SELECT * FROM customer WHERE lower(last_name) = 'quispe flores';
DROP INDEX shipment_created_at_idx, customer_lower_last_name_idx;
ANALYZE customer;
-- 8. EXPLAIN ANALYZE ejecuta la sentencia. Con un UPDATE, dentro de una
-- transacción que se deshace. El ROLLBACK deja una versión muerta de la
-- fila (semana 9); VACUUM la limpia.
BEGIN;
-- name: explain_update
EXPLAIN ANALYZE UPDATE customer SET phone = NULL WHERE customer_id = 5;
ROLLBACK;
VACUUM customer;// Estima la selectividad de un predicado sobre una columna a partir de
// pg_stats, con las mismas reglas que usa el planificador de PostgreSQL, y la
// compara con lo que dice EXPLAIN y con lo que devuelve la consulta.
//
// PGDATABASE=chasqui_w12 node selectivity.mjs shipment created_at '>=' 2026-06-01
// PGDATABASE=chasqui_w12 node selectivity.mjs shipment weight_kg '<' 5
// PGDATABASE=chasqui_w12 node selectivity.mjs shipment sender_id '=' 7
//
// La primera parte son funciones puras (sin base de datos); la segunda lee
// las estadísticas con pg y arma la comparación.
import pg from 'pg';
// ------------------------------------------------------------ funciones puras
// Fracción del histograma que queda por debajo de v. bounds son los límites
// de un histograma equi-depth: nbins = bounds.length - 1 buckets con la misma
// cantidad de filas cada uno. Dentro de un bucket se supone que los valores
// están repartidos de manera uniforme (interpolación lineal).
export function histogramFraction(bounds, v) {
const nbins = bounds.length - 1;
if (nbins < 1) throw new Error('el histograma necesita al menos dos límites');
if (v <= bounds[0]) return 0;
if (v >= bounds[nbins]) return 1;
// Búsqueda binaria del bucket i tal que bounds[i] <= v < bounds[i + 1].
let lo = 0;
let hi = nbins;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (bounds[mid] <= v) lo = mid;
else hi = mid;
}
const width = bounds[lo + 1] - bounds[lo];
const inside = width > 0 ? (v - bounds[lo]) / width : 0.5;
return (lo + inside) / nbins;
}
const OPS = {
'<': (a, b) => a < b,
'<=': (a, b) => a <= b,
'>': (a, b) => a > b,
'>=': (a, b) => a >= b,
'=': (a, b) => a === b,
};
// Selectividad de "columna op v" para op en <, <=, >, >=.
// stats = { nullFrac, mcv: [{ value, freq }], histogram: [números] }.
// Los valores de la lista de más comunes (MCV) se cuentan aparte, con su
// frecuencia exacta; el histograma describe solo el resto de las filas no
// nulas: 1 - nullFrac - (suma de las frecuencias de los MCV).
//
// distinct (opcional) es V(A, r). Si se da, se hace la misma corrección que
// PostgreSQL: el histograma estima "x <= v"; para "x < v" se resta la
// fracción que se estima igual a v (1 / valores distintos fuera de la lista)
// y para "x >= v" se suma. Sin distinct, < y <= dan lo mismo, como en el libro.
export function inequalitySelectivity(stats, op, v, distinct) {
if (!['<', '<=', '>', '>='].includes(op)) throw new Error(`operador no soportado: ${op}`);
const { nullFrac = 0, mcv = [], histogram = [] } = stats;
const holds = OPS[op];
let mcvSel = 0;
let mcvTotal = 0;
for (const { value, freq } of mcv) {
mcvTotal += freq;
if (holds(value, v)) mcvSel += freq;
}
const rest = 1 - nullFrac - mcvTotal;
if (histogram.length < 2) {
if (rest > 1e-9) throw new Error('sin histograma no hay cómo estimar el resto de las filas');
return clamp(mcvSel);
}
const below = histogramFraction(histogram, v); // estima x <= v
let histSel = op === '<' || op === '<=' ? below : 1 - below;
if (distinct !== undefined && (op === '<' || op === '>=')) {
const eq = 1 / Math.max(distinct - mcv.length, 1);
histSel += op === '<' ? -eq : eq;
}
// Como PostgreSQL: los límites vienen de una muestra y son aproximados, así
// que no se cree en una selectividad de 0 o de 1 dentro del histograma.
const cutoff = 0.01 / (histogram.length - 1);
histSel = Math.min(Math.max(histSel, cutoff), 1 - cutoff);
return clamp(mcvSel + rest * histSel);
}
// Selectividad de "columna = v". Si v está en la lista de MCV, su frecuencia.
// Si no, el resto de las filas se reparte por igual entre los valores
// distintos que no están en la lista: (1 - nullFrac - suma MCV) / (V - #MCV).
// distinct es V(A, r), el número de valores distintos (ya en positivo).
export function equalitySelectivity(stats, v, distinct) {
const { nullFrac = 0, mcv = [] } = stats;
const hit = mcv.find((m) => m.value === v);
if (hit) return hit.freq;
const mcvTotal = mcv.reduce((s, m) => s + m.freq, 0);
const others = distinct - mcv.length;
let sel = others > 0 ? (1 - nullFrac - mcvTotal) / others : 0;
// Un valor que no está en la lista no puede ser más común que el menos
// común de la lista.
if (mcv.length > 0) sel = Math.min(sel, mcv[mcv.length - 1].freq);
return clamp(sel);
}
// En pg_stats, n_distinct negativo significa "una fracción de las filas":
// -0,5 quiere decir que hay tantos valores distintos como la mitad de filas.
export function distinctValues(nDistinct, reltuples) {
return nDistinct >= 0 ? nDistinct : Math.round(-nDistinct * reltuples);
}
// PostgreSQL redondea las filas estimadas y nunca estima menos de 1.
export function estimatedRows(sel, reltuples) {
return Math.max(1, Math.round(sel * reltuples));
}
function clamp(sel) {
return Math.min(Math.max(sel, 0), 1);
}
// ------------------------------------------------------- con la base de datos
// Tipos que sabemos convertir a número, y la expresión SQL que lo hace. Las
// fechas se convierten a segundos desde 1970 (epoch); PostgreSQL usa otra
// escala, pero la interpolación lineal da la misma fracción.
const SCALAR = {
integer: (x) => `${x}::float8`,
bigint: (x) => `${x}::float8`,
smallint: (x) => `${x}::float8`,
numeric: (x) => `${x}::float8`,
'double precision': (x) => x,
'timestamp with time zone': (x) => `extract(epoch FROM ${x})::float8`,
date: (x) => `extract(epoch FROM ${x})::float8`,
};
const SQL_TYPE = {
integer: 'integer', bigint: 'bigint', smallint: 'smallint', numeric: 'numeric',
'double precision': 'float8', 'timestamp with time zone': 'timestamptz', date: 'date',
};
// Tipo base de la columna (sin el typmod: numeric(7,2) -> numeric).
async function columnType(client, table, column) {
const { rows } = await client.query(
`SELECT format_type(atttypid, NULL) AS type
FROM pg_attribute
WHERE attrelid = $1::regclass AND attname = $2 AND NOT attisdropped`,
[table, column],
);
if (rows.length === 0) throw new Error(`no existe la columna ${table}.${column}`);
const { type } = rows[0];
if (!SCALAR[type]) throw new Error(`tipo no soportado: ${type}`);
return type;
}
// Lee de pg_stats la fila de la columna y convierte los valores a número.
// histogram_bounds y most_common_vals son de tipo anyarray: se pasan a texto
// y de texto al arreglo del tipo de la columna.
export async function loadStats(client, table, column) {
const type = await columnType(client, table, column);
const t = SQL_TYPE[type];
const conv = SCALAR[type];
const { rows } = await client.query(
`SELECT s.null_frac, s.n_distinct, c.reltuples,
(SELECT array_agg(${conv('v')} ORDER BY ord)
FROM unnest(s.most_common_vals::text::${t}[]) WITH ORDINALITY AS m(v, ord)) AS mcv,
s.most_common_freqs AS mcf,
(SELECT array_agg(${conv('v')} ORDER BY ord)
FROM unnest(s.histogram_bounds::text::${t}[]) WITH ORDINALITY AS h(v, ord)) AS histogram
FROM pg_stats s
JOIN pg_class c ON c.oid = to_regclass($1::text)
WHERE s.schemaname = 'public' AND s.tablename = $1::text AND s.attname = $2`,
[table, column],
);
if (rows.length === 0) throw new Error(`sin estadísticas para ${table}.${column}: falta ANALYZE`);
const r = rows[0];
const mcvValues = r.mcv ?? [];
return {
type,
reltuples: Number(r.reltuples),
nullFrac: Number(r.null_frac),
nDistinct: Number(r.n_distinct),
mcv: mcvValues.map((value, i) => ({ value, freq: Number(r.mcf[i]) })),
histogram: r.histogram ?? [],
};
}
// Estimación propia, estimación de EXPLAIN y filas reales de "column op value".
export async function compare(client, table, column, op, value) {
if (!OPS[op]) throw new Error(`operador no soportado: ${op}`);
const stats = await loadStats(client, table, column);
const t = SQL_TYPE[stats.type];
const { rows: [{ x }] } = await client.query(`SELECT ${SCALAR[stats.type]('$1::' + t)} AS x`, [value]);
const distinct = distinctValues(stats.nDistinct, stats.reltuples);
const sel = op === '='
? equalitySelectivity(stats, x, distinct)
: inequalitySelectivity(stats, op, x, distinct);
// Los nombres de tabla y columna no pueden ir como parámetros: se escapan
// como identificadores. El valor sí va como parámetro ($1).
const where = `${client.escapeIdentifier(column)} ${op} $1::${t}`;
const from = client.escapeIdentifier(table);
const plan = await client.query(`EXPLAIN (FORMAT JSON) SELECT * FROM ${from} WHERE ${where}`, [value]);
const actual = await client.query(`SELECT count(*)::int AS n FROM ${from} WHERE ${where}`, [value]);
return {
selectivity: sel,
estimated: estimatedRows(sel, stats.reltuples),
explain: plan.rows[0]['QUERY PLAN'][0].Plan['Plan Rows'],
actual: actual.rows[0].n,
};
}
// ------------------------------------------------------------ línea de comandos
if (import.meta.url === `file://${process.argv[1]}`) {
const [table, column, op, value] = process.argv.slice(2);
if (!value) {
console.error('uso: node selectivity.mjs <tabla> <columna> <op> <valor>');
process.exit(2);
}
const client = new pg.Client();
await client.connect();
try {
const r = await compare(client, table, column, op, value);
console.log(`${table}.${column} ${op} ${value}`);
console.log(` selectividad estimada ${r.selectivity.toFixed(6)}`);
console.log(` filas (pg_stats) ${r.estimated}`);
console.log(` filas (EXPLAIN) ${r.explain}`);
console.log(` filas reales ${r.actual}`);
} finally {
await client.end();
}
}// Lee los archivos .sql de la semana. Las consultas que las pruebas revisan
// están marcadas con una línea "-- name: nombre"; así las pruebas corren el
// mismo texto que se muestra en clase.
import { readFileSync } from 'node:fs';
// Sentencias del archivo, en orden: [{ name, sql }]. name es null si la
// sentencia no está marcada. Una sentencia termina en la primera línea que
// acaba en punto y coma; las líneas de comentario no son parte de ella.
export function statements(file) {
const text = readFileSync(new URL(file, import.meta.url), 'utf8');
const result = [];
let name = null;
let lines = [];
for (const line of text.split('\n')) {
const mark = line.match(/^-- name: (\w+)\s*$/);
if (mark) {
name = mark[1];
continue;
}
if (lines.length === 0 && (line.trim() === '' || line.startsWith('--'))) continue;
lines.push(line);
if (/;\s*$/.test(line)) {
result.push({ name, sql: lines.join('\n') });
name = null;
lines = [];
}
}
return result;
}
// Las consultas marcadas, como objeto { nombre: texto }.
export function namedQueries(file) {
return Object.fromEntries(statements(file).filter((s) => s.name).map((s) => [s.name, s.sql]));
}
// EXPLAIN [ANALYZE] ... pasa a EXPLAIN (..., FORMAT JSON) ... para que las
// pruebas lean el plan como objeto y no como texto.
export function asJson(sql) {
return sql
.replace(/^EXPLAIN ANALYZE /, 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ')
.replace(/^EXPLAIN (?!\()/, 'EXPLAIN (FORMAT JSON) ');
}
// Corre todas las sentencias del archivo en orden con el mismo cliente y
// devuelve { nombre: resultado }: el plan (objeto Plan de EXPLAIN) o las filas.
export async function runFile(client, file) {
const out = {};
for (const { name, sql } of statements(file)) {
const isExplain = sql.startsWith('EXPLAIN');
const res = await client.query(name && isExplain ? asJson(sql) : sql);
if (!name) continue;
out[name] = isExplain ? res.rows[0]['QUERY PLAN'][0].Plan : res.rows;
}
return out;
}-- Semana 12: estadísticas desactualizadas. Una tabla recién cargada, sin
-- ANALYZE y con autovacuum apagado, y el mismo join antes y después de
-- ANALYZE. Se corre sobre chasqui_w12 después de make:
-- psql -d chasqui_w12 -f stale.sql
-- Crea shipment_copy y la borra al final.
SET max_parallel_workers_per_gather = 0;
SET client_min_messages = warning;
DROP TABLE IF EXISTS shipment_copy;
CREATE TABLE shipment_copy (LIKE shipment) WITH (autovacuum_enabled = off);
INSERT INTO shipment_copy SELECT * FROM shipment;
-- 1. El catálogo todavía dice que la tabla nunca se analizó: reltuples = -1.
-- name: catalog_before
SELECT relpages, reltuples FROM pg_class WHERE relname = 'shipment_copy';
-- 2. Sin estadísticas, el planificador estima el número de filas con el
-- tamaño del archivo (148 páginas) y el ancho de las columnas.
-- name: rows_before
EXPLAIN SELECT * FROM shipment_copy;
-- 3. Y para status = 'delivered' usa una selectividad fija de 0,5 %:
-- 41 filas, cuando son 11 115. Construye la hash table con el lado que
-- cree pequeño, que es el grande.
-- name: join_before
EXPLAIN ANALYZE SELECT c.last_name, s.shipment_id
FROM shipment_copy s
JOIN customer c ON c.customer_id = s.sender_id
WHERE s.status = 'delivered';
ANALYZE shipment_copy;
-- name: catalog_after
SELECT relpages, reltuples FROM pg_class WHERE relname = 'shipment_copy';
-- 4. Con estadísticas, la estimación es exacta y la hash table se construye
-- con customer, que tiene 1800 filas.
-- name: join_after
EXPLAIN ANALYZE SELECT c.last_name, s.shipment_id
FROM shipment_copy s
JOIN customer c ON c.customer_id = s.sender_id
WHERE s.status = 'delivered';
DROP TABLE shipment_copy;-- Semana 12: estadísticas del catálogo y estimación de cardinalidades.
-- Se corre sobre chasqui_w12 después de make:
-- psql -d chasqui_w12 -f stats.sql
-- No cambia datos. En chasqui_w12, ANALYZE lee la tabla completa (la muestra
-- es de 30 000 filas y shipment tiene 12 000), así que las estimaciones de
-- una sola columna salen casi exactas; en chasqui_big son de una muestra.
SET max_parallel_workers_per_gather = 0;
-- 1. n_r y b_r de cada tabla, como los guarda el catálogo.
-- name: sizes
SELECT relname, reltuples::bigint AS n_r, relpages AS b_r
FROM pg_class
WHERE relname IN ('agency', 'customer', 'shipment', 'trip', 'vehicle')
ORDER BY relname;
-- 2. Lo que ANALYZE guardó para algunas columnas de shipment.
-- name: columns
SELECT attname, null_frac, n_distinct,
cardinality(most_common_freqs) AS n_mcv,
cardinality(histogram_bounds::text::text[]) AS n_bounds,
round(correlation::numeric, 3) AS correlation
FROM pg_stats
WHERE tablename = 'shipment'
AND attname IN ('shipment_id', 'status', 'sender_id', 'created_at', 'weight_kg', 'trip_id')
ORDER BY attname;
-- 3. Los valores más comunes de status y sus frecuencias.
-- name: status_mcv
SELECT most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'shipment' AND attname = 'status';
-- 4. Igualdad con un valor de la lista: la estimación es su frecuencia.
-- 0,0055 × 12 000 = 66.
-- name: eq_mcv
EXPLAIN SELECT * FROM shipment WHERE status = 'in_transit';
-- 5. Igualdad con un valor que no está en la lista. sender_id = 7 no está
-- entre los 100 más comunes: el resto de las filas se reparte por igual
-- entre los valores distintos que quedan.
-- name: sender_stats
SELECT n_distinct,
round(-n_distinct * 12000) AS distinct_values,
cardinality(most_common_freqs) AS n_mcv,
round((SELECT sum(f) FROM unnest(most_common_freqs) AS f)::numeric, 4) AS mcv_total,
7 = ANY (most_common_vals::text::int[]) AS seven_in_mcv
FROM pg_stats
WHERE tablename = 'shipment' AND attname = 'sender_id';
-- name: eq_other
EXPLAIN SELECT * FROM shipment WHERE sender_id = 7;
-- 6. El histograma de weight_kg: 101 límites, 100 buckets con la misma
-- cantidad de filas. Los primeros 12 límites.
-- name: weight_bounds
SELECT (histogram_bounds::text::numeric[])[1:12] AS first_bounds
FROM pg_stats
WHERE tablename = 'shipment' AND attname = 'weight_kg';
-- 6b. Para comparar: la estimación del libro sin histograma, (v - min) /
-- (max - min), y un histograma equi-width de 10 buckets de 15 kg.
-- name: weight_uniform
SELECT min(weight_kg), max(weight_kg),
round(12000 * (5 - min(weight_kg)) / (max(weight_kg) - min(weight_kg))) AS uniform_rows
FROM shipment;
-- name: weight_equiwidth
SELECT width_bucket(weight_kg, 0, 150, 10) AS bucket,
(width_bucket(weight_kg, 0, 150, 10) - 1) * 15 AS from_kg,
width_bucket(weight_kg, 0, 150, 10) * 15 AS to_kg,
count(*)
FROM shipment
GROUP BY 1
ORDER BY 1;
-- 7. Un rango: la estimación junta los valores comunes con el histograma.
-- Comparen con selectivity.mjs.
-- name: range_weight
EXPLAIN SELECT * FROM shipment WHERE weight_kg < 5;
-- name: range_created
EXPLAIN SELECT * FROM shipment WHERE created_at >= '2026-06-01';
-- 8. Conjunción con el supuesto de independencia: el producto de las dos
-- selectividades. Aquí funciona, porque las columnas no tienen relación.
-- name: and_independent
EXPLAIN ANALYZE SELECT * FROM shipment WHERE status = 'returned' AND weight_kg < 5;
-- 9. Disyunción: s1 + s2 - s1·s2, que es lo mismo que 1 - (1 - s1)(1 - s2).
-- name: or_independent
EXPLAIN ANALYZE SELECT * FROM shipment WHERE status = 'returned' OR weight_kg < 5;
-- 10. Negación: 1 - s.
-- name: not_eq
EXPLAIN SELECT * FROM shipment WHERE status <> 'delivered';
-- 11. Tamaño de un join por una clave foránea: cada envío tiene un solo
-- remitente, así que el join tiene a lo más n_shipment tuplas. Con un
-- filtro sobre customer: 89 clientes × 12 000 / 1800 envíos por cliente.
-- name: join_fk
EXPLAIN ANALYZE SELECT c.last_name, s.shipment_id
FROM customer c
JOIN shipment s ON s.sender_id = c.customer_id
WHERE c.city = 'Arequipa';Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// Pruebas de los .sql de la semana: cada archivo se corre completo, en orden,
// y se revisan las cifras que cita el capítulo (filas estimadas, filas reales,
// forma del plan). Usan chasqui_w12 recién creada (make test la crea).
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { runFile } from './sqlfile.mjs';
let client;
before(async () => {
client = new pg.Client();
await client.connect();
});
after(() => client.end());
// Todos los nodos del plan, de arriba abajo.
function nodes(plan) {
return [plan, ...(plan.Plans ?? []).flatMap(nodes)];
}
const find = (plan, type, relation) =>
nodes(plan).find((n) => n['Node Type'] === type && (!relation || n['Relation Name'] === relation));
const est = (n) => n['Plan Rows'];
const real = (n) => n['Actual Rows'];
test('stats.sql: tamaños y estadísticas del catálogo', async () => {
const r = await runFile(client, './stats.sql');
const sizes = Object.fromEntries(r.sizes.map((x) => [x.relname, [Number(x.n_r), x.b_r]]));
assert.deepEqual(sizes, {
agency: [29, 1], customer: [1800, 19], shipment: [12000, 148], trip: [5914, 44], vehicle: [24, 1],
});
const cols = Object.fromEntries(r.columns.map((x) => [x.attname, x]));
assert.equal(cols.status.n_distinct, 4);
assert.equal(cols.shipment_id.n_distinct, -1);
assert.equal(cols.created_at.n_bounds, 101);
assert.deepEqual(r.status_mcv[0].most_common_vals, '{delivered,registered,returned,in_transit}');
// Igualdad: con un valor de la lista y con uno que no está.
assert.equal(est(r.eq_mcv), 66);
assert.equal(r.sender_stats[0].distinct_values, 1718);
assert.equal(r.sender_stats[0].seven_in_mcv, false);
assert.equal(est(r.eq_other), 5);
// Rangos.
assert.equal(r.weight_bounds[0].first_bounds.length, 12);
assert.equal(r.weight_uniform[0].uniform_rows, '397');
assert.equal(r.weight_equiwidth.length, 10);
assert.deepEqual(r.weight_equiwidth[0], { bucket: 1, from_kg: 0, to_kg: 15, count: '6411' });
assert.equal(est(r.range_weight), 2060);
assert.equal(est(r.range_created), 1891);
// Conjunción, disyunción y negación.
assert.deepEqual([est(r.and_independent), real(r.and_independent)], [55, 45]);
assert.deepEqual([est(r.or_independent), real(r.or_independent)], [2324, 2342]);
assert.equal(est(r.not_eq), 885);
// Join por clave foránea: 89 × 12 000 / 1800 = 593.
assert.deepEqual([est(r.join_fk), real(r.join_fk)], [593, 822]);
assert.equal(est(find(r.join_fk, 'Seq Scan', 'customer')), 89);
});
test('equivalences.sql: el optimizador baja los filtros y arma joins', async () => {
const r = await runFile(client, './equivalences.sql');
assert.equal(est(r.cross_product), 626_400_000);
// Las dos formas de escribir la consulta dan el mismo plan.
assert.deepEqual(r.where_form, r.join_form);
assert.equal(r.where_form['Node Type'], 'Hash Join');
assert.equal(est(r.where_form), 20);
assert.equal(r.where_form['Total Cost'], 345.34);
const customer = find(r.where_form, 'Seq Scan', 'customer');
const agency = find(r.where_form, 'Seq Scan', 'agency');
assert.equal(customer.Filter, "(city = 'Arequipa'::text)");
assert.equal(agency.Filter, "(region = 'Cusco'::text)");
assert.equal(r.sizes_late[0].customer_shipment_agency, '12000');
assert.deepEqual(r.sizes_early[0], {
customer_arequipa: '89', agency_cusco: '1', after_first_join: '822', result: '61',
});
// EXISTS se convierte en semi join.
assert.match(r.exists_semijoin['Node Type'], /Hash/);
assert.equal(r.exists_semijoin['Join Type'], 'Right Semi');
// Outer join: la condición en el ON y en el WHERE no son lo mismo.
assert.equal(r.outer_on[0].rows_on, '33');
assert.equal(r.outer_where[0].rows_where, '10');
assert.equal(r.outer_where_plan['Join Type'], 'Inner');
});
test('correlated.sql: independencia y CREATE STATISTICS', async () => {
const r = await runFile(client, './correlated.sql');
assert.deepEqual([est(r.delivered_null), real(r.delivered_null)], [820, 0]);
assert.deepEqual([est(r.registered_null), real(r.registered_null)], [21, 500]);
assert.deepEqual([est(r.city_region_before), real(r.city_region_before)], [31, 610]);
const dep = JSON.parse(r.city_region_dep[0].dependencies);
assert.equal(dep['2 => 3'], 1);
assert.deepEqual([est(r.city_region_after), real(r.city_region_after)], [610, 610]);
assert.deepEqual([est(r.od_before), real(r.od_before)], [8, 92]);
assert.deepEqual([est(r.od_groups_before), real(r.od_groups_before)], [784, 120]);
const odDep = JSON.parse(r.od_dep[0].dependencies);
assert.ok(odDep['4 => 5'] < 0.05 && odDep['5 => 4'] < 0.05);
assert.equal(est(r.od_with_dep), 11);
assert.equal(r.od_ndistinct[0].n_distinct, '{"4, 5": 120}');
assert.equal(est(r.od_with_mcv), 92);
assert.equal(est(r.od_groups_after), 120);
// El archivo deja la base como estaba.
const { rows } = await client.query('SELECT count(*)::int AS n FROM pg_statistic_ext');
assert.equal(rows[0].n, 0);
});
test('stale.sql: sin ANALYZE, 41 filas estimadas para 11 115', async () => {
const r = await runFile(client, './stale.sql');
assert.deepEqual(r.catalog_before[0], { relpages: 0, reltuples: -1 });
assert.equal(est(r.rows_before), 8288);
// Antes: la hash table se construye con shipment_copy (el lado "pequeño").
const before = find(r.join_before, 'Hash');
assert.equal(before.Plans[0]['Relation Name'], 'shipment_copy');
assert.deepEqual([est(before), real(before)], [41, 11115]);
assert.deepEqual(r.catalog_after[0], { relpages: 148, reltuples: 12000 });
// Después: se construye con customer.
const afterHash = find(r.join_after, 'Hash');
assert.equal(afterHash.Plans[0]['Relation Name'], 'customer');
assert.deepEqual([est(r.join_after), real(r.join_after)], [11115, 11115]);
});
test('plans.sql: orden de joins, loops y reescrituras', async () => {
const r = await runFile(client, './plans.sql');
const settings = Object.fromEntries(r.settings.map((x) => [x.name, x.setting]));
assert.equal(settings.geqo_threshold, '12');
assert.equal(settings.join_collapse_limit, '8');
assert.equal(settings.random_page_cost, '4');
// Programación dinámica: costos de la pasada 1 y de la pasada 2.
assert.equal(r.dp_customer['Total Cost'], 41.5);
assert.equal(r.dp_shipment['Total Cost'], 268);
assert.equal(r.dp_agency['Total Cost'], 1.36);
assert.deepEqual([r.dp_cs['Total Cost'], est(r.dp_cs)], [342.19, 593]);
assert.deepEqual([r.dp_sa['Total Cost'], est(r.dp_sa)], [305.48, 414]);
assert.equal(r.dp_ca['Node Type'], 'Nested Loop');
// Pasada 3: las alternativas cuestan más que el plan elegido (345.34).
assert.equal(r.dp_sa_c['Total Cost'], 349.18);
assert.equal(r.dp_ca_s['Total Cost'], 403.29);
// Libre, el primer join es trip ⋈ vehicle; forzado, customer ⋈ shipment.
const firstJoin = (plan) => nodes(plan).filter((n) => /Join|Nested Loop/.test(n['Node Type'])).at(-1);
const rels = (n) => nodes(n).map((x) => x['Relation Name']).filter(Boolean).sort();
assert.deepEqual(rels(firstJoin(r.join_order_free)), ['trip', 'vehicle']);
assert.deepEqual(rels(firstJoin(r.join_order_forced)), ['customer', 'shipment']);
assert.equal(real(r.join_order_free), 138);
assert.equal(real(r.join_order_forced), 138);
assert.ok(r.join_order_free['Total Cost'] < r.join_order_forced['Total Cost']);
assert.equal(real(firstJoin(r.join_order_forced)), 12000);
// Nested loop: el lado de adentro corre una vez por fila del de afuera.
assert.equal(r.nested_loop['Node Type'], 'Nested Loop');
const [outer, inner] = r.nested_loop.Plans;
assert.deepEqual([est(outer), real(outer)], [77, 117]);
assert.equal(inner['Actual Loops'], 117);
assert.equal(real(r.nested_loop), 467);
// created_at::date no usa el índice; el rango sí.
assert.equal(r.date_cast['Node Type'], 'Seq Scan');
assert.deepEqual([est(r.date_cast), real(r.date_cast)], [60, 73]);
assert.ok(find(r.date_range, 'Bitmap Index Scan'));
assert.deepEqual([est(r.date_range), real(r.date_range)], [74, 73]);
// lower(last_name): 0,5 % por defecto, y la estimación buena después de
// crear el índice y analizar.
assert.equal(r.lower_no_index['Node Type'], 'Seq Scan');
assert.equal(est(r.lower_no_index), 9);
assert.equal(est(find(r.lower_index, 'Bitmap Index Scan')), 9);
assert.equal(r.lower_index_analyzed['Node Type'], 'Index Scan');
assert.deepEqual([est(r.lower_index_analyzed), real(r.lower_index_analyzed)], [1, 1]);
// EXPLAIN ANALYZE de un UPDATE: se ejecutó y se deshizo.
assert.equal(r.explain_update['Node Type'], 'ModifyTable');
const { rows } = await client.query('SELECT phone FROM customer WHERE customer_id = 5');
assert.notEqual(rows[0].phone, null);
});// Pruebas de selectivity.mjs: primero las funciones puras con estadísticas
// inventadas y fáciles de calcular a mano; después contra chasqui_w12, donde
// la estimación tiene que coincidir con la de EXPLAIN.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import {
histogramFraction, inequalitySelectivity, equalitySelectivity,
distinctValues, estimatedRows, loadStats, compare,
} from './selectivity.mjs';
const close = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) < eps, `${a} ≉ ${b}`);
// Cuatro buckets con la misma cantidad de filas: [0,10], [10,20], [20,30], [30,40].
const BOUNDS = [0, 10, 20, 30, 40];
test('histogramFraction: buckets completos y parte de un bucket', () => {
close(histogramFraction(BOUNDS, 20), 0.5);
close(histogramFraction(BOUNDS, 25), 0.625);
close(histogramFraction(BOUNDS, 3), 0.075);
assert.equal(histogramFraction(BOUNDS, -5), 0);
assert.equal(histogramFraction(BOUNDS, 0), 0);
assert.equal(histogramFraction(BOUNDS, 40), 1);
assert.equal(histogramFraction(BOUNDS, 99), 1);
});
test('histogramFraction: buckets de distinto ancho (equi-depth)', () => {
// Muchas filas entre 0 y 1, pocas entre 1 y 100: cada bucket tiene un tercio.
const b = [0, 0.5, 1, 100];
close(histogramFraction(b, 1), 2 / 3);
close(histogramFraction(b, 50.5), 2 / 3 + 0.5 / 3);
});
test('histogramFraction: la búsqueda binaria coincide con recorrer los buckets', () => {
const b = Array.from({ length: 101 }, (_, i) => i * i);
for (let v = 0; v <= 10000; v += 37) {
let i = 0;
while (i < b.length - 2 && b[i + 1] <= v) i++;
const linear = Math.min(1, (i + (v - b[i]) / (b[i + 1] - b[i])) / 100);
close(histogramFraction(b, v), linear);
}
});
test('histogramFraction: menos de dos límites es un error', () => {
assert.throws(() => histogramFraction([5], 3), /al menos dos/);
});
// 10 % de nulos, dos valores comunes (5 con 20 %, 35 con 10 %) y el 60 %
// restante descrito por el histograma BOUNDS.
const STATS = {
nullFrac: 0.1,
mcv: [{ value: 5, freq: 0.2 }, { value: 35, freq: 0.1 }],
histogram: BOUNDS,
};
test('inequalitySelectivity: MCV más histograma', () => {
// x < 25: el 5 cuenta entero (0,2) y el histograma aporta 0,625 de 0,6.
close(inequalitySelectivity(STATS, '<', 25), 0.2 + 0.6 * 0.625);
// x > 25: el 35 cuenta entero (0,1) y el resto del histograma.
close(inequalitySelectivity(STATS, '>', 25), 0.1 + 0.6 * 0.375);
// Sin los nulos: < y >= suman 1 - nullFrac.
close(inequalitySelectivity(STATS, '<', 25) + inequalitySelectivity(STATS, '>=', 25), 0.9);
});
test('inequalitySelectivity: no cree en 0 ni en 1 dentro del histograma', () => {
const s = { histogram: BOUNDS };
close(inequalitySelectivity(s, '<', -1), 0.01 / 4);
close(inequalitySelectivity(s, '<', 100), 1 - 0.01 / 4);
});
test('inequalitySelectivity: con distinct, < resta la parte que es igual a v', () => {
// 10 valores distintos fuera de la lista (12 - 2): 1/10 del histograma.
close(inequalitySelectivity(STATS, '<', 25, 12), 0.2 + 0.6 * (0.625 - 0.1));
close(inequalitySelectivity(STATS, '<=', 25, 12), 0.2 + 0.6 * 0.625);
close(inequalitySelectivity(STATS, '>=', 25, 12), 0.1 + 0.6 * (0.375 + 0.1));
});
test('inequalitySelectivity: operador desconocido', () => {
assert.throws(() => inequalitySelectivity(STATS, '<>', 3), /no soportado/);
});
test('equalitySelectivity: valor común, valor raro y el tope', () => {
close(equalitySelectivity(STATS, 5, 12), 0.2);
// (1 - 0,1 - 0,3) / (12 - 2) = 0,06.
close(equalitySelectivity(STATS, 17, 12), 0.06);
// Con 4 valores distintos daría 0,3, pero no puede pasar al menos común (0,1).
close(equalitySelectivity(STATS, 17, 4), 0.1);
// Sin lista: 1 / V.
close(equalitySelectivity({ nullFrac: 0 }, 3, 50), 1 / 50);
});
test('distinctValues y estimatedRows', () => {
assert.equal(distinctValues(28, 12000), 28);
assert.equal(distinctValues(-0.5, 12000), 6000);
assert.equal(distinctValues(-1, 1800), 1800);
assert.equal(estimatedRows(0.0055, 12000), 66);
assert.equal(estimatedRows(1e-9, 12000), 1);
});
// ------------------------------------------------------------- con la base
let client;
before(async () => {
client = new pg.Client();
await client.connect();
});
after(() => client.end());
test('loadStats: lo que guardó ANALYZE para shipment.created_at', async () => {
const s = await loadStats(client, 'shipment', 'created_at');
assert.equal(s.type, 'timestamp with time zone');
assert.equal(s.reltuples, 12000);
assert.equal(s.nullFrac, 0);
assert.equal(s.histogram.length, 101);
assert.equal(s.mcv.length, 100);
// Los límites están ordenados y son segundos desde 1970.
for (let i = 1; i < s.histogram.length; i++) assert.ok(s.histogram[i - 1] <= s.histogram[i]);
assert.equal(s.histogram[0], Date.parse('2026-01-05T00:00:00-05:00') / 1000);
});
test('loadStats: errores claros', async () => {
await assert.rejects(loadStats(client, 'shipment', 'no_such_column'), /no existe la columna/);
await assert.rejects(loadStats(client, 'shipment', 'status'), /tipo no soportado/);
});
// Predicados de rango y de igualdad sobre columnas numéricas y de fecha.
const CASES = [
['shipment', 'created_at', '>=', '2026-06-01'],
['shipment', 'created_at', '<', '2026-02-15'],
['shipment', 'weight_kg', '<', '5'],
['shipment', 'weight_kg', '>', '40'],
['shipment', 'price', '<=', '30'],
['shipment', 'declared_value', '>', '1000'],
['trip', 'departure_at', '<', '2026-03-01'],
['shipment_event', 'at', '<', '2026-04-01'],
['shipment', 'sender_id', '=', '5'],
['shipment', 'sender_id', '=', '7'],
];
for (const [table, column, op, value] of CASES) {
test(`compare: ${table}.${column} ${op} ${value} da lo mismo que EXPLAIN`, async () => {
const r = await compare(client, table, column, op, value);
assert.equal(r.estimated, r.explain);
// Y en estos datos se acerca a lo real: a menos de 1 % o de 2 filas.
assert.ok(Math.abs(r.estimated - r.actual) <= Math.max(2, 0.01 * r.actual),
`estimado ${r.estimated}, real ${r.actual}`);
});
}
test('compare: las cifras del capítulo', async () => {
const c = await compare(client, 'shipment', 'created_at', '>=', '2026-06-01');
assert.deepEqual([c.estimated, c.explain, c.actual], [1891, 1891, 1889]);
close(c.selectivity, 0.157552, 1e-6);
const w = await compare(client, 'shipment', 'weight_kg', '<', '5');
assert.deepEqual([w.estimated, w.explain, w.actual], [2060, 2060, 2068]);
});
test('compare: el valor va como parámetro, no como texto de la consulta', async () => {
// Si el valor se pegara en el SQL, esto sería otra consulta; como parámetro
// es solo una fecha inválida.
await assert.rejects(compare(client, 'shipment', 'created_at', '>=', "2026-01-01' OR true --"),
/invalid input syntax/);
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 12: estadísticas, estimación de cardinalidades y planes de ejecución.
# Se usa dentro del contenedor del curso.
#
# make crea la base de datos chasqui_w12 con los datos de ejemplo y
# hace VACUUM (ANALYZE): así las tablas no tienen páginas vacías
# al final y las estadísticas salen siempre iguales
# make test la vuelve a crear y corre las pruebas (*.test.mjs)
# make clean borra chasqui_w12
#
# Los .sql se corren sobre chasqui_w12, por ejemplo
# psql -d chasqui_w12 -f stats.sql
# y selectivity.mjs con
# PGDATABASE=chasqui_w12 node selectivity.mjs shipment created_at '>=' 2026-06-01
DB = chasqui_w12
PSQL = psql -X -q -v ON_ERROR_STOP=1 -d $(DB)
.PHONY: all test clean
all:
chasqui-load $(DB)
$(PSQL) -c "VACUUM (ANALYZE)"
# Las pruebas crean índices, tablas y estadísticas y corren ANALYZE: se
# corren una después de otra.
test: all
PGDATABASE=$(DB) node --test --test-concurrency=1 --test-reporter=spec
clean:
dropdb --if-exists $(DB)