← Назад к вопросам

Как реализовать транзакционный метод при наличии в бизнес-логике взаимодействия с двумя базами данных?

1.2 Junior🔥 201 комментариев
#Многопоточность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

# Транзакции при работе с двумя базами данных

Проблема

Когда бизнес-логика требует выполнения операций в двух отдельных БД, возникает сложность обеспечения ACID свойств (Atomicity, Consistency, Isolation, Durability) для всей операции целиком.

// Проблема: что если вторая БД упадет?
public void transferMoney(long accountId, long amount) {
    // БД 1: уменьшаем баланс
    database1.executeUpdate(
        "UPDATE accounts SET balance = balance - ? WHERE id = ?",
        amount, accountId
    );
    
    // БД 2: добавляем в лог (БД может упасть здесь!)
    database2.executeUpdate(
        "INSERT INTO audit_log (account_id, amount) VALUES (?, ?)",
        accountId, amount
    );  // Деньги уменьшены, но лог не добавлен!
}

Решение 1: Two-Phase Commit (2PC)

Двухфазный коммит гарантирует, что либо обе операции выполнены, либо обе откачены.

Фаза 1: Prepare (подготовка)

Каждая БД проверяет возможность выполнить транзакцию:

public class TwoPhaseCommitExample {
    private DataSource db1;
    private DataSource db2;
    
    public void transferWithXA(long accountId, long amount) throws Exception {
        XADataSource xaDs1 = (XADataSource) db1;
        XADataSource xaDs2 = (XADataSource) db2;
        
        XAConnection xaConn1 = xaDs1.getXAConnection();
        XAConnection xaConn2 = xaDs2.getXAConnection();
        
        XAResource resource1 = xaConn1.getXAResource();
        XAResource resource2 = xaConn2.getXAResource();
        
        // Создаем глобальную транзакцию
        Xid xid = new MyXid(1, "transfer".getBytes(), new byte[0]);
        
        try {
            // PHASE 1: Prepare
            resource1.start(xid, XAResource.TMNOFLAGS);
            Connection conn1 = xaConn1.getConnection();
            conn1.createStatement().executeUpdate(
                "UPDATE accounts SET balance = balance - 1000 WHERE id = 1"
            );
            resource1.end(xid, XAResource.TMSUCCESS);
            
            resource2.start(xid, XAResource.TMNOFLAGS);
            Connection conn2 = xaConn2.getConnection();
            conn2.createStatement().executeUpdate(
                "INSERT INTO audit_log VALUES ('transfer', 1000)"
            );
            resource2.end(xid, XAResource.TMSUCCESS);
            
            // Prepare phase
            int prep1 = resource1.prepare(xid);
            int prep2 = resource2.prepare(xid);
            
            if (prep1 == XAResource.XA_OK && prep2 == XAResource.XA_OK) {
                // PHASE 2: Commit
                resource1.commit(xid, false);
                resource2.commit(xid, false);
            } else {
                // Rollback
                resource1.rollback(xid);
                resource2.rollback(xid);
            }
        } catch (Exception e) {
            resource1.rollback(xid);
            resource2.rollback(xid);
            throw e;
        }
    }
}

Проблемы 2PC:

  • Требует специального драйвера БД (XADataSource)
  • Может заблокировать ресурсы на длительное время
  • Сложная откатка, если один из сервера упадет

Решение 2: Saga Pattern

Saga — это паттерн для управления транзакциями в микросервис-архитектуре, разбивающий операцию на последовательность локальных транзакций с компенсациями (rollback).

