JavaRush /Курсы /Spring Data JPA /Optimistic и pessimistic locking

Optimistic и pessimistic locking

Spring Data JPA
25 уровень , 3 лекция
Открыта

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 — это про конкуренцию, а не про смысл данных.

1
Задача
Spring Data JPA, 25 уровень, 3 лекция
Недоступна
Репозиторий с `@Lock(PESSIMISTIC_WRITE)`
Репозиторий с `@Lock(PESSIMISTIC_WRITE)`
1
Задача
Spring Data JPA, 25 уровень, 3 лекция
Недоступна
Два пути чтения одной сущности: обычный и locking
Два пути чтения одной сущности: обычный и locking
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