Пессимистичные и оптимистичные блокировки: кто успел, тот и купил
Два человека одновременно покупают последнюю единицу товара. Оба видят «в наличии: 1», оба жмут «Купить», и база спокойно оформляет два заказа. Код при этом выглядит правильным, тесты зелёные, а ошибка проявляется только под нагрузкой. Разберём, почему так происходит и какими двумя философиями это лечат: пессимистичной и оптимистичной.
Проблема: потерянное обновление
Возьмём простейшую логику покупки, которую пишут почти все:
- Прочитать остаток:
SELECT stock FROM items WHERE id = 42. - В коде приложения проверить, что
stock >= 1. - Записать новый остаток
stock - 1и создать заказ.
Между шагом 1 и шагом 3 проходит время: миллисекунды на сеть, на код, на соседние запросы. Если в эти миллисекунды второй покупатель успел прочитать тот же остаток, оба примут решение на основе одной и той же устаревшей цифры. Второй UPDATE просто перезапишет первый. Это называется lost update - потерянное обновление.
READ COMMITTED: каждый запрос видит данные, закоммиченные на момент его старта, и обычный SELECT никого не блокирует. Поэтому BEGIN ... COMMIT вокруг кода выше от потерянного обновления не спасает.Решений два, и они отличаются отношением к конфликтам:
- Пессимист: «конфликт точно будет»
Перед тем как читать для изменения, запираем строку. Остальные, кто хочет её изменить, встают в очередь и ждут. Как ключ от примерочной: пока он у вас, следующий стоит у двери.
- Оптимист: «конфликта скорее всего не будет»
Никого не запираем. Но при записи проверяем, не изменил ли кто-то строку, пока мы думали. Если изменил - запись не проходит, перечитываем и решаем заново. Как вики: «страница изменилась, пока вы её редактировали».
Есть и третий, самый простой путь: вообще не разделять чтение и запись, а сделать проверку и изменение одной командой. О нём тоже ниже.
Симулятор: что происходит в базе
Две транзакции, T1 (Аня) и T2 (Борис), работают с одной таблицей items. У товара 42 остаток 1. Выберите сценарий, и он проиграется автоматически. Справа видно состояние строк: остаток, версию и кто держит блокировку.
Что стоит заметить:
- Без защиты обе транзакции проходят без единой ошибки. База не видит проблемы: каждый
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.
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();
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. Нет разрыва между чтением и записью. Выбирайте первым, если логика помещается в одну команду.