Evitando transacciones distribuidas mediante sistemas de mensajería

En sistemas distribuidos, garantizar la consistencia de datos entre múltiples servicios es un desafío común. Por ejemplo, al transferir fondos entre dos aplicaciones separadas, se debe asegurar que ambos cambios se completen correctamente. Si una operación falla después de la primera actualización, los datos pueden quedar inconsistentes. Esto se aplica a escenarios como pedidos en comercio electrónico, donde la reducción de inventario debe sincronizarse con la creación del pedido, o en publicidad digital, donde los clics deben actualizarse en las cuentas de los anunciantes.

El problema se resume en: cuando se actualiza una tabla, ¿cómo garantizar que otra tabla también se actualice de manera confiable?

Transacciones locales

Supongamos un caso de transferencia de fondos entre dos cuentas en una misma base de datos:

  • Tabla de cuenta origen: origen (id, usuario_id, saldo)
  • Tabla de cuenta destino: destino (id, usuario_id, saldo)
  • Usuario con usuario_id = 100

La transferencia se divide en dos pasos:

  1. Debitar 10000 de la cuenta origen: UPDATE origen SET saldo = saldo - 10000 WHERE usuario_id = 100;
  2. Acreditar 10000 en la cuenta destino: UPDATE destino SET saldo = saldo + 10000 WHERE usuario_id = 100;

Para asegurar el equilibrio, se puede usar una transacción local:

BEGIN TRANSACTION;
  UPDATE origen SET saldo = saldo - 10000 WHERE usuario_id = 100;
  UPDATE destino SET saldo = saldo + 10000 WHERE usuario_id = 100;
COMMIT;

En frameworks como Spring, esto se simplifica con anotaciones:

@Transactional(rollbackFor = Exception.class)
public void transferirFondos() {
    actualizarOrigen(); // Actualiza tabla origen
    actualizarDestino(); // Actualiza tabla destino
}

Este enfoque funciona bien cuando ambas tablas están en la misma instancia de base de datos. Sin embargo, en sistemas distribuidos a gran escala, las tablas suelen residir en nodos físicos diferentes, invalidando las transacciones locales.

Transacciones distribuidas: protocolo de dos fases

El protocolo de Two-phase Commit (2PC) se utiliza comúnmente para transacciones distribuidas. Involucra un coordinador (C) y varios participantes (Si), que son las bases de datos. El proceso es:

  1. La aplicación cliente envía una solicitud al coordinador.
  2. El coordinador escribe un mensaje de preparación en su registro local y envía solicitudes de preparación a todos los participantes. Por ejemplo, para una transferencia, notifica a la base de origen para debitar fondos y a la de destino para acreditarlos.
  3. Los participantes ejecutan sus transacciones locales sin confirmar, respondiendo YES o NO. Antes de responder, registran la decisión en sus registros.
  4. El coordinador recopila las respuestas: si todas son YES, envía mensajes de commit; si alguna es NO, envía abort. Los participantes actúan en consecuencia.

Este protocolo ayuda en la recuperación de fallos: tras un reinicio, los participantes verifican sus rgeistros para decidir si confirmar o abortar. Implementaciones como Atomikos en Java facilitan su uso.

Sin embargo, 2PC tiene desventajas significativas en entornos de alta concurrencia:

  • Comunicaciones de red frecuentes aumentan la latencia.
  • Los tiempos de transacción más largos prolongan el bloqueo de recursos.

Debido a estos problemas de rendimiento, muchos sistemas de alta concurrencia evitan 2PC y buscan alternativas.

Uso de colas de mensajería para evitar transacciones distribuidas

Las colas de mensajería ofrecen una solución basada en consistencia eventual. Al separar la operación de negocio del envío de mensajes, se logra mayor tolerancia a fallos. Por ejemplo, en un sistema de pedidos, al crear un pedido se genera un mensaje para actualizar el inventario, garantizando que el cambio se aplique eventualmente.

Almacenamiento confiable de mensajes

Existen dos enfoques principales:

Acoplamiento entre negocio y mensaje

La operación de negocio y el almacenamiento del mensaje ocurren en la misma transacción:

BEGIN TRANSACTION;
  UPDATE origen SET saldo = saldo - 10000 WHERE usuario_id = 100;
  INSERT INTO mensajes (usuario_id, monto, estado) VALUES (100, 10000, 'PENDIENTE');
COMMIT;

Esto asegura que, si se debita el saldo, el mensaje se guarda. Posteriormente, un servicio de mensajería envía el mensaje al destinatario, que tras procesarlo confirma el éxito. Luego, el mensaje original se elimina.

Desacoplamiento entre negocio y mensaje

Para mayor independencia, se puede usar un servicio de mensajería externo:

  1. Antes de confirmar la transacción de negocio, se solicita al servicio de mensajería que registre el mensaje, pero no lo envíe.
  2. Si la transacción de negocio se confirma, se notifica al servicio para enviar el mensaje.
  3. Si la transacción falla, se cancela el envío.
  4. Un sistema de verificación consulta periódicamente el estado de mensajes no confirmados para sincronizarlos.

Ventajas: menor acoplamiento entre componentes. Desventajas: requiere dos solicitudes por mensaje y una interfaz de verificación de estado.

Manejo de mensajes duplicados

Los mensajes pueden duplicarse por fallos en la red o reinicios. Para evitar procesamientos múltiples, se emplea una tabla de estado de mensajes en el destinatario:

for each mensaje in cola:
  BEGIN TRANSACTION;
    SELECT COUNT(*) AS cnt FROM estado_mensajes WHERE mensaje_id = mensaje.id;
    IF cnt = 0 THEN
      UPDATE destino SET saldo = saldo + 10000 WHERE usuario_id = 100;
      INSERT INTO estado_mensajes (mensaje_id) VALUES (mensaje.id);
    END IF;
  COMMIT;

Esta tabla actúa como un registro, asegurando que cada mensaje se procese solo una vez.

Etiquetas: transacciones-distribuidas colas-de-mensajes protocolo-dos-fases Spring java

Publicado el 6-19 02:28