Choreography-based Saga

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TransferSagaChoreography {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Autowired
    private AuditRepository auditRepository;
    
    @Autowired
    private EventPublisher eventPublisher;
    
    // Шаг 1: Уменьшаем баланс в БД 1
    @Transactional("db1TransactionManager")
    public void startTransfer(TransferEvent event) {
        Account account = accountRepository.findById(event.getAccountId())
            .orElseThrow();
        account.setBalance(account.getBalance() - event.getAmount());
        accountRepository.save(account);
        
        // Публикуем событие для следующего шага
        eventPublisher.publish(
            new MoneyReducedEvent(event.getAccountId(), event.getAmount())
        );
    }
    
    // Шаг 2: Добавляем в лог в БД 2
    @Transactional("db2TransactionManager")
    @EventListener
    public void handleMoneyReduced(MoneyReducedEvent event) {
        try {
            AuditLog log = new AuditLog();
            log.setAccountId(event.getAccountId());
            log.setAmount(event.getAmount());
            log.setTimestamp(LocalDateTime.now());
            auditRepository.save(log);
            
            // Успех
            eventPublisher.publish(
                new AuditLogCreatedEvent(event.getAccountId())
            );
        } catch (Exception e) {
            // Ошибка — запускаем компенсацию
            eventPublisher.publish(
                new TransferFailedEvent(event.getAccountId(), event.getAmount())
            );
        }
    }
    
    // Компенсация: отменяем первый шаг
    @Transactional("db1TransactionManager")
    @EventListener
    public void compensateTransfer(TransferFailedEvent event) {
        Account account = accountRepository.findById(event.getAccountId())
            .orElseThrow();
        account.setBalance(account.getBalance() + event.getAmount());
        accountRepository.save(account);
    }
}

Orchestration-based Saga

import org.springframework.statemachine.StateMachine;
import org.springframework.statemachine.config.StateMachineFactory;

public enum TransferState {
    START, MONEY_REDUCED, AUDIT_LOGGED, COMPENSATING, FAILED, COMPLETED
}

public enum TransferEvent {
    REDUCE_MONEY, LOG_AUDIT, COMPENSATE, FAIL
}

@Service
public class TransferSagaOrchestrator {
    
    @Autowired
    private StateMachineFactory<TransferState, TransferEvent> factory;
    
    @Autowired
    private AccountService accountService;
    
    @Autowired
    private AuditService auditService;
    
    public void executeTransfer(long accountId, long amount) {
        StateMachine<TransferState, TransferEvent> machine = 
            factory.getStateMachine();
        machine.start();
        
        try {
            // Шаг 1: Уменьшаем баланс
            accountService.reduceBalance(accountId, amount);
            machine.sendEvent(TransferEvent.REDUCE_MONEY);
            
            // Шаг 2: Логируем
            auditService.logTransfer(accountId, amount);
            machine.sendEvent(TransferEvent.LOG_AUDIT);
            
            machine.sendEvent(TransferEvent.COMPENSATE);
        } catch (Exception e) {
            machine.sendEvent(TransferEvent.FAIL);
            // Компенсация автоматически запустится через state machine
        }
    }
}

Решение 3: Outbox Pattern

Outbox Pattern гарантирует, что если основная операция успешна, связанные события гарантированно будут обработаны.

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    private Long id;
    private Long balance;
}

@Entity
@Table(name = "outbox")
public class OutboxEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String eventType;      // "TRANSFER_COMPLETED"
    private String aggregateId;    // accountId
    private String payload;        // JSON событие
    private LocalDateTime createdAt;
    private boolean published;      // обработано ли
}

@Service
public class TransferServiceWithOutbox {
    
    @Autowired
    private AccountRepository accountRepository;
    
    @Autowired
    private OutboxRepository outboxRepository;
    
    @Transactional("db1TransactionManager")  // Одна БД!
    public void transferMoney(long accountId, long amount) {
        // Операция 1: уменьшаем баланс
        Account account = accountRepository.findById(accountId).orElseThrow();
        account.setBalance(account.getBalance() - amount);
        accountRepository.save(account);
        
        // Операция 2: добавляем событие в outbox (в ту же БД!)
        OutboxEvent event = new OutboxEvent();
        event.setEventType("TRANSFER_COMPLETED");
        event.setAggregateId(String.valueOf(accountId));
        event.setPayload(
            "{\"accountId\": " + accountId + ", \"amount\": " + amount + "}"
        );
        event.setCreatedAt(LocalDateTime.now());
        event.setPublished(false);
        outboxRepository.save(event);
        
        // Обе операции в одной транзакции — либо обе выполнены, либо откачены
    }
}

