lección 6
Día 4 (3AM) -- El incidente: transacciones duplicadas
PagerDuty te despierta: OOM en el streaming job. Diagnóstico bajo presión, data skew por merchant, hotfix con cuarentena y salting como solución permanente.
⏱ 45 min
### Viernes, 3:12 AM -- PagerDuty
Tu teléfono vibra con la intensidad de una alarma nuclear. La pantalla muestra: "CRITICAL -- neobank-txn-streaming OOM killed. Consumer lag > 50,000 events. Feature store stale > 15 min." Te despiertas de golpe con el corazón a 180. El job de streaming que desplegaste ayer -- tu pipeline, tu código, tu responsabilidad -- se ha caído en producción.
1# Slack #incidents -- 3:14 AM2Paula García:3"Vi la alerta. Estoy mirando los logs de EMR. Executor 3 OOM4killed a las 03:08. El job no se ha recuperado. Consumer lag5subiendo: 50K... 80K... ya va por 120K eventos sin procesar.67Te necesito online YA. Tú conoces el job mejor que nadie --8acabas de escribirlo. Yo abro el Spark UI y CloudWatch.9Tú miras los datos del último micro-batch exitoso vs el fallido."1011Tú (3:16 AM):12"Estoy. Dame 2 min que abro el portátil. ¿El batch nocturno13también cayó o es solo streaming?"1415Paula García:16"Solo streaming. Batch está programado para las 4:00, todavía17no ha arrancado. Pero si no restauramos antes de las 4:00,18el batch va a intentar leer de un topic con 200K de lag y19puede cascadear. Reloj corriendo."
Slack del incidente -- Paula y tú coordinando a las 3AM
### Diagnóstico -- el merchant maldito
Abres Spark UI y CloudWatch en paralelo. La timeline del incidente se reconstruye rápido:
- 02:47 -- Micro-batch inusualmente grande entra en procesamiento
- 02:53 -- Executor 3 sube a 14.2GB de memoria (límite configurado: 15GB)
- 03:01 -- GC pauses de >30 segundos. Executor no responde al heartbeat del driver
- 03:08 -- Spark mata executor 3 por OOM. Tasks se redistribuyen a executor 1 y 2
- 03:09 -- Executor 1 también OOM al recibir las tasks redistribuidas (cascada)
- 03:12 -- Job completo falla. PagerDuty dispara alerta a Paula y a ti
- 03:12 -- Consumer lag empieza a crecer: 50K, 100K, 150K, 200K eventos sin procesar
Revisas los datos del último micro-batch exitoso (02:46) vs el fallido (02:47). La diferencia es brutal: el batch fallido contenía 2.8 MILLONES de transacciones de un SOLO merchant -- MERCH-48291. Normalmente un merchant genera entre 100 y 10.000 transacciones al día. Este generó 2.8 millones en 6 minutos. Todas de 0.01 EUR.
1# Diagnóstico ejecutado a las 3:30 AM (query sobre S3 del último batch exitoso)2import pandas as pd34# Distribución del batch problemático5batch_stats = pd.DataFrame({6 "merchant_id": ["MERCH-48291", "MERCH-10001", "MERCH-22340",7 "MERCH-15678", "MERCH-09923", "OTROS (4200+)"],8 "txn_count": [2_800_000, 10_200, 8_750, 7_100, 12_400, 45_000],9 "avg_amount_eur": [0.01, 45.30, 128.50, 22.10, 67.80, 52.40],10 "window_minutes": [6, 60, 60, 60, 60, 60],11})1213total = batch_stats["txn_count"].sum()14normal_avg = batch_stats[batch_stats["merchant_id"] != "MERCH-48291"]["txn_count"].mean()15skew_ratio = 2_800_000 / normal_avg1617print("═══ DIAGNÓSTICO DEL INCIDENTE ═══")18print(f"Total transacciones en batch: {total:,}")19print(f"MERCH-48291: {2_800_000:,} txns en 6 minutos")20print(f" --> {2_800_000/6:.0f} txns/minuto ({2_800_000/360:.0f} txns/segundo)")21print(f" --> Importe promedio: 0.01 EUR (micro-transacciones)")22print(f"Promedio otros merchants: {normal_avg:,.0f} txns/hora")23print(f"Skew ratio: {skew_ratio:.0f}x")24print()25print("CAUSA RAÍZ IDENTIFICADA:")26print(" Bug en TPV de MERCH-48291 genera bucle de retry infinito.")27print(" Cada retry crea una transacción de 0.01 EUR.")28print(" El job particiona por merchant_id en el groupBy.")29print(" -> 2.8M eventos van al MISMO executor -> OOM.")30print()31print(f"IMPACTO:")32print(f" - Features de fraude stale: {47} minutos (SLA: 5 min)")33print(f" - Transacciones en cola: ~{200_000:,} (Kafka retiene 7 días)")34print(f" - Transacciones perdidas: 0 (Kafka retention = safety net)")35print(f" - Impacto en clientes: NINGUNO (modelo usó features cached)")
Diagnóstico: MERCH-48291 con skew de 280x causó OOM en cascada
### Hotfix -- restaurar servicio en minutos
A las 3:45 decides el hotfix con Paula. La prioridad absoluta es restaurar el servicio -- la solución elegante vendrá después. El hotfix: filtrar temporalmente MERCH-48291 del stream principal y enviar sus transacciones a un path de cuarentena en S3 (no se pierden, solo se aparcan):
1# HOTFIX -- PR de emergencia aprobado por Paula a las 3:52 AM2# Restaura el servicio filtrando el merchant problemático34# Lista externalizada en SSM Parameter Store (no requiere redeploy para añadir)5BLOCKED_MERCHANTS = ["MERCH-48291"] # Config dinámica via AWS SSM67# Flujo principal: filtrar merchants bloqueados8main_stream = deduplicated.filter(9 ~col("merchant_id").isin(BLOCKED_MERCHANTS)10)1112# Cuarentena: capturar merchants bloqueados (NO perder datos)13quarantine_stream = deduplicated.filter(14 col("merchant_id").isin(BLOCKED_MERCHANTS)15)1617# Escribir cuarentena a ruta separada para análisis posterior18quarantine_query = quarantine_stream.writeStream \19 .format("parquet") \20 .option("path", "s3://neobank-lake/quarantine/blocked_merchants/") \21 .option("checkpointLocation", "s3://neobank-checkpoints/quarantine/") \22 .trigger(processingTime="30 seconds") \23 .outputMode("append") \24 .start()2526# Timeline del hotfix:27# 03:45 - Decisión tomada28# 03:47 - Código escrito (5 líneas de cambio)29# 03:49 - PR abierto con descripción del incidente30# 03:52 - Paula aprueba (code review de emergencia)31# 03:54 - Deploy via GitHub Actions -> EMR32# 03:55 - Job reinicia desde último checkpoint33# 03:57 - Consumer lag empieza a bajar: 200K -> 150K -> 100K34# 03:59 - Lag en 0. Servicio completamente restaurado.35# Total downtime: 47 minutos.3637print("HOTFIX DESPLEGADO - Servicio restaurado a las 03:59")38print(f" Merchants bloqueados: {BLOCKED_MERCHANTS}")39print(f" Datos en cuarentena: s3://neobank-lake/quarantine/")40print(f" Consumer lag: 0 (restaurado)")
Hotfix: filtrar + cuarentena. Servicio restaurado en 47 minutos.
### Solución permanente -- salting para data skew
El hotfix restaura el servicio pero no resuelve el problema de fondo. Si otro merchant tiene un bug similar mañana, volverá a pasar. La solución permanente es "salting": distribuir las transacciones de un merchant con alto volumen en múltiples sub-particiones artificiales para que ningún executor reciba toda la carga solo.
1from pyspark.sql.functions import (2 col, concat, lit, floor, rand, count, avg,3 approx_count_distinct, window, to_timestamp, sum as spark_sum4)56# SOLUCIÓN PERMANENTE: Salting para data skew7SALT_BUCKETS = 16 # Número de sub-particiones89def apply_salting_to_stream(stream_df):10 """11 Aplica salting al groupBy para distribuir merchants con skew.1213 Idea: en lugar de groupBy(account_id), hacemos:14 1. Añadir salt aleatorio (0 a N-1) como columna15 2. groupBy(account_id, salt) -> agregación parcial distribuida16 3. groupBy(account_id) -> combinar parciales (segunda agregación)1718 Sin salting: 2.8M txns de MERCH-48291 -> 1 executor -> OOM19 Con salting (16 buckets): ~175K txns por executor -> manejable20 """21 # Paso 1: Añadir salt aleatorio22 salted = stream_df.withColumn(23 "salt", floor(rand() * SALT_BUCKETS).cast("int")24 )2526 # Paso 2: Primera agregación con salt (distribuida en N particiones)27 partial_agg = salted \28 .withWatermark("event_time", "5 minutes") \29 .groupBy(30 col("account_id"),31 col("salt"),32 window(col("event_time"), "1 hour", "1 minute")33 ) \34 .agg(35 count("*").alias("partial_txn_count"),36 spark_sum("amount").alias("partial_amount_sum"),37 count("amount").alias("partial_amount_count"),38 approx_count_distinct("merchant_id").alias("partial_merchants"),39 )4041 # Paso 3: Segunda agregación sin salt (combina los N parciales)42 final_agg = partial_agg \43 .groupBy(col("account_id"), col("window")) \44 .agg(45 spark_sum("partial_txn_count").alias("txn_count_1h"),46 (spark_sum("partial_amount_sum") / spark_sum("partial_amount_count")).alias("avg_amount_1h"),47 spark_sum("partial_merchants").alias("distinct_merchants_1h_approx"),48 )4950 return final_agg5152# Resultado:53# Sin salting: 1 executor recibe 2.8M txns -> OOM garantizado54# Con salting: 16 executors reciben ~175K cada uno -> 1.1GB por executor55print(f"Sin salting: 1 executor con 2.8M txns (14.2GB -> OOM)")56print(f"Con salting ({SALT_BUCKETS} buckets):")57print(f" Cada executor: ~{2_800_000 // SALT_BUCKETS:,} txns")58print(f" Memoria estimada: ~{2_800_000 // SALT_BUCKETS * 500 / 1024 / 1024:.1f} MB por executor")59print(f" Overhead: 2x shuffles (acceptable trade-off vs OOM)")
Salting: distribuir merchants con skew en 16 sub-particiones
Data skew es el asesino silencioso de jobs de Spark. Puede estar latente meses (todo funciona con datos uniformes) y explotar cuando un solo merchant/cliente genera volumen anómalo. SIEMPRE diseña para skew desde el día 1. El coste de salting (2x shuffles) es trivial comparado con el coste de un incidente a las 3AM.
En un incidente real, la prioridad SIEMPRE es restaurar el servicio primero (hotfix) y después investigar la causa raíz (solución permanente). Un hotfix feo que funciona en 5 minutos es infinitamente mejor que una solución elegante que tarda 2 horas. Paula te lo dijo al oído: "Deploy first, refactor later."
## ejercicios
Diagnosticar data skew
Dado un DataFrame con estadísticas por merchant, identifica el merchant anómalo, calcula el skew ratio y genera un reporte de diagnóstico con causa probable.
Implementar salting para distribución de carga
Implementa el patrón de salting completo: añadir salt, agregar por clave salteada, y combinar los parciales. Verifica que los totales son correctos.
Generar timeline de incidente
Implementa un IncidentTracker que registra eventos con timestamp, calcula métricas del incidente (MTTD, MTTR) y genera una timeline formateada para el post-mortem.
Regístrate para guardar tu progreso.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...