Módulo 6: Bulkheads y aislamiento

1. Presentación del módulo: los compartimentos estancos

Descripción

Hasta aquí has aprendido a blindar una llamada a la vez. Le pusiste un timeout para que un dependiente lento no retuviera tu hilo para siempre (módulo 2), la reintentaste con backoff y jitter sin desatar un retry storm (módulo 3), la hiciste idempotente para que un reintento no cobrara dos veces (módulo 4) y le pusiste un circuit breaker para dejar de golpear a un servicio muerto (módulo 5). Cada una de esas defensas mira una llamada a una dependencia y la hace más segura. Este módulo cambia el foco: deja de mirar una llamada y mira el servicio entero, con sus tres dependencias a la vez —payments, shipping y catalog— compartiendo un mismo recurso.

Ese recurso compartido es el pool de hilos de orders. Todas las requests que entran a orders —las que cobran, las que crean envíos, las que leen el catálogo— sacan un hilo del mismo pool. Y ahí nace un problema nuevo, uno que ninguna defensa por-llamada resuelve del todo: si shipping se cuelga y recibe suficiente tráfico, sus requests se comen todos los hilos del pool, y entonces payments y catalog —que están perfectamente sanos— se quedan sin un solo hilo con el cual atender. La falla de una dependencia se convierte en la caída de las tres. No por un error que se propaga, sino por un recurso que se agota. A eso lo llamamos contagio por pool compartido, y es el enemigo de todo el módulo.

La cura no es por-llamada; es estructural. Si el problema es que las tres dependencias comparten un pool, la solución es dejar de compartirlo: darle a cada dependencia su propio sub-pool, su propio compartimento. Así, cuando shipping se cuelga, solo agota su compartimento; payments y catalog siguen con los suyos intactos. Ese patrón —aislar recursos por dependencia para que la falla de una no inunde a las demás— se llama bulkhead, que en español es el mamparo de un barco: la pared estanca que divide el casco en compartimentos para que una vía de agua en uno no hunda al barco entero.

Conexión con el módulo: esta es la lección-mapa. No construimos bulkheads todavía; hoy fijamos el problema (por qué compartir un pool contagia la falla), el vocabulario (pool compartido, sub-pool, aislamiento por thread pool, aislamiento por semáforo, radio de explosión) y el escenario medido que recorreremos: orders con sus tres dependencias, shipping colgado. La lección 2 mide el contagio a fondo (las tres dependencias colapsando sobre el pool compartido). La 3 define qué es un bulkhead y sus dos formas de implementarlo. La 4 aplica la solución —un sub-pool por dependencia— y mide el aislamiento. La 5 desarrolla la analogía del Titanic y la lección de por qué se hundió pese a tener compartimentos. La 6 lleva el aislamiento más allá de la dependencia, a la clase de tráfico. La 7 pone sobre la mesa el costo del aislamiento, para que no sobre-particiones. Y la 8 te pone a aislar el checkout de Mercado con tus manos y a medir el antes y el después.

El hospital con un solo mostrador: por qué compartir contagia

Imagina un hospital con un único mostrador de admisión, atendido por ocho recepcionistas —ese es el pool—. Por ese mismo mostrador pasa todo el mundo: la persona que viene a una urgencia grave, la que viene a una consulta de rutina y la que solo viene a pedir una copia de un estudio. En un día normal, los ocho recepcionistas alcanzan de sobra: cada trámite toma un par de minutos y la fila avanza.

Un día, uno de los trámites se atasca. Llega un caso con un papeleo endemoniado —hay que confirmar algo con un seguro externo que hoy no responde—, y el recepcionista que lo atiende se queda esperando esa confirmación que no llega. Un recepcionista menos. Detrás llega otro caso hacia el mismo seguro colapsado: otro recepcionista bloqueado. Y otro. En pocos minutos, los ocho recepcionistas están congelados esperando al seguro externo, y la persona que solo venía por una copia de su estudio —un trámite de treinta segundos que no toca a ningún seguro— no tiene a nadie que la atienda. El hospital está sano: sus médicos trabajan, sus salas están libres, su sistema funciona. Pero su admisión está caída, porque un solo tipo de trámite atascado secuestró a todos los recepcionistas del único mostrador.

