El Beso de la Muerte en AWS Lambda

El “Kiss of Death” en AWS Lambda es un problema donde las conexiones a la base de datos se reutilizan entre invocaciones de la función, pero las transacciones no se cierran correctamente, causando que la historia de versiones en InnoDB crezca exponencialmente y congele la base de datos. Ocurre cuando inicializas la conexión en el global scope de Lambda y reutilizas la misma conexión sin cerrar explícitamente las transacciones, lo que genera deadlocks, slowlogs atascados y picos de CPU incontrolables en RDS.

En 30 segundos

  • Es un problema de arquitectura serverless donde Lambda reutiliza conexiones a BD pero deja transacciones abiertas, congela la base de datos y causa que innodb_history_list_length se descontrole.
  • Ocurre porque Lambda mantiene el global scope entre invocaciones (feature que suele ser útil para rendimiento), pero sin cerrar transacciones explícitamente, el MVCC de InnoDB acumula versiones antiguas que no puede purgar.
  • La solución principal es cambiar el isolation level de REPEATABLE-READ (default) a READ-COMMITTED, o usar Amazon RDS Proxy para pooling automático y multiplexación de conexiones.
  • Síntomas: database freezes periódicos, queries bloqueadas en slowlog, escrituras lentas, alertas de “max connections reached” sin razón aparente.
  • Fue documentado en Shattered Silicon con casos reales donde el innodb_history_list_length llegó a números insanos (billones de undo segments pendientes).

¿Qué es el “Kiss of Death” en AWS Lambda?

Lambda Kiss of Death es el nombre que le dieron en Shattered Silicon a un problema silencioso pero catastrófico que rompe bases de datos en producción. Funciona así: escribís código Lambda donde inicializas la conexión a RDS fuera del handler (en el global scope), reutilizas esa conexión en cada invocación (buena práctica para no pagar el costo de conectar cada vez), pero cuando ejecutás transacciones, algunas nunca se cierran porque tu código es descuidado, la excepción no se maneja bien, o la invocación termina antes de hacer el COMMIT.

Eso que dijimos es un problema, pero apenas rozamos la raíz. El “Kiss of Death” es lo que pasa después: esas transacciones abiertas (aunque sea durante un par de segundos) le dicen al motor de InnoDB “ojo, yo estoy usando datos de esta versión”, y InnoDB, siendo serio con sus garantías ACID, mantiene todas las versiones antiguas “por si alguien las necesita”. Cuando la siguiente invocación llega, la siguiente, y la siguiente (Lambda escala a decenas, cientos, miles de invocaciones en paralelo), todas dejan transacciones abiertas “accidentalmente”, y de repente tu base de datos tiene millones de versiones pendientes que no puede limpiar.

El resultado: el motor se congela (literal, no metafórico), el history list se descontrola, el buffer pool se satura, y cualquier query nueva espera o falla. Es como si miles de tipos estuvieran leyendo libros en una biblioteca y nunca devolvieran los libros, así que el bibliotecario no puede destruir las copias viejas y la biblioteca colapsa.

Cómo surge el problema: Connection Pooling en Lambda

Ponele que configurás Lambda así (pseudocódigo):

// global scope — se ejecuta SOLO en cold start
connection = mysql.connect(host, user, password)

def handler(event):
// global scope — la conexión persiste entre invocaciones
result = connection.query("SELECT ...")
return result

Eso es correcto y recomendado por AWS, porque cada conexión cuesta tiempo (50-300ms de latencia), así que reutilizar es un golazo. El problema es qué pasa cuando hablamos de transacciones: Te puede servir nuestra cobertura de ejecutar agentes locales sin APIs.

connection.begin_transaction()
connection.query("UPDATE ...")
// La invocación termina acá, sin COMMIT

En una aplicación web tradicional, eso es una catástrofe que ves inmediatamente: tu request retorna sin confirmar la transacción, y todos los debuggers te gritan. Pero en Lambda, pasa en silencio (spoiler: nadie lo validaba), la invocación termina, la conexión queda ahí en el global scope con una transacción abierta esperando COMMIT. A la siguiente invocación, la conexión sigue abierta, la transacción anterior sigue abierta en el motor. InnoDB ve una transacción activa y piensa “debo mantener las versiones antiguas por si la necesita.”

El problema real es el isolation level. InnoDB por defecto usa REPEATABLE-READ, que es paranoia: garantiza que si iniciaste una transacción, cualquier SELECT te devuelve la misma versión de datos incluso si otro proceso actualiza la tabla. Para mantener esa garantía, InnoDB crea un “snapshot” de toda la historia de cambios. Si la transacción nunca termina, ese snapshot persiste eternamente, y el garbage collector (purge thread) de InnoDB no puede tirar versiones antiguas.

