1. Два подхода к конкуренции
Когда разработчики впервые слышат слова optimistic и pessimistic, возникает ощущение, что это какая-то философия уровня «стакан наполовину пуст». На самом деле это два очень приземлённых способа организовать доступ к одной и той же строке в базе, когда пользователей (и транзакций) много, а строка — одна. Мы уже «потрогали руками» optimistic locking через @Version, и теперь логично сравнить его с подходом, где конфликт не ловят на выходе, а предотвращают на входе.
Если говорить совсем по-человечески, optimistic locking — это режим «работайте параллельно, но если вы оба изменили одно и то же, второй получит отказ». Это похоже на совместное редактирование: вы оба правите документ, но система честно говорит «конфликт версий» и заставляет вас принять решение. Пессимистичный подход похож на ситуацию, когда вы взяли единственную ручку на столе и держите её, пока не допишете: никто не может писать одновременно, зато и конфликтов «кто последний записал» не будет — все просто ждут.
Важно заметить: оба подхода «нормальные». Нет такого, что optimistic — для хороших людей, а pessimistic — для параноиков. Просто у каждого подхода своя цена: optimistic чаще даёт исключения (но не блокирует), pessimistic чаще даёт ожидание (но снижает вероятность конфликта на уровне логики приложения).
2. Optimistic locking: конфликт на UPDATE
С optimistic locking вы уже знакомы, но для сравнения нам нужно закрепить его в одной фразе: мы не блокируем строку при чтении, а при записи делаем проверку версии. Это значит, что две транзакции могут спокойно прочитать одну и ту же строку, обе «уверенно» что-то посчитать в Java-коде, и только в момент записи одна из них узнает, что её расчёт опирался на устаревшее состояние.
На SQL-уровне это выражается простым, но очень важным трюком: версия участвует в WHERE. Упрощённо это выглядит так:
-- Важный момент: версия участвует в WHERE.
-- Если кто-то уже обновил строку, version изменится и UPDATE затронет 0 строк.
update stock_item
set available_quantity = ?,
version = ?
where id = ?
and version = ?;
Фраза «обновили 0 строк» превращается в смысл «строка уже не та, с которой вы работали». И дальше приложение (или вы в сервисе) решает, что делать: сообщить об ошибке, повторить попытку, перечитать состояние, попросить пользователя обновить страницу — вариантов много, но сам факт конфликта больше не «прячется» в данных. Это уже огромный прогресс по сравнению с lost update, который тихо и без предупреждений перетирает изменения.
Важный практический нюанс: optimistic locking почти всегда хорошо живёт там, где конфликты действительно редки. Например, если StockItem меняется не каждую миллисекунду сотней пользователей. Тогда вы получаете максимальную параллельность и редкие, но честные конфликты.
3. Pessimistic locking: блокировка строки
Пессимистичная блокировка начинается с другой мысли: «давайте считать, что конфликт возможен, и не будем делать вид, что его не существует». Поэтому мы берём блокировку в момент чтения, ещё до того, как начали что-то менять. На практике это означает, что запрос чтения превращается в чтение с блокировкой строки на стороне базы.
Если говорить про PostgreSQL (а наш курс на нём и живёт), то типичный вид SQL — это SELECT ... FOR UPDATE. В очень упрощённом виде так:
-- FOR UPDATE берёт блокировку строки на время транзакции:
-- другая транзакция, которая тоже захочет FOR UPDATE по этой строке, будет ждать.
select *
from stock_item
where id = ?
for update;
Что это даёт? Если транзакция A взяла строку stock_item.id = 42 «на запись», то транзакция B, которая тоже попробует взять эту же строку «на запись», будет ждать, пока A завершит commit/rollback. То есть конфликт превращается не в исключение, а в очередь. Звучит даже уютно: «ну подождём чуть-чуть, зато без ошибок». Но тут важно помнить цену: ожидание — это время. А время в backend-сервисе — это занятый поток, занятое соединение, занятая транзакция и потенциально цепочка из «ждём-ждём-ждём».
И ещё один момент, который часто путают: пессимистическая блокировка — это не synchronized и не «монитор» в Java. Это блокировка на уровне базы данных, то есть на уровне строки таблицы. Она работает даже если у вас два разных инстанса приложения, два разных JVM и вообще «всё разнесено по разным машинам». В этом смысле DB-lock — штука более фундаментальная, чем Java-локи, потому что база и является общей точкой, где встречаются все транзакции.
4. @Lock в Spring Data
Теперь приземлим идею до кода. В Spring Data JPA вы обычно работаете не напрямую с EntityManager, а через репозиторий. И для запроса «дай мне сущность и поставь на неё DB-lock» в Spring Data существует аннотация @Lock. Мы можем повесить её на метод репозитория, и тогда Spring Data скажет JPA-провайдеру (Hibernate): «этот запрос выполняй в таком lock-mode».
В рамках дня нам нужен ровно один режим: LockModeType.PESSIMISTIC_WRITE. Он означает «мне нужен эксклюзивный доступ для изменения, конкурирующие изменения должны подождать». В JPA-терминах это режим, который обычно транслируется в FOR UPDATE (для PostgreSQL).
Вот как может выглядеть репозиторий для StockItem в нашем проекте shop-data-jpa:
import com.example.shopdatajpa.inventory.entity.StockItem;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
public interface StockItemRepository extends JpaRepository<StockItem, Long> {
// Просим у JPA-провайдера (Hibernate) взять DB-lock на чтении.
// Для PostgreSQL это обычно превращается в SELECT ... FOR UPDATE.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select s
from StockItem s
where s.id = :id
""")
Optional<StockItem> findForUpdate(@Param("id") Long id);
}
Несколько важных смыслов спрятано даже в названии findForUpdate. Это не «просто find». Это метод, который явно говорит читателю кода: «я читаю строку так, что она будет заблокирована для изменений». И вот это, честно, половина инженерного успеха. В concurrency-коде самый частый баг — не технический, а человеческий: кто-то использовал неправильный метод, потому что «они же оба читают сущность».
Заметьте, что запрос у нас JPQL (по сущностям), но блокировка — вполне себе «базовая», на уровне БД. ORM здесь выступает как переводчик: вы попросили lock-mode, Hibernate сформировал SQL так, чтобы база реально взяла нужную блокировку.
5. Pessimistic lock живёт в транзакции
Самая болезненная ловушка с pessimistic locking — думать, что блокировка «прилипает» к объекту. На самом деле она живёт ровно столько, сколько живёт DB-транзакция. Как только транзакция завершилась (commit/rollback), блокировка снята. Поэтому repository-метод с @Lock имеет смысл только тогда, когда вокруг него есть транзакция, которая продолжается дальше — то есть обычно это сервисный метод с @Transactional.
В нашем проекте логично делать это в InventoryService (или похожем use-case сервисе). Например, для демонстрации можно написать такой метод:
Сама операция резерва при этом не меняется: в каноническом StockItem.reserve(qty) мы так же переносим количество из availableQuantity в reservedQuantity. Меняется только способ, которым мы защищаем этот write-сценарий от конкурирующей транзакции.
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void reserveWithLock(Long stockItemId, int qty) {
// В этот момент берём DB-lock (PESSIMISTIC_WRITE) и держим его до конца транзакции.
StockItem item = stockItemRepository.findForUpdate(stockItemId).orElseThrow();
// Сама доменная операция остаётся прежней:
// reserve(qty) проверяет остаток и переносит количество между available и reserved
item.reserve(qty);
}
Что важно в этом фрагменте, даже если он выглядит «слишком простым»:
Во-первых, блокировка берётся на чтении findForUpdate(...). Если другая транзакция попытается сделать то же самое, она упрётся в ожидание. То есть вы прямо в поведении системы получите «очередь на изменение остатка».
Во-вторых, мы не делаем под блокировкой ничего лишнего. Никаких HTTP-вызовов, никаких чтений файлов, никаких «ой, давай ещё отправим email». Чем дольше вы держите блокировку, тем больше вы тормозите всех остальных. Пессимистическая блокировка — как занятая касса в супермаркете: если кассир ушёл пить кофе, очередь не становится счастливее.
В-третьих, блокировка — это не замена инвариантов. Даже под PESSIMISTIC_WRITE вам всё равно нужно проверять бизнес-правила, пусть даже внутри item.reserve(qty). Блокировка защищает от конкурирующего изменения, но не от логических ошибок внутри одной транзакции.
И ещё один важный момент из нашего прошлого модуля: flush() не снимает блокировку. flush() отправляет SQL в базу, но транзакция продолжается, значит блокировка остаётся. Блокировка снимается только на commit или rollback.
6. Optimistic vs pessimistic: сравнение
Чтобы различие не осталось «словами из учебника», полезно сравнить оба подхода на одном и том же сценарии: два параллельных изменения одного StockItem.
Конфликт при optimistic locking
При optimistic locking обе транзакции могут свободно читать. Конфликт виден поздно — на записи:
sequenceDiagram
%% Оба читают одну и ту же версию: конфликт проявится только при UPDATE.
participant A as Tx A
participant DB as PostgreSQL
participant B as Tx B
A->>DB: "SELECT stock_item (version=5)"
B->>DB: "SELECT stock_item (version=5)"
A->>DB: "UPDATE ... WHERE id=42 AND version=5"
DB-->>A: "1 row updated (version becomes 6)"
B->>DB: "UPDATE ... WHERE id=42 AND version=5"
DB-->>B: "0 rows updated"
B-->>B: "OptimisticLockException"
Поведение «быстрое и параллельное», но иногда заканчивается исключением. С точки зрения UX это может быть «пожалуйста, повторите попытку» или «остаток изменился, обновите данные».
Конкуренция при pessimistic locking
При pessimistic locking конфликт виден рано — на чтении. Вторая транзакция не получит строку, пока первая не завершится:
sequenceDiagram
%% Тут конфликт превращается в ожидание: второй SELECT ... FOR UPDATE "висит" до COMMIT первой транзакции.
participant A as Tx A
participant DB as PostgreSQL
participant B as Tx B
A->>DB: "SELECT ... FOR UPDATE (id=42)"
DB-->>A: "row locked for write"
B->>DB: "SELECT ... FOR UPDATE (id=42)"
Note over B,DB: "B waits (row is locked)"
A->>DB: "UPDATE ..."
A->>DB: "COMMIT"
DB-->>B: "lock acquired, row returned"
Тут почти нет «ошибок», но есть ожидание. Если ожидание короткое — всё ок. Если ожидание длинное — сервис начинает «тупить», а под нагрузкой это превращается в очередь, которая может убить пропускную способность.
Таблица сравнения:
| Вопрос | Optimistic (@Version) | Pessimistic (@Lock(PESSIMISTIC_WRITE)) |
|---|---|---|
| Когда возникает конфликт? | Поздно, на flush/commit (на UPDATE/DELETE) | Рано, на SELECT ... FOR UPDATE |
| Как конфликт проявляется? | Исключение (OptimisticLockException) | Ожидание (вторая транзакция «висит») |
| Что делает база? | Не блокирует строку заранее | Блокирует строку до конца транзакции |
| Цена ошибки | Нужно уметь обрабатывать/повторять | Нужно контролировать время транзакции, иначе «очередь» |
| Хороший default для CRUD | Да, часто лучший базовый вариант | Нет, обычно это осознанное исключение |
| Что чувствует пользователь/клиент API | Иногда «конфликт, повторите» | Иногда «долго отвечает», но без конфликта |
И вот здесь становится видно главное: optimistic locking — это про честную проверку, pessimistic — про предотвращение конкуренции через блокировку.
В нашем курсе и проекте базовый выбор — optimistic locking на StockItem через @Version, потому что он обучает правильному мышлению: конфликты реальны, их нельзя скрывать, и мы обязаны их обнаруживать. Пессимистический подход мы держим как инструмент, который полезно знать и уметь применять точечно, но опасно сделать «стилем по умолчанию».
Для Mini Shop на этом и останавливаемся: базовым остаётся versioned StockItem с optimistic locking. В прикладном коде важнее не заменить всё подряд на @Lock, а уметь довести optimistic conflict до понятного сервисного исхода.
Нюанс: дисциплина при pessimistic lock
Есть одна тонкость, которая ломает ожидания новичка (и именно поэтому её стоит проговорить сейчас, пока всё не превратилось в хаос). Пессимистическая блокировка «не спасает от всего», если конкурентный код работает иначе.
Представьте ситуацию: транзакция A делает SELECT ... FOR UPDATE, меняет остаток и коммитится. Транзакция B не брала блокировку на чтении, а просто прочитала остаток обычным SELECT, посчитала «10 - 4 = 6», и потом попыталась сделать UPDATE. Её UPDATE действительно будет ждать, пока A освободит строку. Но как только строка освободится, B запишет «6». То есть lost update может снова случиться, потому что B опиралась на старое значение.
Из этого следует практическое правило: если вы выбрали стратегию «защищаем сценарий через pessimistic lock», то защищать нужно именно все конкурирующие write-сценарии, а не один «самый важный». Иначе вы получаете неприятную смесь: часть запросов ждёт, часть нет, а результат всё равно может быть странным.
В нашем учебном проекте эта проблема смягчается тем, что StockItem уже versioned через @Version. То есть даже если где-то вы забудете взять пессимистическую блокировку, optimistic проверка версии всё равно сможет поймать конфликт. Но это не повод расслабляться — это повод понимать, что вы делаете: @Lock — это способ управлять конкурентным доступом заранее, а не «суперклей поверх любого кода».
7. Типичные ошибки при locking
Ошибка №1: воспринимать @Lock как Java-блокировку (synchronized).
Иногда кажется, что @Lock(PESSIMISTIC_WRITE) — это «как будто мы синхронизировали метод». Нет: это блокировка строки в базе данных. Она действует между разными JVM и даже разными машинами, но живёт строго внутри DB-транзакции. И она не защищает ваш Java-код от параллельного выполнения — она защищает данные в конкретной строке.
Ошибка №2: вызывать lock-метод репозитория без сервисной транзакции и думать, что блокировка “держится”.
Если вокруг нет длинной транзакции, блокировка будет взята и тут же отпущена при выходе из репозиторного метода. В лучшем случае вы просто не получите ожидаемого эффекта, в худшем — начнёте менять detached-сущность и удивляться, почему изменения не сохранились. На уровне архитектуры это обычно лечится одной мыслью: блокировка — это часть use case, а значит она должна жить на service-layer под @Transactional.
Ошибка №3: держать блокировку слишком долго, потому что “ну оно же работает”.
Пессимистическая блокировка даёт комфорт «без конфликтов», но расплачивается ожиданием других транзакций. Если под блокировкой делать лишнюю работу, вы превращаете базу в бутылочное горлышко. Снаружи это выглядит как «иногда API зависает». Внутри это выглядит как очередь транзакций, которые ждут один и тот же stock_item.
Ошибка №4: пытаться сделать все чтения пессимистическими “на всякий случай”.
Это классическая «защитная» реакция: раз есть lost update, давайте всё блокировать. На практике это часто приводит к деградации производительности и к тому, что система теряет параллельность там, где она была бы безопасна. Optimistic locking существует не для красоты: он как раз и позволяет большинству операций идти параллельно, а конфликтные ситуации выявлять и обрабатывать точечно.
Ошибка №5: ожидать, что при pessimistic locking “конфликт исчезает”, и забыть про бизнес-инварианты.
Блокировка защищает от конкурирующей записи, но не превращает код в правильный автоматически. Даже под FOR UPDATE можно сделать availableQuantity = availableQuantity - qty и получить отрицательное значение, если не проверили правила. Lock — это про конкуренцию, а не про смысл данных.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