Postgres ya hace lo que estás por instalar

Hay un momento en casi todo proyecto en que alguien dice “necesitamos Redis para la cola”. Después “Elasticsearch para el buscador”. Después una base vectorial. Cada uno resuelve un problema real, y cada uno agrega un servicio que hay que desplegar, monitorear, respaldar, actualizar y mantener consistente con los datos que ya viven en otro lado.
Muchas de esas necesidades las cubre Postgres, que ya está instalado y del que ya tenés copias de seguridad. Este artículo muestra el SQL concreto de cuatro casos, y dónde está el límite real de cada uno.
Una cola de trabajos, con SKIP LOCKED
El caso más frecuente. La tabla:
CREATE TABLE trabajos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tipo text NOT NULL,
payload jsonb NOT NULL,
estado text NOT NULL DEFAULT 'pendiente',
intentos int NOT NULL DEFAULT 0,
ejecutar_en timestamptz NOT NULL DEFAULT now(),
error text
);
-- Índice parcial: solo indexa lo pendiente, que es lo único que se consulta.
-- Cuando un trabajo se completa, sale del índice solo.
CREATE INDEX trabajos_pendientes
ON trabajos (ejecutar_en)
WHERE estado = 'pendiente';
Y la parte que importa, tomar el siguiente trabajo:
UPDATE trabajos
SET estado = 'procesando',
intentos = intentos + 1
WHERE id = (
SELECT id
FROM trabajos
WHERE estado = 'pendiente'
AND ejecutar_en <= now()
ORDER BY ejecutar_en
FOR UPDATE SKIP LOCKED -- ← acá está todo
LIMIT 1
)
RETURNING *;
FOR UPDATE SKIP LOCKED es la instrucción que hace viable esto. Sin ella,
diez procesos trabajadores compiten por la misma fila: nueve quedan esperando a
que el primero termine su transacción, y la cola procesa de a uno. Con ella,
cada proceso que encuentra una fila bloqueada la saltea y toma la siguiente. Los
diez trabajan en paralelo sin coordinarse entre sí.
Los reintentos con espera creciente son una línea:
UPDATE trabajos
SET estado = 'pendiente',
ejecutar_en = now() + (interval '1 minute' * power(2, intentos)),
error = $2
WHERE id = $1;
Y a diferencia de una cola externa, encolar el trabajo y modificar tus datos ocurren en la misma transacción. Si la operación falla, el trabajo no queda encolado. Con Redis o RabbitMQ eso hay que resolverlo aparte, y es una fuente conocida de inconsistencias.
Búsqueda de texto completo
Postgres tiene un buscador con análisis morfológico incorporado. La forma moderna de usarlo es con una columna generada, que se mantiene sola:
ALTER TABLE articulos ADD COLUMN busqueda tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('spanish', coalesce(titulo, '')), 'A') ||
setweight(to_tsvector('spanish', coalesce(resumen, '')), 'B') ||
setweight(to_tsvector('spanish', coalesce(cuerpo, '')), 'C')
) STORED;
CREATE INDEX articulos_busqueda ON articulos USING GIN (busqueda);
setweight marca la importancia de cada parte: una coincidencia en el título
vale más que una en el cuerpo. Y 'spanish' activa el análisis morfológico del
idioma, así que buscar “consulta” encuentra “consultas” y “consultando”.
Para la consulta, usá websearch_to_tsquery y no to_tsquery:
SELECT titulo,
ts_rank(busqueda, q) AS relevancia,
ts_headline('spanish', cuerpo, q) AS fragmento
FROM articulos, websearch_to_tsquery('spanish', $1) q
WHERE busqueda @@ q
ORDER BY relevancia DESC
LIMIT 20;
websearch_to_tsquery acepta lo que la gente escribe de verdad: comillas
para frases exactas, or, y -palabra para excluir. to_tsquery exige una
sintaxis con operadores y lanza un error ante una entrada mal formada, que con
texto que viene de un usuario es todo el tiempo.
ts_headline devuelve el fragmento con los términos resaltados, que es lo que
mostrás en los resultados.
Caché, con tablas sin registro
Para valores calculados que se pueden perder:
CREATE UNLOGGED TABLE cache (
clave text PRIMARY KEY,
valor jsonb NOT NULL,
vence timestamptz NOT NULL
);
CREATE INDEX ON cache (vence);
UNLOGGED es la palabra clave. Una tabla así no escribe en el registro de
transacciones, que es la parte más costosa de cada escritura. A cambio, se vacía
si el servidor se cae. Para una caché eso no es una limitación: es exactamente
el comportamiento correcto.
Leer y escribir:
-- Leer, ignorando lo vencido
SELECT valor FROM cache WHERE clave = $1 AND vence > now();
-- Escribir o reemplazar
INSERT INTO cache (clave, valor, vence)
VALUES ($1, $2, now() + interval '15 minutes')
ON CONFLICT (clave) DO UPDATE
SET valor = excluded.valor, vence = excluded.vence;
La limpieza, en un trabajo periódico: DELETE FROM cache WHERE vence < now();
Eventos entre procesos
Cuando un proceso necesita avisarle a otro que algo pasó, sin encuestar la base cada segundo:
-- Quien escucha
LISTEN pedidos_nuevos;
-- Quien avisa, dentro de la misma transacción que hizo el cambio
NOTIFY pedidos_nuevos, '{"pedido_id": 4821}';
La notificación se entrega solo si la transacción se confirma, lo cual elimina el caso de avisar sobre algo que después se revirtió.
Combinado con la cola de arriba, los procesos trabajadores dejan de consultar cada segundo y reaccionan al aviso. La carga base de la base de datos baja bastante en sistemas con muchos procesos en espera.
Búsqueda por significado
Es lo más nuevo. PostgreSQL 18 incorporó el tipo vectorial y los índices HNSW
e IVFFlat al motor; en versiones anteriores hacía falta instalar la extensión
pgvector.
Consiste en convertir cada texto en una lista de números que representa su significado —un embedding— y buscar los que están más cerca:
CREATE TABLE documentos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
proyecto_id bigint NOT NULL REFERENCES proyectos(id),
cuerpo text NOT NULL,
embedding vector(1536) NOT NULL
);
CREATE INDEX ON documentos USING hnsw (embedding vector_cosine_ops);
Y acá está la ventaja frente a una base vectorial aparte:
SELECT d.cuerpo, d.embedding <=> $1 AS distancia
FROM documentos d
JOIN proyectos p ON p.id = d.proyecto_id
WHERE p.cliente_id = $2 -- permisos, con las claves de siempre
AND d.creado_en > $3
ORDER BY distancia
LIMIT 10;
El filtro relacional y la búsqueda por parecido ocurren en la misma consulta. Con un servicio separado, esto son dos viajes y un cruce en el código de la aplicación: traer los cien más parecidos, filtrar por permisos, y esperar que queden diez.
Dónde está el límite
Esto no es “Postgres para todo siempre”. Los puntos donde deja de alcanzar:
La cola, cuando pasás de unos pocos miles de trabajos por segundo sostenidos, o cuando necesitás varios consumidores independientes leyendo el mismo flujo. Ahí corresponde Kafka o similar, que están hechos para eso.
La caché, cuando necesitás lecturas por debajo del milisegundo con mucha concurrencia. Postgres tiene que atravesar su capa de transacciones incluso en una tabla sin registro; Redis vive en memoria y no.
La búsqueda, cuando necesitás corrección de errores de tipeo, sinónimos configurables o agregaciones complejas sobre los resultados.
Los vectores, cuando pasás de algunas decenas de millones y necesitás particionar el índice entre varias máquinas.
La regla que uso: no agregues un servicio hasta poder nombrar el número que te obliga a hacerlo. Si no lo tenés medido, todavía no lo necesitás — y cada servicio que sumás es algo más que puede fallar a las tres de la mañana.
Comentarios
Iniciá sesión para comentar y dar "me gusta".
Todavía no hay comentarios. Sé la primera persona en escribir uno.