Приложение было живо. Health check зелёный, CPU холодный, логи чистые. Но ни один запрос не возвращался. Детективная история о том, как пять соединений с базой держат в заложниках весь production: HikariCP, Transactional, deadlock, self-invocation, REQUIRES_NEW и один дворецкий, которого никто не подозревал. Демо-проект прилагается, каждое преступление воспроизводится одной командой.


Пролог. Очередь у бара

Город спал. Я, по традиции, нет.

Четверг, одиннадцать вечера. После дела о молчаливой JVM в участке многое изменилось: на каждом углу висели жучки Прометея, Грейс рисовала свою доску, коммутатор будил только тех, кого надо. Мы почти поверили, что контролируем этот город.

Зазвонил телефон. Даня, стажёр. Голос человека, который уже открыл дашборд и не понял в нём ни слова.

— Приложение висит. Но оно живое! Health check зелёный, heap в норме, CPU спит. А запросы не отвечают. Все. Вообще все.

— Латентность?

— p99 упёрся в потолок. И смотри, странное: рация кричит «ConnectionTimeouts», а база… база в порядке. Я проверил, она отвечает мгновенно.

Вот это и есть самое интересное в нашей профессии. По документам все живы. Осталось выяснить, почему никто не дышит. Я допил кофе и открыл ноутбук.

— Даня, помнишь бар «У Хикари» из прошлого дела? Десять столиков, бармен с секундомером?

— Помню. У нас в этом проекте пять столиков.

— Вот. Кто-то сел за все пять и не уходит. Едем смотреть, кто.

Забегая вперёд: подозреваемых оказалось пятеро, и все пятеро имели мотив. А виновным оказался тот, кого мы вообще не вносили в список. Но по порядку.


Эпизод 1. Осмотр места: чем занят пул

Прежде чем кого-то допрашивать, нужно понять, что вообще произошло. Connection pool - это пять соединений с базой (в нашем демо; в проде обычно 10-20). Каждый запрос, которому нужна база, берёт соединение, работает, возвращает. Если возвращать забывают или держат дольше нужного, начинается очередь. Очередь на входе в бар выглядит так:

http request -> ждёт соединение -> connection-timeout (5s) -> SQLTransientConnectionException -> 500

Приложение при этом здорово. Tomcat принимает запросы, heap чист, GC отдыхает. Просто каждый, кто пришёл за соединением, стоит и ждёт. Классическое преступление без взлома: всё заперто изнутри.

Улика первая: метрики

Прослушку мы поставили ещё в прошлом деле, поэтому первые показания сняли за минуту. У HikariCP четыре главных свидетеля:

# Сколько столиков занято
hikaricp_connections_active

# Сколько свободно
hikaricp_connections_idle

# ОЧЕРЕДЬ У ВХОДА. Главная метрика этого дела.
# В здоровой системе - ноль. Всегда ноль.
hikaricp_connections_pending

# Скольким уже отказали (ждали connection-timeout и получили исключение)
hikaricp_connections_timeout_total

Наша картина на момент преступления: active = 5, idle = 0, pending = 12, и timeout_total растёт. Перевожу с прометейского: все столики заняты, двенадцать человек мёрзнут у входа, и вышибала уже начал отказывать.

И ещё две метрики, которые отвечают на вопрос «занято кем»:

# Сколько ждут соединение (acquire) - боль тех, кто в очереди
rate(hikaricp_connections_acquire_seconds_sum[1m])
/ rate(hikaricp_connections_acquire_seconds_count[1m])

# Сколько ДЕРЖАТ соединение (usage) - наглость тех, кто внутри
rate(hikaricp_connections_usage_seconds_sum[1m])
/ rate(hikaricp_connections_usage_seconds_count[1m])

Usage в норме - миллисекунды: взял, спросил, вернул. У нас usage показывал секунды. Кто-то сидел за столиком и не заказывал.

Улика вторая: thread dump

Метрики говорят «что». Thread dump говорит «кто». Снимается на месте преступления одной командой:

jstack <pid> > threads.txt
# или, если приложение в контейнере:
docker exec todo-tx-app jstack 1 > threads.txt

Ищите потоки, застывшие в ожидании соединения:

"http-nio-8080-exec-3" ... WAITING
   at java.base@17/jdk.internal.misc.Unsafe.park
   at com.zaxxer.hikari.util.ConcurrentBag.borrow
   at com.zaxxer.hikari.pool.HikariPool.getConnection

Десяток таких - и диагноз подтверждён: очередь за соединениями. А потоки, которые соединение ДЕРЖАТ, будут в этот момент делать что-то своё: спать, ждать HTTP-ответа от чужого API, держать блокировку. Запомните это «что-то своё». К нему мы ещё вернёмся.

Улика третья: показания самой базы

PostgreSQL ведёт списки всех, кто сидит в баре. Допрашивается напрямую:

-- Кто подключён и чем занят
SELECT pid, state, wait_event_type,
       now() - xact_start AS tx_age,
       left(query, 60) AS query
FROM pg_stat_activity
WHERE datname = 'todo'
ORDER BY xact_start;

Особое внимание на state = 'idle in transaction'. Это соединение, у которого транзакция открыта, а запросов нет. Клиент сидит за столиком, ничего не заказывает и не уходит. В маленьких дозах это нормально - миллисекунды между запросами внутри транзакции. Но если tx_age измеряется секундами, вы смотрите на подозреваемого.

Утром мы собрали всё в участке. Лёша, капитан, посмотрел на доску и сформулировал задачу так:

— Значит, пять соединений, и все заняты неизвестно кем. Список подозреваемых - на стол. К вечеру.

Список получился из пяти пунктов. Разберём каждого - все пятеро водятся в любом среднем Spring Boot проекте, и каждый умеет положить пул в одиночку.


Эпизод 2. Подозреваемый №1: соединение, которое не вернули

Самый старый трюк в этом городе. Где-то в кодовой базе живёт «легаси-отчёт», написанный до эпохи Spring Data, и он берёт соединение у DataSource напрямую:

// ПРЕСТУПЛЕНИЕ: нет try-with-resources. Соединение взяли - не вернули.
public long generateReportWithLeak() throws SQLException {
    Connection connection = dataSource.getConnection();

    Statement statement = connection.createStatement();
    ResultSet resultSet = statement.executeQuery("select count(*) from tasks");
    resultSet.next();
    return resultSet.getLong(1);
    // connection.close()? Не слышали.
}

Метод отработал, результат вернул, тесты зелёные. А соединение осталось у метода навсегда. Пул о нём помнит - для пула оно «занято». Каждый вызов отчёта - минус один столик. Пять вызовов - и бар закрыт. Столики заняты отсутствующими людьми. Почти совещание.

В демо-проекте это воспроизводится за десять секунд:

# Пять раз дёргаем дырявый отчёт
curl -X POST http://localhost:8080/api/crime/leak   # x5

# Смотрим на пул
curl http://localhost:8080/api/crime/pool
# {"active":5,"idle":0,"waiting":0,"total":5,"leaked":5}

# Пробуем обычный эндпоинт
curl http://localhost:8080/api/tasks
# ...5 секунд тишины... 500

Как ловить

У Hikari есть штатный сыщик, и его нужно просто включить:

spring:
  datasource:
    hikari:
      # Если соединение не вернули за 10 секунд - WARN со stack trace
      # того места, где его взяли. Виновник с поличным.
      leak-detection-threshold: 10000

Через десять секунд после кражи в логе появляется признание:

WARN com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection
triggered for org.postgresql.jdbc.PgConnection@..., stack trace follows
java.lang.Exception: Apparent connection leak detected
    at com.example.todo.crime.LeakyReportService.generateReportWithLeak(...)

Stack trace показывает точную строчку, где соединение взяли. Не «где-то в модуле отчётов», а файл и номер строки. В production держите порог 30-60 секунд, чтобы не ловить честные долгие операции: сыщик, который строчит ложные доносы, хуже бездельника.

Как чинить

Скучно и надёжно - try-with-resources:

public long generateReportProperly() throws SQLException {
    try (Connection connection = dataSource.getConnection();
         Statement statement = connection.createStatement();
         ResultSet resultSet = statement.executeQuery("select count(*) from tasks")) {
        resultSet.next();
        return resultSet.getLong(1);
    }
    // Всё закрыто в обратном порядке. Даже при исключении. Всегда.
}

Даня спросил, почему JPA-код так не ломается. Потому что Spring Data возвращает соединения за вас - это его работа. Утечки почти всегда живут в ручном JDBC, во «временных» скриптах и в библиотеках, которым дали DataSource и поверили на слово. Доверяй, но проверяй leak-detection-threshold’ом.


Эпизод 3. Подозреваемый №2: транзакция, которая вышла покурить

Второй подозреваемый выглядит прилично. Никаких утечек, все соединения возвращаются. Код - хоть в учебник:

@Transactional
public Task createAndNotifySlow(String title) {
    Task saved = taskRepository.save(new Task(title));   // 5 миллисекунд
    externalClient.notifyExternalSystem(saved);          // 5 СЕКУНД
    return saved;
}

Сохранили задачу, уведомили внешнюю систему. Логично? Логично. Атомарно? Вроде бы даже атомарно. Одна деталь: @Transactional держит соединение с базой до конца метода. База была нужна пять миллисекунд. Соединение занято пять секунд с хвостом - всё время, пока чужой API думает над ответом.

Один такой запрос - незаметно. Пять параллельных - и пул кончился:

# Пять параллельных вызовов - и на 5 секунд бар закрыт
for i in $(seq 5); do curl -s -X POST \
  http://localhost:8080/api/crime/slow-tx & done

curl http://localhost:8080/api/crime/pool
# {"active":5,"idle":0,...}

Это самый массовый вид преступления в городе. Внешний HTTP-вызов внутри транзакции, отправка в Kafka внутри транзакции, генерация PDF внутри транзакции. Транзакция вышла покурить, а столик держит.

Как чинить

Правило одно: транзакция - ровно вокруг работы с базой. Всё медленное - снаружи. Удобнее всего границы видно с TransactionTemplate:

public Task createAndNotifyFixed(String title) {
    // Транзакция: открылась, сохранила, закоммитилась. Соединение в пуле.
    Task saved = transactionTemplate.execute(status ->
            taskRepository.save(new Task(title)));

    // Внешний вызов - БЕЗ транзакции и без соединения
    externalClient.notifyExternalSystem(saved);
    return saved;
}

Тот же эффект даёт вынос внешнего вызова в слушатель события с @TransactionalEventListener(AFTER_COMMIT) - этот приём мы разбирали в прошлом деле, в эпизоде про задачи-призраки. Бонус: если уведомление упало, транзакция уже зафиксирована, и данные не откатятся вместе с ней.

И страховка от долгих транзакций вообще:

// Транзакция дольше 5 секунд - исключение. Лучше поймать одного
// медленного, чем ждать, пока он передушит весь пул.
@Transactional(timeout = 5)
public Task create(String title) { ... }

Одна честная оговорка: этот таймаут проверяется на операциях с базой - на следующем запросе или на коммите. Поток, зависший в чужом HTTP внутри транзакции, он не разбудит: исключение прилетит, только когда метод очнётся и снова тронет базу. Так что от героя этого эпизода защищают таймауты самого HTTP-клиента (connect и read timeout), а timeout транзакции ловит другой класс медленных - долгие запросы к самой базе.

Серёга, когда услышал про этот эпизод, кивнул со знанием дела: «У нас однажды платёжный шлюз стал отвечать за тридцать секунд вместо двух. Сам по себе - неприятность. Но он был внутри транзакций, так что через минуту у нас лежало всё, включая эндпоинты, которые про платежи не слышали. Чужая медлительность становится твоим даунтаймом, если держишь её в транзакции».


Эпизод 4. Подозреваемый №3: аннотация с фальшивым алиби

Третий подозреваемый особенный. Он не вешает пул. Хуже - он врёт.

В четверг Даня показал мне код и побожился, что тот работает в транзакции:

@Service
public class SelfInvocationService {

    public void processBatch() {
        // ... подготовка ...
        this.saveWithTransaction();   // <- вот тут внимательно
    }

    @Transactional
    public void saveWithTransaction() {
        taskRepository.save(new Task("..."));
    }
}

— Аннотация стоит? Стоит. Значит, транзакция есть.

— Проверим, — сказал я. — В нашем деле алиби проверяют, а не слушают.

Проверка встроена в демо:

curl http://localhost:8080/api/crime/self-invocation
# {"call":"this.saveWithTransaction()","transactionActive":false,...}

false. Транзакции нет. Аннотация на месте, транзакции нет, и никакого предупреждения - ни в логе, ни при компиляции. Молчаливое преступление - а они в этом городе самые опасные.

Механика обмана

@Transactional - это не магия внутри метода. Это прокси. Spring оборачивает ваш бин в обёртку, и транзакцию открывает обёртка - до того, как вызов дойдёт до вашего кода:

снаружи:  caller -> [ПРОКСИ: открыть tx] -> ваш метод -> [ПРОКСИ: commit]
изнутри:  this.метод() -> ваш метод        (прокси не участвовал)

Вызов через this идёт напрямую, мимо обёртки. Аннотация не срабатывает - ей просто некому сработать. То же самое происходит с @Transactional на private-методах (прокси не может их переопределить) и с любыми self-invocation цепочками, включая REQUIRES_NEW, который «почему-то» не открывает новую транзакцию.

Последствия бывают двух сортов. Либо код без транзакции работает в автокоммите: метод упал на середине - половина изменений уже в базе, и откатить их нечем. Либо, наоборот, «короткая» транзакция REQUIRES_NEW не отделяется от родительской и держит соединение дольше задуманного. В обоих случаях виновника ищут неделями, потому что аннотация - вот же она, стоит.

Как чинить

Вызывать транзакционные методы через другой бин - прокси снова в деле:

@Service
public class SelfInvocationService {
    private final TransactionalWorker worker;   // отдельный бин

    public void processBatch() {
        worker.saveWithTransaction();   // через прокси -> транзакция есть
    }
}
curl http://localhost:8080/api/crime/self-invocation-fixed
# {"call":"worker.saveWithTransaction()","transactionActive":true,...}

А в демо-проекте живёт тест, который навсегда фиксирует эти показания - оба сценария, false и true. Прошлое дело научило нас: улику, которую нашли однажды, нужно превращать в тест, иначе найдёте её снова.

И до кучи вторая строчка из досье этого фигуранта: по умолчанию @Transactional откатывается только на unchecked-исключениях. throws Exception из недр метода коммитит транзакцию как ни в чём не бывало. Нужен откат на checked - пишите @Transactional(rollbackFor = Exception.class) явно.


Эпизод 5. Подозреваемый №4: дуэль

Дело четвёртого подозреваемого началось со строчки в логе, которую боятся все, кто её хоть раз видел:

org.postgresql.util.PSQLException: ERROR: deadlock detected

Дуэль. Две транзакции, два ствола, одна база.

Транзакция первая блокирует задачу №1 и тянется за задачей №2. Транзакция вторая уже держит задачу №2 и тянется за задачей №1. Каждая ждёт, пока другая отпустит. Другая не отпустит никогда. Патовая ситуация. Как два вежливых человека в дверях, только оба с SELECT FOR UPDATE.

// tx-1:                                // tx-2:
lock(task1);                            lock(task2);
// ...                                  // ...
lock(task2);  // ждёт tx-2              lock(task1);  // ждёт tx-1

PostgreSQL - секундант опытный. Через deadlock_timeout (по умолчанию одна секунда) он осматривает граф ожидания, находит цикл и стреляет в одного из дуэлянтов: транзакция откатывается с ошибкой 40P01. Второй выживает и коммитится.

В демо дуэль запускается по свистку:

curl -X POST "http://localhost:8080/api/crime/deadlock?idA=1&idB=2"
# {"tx-1":"выжил: заблокировал 1, затем 2",
#  "tx-2":"погиб: ERROR: deadlock detected"}

Под капотом - два потока, каждый в своей транзакции берёт SELECT ... FOR UPDATE (в JPA это @Lock(PESSIMISTIC_WRITE)) на те же две строки во встречном порядке, с паузой в полсекунды, чтобы дуэлянты успели встать в позицию.

Важно понимать: deadlock сам по себе пул не вешает - Postgres прибирает за дуэлянтами быстро. Но каждая дуэль - это секунда, на которую оба соединения выпали из оборота, плюс откат, плюс ретрай. Под нагрузкой дуэли идут сериями, и пул начинает работать на них, а не на пользователей. А если блокировки берутся без deadlock-детектора между разными системами (база + распределённый лок, например), дуэль становится вечной, и вот тогда соединения не вернутся.

Как чинить

Лекарство скучное до зевоты: блокировать строки всегда в одном порядке. Дуэль невозможна, если оба стреляют в одну сторону:

// Кто бы ни пришёл первым - блокировки берутся по возрастанию id.
long first = Math.min(idA, idB);
long second = Math.max(idA, idB);

taskRepository.findWithLockById(first);
taskRepository.findWithLockById(second);
curl -X POST "http://localhost:8080/api/crime/deadlock-fixed?idA=1&idB=2"
# {"tx-1":"выжил...", "tx-2":"выжил..."}  - оба целы

Второй участник просто подождал полсекунды у первой блокировки и прошёл следом. Очередь вместо перестрелки. Менее кинематографично, зато все живы.

Плюс два правила гигиены: держите транзакции с блокировками короткими (см. подозреваемого №2 - эти двое часто работают в паре) и оборачивайте участки с ожидаемыми конфликтами в ретрай с backoff. Deadlock - это не всегда баг, иногда это погода. Но погоду надо учитывать при выходе из дома.


Эпизод 6. Подозреваемый №5: второй столик

Пятый фигурант попал в дело по наводке. Помните, в эпизоде про фальшивое алиби мелькнул REQUIRES_NEW? Мы тогда починили self-invocation, и аудит заработал как задумано. Код после починки - образцовый:

@Service
public class TaskProcessingService {

    @Transactional
    public void process(Long taskId) {
        // ... работа с базой ...
        auditTrail.record("processed " + taskId);   // <- отдельный бин, всё честно
    }
}

@Service
public class AuditTrailService {

    // Аудит должен сохраниться, даже если основная транзакция откатится.
    // Хрестоматийный REQUIRES_NEW. Семантика - безупречная.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String event) {
        auditRepository.save(new AuditRecord(event));
    }
}

Вызов через отдельный бин - прокси работает. Семантика верная - запись в аудит переживёт откат основного процесса. Ревью такой код проходит с комплиментами. А теперь следите за руками.

REQUIRES_NEW не «переиспользует» ресурсы внешней транзакции. Он её подвешивает и открывает новую физическую транзакцию - с новым соединением из пула. Подвешенная транзакция своё соединение при этом не отдаёт: она не закончилась, ей ещё коммитить. Итого один запрос в пике держит два столика. Одна транзакция - работа, вторая - отчётность о проделанной работе. Отчётность, как обычно, обходится дороже работы.

Теперь арифметика бара. Пул - пять соединений. Пять параллельных запросов входят одновременно: каждый занимает по столику и просит второй. Свободных - ноль. Кто-то должен уйти, чтобы кто-то сел, но никто не уйдёт, пока не дождётся второго столика. Знакомая картина? Это дуэль из прошлого эпизода, только хуже: Postgres этой дуэли не видит. Граф ожидания живёт не в базе, а в пуле, и секунданта с секундомером там нет. Никто никого не пристрелит через секунду - все просто стоят до connection-timeout.

curl -X POST "http://localhost:8080/api/crime/requires-new?robbers=5"
# ...5 секунд тишины...
# {"tx-1":"погиб: TodoHikariPool - Connection is not available,
#          request timed out after 5006ms",
#  "tx-2":"погиб: ...", ... }   - все пятеро

Пока они ждут, /api/crime/pool показывает происходящее в прямом эфире: active = 5, pending = 5. Пять держат по столику, пять внутренних транзакций мёрзнут в очереди, и это одни и те же пять запросов. Пул задушил сам себя, база при этом не сделала ни одного тяжёлого запроса.

Коварство в том, что при слабой нагрузке этот код работает. Два-три параллельных запроса при пуле в пять соединений - всем хватает вторых столиков, тесты зелёные, демо на стейдже блестит. Преступление совершается только в час пик - в пятницу вечером, под распродажу, когда параллельных запросов становится столько же, сколько соединений. Идеальное алиби на каждый день, кроме одного.

Как чинить

Правило: не открывать REQUIRES_NEW под транзакцией, которая держит соединение. Матрёшку - разобрать в последовательность:

public void process(Long taskId) {
    // Транзакция №1: открылась, отработала, вернула соединение.
    transactionTemplate.executeWithoutResult(status -> {
        // ... работа с базой ...
    });

    // Транзакция №2: соединение берётся, когда первое уже свободно.
    auditTrail.record("processed " + taskId);
}
curl -X POST "http://localhost:8080/api/crime/requires-new-fixed?robbers=5"
# все пятеро выжили: в моменте каждому нужно ОДНО соединение

Нужен аудит и на случай отката? Пишите его из catch, когда основная транзакция уже завершилась и соединение вернула, - или через @TransactionalEventListener(AFTER_ROLLBACK). Семантика та же, вторые столики не нужны.

А если вложенность действительно неизбежна - у HikariCP есть формула минимального размера пула, при котором самоудушение невозможно: pool size = Tn × (Cm − 1) + 1, где Tn - число потоков, Cm - максимум соединений на один поток. Для нашего случая (Cm = 2) при пяти потоках это 6 соединений: хотя бы один второй столик всегда найдётся, очередь будет двигаться. Но это страховка от катастрофы, а не лицензия на матрёшки: каждое REQUIRES_NEW под открытой транзакцией удваивает аппетит запроса, и формула об этом честно предупреждает.

Серёга подвёл итог эпизода в своём стиле: «REQUIRES_NEW - приличный инструмент. Просто он как второй кредит: сам по себе не преступление, пока ты не берёшь его, чтобы обслуживать первый».


Эпизод 7. Дворецкий

К пятнице мы закрыли дела всех пятерых. Утечку заткнули, транзакции сузили, self-invocation вывели на чистую воду, дуэлянтов развели по одной дороге, матрёшку REQUIRES_NEW разобрали в последовательность. Пул задышал.

А в понедельник под нагрузкой pending снова пополз вверх.

Я сидел и смотрел на код, как на свидетеля, который уже трижды всё рассказал. Все транзакции короткие. Утечек нет. И тут Серёга, гостивший у нас со своим термосом, ткнул пальцем в лог запуска приложения:

spring.jpa.open-in-view is enabled by default. Therefore, database
queries may be performed during view rendering. Explicitly configure
spring.jpa.open-in-view to disable this warning

— Вот эту строчку, — сказал он, — все видели тысячу раз. И никто никогда не читал. Она была в логах ещё до того, как вы въехали в этот участок.

