В любом микросервисе четкое управление взаимодействием с базой данных является ключевым фактором для поддержания производительности приложения и его надежности на должном уровне. Обычно мы натыкаемся на странные проблемы с подключением к базе данных во время тестирования производительности. Недавно мы обнаружили критическую проблему внутри слоя репозиториев в нашем микросервисном 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)
aleksandy
09.07.2024 14:23+2Альтернативное решение — использовать аннотацию @Transactional на методе.
За такое надо бить по рукам железной линейкой. Можно даже ребром. Никогда, никогда репозиторный метод не должен заниматься транзакциями.
Равно как и сервисный слой бизнес-логики. Транзакции должны рулиться в инфраструктурных фасадах, иначе будет неподдерживаемая лапша кода.
Самое печальное - транзакции на репозиториях сделаны из коробки в spring-data, но, вместо логичного
@Transactional(propagation = MANDATORY)
, там простой@Transactional
.headliner1985
09.07.2024 14:23Так а что делать если не 3 слоя а два, без фасадов?
aleksandy
09.07.2024 14:23Очевидно, что нужно их добавить, нет?
Тут ещё дело такое: то, что вы фасады не создаёте, не означает, что их нет. Просто они генерируются тем же спрингом: бизнес-логика - сервис, написанный руками и проаннотированный всякими
@Transactional
и иже с ним, инфраструктурный фасад - реальный класс бина во время исполнения кода, после наворачивания всей магии АОП.Если сервисы бизнес-логики не пересекаются и не переиспользуются друг внутри друга, то аннотировать их
@Transactional
допустимо. Но надо точно понимать, что происходит во время исполнения кода.Грубо говоря, правило такое: в стеке вызовов
@Transactional
-метод должен гарантированно встречаться лишь единожды. При несоблюдении сего простого правила, как минимум, появляется код, который лишь отапливает помещение серверной, проверяя перед вызовом методов, а открыта ли транзакция.А понимание этого приходит сразу после того, как отключишь АОП и попробуешь руками, например, на
TransactionTemplate
написать всё то, что будет сгенерировано в прокси-классах.
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 сам закроет транзакцию и как следствие курсор с данными от хранимки. Особенно так вредно делать с одиночными запросами к бд. Писал по этому поводу статью - бесплатно в этой жизни ничего не бывает и за все приходиться чем-то платить.
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); }
...?
Глушить эксепшены тоже, кстати, нехорошая практика.
В общем, вопросы есть к изложенному в статье.
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()
Поддерживаю, статья не граммотная и не вскрыта суть проблемы.
OniksoiD
09.07.2024 14:23Не понял каким образом здесь помогла явная аннотация. Транзакция открывалась же где-то и без неё, скоро всего ниже по стеку, и там же и закрывалась. Теперь старт транзакции был перенесён выше
spring_aio Автор
09.07.2024 14:23Действительно, это не раскрыто в оригинальной статье. Можно предположить, что там стоял open-in-view=true, что вообще скорее антипаттерн
Hoota
09.07.2024 14:23Довольно вредный совет, нужно быть очень осторожным, такая транзакция может не открыться в случае если метод помеченный такой аннотацией вызывается из другого метода этого же класса, то есть через this – такой вызов не проходит через Proxy который создаётся спрингом на старте приложения для бина этого класса, и соответственно транзакция не открывается.
SimSonic
А я думал, что в статье напишите "Вешайте TxMode.NEVER на все клиенты к внешним системам" :)
Тоже своего рода защита пула коннектов от исчерпания из-за медленных зависимостей...