Tema representativo de entrevista

Entrevista de Backend: ¿Cómo resuelves el problema de la doble escritura (Dual-Write) entre base de datos y Message Broker?

BackendDifícil
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Un servicio de pedidos escribe un pedido en PostgreSQL y publica OrderCreated en un message broker. Sin commit distribuido en dos fases (2PC), ¿cómo garantizarías que un pedido revertido no emita ningún evento, que un pedido confirmado eventualmente emita al menos un evento y que las caídas del relay o del consumidor no dupliquen el efecto de negocio? Explica también el ordenamiento, las operaciones y la verificación.

Prompt y contexto de aplicación

Un servicio de pedidos debe realizar dos acciones ante un único comando:

  1. escribir un pedido en PostgreSQL; y
  2. publicar un evento OrderCreated en un message broker.

El contrato de negocio es más estricto que simplemente "intentar ambas llamadas". Si la transacción de la base de datos se revierte (rollback), ningún evento debe describir ese pedido inexistente. Si la transacción se confirma (commit), la intención de publicar debe sobrevivir a la caída de un proceso y eventualmente llegar al broker. Los eventos correspondientes al mismo pedido deben mantener su orden, mientras que un orden global no es necesario. El relay y los consumidores pueden fallar en cualquier momento, y el broker puede reenviar mensajes. No se dispone de un commit distribuido en dos fases.

Este es el problema del transactional outbox. Aparece siempre que una solicitud modifica el estado transaccional y debe desencadenar trabajo de forma confiable en otro sistema: reserva de inventario, facturación, indexación de búsqueda, correos electrónicos, webhooks o analítica. El objetivo no es una entrega exactamente-una-vez (exactly-once) mágica. El objetivo es identificar el límite atómico disponible, hacer duradera la intención que cruza los límites y asegurar que los reintentos sean seguros.

Qué evalúa el entrevistador

La primera señal es el razonamiento sobre las ventanas de falla. "Escribir en la base de datos y luego publicar" pierde el evento si el proceso se cae tras el commit. "Publicar y luego hacer commit" expone un evento incluso si la base de datos se revierte posteriormente. Un callback en memoria posterior al commit también desaparece con el proceso. Una respuesta sólida nombra estas ventanas antes de proponer un patrón.

La segunda señal es una garantía exacta. La fila de negocio y una fila de outbox pueden confirmarse atómicamente en una única transacción local de la base de datos. La publicación en el broker ocurre después. Esto garantiza una intención de evento duradera para cada mutación confirmada; no convierte a la base de datos y al broker en una sola transacción, ni promete una entrega exactamente-una-vez.

La tercera señal es la seguridad en los reintentos de extremo a extremo. Si el broker acepta un evento y el relay se cae antes de registrar el éxito, el evento se publicará de nuevo. Por lo tanto, el relay proporciona una publicación al-menos-una-vez (at-least-once), y cada consumidor debe hacer que su efecto de negocio sea idempotente. Una buena respuesta también distingue el efecto en la base de datos local del consumidor de un efecto secundario externo como cobrar una tarjeta.

Las señales finales son el ordenamiento y la operabilidad: números de secuencia por agregado, claves de partición, propiedad concurrente del relay, eventos venenosos (poison events), política de reintentos, limpieza, retención para reejecución (replay), métricas de retraso (lag) y pruebas de inyección de fallas. Mencionar el patrón sin estos límites resulta incompleto.

