Skip to content

Materialized Views en Postgres: cambiar actualidad por velocidad

Cómo usar materialized views para acelerar agregaciones costosas en Postgres sin servir a escondidas datos desactualizados a tus usuarios.

Publicado el 22 de agosto de 20266 min de lectura
Diagrama del plan de consulta de Postgres que muestra un pipeline de actualización de una materialized view

Toda consulta de dashboard que agrega millones de filas al vuelo es una bomba de tiempo. Funciona bien con datos de prueba, se vuelve lenta con datos reales, y tarde o temprano alguien agrega un cron job que ejecuta esa misma consulta costosa cada cinco minutos «solo para cachearla». Postgres ya tiene una primitiva para esto — las materialized views — y la mayoría de los equipos no las conoce o las usa mal.

El tradeoff central es simple: las materialized views cambian actualidad por velocidad. Lo difícil no es crearlas; es decidir cuánta desactualización es aceptable y construir una estrategia de actualización que no bloquee tu aplicación mientras se ejecuta.

Qué te da realmente una materialized view

Una view normal es solo una consulta guardada — cada vez que consultas sobre ella, Postgres ejecuta el SQL subyacente. Una materialized view ejecuta la consulta una sola vez, guarda el conjunto de resultados como una tabla física, y sirve las lecturas desde ese snapshot hasta que la actualizas explícitamente.