Ahora rediseña el hospital: en vez de un mostrador con ocho recepcionistas para todo, pon tres mostradores separados —uno para urgencias, uno para consultas, uno para trámites administrativos—, con recepcionistas dedicados a cada uno. Cuando el trámite del seguro externo vuelve a atascarse, ahora solo congela a los recepcionistas del mostrador administrativo; los de urgencias y los de consultas siguen atendiendo normal, porque tienen sus propios recepcionistas que nadie les puede robar. El trámite del seguro sigue igual de atascado —eso no se arregló—, pero su atasco quedó confinado a un mostrador en lugar de tumbar la admisión entera. Eso es un bulkhead: los tres mostradores separados son los tres sub-pools, el trámite del seguro colapsado es shipping colgado, y la persona que solo quería una copia de su estudio es el tráfico de catalog que, con el mostrador compartido, se caía sin tener nada que ver.

El mecanismo, con los nombres de Mercado

Pongámosle a la analogía los nombres exactos que usaremos todo el módulo. orders es el servicio que orquesta el checkout, y tiene un pool de hilos finito —digamos doce— que atiende todas sus requests. Sobre ese pool caen tres tipos de tráfico, cada uno hacia una dependencia distinta:

  • Requests a catalog (leer un producto): sanas, ~20 ms, muy frecuentes.
  • Requests a payments (cobrar): sanas, ~120 ms.
  • Requests a shipping (crear el envío): hoy shipping está colgado, y cada llamada se queda esperando ~3000 ms.

Con un pool compartido, la secuencia es la del hospital. Las primeras requests a shipping toman hilos y se quedan pegadas esperando los ~3000 ms. Como llegan más rápido de lo que se liberan, en menos de un segundo los doce hilos del pool están retenidos por llamadas colgadas a shipping. A partir de ahí, cada request a catalog —una lectura de 20 ms— y cada request a payments —un cobro de 120 ms— llega y no encuentra un solo hilo libre. Se van a la cola; cuando la cola se llena, orders empieza a rechazarlas. El catálogo responde en 20 ms, el cobro responde en 120 ms, los dos están sanos, y aun así sus usuarios reciben errores, porque orders no tiene un hilo con el cual atenderlos. La falla de shipping se volvió la falla de catalog y de payments, sin que ninguno de los dos tuviera nada roto. Eso es el contagio por pool compartido.

El número que quiero que veas hoy —lo mediremos a fondo en la lección 2— es este contraste, con el pool de doce hilos de orders bajo shipping colgado, comparando un pool compartido contra un sub-pool por dependencia:

                              success_rate por dependencia
                        catalog (sano)   payments (sano)   shipping (COLGADO)
  Pool COMPARTIDO :         19.3%            16.8%              20.5%
  Con BULKHEADS   :        100.0%           100.0%               6.8%

Léelo despacio, porque es la tesis del módulo en seis números. Con el pool compartido, las dos dependencias sanas se derrumban: catalog sirve solo el 19.3% de su tráfico y payments el 16.8% —más del 80% de un tráfico que no tenía nada roto, rechazado—. Con bulkheads (un sub-pool por dependencia), las dos sanas vuelven al 100%: ni una request perdida. ¿Y shipping? Sigue roto en los dos casos —de hecho, aislado sirve menos (6.8%) porque ahora solo tiene su propio sub-pool en vez de acaparar todo—; pero eso es correcto y buscado: el bulkhead no cura a shipping, lo confina. Lo único que cambió fue la estructura del pool: de uno compartido a tres separados. shipping sigue igual de colgado; la diferencia es que en un caso se lleva a catalog y a payments con él, y en el otro se hunde solo. Ese es todo el módulo.

El bulkhead no cura: confina

Vale la pena decirlo con toda claridad desde el principio, porque es la confusión más común con este patrón: el bulkhead no hace que la dependencia rota funcione. shipping colgado seguirá colgado. Si miras solo el success_rate de shipping, el bulkhead parece inútil —incluso lo empeora, de 20.5% a 6.8%—. Pero esa es la métrica equivocada. El bulkhead se juzga por lo que le pasa a las demás dependencias: catalog y payments pasaron de derrumbarse (19.3% y 16.8%) a estar perfectas (100% y 100%). El valor del bulkhead vive en el tráfico que no tocaba a shipping y que, sin aislamiento, se caía por contagio.

Piénsalo en términos del barco, que es la imagen que gobierna el patrón. Un mamparo no impide que un torpedo abra una vía de agua; impide que el agua de ese compartimento inunde a los demás. El compartimento golpeado se llena —eso no lo evita el mamparo—, pero el barco flota porque los otros compartimentos siguen secos. El bulkhead de orders es igual: no impide que shipping se cuelgue; impide que el cuelgue de shipping ahogue a payments y a catalog. La pregunta correcta nunca es "¿el bulkhead arregló la dependencia rota?" —no es su trabajo—; es "¿el bulkhead evitó que la falla de una dependencia se derramara a las demás?". Y la respuesta, medida, es sí.