Preguntas para aclarar antes de responder

  • ¿Cuál es la garantía requerida? ¿Es suficiente una publicación al-menos-una-vez con un efecto de negocio exactamente-una-vez, o se requiere una confirmación síncrona antes de responder al cliente?
  • ¿Qué mutación y qué evento van juntos? Una mutación de pedido puede generar un evento, o una sola transacción puede generar varios eventos que requieran números de secuencia consecutivos por pedido.
  • ¿Qué ordenamiento se requiere? Este diseño asume un orden por pedido, no un orden total global entre todos los pedidos.
  • ¿Qué puede garantizar el broker? Pregunta sobre acuses de recibo (acknowledgements), reenvíos, ordenamiento por partición, retención e idempotencia del productor. Ninguno de ellos elimina por sí solo la brecha de transferencia entre la base de datos y el broker.
  • ¿Con qué rapidez debe aparecer un evento? El objetivo de latencia afecta el intervalo de sondeo (polling), la carga de la base de datos y si se justifica el uso de Change Data Capture (CDC).
  • ¿Qué hace el consumidor? Una actualización en la base de datos local puede compartir una transacción con una fila de inbox; un pago externo o un correo electrónico requiere una clave de idempotencia downstream u otra transferencia duradera.
  • ¿Durante cuánto tiempo debe ser posible la reejecución (replay)? La limpieza de los registros del outbox y de desduplicación del consumidor debe preservar el horizonte requerido de reintentos y replay.
  • ¿Pueden ambos recursos participar en un commit en dos fases? El prompt indica que no. Si un sistema real requiere verdaderamente atomicidad síncrona entre recursos y ambos lo soportan, aún deben evaluarse sus costos de disponibilidad y acoplamiento en lugar de declararlo universalmente imposible.

Estructura de respuesta de 30 segundos

"Escribiría el pedido y un evento inmutable de outbox en la misma transacción de PostgreSQL. Un relay independiente toma las filas confirmadas del outbox, las publica y las marca como publicadas únicamente tras el acuse de recibo del broker. Si el relay cae antes de publicar, la fila permanece pendiente; si cae después de que el broker acepta pero antes de marcarla, la republica, por lo que la entrega es al-menos-una-vez. Cada evento tiene un ID estable, y el consumidor inserta ese ID en una tabla de desduplicación dentro de la misma transacción de su actualización de negocio. Asignaría una secuencia por pedido, usaría el ID del pedido como clave de partición del broker, evitaría que eventos posteriores superen a un evento pendiente anterior y monitorearía la antigüedad del elemento pendiente más antiguo. Luego inyectaría caídas en cada límite de commit, publicación, confirmación y consumo para verificar las invariantes."

Análisis detallado paso a paso

Comienza demostrando por qué los órdenes de llamada evidentes fallan. En un flujo donde primero va la base de datos, esta puede confirmarse en el tiempo T1 y el proceso puede detenerse antes de que el broker acepte en T2; el pedido existe pero el evento no. Reintentar la solicitud HTTP no es una solución completa porque el cliente podría no reintentar, y un reintento puede duplicar el pedido a menos que el comando en sí sea idempotente. En un flujo donde primero va el broker, los consumidores pueden observar un evento antes de que la transacción del pedido falle. Invertir las llamadas solo invierte la inconsistencia.

Mueve la intención duradera dentro del único límite atómico que posee el servicio. En una sola transacción de PostgreSQL, valida el comando, muta el pedido, asigna la siguiente secuencia para ese pedido e inserta una fila inmutable en el outbox. O ambas filas se confirman, o ninguna lo hace. Un esquema representativo es:

sql
CREATE TABLE outbox_events (
  event_id uuid PRIMARY KEY,
  aggregate_type text NOT NULL,
  aggregate_id text NOT NULL,
  aggregate_sequence bigint NOT NULL,
  event_type text NOT NULL,
  schema_version integer NOT NULL,
  payload jsonb NOT NULL,
  occurred_at timestamptz NOT NULL DEFAULT now(),
  available_at timestamptz NOT NULL DEFAULT now(),
  claimed_by text,
  claim_until timestamptz,
  published_at timestamptz,
  attempt_count integer NOT NULL DEFAULT 0,
  last_error text,
  UNIQUE (aggregate_type, aggregate_id, aggregate_sequence)
);

CREATE INDEX outbox_dispatch_idx
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;

event_id permanece estable a lo largo de cada reintento. schema_version hace explícita la evolución del payload. La secuencia única por agregado evita que dos eventos ocupen la misma posición lógica. La secuencia debe asignarse bajo la misma transacción y regla de bloqueo que el agregado; una marca de tiempo o el orden de procesamiento del relay no son sustitutos seguros. Si una transacción emite múltiples eventos, asigna valores de secuencia consecutivos en el orden previsto.

