В любом микросервисе четкое управление взаимодействием с базой данных является ключевым фактором для поддержания производительности приложения и его надежности на должном уровне. Обычно мы натыкаемся на странные проблемы с подключением к базе данных во время тестирования производительности. Недавно мы обнаружили критическую проблему внутри слоя репозиториев в нашем микросервисном Spring приложении: неправильная обработка исключения приводила к неожиданным сбоям и нарушению работы сервиса во время тестирования производительности. Эта статья представляет собой анализ проблемы и рассказывает, как она была решена с помощью аннотации @Transactional

Микросервисные Spring приложения сильно зависят от стабильного и эффективного взаимодействия с базой данных, которое часто осуществляется через Java Persistence API (JPA). Для поддержания высокой производительности важно правильно управлять пулом соединений и предотвращать утечки соединений, чтобы взаимодействие с базой данных не снижало производительность приложения. 

История проблемы

Во время недавнего раунда тестирования производительности проявилась критическая проблема внутри одного из важных микросервисов, который предназначался для обмена сообщениями с клиентом. Этот сервис начал возвращать повторяющиеся ошибки типа Gateway time-out. Скрытая проблема находилась в слое репозитория в операциях с базой данных. 

Расследование показало, что ошибку выдавала хранимая процедура. Ошибка вызывалась невалидным параметром, передаваемым процедуре, что приводило к исключению на бизнес логике в хранимой процедуре. Слой репозитория не мог эффективно справляться с этим исключением; он передавал его наверх. Ниже приводится исходный код вызова процедуры: 

public long createInboxMessage(String notifCode, String acctId, String userId, String s3KeyName,
                               List<Notif> notifList, String attributes, String notifTitle,
                               String notifSubject, String notifPreviewText, String contentType, 
                               boolean doNotDelete, boolean isLetter, String groupId) throws EDeliveryException {

    try {
        StoredProcedureQuery query = entityManager.createStoredProcedureQuery("p_create_notification");
        DbUtility.setParameter(query, "v_notif_code", notifCode); 
        DbUtility.setParameter(query, "v_user_uuid", userId); 
        DbUtility.setNullParameter(query, "v_user_id", Integer.class); 
        DbUtility.setParameter(query, "v_acct_id", acctId); 
        DbUtility.setParameter(query, "v_message_url", s3KeyName); 
        DbUtility.setParameter(query, "v_ecomm_attributes", attributes); 
        DbUtility.setParameter(query, "v_notif_title", notifTitle); 
        DbUtility.setParameter(query, "v_notif_subject", notifSubject); 
        DbUtility.setParameter(query, "v_notif_preview_text", notifPreviewText); 
        DbUtility.setParameter(query, "v_content_type", contentType); 
        DbUtility.setParameter(query, "v_do_not_delete", doNotDelete); 
        DbUtility.setParameter(query, "v_hard_copy_comm", isLetter); 
        DbUtility.setParameter(query, "v_group_id", groupId); 
        DbUtility.setOutParameter(query, "v_notif_id", BigInteger.class); 

        query.execute(); 
        BigInteger notifId = (BigInteger) query.getOutputParameterValue("v_notif_id"); 
        return notifId.longValue();
    } catch (PersistenceException ex) { 
        logger.error("DbRepository::createInboxMessage - Error creating notification", ex); 
        throw new EDeliveryException(ex.getMessage(), ex); 
    } 
} 

Анализ проблемы

Как иллюстрирует наш сценарий, когда процедура сталкивалась с ошибкой, исключение передавалось наверх из слоя репозитория в слой сервиса и, в конце концов, на контроллер. При этой передаче возникали проблемы, заставляющие API отвечать HTTP статусом, отличным от 200 — чаще всего 500 или 400. После нескольких таких процедур контейнер сервиса достигал точки, после которой он уже не мог справляться с входящими запросами, что и порождало ошибку 502 Gateway Timeout. Это критическое состояние получало отражение в наших системах мониторинга, при этом логи Kibana отображали проблему следующим образом:  

HikariPool-1 - Connection is not available, request timed out after 30000ms.

Проблема была в некорректной обработке исключения, когда исключение передавалось наверх через все слои системы без правильной обработки. Это не позволяло высвобождать соединения с базой данных, возвращая их в пул соединений, и таким образом доступный запас соединений истощался. В результате, после полного истощения запаса соединений контейнер не мог обрабатывать новые запросы, что и вызывало ошибку, отображенную в логах Kibana и в HTTP ответе, отличном от 200.  

Решение

В качестве одного из решений, мы могли бы корректно обработать исключение и не пересылать его выше, позволяя JPA и Spring высвободить соединения и вернуть их в пул. Альтернативное решение — использовать аннотацию @Transactional на методе. Ниже приведен тот же метод с аннотацией: 

