lenec ru

← все посты

Redis Cluster: partitioning, resharding, client-side routing

19K

Два пользователя открывают карточку товара. Остаток — 10 штук. Оба оформляют заказ на 8 штук. Оба нажимают «Купить». Ваш код читает stock = 10 дважды, вычитает 8 дважды, записывает stock = 2 дважды. Итог: продано 16 штук при остатке 10. Это классический lost update — результат race condition при concurrent updates. PostgreSQL предлагает два подхода: pessimistic locking (блокировать строку на чтение) и optimistic locking (проверять версию при записи).

Проблема: race conditions и lost updates

Сценарий без защиты:

Time  Transaction A              Transaction B
t1    SELECT stock (10)
t2                                 SELECT stock (10)
t3    UPDATE stock=2
t4                                 UPDATE stock=2
Result: stock=2, продано 16

PostgreSQL MVCC позволяет читателям не блокировать писателей, но не защищает от read-modify-write race. Обе транзакции читают одно значение, обе пишут результат своих вычислений. Последняя запись побеждает, первая теряется. Другие проявления: двойное списание баланса, перебронирование билетов, дублирование заказов.

Pessimistic locking: SELECT FOR UPDATE

Pessimistic locking блокирует строку при чтении. Другие транзакции ждут завершения первой:

BEGIN;

SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- Строка заблокирована

UPDATE products SET stock = stock - 8 WHERE id = 1;

COMMIT;

Сценарий с FOR UPDATE:

Time  Transaction A                Transaction B
t1    SELECT ... FOR UPDATE (LOCKED)
t2                                   SELECT ... FOR UPDATE (WAITING)
t3    UPDATE stock=2
t4    COMMIT (unlock)
t5                                   (unblocked, stock=2)
t6                                   UPDATE → ERROR (constraint)

Transaction B ждёт, пока A не завершится, затем видит актуальное значение и корректно обрабатывает нехватку товара.

Варианты FOR UPDATE

  • FOR UPDATE — полная блокировка
  • FOR UPDATE SKIP LOCKED — пропустить заблокированные (job queues)
  • FOR UPDATE NOWAIT — ошибка вместо ожидания
  • FOR SHARE — разрешить читать, блокировать UPDATE

Реализация в Node.js:

const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function purchaseProductPessimistic(productId, quantity) {
  const client = await pool.connect();
  
  try {
    await client.query('BEGIN');
    
    const result = await client.query(
      'SELECT stock FROM products WHERE id = $1 FOR UPDATE',
      [productId]
    );
    
    const currentStock = result.rows[0].stock;
    
    if (currentStock < quantity) {
      throw new Error('Insufficient stock');
    }
    
    await client.query(
      'UPDATE products SET stock = stock - $1 WHERE id = $2',
      [quantity, productId]
    );
    
    await client.query('COMMIT');
    return { success: true };
    
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Подводные камни

Deadlocks: транзакция A блокирует строку 1, затем строку 2; транзакция B — наоборот. PostgreSQL детектирует и убивает одну. Решение: блокировать строки в одном порядке (по возрастанию id).

Длительность блокировки: блокировка держится всю транзакцию. Если между SELECT FOR UPDATE и COMMIT выполняется HTTP-запрос — строка заблокирована на всё это время. Запрос 200 мс = блокировка 200 мс. При 50 конкурентных запросах/сек 49 ждут в очереди.

Optimistic locking: версионирование

Optimistic locking не блокирует. Добавляется колонка version, которая инкрементируется при UPDATE. При записи проверяется, что версия не изменилась:

ALTER TABLE products ADD COLUMN version INTEGER DEFAULT 1;

-- Чтение без блокировки
SELECT id, stock, version FROM products WHERE id = 1;
-- stock=10, version=5

-- Запись с проверкой версии
UPDATE products
SET stock = stock - 8, version = version + 1
WHERE id = 1 AND version = 5;

-- affected rows = 0 → конфликт, retry

Сценарий:

Time  Transaction A              Transaction B
t1    SELECT (stock=10, v=5)
t2                               SELECT (stock=10, v=5)
t3    UPDATE WHERE v=5 → OK (v=6)
t4                               UPDATE WHERE v=5 → 0 rows!
t5                               Retry: SELECT (stock=2, v=6)
t6                               UPDATE → ERROR (constraint)

Transaction B детектирует конфликт (affected rows = 0), перечитывает актуальное состояние и корректно обрабатывает нехватку.

Реализация с retry logic

async function purchaseProductOptimistic(productId, quantity, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const result = await pool.query(
      'SELECT stock, version FROM products WHERE id = $1',
      [productId]
    );
    
    const { stock, version } = result.rows[0];
    
    if (stock < quantity) {
      return { success: false, error: 'Insufficient stock' };
    }
    
    const updated = await pool.query(
      'UPDATE products SET stock = stock - $1, version = version + 1 WHERE id = $2 AND version = $3',
      [quantity, productId, version]
    );
    
    if (updated.rowCount === 0) {
      if (attempt < maxRetries - 1) {
        await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)));
        continue;
      }
      return { success: false, error: 'Conflict, max retries exceeded' };
    }
    
    return { success: true };
  }
}

Ключевые моменты: проверка rowCount, экспоненциальная задержка, лимит попыток.

Сравнение: когда что использовать

КритерийPessimisticOptimistic
КонфликтыЧастыеРедкие
ТранзакцияКороткая (мс)Любая (секунды)
ThroughputНизкийВысокий
DeadlocksВозможныНевозможны
ПримерыПлатежи, flash saleРедактирование документов

Pessimistic: конфликты частые, транзакции короткие, критично предотвратить конфликт (платежи), job queue с SKIP LOCKED.

Optimistic: конфликты редкие, транзакции длинные (пользователь редактирует форму минуты), нужен высокий throughput, распределённая система.

Trade-offs

Pessimistic: каждый запрос платит стоимость блокировки, даже без конфликта. Блокировка длится всю транзакцию, включая медленную логику. Риск deadlock.

Optimistic: только конфликтующие запросы платят стоимость retry. Нужна retry logic. При высокой contention — retry storm (все постоянно конфликтуют).

Автоинкремент версии через триггер:

CREATE OR REPLACE FUNCTION increment_version()
RETURNS TRIGGER AS $$
BEGIN
  NEW.version = OLD.version + 1;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_version_trigger
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION increment_version();

Вывод

Optimistic locking — ваш default. Он не блокирует, не создаёт deadlocks, масштабируется лучше. Pessimistic — override для hot rows с высокой contention.

Добавьте version INTEGER DEFAULT 1, включите в WHERE при UPDATE, проверяйте rowCount. Реализуйте retry logic с экспоненциальной задержкой и лимитом. Для критичных операций (платежи, инвентарь во время распродажи) используйте SELECT FOR UPDATE с короткими транзакциями и консистентным порядком блокировок. Мониторьте lock waits через pg_stat_activity и pg_locks. Lost updates — невидимая проблема до момента, когда данные испорчены.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.