Dos formas de poner la pared: hilos y semáforos

Adelanto un matiz que la lección 3 desarrolla, porque conviene que lo tengas en mente desde ya. Hay dos maneras de darle a cada dependencia su propio compartimento, y las dos aíslan, pero con costos distintos:

  • Aislamiento por thread pool: le das a cada dependencia un pool de hilos físicamente separado. catalog tiene sus hilos, payments los suyos, shipping los suyos. Es la separación más fuerte —los hilos de shipping literalmente no existen para catalog—, pero cuesta más recursos (más hilos totales reservados) y añade el costo de saltar de hilo en cada llamada.
  • Aislamiento por semáforo: mantienes un solo pool, pero le pones a cada dependencia un contador de concurrencia —un semáforo— que limita cuántas requests suyas pueden estar en vuelo a la vez. shipping puede usar como máximo, digamos, cuatro slots del pool; si ya tiene cuatro en vuelo, sus requests nuevas se rechazan de inmediato en vez de tomar más hilos. Es más barato (no reservas hilos aparte, no saltas de hilo), pero un poco menos aislante en los detalles finos.

Netflix Hystrix, la librería que popularizó estos patrones, ofrecía exactamente estas dos estrategias con esos nombres —thread pool isolation y semaphore isolation—. En la lección 3 mides que las dos protegen a catalog y a payments prácticamente igual; la elección entre ellas es un asunto de costo, no de si aíslan o no. Por ahora quédate con la idea: "un compartimento por dependencia" se puede lograr con hilos separados o con un contador que impide que una dependencia acapare el pool común.

Errores comunes

Creer que el timeout ya resolvía esto. Qué pasa: alguien dice "pero ya le puse timeout de 300 ms a shipping en el módulo 2, ¿no basta?". Por qué pasa: el timeout limita cuánto tiempo retiene cada hilo, pero no cuántos hilos puede tomar shipping en total. Cómo detectarlo: bajo suficiente tráfico a shipping, aun con timeout el pool compartido se llena —cada request retiene su hilo 300 ms, pero si llegan más rápido que eso, shipping ocupa todos los hilos igual—. Cómo corregirlo: el timeout y el bulkhead resuelven cosas distintas y se necesitan los dos. El timeout acota la retención por request; el bulkhead acota cuántos hilos del sistema puede monopolizar una dependencia. Sin bulkhead, un shipping con muchas requests (aunque cada una respete el timeout) puede seguir ahogando a catalog.

Confundir el bulkhead con el circuit breaker. Qué pasa: se usan como sinónimos, o se pone uno creyendo que hace el trabajo del otro. Por qué pasa: ambos protegen contra dependencias que fallan, y suelen aparecer juntos en las mismas librerías. Cómo detectarlo: si tu respuesta a "¿qué hace el bulkhead?" es "deja de llamar a la dependencia rota", estás describiendo el breaker. Cómo corregirlo: el breaker corta (deja de llamar a un servicio muerto, actúa sobre el tiempo); el bulkhead aísla (pone a cada dependencia en su propio recurso, actúa sobre el espacio de recursos). El breaker decide si llamar; el bulkhead decide con qué recurso se llama. Se combinan —breaker por dependencia, cada uno con su bulkhead— pero no se sustituyen.

Juzgar el bulkhead por el success_rate de la dependencia rota. Qué pasa: se mide shipping bajo bulkhead, se ve 6.8% (peor que el 20.5% compartido) y se concluye que el bulkhead "empeoró las cosas". Por qué pasa: se mira la métrica equivocada. Cómo detectarlo: tu conclusión ignora que catalog y payments pasaron de ~18% a 100%. Cómo corregirlo: el bulkhead se juzga por lo que le pasa a las dependencias sanas, que es donde vive el contagio evitado. Que shipping roto sirva menos aislado es esperado —ya no puede acaparar recursos ajenos— y hasta deseable: su falla dejó de ser problema de todos.

Ejercicios

Ejercicio 1 — ¿Por qué el timeout solo no basta? orders tiene un pool compartido de 12 hilos y ya le puso a shipping un timeout de lectura de 300 ms (del módulo 2). Hoy shipping está colgado y llegan requests a shipping a razón de una cada 20 ms. Explica por qué, pese al timeout, shipping puede seguir agotando el pool y ahogando a catalog. ¿Qué añade el bulkhead que el timeout no puede dar?