Open Session In View. Дворецкий. Его никто не нанимал - он просто был в доме с первого spring init. Как сотрудник, которого не брали на работу, но он уже согласовывает отпуск. Ключи от всех дверей у него, и именно поэтому его никто не вносил в список подозреваемых.

Что он делает

OSIV держит Hibernate-сессию открытой до конца HTTP-запроса, а не до конца транзакции. Задумано из вежливости: чтобы ленивые (LAZY) коллекции можно было догружать где угодно - в контроллере, при сериализации в JSON. Транзакция закрылась, а сессия жива, и любое касание LAZY-поля тихо идёт в базу за новым соединением.

Следствия:

  • Запросы к базе уходят из слоя, который вообще не должен знать о базе, - из сериализации ответа. N+1 в контроллере, добрый вечер.

  • Соединения берутся из пула в непредсказуемый момент - посреди рендеринга ответа, вне границ транзакции и вне вашего контроля. При N+1 это происходит десятки раз на один запрос.

  • Никаких исключений. Всё «работает». Медленно, жадно и незаметно - идеальный сообщник для всех предыдущих фигурантов.

Очная ставка

Выключаем дворецкого и смотрим, что он покрывал:

spring:
  jpa:
    open-in-view: false
curl http://localhost:8080/api/crime/osiv/1
# 500: LazyInitializationException:
#   failed to lazily initialize a collection of role Task.comments -
#   could not initialize proxy - no Session

Вот оно, признание. Контроллер отдавал entity с LAZY-коллекцией, транзакция закрывалась, а Jackson догружал комментарии через OSIV. С выключенным OSIV та же ошибка кричит в голос: «меня сериализуют вне транзакции!». Раньше она молчала и ходила в базу. Иногда отключение удобства - это включение правды.

Лекарство - загружать всё, что нужно ответу, внутри транзакции, и отдавать наружу DTO:

// Fetch join: комментарии приезжают тем же запросом, в той же транзакции
@Query("select distinct t from Task t left join fetch t.comments where t.id = :id")
Optional<Task> findWithCommentsById(@Param("id") Long id);
curl http://localhost:8080/api/crime/osiv-fixed/1
# 200, задача с комментариями, ноль ленивых сюрпризов

open-in-view: false - настройка, которую стоит ставить в новом проекте до первого коммита. В старом - аккуратнее: включив её под конец квартала, вы узнаете о каждом месте, где код надеялся на дворецкого. Узнаете через LazyInitializationException, в production, по одному. Лучше прогнать тесты и пройтись по эндпоинтам заранее.


Кодекс: правила бара «У Хикари»

Капитан Лёша потребовал выжимку на одну страницу. Вешайте рядом с чеклистом из прошлого дела.

Транзакции:

  • Транзакция - ровно вокруг работы с БД. HTTP-вызовы, Kafka, файлы, PDF - снаружи.

  • @Transactional(timeout = N) на всём, что пишет. Медленная транзакция должна умирать молодой. Но помните: он ловит долгий SQL, а не зависший HTTP - для того есть таймауты HTTP-клиента.

  • readOnly = true для чтения: дешевле для Hibernate и честнее для читающего.

  • @Transactional работает через прокси: не вызывайте транзакционные методы через this и не вешайте аннотацию на private.

  • Откат по умолчанию - только на unchecked. Нужен откат на checked - rollbackFor явно.

  • REQUIRES_NEW под открытой транзакцией - это два соединения на один запрос. Аудит и логи - последовательной транзакцией после основной, а не матрёшкой внутри.

Пул:

  • leak-detection-threshold включён всегда (30-60 секунд в production).

  • connection-timeout меньше таймаута вашего HTTP-клиента: пусть запрос честно упадёт, а не висит.

  • Размер пула - не «чем больше, тем лучше». Формула HikariCP: connections = cores * 2 + effective_spindle_count. Больше - это очередь не у пула, а внутри базы, где её хуже видно.

