Прямой ответ
Оптимистичная блокировка предполагает, что конфликты редки. Вместо блокировки строки при чтении она проверяет, не изменились ли данные к моменту записи. Если изменились — запись отклоняется и повторяется.
Механизм — колонка версии:
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. Не применяйте версию к вычисляемым полям. Если значение пересчитывается из других данных, проверка версии может блокировать обновление, которое не конфликтует по смыслу. В таких случаях сравнивают только значимые поля.
Рекомендации:
- Начинайте с колонки версии. Это самый явный и переносимый способ; сравнение значений оставьте для случаев, где схему менять нельзя.
- Не смешивайте с пессимистичной блокировкой на одной строке. Смешанный подход даёт непредсказуемое поведение при высокой нагрузке.
- Логируйте конфликты. Рост числа конфликтов означает, что выбранная стратегия не подходит под нагрузку.
- Тестируйте параллельное изменение. Два потока, меняющие одну запись, должны привести к одному успешному обновлению и одному конфликту; о мёртвых блокировках — в статье про deadlock-и.
- Передавайте версию клиенту. Если клиент работает с данными долго, версия должна приходить в ответе, иначе проверка невозможна.
Практический сценарий: изменения статуса заказа терялись молча
Контекст: операторы поддержки и автоматический процесс обновляли статус заказа. Периодически статус возвращался к прежнему значению без объяснения.
- Диагностика. В логах не было ошибок: оба обновления завершались успешно. Порядок событий показал, что оператор читал заказ, затем автоматический процесс менял статус, затем оператор сохранял свою форму — перезаписывая чужое изменение.
- Причина. Классическая потеря обновления: чтение и запись не были связаны, и последнее изменение просто затирало предыдущее.
- Действия. (а) Добавили колонку
versionв таблицу заказов; (б) обновление выполнялось с условиемAND version = :vи проверкой числа затронутых строк; (в) при нуле строк клиент получал 409 и перечитывал данные; (г) в ORM включилиversion_id_col, чтобы проверка применялась ко всем обновлениям; (д) добавили метрику числа конфликтов и алерт при росте; (е) в форме оператора показали версию и предупреждение при конфликте. - Результат. Потерянные обновления исчезли: конфликты стали явными, и оператор видел сообщение о том, что данные изменились. Число конфликтов составляло около двух в день, что подтверждало выбор оптимистичной стратегии.
Вывод: потеря обновления не вызывает ошибок и не видна в логах. Колонка версии превращает молчаливую перезапись в явный конфликт, который обрабатывается перечитыванием.
FAQ
Чем оптимистичная блокировка отличается от пессимистичной? Пессимистичная блокирует строку при чтении и удерживает блокировку до конца транзакции. Оптимистичная не блокирует ничего и проверяет версию при записи. Первая лучше при высокой конкуренции, вторая — при низкой.
Что делать, если конфликты частые? Перейти на пессимистичную блокировку либо изменить модель: уменьшить длительность транзакции, разбить горячую запись, перенести изменение в очередь.
Можно ли использовать отметку времени вместо версии? Можно, но с осторожностью: разрешение времени может оказаться недостаточным, а перевод часов создаст ложные результаты. Счётчик надёжнее.
Нужна ли версия для вставки новой записи? Нет, конфликт возможен только при обновлении существующей. Для вставки достаточно уникального ограничения на естественный ключ.
Источники: PostgreSQL docs (isolation), SQLAlchemy docs (versioning), RFC 9110 (If-Match) — ссылки в шапке.