Saltar al contenido principal

💬 Casos Prácticos: Chat, Pagos y Búsqueda

Ejemplo: Diseñar un sistema de chat (tipo WhatsApp)

Requerimientos

Funcionales:
- Mensajes 1:1 y grupos (hasta 500 miembros)
- Indicadores de entrega (enviado ✓, entregado ✓✓, leído ✓✓ azul)
- Presencia online/offline
- Historial de mensajes

No funcionales:
- 50M DAU, cada usuario envía ~40 msgs/día
- Writes: ~23.000 msg/seg
- Latencia < 500ms para entrega
- Alta disponibilidad (99.99%)

Estimaciones

Mensajes:
50M usuarios × 40 msgs/día = 2.000M msgs/día
2.000M / 86.400 seg ≈ 23.000 msgs/seg (writes)
Reads: ~5× → 115.000 msgs/seg

Storage:
Asumiendo retención de 5 años, ~100 bytes/msg:
2.000M × 365 × 5 × 100 bytes ≈ 365 TB

Conexiones WebSocket simultáneas:
50M DAU con ~10% concurrencia ≈ 5M conexiones activas

Arquitectura

                         ┌──────────────────────────┐
Clientes ──WebSocket──→ │ Chat Servers │
│ (stateful, millones de │
│ conexiones por servidor)│
└──────────┬───────────────┘

┌────────────────────┼────────────────────┐
↓ ↓ ↓
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Presence │ │ Message Queue │ │ Push Notif │
│ Service │ │ (Kafka) │ │ Service │
│ (Redis) │ │ │ │ (APNs/FCM) │
└─────────────┘ └────────┬────────┘ └──────────────┘

┌─────────▼──────────┐
│ Message Storage │
│ (Cassandra) │
│ Particionada por │
│ conversation_id │
└────────────────────┘

Flujo de entrega de un mensaje

1. User A (conectado a ChatServer1) envía msg a User B

2. ChatServer1:
a. Persiste msg en Cassandra con status=SENT
b. Publica en Kafka: topic "messages", key=conversation_id

3. Message Router consume de Kafka:
a. Busca en qué ChatServer está conectado User B
(Lookup en Redis: user_id → server_id)
b. Si User B está online → envía el msg al ChatServer de B
c. Si User B está offline → encola en Push Notification Service

4. ChatServer de B entrega por WebSocket al cliente B
B confirma recepción → actualizar status a DELIVERED

5. B abre el mensaje → actualizar status a READ
ChatServer notifica a A el cambio de status

Schema en Cassandra (optimizado para leer por conversación)

-- Partition key: conversation_id (todos los msgs de una conv juntos)
-- Clustering key: created_at DESC (últimos msgs primero)
CREATE TABLE messages (
conversation_id UUID,
created_at TIMESTAMP,
message_id UUID,
sender_id BIGINT,
content TEXT,
status TEXT, -- SENT | DELIVERED | READ
PRIMARY KEY (conversation_id, created_at, message_id)
) WITH CLUSTERING ORDER BY (created_at DESC);

-- Para cargar últimos 50 msgs de una conversación:
-- SELECT * FROM messages WHERE conversation_id = ? LIMIT 50
-- → O(1) porque está en la misma partición

Por qué Cassandra y no PostgreSQL

Chat tiene un patrón de acceso muy específico:
- Siempre lees mensajes de UNA conversación (sin JOINs)
- Writes extremadamente frecuentes (23K/seg)
- Los datos son inmutables (los msgs no se editan, solo se añade status)
- Necesitas escalar horizontalmente sin sharding manual

Cassandra resuelve todo esto nativamente:
- Partición por conversation_id → todos los msgs juntos en disco
- Escala horizontal simple (añadir nodos)
- Alta write throughput
- Tunable consistency (QUORUM para writes importantes)

Ejemplo: Diseñar un sistema de pagos

Por qué es especial

Los sistemas de pagos tienen requisitos únicos:

  • Idempotencia: el mismo pago no puede procesarse dos veces
  • Atomicidad: débito y crédito ocurren juntos o ninguno
  • Auditabilidad: todo debe ser trazable y reversible

Requerimientos

Funcionales:
- Procesar pagos entre usuarios (débito de origen, crédito a destino)
- Soportar múltiples métodos: tarjeta, wallet, transferencia
- Historial de transacciones
- Reembolsos