Un relay basado en sondeo (polling) debe reclamar un lote pequeño en una transacción corta. Puede seleccionar filas con FOR UPDATE SKIP LOCKED, actualizar claimed_by y claim_until, y luego confirmar la transacción; el arrendamiento (lease) persistido evita que otros workers procesen intencionalmente la misma fila después de liberar el bloqueo de fila. Debe publicar fuera de un bloqueo prolongado de la base de datos y marcar la fila como publicada únicamente después de que el broker la confirme. La expiración del arrendamiento permite la recuperación cuando un worker muere. El backoff y available_at evitan que un destino con fallas genere un bucle de reintentos ajustado. Mantener abierta una transacción de base de datos durante la publicación por red incrementa la contención y aun así no crea atomicidad con el broker.

Existe una brecha inevitable de acuse de recibo. El broker puede aceptar de forma duradera el evento E, tras lo cual el relay puede caer antes de establecer published_at. Al recuperarse, E se publica nuevamente. Marcarlo antes de la publicación crearía la brecha opuesta, con pérdida de datos. Por lo tanto, el relay debe inclinarse por el lado seguro (posibles duplicados) y los consumidores deben desduplicar.

Para un consumidor cuyo efecto de negocio reside en una base de datos, almacena los IDs de eventos procesados en esa misma transacción:

sql
CREATE TABLE processed_events (
  consumer_name text NOT NULL,
  event_id uuid NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);

El consumidor inicia una transacción y utiliza INSERT ... ON CONFLICT DO NOTHING RETURNING para (consumer_name, event_id). Aplica el cambio de negocio únicamente cuando la inserción devuelve una fila, y luego hace commit. Si no se devuelve ninguna fila, significa que el evento ya fue aplicado, por lo que el duplicado puede reconocerse sin repetir el cambio. Escribir la fila de desduplicación en una transacción y el efecto de negocio en otra simplemente recrea un nuevo problema de doble escritura. El registro de desduplicación también debe conservarse durante al menos el tiempo que un evento antiguo pueda ser reejecutado.

Este inbox local no cubre atómicamente un efecto externo no transaccional. Para una API de pagos, pasa event_id como la clave de idempotencia del proveedor. Si el destino no soporta idempotencia, introduce otro comando/outbox duradero junto con un proceso de conciliación, o acepta un riesgo documentado de duplicación. La misma advertencia aplica para correos electrónicos, webhooks y otras llamadas irreversibles.

El ordenamiento debe alinearse con el límite del negocio. Asigna una secuencia monotónica por pedido, no permitas que la secuencia k + 1 sobrepase a una k no publicada, y utiliza aggregate_id como clave de partición en el broker. La consulta de reclamo puede seleccionar solo la secuencia no publicada más baja para cada agregado, o la propiedad del relay puede particionarse mediante un hash estable de aggregate_id; cualquiera de las opciones debe garantizar un único carril de publicación ordenado por agregado. Los consumidores pueden rechazar, almacenar en búfer o conciliar una brecha en la secuencia según el dominio. Exigir una secuencia global única serializaría pedidos no relacionados y reduciría la disponibilidad sin aportar beneficios a una invariante por pedido.

El sondeo (polling) es el relay portátil más simple y hace visible la propiedad en la base de datos de la aplicación, pero su intervalo intercambia latencia por carga de consultas. El índice de pendientes, los lotes pequeños, los arrendamientos y la limpieza acotada cobran importancia a medida que crece el volumen. Change Data Capture (CDC) puede seguir el log de la base de datos y enrutar las filas insertadas en el outbox con menor presión de sondeo y, a menudo, menor latencia. Añade offsets de conectores, retención de logs de base de datos, despliegue y recuperación al límite operativo. Capturar cambios arbitrarios en tablas de negocio también expone mutaciones de almacenamiento en lugar de eventos de dominio intencionales; un outbox explícito mantiene el contrato estable.

Las operaciones completan el diseño. Monitorea la cantidad de filas pendientes, la antigüedad de la fila no publicada más antigua, el rendimiento y fallas de despacho, el conteo de intentos, los arrendamientos expirados, la latencia de confirmación del broker, los conteos de desduplicación en consumidores, los eventos en cuarentena y el crecimiento de las tablas. Archiva o elimina filas publicadas en lotes acotados únicamente después del horizonte de auditoría y replay. Un evento venenoso requiere una política deliberada: reintentar, poner en cuarentena o reparar. Omitirlo puede violar el orden por pedido, por lo que los eventos posteriores para ese agregado no pueden continuar silenciosamente.