Síntomas: Cómo detectar que tienes el Kiss of Death

El problema no te golpea de una. Empieza sutil: un día notás que el sitio se vuelve más lento durante los picos de tráfico. Checás RDS y ves picos de conexiones activas, pero no están haciendo nada visible (queries cortas, CPU normal). Activás el slowlog y ves queries que deberían ser instantáneas esperando 10, 20, 30 segundos bloqueadas.

Los síntomas clásicos:

  • Database freezes periódicos: cada 1-2 horas (coincidiendo con picos de invocaciones), la BD se congela 1-5 minutos, luego se recupera. Ningún error en logs, solo timeout de conexión.
  • Spikes de “Threads Connected” sin carga aparente: en el dashboard de RDS ves 500 conexiones activas, pero el query activity monitor muestra 10 queries. Las 490 restantes están ahí esperando.
  • Slowlog atascado con “Waiting for lock”: escribes `SHOW PROCESSLIST` y ves 100 queries con estado “Waiting for lock on InnoDB table” o “Statistics”.
  • Alertas de “max_connections” alcanzado: RDS te avisa que no puede aceptar más conexiones, incluso aunque tenés 200 conexiones en global scope.
  • innodb_history_list_length desbocado: ejecutás `SHOW ENGINE INNODB STATUS` y ves números insanos: 1 millón, 10 millones, 100 millones de undo segments pendientes.

El último síntoma es la pista definitiva. Si `innodb_history_list_length` está arriba de 100k, tienes un problema. Arriba de 1M, tienes el Kiss of Death.

El Impacto en InnoDB: History List Length Desbocado

InnoDB usa MVCC (Multi-Version Concurrency Control) para garantizar que múltiples transacciones puedan leer datos simultáneamente sin bloquearse. La idea es simple: en lugar de bloquear la fila mientras alguien la lee, créa una nueva versión cada vez que alguien la modifica, y mantén todas las versiones antiguas en los “undo logs”. Sobre eso hablamos en soluciones serverless en GitHub.

El problema es cuándo limpiar las versiones viejas. InnoDB tiene un purge thread que corre en background y elimina versiones que ya nadie usa. Pero para saber si alguien usa una versión, InnoDB chequea: “¿hay alguna transacción activa que necesita esta versión?” Si hay una transacción abierta que inició antes de que la versión se creara, la respuesta es “sí, mantén esa versión.”

Cuando AWS documentó el caso real de Kiss of Death, el `innodb_history_list_length` había alcanzado números insanos, se hablaba de billones de undo segments. Acá viene lo bueno: cada undo segment es memoria. El history list creció tanto que agotó el buffer pool (la memoria caché de InnoDB), el motor empezó a leer/escribir disco continuamente, y todo se desaceleró exponencialmente.

Además, los undo logs sin limpiar generan otro problema: el checkpoint progress de InnoDB se ralentiza. InnoDB no puede hacer checkpoint (punto seguro en el log de recuperación) si hay transacciones antiguas que aún necesitan versiones previas a ese checkpoint. Así que los undo logs se acumulan en disco, y el performance se desmorona.

Solución 1: Cambiar Transaction Isolation a READ-COMMITTED

La solución más importante y directa: cambiar el isolation level de REPEATABLE-READ (el default en InnoDB) a READ-COMMITTED. La diferencia es una sola cosa conceptual: READ-COMMITTED no garantiza que dos SELECTs en la misma transacción vean los mismos datos. Permite “phantom reads”. Pero casi todas las aplicaciones web no necesitan esa garantía (les importa actualizar datos consistentemente, no leerlos de forma inmutable).

Cuando cambias a READ-COMMITTED, InnoDB no necesita mantener el snapshot completo de la transacción. Limpia versiones antiguas mucho más agresivamente. Un undo segment que con REPEATABLE-READ vivía indefinidamente, con READ-COMMITTED se elimina apenas la transacción termina (aunque sea sin COMMIT —que es el caso aquí, transacciones que se “olvidan” de cerrar).

Implementación (en tu código Lambda):

connection = mysql.connect(...)
// Una sola vez, en cold start
connection.query("SET SESSION innodb_trx_rseg_n_slots_debug = 0")
connection.query("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")
Más contexto en herramientas de infraestructura IA.

O en la connection string (algunos drivers lo soportan):

mysql://user:pass@host/db?isolation=READ_COMMITTED

