Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Оптимистичная блокировка: версии строк и конфликты

Оптимистичная блокировка: колонка версии вместо ожидания

Оптимистичная блокировка: колонка версии, UPDATE с проверкой версии, обработка конфликта, ETag и If-Match на уровне API.

Прямой ответ

Оптимистичная блокировка предполагает, что конфликты редки. Вместо блокировки строки при чтении она проверяет, не изменились ли данные к моменту записи. Если изменились — запись отклоняется и повторяется.

Механизм — колонка версии:

ALTER TABLE orders ADD COLUMN version INTEGER NOT NULL DEFAULT 1;

UPDATE orders
   SET status = 'paid',
       version = version + 1
 WHERE id = 'ord-42'
   AND version = 7;               -- версия, прочитанная ранее

Если запрос затронул одну строку — обновление прошло. Если ноль строк — кто-то изменил запись раньше, и версию нужно перечитать.

def pay_order(session, order_id: str, expected_version: int) -> bool:
    result = session.execute(
        text("""UPDATE orders
                   SET status = 'paid', version = version + 1
                 WHERE id = :id AND version = :v"""),
        {"id": order_id, "v": expected_version},
    )
    session.commit()
    return result.rowcount == 1

Проверка rowcount == 1 обязательна: без неё конфликт проходит незамеченным, и изменения теряются.

Обработка конфликта — повтор с перечитыванием:

for attempt in range(5):
    order = load_order(order_id)                    # свежая версия
    if apply_change(order):
        if pay_order(session, order_id, order.version):
            return True
    time.sleep(random.uniform(0.01, 0.05 * 2 ** attempt))
raise ConflictError("Не удалось применить изменение")

Тот же принцип на уровне API задаётся через ETag и If-Match, определённые в RFC 9110:

GET /orders/ord-42
ETag: "7"

PUT /orders/ord-42
If-Match: "7"
→ 200 OK            (версия совпала)
→ 412 Precondition Failed   (данные изменились)

Код 412 сообщает клиенту, что его копия устарела и нужно перечитать данные.

Практика: когда применять и как настраивать

1. Применяйте при низкой конкуренции. Если запись меняют редко, конфликты единичны, и повтор дешевле блокировки. При высокой конкуренции на одной строке большинство попыток будет отклоняться — нужна пессимистичная блокировка; о ней — в статье про SELECT FOR UPDATE.

2. Уровня изоляции недостаточно. Даже SERIALIZABLE в PostgreSQL не даёт автоматического повторного применения: транзакция прерывается с ошибкой сериализации, и повтор — задача приложения. О уровнях изоляции — в статье про транзакции.

3. Используйте версионирование ORM. SQLAlchemy поддерживает колонку версии штатно:

class Order(Base):
    __tablename__ = "orders"
    id = mapped_column(String, primary_key=True)
    status = mapped_column(String)
    version = mapped_column(Integer, nullable=False)

    __mapper_args__ = {"version_id_col": version}

ORM сам добавляет проверку версии и выдаёт StaleDataError при конфликте.

4. Ограничивайте число повторов. Бесконечный цикл повторов при высокой конкуренции не завершится. Практический предел — три-пять попыток, затем явная ошибка.

5. Добавляйте джиттер между попытками. Одновременные повторы конфликтуют снова; случайная пауза разводит их во времени.

6. Проверяйте конфликт по всем полям, если версии нет. Альтернатива колонке версии — сравнение значений: WHERE id = :id AND status = :old_status. Это менее надёжно, поскольку не ловит изменение, вернувшее прежнее значение.

7. Возвращайте клиенту понятную ошибку. Код 409 Conflict или 412 с указанием, что данные изменились, лучше, чем общая ошибка сервера.

8. Проверяйте число затронутых строк всегда. Отсутствие проверки rowcount — самая частая ошибка: запрос выполняется без исключения, но изменения не применяются. Молчаливая потеря обновления труднее всего диагностируется.

9. Не применяйте версию к вычисляемым полям. Если значение пересчитывается из других данных, проверка версии может блокировать обновление, которое не конфликтует по смыслу. В таких случаях сравнивают только значимые поля.

Рекомендации:

  1. Начинайте с колонки версии. Это самый явный и переносимый способ; сравнение значений оставьте для случаев, где схему менять нельзя.
  2. Не смешивайте с пессимистичной блокировкой на одной строке. Смешанный подход даёт непредсказуемое поведение при высокой нагрузке.
  3. Логируйте конфликты. Рост числа конфликтов означает, что выбранная стратегия не подходит под нагрузку.
  4. Тестируйте параллельное изменение. Два потока, меняющие одну запись, должны привести к одному успешному обновлению и одному конфликту; о мёртвых блокировках — в статье про deadlock-и.
  5. Передавайте версию клиенту. Если клиент работает с данными долго, версия должна приходить в ответе, иначе проверка невозможна.

Практический сценарий: изменения статуса заказа терялись молча

Контекст: операторы поддержки и автоматический процесс обновляли статус заказа. Периодически статус возвращался к прежнему значению без объяснения.

  1. Диагностика. В логах не было ошибок: оба обновления завершались успешно. Порядок событий показал, что оператор читал заказ, затем автоматический процесс менял статус, затем оператор сохранял свою форму — перезаписывая чужое изменение.
  2. Причина. Классическая потеря обновления: чтение и запись не были связаны, и последнее изменение просто затирало предыдущее.
  3. Действия. (а) Добавили колонку version в таблицу заказов; (б) обновление выполнялось с условием AND version = :v и проверкой числа затронутых строк; (в) при нуле строк клиент получал 409 и перечитывал данные; (г) в ORM включили version_id_col, чтобы проверка применялась ко всем обновлениям; (д) добавили метрику числа конфликтов и алерт при росте; (е) в форме оператора показали версию и предупреждение при конфликте.
  4. Результат. Потерянные обновления исчезли: конфликты стали явными, и оператор видел сообщение о том, что данные изменились. Число конфликтов составляло около двух в день, что подтверждало выбор оптимистичной стратегии.

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

FAQ

Чем оптимистичная блокировка отличается от пессимистичной? Пессимистичная блокирует строку при чтении и удерживает блокировку до конца транзакции. Оптимистичная не блокирует ничего и проверяет версию при записи. Первая лучше при высокой конкуренции, вторая — при низкой.

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

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

Нужна ли версия для вставки новой записи? Нет, конфликт возможен только при обновлении существующей. Для вставки достаточно уникального ограничения на естественный ключ.

Источники: PostgreSQL docs (isolation), SQLAlchemy docs (versioning), RFC 9110 (If-Match) — ссылки в шапке.