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

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

Ответ

В Node.js приложениях я реализую блокировки на уровне БД в зависимости от конкретной СУБД и сценария использования. Вот мои практические подходы:

1. PostgreSQL - пессимистичные блокировки:

const { Pool } = require('pg');
const pool = new Pool();

async function transferFundsWithLock(fromAccountId, toAccountId, amount) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // Блокировка обеих записей для обновления
    const fromAccount = await client.query(
      'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
      [fromAccountId]
    );

    const toAccount = await client.query(
      'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
      [toAccountId]
    );

    if (fromAccount.rows[0].balance < amount) {
      throw new Error('Insufficient funds');
    }

    // Выполнение операций
    await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
      [amount, fromAccountId]
    );

    await client.query(
      'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
      [amount, toAccountId]
    );

    // Логирование транзакции
    await client.query(
      `INSERT INTO transactions (from_account, to_account, amount, status) 
       VALUES ($1, $2, $3, 'completed')`,
      [fromAccountId, toAccountId, amount]
    );

    await client.query('COMMIT');
    return { success: true };

  } catch (error) {
    await client.query('ROLLBACK');
    console.error('Transfer failed:', error);
    throw error;
  } finally {
    client.release();
  }
}

2. MongoDB - оптимистичные блокировки:

const { MongoClient } = require('mongodb');

async function updateProductStock(productId, quantityChange) {
  const client = await MongoClient.connect(process.env.MONGODB_URI);
  const db = client.db('ecommerce');

  let retries = 3;

  while (retries > 0) {
    const product = await db.collection('products').findOne({ _id: productId });

    if (!product) {
      throw new Error('Product not found');
    }

    const newStock = product.stock + quantityChange;

    if (newStock < 0) {
      throw new Error('Insufficient stock');
    }

    // Оптимистичная блокировка через version
    const result = await db.collection('products').updateOne(
      { 
        _id: productId,
        version: product.version // Проверяем, что версия не изменилась
      },
      { 
        $set: { 
          stock: newStock,
          updatedAt: new Date()
        },
        $inc: { version: 1 }
      }
    );

    if (result.modifiedCount === 1) {
      await client.close();
      return { success: true, newStock };
    }

    retries--;
    await new Promise(resolve => setTimeout(resolve, 100)); // Задержка перед повторной попыткой
  }

  await client.close();
  throw new Error('Failed to update product after retries');
}

3. Redis - распределённые блокировки:

const Redis = require('ioredis');
const redis = new Redis();

async function acquireDistributedLock(lockKey, ttl = 10000) {
  const lockValue = `${Date.now()}-${Math.random()}`;

  // Пытаемся получить блокировку
  const result = await redis.set(
    lockKey,
    lockValue,
    'NX', // Только если ключ не существует
    'PX', // Время жизни в миллисекундах
    ttl
  );

  if (result === 'OK') {
    return {
      unlock: async () => {
        // Безопасное освобождение с проверкой значения
        const script = `
          if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
          else
            return 0
          end
        `;
        await redis.eval(script, 1, lockKey, lockValue);
      },
      value: lockValue
    };
  }

  return null; // Блокировка не получена
}

// Использование распределённой блокировки
async function processOrderWithLock(orderId) {
  const lockKey = `lock:order:${orderId}`;
  const lock = await acquireDistributedLock(lockKey);

  if (!lock) {
    throw new Error('Could not acquire lock for order processing');
  }

  try {
    // Критическая секция
    const order = await getOrderFromDB(orderId);
    // Обработка заказа...
    await updateOrderStatus(orderId, 'processed');
  } finally {
    await lock.unlock();
  }
}

4. Практические рекомендации:

  • Выбор стратегии: Пессимистичные блокировки для финансовых операций, оптимистичные для высоконагруженных систем
  • Таймауты: Всегда устанавливаю таймауты для блокировок
  • Уровни изоляции: Настраиваю подходящий уровень изоляции транзакций
  • Мониторинг: Отслеживаю deadlock-и и длительные блокировки
  • Альтернативы: Для некоторых сценариев использую очереди сообщений (RabbitMQ, Kafka) вместо блокировок