La verificación debe enfocarse en los límites, no solo en el camino feliz. Inyecta fallas antes del commit de la base de datos, después del commit pero antes de la respuesta, durante el reclamo del relay, antes de la publicación, después de la aceptación del broker pero antes de published_at, después del commit de negocio del consumidor pero antes del acuse de recibo, y durante la limpieza. Las pruebas deben establecer cuatro invariantes:

  1. cada mutación de negocio confirmada tiene exactamente una intención duradera en el outbox;
  2. cada mutación revertida no tiene ninguna intención en el outbox;
  3. cada intención duradera se publica eventualmente al menos una vez tras la recuperación; y
  4. las entregas duplicadas aplican el efecto de negocio visible para el consumidor exactamente una vez.

También detén el relay el tiempo suficiente para generar acumulación, reinícialo y verifica la recuperación del retraso, la secuencia por pedido, la carga acotada en la base de datos y el comportamiento de las alertas. Pon a prueba un evento venenoso, la evolución de versiones de payload, el replay de eventos antiguos y la limpieza en torno al límite de retención.

Ejemplo de respuesta de alta calidad

"Dado que la base de datos y el broker no comparten un commit atómico, primero incorporaría la intención del evento dentro de la transacción de la base de datos. La fila del pedido y una fila inmutable del outbox se confirman juntas. Si la transacción se revierte, ninguna existe. Si se confirma y el proceso muere inmediatamente, otro proceso aún puede ver la fila del outbox.

Un relay reclama filas pendientes mediante transacciones cortas de base de datos y un arrendamiento con expiración, las publica y establece published_at solo tras el acuse de recibo del broker. No mantendría un bloqueo de base de datos mientras se espera una respuesta de red. Todavía existe una ventana de caída después de que el broker acepta y antes de la actualización de estado, por lo que el relay puede publicar un duplicado. Ese es el sesgo de falla correcto: un duplicado es recuperable, mientras que un evento perdido no lo es.

Cada evento tiene un UUID estable. Un consumidor de base de datos inserta ese UUID en una tabla indexada por el nombre del consumidor dentro de la misma transacción de su actualización de negocio. Un duplicado entra en conflicto y se convierte en una operación no-op. Si el consumidor llama a un proveedor de pagos o de correos, debe pasar el UUID del evento como clave de idempotencia o usar otra transferencia duradera, ya que la transacción de desduplicación local no puede incluir ese efecto remoto.

Para el ordenamiento, asigno una secuencia dentro de la transacción del pedido, publico usando el ID del pedido como clave de partición y bloqueo que una secuencia posterior adelante a un evento pendiente anterior para ese pedido. No impongo un orden global. Comenzaría con sondeo a menos que los objetivos de latencia y carga justifiquen CDC, y luego monitorearía la antigüedad del pendiente más antiguo, reintentos, arrendamientos expirados, tasa de duplicados, eventos venenosos y crecimiento de tablas.

Por último, detendría procesos abruptamente en cada límite. Los resultados obligatorios son: un rollback no genera intención, un commit siempre deja una intención, la recuperación publica cada intención al menos una vez, y una entrega duplicada modifica el estado del consumidor una sola vez. El outbox resuelve la transferencia confiable; la idempotencia de solicitudes, la idempotencia de consumidores, la evolución de esquemas y la conciliación siguen siendo partes explícitas del sistema."

