Arquitectura

Implementación del patrón Outbox en PHP y MySQL: entrega garantizada de eventos en microservicios

Ruslan Ismailov Publicado 12 min de lectura
I

Introducción: el problema de la doble escritura y la pérdida de eventos

En una arquitectura de microservicios, los servicios se comunican mediante eventos. Un escenario típico: tras guardar un pedido en la base de datos, es necesario publicar el evento OrderCreated en un broker de mensajes (RabbitMQ, Kafka, etc.). A primera vista parece trivial: guardas el registro y publicas el evento. Sin embargo, aquí se esconde el problema de la doble escritura en la base de datos.

Veamos el enfoque ingenuo:

// Guardamos el pedido
$db->exec("INSERT INTO orders (id, status) VALUES (1, 'created')");

// Publicamos el evento en el broker
$broker->publish('order.created', ['order_id' => 1]);

Entre estas dos operaciones no existe atomicidad. Si la aplicación falla después del INSERT pero antes de la publicación, el evento se pierde. Si el broker no está disponible, el evento tampoco llegará. Como resultado, el estado de la base de datos y el estado de los demás servicios divergen, generando inconsistencias difíciles de depurar.

Precisamente para resolver este problema existe el patrón Transactional Outbox.

Qué es el patrón Transactional Outbox y para qué sirve

El patrón Outbox (también conocido como Transactional Outbox) es un patrón arquitectónico que garantiza la escritura atómica de datos de negocio y los eventos que deben publicarse. La idea es simple: en lugar de publicar el evento directamente en el broker, lo guardamos en una tabla especial outbox dentro de la misma transacción MySQL que los datos principales. Un proceso separado (polling worker) lee esta tabla y publica los eventos en el broker.

Ventajas clave del patrón:

  • Entrega garantizada de eventos: el evento se guarda de forma atómica junto con los datos de negocio, por lo que no puede perderse debido a un fallo de la aplicación.
  • Sin dependencia del broker en el momento de procesar la solicitud: si el broker no está disponible, la solicitud se completa igualmente con éxito.
  • Idempotencia: es seguro reintentar la publicación ante fallos del worker.
  • Simplicidad: no requiere transacciones distribuidas ni commit en dos fases.

El patrón es especialmente relevante en microservicios PHP donde la fiabilidad transaccional es crítica y el uso de transacciones distribuidas no es deseable.

Esquema arquitectónico: escritura atómica en MySQL

La arquitectura se basa en tres componentes:

  1. Servicio principal: en una sola transacción guarda los datos de negocio y añade un registro en la tabla outbox.
  2. Tabla outbox: almacenamiento intermedio de eventos dentro de la misma base de datos MySQL.
  3. Polling worker: proceso en segundo plano que periódicamente lee los registros no procesados de outbox, los publica en el broker y los marca como procesados.

Es importante entender: la garantía de atomicidad se logra precisamente porque la escritura en outbox ocurre en la misma transacción MySQL que el INSERT/UPDATE de los datos principales. O ambas operaciones tienen éxito, o ambas se revierten.

Implementación paso a paso en PHP y Laravel

Estructura de la tabla outbox

Creamos la tabla outbox en MySQL. Debe almacenar el tipo de evento, el payload, el estado de procesamiento y las marcas de tiempo:

CREATE TABLE outbox (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    event_type  VARCHAR(255)  NOT NULL,
    payload     JSON          NOT NULL,
    status      ENUM('pending', 'processing', 'processed', 'failed')
                DEFAULT 'pending' NOT NULL,
    attempts    TINYINT UNSIGNED DEFAULT 0 NOT NULL,
    created_at  DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    processed_at DATETIME(3)  NULL,
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

El índice idx_status_created es crítico para el rendimiento de las consultas de polling: el worker siempre selecciona filas con status = 'pending' ordenadas por fecha de creación.

Escritura de eventos en una sola transacción (PHP puro)

Ejemplo sin framework usando PDO:

$pdo->beginTransaction();
try {
    // 1. Operación de negocio principal
    $stmt = $pdo->prepare(
        "INSERT INTO orders (user_id, status, total) VALUES (?, 'created', ?)"
    );
    $stmt->execute([$userId, $total]);
    $orderId = $pdo->lastInsertId();

    // 2. Escritura del evento en outbox dentro de la misma transacción
    $payload = json_encode([
        'order_id' => $orderId,
        'user_id'  => $userId,
        'total'    => $total,
    ], JSON_THROW_ON_ERROR);

    $outbox = $pdo->prepare(
        "INSERT INTO outbox (event_type, payload) VALUES ('OrderCreated', ?)"
    );
    $outbox->execute([$payload]);

    $pdo->commit();
} catch (\Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

Ambos INSERT se ejecutan en una sola transacción: la atomicidad está garantizada.

Escritura de eventos en Laravel

En Laravel la implementación es aún más concisa gracias a Eloquent y el método DB::transaction():

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($userId, $total) {
    $order = Order::create([
        'user_id' => $userId,
        'status'  => 'created',
        'total'   => $total,
    ]);

    DB::table('outbox')->insert([
        'event_type' => 'OrderCreated',
        'payload'    => json_encode([
            'order_id' => $order->id,
            'user_id'  => $userId,
            'total'    => $total,
        ], JSON_THROW_ON_ERROR),
        'created_at' => now(),
    ]);
});

Polling worker en PHP

El worker se ejecuta como un proceso de larga duración independiente (por ejemplo, mediante supervisord). Su tarea es recoger periódicamente un lote de eventos no procesados y publicarlos:

while (true) {
    $pdo->beginTransaction();
    try {
        // Tomamos hasta 10 filas con bloqueo
        $stmt = $pdo->query(
            "SELECT id, event_type, payload
             FROM outbox
             WHERE status = 'pending'
             ORDER BY created_at ASC
             LIMIT 10
             FOR UPDATE SKIP LOCKED"
        );
        $events = $stmt->fetchAll(PDO::FETCH_ASSOC);

        if (empty($events)) {
            $pdo->rollBack();
            sleep(1); // Pausa cuando la cola está vacía
            continue;
        }

        $ids = array_column($events, 'id');

        // Marcamos como processing
        $placeholders = implode(',', array_fill(0, count($ids), '?'));
        $pdo->prepare(
            "UPDATE outbox SET status = 'processing' WHERE id IN ($placeholders)"
        )->execute($ids);

        $pdo->commit();

        // Publicamos los eventos en el broker (fuera de la transacción)
        foreach ($events as $event) {
            try {
                $broker->publish(
                    $event['event_type'],
                    json_decode($event['payload'], true, 512, JSON_THROW_ON_ERROR)
                );

                // Marcamos como processed
                $pdo->prepare(
                    "UPDATE outbox
                     SET status = 'processed', processed_at = NOW(3)
                     WHERE id = ?"
                )->execute([$event['id']]);

            } catch (\Throwable $e) {
                // En caso de error, incrementamos el contador de intentos
                $pdo->prepare(
                    "UPDATE outbox
                     SET status = 'pending', attempts = attempts + 1
                     WHERE id = ?"
                )->execute([$event['id']]);

                error_log("Outbox publish failed for id={$event['id']}: " . $e->getMessage());
            }
        }

    } catch (\Throwable $e) {
        $pdo->rollBack();
        error_log('Outbox worker error: ' . $e->getMessage());
        sleep(5);
    }
}

Optimización del polling worker

SELECT FOR UPDATE SKIP LOCKED en MySQL 8+

La construcción SELECT FOR UPDATE SKIP LOCKED apareció en MySQL 8.0 y es la herramienta clave para escalar el worker. Permite que múltiples instancias del worker procesen la cola en paralelo sin bloquearse entre sí: cada instancia omite las filas ya bloqueadas por otro proceso.

Sin SKIP LOCKED, el segundo worker esperará a que se libere el bloqueo, lo que representa un cuello de botella. Con SKIP LOCKED, cada worker obtiene su lote de filas de inmediato y trabaja de forma independiente.

Intervalos de polling y polling adaptativo

Un sleep(1) estático no siempre es óptimo. Con la cola vacía, se puede aplicar backoff exponencial: aumentar la pausa hasta 5–10 segundos para reducir la carga sobre MySQL. Cuando aparecen eventos, restablecer el intervalo al mínimo de inmediato.

Manejo de duplicados e idempotencia

El worker puede entregar un evento dos veces (por ejemplo, si falla después de publicar en el broker pero antes de actualizar el estado). Por ello, los consumidores de eventos deben ser idempotentes. Para ello conviene transmitir el id del registro de la tabla outbox como identificador único del evento: el consumidor puede deduplicar usando dicho identificador.

También es recomendable limitar el número máximo de intentos: si attempts >= 5, pasar el registro al estado failed y alertar al equipo.

Monitoreo de la tabla outbox

Sin monitoreo, el patrón Outbox pierde su sentido: no sabrás si la cola se acumula o si hay eventos bloqueados.

Métricas clave a seguir:

  • Número de filas con estado pending: si crece, el worker no da abasto o se ha detenido.
  • Número de filas con estado failed: requiere atención inmediata.
  • Lag (retraso): diferencia entre el created_at del registro pending más antiguo y el tiempo actual.
  • Throughput del worker: número de eventos procesados por segundo.

Consulta para obtener el estado actual de la cola:

SELECT
    status,
    COUNT(*)                          AS count,
    MIN(created_at)                   AS oldest,
    MAX(attempts)                     AS max_attempts
FROM outbox
WHERE status IN ('pending', 'processing', 'failed')
GROUP BY status;

Estas métricas se pueden exportar fácilmente a Prometheus mediante un exporter personalizado o integrarse en Laravel Horizon / Telescope. Configura alertas: si el lag supera los 60 segundos o el número de registros failed es mayor que cero, envía una notificación a Slack/PagerDuty.

Si la cola crece, verifica primero: si el worker está activo, si hay problemas de conexión con el broker y si MySQL ha alcanzado el límite de conexiones.

Comparación con alternativas

Change Data Capture (CDC) con Debezium

CDC es un enfoque más avanzado: Debezium lee el log binario de MySQL (binlog) y publica automáticamente los cambios de filas en Kafka. No requiere modificar el código de la aplicación ni un polling worker. Sin embargo, tiene un coste: mayor complejidad de infraestructura (se necesita Kafka Connect, Zookeeper/KRaft), configuración de replicación en MySQL y restricciones en tipos de datos y esquemas.

El patrón Outbox con polling worker es más sencillo de implementar y operar para la mayoría de los equipos, especialmente si Kafka aún no se utiliza en el proyecto.

Publicación directa de eventos

Publicar el evento directamente desde el código del servicio (sin outbox) es el enfoque más simple, pero no ofrece ninguna garantía de entrega. Solo es adecuado para notificaciones no críticas donde la pérdida de un evento es aceptable.

Commit en dos fases (2PC)

Teóricamente resuelve el problema, pero en la práctica es extremadamente complejo de implementar, escala mal y crea puntos únicos de fallo. En microservicios PHP prácticamente no se utiliza.

Casos reales y problemas frecuentes

En la práctica, al implementar el patrón Outbox en PHP y MySQL los equipos se enfrentan a una serie de problemas no triviales:

  • Crecimiento de la tabla outbox: es necesario limpiar periódicamente los registros procesados. Un simple cron job con DELETE FROM outbox WHERE status = 'processed' AND processed_at < NOW() - INTERVAL 7 DAY resuelve el problema, pero no olvides el particionamiento bajo alta carga.
  • Orden de los eventos: el polling worker procesa eventos en orden de created_at, pero con workers paralelos el orden dentro de un mismo agregado puede romperse. Solución: enrutar los eventos por clave (por ejemplo, order_id) hacia una única instancia del worker.
  • Payloads grandes: el campo de tipo JSON en MySQL almacena datos de forma eficiente, pero no guardes el objeto completo en el evento. Es mejor transmitir el identificador y el tipo de evento; el consumidor obtendrá los datos actuales por sí mismo (patrón Event-Carried State Transfer vs. Event Notification).
  • Transacciones largas: si la transacción de negocio principal tarda mucho tiempo, el registro en outbox permanecerá invisible para el worker durante ese periodo. Controla la duración de las transacciones.
  • Múltiples tablas outbox: bajo alta carga tiene sentido separar los eventos de distintos dominios en tablas independientes para evitar la contención de bloqueos.

El patrón Outbox no es una bala de plata, sino un compromiso consciente entre complejidad y fiabilidad. Añade latencia en la entrega de eventos (equivalente al intervalo de polling), pero a cambio ofrece atomicidad y resiliencia ante fallos.

Conclusión y recomendaciones

El patrón Transactional Outbox es una de las formas más prácticas de garantizar la entrega de eventos en microservicios PHP sin recurrir a transacciones distribuidas. Su implementación en MySQL requiere una infraestructura mínima y encaja bien en proyectos PHP existentes, incluidos los basados en Laravel.

Recomendaciones clave para una implementación exitosa:

  1. Escribe siempre el evento en outbox en la misma transacción que los datos principales: esa es la base de la atomicidad.
  2. Usa SELECT FOR UPDATE SKIP LOCKED para un polling paralelo seguro en MySQL 8+.
  3. Haz que los consumidores de eventos sean idempotentes: el worker puede entregar un evento más de una vez.
  4. Configura el monitoreo: el lag de la cola y el número de registros failed deben estar visibles en tus dashboards.
  5. Limpia regularmente los registros procesados de la tabla outbox.
  6. Considera CDC con Debezium solo si ya cuentas con infraestructura Kafka y el equipo está preparado para operarla.

Comienza con una implementación sencilla, mide el rendimiento y escala a medida que crezca la carga. El patrón Outbox en PHP y MySQL es una solución probada que funciona de forma fiable incluso en sistemas de alta demanda.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →