Опирается на правила: R-JOOQ-LCK-1R-JOOQ-LCK-4 и R-JOOQ-LCK-X1R-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 перед апдейтом или явной транзакционной логике.

Куда дальше