Пессимистичные и оптимистичные блокировки: кто успел, тот и купил

Пессимистичная блокировка: владелец ключа запер последний товар, второй покупатель ждёт в очереди. Оптимистичная: оба тянутся к товару, версия меняется с v1 на v2, второй получает отказ и повторяет попытку

Два человека одновременно покупают последнюю единицу товара. Оба видят «в наличии: 1», оба жмут «Купить», и база спокойно оформляет два заказа. Код при этом выглядит правильным, тесты зелёные, а ошибка проявляется только под нагрузкой. Разберём, почему так происходит и какими двумя философиями это лечат: пессимистичной и оптимистичной.

Проблема: потерянное обновление

Возьмём простейшую логику покупки, которую пишут почти все:

  1. Прочитать остаток: SELECT stock FROM items WHERE id = 42.
  2. В коде приложения проверить, что stock >= 1.
  3. Записать новый остаток stock - 1 и создать заказ.

Между шагом 1 и шагом 3 проходит время: миллисекунды на сеть, на код, на соседние запросы. Если в эти миллисекунды второй покупатель успел прочитать тот же остаток, оба примут решение на основе одной и той же устаревшей цифры. Второй UPDATE просто перезапишет первый. Это называется lost update - потерянное обновление.

«Но ведь всё внутри транзакции!» Транзакция гарантирует, что её изменения применятся целиком или не применятся вовсе (атомарность). Она не гарантирует, что никто другой не прочитает ту же строку параллельно. В PostgreSQL по умолчанию уровень изоляции READ COMMITTED: каждый запрос видит данные, закоммиченные на момент его старта, и обычный SELECT никого не блокирует. Поэтому BEGIN ... COMMIT вокруг кода выше от потерянного обновления не спасает.

Решений два, и они отличаются отношением к конфликтам:

  • Пессимист: «конфликт точно будет»

    Перед тем как читать для изменения, запираем строку. Остальные, кто хочет её изменить, встают в очередь и ждут. Как ключ от примерочной: пока он у вас, следующий стоит у двери.

  • Оптимист: «конфликта скорее всего не будет»

    Никого не запираем. Но при записи проверяем, не изменил ли кто-то строку, пока мы думали. Если изменил - запись не проходит, перечитываем и решаем заново. Как вики: «страница изменилась, пока вы её редактировали».

Есть и третий, самый простой путь: вообще не разделять чтение и запись, а сделать проверку и изменение одной командой. О нём тоже ниже.

Симулятор: что происходит в базе

Две транзакции, T1 (Аня) и T2 (Борис), работают с одной таблицей items. У товара 42 остаток 1. Выберите сценарий, и он проиграется автоматически. Справа видно состояние строк: остаток, версию и кто держит блокировку.

Две транзакции, одна строка
Таблица items
Таблица orders
Что происходит

Что стоит заметить:

  • Без защиты обе транзакции проходят без единой ошибки. База не видит проблемы: каждый UPDATE корректен сам по себе. Ошибка в логике «прочитал - подумал - записал».
  • FOR UPDATE заставляет Бориса ждать. Когда Аня коммитит, Борис получает свежий остаток 0 и честный отказ.
  • NOWAIT вместо ожидания сразу возвращает ошибку. Полезно, когда ждать бессмысленно.
  • Версия строки никого не блокирует на время «раздумий». Конфликт обнаруживается по тому, что UPDATE затронул 0 строк.
  • Атомарный UPDATE показывает, что PostgreSQL и так блокирует строку на время UPDATE до конца транзакции. Просто держится эта блокировка коротко.
  • Deadlock - цена пессимизма: две транзакции ждут друг друга по кругу, и база убивает одну из них.

Пессимистичная блокировка в SQL

Ключевая конструкция - SELECT ... FOR UPDATE. Она читает строку и одновременно ставит на неё блокировку, которая держится до конца транзакции (COMMIT или ROLLBACK). Пока блокировка у вас, любой другой FOR UPDATE, UPDATE или DELETE этой строки будет ждать. Обычный SELECT без FOR UPDATE при этом не ждёт: читатели писателям не мешают.

BEGIN;

SELECT stock FROM items WHERE id = 42 FOR UPDATE;
-- stock = 1. Строка заперта до конца транзакции,
-- второй такой же запрос будет ждать здесь.

-- приложение проверяет stock >= 1
UPDATE items SET stock = stock - 1 WHERE id = 42;
INSERT INTO orders (item_id, user_id) VALUES (42, 7);

COMMIT; -- блокировка снята, следующий в очереди проснулся

Важная деталь: в READ COMMITTED дождавшийся FOR UPDATE возвращает последнюю закоммиченную версию строки, а не ту, что была в момент начала ожидания. Именно поэтому Борис в симуляторе видит 0, а не 1.

Варианты, которые стоит знать:

  • FOR UPDATE NOWAIT - если строка занята, сразу ошибка could not obtain lock on row. Для интерфейсов, где лучше сказать «занято, попробуйте позже», чем висеть.
  • FOR UPDATE SKIP LOCKED - занятые строки молча пропускаются. Классика для очередей задач в таблице: каждый воркер берёт первую свободную задачу, и они не толкаются.
  • FOR NO KEY UPDATE - более мягкая блокировка, если вы не меняете первичный ключ. Она не мешает другим транзакциям вставлять строки, ссылающиеся на эту через внешний ключ. FOR UPDATE - самая строгая.
  • SET LOCAL lock_timeout = '2s' - ограничить ожидание, чтобы запрос не висел бесконечно.
-- очередь задач: 10 воркеров параллельно, никто не берёт одну задачу дважды
BEGIN;
SELECT id, payload FROM jobs
WHERE state = 'new'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- ...выполнить задачу...
UPDATE jobs SET state = 'done' WHERE id = :id;
COMMIT;

Цена пессимизма

  • Ожидание. Пока одна транзакция держит строку, остальные стоят. Если внутри блокировки делать что-то медленное (HTTP-запрос к платёжке, отправку письма, вызов LLM), очередь растёт, а соединения с базой заканчиваются.
  • Deadlock. Если T1 заперла строку A и хочет B, а T2 заперла B и хочет A, они ждут друг друга вечно. PostgreSQL проверяет такие круги через deadlock_timeout (по умолчанию 1 секунда) и убивает одну транзакцию с ошибкой deadlock detected. Лечится одинаковым порядком блокировок: например, всегда ORDER BY id.
  • Блокировка живёт ровно столько, сколько транзакция. Нельзя «запереть строку, пока пользователь заполняет форму» - транзакция не может длиться минуты в ожидании человека.

Оптимистичная блокировка в SQL

Здесь в строку добавляют служебную колонку, обычно version (целое число). Каждая запись увеличивает её на 1 и делает это только при условии, что версия всё ещё та, которую мы прочитали:

ALTER TABLE items ADD COLUMN version integer NOT NULL DEFAULT 0;

SELECT stock, version FROM items WHERE id = 42;
-- stock = 1, version = 7. Никаких блокировок, можно думать сколько угодно.

UPDATE items
SET stock = 0, version = 8
WHERE id = 42 AND version = 7;
-- UPDATE 1 -> успех, мы были первыми
-- UPDATE 0 -> кто-то успел раньше: перечитать и решить заново

Сама база здесь ничего не «ловит» и ошибку не кидает. Сигнал о конфликте - количество изменённых строк, и проверять его обязано приложение. Забыли проверить - получили то же потерянное обновление.

Где оптимизм особенно хорош:

  • Долгие правки человеком. Менеджер открыл карточку товара, ушёл на обед, вернулся и нажал «Сохранить». Версия передаётся в скрытом поле формы, и если коллега успел сохранить раньше, вместо тихой перезаписи показываем «данные изменились, обновите страницу».
  • Редкие конфликты. Если одну строку почти никогда не трогают одновременно, платить ожиданием за каждую запись незачем.
  • HTTP API. Тот же принцип есть в HTTP: сервер отдаёт ETag, клиент присылает его в If-Match, а при несовпадении получает 412 Precondition Failed.

Где плох: при высокой конкуренции за одну строку (распродажа одного товара, общий счётчик) почти все попытки будут проваливаться и повторяться. Пессимистичная очередь в таком случае честнее и дешевле.

Оптимизм не значит «совсем без блокировок». Сам UPDATE в PostgreSQL всё равно на мгновение запирает строку до конца своей транзакции. Если две транзакции делают оптимистичный UPDATE одновременно, вторая подождёт первую, а затем перепроверит WHERE version = 7 на свежей версии строки и получит UPDATE 0. Разница в том, что блокировка держится миллисекунды записи, а не всё время «чтения и раздумий».

Третий путь: атомарный UPDATE

Часто и пессимизм, и оптимизм не нужны: проверку можно перенести прямо в WHERE, а вычисление - в SET. Тогда нет разрыва между «прочитал» и «записал»:

UPDATE items
SET stock = stock - 1
WHERE id = 42 AND stock >= 1;
-- UPDATE 1 -> товар наш, создаём заказ в той же транзакции
-- UPDATE 0 -> товара нет

-- то же с возвратом нового значения
UPDATE items SET stock = stock - 1
WHERE id = 42 AND stock >= 1
RETURNING stock;

Это работает для счётчиков, балансов, остатков и флагов вида «перевести из состояния A в B, только если сейчас A» (UPDATE orders SET state = 'paid' WHERE id = 5 AND state = 'pending'). Если решение требует сложной логики в коде (сходить в другой сервис, посчитать скидку по десяти таблицам), одной командой не обойтись, и тогда выбирают между двумя подходами выше.

А если поднять уровень изоляции?

В REPEATABLE READ и SERIALIZABLE PostgreSQL сам заметит, что вы пытаетесь изменить строку, которую после начала вашей транзакции изменил кто-то другой, и бросит could not serialize access due to concurrent update. По сути это оптимистичная блокировка, встроенная в базу: никто не ждёт заранее, конфликт обнаруживается при записи, и всю транзакцию нужно повторить. Подход рабочий, но требует оборачивать в retry вообще всё, поэтому на практике чаще выбирают точечные инструменты выше.

Как выбрать

СитуацияЧто братьПочему
Счётчик, остаток, балансАтомарный UPDATE ... WHEREПроверка и запись в одной команде, блокировка минимальна
Смена статусаАтомарный UPDATE ... WHERE state = 'old'Второй исполнитель получит 0 строк и поймёт, что опоздал
Сложная логика, частые конфликтыSELECT ... FOR UPDATEОчередь вместо бесконечных повторов
Очередь задач в таблицеFOR UPDATE SKIP LOCKEDВоркеры разбирают разные строки и не ждут друг друга
Пользователь правит форму минутамиВерсия строкиТранзакцию нельзя держать открытой, пока человек думает
Редкие конфликты, много чтенийВерсия строкиНикто не платит ожиданием за конфликт, которого нет

Как это выглядит в Rails

В ActiveRecord есть оба подхода из коробки.

Пессимистично: lock, lock!, with_lock

item = Item.find(42)
item.with_lock do                  # BEGIN + SELECT * FROM items WHERE id = 42 FOR UPDATE
  raise OutOfStock if item.stock < 1
  item.update!(stock: item.stock - 1)
  Order.create!(item: item, user: user)
end                                # COMMIT

# то же самое руками
Item.transaction do
  item = Item.lock.find(42)        # SELECT ... FOR UPDATE
  ...
end

Item.lock("FOR UPDATE NOWAIT").find(42)
Job.where(state: "new").order(:id).lock("FOR UPDATE SKIP LOCKED").first

Что важно понимать про эти методы:

  • transaction сам по себе ничего не блокирует. Это только BEGIN и COMMIT. Код Item.transaction { item = Item.find(42); item.update!(stock: item.stock - 1) } подвержен потерянному обновлению так же, как и без транзакции.
  • with_lock = transaction + lock!. А lock! перечитывает запись из базы с FOR UPDATE. Значит, значения атрибутов внутри блока свежие, но изменения, которые вы сделали в объекте до вызова и не сохранили, потеряются. Rails в такой ситуации бросает ошибку, чтобы это не происходило молча.
  • Вложенность. Если with_lock вызван внутри уже открытой транзакции, новой транзакции не будет. Блокировка продержится до COMMIT внешней - иногда это намного дольше, чем кажется по коду.
  • Item.lock.find(42) вне транзакции бесполезен. Rails выполнит запрос в режиме autocommit, и блокировка снимется сразу после него.

Оптимистично: колонка lock_version

Достаточно добавить колонку с магическим именем, и Rails начнёт делать проверку версии на каждом save автоматически:

# миграция
add_column :items, :lock_version, :integer, null: false, default: 0

item = Item.find(42)                    # lock_version = 7
item.update!(stock: item.stock - 1)
# UPDATE items SET stock = 0, lock_version = 8
# WHERE id = 42 AND lock_version = 7
# 0 строк -> ActiveRecord::StaleObjectError

# повтор при конфликте
attempts = 0
begin
  item.reload
  raise OutOfStock if item.stock < 1
  item.update!(stock: item.stock - 1)
rescue ActiveRecord::StaleObjectError
  retry if (attempts += 1) < 3
  raise
end

Для форм версия передаётся скрытым полем: f.hidden_field :lock_version. Если кто-то сохранил запись раньше, update из контроллера получит StaleObjectError, и можно показать пользователю, что данные устарели.

Атомарно

updated = Item.where(id: 42).where("stock >= 1").update_all("stock = stock - 1")
raise OutOfStock if updated == 0

Item.update_counters(42, views_count: 1)  # UPDATE items SET views_count = COALESCE(views_count, 0) + 1

update_all возвращает количество изменённых строк, но пропускает валидации и колбэки модели. Для счётчиков и смены статусов это обычно именно то, что нужно.

Как это выглядит в Go

В Go с database/sql (или pgx) всё пишется почти сырым SQL, поэтому механика видна целиком.

// пессимистично
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() // после Commit ничего не делает

var stock int
err = tx.QueryRowContext(ctx,
    `SELECT stock FROM items WHERE id = $1 FOR UPDATE`, id).Scan(&stock)
if err != nil { return err }
if stock < 1 { return ErrOutOfStock }

if _, err = tx.ExecContext(ctx, `UPDATE items SET stock = stock - 1 WHERE id = $1`, id); err != nil {
    return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO orders (item_id, user_id) VALUES ($1, $2)`, id, userID); err != nil {
    return err
}
return tx.Commit()
// оптимистично: конфликт = RowsAffected() == 0
for attempt := 0; attempt < 3; attempt++ {
    var stock, version int
    err := db.QueryRowContext(ctx,
        `SELECT stock, version FROM items WHERE id = $1`, id).Scan(&stock, &version)
    if err != nil { return err }
    if stock < 1 { return ErrOutOfStock }

    res, err := db.ExecContext(ctx,
        `UPDATE items SET stock = $1, version = version + 1 WHERE id = $2 AND version = $3`,
        stock-1, id, version)
    if err != nil { return err }
    if n, _ := res.RowsAffected(); n == 1 { return nil }
    // кто-то успел раньше: перечитываем и пробуем снова
}
return ErrConflict

В реальном коде UPDATE и INSERT заказа должны быть в одной транзакции, здесь для краткости показан только сам приём. В GORM пессимистичная блокировка пишется как tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, id), а для оптимистичной есть плагин gorm.io/plugin/optimisticlock с полем типа optimisticlock.Version.

Типичная ошибка в Go: начать транзакцию через tx, а внутри по привычке вызвать db.QueryRowContext. Такой запрос уйдёт по другому соединению из пула, вне транзакции, и блокировка на нём не будет иметь отношения к вашему tx.

Как это выглядит в Node.js

С драйвером pg главное правило: вся транзакция должна идти через одно соединение, взятое из пула.

// пессимистично
const client = await pool.connect();
try {
  await client.query('BEGIN');
  const { rows } = await client.query(
    'SELECT stock FROM items WHERE id = $1 FOR UPDATE', [id]);
  if (rows[0].stock < 1) throw new Error('out of stock');
  await client.query('UPDATE items SET stock = stock - 1 WHERE id = $1', [id]);
  await client.query('INSERT INTO orders (item_id, user_id) VALUES ($1, $2)', [id, userId]);
  await client.query('COMMIT');
} catch (e) {
  await client.query('ROLLBACK');
  throw e;
} finally {
  client.release();
}

// оптимистично: конфликт = rowCount === 0
const { rowCount } = await pool.query(
  'UPDATE items SET stock = $1, version = version + 1 WHERE id = $2 AND version = $3',
  [stock - 1, id, version]);
if (rowCount === 0) throw new ConflictError();
Типичная ошибка в Node.js: await pool.query('BEGIN'), потом await pool.query('SELECT ... FOR UPDATE'). Каждый pool.query может взять другое соединение, поэтому BEGIN открылся в одном, SELECT выполнился в другом в режиме autocommit, и блокировка снялась мгновенно. Код выглядит как транзакция, но ей не является.

В ORM то же самое называется по-разному:

  • Prisma не умеет FOR UPDATE в своём API, поэтому пишут tx.$queryRaw внутри prisma.$transaction(async (tx) => { ... }). Оптимистично - через updateMany({ where: { id, version }, data: { ..., version: { increment: 1 } } }) и проверку count.
  • Sequelize: Item.findByPk(id, { transaction: t, lock: t.LOCK.UPDATE }) для пессимизма, опция модели version: true для оптимизма (бросает OptimisticLockError).
  • TypeORM: .setLock('pessimistic_write') в query builder и декоратор @VersionColumn().
  • Knex: .forUpdate(), .noWait(), .skipLocked() внутри knex.transaction(async (trx) => ...).

Чеклист

  • Видите в коде «прочитал - проверил в приложении - записал»? Это кандидат на гонку. Спросите себя, что будет, если между чтением и записью кто-то вклинится.
  • Сначала попробуйте атомарный UPDATE ... WHERE. Это самый короткий и самый надёжный вариант.
  • Внутри FOR UPDATE не ходите в сеть и не делайте ничего медленного. Блокировку держат миллисекунды, а не секунды.
  • Если блокируете несколько строк - всегда в одном порядке.
  • При оптимистичном подходе всегда проверяйте количество изменённых строк (или ловите исключение ORM) и решите заранее, что делать при конфликте: повторить или показать пользователю.
  • Убедитесь, что транзакция действительно одна: одно соединение в Node.js, один tx в Go, вложенный with_lock в Rails живёт до внешнего COMMIT.
ПессимистSELECT ... FOR UPDATE. Запираем заранее, остальные ждут. Подходит для частых конфликтов и коротких операций.
ОптимистКолонка version и WHERE version = N. Никто не ждёт, конфликт виден по 0 изменённых строк. Подходит для редких конфликтов и долгих правок.
АтомарноUPDATE ... SET x = x - 1 WHERE x >= 1. Нет разрыва между чтением и записью. Выбирайте первым, если логика помещается в одну команду.