También hay que tunear InnoDB para purgar más agresivamente:

innodb_undo_log_truncate = ON
innodb_max_undo_log_size = 100M // ajustá según tu RAM

Estos cambios se hacen en el parameter group de RDS (sin downtime, aplican a nuevas conexiones). El impacto en consistencia es mínimo para aplicaciones web típicas (ojo: si usas reportes complejos que reutilizan la misma conexión, probá primero).

Solución 2: Usar Amazon RDS Proxy para Connection Pooling

RDS Proxy es un proxy administrado que se pone entre Lambda y RDS. En lugar de que Lambda mantenga cientos de conexiones reales a RDS, Lambda se conecta a RDS Proxy (que acepta muchas conexiones en paralelo), y RDS Proxy multiplexea esas conexiones a solo 10-20 conexiones reales a la base de datos.

El beneficio para el Kiss of Death: RDS Proxy termina explícitamente cada conexión después de cada transacción (pooling + multiplexing), sin importar si tu Lambda hizo COMMIT o no. Las transacciones rotas se cierran del lado del proxy, nunca llegan a contaminar InnoDB.

Setup básico:

  • Creas un proxy en RDS → Admin → DB Proxies → Create proxy.
  • Configurás el endpoint del proxy (algo como `donweb-proxy.proxy-xxxxx.us-east-1.rds.amazonaws.com`), la máx de conexiones al proxy (1000), y la estrategia de pooling (Session pooling es lo más común).
  • En Lambda, cambias la connection string del proxy en lugar de directa a RDS.
  • RDS Proxy cuesta ~0.05 USD/hora + tráfico, pero para un blog de alto volumen es nada comparado con el downtime que evitas.

Mejores Prácticas para Evitar el Kiss of Death

Más allá de las soluciones, hay prácticas que evitan que te lleves sorpresas:

  • Siempre cierra transacciones explícitamente: usa try/finally o context managers. Si tu librería no los tiene, querrete un poco y busca una que los tenga.
  • Implementá health checks: cada 30 invocaciones, hacé un `SELECT 1` para confirmar que la conexión sigue viva. Si tira error, reconecta.
  • Limita el tiempo de transacción: ponele un timeout de 2-3 segundos. Si la transacción no termina en ese tiempo, rollback automático.
  • Monitorea innodb_history_list_length: hace una query cada hora que chequee ese valor. Si sube arriba de 10k, lanza una alerta en Telegram/CloudWatch.
  • Usa Aurora Serverless como alternativa: si lo que querés es autoscaling verdadero, Aurora Serverless (V1 o V2) maneja connection pooling automáticamente y no tienes que pensar en nada.

Comparativa de Soluciones

SoluciónCostoEsfuerzoEficaciaCuándo usarla
Cambiar a READ-COMMITTED$0Bajo (cambio en config)Alta (85-90% de casos)Si el código es tuyo y podés garantizar consistency guarantees
RDS Proxy~$35-50/mesBajo (cambiar connection string)Muy Alta (100%)Workloads pesadas de Lambda, múltiples funciones, tranquilidad garantizada
Aurora Serverless V2Variable (PPU)Medio (migración de datos)Muy Alta (100%)Nuevos proyectos o si planeás autoscale permanente
Refactor de código (cerrar transacciones)$0Medio-Alto (review de código)Alta si se hace bienSolución a largo plazo, mandatory de todas formas
beso de la muerte aws lambda diagrama explicativo

Errores Comunes

Error 1: Asumir que Lambda maneja conexiones como una aplicación tradicional

En una app web clásica, cada request obtiene una conexión del pool, la usa, y la devuelve al pool. Fin. En Lambda, la conexión persiste entre invocaciones. Muchos desarrolladores migran código de aplicaciones web sin entender esa diferencia, escriben `connection = new_connection()` en el handler (no en global scope), y le pagan la latencia de conexión a cada invocación. Los demás ven ese código “ineficiente” y lo mueven a global scope sin cambiar el resto de la lógica, y boom: transacciones abiertas olvidadas en global scope.

Error 2: Confundir “no hace rollback implícito” con “está seguro”

Lambda ejecuta tu código. Si tu código abre una transacción y termina sin COMMIT, la transacción se queda abierta. NO se hace rollback automático cuando tu función retorna (eso pasa solo cuando la conexión se cierra de verdad, que en Lambda es cuando muere el contenedor, potencialmente horas después). Muchos devs piensan “bueno, si hay error, Lambda la clausura y listo.” No. La conexión en global scope vive hasta que el contenedor muere o la reconectas. Complementá con funciones serverless de Microsoft.

