Arquitectura de software
Patrones de diseño de microservicios: resiliencia, descubrimiento, datos y observabilidad
Avanzado11 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Los sistemas distribuidos fallan de maneras que un único proceso no conoce, y el catálogo de patrones de microservicios existe para que el sistema sobreviva a esos fallos. Esta guía agrupa los patrones según el problema que resuelven (enrutamiento, resiliencia, consistencia de datos, despliegue y observabilidad), muestra la configuración y el código de los que de verdad vas a implementar y deja claro el coste que añade cada uno.
Lo que aprenderás
- Elegir el patrón de enrutamiento y descubrimiento adecuado para una plataforma de despliegue concreta
- Configurar timeouts, retries, circuit breakers y bulkheads para que los fallos queden contenidos
- Hacer idempotente una operación para que los reintentos y las reentregas sean seguros
- Elegir entre CQRS, event sourcing y un simple modelo de lectura compartido ante un problema de consultas
- Decidir si un service mesh compensa su complejidad operativa en tu plataforma
El catálogo de patrones de microservicios parece una lista de la compra, y tratarlo como tal es la forma más rápida de construir algo que nadie es capaz de operar. Cada patrón de esta guía existe porque un fallo concreto hizo el daño suficiente a alguien como para dejarlo por escrito, y cada uno añade piezas móviles. La pregunta útil nunca es «qué patrones deberíamos adoptar», sino «a qué fallo estoy expuesto ahora mismo y qué es lo más barato que lo elimina».
Esta guía está organizada por problema y no por nombre de patrón. Da por hecho que ya has elegido el estilo y trazado tus fronteras (para eso, consulta microservicios) y deja el tratamiento a fondo de la consistencia para transacciones distribuidas y consistencia, así que aquí la saga y el outbox solo aparecen en la medida en que dan forma a un diseño.
Llevar una petición a la instancia correcta
Gateway de borde. Los clientes no deberían conocer la topología de tus servicios. Un único punto de entrada termina TLS, autentica, aplica límites de tasa y enruta por ruta o por host al servicio que corresponde. Mantiene las preocupaciones transversales en un solo lugar y te permite mover una ruta a una nueva implementación sin tocar a los clientes, que es precisamente lo que hace posible una migración strangler fig. El gateway tiene que ser delgado: enrutamiento, políticas, traducción de protocolos y nada que codifique reglas de negocio. La guía de gestión de APIs trata a fondo los productos y las políticas.
Backend for frontend (BFF). Cuando una app móvil y una web necesitan agregaciones sustancialmente distintas, una única API de gateway que sirva a las dos acaba siendo un compromiso que no le va bien a ninguna. Un BFF es un servicio pequeño por tipo de cliente, propiedad del equipo de ese cliente, que compone llamadas a los servicios downstream y da forma a las respuestas para un solo consumidor. El riesgo es duplicar lógica entre BFFs; limítalos a componer y formatear.
Descubrimiento de servicios. Las instancias aparecen y desaparecen, así que quien llama resuelve un nombre lógico a una dirección en el momento de la llamada. Hay dos variantes. En el descubrimiento en el cliente, quien llama consulta un registro y elige él mismo la instancia, lo que permite una selección sofisticada (la menos cargada, con afinidad de zona) a cambio de tener un cliente de descubrimiento en cada lenguaje que uses. En el descubrimiento en el servidor, quien llama habla con una dirección virtual estable y es un proxy o el balanceador de la plataforma quien elige la instancia. En Kubernetes esto es simplemente un Service, y es la razón de que la mayoría de los sistemas nuevos no necesiten ningún registro aparte.
| Enfoque | Dónde se toma la decisión | Encaja con |
|---|---|---|
| Registro en el cliente | En el proceso que llama | Plataformas mixtas, despliegues en VM, enrutamiento fino |
| Proxy en el servidor | En un balanceador o en la plataforma | Orquestación de contenedores, flotas políglotas |
| Solo DNS | Resolutor, con caché según el TTL | Topologías sencillas; ojo con las cachés obsoletas en un failover |
Sobre el balanceo de carga, una advertencia: round-robin reparte las peticiones por igual, y ese es el objetivo equivocado cuando unas peticiones cuestan más que otras. Una política de «least outstanding requests» envía el trabajo a la instancia con menos llamadas en curso y se adapta sola a una instancia lenta; úsala siempre que tu proxy o tu cliente la ofrezca.
Contener los fallos
Toda llamada remota puede salir mal de tres maneras: falla rápido, falla lento o miente. El fallo lento es el peligroso, porque quien espera a una dependencia retiene un thread, una conexión y memoria, y unos pocos miles de llamadas en esa situación agotan la capacidad del propio servicio que llama. Todos los patrones que siguen atacan esa cadena.
Timeouts. El control más rentable de todos, y el que más a menudo se deja en un valor por defecto de la biblioteca que se mide en minutos. Cada llamada saliente necesita un plazo calculado a partir de lo que quien llama se puede permitir, no de lo que suele tardar el servicio llamado. Una disciplina útil es el presupuesto por petición: una petición entrante que dispone de 800 ms asigna 300 ms a una dependencia y 200 ms a otra, y cualquier llamada que fuera a superar el presupuesto restante ni siquiera se intenta.
Reintentos con backoff y jitter. Reintentar un fallo transitorio merece la pena; reintentar de inmediato, desde todos los clientes, es la forma de convertir un tropiezo de cinco segundos en una estampida (thundering herd). Usa un número máximo de intentos bajo, backoff exponencial y jitter aleatorio para que los clientes no se sincronicen. Nunca reintentes una petición que no sea idempotente, y nunca reintentes ante una respuesta que indique un error del cliente.
Circuit breaker. Sigue la tasa de fallos reciente de una dependencia; cuando supere un umbral, deja de llamarla durante un periodo de enfriamiento y falla de inmediato con un fallback. Pasada esa ventana, deja pasar unas pocas llamadas de prueba y cierra el breaker si salen bien. El beneficio es doble: quien llama deja de quemar recursos en llamadas que van a fallar, y la dependencia en apuros tiene la oportunidad de recuperarse en lugar de seguir recibiendo golpes.
Bulkhead. Compartimenta los recursos de quien llama para que ninguna dependencia pueda consumirlos todos. En la práctica, eso significa un pool de threads acotado o un semáforo que limite las llamadas concurrentes por dependencia, y pools de conexiones separados en lugar de uno compartido.
resilience4j:
circuitbreaker:
instances:
pricing:
slidingWindowType: COUNT_BASED
slidingWindowSize: 50
failureRateThreshold: 50 # se abre con un 50 % de fallos en la ventana
slowCallDurationThreshold: 400ms # una llamada lenta cuenta como fallo
slowCallRateThreshold: 60
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 5
bulkhead:
instances:
pricing:
maxConcurrentCalls: 20 # nunca más de 20 en curso
maxWaitDuration: 0 # rechazar de inmediato en lugar de encolar
retry:
instances:
pricing:
maxAttempts: 3
waitDuration: 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- java.io.IOException
@Service
class PricingClient {
@CircuitBreaker(name = "pricing", fallbackMethod = "listPrice")
@Bulkhead(name = "pricing")
@Retry(name = "pricing")
Money quote(String sku, String customerId) {
return http.get("/quotes/{sku}?customer={c}", Money.class, sku, customerId);
}
// Degradar, no fallar: el precio de catálogo es peor que una cotización, pero mejor que una página de error.
private Money listPrice(String sku, String customerId, Throwable cause) {
log.warn("pricing degraded for {}: {}", sku, cause.toString());
return catalogue.listPrice(sku);
}
}
El rate limiting y el load shedding protegen el otro lado de la misma relación, el del servicio llamado. Un servicio que acepta todas las peticiones hasta derrumbarse no atiende a nadie; uno que rechaza rápido la carga sobrante con un 429 sigue atendiendo el tráfico que puede asumir. Descartar por prioridad (rechazar el tráfico batch y de informes antes que el interactivo) es un refinamiento barato y eficaz.
Hacer que las operaciones se puedan repetir sin riesgo
Los patrones de resiliencia garantizan que algunas peticiones se entregarán más de una vez. La entrega at-least-once de un broker garantiza lo mismo. La única defensa es la idempotencia: procesar dos veces la misma operación lógica tiene el mismo efecto que procesarla una.
Las lecturas y los borrados son idempotentes por naturaleza, y PUT sustituye un recurso completo, así que normalmente también lo es. El problema está en la creación y la modificación. La respuesta habitual es una clave de idempotencia: el cliente la envía, el servidor la guarda junto con el resultado y devuelve ese mismo resultado ante una repetición.
-- Lo que hace que esto sea correcto es la restricción de unicidad, no el código de la aplicación.
CREATE TABLE processed_request (
idempotency_key VARCHAR(64) PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
El handler inserta la clave en la misma transacción que el cambio de negocio. Si el insert viola la clave primaria, la petición es una repetición: devuelve la respuesta guardada. Si llega la misma clave con un hash de payload distinto, es un bug del cliente: recházala con un 422 en lugar de devolver en silencio la respuesta antigua. Conserva las filas más tiempo que la ventana de reintentos de cualquier cliente y después haz que caduquen.
Para los consumidores de mensajes se aplica la misma idea, con el identificador del mensaje como clave. Cuando el efecto es una transición de estado, funciona bien una alternativa más barata: condiciona la actualización al estado actual, de modo que UPDATE orders SET status='PAID' WHERE id=? AND status='PENDING' simplemente no afecte a ninguna fila ante un duplicado.
Patrones de datos entre servicios
En cuanto cada servicio es dueño de sus datos aparecen tres problemas recurrentes: escribir en tu base de datos y publicar un evento de forma atómica, mantener la consistencia de un proceso de negocio que atraviesa varios servicios y consultar datos que no son tuyos.
Transactional outbox. Un servicio no puede escribir en su base de datos y publicar en un broker dentro de una misma transacción, así que escribe el evento en una tabla outbox dentro de la transacción de negocio, y un relay independiente lee esa tabla y publica. No se pierde nada si el proceso muere entre los dos pasos.
Saga. Un proceso de negocio que abarca varios servicios se convierte en una secuencia de transacciones locales, cada una con una acción compensatoria por si falla un paso posterior. La coreografía conecta los servicios mediante eventos; la orquestación pone a un coordinador al mando de la secuencia. Ambas se tratan como es debido, con análisis de fallos, en la guía de transacciones distribuidas.
CQRS. Separa el modelo con el que escribes del modelo con el que lees. El modelo de escritura hace cumplir los invariantes y se mantiene normalizado; uno o varios modelos de lectura se diseñan para consultas concretas y se actualizan a partir de eventos. Úsalo cuando los requisitos de lectura y escritura divergen de verdad (búsquedas de alto volumen sobre datos con un modelo de escritura complejo) y no por defecto, porque introduce un retraso que todos los consumidores deben tolerar y en torno al cual hay que diseñar cada interfaz.
Event sourcing. Persiste la secuencia de eventos que cambian el estado en lugar del estado actual, y reconstruye el estado reproduciéndolos. Ganas una pista de auditoría perfecta, consultas temporales y la posibilidad de construir nuevas proyecciones a partir del historial. Lo pagas con la evolución del esquema de unos eventos que viven para siempre, con snapshots para los flujos largos y con un modelo mental poco familiar para la mayoría de los equipos. Adóptalo en un bounded context donde el historial es el producto (libros contables, trading, cumplimiento normativo) y no en todo el sistema.
Composición de APIs. El caso sencillo: quien llama consulta en paralelo varios servicios y une los resultados. Perfectamente razonable para conjuntos de resultados pequeños, inviable para cualquier cosa que necesite filtrar u ordenar a través de varios servicios.
Patrones de despliegue y de plataforma
Sidecar. Despliega un contenedor auxiliar junto al servicio, en la misma unidad, compartiendo su ciclo de vida y su espacio de nombres de red, para aportar una capacidad que el servicio no debería implementar por sí mismo: envío de logs, rotación de credenciales, un proxy. El servicio se centra en el comportamiento de negocio y el sidecar se actualiza de forma independiente.
Service mesh. Un mesh son sidecars por todas partes: cada instancia recibe un proxy, y un plano de control los configura para ofrecer TLS mutuo, reintentos, timeouts, reparto de tráfico y telemetría uniforme sin código de aplicación. Eso tiene un valor real en una flota grande y políglota. También es un segundo sistema distribuido, con su propio camino de actualización, sus propios modos de fallo y su coste en latencia, y duplica capacidades que quizá ya te dan tus bibliotecas. Decide contando: cuántos lenguajes, cuántos servicios y qué parte de la lista de funcionalidades del mesh tendrías que escribir tú si no lo usaras.
Configuración externalizada. La configuración viene del entorno, no del artefacto, de modo que una misma imagen se promueve sin cambios de un entorno a otro. Los secretos vienen de un gestor de secretos y se pueden rotar sin recompilar.
Endpoints de salud. Distingue liveness (¿está el proceso roto sin remedio? Reinícialo) de readiness (¿puede atender tráfico ahora mismo? No le envíes nada hasta que pueda). Confundirlos provoca bucles de reinicio cuando una dependencia deja de estar disponible un momento, lo que convierte una caída parcial en una total.
Despliegues blue/green y canary. Envía una pequeña parte del tráfico de producción a la nueva versión, compara su tasa de errores y su latencia con las de la anterior, y promociona o haz rollback de forma automática. Es el patrón que hace que el despliegue independiente sea seguro en la práctica, y depende de interfaces y cambios de base de datos compatibles hacia atrás.
Ver qué está pasando
En un monolito, una stack trace explica un fallo. En un sistema distribuido nada lo explica a menos que lo hayas preparado para ello.
Correlación y trazas distribuidas. Cada petición entrante recibe un identificador de traza que se propaga en cada llamada saliente mediante las cabeceras W3C traceparent, y cada salto registra un span. La traza resultante muestra exactamente dónde se fueron los 4 segundos del checkout. OpenTelemetry permite instrumentar todo esto sin atarte a un proveedor; con la autoinstrumentación de los frameworks habituales, lo básico cuesta poco esfuerzo.
Logs estructurados. Líneas JSON con el ID de traza, el nombre del servicio, la versión y el tenant en cada registro, agregadas de forma centralizada. Los logs de texto libre repartidos entre doce servicios son imposibles de buscar.
Métricas que describen a los usuarios. Tasa de peticiones, tasa de errores y percentiles de latencia por endpoint, más la saturación de los pools que has compartimentado con bulkheads. Alerta sobre objetivos de nivel de servicio («el 99 % de los checkouts se completa en menos de 1 s a lo largo de 30 días») y no sobre la CPU, que no te dice nada de si alguien lo está pasando mal. Interpretar bien esos percentiles es el tema de rendimiento de aplicaciones.
Salud agregada. Un panel por servicio con las cuatro señales y el estado de los circuit breakers basta para la mayoría de los incidentes; cualquier cosa más elaborada acaba sin que nadie la mire.
Ideas clave
- Adopta un patrón para eliminar un fallo al que estés realmente expuesto; todos añaden piezas móviles y superficie operativa.
- Los gateways y el descubrimiento de servicios evitan que los clientes conozcan la topología; en una plataforma de contenedores, la abstracción de servicio de la propia plataforma suele sustituir a un registro aparte.
- Timeouts, reintentos acotados con jitter, circuit breakers y bulkheads funcionan como un conjunto: configura los reintentos en una sola capa y colócalos dentro del breaker.
- La entrega at-least-once y los reintentos hacen obligatoria la idempotencia; garantízala con una clave de idempotencia almacenada o con una actualización condicionada al estado, no con una lógica de aplicación esperanzada.
- Outbox, saga, CQRS y event sourcing resuelven problemas de datos distintos; usa el más pequeño que encaje y no recurras a event sourcing por defecto.
- Las trazas distribuidas, los logs estructurados con un ID de correlación y las alertas basadas en SLO no son extras opcionales: sin ellos, un fallo en microservicios no tiene explicación.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Dónde deben vivir los reintentos: en el cliente, en el gateway o en el service mesh?
- En un único sitio. Los reintentos configurados en tres niveles se multiplican: tres intentos en cada nivel se convierten en veintisiete peticiones a un servicio que ya está sufriendo, y así es como una degradación se convierte en una caída. Ponlos donde quien llama sabe si la operación se puede repetir sin riesgo, que normalmente es la biblioteca cliente, y desactívalos en el resto. Si los reintentos los hace el mesh, la aplicación no debe hacerlos.
- ¿Qué diferencia hay entre un circuit breaker y un bulkhead?
- Un circuit breaker deja de llamar a una dependencia que ya está fallando, de modo que quien llama falla rápido en lugar de quedarse en cola detrás de timeouts. Un bulkhead limita cuánto de los recursos propios de quien llama (threads, conexiones, peticiones en curso) puede consumir una sola dependencia, para que una dependencia lenta no deje sin recursos al resto de la aplicación. Atacan dos mitades distintas del mismo problema y normalmente se aplican juntos.
- ¿Necesito un service mesh?
- Solo cuando tienes suficientes servicios, en suficientes lenguajes, como para que implementar mTLS, reintentos, reparto de tráfico y telemetría en cada biblioteca cliente se haya convertido en una carga real. Un mesh traslada ese trabajo a proxies sidecar a cambio de otro plano de control distribuido que operar, actualizar y depurar, más un pequeño peaje de latencia en cada salto. Por debajo de una docena de servicios, más o menos, las bibliotecas suelen salir más baratas.
- ¿Hace falta event sourcing para aplicar CQRS?
- No, y confundirlos genera mucha complejidad innecesaria. CQRS solo significa que el modelo de escritura y el de lectura están separados, algo que puedes implementar con una base de datos corriente y una tabla de proyección. Event sourcing significa que el registro de eventos es la fuente de verdad. Cada uno es útil por separado; combinarlos tiene sentido sobre todo cuando necesitas un historial de auditoría completo y consultas temporales.