No funcionales:
- 1M transacciones/día = ~12 TPS en promedio, picos de 100 TPS
- Consistency FUERTE (nada de eventual consistency aquí)
- 99.999% availability (5 nines)
- Idempotencia garantizada

Patrón Idempotency Key

El cliente genera un idempotency_key único (UUID) antes de enviar.
Si el request falla o hay timeout, puede reenviar el MISMO key.
El servidor detecta el key duplicado y devuelve el resultado anterior
sin procesar el pago de nuevo.

Request:
POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
"from": "user_a",
"to": "user_b",
"amount": 100.00,
"currency": "USD"
}
public class PaymentService
{
private readonly AppDbContext _db;
private readonly IDistributedCache _redis;

public async Task<PaymentResult> ProcessPaymentAsync(
PaymentRequest request,
string idempotencyKey)
{
// 1. Verificar idempotencia: ¿ya procesamos este key?
var existingResult = await _redis.GetStringAsync($"idempotency:{idempotencyKey}");
if (existingResult is not null)
return JsonSerializer.Deserialize<PaymentResult>(existingResult)!;

// 2. Adquirir lock distribuido para este idempotency key
// (evitar race condition si dos requests llegan simultáneamente)
var lockKey = $"lock:payment:{idempotencyKey}";
var lockAcquired = await TryAcquireLockAsync(lockKey);
if (!lockAcquired) throw new ConcurrentPaymentException();

try
{
// 3. Doble verificación después del lock
existingResult = await _redis.GetStringAsync($"idempotency:{idempotencyKey}");
if (existingResult is not null)
return JsonSerializer.Deserialize<PaymentResult>(existingResult)!;

// 4. Ejecutar la transacción ACID en la DB
using var transaction = await _db.Database.BeginTransactionAsync(
IsolationLevel.Serializable); // El más estricto, evita phantom reads

var from = await _db.Accounts
.Where(a => a.UserId == request.FromUserId)
.FirstOrDefaultAsync()
?? throw new AccountNotFoundException();

if (from.Balance < request.Amount)
throw new InsufficientFundsException();

from.Balance -= request.Amount;

var to = await _db.Accounts
.Where(a => a.UserId == request.ToUserId)
.FirstOrDefaultAsync()
?? throw new AccountNotFoundException();

to.Balance += request.Amount;

// 5. Registrar en el ledger (inmutable)
_db.Transactions.Add(new Transaction
{
Id = Guid.NewGuid(),
IdempotencyKey = idempotencyKey,
FromAccountId = from.Id,
ToAccountId = to.Id,
Amount = request.Amount,
Currency = request.Currency,
Status = "COMPLETED",
CreatedAt = DateTime.UtcNow
});

await _db.SaveChangesAsync();
await transaction.CommitAsync();

var result = new PaymentResult { Success = true, TransactionId = Guid.NewGuid() };

// 6. Guardar resultado en caché de idempotencia (TTL 24h)
await _redis.SetStringAsync(
$"idempotency:{idempotencyKey}",
JsonSerializer.Serialize(result),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});

return result;
}
finally
{
await ReleaseLockAsync(lockKey);
}
}
}

Double-Entry Ledger (Libro mayor de doble entrada)

Principio contable fundamental: cada transacción tiene DÉBITO y CRÉDITO.
Los saldos nunca se borran, solo se añaden entradas.

Tabla transactions (append-only, nunca UPDATE ni DELETE):
┌──────────────┬───────────────┬──────────────┬──────────┬───────┐
│ tx_id │ account_id │ amount │ type │ ref │
├──────────────┼───────────────┼──────────────┼──────────┼───────┤
│ tx001 │ account_A │ -100.00 │ DEBIT │ pay01 │
│ tx002 │ account_B │ +100.00 │ CREDIT │ pay01 │
│ tx003 │ account_A │ +100.00 │ CREDIT │ ref01 │ ← reembolso
│ tx004 │ account_B │ -100.00 │ DEBIT │ ref01 │
└──────────────┴───────────────┴──────────────┴──────────┴───────┘

Balance de account_A = SUM(amount) = -100 + 100 = 0
Balance de account_B = SUM(amount) = +100 - 100 = 0

Ventaja: Auditoría completa. Cualquier discrepancia es detectable.
El saldo siempre se puede recalcular desde cero.

Manejo de fallos con Saga Pattern

Problema: un pago puede involucrar múltiples servicios (bank, fraud-check, ledger).
Las transacciones distribuidas con 2PC son lentas y frágiles.

