Как обеспечивал транзакционность между двумя разными базами данных в Node.js?

«Как обеспечивал транзакционность между двумя разными базами данных в Node.js?» — вопрос из категории Базы данных, который задают на 26% собеседований Node.js Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Обеспечить атомарную транзакцию между двумя разными СУБД (например, PostgreSQL и MongoDB) стандартными средствами невозможно, так как это требует координации, которую не предоставляют сами БД. В Node.js для решения этой проблемы применяются компенсирующие транзакции (Saga) и другие паттерны.

Паттерн Saga (наиболее распространённый подход): Saga — это последовательность локальных транзакций в каждой БД. Каждая следующая транзакция запускается после успешного завершения предыдущей. Если какая-то транзакция падает, выполняются компенсирующие действия (компенсирующие транзакции) для отката уже выполненных шагов.

Пример реализации Saga для перевода денег между счетами в разных БД:

// Псевдокод, иллюстрирующий логику
async function transferMoney(accountFromId, accountToId, amount) {
  const transactionId = generateId();
  const sagaLog = []; // Для идемпотентности и отслеживания

  try {
    // 1. Шаг 1: Списать деньги в первой БД (SQL)
    await sqlDb.transaction(async (t) => {
      await t.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, accountFromId]);
      sagaLog.push({ step: 'debit', txId: transactionId, status: 'completed' });
    });

    // 2. Шаг 2: Зачислить деньги во второй БД (NoSQL)
    await mongoDb.withSession(async (session) => {
      session.startTransaction();
      await mongoDb.collection('accounts').updateOne(
        { _id: accountToId },
        { $inc: { balance: amount } },
        { session }
      );
      await session.commitTransaction();
      sagaLog.push({ step: 'credit', txId: transactionId, status: 'completed' });
    });

    // 3. Финализация
    await sagaLog.markAsCompleted(transactionId);

  } catch (error) {
    console.error('Saga failed:', error);
    // Запуск компенсирующих транзакций
    await runCompensation(sagaLog, transactionId);
    // Например, вернуть деньги на первый счёт
    await sqlDb.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, accountFromId]);
    throw new Error('Transfer failed, rollback completed');
  }
}

Ключевые моменты и инструменты:

  • Идемпотентность: Каждый шаг и компенсация должны быть идемпотентными (повторный вызов не должен ломать систему). Достигается через запись состояния шага (например, в отдельную таблицу saga_log).
  • Отслеживание состояния: Нужно хранить состояние всей саги, чтобы иметь возможность восстановиться после сбоя.
  • Асинхронная коммуникация: Часто шаги саги связываются через сообщения в брокере (Kafka, RabbitMQ), что делает процесс более отказоустойчивым.
  • Библиотеки: Для сложных сценариев можно рассмотреть специализированные решения или фреймворки, реализующие паттерн Saga.

Альтернативный подход — Outbox Pattern: Изменения в первой БД и сообщение для второй БД записываются в одной транзакции (в таблицу outbox). Отдельный процесс (например, CDC) читает outbox и гарантированно доставляет сообщение и применяет изменение ко второй БД. Это обеспечивает eventual consistency.