Redis Cluster: partitioning, resharding, client-side routing
Два пользователя открывают карточку товара. Остаток — 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, экспоненциальная задержка, лимит попыток.
Сравнение: когда что использовать
| Критерий | Pessimistic | Optimistic |
|---|---|---|
| Конфликты | Частые | Редкие |
| Транзакция | Короткая (мс) | Любая (секунды) |
| 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 — невидимая проблема до момента, когда данные испорчены.