La pregunta SQL vs NoSQL está mal planteada. No es una guerra entre dos bandos — es un espectro de modelos de datos, cada uno óptimo para un conjunto diferente de restricciones. La pregunta correcta es: ¿qué forma tiene tu problema?
SQL — PostgreSQL como laboratorio
Normalización en la práctica
Las formas normales no son un dogma, son una herramienta para detectar anomalías de escritura:
- 1NF: cada celda, un valor. Sin arrays ni JSON anidado. PostgreSQL viola esto con
JSONByTEXT[]intencionalmente. - 2NF: toda columna no clave depende de la clave completa. Rompes esto cuando tienes una tabla con clave compuesta y un campo que solo depende de una parte.
- 3NF/BCNF: toda columna no clave depende solo de la clave, no de otra columna no clave.
-- Violación de 2NF: category_description depende solo de category_id, no de (order_id, product_id)
CREATE TABLE order_items (
order_id INT, product_id INT,
quantity INT, price DECIMAL,
category_id INT, category_description TEXT,
PRIMARY KEY (order_id, product_id)
);
-- Solución: normalizar categories en tabla separada
CREATE TABLE categories (
id INT PRIMARY KEY, description TEXT
);Indexación estratégica
PostgreSQL tiene 4 tipos de índice principales:
| Índice | Uso óptimo | Cuándo no usarlo |
|---|---|---|
| B-tree | Igualdad, rangos, ORDER BY, LIKE 'foo%' | LIKE '%foo', JSONB, arrays |
| GIN | Arrays, JSONB, full-text search, trigramas | Columnas con pocos valores únicos |
| GiST | Geometría, búsqueda por rango, trigramas (pg_trgm) | Consultas de igualdad exacta |
| BRIN | Tablas muy grandes con orden físico correlacionado | Datos aleatorios sin orden |
-- Índice parcial: solo indexa lo que realmente consultas
CREATE INDEX idx_orders_active ON orders (created_at)
WHERE status = 'pending';
-- Índice cubriente (covering index): evita acceder a la tabla
CREATE INDEX idx_users_email_cover ON users (email) INCLUDE (name, avatar_url);EXPLAIN ANALYZE te muestra si PostgreSQL está usando tus índices:
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending' AND created_at > NOW() - INTERVAL '7 days';Ventanas y CTEs — SQL de nivel superior
-- Window function: ranking sin auto-join
SELECT
student_id, course_id, score,
RANK() OVER (PARTITION BY course_id ORDER BY score DESC) as position,
AVG(score) OVER (PARTITION BY course_id) as avg_score
FROM enrollments;
-- CTE recursiva para árboles (categorías, organigramas, comentarios)
WITH RECURSIVE tree AS (
SELECT id, name, parent_id, 1 as depth
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1
FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY depth, name;JSONB en PostgreSQL
Cuando necesitas esquema flexible dentro de lo relacional:
CREATE TABLE events (
id UUID PRIMARY KEY,
type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_events_payload ON events USING GIN (payload jsonb_path_ops);
-- Consultar dentro de JSONB
SELECT * FROM events
WHERE payload @> '{"severity": "critical", "source": "auth"}';El jsonb_path_ops acelera consultas de contención (@>) a costa de no soportar otros operadores. El GIN default soporta todos (?, ?|, ?&, @>).
NoSQL por modelo
Documentos — MongoDB
// Aggregation pipeline — el equivalente a GROUP BY + JOIN + FILTER
db.orders.aggregate([
{ $match: { status: "completed", date: { $gte: ISODate("2026-01-01") } } },
{ $unwind: "$items" },
{ $group: { _id: "$items.category", total: { $sum: "$items.price" }, count: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $lookup: { from: "categories", localField: "_id", foreignField: "code", as: "category" } }
])Patrones de esquema para documentos:
- Bucket: agrupa datos de tiempo en buckets (por hora/día) para reducir documentos.
- Polymorphic: un campo
typediscrimina la estructura del documento. - Computed: precalcula agregados al momento de escritura.
- Approximation: usa contadores aproximados (como hiperloglog) cuando la precisión exacta no importa.
Anti-patrón: usar $lookup para todo. Si necesitas joins frecuentes, estás usando la DB equivocada — el modelo documental brilla por inclusión, no por referencia.
Clave-valor — Redis
Redis no es solo caché. Sus estructuras de datos nativas permiten patrones poderosos:
// Streams — message queue durable
XADD logs:auth * user_id "123" action "login" ip "10.0.0.1"
XREAD COUNT 10 BLOCK 5000 STREAMS logs:auth 0
// Sorted sets — leaderboards en tiempo real
ZADD leaderboard:2026 1500 "user:1" 2000 "user:2"
ZREVRANGE leaderboard:2026 0 9 WITHSCORESPersistencia: RDB (snapshot periódico) vs AOF (write-ahead log). AOF es más durable pero más lento. La configuración típica es RDB cada 5 minutos + AOF cada segundo.
Wide-column — Cassandra
Cassandra modela alrededor de las queries, no de las entidades:
CREATE TABLE user_timeline (
user_id UUID,
post_time TIMESTAMP,
post_id UUID,
content TEXT,
PRIMARY KEY (user_id, post_time, post_id)
) WITH CLUSTERING ORDER BY (post_time DESC);
-- Esta query es eficiente porque el clustering key lo ordena por tiempo descendente
SELECT * FROM user_timeline WHERE user_id = ? LIMIT 20;Compaction strategies: STCS (Size-Tiered — write-heavy), LCS (Leveled — read-heavy), TWCS (Time-Window — time-series).
Grafos — Neo4j
// Encontrar el camino más corto entre dos usuarios
MATCH (a:User {id: "1"}), (b:User {id: "100"})
MATCH path = shortestPath((a)-[:FOLLOWS|:MENTIONS*]-(b))
RETURN pathCuando los datos son inherentemente conectados (recomendaciones, detección de fraude, redes sociales), el modelo gráfico supera a SQL en órdenes de magnitud para queries de travesía.
CAP y consistencia
CAP no es "elige 2 de 3" — es: durante una partición de red, eliges entre consistencia y disponibilidad. El teorema PACELC extiende esto: incluso sin partición, hay tradeoff entre latencia y consistencia.
| Sistema | Durante partición | Normal |
|---|---|---|
| PostgreSQL | Consistente (no disponible en nodos caídos) | Fuerte |
| MongoDB (default) | Disponible (lecturas eventuales) | Eventual |
| Cassandra | Disponible (consistencia configurable por query) | Tunable |
| Redis Cluster | Disponible (pérdida de writes en partición) | Fuerte por nodo |
Eventual consistency no es opcional en sistemas distribuidos. Los patrones para manejarla: CRDTs (conflict-free replicated data types), vector clocks, last-write-wins con timestamps.
SQL Injection — la amenaza real
// ❌ Esto es SQL injection aunque tu backend esté tipado
const query = `SELECT * FROM users WHERE email = '${userInput}'`
// ✅ Prepared statement — la ÚNICA defensa correcta
db.query("SELECT * FROM users WHERE email = $1", [userInput])El peligro no es solo ' OR 1=1 --. Existen inyecciones a ciegas (blind boolean-based, time-based), error-based (extraer datos mediante mensajes de error), y second-order (el payload se almacena y ejecuta después).
Los ORMs no te salvan automáticamente. Prisma parameteriza por defecto, pero $queryRawUnsafe existe. Knex permite raw queries sin parámetros. Siempre verifica que tu ORM esté generando prepared statements a nivel de driver.
Framework de decisión
| Necesitas | Elige | Por qué |
|---|---|---|
| Joins complejos, transacciones, reportes | PostgreSQL | Madurez, extensibilidad, ACID |
| Write-heavy, multi-región, queries conocidas | Cassandra | Escrituras lineales, sin SPOF |
| Tiempo real, sesiones, rate-limiting | Redis | Operaciones en memoria, estructuras nativas |
| Prototipado rápido, esquema flexible | MongoDB | Documentos embebidos, sin migraciones |
| Grafos de relación, recomendaciones | Neo4j | Travesía en O(log n) vs O(n^m) en SQL |
La base de datos no es una decisión secundaria. Modela tus datos primero, elige el motor después. Una elección incorrecta al inicio = migración costosa al final.