// Отдельный сервис читает outbox и отправляет в БД 2
@Service
public class OutboxEventPublisher {
    
    @Autowired
    private OutboxRepository outboxRepository;
    
    @Autowired
    private AuditRepository auditRepository;
    
    @Transactional("db2TransactionManager")
    @Scheduled(fixedDelay = 1000)  // Проверяем каждую секунду
    public void publishEvents() {
        List<OutboxEvent> unpublished = outboxRepository.findByPublished(false);
        
        for (OutboxEvent event : unpublished) {
            try {
                // Отправляем в БД 2
                AuditLog log = parseAndCreateLog(event);
                auditRepository.save(log);
                
                // Помечаем как опубликованное
                event.setPublished(true);
                outboxRepository.save(event);
            } catch (Exception e) {
                // Повтор при следующем прогоне
                logger.error("Failed to publish event", e);
            }
        }
    }
}

Решение 4: Event Sourcing

Event Sourcing хранит все изменения как последовательность событий, обеспечивая полную историю и возможность восстановления.

@Entity
public class AccountEvent {
    @Id
    @GeneratedValue
    private Long id;
    
    private Long accountId;
    private String eventType;  // "MONEY_TRANSFERRED", "MONEY_DEPOSITED"
    private Long amount;
    private LocalDateTime timestamp;
    private String metadata;   // JSON
}

@Service
public class EventSourcingService {
    
    @Autowired
    private AccountEventRepository eventRepository;
    
    @Transactional("db1TransactionManager")
    public void transferMoney(long accountId, long amount) {
        // Шаг 1: Записываем событие в БД 1
        AccountEvent event = new AccountEvent();
        event.setAccountId(accountId);
        event.setEventType("MONEY_TRANSFERRED");
        event.setAmount(amount);
        event.setTimestamp(LocalDateTime.now());
        eventRepository.save(event);
        
        // Шаг 2: События обрабатываются асинхронно
        // и могут быть отправлены в БД 2 (even сторонние системы)
    }
    
    // Восстановление текущего состояния из событий
    public long getAccountBalance(long accountId) {
        List<AccountEvent> events = eventRepository.findByAccountId(accountId);
        long balance = 0;
        
        for (AccountEvent event : events) {
            if ("MONEY_TRANSFERRED".equals(event.getEventType())) {
                balance -= event.getAmount();
            } else if ("MONEY_DEPOSITED".equals(event.getEventType())) {
                balance += event.getAmount();
            }
        }
        
        return balance;
    }
}

Сравнение подходов

ПодходСложностьЗадержкаГарантииМасштабируемость
2PCВысокаяСинхроннаяСильные ACIDНизкая (блокировки)
SagaСредняяАсинхроннаяEventual ConsistencyВысокая
OutboxСредняяАсинхроннаяГарантированная доставкаВысокая
Event SourcingВысокаяАсинхроннаяПолная историяОчень высокая

Рекомендации

  1. Используй Outbox Pattern если:

    • У тебя есть возможность записать outbox в основную БД
    • Нужна гарантированная доставка с простотой
  2. Используй Saga если:

    • БД действительно разные системы
    • Нужны компенсирующие транзакции
    • Масштабируешь архитектуру
  3. Используй 2PC только если:

    • Абсолютно критична немедленная консистентность
    • Обе БД поддерживают XA
    • Готов к производительности
  4. Избегай если возможно:

    • Проверь, можешь ли ты объединить БД
    • Или пересмотри архитектуру бизнес-логики

Вывод

Транзакции с двумя БД требуют специальных подходов. Outbox Pattern является современным и практичным решением для большинства случаев, обеспечивая гарантированную доставку и eventual consistency без сложности 2PC.

Как реализовать транзакционный метод при наличии в бизнес-логике взаимодействия с двумя базами данных? | PrepBro