Как реализовать блокировки на уровне базы данных в 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) вместо блокировок

Ответ 18+ 🔞

Ну ты представляешь, какие танцы с бубном приходится откалывать, чтобы в 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(); // Не забудь отпустить клиента, а то он так и будет висеть!
  }
}

Суть в том, что FOR UPDATE — это как сказать базе: "Слушай, эти строки мои, пока я с ними не разберусь, остальные пусть подождут". Мёртвые захваты (deadlock) могут быть, конечно, но с правильным порядком блокировок — жить можно.

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 // Если 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 — когда блокировка нужна между кучей сервисов А вот это уже магия распределённых систем. Тут одна СУБД не справится, нужен 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', // Поставить только если ключа нет (Not eXists)
    'PX', // Время жизни в миллисекундах
    ttl
  );

  if (result === 'OK') {
    return {
      unlock: async () => {
        // Важно удалять только свой замок, а не чужой! Lua-скрипт в Redis атомарный.
        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. Ну и общие советы, чтобы не было мучительно больно:

  • Что выбирать: Пессимистичные блокировки — для операций, где ошибка стоит дорого (деньги). Оптимистичные — где скорость и масштаб важнее, а конфликты редки.
  • Таймауты — твои лучшие друзья. Вечная блокировка — это гарантированный дедлок в продакшене и пиздец в полночь.
  • Уровни изоляции в PostgreSQL — изучай, как таблицу умножения. READ COMMITTED часто хватает, но иногда нужен REPEATABLE READ или даже SERIALIZABLE, если совсем паранойя.
  • Следи за дедлоками! Они будут, это нормально. Главное — знать, где и почему.
  • Иногда блокировки — это костыль. Для фоновых задач или обработки событий лучше очередь (RabbitMQ, Kafka) — там конфликты сами собой решаются.

Короче, чувак, блокировки — это как управление хаосом. Главное — понимать, какой инструмент для какого бардака подходит, и не пытаться молотком забить микроскоп.