Implementación del patrón Outbox en PHP y MySQL: entrega garantizada de eventos en microservicios
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:
- Servicio principal: en una sola transacción guarda los datos de negocio y añade un registro en la tabla
outbox. - Tabla outbox: almacenamiento intermedio de eventos dentro de la misma base de datos MySQL.
- 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_atdel registropendingmá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 DAYresuelve 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
JSONen 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
outboxpermanecerá 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:
- Escribe siempre el evento en
outboxen la misma transacción que los datos principales: esa es la base de la atomicidad. - Usa
SELECT FOR UPDATE SKIP LOCKEDpara un polling paralelo seguro en MySQL 8+. - Haz que los consumidores de eventos sean idempotentes: el worker puede entregar un evento más de una vez.
- Configura el monitoreo: el lag de la cola y el número de registros
faileddeben estar visibles en tus dashboards. - Limpia regularmente los registros procesados de la tabla
outbox. - 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í →