Сегодня поговорим о том, как решать проблему потерянных обновлений на уровне приложения — с помощью оптимистичной блокировки.
Что такое оптимистичная блокировка?
Оптимистичная блокировка (Optimistic Concurrency Control, OCC) — это подход к управлению конкурентным доступом к данным, который исходит из того, что конфликты при записи возникают редко. Вместо того чтобы блокировать запись на время чтения, OCC позволяет нескольким транзакциям читать одни и те же данные, а проверку на конфликт выполняет только в момент записи.
Если в момент записи обнаруживается, что данные были изменены другой транзакцией, текущая транзакция откатывается и может быть повторена.
Аналогия из жизни. Представьте, что вы читаете книгу вместе с другом. При пессимистичном подходе вы берёте книгу себе и не отдаете другу, пока не дочитаете. При оптимистичном подходе вы оба читаете одновременно, а когда пора писать продолжение, проверяете — не написал ли друг свою версию. Если написал — читаете его версию и пишете свое продолжение с нуля.
Почему не pessimistic locking?
Прежде чем углубляться в оптимистичный подход, давайте вспомним, как работает пессимистичная блокировка (Pessimistic Locking).
При SELECT ... FOR UPDATE база данных ставит блокировку на читаемую строку. Другие транзакции, которые пытаются прочитать или изменить эту строку, должны ждать. Это надежно, но есть проблемы:
- Блокировки снижают параллелизм. Пока одна транзакция читает и «думает», другие ждут.
- Риск дедлоков. Две транзакции могут заблокировать друг друга взаимно.
- Накладные расходы на управление блокировками. База данных тратит ресурсы на отслеживание, кто что заблокировал.
- Долгие транзакции. Чем дольше транзакция, тем дольше держатся блокировки.
Оптимистичная блокировка решает эти проблемы, отказываясь от блокировок вообще — проверка происходит только при записи.
Как это работает?
Базовый алгоритм
- Чтение. Транзакция читает данные вместе с каким-то маркером версии (число, timestamp, хеш).
- Обработка. Приложение обрабатывает данные, пользователь вносит изменения.
- Проверка перед записью. При обновлении проверяется, не изменился ли маркер версии с момента чтения.
- Запись или откат. Если версия не изменилась — запись проходит. Если изменилась — выбрасывается ошибка, транзакция откатывается.
Варианты реализации
Есть три основных способа отслеживать изменения:
1. Версионный счетчик
Каждая строка имеет поле version (INT), которое увеличивается при каждом обновлении:
-- Чтение
SELECT id, name, price, version FROM products WHERE id = 1;
-- Результат: { id: 1, name: 'Товар', price: 1000, version: 5 }
-- Обновление
UPDATE products
SET name = 'Новый товар', price = 1200, version = version + 1
WHERE id = 1 AND version = 5;
-- Если затронуто 0 строк — версия изменилась, конфликт!
Это самый распространённый и простой вариант. PostgreSQL, Hibernate, Spring Data — все используют именно его.
2. Timestamp
Вместо счётчика используется timestamp или datetime:
-- Чтение
SELECT id, name, updated_at FROM products WHERE id = 1;
-- Результат: { id: 1, name: 'Товар', updated_at: '2026-10-30 15:00:00' }
-- Обновление
UPDATE products
SET name = 'Новый товар', updated_at = NOW()
WHERE id = 1 AND updated_at = '2026-10-30 15:00:00';
Минус: если две транзакции читают данные в одно и то же время, а обновляются с разницей в миллисекунды, timestamp может не сработать. Счетчик надежнее.
3. Хеш содержимого (ETag)
Вычисляется хеш от всех полей строки. При обновлении проверяется, что хеш не изменился:
-- Чтение
SELECT id, name, price, etag FROM products WHERE id = 1;
-- Результат: { id: 1, name: 'Товар', price: 1000, etag: 'abc123' }
-- Обновление
UPDATE products
SET name = 'Новый товар', price = 1200, etag = MD5(CONCAT(name, price))
WHERE id = 1 AND etag = 'abc123';
Подходит для REST API, где etag передается в заголовке If-Match.
Пример на практике
Рассмотрим типичный сценарий — обновление баланса счета в банковском приложении:
# Псевдокод
# 1. Читаем баланс
account = db.query("SELECT id, balance, version FROM accounts WHERE id = ? FOR SHARE", 123)
# account = { id: 123, balance: 5000, version: 3 }
# 2. Пользователь вносит изменение
new_balance = account.balance + 1000 # Пополнение на 1000
# 3. Пытаемся обновить с проверкой версии
result = db.execute(
"UPDATE accounts SET balance = ?, version = version + 1 "
"WHERE id = ? AND version = ?",
new_balance, 123, 3
)
if result.rows_affected == 0:
# Конфликт! Кто-то обновил баланс между чтением и записью
raise ConcurrencyError("Данные были изменены. Попробуйте снова.")
else:
# Успех
commit()
Оптимистичная блокировка в популярных фреймворках
Spring Boot / Hibernate
@Entity
public class Product {
@Id
private Long id;
private String name;
private BigDecimal price;
@Version
private Long version; // Оптимистичная блокировка!
}
Аннотация @Version автоматически добавляет проверку версии в UPDATE:
UPDATE products SET name = ?, price = ?, version = version + 1
WHERE id = ? AND version = ?
При конфликте выбрасывается OptimisticLockException.
Django
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=100)
price = models.DecimalField(max_digits=10, decimal_places=2)
version = models.PositiveIntegerField(default=0)
def save(self, *args, **kwargs):
if self.pk:
updated = Product.objects.filter(
pk=self.pk,
version=self.version
).update(
name=self.name,
price=self.price,
version=F('version') + 1
)
if updated == 0:
raise DatabaseError("Concurrency conflict!")
super().save(*args, **kwargs)
EF Core (.NET)
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
[Timestamp] // Оптимистичная блокировка
public byte[] RowVersion { get; set; }
}
Плюсы и минусы
Преимущества
| Плюсы | Описание |
|---|---|
| Высокий параллелизм | Нет блокировок при чтении — все читают одновременно |
| Нет дедлоков | Блокировок нет — не может быть взаимной блокировки |
| Простота | Не нужно управлять lifecycle блокировок |
| Хорошо для read-heavy | Если чтение происходит намного чаще записи — идеальный вариант |
| Меньше накладных расходов | БД не тратит ресурсы на отслеживание блокировок |
Недостатки
| Минусы | Описание |
|---|---|
| Откаты транзакций | При конфликте транзакция откатывается — пользователь видит ошибку |
| Не для write-heavy | Если записи часто конфликтуют — постоянные откаты снижают производительность |
| Сложность обработки | Нужно реализовать retry-логику и обработку конфликтов |
| Не для критичных данных | В финансовых системах иногда нужен строгий контроль |
| Повторная попытка | При конфликте нужно перечитать актуальные данные и повторить операцию |
Когда использовать?
Подходит для:
- Read-heavy нагрузки — чтение в 10-100 раз чаще записи
- Низкая вероятность конфликтов — разные пользователи редактируют разные записи
- Длинные транзакции — пользователь «думает» над изменениями несколько секунд
- Распределённые системы — где distributed locking слишком дорог
Не подходит для:
- Write-heavy нагрузка — постоянные конфликты = постоянные откаты
- Высокая конкуренция за одни и те же записи — например, бронирование последнего места
- Критичные финансовые операции — где потерянная транзакция недопустима
- Длинные блокировки в UI — когда пользователь редактирует форму часами
Retry-паттерн
Правильная обработка конфликтов требует механизма повторных попыток:
import time
from concurrent.futures import TimeoutError
MAX_RETRIES = 3
RETRY_DELAY = 0.1 # секунды
def update_with_retry(update_func, timeout=5):
"""
Выполняет операцию с повторными попытками при конфликте.
"""
for attempt in range(1, MAX_RETRIES + 1):
try:
result = update_func()
return result
except ConcurrencyError:
if attempt == MAX_RETRIES:
raise
# Ждём случайное время (backoff) и повторяем
delay = RETRY_DELAY * attempt
time.sleep(delay + random.uniform(0, 0.05))
raise ConcurrencyError("Не удалось выполнить операцию после нескольких попыток")
# Использование
def update_balance(account_id, amount):
def do_update():
account = db.get_account(account_id)
new_balance = account.balance + amount
db.update_account(account_id, new_balance, account.version)
return new_balance
return update_with_retry(do_update)
Сравнение подходов
| Критерий | Pessimistic Locking | Optimistic Locking |
|---|---|---|
| Блокировки | Да, при чтении | Нет |
| Параллелизм | Низкий | Высокий |
| Дедлоки | Возможны | Невозможны |
| Конфликты | Обнаруживаются при чтении | Обнаруживаются при записи |
| Откаты | Редко | Часто (при высокой конкуренции) |
| Read-heavy | Неэффективно | Отлично |
| Write-heavy | Предпочтительно | Неэффективно |
| Сложность | Низкая | Средняя (retry-логика) |
Итоги
Оптимистичная блокировка — это мощный инструмент для управления конкурентным доступом, который отлично работает в большинстве реальных сценариев:
- Не требует блокировок — данные читаются без ограничений
- Проверка на конфликты происходит только при записи
- При конфликте транзакция откатывается и может быть повторена
- Идеально для read-heavy приложений с низкой вероятностью конфликтов
- Плохо для write-heavy с высокой конкуренцией за одни и те же записи
В современных приложениях оптимистичная блокировка — это де-факто стандарт для управления конкурентностью на уровне приложения. Фреймворки вроде Spring, Hibernate, Django, EF Core имеют встроенную поддержку, что делает её использование максимально простым.
Для прокачивания в этой теме изучите так же:
- Транзакции
- Уровни изоляции
- MVCC
- Pessimistic Locking