Error 3: No monitorear innodb_history_list_length hasta que es demasiado tarde

El Kiss of Death es silencioso hasta que no lo es. El sitio funciona bien, luego de repente falla. Para entonces, el history list está en varios millones y la recuperación toma horas. Deberías tener una alerta que grite cuando `innodb_history_list_length` > 50k, antes de que se convierta en problema.

Qué está confirmado / Qué no

  • Confirmado: AWS Lambda mantiene el global scope entre invocaciones (feature oficial de AWS).
  • Confirmado: Si una transacción en InnoDB no se cierra, el history list crece y el motor se ralentiza (comportamiento estándar de MVCC).
  • Confirmado: Cambiar a READ-COMMITTED reduce significativamente el retention de undo segments (documentado en AWS y comunidades MySQL).
  • Confirmado: RDS Proxy multiplexea conexiones y cierra sesiones entre invocaciones (feature oficial de RDS Proxy).
  • No totalmente confirmado: Cuánto impacto tiene el cambio a READ-COMMITTED en aplicaciones específicas (depende de la lógica de negocio y la consistencia que necesites). Probá en staging primero.
  • No confirmado: La magnitud exacta del problema (“billones de undo segments”) en otros casos. Fue un caso real publicado en Shattered Silicon, pero la severidad varía según el volumen de Lambda y la duración de las transacciones olvidadas.

Preguntas Frecuentes

¿Qué es el innodb_history_list_length y por qué importa?

Es el número de undo segments que InnoDB mantiene en memoria y disco. Cada transacción abierta crea un “snapshot” que impide que InnoDB limpie versiones antiguas. Cuando ese número crece (cien mil, un millón, más), consume RAM, ralentiza el motor y puede causar freezes. Si ves ese valor arriba de 100k, tenés un problema. Arriba de 1M, tenés una emergencia.

¿Debo cambiar a READ-COMMITTED si mi sitio es crítico?

Probá primero en staging con tu carga real. READ-COMMITTED es seguro para 95% de aplicaciones web (e-commerce, blogs, redes sociales). El riesgo es bajo si no haces consultas complejas que dependen de REPEATABLE-READ explícitamente. Si tu código usa `SERIALIZABLE` o lógica que asume snapshots inmutables, entonces no cambies sin revisar primero.

¿RDS Proxy resuelve el problema sin cambiar código?

Sí, principalmente. RDS Proxy cierra sesiones entre transacciones, así que transacciones rotas se cierran automáticamente. El cambio de código es mínimo: solo cambiar el endpoint de la base de datos en la connection string. Sin embargo, el costo es real (~$35-50/mes), así que comparalo con el costo de downtime y perdida de tráfico.

¿Cómo detecto si tengo el Kiss of Death ahora?

Tres cheques rápidos: (1) Ejecutá `SHOW ENGINE INNODB STATUS` y buscá “History list length”. Si dice 100k o más, ding ding ding. (2) Mirá RDS CloudWatch → “Database Connections” ¿hay picos inexplicados? (3) Checá slowlog: ¿hay queries con estado “Waiting for lock”? Si dos de esos tres dan positivo, el Kiss of Death está visitando tu base de datos.

¿Aurora Serverless evita el problema?

Aurora Serverless V2 maneja connection pooling automáticamente y escala según necesidad, así que el problema es muy poco probable. Pero requiere migración (backup/restore o AWS DMS). Para proyectos nuevos, es una opción fuerte. Para sitios en producción, es un cambio mayor.

Conclusión

El “Kiss of Death” en AWS Lambda es real, documentado, y ha roto bases de datos en producción. No es un escenario hipotético. Ocurre cuando inicializas conexiones en el global scope de Lambda (buena idea para performance), pero no cierras transacciones explícitamente (mala idea para durabilidad). InnoDB se ve forzado a mantener millones de versiones antiguas, el motor congela, y el sitio cae.

Las soluciones son directas: (1) cambiar a READ-COMMITTED si podés garantizar consistency, (2) usar RDS Proxy para pooling automático, o (3) refactorizar código para garantizar que cada transacción cierre. La combinación de los tres es lo ideal: código limpio + RDS Proxy + isolation level apropiado.

Si corrés Lambda en AWS con RDS, sumá un monitoreo de `innodb_history_list_length` a tus alertas. Un valor > 50k es tu señal de alerta. La diferencia entre un problema que detectás a tiempo y un apagón de producción es una métrica en CloudWatch.

Fuentes

Te puede interesar...