Evolución de las Transacciones Locales a las Distribuidas
Para comprender la necesidad de las transacciones, analicemos un sistema financiero. Si un usuario transfiere fondos a otra cuenta, el saldo del remitente debe disminuir y el del destinatario aumentar simultáneamente. Si el sistema falla a mitad del proceso, los fondos podrían perderse. Aquí es donde las transacciones garantizan la integridad de los datos mediante las propiedades ACID: Atomicidda, Consistencia, Aislamiento y Durabilidad.
En entornos monolíticos, las bases de datos relacionales como MySQL gestionan esto mediante transacciones locales (BEGIN, COMMIT, ROLLBACK), utilizando registros redo y undo. Frameworks como Spring abstraen esta complejidad mediante la anotación @Transactional, gestionando el ciclo de vida a través de proxies.
Sin embargo, a medida que el volumen de datos crece, surgen cuellos de botella en el hardware (CPU, memoria, I/O, red). La fragmentación de bases de datos (sharding) se vuelve necesaria:
- Fragmentación Vertical: Divide las columnas de una tabla en múltiples tablas para reducir el tamaño de las filas y optimizar el uso del ancho de banda y la caché.
- Fragmentación Horizontal: Distribuye las filas de una tabla en múltiples nodos utilizando estrategias como hashing o rangos de fechas.
Aunque el sharding dentro de una misma instancia física aún permite el uso de transacciones locales, la separación física de datos en diferentes servidores (microservicios con bases de datos independientes) rompe este modelo. Una operación de negocio que abarca múltiples servicios requiere coordinación a través de la red, dando paso a las transacciones distribuidas.
En sistemas distribuidos, el Teorema CAP establece que es imposible garantizar simultáneamente Consistencia, Disponibilidad y Tolerancia a Particiones. Dado que la tolerancia a particiones es obligatoria en redes distribuidas, los arquitectos deben elegir entre Consistencia (CP) y Disponibilidad (AP). Para escenarios de alta concurrencia, la teoría BASE (Basically Available, Soft state, Eventually consistent) ofrece un modelo alternativo: sacrificar la consistencia inmediata para garantizar la disponibilidad, permitiendo que los datos converjan a un estado consistente con el tiempo.
Soluciones de Consistencia Fuerte
Protocolo de Commit en Dos Fases (2PC)
El protocolo XA utiliza un enfoque de dos fases para coordinar múltiples nodos:
- Fase de Preparación: El coordinador consulta a todos los participantes si pueden ejecutar la transacción. Los participantes bloquean los recursos y responden con un voto afirmativo o negativo sin confirmar los cambios.
- Fase de Commit: Si todos votan a favor, el coordinador envía la orden de confirmación. Si al menos uno falla, se emite una orden de reversa global.
Aunque 2PC garantiza consistencia fuerte, sufre de bloqueos síncronos prolongados y representa un punto único de falla. Si el coordinador o un participante colapsa durante el proceso, los recursos pueden quedar bloqueados indefinidamente.
Protocolo de Commit en Tres Fases (3PC)
Para mitigar los bloqueos de 2PC, 3PC introduce una fase adicional de validación (CanCommit) y mecanismos de tiempo de espera. Las fases son: CanCommit, PreCommit y DoCommit. Si un participante no responde dentro del tiempo límite, el sistema asume un aborto. Aunque reduce los bloqueos, las particiones de red durante los tiempos de espera aún pueden provocar estados inconsistentes si algunos nodos asumen un commmit por defecto mientras otros abortan.
Soluciones de Consistencia Eventual
Patrón TCC (Try-Confirm-Cancel)
El modelo TCC traslada la lógica de coordinación al nivel de aplicación, evitando bloqueos a nivel de base de datos. Divide cada operación de negocio en tres métodos:
- Try: Reserva los recursos necesarios y realiza validaciones de negocio.
- Confirm: Ejecuta la operación real utilizando los recursos reservados. Debe ser idempotente.
- Cancel: Libera los recursos reservados si la fase Try falla o si otro participante aborta.
Un cooordinador global rastrea el estado de la transacción. A continuación, se muestra una implementación modificada para un servicio de procesamiento de pagos utilizando un framework TCC:
public class PaymentProcessingService {
// Fase de reserva de fondos
@Compensable(
confirmMethod = "executeFundTransfer",
cancelMethod = "releaseReservedFunds",
contextEditor = PaymentTransactionContextEditor.class
)
@Transactional
public void reserveFunds(TransactionContext ctx, TransferRequest request) {
// Lógica para congelar el saldo en la cuenta del usuario
accountRepository.freezeBalance(request.getSenderId(), request.getAmount());
transactionLogRepository.save(new TransactionLog(ctx.getGlobalTxId(), Status.TRYING));
}
// Fase de confirmación
@Transactional
public void executeFundTransfer(TransactionContext ctx, TransferRequest request) {
// Deducción real y acreditación al destinatario
accountRepository.deductFrozenBalance(request.getSenderId(), request.getAmount());
accountRepository.addBalance(request.getReceiverId(), request.getAmount());
transactionLogRepository.updateStatus(ctx.getGlobalTxId(), Status.CONFIRMED);
}
// Fase de cancelación
@Transactional
public void releaseReservedFunds(TransactionContext ctx, TransferRequest request) {
// Descongelar saldo
accountRepository.unfreezeBalance(request.getSenderId(), request.getAmount());
transactionLogRepository.updateStatus(ctx.getGlobalTxId(), Status.CANCELLED);
}
}
Un aspecto crítico en TCC y cualquier mecanismo de reintento es la idempotencia. Las operaciones de Confirm y Cancel deben poder ejecutarse múltiples veces sin alterar el resultado final. Esto se logra mediante índices únicos, máquinas de estado o verificando el identificador de transacción global antes de procesar.
Patrón de Compensación
Cuando las transacciones abarcan servicios asíncronos, la compensación mediante reintentos y conciliación es clave. Los reintentos pueden ser síncronos o gestionados mediante colas de mensajes (ej. RabbitMQ, RocketMQ) con políticas de backoff exponencial.
La conciliación programada es un mecanismo de respaldo donde tareas cron (utilizando Quartz o XXL-JOB) comparan periódicamente los estados entre un servicio principal y sus dependencias. Si se detectan discrepancias, como un pago procesado sin la correspondiente actualización de inventario, el sistema ejecuta scripts de corrección o alerta a los operadores.
Patrón de Evento Fiable (Outbox)
La comunicación basada en eventos a través de brokers como Kafka o RabbitMQ es propensa a inconsistencias si un servicio falla después de actualizar su base de datos pero antes de publicar el mensaje. El patrón de Evento Fiable resuelve esto utilizando una tabla de salida (Outbox) local:
@Service
public class OrderCheckoutService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private OutboxEventRepository outboxRepository;
@Autowired
private MessageBrokerPublisher publisher;
@Transactional
public void processCheckout(CheckoutCommand command) {
// 1. Persistir el estado del negocio
Order order = new Order(command);
orderRepository.save(order);
// 2. Persistir el evento en la misma transacción local
OutboxEvent event = new OutboxEvent(
"ORDER_CREATED",
order.getId(),
Status.PENDING_DELIVERY
);
outboxRepository.save(event);
}
// Proceso asíncrono o posterior para el despacho
public void dispatchPendingEvents() {
List<outboxevent> pendingEvents = outboxRepository.findByStatus(Status.PENDING_DELIVERY);
for (OutboxEvent event : pendingEvents) {
try {
publisher.send(event.getTopic(), event.getPayload());
event.setStatus(Status.DELIVERED);
} catch (BrokerException e) {
// El sistema de reintento programado volverá a intentar los eventos PENDING_DELIVERY
event.incrementRetryCount();
}
outboxRepository.update(event);
}
}
}
</outboxevent>
Para garantizar el procesamiento exacto una vez (exactly-once) en el consumidor, se debe utilizar confirmación manual (Manual ACK) y aplicar técnicas de idempotencia basadas en el ID del mensaje.
Implementaciones en Proyectos de Código Abierto
Apache RocketMQ
RocketMQ ofrece un mecanismo nativo de mensajes transaccionales. El flujo comienza con el productor enviando un "Half Message" (mensaje preparatorio) al broker. Si el broker lo almacena correctamente, el productor ejecuta su transacción local. Dependiendo del resultado, envía un commit o rollback al broker. Si el productor falla antes de enviar el estado final, el broker consulta periódicamente al grupo de productores para resolver el estado del mensaje, asegurando la atomicidad entre la base de datos local y el sistema de mensajería.
ServiceComb Saga
Saga es un patrón que descompone una transacción distribuida en una secuencia de transacciones locales. Cada transacción local tiene una operación de compensación correspondiente. A diferencia de TCC, Saga no requiere una fase de reserva, lo que reduce la sobrecarga pero complica las compensaciones si ocurren fallas a mitad de camino.
ServiceComb implementa este patrón mediante dos componentes: Alpha (el coordinador global) y Omega (un agente incrustado en los microservicios). Soporta recuperación hacia adelante (reintentos continuos) y hacia atrás (ejecución de compensaciones). La implementación requiere definir las operaciones y sus respectivos métodos de reversa:
@RestController
public class TravelBookingController {
@Autowired
private FlightService flightService;
@Autowired
private HotelService hotelService;
@SagaStart
@PostMapping("/book-trip")
public String bookCompleteTrip(@RequestBody TravelRequest request) {
// Omega intercepta esta llamada para iniciar el contexto de transacción global
flightService.reserveFlight(request);
hotelService.reserveRoom(request);
return "Booking successful";
}
}
@Service
public class FlightService {
@Compensable(compensationMethod = "cancelFlightReservation")
@Transactional
public void reserveFlight(TravelRequest request) {
// Lógica de persistencia para reservar el vuelo
flightRepository.save(new FlightBooking(request));
}
@Transactional
public void cancelFlightReservation(TravelRequest request) {
// Lógica de reversa ejecutada por Alpha si HotelService falla
flightRepository.deleteByRequestId(request.getId());
}
}