Блокировки:

  • Блокируем строки всегда в одном порядке (сортировка по id спасает от 90% дуэлей).

  • Транзакции с блокировками - самые короткие в доме.

  • Ретрай с backoff на deadlock-ошибки там, где конфликты ожидаемы.

Дворецкий:

  • spring.jpa.open-in-view: false в каждом новом проекте.

  • Наружу - DTO. Entity с LAZY-полями не пересекают границу транзакции.

  • Всё, что нужно ответу, грузится fetch join’ом или @EntityGraph внутри транзакции.

Сигнализация (Prometheus, правила в демо-проекте):

# Очередь у входа - предвестник
- alert: ConnectionPoolPending
  expr: hikaricp_connections_pending{pool="TodoHikariPool"} > 0
  for: 1m
  labels:
    severity: warning

# Отказы - уже инцидент
- alert: ConnectionTimeouts
  expr: increase(hikaricp_connections_timeout_total{pool="TodoHikariPool"}[5m]) > 0
  for: 1m
  labels:
    severity: critical

# Соединения держат подозрительно долго - ищите, кто
- alert: LongConnectionUsage
  expr: |
    rate(hikaricp_connections_usage_seconds_sum[5m])
    / rate(hikaricp_connections_usage_seconds_count[5m]) > 1
  for: 5m
  labels:
    severity: warning

Эпилог. Пять свободных столиков

Вечер пятницы, вторая неделя после дела. Я зашёл в дашборд, как заходят в знакомый бар: не за чем-то, а проверить, что всё на местах.

active: 1. idle: 4. pending: 0. Usage - четыре миллисекунды в среднем. За единственным занятым столиком кто-то быстро выпил свой SELECT и уже собирался уходить.

Даня зашёл попрощаться перед выходными и задал вопрос, который стоил всего расследования:

— Получается, соединений всегда хватает, пока их возвращают?

— Денег тоже, Даня. Но это уже другая фантастика.

Пул не бывает «слишком маленьким» - бывают соединения, которые держат дольше, чем работают. Из пяти наших фигурантов ни один не был экзотикой: ручной JDBC без close, внешний вызов под транзакцией, аннотация мимо прокси, блокировки во встречном порядке, аудит с REQUIRES_NEW под открытой транзакцией. И дворецкий, который прикрывал всех, потому что так настроено из коробки.

Проверьте свой участок. Начните с трёх команд: hikaricp_connections_pending в Prometheus, warning про open-in-view в логе запуска и grep по коду на dataSource.getConnection(). На это уйдёт десять минут. Воскресный инцидент тоже любит готовиться заранее.

Материалы дела, по эпизодам:

  • Осмотр: метрики HikariCP, thread dump, pg_stat_activity

  • Подозреваемый №1: connection leak и leak-detection-threshold

  • Подозреваемый №2: внешние вызовы внутри Transactional, TransactionTemplate, timeout

  • Подозреваемый №3: self-invocation, прокси, rollbackFor

  • Подозреваемый №4: deadlock и порядок блокировок

  • Подозреваемый №5: REQUIRES_NEW и второе соединение из пула

  • Дворецкий: Open Session In View и жизнь после него

  • Кодекс: чеклист и алерты


Дополнительные ресурсы

Документация:

Предыдущее дело:


Демо-проект: все вещественные доказательства - в репозитории. Запустите docker-compose up и воспроизводите преступления по одному: каждый эпизод - это эндпоинт /api/crime/*, рядом с каждым лежит его исправленная версия. Grafana с дашбордом пула и алерты Prometheus прилагаются. Улики никуда не денутся: counter, как известно, назад не отматывается.

Если дело оказалось полезным - ставьте плюс и подписывайтесь на телеграм-канал. Следствие продолжается. Production тоже.

Комментарии (1)


  1. Snaret
    15.07.2026 06:42

    Ничего нового, но почитать было интересно))