@Transactional
public long createInboxMessage(String notifCode, String acctId, String userId, String s3KeyName,
                               List<Notif> notifList, String attributes, String notifTitle,
                               String notifSubject, String notifPreviewText, String contentType, boolean doNotDelete, boolean isLetter,
                               String groupId) throws EDeliveryException {
	………
}

Реализация метода, приведенная ниже, демонстрирует подход к обработке исключений, не позволяющий исключению переходить выше по стэку путем его поимки и отображения в логе внутри самого метода: 

public long createInboxMessage(String notifCode, String acctId, String userId, String s3KeyName,
                               List<Notif> notifList, String attributes, String notifTitle,
                               String notifSubject, String notifPreviewText, String contentType, boolean doNotDelete, boolean isLetter,
                               String loanGroupId) {
    try {
        .......
        query.execute();
    	BigInteger notifId = (BigInteger) query.getOutputParameterValue("v_notif_id");
    	return notifId.longValue();      
    } catch (PersistenceException ex) {
        logger.error("DbRepository::createInboxMessage - Error creating notification", ex);
    }
    return -1;
}

Используем @Transactional 

Аннотация @Transactional во фреймворке Spring управляет границами транзакции. Она начинает транзакцию, когда метод, помеченный аннотацией, стартует, затем подтверждает или откатывает ее после окончания работы метода. Когда метод выбрасывает исключение, аннотация @Transactional обеспечивает откат транзакции, что помогает правильным образом высвободить соединение и вернуть его в пул.  

Без @Transactional 

Когда метод репозитория, вызывающий сохраненную процедуру, не помечен аннотацией @Transactional, Spring не управляет границами транзакции внутри этого метода. Управление транзакцией должно программироваться вручную для случая, когда сохраненная процедура выбрасывает исключение. Если не управлять этим должным образом, может создаться ситуация, когда соединение с базой данных не закрывается и не возвращается в пул соединений, приводя к утечке. 

Оптимальный подход 

  • Всегда используйте @Transactional, когда операции в методе должны выполняться в пределах одной транзакции. Это особенно важно для операций, имеющих отношение к хранимым процедурам, которые могут модифицировать состояние базы данных. 

  • Обеспечивайте обработку исключений внутри метода таким образом, чтобы эта обработка включала правильный откат транзакции и закрытие соединений с базой данных, особенно если не используете @Transactional

Вывод 