Errores comunes

  • Llamar a la base de datos y al broker secuencialmente → cualquiera de las llamadas puede tener éxito por separado → Confirma la mutación de negocio y la intención del evento en una sola transacción local de base de datos.
  • Llamar a un publicador en memoria después del commit → una caída pierde el callback y su estado → Persiste la intención antes de retornar.
  • Afirmar que el outbox ofrece entrega exactamente-una-vez → la brecha entre la aceptación del broker y el estado no registrado genera duplicados → Establece publicación al-menos-una-vez y diseña un efecto de negocio exactamente-una-vez.
  • Marcar una fila como publicada antes del acuse de recibo del broker → una caída puede perder el evento permanentemente → Registra el éxito solo después del acuse de recibo y tolera la republicación.
  • Escribir el estado de desduplicación de forma separada al efecto del consumidor → el consumidor recrea la misma brecha de doble escritura → Coloca ambos en una sola transacción local.
  • Tratar un inbox local como protección para un cobro remoto → el efecto remoto no puede unirse a la transacción → Usa una clave de idempotencia downstream, transferencia duradera y conciliación.
  • Usar marcas de tiempo para el ordenamiento → el reloj y la concurrencia no asignan una posición causal única → Asigna una secuencia transaccional por agregado y usa el agregado como clave de partición.
  • Ejecutar múltiples pollers sin reclamos ni arrendamientos → los workers compiten intencionalmente por las mismas filas → Usa reclamos cortos, expiración, lotes pequeños y un índice de filas pendientes.
  • Eliminar filas publicadas y de desduplicación de inmediato → los reintentos demorados y el replay pueden repetir efectos antiguos → Configura la limpieza a partir del horizonte documentado de replay y auditoría.
  • Probar solo la publicación exitosa → las garantías del diseño residen en las ventanas de caída → Inyecta fallas antes y después de cada límite duradero y valida las invariantes.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Qué sucede si el destino es una API externa en lugar de un broker?

La transacción de origen aún puede escribir un comando en el outbox. Un worker llama a la API con event_id como clave de idempotencia y registra la respuesta. Un timeout es ambiguo (el servicio remoto pudo haber completado la llamada), por lo que se debe reintentar únicamente con la misma clave. Si la API no ofrece soporte de idempotencia ni un estado de operación consultable, no se puede garantizar un efecto exactamente-una-vez; agrega conciliación o expón el riesgo de duplicados en el contrato de negocio.

Pregunta de seguimiento 2: ¿Qué sucede si un flujo de trabajo abarca varios servicios y bases de datos?

Un outbox publica de manera confiable la transición de estado local de cada servicio; no confirma atómicamente un flujo de trabajo completo entre múltiples servicios. Modela el flujo como una saga con pasos hacia adelante explícitos, idempotencia, estado persistido y acciones compensatorias. Cada paso de la saga puede usar su propia transacción local más outbox. Define qué sucede cuando la compensación también falla en lugar de describirlo como un rollback de todas las bases de datos.

Pregunta de seguimiento 3: ¿Qué pasa si el entrevistador exige un orden de eventos global estricto?

Aclara por qué agregados independientes necesitan un único orden y qué rendimiento o disponibilidad se puede sacrificar por ello. Un único secuenciador o una sola partición de broker puede establecer un orden total, pero se convierte en un cuello de botella de serialización y de fallas. La mayoría de los flujos de pedidos solo necesitan orden causal dentro de un mismo pedido, lo cual se logra de forma más económica mediante una secuencia transaccional de agregado y una clave de partición por agregado.

Pregunta de seguimiento 4: ¿Cómo se recupera el diseño de una interrupción del conector CDC?

Las filas confirmadas en el outbox siguen siendo la fuente de la verdad. Configura alertas para el retraso del conector y el margen de retención del log de la base de datos, persiste los offsets del conector de forma duradera y prueba el reinicio desde el último offset confirmado. La base de datos debe retener el log el tiempo suficiente para cubrir el objetivo de tiempo de interrupción; de lo contrario, se requerirá un snapshot o un backfill controlado. La desduplicación del consumidor hace que reproducir un rango superpuesto sea seguro.

Pregunta de seguimiento 5: ¿Cuándo se debe evitar el transactional outbox?

Utiliza el diseño más simple cuando el evento sea explícitamente de mejor esfuerzo (best effort), como telemetría descartable, o cuando el sistema downstream pueda consultar periódicamente (poll) la fuente de la verdad de forma segura y el objetivo de latencia lo permita. Si ambos recursos soportan genuinamente un commit en dos fases y la atomicidad síncrona es obligatoria, evalúa esa opción considerando sus costos de acoplamiento y disponibilidad. Event sourcing es otra alternativa, pero cambia el modelo de fuente de la verdad y no debería introducirse meramente para evitar una sola transferencia.

Fuentes públicas

Preguntas relacionadas