Опирается на правила:
R-JOOQ-LCK-1…R-JOOQ-LCK-4иR-JOOQ-LCK-X1…R-JOOQ-LCK-X3из jOOQ Style Guide → раздел 9. Lock-режимы.
Важно знать
SelectMode— domain-enum вcore/:NO_LOCK,FOR_UPDATE,FOR_UPDATE_SKIP_LOCKED. Каждый read-метод принимает его.applyLock(query, mode)— private helper в репозитории, переключающий jOOQ-builder.forUpdate()без@Transactionalне работает. Лок отпустится мгновенно при закрытии соединения. См.PG-L-041.- Optimistic locking — через
version-колонку, не черезwithExecuteWithOptimisticLocking(true)в Settings.FOR UPDATEдля read-only query — лишнее блокирование. На query-handler —NO_LOCK.
FOR UPDATE берёт строчную блокировку на уровне PG. Никакая параллельная транзакция не сможет UPDATE/DELETE/SELECT FOR UPDATE по той же строке, пока лок не отпустится (на COMMIT/ROLLBACK). Это самая распространённая защита от race conditions при «read → modify → write» в одной транзакции. Раскрытие правил R-JOOQ-LCK-* ниже.
SelectMode — domain-enum
R-JOOQ-LCK-1: enum в core/, три значения.
// core/domain/repository/SelectMode.java
public enum SelectMode {
NO_LOCK, // обычный SELECT
FOR_UPDATE, // SELECT FOR UPDATE — ждём, если строка залочена
FOR_UPDATE_SKIP_LOCKED // SELECT FOR UPDATE SKIP LOCKED — пропускаем залоченные
}
Каждый read-метод репозитория принимает SelectMode mode параметром:
public interface OrderRepository {
Optional<Order> findById(Long id, SelectMode mode);
PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode);
}
Почему параметр, а не два метода findById + findByIdForUpdate:
- Лок — решение handler'а. Read для view возьмёт
NO_LOCK, command-handler перед UPDATE возьмётFOR_UPDATE. Репозиторий не угадывает. - Меньше методов в интерфейсе. 3 lock-режима × 5 read-методов = 15 методов — нечитаемо.
- Виден контракт. Handler пишет
SelectMode.FOR_UPDATE— в коде сразу видно, что берётся строчная блокировка. Скрывать это «внутри говорящего имени» — прятать важную деталь.
См. также R-JOOQ-REPO-4 в Repository pattern.
applyLock — private helper
R-JOOQ-LCK-2: switch по enum, переключающий jOOQ-builder.
private <Q extends SelectForUpdateOfStep<?>> Q applyLock(Q query, SelectMode mode) {
return switch (mode) {
case NO_LOCK -> query;
case FOR_UPDATE -> (Q) query.forUpdate();
case FOR_UPDATE_SKIP_LOCKED -> (Q) query.forUpdate().skipLocked();
};
}
Используется в репозитории так:
return dslContext
.selectFrom(orders)
.where(orders.ID.eq(id))
.pipe(q -> applyLock(q, mode))
.fetchOptionalInto(OrdersPojo.class);
switch — exhaustive: если завтра в SelectMode добавится значение FOR_SHARE, компилятор поймает все applyLock-методы, где забыли его обработать.
FOR UPDATE без @Transactional — баг
R-JOOQ-LCK-3 и R-JOOQ-LCK-X1: любой forUpdate() живёт внутри @Transactional-метода.
Почему: блокировка PG привязана к транзакции. Когда транзакция закрывается (COMMIT или ROLLBACK), все строчные локи отпускаются. Без @Transactional Spring + jOOQ открывают соединение, выполняют запрос, закрывают соединение (= автоматический COMMIT) — и лок снят в течение микросекунд.
// ПЛОХО — лок отпустится сразу после SELECT, никакой защиты от race
@Component
@RequiredArgsConstructor
public class CancelOrderCommandHandler {
private final OrderRepository repository;
public void handle(CancelOrderCommand cmd) { // ← НЕТ @Transactional
Order order = repository.findById(cmd.id(), SelectMode.FOR_UPDATE).orElseThrow();
// ↑ jOOQ открыл соединение, выполнил SELECT FOR UPDATE, закрыл соединение → лок отпущен
order.cancel();
// ↑ между этими строчками параллельная транзакция может прочитать и обновить заказ
repository.save(order);
}
}
// ХОРОШО
@Component
@Transactional // ← границы транзакции = границы лока
@RequiredArgsConstructor
public class CancelOrderCommandHandler {
private final OrderRepository repository;
public void handle(CancelOrderCommand cmd) {
Order order = repository.findById(cmd.id(), SelectMode.FOR_UPDATE).orElseThrow();
order.cancel();
repository.save(order);
}
}
@Transactional на handler'е держит транзакцию открытой от входа в метод до выхода. Лок живёт всё это время. Параллельная транзакция, попытавшаяся FOR UPDATE или UPDATE на той же строке, ждёт до коммита.
См. также PG-L-041 в PG Style Guide → блокировки и ACID и уровни изоляции — про MVCC и снимки.
SKIP LOCKED — для batch-схедулеров
FOR_UPDATE_SKIP_LOCKED — особая семантика: «если строка уже залочена другой транзакцией, пропусти её, не жди». Используется в task-queue / outbox-relay паттернах: несколько worker'ов параллельно тянут из очереди, и каждый получает только то, что не успели взять остальные.
// Worker — забирает пачку задач, которые никто не обрабатывает прямо сейчас
@Transactional
public List<Task> claimBatch(int size) {
return taskRepository.findReadyTasks(size, SelectMode.FOR_UPDATE_SKIP_LOCKED);
}
В репозитории:
return dslContext
.selectFrom(tasks)
.where(tasks.STATUS.eq(TaskStatus.READY))
.orderBy(tasks.CREATED_AT)
.limit(size)
.pipe(q -> applyLock(q, SelectMode.FOR_UPDATE_SKIP_LOCKED))
.fetch();
Без SKIP LOCKED все worker'ы выстроились бы в очередь на первую задачу. С ним — каждый идёт по своей пачке, параллелизм работает.
См. PG Runtime → task-queue и task-queue паттерн в PG runtime design — практическое применение.
Optimistic locking через version-колонку
R-JOOQ-LCK-4 и R-JOOQ-LCK-X2: optimistic locking — через явную колонку, не через jOOQ Settings.
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
status TEXT NOT NULL,
-- ...
version BIGINT NOT NULL DEFAULT 0 -- ← optimistic-lock колонка
);
// При апдейте увеличиваем version и проверяем
public void save(Order order) {
int updated = dslContext.update(orders)
.set(orders.STATUS, order.status())
.set(orders.VERSION, orders.VERSION.plus(1))
.where(orders.ID.eq(order.id().value()))
.and(orders.VERSION.eq(order.version())) // ← версия совпадает с прочитанной?
.execute();
if (updated == 0) {
throw new OptimisticLockException(order.id());
}
}
Идея: между findById и save могла пройти параллельная транзакция, увеличившая version. Если так — наш UPDATE «по старой версии» вернёт 0 row-count, мы бросаем OptimisticLockException, handler ловит, retry'ит — но уже на свежем Order.
Почему не Settings.withExecuteWithOptimisticLocking(true):
- Скрытая магия. Конфликт-error может выскочить из любого
record.store(), без явной колонки в схеме. Дебаг тяжёлый. - Не виден в миграциях.
version-колонка — часть схемы, ревью миграции её увидит. Settings-флаг — глобальная штука в коде, легко пропустить. - Не работает с DSL-стилем UPDATE. Мы используем
update(...).set(...).where(...)(см.R-JOOQ-QRY-X3), неrecord.store(). Settings-механика рассчитана на record-mutate.
См. PG-L-051 в PG Style Guide про version-колонки.
FOR UPDATE для read-only — лишнее
R-JOOQ-LCK-X3: query-handler не берёт FOR UPDATE.
// ПЛОХО
@Transactional(readOnly = true)
public OrderView handle(GetOrderQuery query) {
Order order = repository.findById(query.id(), SelectMode.FOR_UPDATE).orElseThrow();
// ^^^^^^^^^^^^^^^^^^^^
// read-only handler, зачем мы блокируем строку?
return mapper.toView(order);
}
FOR UPDATE на чтении приводит к:
- Контеншн. Параллельные read-запросы становятся последовательными.
- Deadlock'и. Если два query-handler'а блокируют разные строки в разном порядке.
- Лишняя нагрузка на PG — locks нужно где-то хранить, на интенсивном чтении это память shared memory.
SelectMode.NO_LOCK (default для query-handler'ов). FOR_UPDATE — только в command-handler перед апдейтом или явной транзакционной логике.
Куда дальше
- jOOQ Style Guide → раздел 9. Lock-режимы — нормативные формулировки.
- Транзакции — почему
@Transactionalна handler'е, а не на репозитории. - ACID и уровни изоляции — MVCC, снимки, и почему
FOR UPDATEчасто проще, чем поднимать уровень изоляции. - PG Style Guide → блокировки —
PG-L-041,PG-L-051.