sqlsql
-- ❌ Recomputed on every request, scans the full orders table
CREATE VIEW daily_revenue AS
SELECT
  date_trunc('day', created_at) AS day,
  sum(amount) AS revenue,
  count(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY 1;
 
-- ✅ Computed once, stored as a table, read is a simple index scan
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT
  date_trunc('day', created_at) AS day,
  sum(amount) AS revenue,
  count(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY 1;
 
CREATE UNIQUE INDEX ON daily_revenue (day);

El índice único no es un adorno opcional — sin él no puedes usar REFRESH MATERIALIZED VIEW CONCURRENTLY, y eso cambia por completo qué tan disruptivas son las actualizaciones.

El problema de bloqueo del que nadie te advierte

El comando de actualización ingenuo bloquea la view para lecturas durante todo el proceso de reconstrucción:

sqlsql
-- ❌ Blocks all SELECTs against daily_revenue until the rebuild finishes
REFRESH MATERIALIZED VIEW daily_revenue;

En una view pequeña esto es invisible. En una view que alimenta un dashboard de cara al cliente con unos segundos de tiempo de reconstrucción, significa timeouts intermitentes en las consultas cada vez que corre el job de actualización. Vi esto causar un incidente en producción donde un job de actualización nocturno «inofensivo» empezó a tardar 40 segundos después de un pico en el volumen de datos, y cada solicitud al dashboard durante esa ventana devolvía un 504.

CONCURRENTLY soluciona esto construyendo una copia nueva de los datos junto a la anterior y haciendo el intercambio de forma atómica:

sqlsql
-- ✅ Readers keep hitting the old snapshot until the new one is ready
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;

Requiere el índice único mencionado antes, consume más disco y CPU durante la actualización (está construyendo una segunda copia, no truncando en el lugar), y tarda más tiempo en completarse. Ese es el tradeoff: las actualizaciones concurrentes son más lentas, pero no bloquean. Para cualquier cosa de cara al usuario, no bloquear gana siempre.

!

REFRESH MATERIALIZED VIEW CONCURRENTLY requiere al menos un índice UNIQUE en la view. Sin él, Postgres lanza un error al momento de actualizar — prueba esto en staging antes de confiar en ello en producción.

Cómo programar actualizaciones sin acumular cron jobs

La mayoría de los equipos conectan pg_cron o un scheduler externo para llamar REFRESH con un timer. Eso funciona, pero un intervalo fijo es una herramienta poco precisa — o actualizas con demasiada frecuencia (CPU desperdiciada) o muy poco (datos desactualizados durante picos de tráfico).

Un patrón mejor es hacer que las actualizaciones sean event-driven, disparadas por el mismo write path que invalida los datos:

sqlsql
-- Track staleness explicitly instead of guessing
CREATE TABLE view_refresh_state (
  view_name text PRIMARY KEY,
  last_refreshed_at timestamptz NOT NULL DEFAULT now(),
  is_stale boolean NOT NULL DEFAULT false
);
 
-- Mark stale whenever an order completes
CREATE OR REPLACE FUNCTION mark_revenue_stale()
RETURNS trigger AS $$
BEGIN
  UPDATE view_refresh_state
  SET is_stale = true
  WHERE view_name = 'daily_revenue';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER orders_mark_stale
AFTER INSERT OR UPDATE OF status ON orders
FOR EACH ROW
WHEN (NEW.status = 'completed')
EXECUTE FUNCTION mark_revenue_stale();

Un worker consulta view_refresh_state, actualiza solo las views marcadas como is_stale, y resetea la marca al terminar con éxito. Así obtienes actualizaciones que ocurren poco después de las escrituras reales en vez de en un reloj arbitrario, y puedes exponer last_refreshed_at directamente en la UI para que los usuarios sepan exactamente qué tan actualizados están los datos — algo que importa más de lo que la mayoría de los equipos admite.

Cómo manejar los patrones de consulta aguas abajo

Las materialized views brillan en flujos de lectura con muchas agregaciones, pero no sustituyen una buena indexación en las tablas base, y no combinan bien con row-level security si necesitas filtrado por tenant incorporado en la misma view.

sqlsql
-- ❌ One giant view mixing all tenants — RLS can't help you here
CREATE MATERIALIZED VIEW tenant_revenue AS
SELECT tenant_id, date_trunc('day', created_at) AS day, sum(amount) AS revenue
FROM orders
GROUP BY 1, 2;
 
-- ✅ Filter at query time on top of the materialized aggregate,
-- with an index that makes the filter cheap
CREATE INDEX ON tenant_revenue (tenant_id, day);
 
SELECT day, revenue
FROM tenant_revenue
WHERE tenant_id = $1
ORDER BY day DESC
LIMIT 30;

Si el aislamiento entre tenants es un requisito de seguridad estricto, no confíes solo en las materialized views — aplícalo en la capa de aplicación o mediante una función security-definer que envuelva la consulta con la cláusula correcta WHERE tenant_id = current_tenant().

Cuándo las materialized views son la herramienta equivocada

EscenarioMejor enfoquePor qué
Los datos deben ser en tiempo real (menos de un segundo)View normal o consulta directa con buenos índicesLa materialización siempre introduce retraso
Los datos subyacentes cambian todo el tiempo y la agregación es barataConsulta indexada normalEl costo de actualizar supera el costo de la consulta
El conjunto de resultados es enorme (miles de millones de filas)Tabla de agregación incremental mantenida por triggersUna actualización completa se vuelve demasiado costosa de ejecutar
Necesitas filtrado por solicitud con ACLs complejasCache en la capa de aplicación (Redis)Las materialized views no entienden el contexto de la solicitud

El error que veo con más frecuencia es recurrir a una materialized view como un botón genérico de «hazlo rápido» sin preguntarse con qué frecuencia cambian realmente los datos subyacentes. Si tu tabla orders recibe miles de escrituras por segundo, un trigger ingenuo que actualiza en cada escritura va a saturar tu worker de actualización constantemente — necesitas debounce o ventanas de actualización por lotes, no triggers por fila.

Actualización con debounce en la práctica

tstypescript
// ❌ Refreshes on every single write — thrashes the database
async function onOrderCompleted(order: Order) {
  await db.query("REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue");
}
 
// ✅ Batches refreshes with a debounce window
class ViewRefresher {
  private pending = new Map<string, NodeJS.Timeout>();
 
  schedule(viewName: string, debounceMs = 5000): void {
    const existing = this.pending.get(viewName);
    if (existing) clearTimeout(existing);
 
    const timer = setTimeout(async () => {
      this.pending.delete(viewName);
      try {
        await db.query(
          `REFRESH MATERIALIZED VIEW CONCURRENTLY ${viewName}`,
        );
      } catch (error) {
        console.error(`Refresh failed for ${viewName}`, error);
        // Retry logic or dead-letter queue goes here
      }
    }, debounceMs);
 
    this.pending.set(viewName, timer);
  }
}

Esto te da una actualidad predecible (peor caso: debounceMs de retraso respecto al tiempo real) sin convertir cada escritura en una reconstrucción de toda la base de datos.

Puntos clave

  1. Crea siempre un índice único en las materialized views que planees actualizar de forma concurrente — es la diferencia entre una actualización no bloqueante y una caída en producción.
  2. Expón la desactualización a tus usuarios en vez de ocultarla — una etiqueta de «actualizado hace 2 minutos» genera más confianza que una inconsistencia silenciosa.
  3. Dispara las actualizaciones desde las escrituras, no desde timers fijos — la invalidación event-driven mantiene los datos más frescos sin desperdiciar ciclos en datos que no cambiaron.
  4. Aplica debounce a los disparadores de actualización en tablas con muchas escrituras para evitar que cada insert se convierta en una reconstrucción completa de la view.
  5. No uses materialized views para requisitos en tiempo real o ACLs por tenant — resuelven el costo de agregación, no la actualidad de los datos ni el control de acceso.

Las materialized views son una de las herramientas de mayor impacto en Postgres para cargas de trabajo analíticas con muchas lecturas, pero suelen desplegarse sin cuidado — como un cache sin estrategia de invalidación y sin visibilidad sobre qué tan desactualizados están los datos. Trata la estrategia de actualización como una decisión de diseño de primera clase, no como algo secundario, y te van a ahorrar tener que construir una capa de cache separada para problemas que Postgres ya resuelve.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX