Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Оптимистичные и пессимистичные блокировки: как убрать гонки в заказах и не уронить производительность

Разработка и технологии22 февраля 2026 г.
Гонки при одновременных изменениях приводят к потерям денег: двойному резерву товара, неверным остаткам, потерянным правкам. Разбираем, когда выбирать оптимистичную или пессимистичную блокировку, как это сделать в Postgres и на уровне API, и как не замедлить систему. В конце — чек‑лист выбора подхода, метрики для мониторинга и готовые примеры кода.
Оптимистичные и пессимистичные блокировки: как убрать гонки в заказах и не уронить производительность

Оглавление

  • Зачем нужны блокировки в бизнес‑логике
  • Две стратегии: оптимистичная и пессимистичная
  • «Потерянные обновления»: простая демонстрация гонки
  • Оптимистичная блокировка: версия строки, CAS и ETag
    • Версия строки в Postgres
    • Пример в Go (database/sql)
    • ETag/If-Match для REST API
  • Пессимистичная блокировка: SELECT FOR UPDATE, NOWAIT, SKIP LOCKED
  • Уровни изоляции в Postgres и что они реально дают
  • Адвайзори‑локи: когда нужно «закрыть» не строку, а бизнес‑сущность
  • Уникальные индексы как страховка от дублей
  • Производительность и антипаттерны
  • Мониторинг блокировок и дедлоков
  • Пример из практики: резерв товара без гонок
  • Чек‑лист выбора стратегии
  • Итоги

Зачем нужны блокировки в бизнес‑логике

Даже идеально написанное API начнёт врать, когда два клиента одновременно меняют одну и ту же сущность. Примеры:

  • Остатки на складе: два заказа «успели» зарезервировать один и тот же последний товар.
  • Баланс: списание и пополнение пересекаются — в итоге баланс уходит в минус.
  • Профиль клиента: оператор и сам клиент одновременно редактируют адрес — одна из правок теряется.

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

Две стратегии: оптимистичная и пессимистичная

  • Оптимистичная блокировка: предполагаем, что конфликты редки. Не блокируем ресурс заранее, а проверяем при записи, не поменялись ли данные с тех пор, как мы их читали. Если поменялись — операция отклоняется, клиент должен перечитать и повторить. Дёшево и быстро для 90% кейсов, где конкуренции мало.
  • Пессимистичная блокировка: перед изменением «запираем» запись — другие ждут или получают ошибку. Безопасно при высокой конкуренции, но легко уронить производительность, если держать блокировку долго.

«Потерянные обновления»: простая демонстрация гонки

Пусть есть таблица остатков:

CREATE TABLE stock (
  sku TEXT PRIMARY KEY,
  qty INTEGER NOT NULL
);
INSERT INTO stock(sku, qty) VALUES ('SKU1', 1);

Два потока читают qty=1 и оба решают «резервировать 1». Если оба делают UPDATE без координации, итоговый qty может стать 0, хотя один из заказов должен был быть отклонён. Ещё хуже — конечное значение может быть пересчитано неправильно, если логика сложнее простого вычитания.

Оптимистичная блокировка: версия строки, CAS и ETag

Ключевая идея — сравнение‑и‑обновление (compare‑and‑set, CAS): мы обновляем строку только если она не изменилась с момента чтения.

Версия строки в Postgres

Добавим версионность:

ALTER TABLE stock ADD COLUMN version BIGINT NOT NULL DEFAULT 0;

Чтение: получаем qty и version. Обновление: меняем qty, если version совпадает, и инкрементим version.

-- попытка зарезервировать 1 единицу
WITH cur AS (
  SELECT qty, version FROM stock WHERE sku = 'SKU1'
)
UPDATE stock s
SET qty = s.qty - 1,
    version = s.version + 1
FROM cur
WHERE s.sku = 'SKU1' AND s.version = cur.version AND s.qty >= 1
RETURNING s.qty, s.version;

Если другая транзакция уже изменила строку, RETURNING ничего не вернёт — конфликт, клиенту нужно перечитать и повторить. Важно: такая операция атомарна и не требует явной блокировки.

Пример в Go (database/sql)

package main

import (
    "context"
    "database/sql"
    "fmt"
    _ "github.com/lib/pq"
    "log"
    "time"
)

type Stock struct {
    SKU     string
    Qty     int64
    Version int64
}

func reserveOne(ctx context.Context, db *sql.DB, sku string) (Stock, error) {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil { return Stock{}, err }
    defer func() { _ = tx.Rollback() }()

    var s Stock
    if err := tx.QueryRowContext(ctx, "SELECT sku, qty, version FROM stock WHERE sku=$1 FOR SHARE", sku).
        Scan(&s.SKU, &s.Qty, &s.Version); err != nil {
        return Stock{}, err
    }

    // CAS-обновление по версии
    row := tx.QueryRowContext(ctx, `
        UPDATE stock SET qty = qty - 1, version = version + 1
        WHERE sku = $1 AND version = $2 AND qty >= 1
        RETURNING sku, qty, version
    `, sku, s.Version)

    if err := row.Scan(&s.SKU, &s.Qty, &s.Version); err != nil {
        if err == sql.ErrNoRows {
            return Stock{}, fmt.Errorf("conflict or not enough stock")
        }
        return Stock{}, err
    }

    if err := tx.Commit(); err != nil { return Stock{}, err }
    return s, nil
}

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/app?sslmode=disable")
    if err != nil { log.Fatal(err) }
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    s, err := reserveOne(ctx, db, "SKU1")
    if err != nil { log.Println("reserve failed:", err); return }
    log.Printf("reserved: %+v\n", s)
}

Здесь SELECT ... FOR SHARE защищает от удаления строки, но не блокирует на запись — это нормально: контроль делает WHERE version = ...

ETag/If-Match для REST API

Для API с редкими конфликтами удобно использовать ETag (хэш содержимого) и заголовок If-Match. Клиент читает ресурс, получает ETag, отправляет PUT/PATCH с If-Match: . Сервер применяет изменения только если ETag совпал.

// Пример на Node.js (express + pg)
const express = require('express');
const { Pool } = require('pg');
const crypto = require('crypto');

const app = express();
app.use(express.json());
const pool = new Pool({ connectionString: process.env.DSN });

function etagOf(obj) {
  return 'W/"' + crypto.createHash('sha1').update(JSON.stringify(obj)).digest('hex') + '"';
}

app.get('/profile/:id', async (req, res) => {
  const { rows } = await pool.query('SELECT id, name, address, version FROM profile WHERE id=$1', [req.params.id]);
  if (!rows[0]) return res.sendStatus(404);
  const etag = etagOf(rows[0]);
  res.set('ETag', etag).json(rows[0]);
});

app.patch('/profile/:id', async (req, res) => {
  const ifMatch = req.header('If-Match');
  if (!ifMatch) return res.status(428).send('Precondition Required');

  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows: curRows } = await client.query('SELECT id, name, address, version FROM profile WHERE id=$1 FOR SHARE', [req.params.id]);
    if (!curRows[0]) return res.sendStatus(404);
    const cur = curRows[0];
    const curEtag = etagOf(cur);
    if (curEtag !== ifMatch) {
      await client.query('ROLLBACK');
      return res.status(412).send('Precondition Failed');
    }
    const { name, address } = req.body;
    const { rows: upd } = await client.query(
      'UPDATE profile SET name=$1, address=$2, version=version+1 WHERE id=$3 AND version=$4 RETURNING id, name, address, version',
      [name ?? cur.name, address ?? cur.address, cur.id, cur.version]
    );
    await client.query('COMMIT');
    const etag = etagOf(upd[0]);
    res.set('ETag', etag).json(upd[0]);
  } catch (e) {
    await client.query('ROLLBACK');
    res.status(500).send('error');
  } finally {
    client.release();
  }
});

app.listen(3000);

Плюсы: минимум блокировок, отличная масштабируемость. Минус: нужно уметь корректно обработать конфликт на стороне клиента (показать обновлённые данные, предложить повтор).

Пессимистичная блокировка: SELECT FOR UPDATE, NOWAIT, SKIP LOCKED

Когда конкуренция высокая (например, воркер‑пул берёт задания из очереди, сотни потоков резервируют одно и то же SKU), безопаснее «запирать» запись на время операции.

  • SELECT ... FOR UPDATE — эксклюзивно блокирует выбранные строки до конца транзакции.
  • NOWAIT — вместо ожидания сразу ошибка, полезно для быстрых провалидированных путей.
  • SKIP LOCKED — пропускает заблокированные строки, удобно для конкурентных воркеров без конфликтов.
BEGIN;
SELECT * FROM jobs WHERE status = 'pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 10;
-- обрабатываем и помечаем
UPDATE jobs SET status='done' WHERE id IN (...);
COMMIT;

Для запасов:

BEGIN;
SELECT qty FROM stock WHERE sku='SKU1' FOR UPDATE;
-- строка захвачена, никто параллельно не поменяет qty
UPDATE stock SET qty = qty - 1 WHERE sku='SKU1' AND qty >= 1;
COMMIT;

Важно: держите транзакции максимально короткими — никакой сети, HTTP‑запросов и тяжёлой логики между SELECT FOR UPDATE и COMMIT. Иначе застрянете в очередях ожидания и словите дедлоки.

Уровни изоляции в Postgres и что они реально дают

  • Read Committed (по умолчанию): каждое выражение видит только закоммиченные данные на момент старта выражения. Потерянные обновления возможны без явной координации.
  • Repeatable Read: в рамках транзакции вы видите снимок на момент старта. Потерянные обновления по‑прежнему возможны, если вы читаете — вычисляете — пишете без блокировок. Это удивляет, но это так: защита от «неповторяющегося чтения», а не от логических гонок.
  • Serializable: эмулирует последовательное исполнение. Дорого, возможны откаты транзакций по сериализации — их нужно ловить и повторять. Для горячих секций может оказаться слишком затратным.

Вывод: изоляция не заменяет прикладную стратегию блокировок. Она лишь меняет вероятность конфликтов и типы аномалий.

Адвайзори‑локи: когда нужно «закрыть» не строку, а бизнес‑сущность

Иногда нужно синхронизироваться по ключу, который не совпадает с одной строкой: например, вы пересчитываете агрегат по нескольким таблицам или обновляете сразу набор SKU.

Postgres даёт лёгкие «советующие» блокировки:

-- Берём лок по 64‑битному ключу (например, хэш customer_id)
SELECT pg_advisory_xact_lock(1234567890123456789);
-- В рамках транзакции никто другой не возьмёт этот же лок

Они не привязаны к строкам и живут до конца транзакции. Отлично подходят для сериализации по бизнес‑ключу, когда явный SELECT FOR UPDATE «не к чему» приложить. Но будьте осторожны с картой ключей: важна стабильная хеш‑функция и отсутствие коллизий для горячих сущностей.

Уникальные индексы как страховка от дублей

Иногда лучший «лок» — это гарантия уникальности. Пример: не допустить два активных резерва на одну и ту же корзину.

CREATE TABLE reservations (
  id BIGSERIAL PRIMARY KEY,
  cart_id BIGINT NOT NULL,
  sku TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uniq_active ON reservations(cart_id, sku) WHERE cancelled_at IS NULL;

Попытка создать второй активный резерв упадёт с нарушением уникальности. Дальше — идемпотентный повтор с тем же ключом или корректный возврат 409 Conflict из API.

Производительность и антипаттерны

  • Не держите блокировки дольше необходимого. Всё, что может ждать — выносите за пределы транзакции.
  • Не делайте массовые сканы с FOR UPDATE без нужды. Лучше брать конкретные ключи, а для фоновой обработки — порционно и с SKIP LOCKED.
  • Избегайте «прочитал — посчитал — записал» без CAS/версии. Это рецепт потерянных обновлений.
  • Для горячих ключей подумайте о шардировании по ключу и/или очередях (один воркер на ключ).
  • Не путайте кэш и источник истины: обновления всегда должны проверяться в базе.

Мониторинг блокировок и дедлоков

  • Включите логирование ожиданий блокировок:
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '200ms';
SELECT pg_reload_conf();
  • Снимайте метрики ожиданий:
SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state = 'active' AND wait_event IS NOT NULL;
  • Анализируйте текущие блокировки:
SELECT locktype, mode, granted, pid, relation::regclass AS rel, virtualxid, transactionid
FROM pg_locks
ORDER BY granted, locktype;
  • Алерты: рост времени ожидания локов, частые дедлоки, рост длины очереди воркеров.

Пример из практики: резерв товара без гонок

Бизнес‑требование: не допустить продажи сверх остатка. Есть веб‑витрина (редкие конфликты) и батч‑процессы (высокая конкуренция). Решение — гибридная стратегия.

  1. Витрина: оптимистично через версию строки или ETag.
-- одной командой списываем при неизменной версии и достаточном остатке
UPDATE stock
SET qty = qty - $1, version = version + 1
WHERE sku = $2 AND version = $3 AND qty >= $1
RETURNING qty, version;

Если вернулась пустота — показываем пользователю обновлённый остаток и предлагаем изменить количество.

  1. Фоновые резервы для сборки заказов: пессимистично через FOR UPDATE SKIP LOCKED.
BEGIN;
-- забираем партию позиций на сборку, исключая конфликтующие
WITH batch AS (
  SELECT id FROM order_items WHERE status='to_reserve'
  ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100
)
UPDATE order_items oi
SET status='reserving'
FROM batch b
WHERE oi.id = b.id;
COMMIT;

Далее каждый элемент резервируется с короткой транзакцией и блокировкой конкретного SKU:

BEGIN;
SELECT qty FROM stock WHERE sku=$1 FOR UPDATE;
UPDATE stock SET qty = qty - $2 WHERE sku=$1 AND qty >= $2;
UPDATE order_items SET status='reserved' WHERE id=$3; -- только если предыдущее UPDATE изменило строку
COMMIT;

Плюс уникальный индекс на активные резервы для страховки от дублей.

Чек‑лист выбора стратегии

  • Конфликты редки, важна отзывчивость, пользователи готовы «обновить страницу»? Берите оптимистичную блокировку (версия, ETag, CAS).
  • Горячие ключи, много параллельных воркеров, нельзя терять попытки? Пессимистичная с SELECT FOR UPDATE (+ NOWAIT/SKIP LOCKED по ситуации).
  • Нужно сериализовать по бизнес‑ключу, который не равен одной строке? Рассмотрите pg_advisory_xact_lock.
  • Нужна безусловная гарантия от дублей? Уникальный индекс плюс корректная обработка ошибок.
  • Высокая цена ошибки, но небольшая нагрузка? Можно подумать о уровне изоляции Serializable с ретраями.

Технические практики:

  • Транзакции короткие, без внешних вызовов, без I/O под локами.
  • Метрики ожиданий локов и дедлоков — в алертах.
  • Чёткая стратегия ретраев при оптимистичных конфликтах (джиттер, ограничение числа попыток).

Итоги

Гонки — не «редкая дичь», а повседневность любой нагруженной системы. Правильный выбор между оптимистичной и пессимистичной блокировкой позволяет одновременно защитить деньги и сохранить скорость. Думайте от бизнес‑рисков и профиля нагрузки: где конфликты редки — используйте CAS/ETag; где горячо — берите локи короткими транзакциями и распределяйте работу. Не забывайте про мониторинг: как только появятся ожидания и дедлоки, бизнес почувствует это быстрее всех. С приведёнными приёмами и кусками кода у вас есть готовый каркас, чтобы закрыть тему гонок без лишней боли и вкладок с инцидентами.


PostgreSQLконкурентностьблокировки