Esto es fantasía (Parte 3).
Lanzamos el 1 de enero de 2026. En febrero separamos la infraestructura: base de datos en RDS, dos instancias detrás de un load balancer, y el sistema voló. Se sentía como el día del lanzamiento. Para marzo, llegamos a los 10,000 usuarios únicos al mes.
Y entonces la base de datos empezó a arder.
El síntoma
PostgreSQL alcanzaba el 100% de CPU durante horas. La memoria se disparaba sin control. Los queries que antes respondían en milisegundos empezaban a acumular segundos. En horas pico, era una bola de nieve: muchos usuarios generando consultas pesadas al mismo tiempo, cada una más lenta que la anterior, hasta que el sistema colapsaba.
La solución de emergencia: reiniciar la base de datos. Hasta 20 minutos de inactividad total mientras RDS se recuperaba.
La solución de fuerza bruta: escalar la instancia. Funcionó unos días, como siempre. Pero volvíamos al punto de partida porque el problema no era el tamaño del servidor, sino cómo lo estábamos usando.
¿Por qué crecía tan rápido?
Nuestra app tiene una particularidad: un usuario nuevo no empieza en cero. Al registrarse, conecta sus cuentas bancarias y el sistema importa tarjetas, movimientos y balances desde el primer segundo. Un solo registro puede significar miles de inserciones.
Más usuarios → más datos desde el día uno → más presión sobre la base de datos.
Paso 1: Índices
Cuando analizamos los queries lentos, descubrimos algo vergonzoso: solo el id estaba indexado. Pero las búsquedas reales las hacíamos por fecha, email, username y account_id. Sin índices en esos campos, cada consulta hacía un sequential scan — recorría la tabla entera.
Con una base de datos en crecimiento constante, eso es insostenible.
-- Antes: sequential scan en cada consulta
EXPLAIN ANALYZE SELECT * FROM transactions WHERE account_id = 'abc-123' AND date >= '2026-01-01';
-- Seq Scan on transactions (cost=0.00..45892.00 rows=234 width=128)
-- Execution Time: 1,842.531 ms
-- Después: creamos los índices que faltaban
CREATE INDEX idx_transactions_account_date ON transactions (account_id, date);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_username ON users (username);
CREATE INDEX idx_accounts_user_id ON accounts (user_id);
-- Resultado: index scan
EXPLAIN ANALYZE SELECT * FROM transactions WHERE account_id = 'abc-123' AND date >= '2026-01-01';
-- Index Scan using idx_transactions_account_date (cost=0.42..18.67 rows=234 width=128)
-- Execution Time: 2.341 ms
De 1,842ms a 2ms. La mejora fue inmediata y brutal.
Lección: Los índices no son una optimización prematura. Son lo mínimo que necesitás para que una base de datos funcione en producción.
Paso 2: Separar lecturas de escrituras
Con los índices resueltos, apareció otro problema. Cuando un usuario se registraba e importaba sus datos financieros, la base de datos se frenaba para todos.
Analizamos el patrón de tráfico y encontramos una proporción de 1:1,000 — por cada escritura, había casi mil lecturas. El problema es que las inserciones no solo escriben datos: también actualizan los índices y ocasionalmente disparan un rebalanceo del B-tree. Mientras eso pasa, las lecturas esperan.
La solución: un cluster de RDS con réplicas de lectura.
┌──────────────────────┐
│ RDS Writer │
│ (db.r6g.xlarge) │
│ │
│ INSERT / UPDATE │
└──────────▲─────────────┘
│
Replicación
asíncrona
│
┌──────────────────────┼──────────────────────┐
│ │ │
┌────────▼─────────┐ ┌────────▼─────────┐ ┌────────▼─────────┐
│ Reader 1 │ │ Reader 2 │ │ Reader 3 │
│ (db.r6g.medium) │ │ (db.r6g.medium) │ │ (db.r6g.medium) │
│ SELECT (reportes)│ │ SELECT (app) │ │ SELECT (app) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
En la API, la implementación es directa. Dos conexiones, una para cada rol:
// datasource.ts
const writerPool = new Pool({
host: process.env.RDS_WRITER_ENDPOINT,
// finanzas-db.cluster-cxyz.us-west-2.rds.amazonaws.com
});
const readerPool = new Pool({
host: process.env.RDS_READER_ENDPOINT,
// finanzas-db.cluster-ro-cxyz.us-west-2.rds.amazonaws.com
});
export const db = {
write: writerPool, // INSERT, UPDATE, DELETE
read: readerPool, // SELECT
};
// Uso en la API
// Lectura → va a las réplicas
const transactions = await db.read.query(
'SELECT * FROM transactions WHERE account_id = $1 AND date >= $2',
[accountId, startDate]
);
// Escritura → va al writer
await db.write.query(
'INSERT INTO transactions (account_id, amount, date) VALUES ($1, $2, $3)',
[accountId, amount, date]
);
RDS distribuye automáticamente las lecturas entre las réplicas. El resultado: las inserciones masivas de un registro nuevo ya no bloquean las consultas de los 9,999 usuarios restantes.
Paso 3: Connection Pooling
Con más servicios conectándose a la base de datos, empezamos a ver bloqueos que no tenían sentido. El CPU estaba en 20%, la memoria tranquila, pero la base de datos no respondía.
El problema: las conexiones. PostgreSQL crea un proceso por cada conexión. Nuestros servicios abrían conexiones, las mantenían activas mientras procesaban la respuesta HTTP, y las nuevas solicitudes se quedaban esperando porque PostgreSQL había alcanzado su límite de conexiones (max_connections).
Implementamos PgBouncer como connection pooler entre la API y RDS:
┌───────────┐ ┌────────────┐ ┌──────────┐
│ API │─────▶│ PgBouncer │─────▶│ RDS │
│ (100 conn)│ │ (20 conn) │ │ │
└───────────┘ └────────────┘ └──────────┘
La API puede abrir 100 conexiones contra PgBouncer, pero PgBouncer solo mantiene 20 conexiones reales contra PostgreSQL. Cuando un proceso termina de usar una conexión, PgBouncer la recicla para el siguiente en la cola.
Menos conexiones activas → menos procesos en PostgreSQL → menos memoria y CPU desperdiciados en overhead de conexión.
El costo del crecimiento
| Concepto | Costo mensual (estimado) |
|---|---|
| 2x EC2 t3.small (API) | ~$30.00 USD |
| RDS Writer (db.r6g.xlarge) | ~$180.00 USD |
| 3x RDS Reader (db.r6g.medium) | ~$135.00 USD |
| Application Load Balancer | ~$20.00 USD |
| Data Transfer & Storage | ~$15.00 USD |
| Total | ~$380 - $400 USD |
De $25 a $85 a $400. El salto es grande, pero la alternativa era seguir reiniciando la base de datos en horas pico y perdiendo usuarios.
Lo que aprendimos
- Los índices no son opcionales. Si hacés queries por un campo, ese campo necesita un índice. Es así de simple.
- Leer y escribir son problemas diferentes. Separarlos te da control sobre cada uno.
- Las conexiones son un recurso finito. Connection pooling no es una optimización: es una necesidad a partir de cierta escala.
- Escalar verticalmente es un parche. Comprar más CPU aplaza el problema. Entender el problema lo resuelve.
¿Qué sigue?
Con 10,000 usuarios, la base de datos respira. Pero hay algo que no tiene sentido: el dashboard de un usuario muestra los mismos datos todo el día — el balance, las últimas transacciones, los presupuestos — y cada vez que abre la app, le pegamos a la base de datos como si fuera la primera vez. Multiplicá eso por miles de usuarios en hora pico y estamos consultando lo mismo una y otra vez. La base de datos ya no es lenta, pero le estamos pidiendo trabajo que no necesita hacer.
Top comments (0)