Ver solución

Con timeout de 300 ms, cada request a shipping retiene su hilo 300 ms (no los 3000 completos). Pero si llegan requests a shipping cada 20 ms, en 300 ms han llegado 15 requests nuevas, y solo hay 12 hilos. Es decir, shipping genera trabajo más rápido de lo que el timeout lo libera: la tasa de llegada (una cada 20 ms) supera a la tasa de liberación (una cada 300 ms por hilo × 12 hilos ≈ una cada 25 ms). El pool se llena de requests a shipping que respetan el timeout pero, en agregado, ocupan los doce hilos casi todo el tiempo. Cuando llega un catalog, no hay hilo libre.

El timeout acota la retención por request (300 ms cada una), pero no acota cuántos hilos en total puede tomar shipping a la vez. Eso es exactamente lo que añade el bulkhead: un tope a la concurrencia de shipping (por ejemplo, "shipping nunca usa más de 4 hilos"). Con ese tope, aunque lleguen mil requests a shipping, solo 4 hilos están ocupados por él en cualquier instante, y los otros 8 quedan garantizados para catalog y payments. Timeout y bulkhead son ortogonales: uno limita el tiempo por hilo, el otro el número de hilos por dependencia. Se necesitan los dos.

Ejercicio 2 — Bulkhead vs breaker en una frase cada uno. Un compañero dice: "no entiendo la diferencia entre el circuit breaker del módulo 5 y el bulkhead de este módulo; los dos protegen contra shipping roto". Explícale la diferencia en términos de qué decide cada uno, y da un ejemplo de por qué querrías los dos juntos.

Ver solución
  • El circuit breaker decide si llamar. Cuando shipping acumula fallas, el breaker se abre y orders deja de llamar a shipping del todo, devolviendo el error de inmediato sin gastar ni el timeout. Actúa sobre el tiempo: cuándo parar y cuándo volver a probar (half-open).
  • El bulkhead decide con qué recurso se llama. Le da a shipping su propio sub-pool para que, mientras el breaker aún no se ha abierto (o mientras shipping está lento pero no lo bastante para disparar el breaker), sus requests no puedan tomar más que su cuota de hilos. Actúa sobre el espacio de recursos: cuánto del pool puede ocupar cada dependencia.

Por qué los quieres juntos: hay una ventana peligrosa antes de que el breaker se abra —los primeros segundos del incidente, cuando shipping empieza a colgarse pero el breaker todavía no acumuló suficientes fallas para abrirse—. En esa ventana, sin bulkhead, shipping puede llenar el pool y ahogar a catalog antes de que el breaker reaccione. El bulkhead protege durante esa ventana (limita el daño desde el primer instante), y el breaker luego corta el tráfico inútil por completo. Juntos: el bulkhead contiene el radio de explosión desde el segundo cero, el breaker apaga la fuente cuando confirma que está muerta. Es la pareja clásica, y la armarás en el capstone (módulo 8).

Ejercicio 3 — El compartimento que no cura. Después de aislar shipping en su propio sub-pool, mides que catalog y payments están al 100%, pero shipping sigue fallando el 93% de sus requests. Un gerente pregunta: "¿entonces de qué sirvió el bulkhead, si el envío sigue sin funcionar?". Responde.

Ver solución

El bulkhead nunca prometió arreglar shipping; shipping está roto por su cuenta (colgado), y ningún patrón de aislamiento hace que un servicio caído responda. Lo que el bulkhead logró es que la falla de shipping dejara de ser la falla de todos. Antes del bulkhead, un shipping colgado tumbaba también a catalog (que solo lee productos) y a payments (que solo cobra): los tres servicios de Mercado caían juntos, y el cliente ni siquiera podía navegar el catálogo o completar un cobro de un pedido que no requería envío inmediato. Después del bulkhead, el cliente puede seguir navegando (catalog al 100%) y payments sigue cobrando (100%); solo la creación del envío falla.

En términos de negocio: pasamos de "Mercado entero caído" a "Mercado funciona, salvo que ahora mismo no se pueden crear envíos". El primero es un incidente de máxima severidad; el segundo es una degradación acotada de una función. El bulkhead convirtió una caída total en una falla parcial contenida —redujo el radio de explosión—. Qué hacer con la función de envío que sí falla (reintentar, cortar con el breaker, o completar el pedido con envío diferido) es trabajo de otros módulos; el bulkhead ya hizo el suyo, que era impedir que el incendio de shipping quemara la casa entera.