SAGA PATTERN: secuencia de transacciones locales + compensaciones.

Pago exitoso:
1. ReservarFondos(from) → OK
2. FraudCheck() → OK
3. TransferirFondos(from, to) → OK
4. NotificarUsuarios() → OK

Pago fallido en paso 3:
3. TransferirFondos → FALLA
← LiberarFondos(from) [compensación de paso 1]

Cada paso tiene una transacción de compensación para deshacer su efecto.

Ejemplo: Diseñar un sistema de búsqueda (tipo Elasticsearch)

Concepto clave: Índice Invertido

ÍNDICE NORMAL (tabla → fila):
doc_id 1: "el gato come pescado"
doc_id 2: "el perro come carne"
doc_id 3: "el gato duerme"

ÍNDICE INVERTIDO (palabra → documentos que la contienen):
"el" → [1, 2, 3]
"gato" → [1, 3]
"come" → [1, 2]
"pescado" → [1]
"perro" → [2]
"carne" → [2]
"duerme" → [3]

Buscar "gato come" → intersección de [1,3] y [1,2] = [1]
Resultado: doc_id 1, en O(1) sin escanear todos los documentos.

Arquitectura de ingesta

Datos fuente


┌───────────────┐
│ Indexer │ ← Tokenización, stemming, stop words
│ Service │ "gatos" → "gat" (stem)
│ │ "el, la, los" → ignorar (stop words)
└───────┬───────┘


┌───────────────┐ ┌──────────────────────┐
│ Kafka │────→│ Index Shards │
│ (buffer de │ │ (Lucene segments) │
│ ingesta) │ │ Shard por rango │
└───────────────┘ │ de doc_ids │
└──────────────────────┘

Arquitectura de consulta

Query: "gato AND come NOT perro"


┌───────────────────┐
│ Query Parser │ ← Parsea sintaxis, expande sinónimos
└──────────┬────────┘

↓ (broadcast a todos los shards en paralelo)
┌──────────┬──────────┬──────────┐
│ Shard 1 │ Shard 2 │ Shard 3 │ ← Cada shard evalúa la query
│ results │ results │ results │ y calcula relevancia (TF-IDF)
└──────────┴──────────┴──────────┘


┌───────────────────┐
│ Result Merger │ ← Merge de resultados, ranking global
│ & Ranker │
└──────────┬────────┘


Top-K resultados al cliente

TF-IDF (Relevancia básica)

TF (Term Frequency): cuántas veces aparece el término en el doc
TF("gato", doc1) = 2/10 = 0.2 (2 ocurrencias en 10 palabras)

IDF (Inverse Document Frequency): cuán raro es el término en el corpus
IDF("gato") = log(N_docs / N_docs_con_gato) = log(1M / 50K) = 3.0

Palabras raras tienen IDF alto → más discriminativas
Palabras comunes ("el", "de") tienen IDF bajo → menos relevantes

TF-IDF = TF × IDF
→ Docs que usan el término frecuentemente Y el término es raro = alta relevancia

Checklist de System Design para entrevistas

Antes de empezar a diseñar, preguntar siempre:

ESCALA:
□ ¿Cuántos usuarios (DAU/MAU)?
□ ¿Cuántas requests por segundo (peak/promedio)?
□ ¿Cuánto storage necesitamos?

CONSISTENCIA:
□ ¿Necesitamos consistencia fuerte o eventual es aceptable?
□ ¿Qué pasa si perdemos un dato? ¿Cuánto durability necesitamos?

DISPONIBILIDAD:
□ ¿Cuánto downtime es aceptable? (99.9% = 8.7h/año, 99.99% = 52min/año)
□ ¿Necesitamos multi-región?

LATENCIA:
□ ¿Cuál es la latencia máxima aceptable (p99)?
□ ¿Es lectura o escritura el path crítico?

Los 6 trade-offs que siempre aparecen

Trade-offOpción AOpción B
Consistencia vs DisponibilidadCP (SQL)AP (Cassandra)
Latencia vs ConsistenciaCaché (rápido, puede ser stale)DB directa (lento, siempre fresco)
Normalización vs DenormalizaciónSin redundancia, JOINsRedundancia, lecturas rápidas
Push vs Pull (fanout)Write costoso, read baratoWrite barato, read costoso
Sharding vs ReplicaciónEscala writesEscala reads
SQL vs NoSQLACID, JOINs, schema fijoEscala horizontal, schema flexible