Как реализовать транзакционный метод при наличии в бизнес-логике взаимодействия с двумя базами данных?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
# Транзакции при работе с двумя базами данных
Проблема
Когда бизнес-логика требует выполнения операций в двух отдельных БД, возникает сложность обеспечения 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 | Высокая | Асинхронная | Полная история | Очень высокая |
Рекомендации
-
Используй Outbox Pattern если:
- У тебя есть возможность записать outbox в основную БД
- Нужна гарантированная доставка с простотой
-
Используй Saga если:
- БД действительно разные системы
- Нужны компенсирующие транзакции
- Масштабируешь архитектуру
-
Используй 2PC только если:
- Абсолютно критична немедленная консистентность
- Обе БД поддерживают XA
- Готов к производительности
-
Избегай если возможно:
- Проверь, можешь ли ты объединить БД
- Или пересмотри архитектуру бизнес-логики
Вывод
Транзакции с двумя БД требуют специальных подходов. Outbox Pattern является современным и практичным решением для большинства случаев, обеспечивая гарантированную доставку и eventual consistency без сложности 2PC.