De blindar una llamada a estructurar el servicio

Los cinco módulos anteriores te dieron herramientas para una llamada: acótala en el tiempo (timeout), reinténtala con cabeza (backoff, jitter), hazla segura de repetir (idempotencia), deja de insistir cuando el servicio murió (breaker). Todas miran hacia afuera, hacia la dependencia. Este módulo mira hacia adentro, hacia cómo tu propio servicio reparte sus recursos entre las dependencias que llama. Y descubre que la manera de repartir —un pool para todos, o un compartimento para cada uno— decide si la falla de una dependencia es un problema local o una catástrofe general.

El resto del módulo es esa idea, medida y refinada. Primero verás el contagio en números crudos (lección 2), para que el problema sea innegable. Luego definirás la cura con precisión (qué es un bulkhead y cómo se implementa, lección 3) y la aplicarás midiendo el aislamiento (un sub-pool por dependencia, lección 4). Después profundizarás la imagen mental (el Titanic y por qué sus compartimentos no bastaron, lección 5), extenderás el aislamiento a las clases de tráfico (lección 6) y pondrás sobre la mesa lo que cuesta (lección 7). Al final habrás aislado el checkout de Mercado con tus manos y tendrás la tabla que prueba que el contagio se detuvo.

Resumen y siguiente paso

En esta lección viste por qué compartir un pool de recursos convierte la falla de una dependencia en la caída de todas. El mecanismo cabe en una frase: cuando varias dependencias sacan hilos del mismo pool, la que se cuelga acapara todos los hilos y deja a las sanas sin con qué atender. Lo viste en la analogía del hospital de un solo mostrador y en el número que recorreremos todo el módulo: con pool compartido bajo shipping colgado, catalog cae a 19.3% y payments a 16.8% —sanos, contagiados—; con bulkheads (un sub-pool por dependencia), los dos vuelven al 100% mientras shipping roto queda confinado a su compartimento (6.8%).

Aprendiste que el bulkhead no cura, confina —se juzga por lo que le pasa a las dependencias sanas, no a la rota—; que es distinto del circuit breaker —el breaker corta, el bulkhead aísla— y complementario a él; que no sustituye al timeout sino que lo complementa —el timeout acota el tiempo por hilo, el bulkhead el número de hilos por dependencia—; y que se puede implementar con thread pools separados o con semáforos de concurrencia sobre un pool común.

Antes de avanzar deberías poder: enunciar de memoria el mecanismo del contagio por pool compartido; explicar por qué el timeout solo no lo resuelve; y distinguir en una frase el bulkhead del breaker.

Lo que sigue es dejar de creer en el contagio y medirlo. La lección 2 monta el escenario exacto —orders con sus tres dependencias sobre un pool compartido, shipping colgado— como una simulación determinista, y pone en una tabla cómo catalog y payments sanos se derrumban por culpa de un shipping que no tiene nada que ver con ellos. Es la demostración empírica del problema que el resto del módulo viene a resolver.

Recursos

  • Michael T. Nygard, Release It! Design and Deploy Production-Ready Software, 2ª ed. (Pragmatic Bookshelf, 2018) — la fuente canónica de estos patrones. El capítulo de stability patterns introduce el Bulkhead con la imagen del barco y explica por qué particionar los recursos limita el daño de una falla a un compartimento. En inglés.
  • Netflix Technology Blog, "Making the Netflix API More Resilient" — netflixtechblog.com/making-the-netflix-api-more-resilient-a8ec62159c2d. El post donde Netflix describe cómo aíslan cada dependencia en su propio thread pool para que la falla de una no tumbe la API entera —el origen práctico de Hystrix y el bulkhead por dependencia—. Gratis y en inglés.
  • Documentación de Hystrix, "How it Works" — github.com/Netflix/Hystrix/wiki/How-it-Works. Explica las dos estrategias de aislamiento —thread pool y semaphore— que este módulo mide, con diagramas de cómo cada dependencia queda en su propio compartimento. En inglés.
  • Documentación de resilience4j, "Bulkhead" — resilience4j.readme.io/docs/bulkhead. El sucesor moderno de Hystrix; muestra las dos implementaciones (SemaphoreBulkhead y ThreadPoolBulkhead) que corresponden exactamente a las dos formas de aislamiento de la lección 3. En inglés.