Эффективное управление транзакциями исключительно важно для поддержания нормального состояния и высокой производительности микросервисных приложений на Spring, использующих JPA. Аннотация @Transactional помогает избежать утечек соединений и гарантирует, что работа с базой данных не ухудшит производительность или стабильность системы. Следование этим правилам улучшает надежность и эффективность наших Spring микросервисов, предоставляя стабильные и быстрые по времени ответа сервисы приложению-потребителю или конечным пользователям.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь!

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


  1. SimSonic
    09.07.2024 14:23

    А я думал, что в статье напишите "Вешайте TxMode.NEVER на все клиенты к внешним системам" :)

    Тоже своего рода защита пула коннектов от исчерпания из-за медленных зависимостей...


  1. aleksandy
    09.07.2024 14:23
    +2

    Альтернативное решение — использовать аннотацию @Transactional на методе.

    За такое надо бить по рукам железной линейкой. Можно даже ребром. Никогда, никогда репозиторный метод не должен заниматься транзакциями.

    Равно как и сервисный слой бизнес-логики. Транзакции должны рулиться в инфраструктурных фасадах, иначе будет неподдерживаемая лапша кода.

    Самое печальное - транзакции на репозиториях сделаны из коробки в spring-data, но, вместо логичного @Transactional(propagation = MANDATORY), там простой @Transactional.


    1. headliner1985
      09.07.2024 14:23

      Так а что делать если не 3 слоя а два, без фасадов?


      1. aleksandy
        09.07.2024 14:23

        Очевидно, что нужно их добавить, нет?

        Тут ещё дело такое: то, что вы фасады не создаёте, не означает, что их нет. Просто они генерируются тем же спрингом: бизнес-логика - сервис, написанный руками и проаннотированный всякими @Transactional и иже с ним, инфраструктурный фасад - реальный класс бина во время исполнения кода, после наворачивания всей магии АОП.

        Если сервисы бизнес-логики не пересекаются и не переиспользуются друг внутри друга, то аннотировать их @Transactional допустимо. Но надо точно понимать, что происходит во время исполнения кода.

        Грубо говоря, правило такое: в стеке вызовов @Transactional-метод должен гарантированно встречаться лишь единожды. При несоблюдении сего простого правила, как минимум, появляется код, который лишь отапливает помещение серверной, проверяя перед вызовом методов, а открыта ли транзакция.

        А понимание этого приходит сразу после того, как отключишь АОП и попробуешь руками, например, на TransactionTemplate написать всё то, что будет сгенерировано в прокси-классах.


  1. grisha9
    09.07.2024 14:23
    +3

    Опять вредные советы подъехали из разряда: ставьте везде аннотацию Transactional и будет вам хорошо.

    По моему вы недостаточно разобрались в API для вызова процедур с которым работаете.

    Даже если посмотреть строгу по коду, не читая javadoc, то уже видно что:

    • сначала вы выполняете запрос query.execute();

    • далее извлекаете результат query.getOutputParameterValue();

    После этого становиться очевидно, что метод execute создает внутри какой-то стейт(курсор) из которого уже далее извлекаются результаты. Поэтому напрашивается вопрос, а закрывать этот ресурс кто будет?) Вы не закрыли ресурс, получили проблему... извините но ни обработка ошибок, ни Transactional тут не причем.

    После чтения javadoc все становится более понятным. Метод execute по завершению возвращает флаг есть ли результат или нет. А далее уже идет работа с результатом/курсором - поэтому коннект и удерживается. Но все же справедливости ради: данное API весьма запутанное. Гугление по "storedprocedurequery transaction", выдало вот такой вот результат - совсем не очевидно как же тут закрыть соединение средствами API.
    На одном из прошлых мест работ была похожая ситуация. Нужно было импортировать в PostgreSQL csv файлы. В качестве тулзы для работы с БД использовали Spring JdbcTemplate. Разраб что делал задачу, нашел что в драйвере JDBC PostreSQL есть соответсвующий класс CopyManager, который на вход принимает коннект. Найдено - сделано. Заинжектил DataSource, получил из него коннект, сделал свое дело и пошел дальше) То что коннект надо освобождать он не подумал, в итоге получили аналогичную проблему. Хотя надо было просто воспользоваться соответствующим методом из JdbcTemplate. Но соглашусь что у вас случай более запутанный, т.к. есть открытое issue по поводу закрытия ресурсов - в интерфейсе специальных методов для этого нет.

    Мораль все же в том - что надо лучше понимать что делаете, читать javadoc и гуглить проблемы) А вешать транзакции где попало, это не выход, хотя в Вашем случае это действительно решает проблему, т.к. Spring сам закроет транзакцию и как следствие курсор с данными от хранимки. Особенно так вредно делать с одиночными запросами к бд. Писал по этому поводу статью - бесплатно в этой жизни ничего не бывает и за все приходиться чем-то платить.


  1. Slobodator
    09.07.2024 14:23

    А, кстати, я правильно предполагаю, что entityManager.createStoredProcedureQuery вне транзакции не работает? Т.е. границы транзакции всё таки были где-то "выше"?

    Но транзакция не закрывалась, потому что наружу выходило checked exception EDeliveryException?

    И проблема решилась не столько добавлением @Transactional к createInboxMessage методу — это какбэ принципиально не верно — задавать границы транзакции в слое репозитория, а потому что перестали checked exception бросать

    } catch (PersistenceException ex) {
        logger.error("DbRepository::createInboxMessage - Error creating notification", ex);
    }

    ...?

    Глушить эксепшены тоже, кстати, нехорошая практика.

    В общем, вопросы есть к изложенному в статье.


    1. grisha9
      09.07.2024 14:23

      Ответ есть выше - https://habr.com/ru/companies/spring_aio/articles/827642/comments/#comment_27025050

      Если кратко то StoredProcedureQuery имеет стейт, который надо "закрывать". Но апи для работы с ним очень кривое и там нет явно метода для этого. На это тему есть открытое issue + workaround с явным кастом к ProcedureCall и далее ProcedureCall#getOutputs().release()

      Поддерживаю, статья не граммотная и не вскрыта суть проблемы.


  1. OniksoiD
    09.07.2024 14:23

    Не понял каким образом здесь помогла явная аннотация. Транзакция открывалась же где-то и без неё, скоро всего ниже по стеку, и там же и закрывалась. Теперь старт транзакции был перенесён выше


    1. spring_aio Автор
      09.07.2024 14:23

      Действительно, это не раскрыто в оригинальной статье. Можно предположить, что там стоял open-in-view=true, что вообще скорее антипаттерн


  1. Neuronix
    09.07.2024 14:23
    +1

    Битриксом пахнуло... Я устал считать количество параметров у метода.


  1. Hoota
    09.07.2024 14:23

    Довольно вредный совет, нужно быть очень осторожным, такая транзакция может не открыться в случае если метод помеченный такой аннотацией вызывается из другого метода этого же класса, то есть через this – такой вызов не проходит через Proxy который создаётся спрингом на старте приложения для бина этого класса, и соответственно транзакция не открывается.