В уже уходящем 2024 году мне удалось побывать на конференции JPoint, которая проходила в апреле. В числе прочего там активно обсуждалась тема обновления проектов на Spring Boot 3. Однако из тех, кого мне удалось послушать, и с кем пообщаться, ни у кого не было реального опыта такого обновления. Опасения в первую очередь были связаны с Hibernate 6, который сильно изменился по сравнению с предыдущей пятой версией.

Как я уже позже выяснил на собственном опыте, опасались не зря. Именно из-за изменений в поведении Hibernate мы получили аварию на проде: наша база начала грузить CPU под 100%. Это была самая серьёзная, но далеко не единственная проблема, с которой пришлось столкнуться. Далее опишу в деталях, что, как делали и какие проблемы поймали.

Общий контекст

Что именно мы обновляли:

  • JVM с 11 до 17;

  • Kotlin с 1.6.21 до 1.9.24;

  • Spring Boot - с 2.7.12 до 3.3.1;

  • Все остальные зависимости, которые тянет за собой обновление Spring Boot.

Это было самое большое обновление бэкэнда в истории нашего проекта, которому уже исполнилось пять лет. Сам проект немаленький: содержит около 200 тысяч строк кода. При обновлении изменениями было затронуто примерно 2000 файлов. Весь процесс занял около трёх месяцев.

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

  1. Берём сервис, обновляем зависимости, добиваемся того, чтобы все автотесты работали.

  2. Отдаём QA инженерам, которые проверяют сервис в ручном режиме и запускают на него свои функциональные тесты.

  3. Обновляем сервис на проде, ждём одну-две недели, в течение которых отлавливаем и чиним возникающие проблемы.

  4. Берём очередной сервис и начинаем процесс заново с первого пункта.

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

@EntityGraph меняет поведение

В нашем проекте мы активно используем hibernate. Изменений по нему было очень много, однако, все проблемы с ним были найдены на этапе тестирования. Кроме одной: той, которая влияет на производительность.

У нас есть функция, которая помечена аннотацией @EntityGraph:

@EntityGraph(
	attributePaths = [
		“prop1”,
		“prop2”,
		“prop3”
	]
)
fun findAllByStatus(status: AdZoneStatus): List<AdZone>

Эта функция выбирает из базы список объектов AdZone с заданным статусом. Проблема с этой функцией в том, что внутри объекта AdZone находится большое количество полей, которые обеспечивают связь с другими зависимыми объектами (через стандартные аннотации @OneToMany, @OneToOne, @ManyToOne).

Если вызывать эту функцию без аннотации @EntityGraph, то все связанные таблицы тоже начинают загружаться из базы, что приводит к огромному количеству select запросов. Чтобы этого не происходило, а загружались только нужные поля, можно, указав аннотацию, перечислить все необходимые поля в списке. Тогда загружаться из базы будет только выбранный список полей, и не будет создаваться лишняя нагрузка на систему.

Так вот, дело в том, что после обновления hibernate эта аннотация перестала работать. Функция начала делать в базу сотни тысяч запросов вместо одного. Если погуглить эту проблему, то можно найти, что люди с таким периодически сталкиваются. Однако как решить именно наш случай, мне в интернете найти не удалось.

После нескольких часов возни в итоге удалось выяснить, что проблема возникает, если в коде используется несколько @Entity на одну и ту же таблицу в базе. Например у нас кроме класса AdZone был также класс AdZoneView, который работал с той же таблицей ad_zones. Проблема ушла после того, как я объединил эти два класса в один общий.

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

  1. Ситуации бывают разные, вполне возможно у авторов этого кода были веские причины сделать именно так.

  2. Факт в том, что эта функция работала хорошо до обновления и перестала работать после него. И связано это именно с изменениями, которые произошли в hibernate.

Эта проблема привела к тому, что на нашем проде postgres загрузил CPU своих серверов до 100%. Это продолжалось 10-15 минут, пока мы не нашли в чём причина и не предприняли действий по ее купированию.

HighLoad при выдаче метрик

Для сбора и анализа метрик на нашем проекте используется Prometheus. Каждый сервис по HTTP запросу выдаёт свои метрики через Spring Actuator. Периодичность сбора метрик в системе равна одной минуте. До обновления метрики выдавались примерно за 50 миллисекунд, после обновления это время увеличилось на два порядка и стало равно примерно пяти секундам.

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

Тем не менее решено было разобраться в проблеме. С помощью профайлера обнаружили узкое место: оно было в библиотеке https://github.com/prometheus/client_java. Проблема была в том, что в этой библиотеке время сбора метрик имело квадратичную зависимость от количества метрик. Другими словами если, например, количество метрик увеличивается в 5 раз, то время сбора метрик увеличивается в 25 раз. В нашей системе очень много метрик, поэтому мы получили замедление работы этой функции в 100 раз.

Я сделал PR, который исправляет эту проблему: https://github.com/prometheus/client_java/pull/985. Фикс уже смержен и вошел в релиз 1.3.2.

Утечки коннектов в Undertow

В наших самых нагруженных сервисах, которые принимают трафик по HTTP, мы используем Undertow, т.к. для нас он работает в разы быстрее, чем Tomcat или Jetty. В этом веб сервере есть баг, который проявляется не всегда и не везде, но у нас присутствует. Это утечки коннектов, вызванные гонкой потоков в классе HttpServerExchange. Проблема эта очень старая, на неё заведено пара тикетов:

Почти год назад я делал PR с фиксом этой проблемы: https://github.com/undertow-io/undertow/pull/1560. На нашем проде работает форк с этим фиксом. Однако PR так и не был смёржен на момент обновления Spring Boot. При этом сам Spring обновил у себя Undertow с версии 2.2 до версии 2.3 без поддержки обратной совместимости. Таким образом пришлось форкать уже Undertow 2.3, т.к. утечка коннектов по-прежнему происходила на нашем проде.

К счастью недавно другой PR с аналогичным фиксом таки был смержен здесь: https://github.com/undertow-io/undertow/pull/1661. Есть надежда, что при следующем обновлении Spring Boot уже не придётся сталкиваться с этой проблемой.

Судьба Undertow вызывает у меня некоторое беспокойство: главный контрибьютор Stuart Douglas перестал коммитить в него ещё в 2020 году. Сейчас проект поддерживается другими людьми. Сайт веб сервера судя по всему заброшен: https://undertow.io/. Последний релиз, который там упоминается, это 2.1. Запись о нём была сделана всё в том же 2020 году.

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

Liquibase устраняет технический долг

После обновления упали все тесты, в которых использовались миграции liquibase. Причин было сразу несколько и одна из них в том, что в файлах с миграциями мы используем команду afterColumn. На нашем проекте используется postgres, в котором это не поддерживается, поэтому старые версии liquibase просто игнорировали эту команду.

Новая версия liquibase перестала это игнорировать и начала падать с ошибками во время миграций: https://github.com/liquibase/liquibase/pull/2943. На первый взгляд чинится это легко: просто убираем команду afterColumn из changelog’а, которая раньше и так ничего не делала, и всё, тесты начинают работать.

Но здесь не всё так просто. Если вы меняете changelog, то меняется его чексумма. Если liquibase при выполнении миграций увидит, что у одного из старых changelog’ов чексумма отличается от той, что записана в базе, то он упадёт с ошибкой. Тут самое коварство в том, что автотесты у вас работают, т.к. они запускают миграции с нуля. А на проде миграции упадут, т.к. там ранее уже выполнялись эти же миграции.

Решить проблему можно, если указать в старом changelog’е параметр validCheckSum, в котором следует выставить старую чексумму.

Проблема с библиотекой jtwig

На нашем проекте мы иногда отправляем пользователям email сообщения, которые создаём с помощью библиотеки шаблонизатора jtwig. Проблема в том, что эта библиотека умерла. Последний релиз был в 2018 году, а сайт уже недоступен: http://www.jtwig.org/

На Java 11 она ещё работала, однако при обновлении Java до версии 17 работать перестала. Менять её на какую-то другую библиотеку нам довольно трудозатратно, т.к. уже накопилось много немаленьких шаблонов, которые пришлось бы переписывать. К счастью нашёлся workaround: https://github.com/gasparbarancelli/spring-native-query/issues/46. Если указать JVM параметр --add-opens java.base/java.lang=ALL-UNNAMED, то она снова начинает работать.

Заключение

По моему опыту обновление бэкэнда - это всегда долго, сложно и сопряжено с большим количеством проблем, в том числе иногда и для бизнеса (в худшем случае). Java 17 и Spring Boot 3 вышли в релиз уже довольно давно и с тех пор, уверен, было исправлено большое количество других проблем, с которыми я не сталкивался. Поэтому, как правило, лучше выжидать не менее года, а лучше и более перед тем, как обновлять прод.

Люди, мало знакомые с бэкэнд разработкой, могут задаться вопросом: а зачем вообще обновляться? Ведь в целом стабильность прода обычно куда важнее, чем какие-то новые фишки фреймворка или языка программирования? Да, всё верно, важнее. Но на стабильность прода также в значительной степени влияют уязвимости, которые люди постоянно находят во всех без исключения библиотеках.

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

Автор: Андрей Буров, Максилект.

P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на нашу страницу в VK или на Telegram-канал, чтобы узнавать обо всех публикациях и других новостях компании Maxilect.

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


  1. IvanVakhrushev
    16.12.2024 14:17

    Вы же не руками обновляли сервисы? OpenRewrite же?


    1. burov4j
      16.12.2024 14:17

      Все обновления выполнялись вручную


  1. panzerfaust
    16.12.2024 14:17

    Зачем вы оправдываетесь в конце? Вы как профессионалы сами решаете, когда, как и зачем апгрейдить ПО. Кому интересна аргументация - пусть релиз ноутсы читает.

    Если долго не обновлять бэкэнд, то уязвимости копятся, их количество начинает исчисляться сотнями

    В экосистеме джавы сегодня много делается не столько ради устранения CVE, сколько ради оптимизации. Java 8 и грядущая Java 24 - разные вещи в плане перфоманса. То же самое хибер сегодня и хибер времен 8 джавы.


    1. burov4j
      16.12.2024 14:17

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

      Что вы получаете взамен? Если улучшение перформанса, то вам необходимо понимать, сколько именно. Плюс пять процентов? Десять? Или вообще минус? Вам следует это посчитать и понять, стоит ли оно того. Может дешевле просто добавить сервер в кластер?

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


      1. panzerfaust
        16.12.2024 14:17

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

        С точки зрения бизнеса любая инженерная деятельность это какая-то мутная возня, которая стоит денег и иногда приводит к проблемам на проде. Система может навернуться как после "ненужного" апгрейда зависимости, так и после "нужного" внедрения фичи. После фичи даже гораздо вероятнее все распердолить. С т.з. пресловутого бизнеса - никакой разницы.

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


      1. Acvilon
        16.12.2024 14:17

        Глазами бизнеса как раз таки все понятно. Приходит клиент, запрашивает аудит. Смотрит результаты и говорит, что он не будет заключать договор по причине того, что ваш продукт дырявый и с кучей уязвимостей.


  1. bex2014
    16.12.2024 14:17

    есть же netty


  1. jdev
    16.12.2024 14:17

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

    Я в прошлом году переводил проект меньше раз в 7 (~300 Котлин файлов), но порядка на 2-3 быстрее:

    Для перехода я:

    1. очевидным образом, обновил версии

    2. заменой по проекту поправил javax.* -> jakarta.*

    3. Поменял com.github.tomakehurst:wiremock-jre8:2.35.0 -> com.github.tomakehurst:wiremock-jre8-standalone:2.35.0. Без этого были проблемы со спекой сервлетов, что ли

    4. В конфиге Spring Security заменой по файлу поправил antMatchers -> requestMatchers

    5. Руками поудалял ConstructorBinding

    6. Больше всего времени ушло на то, чтобы реанимировать вытягивание доков к эндпоинтам в сваггере из котлин доков. Для этого пришлось руками добавить зависимость runtimeOnly("com.github.therapi:therapi-runtime-javadoc:0.15.0"), а допетрить до того, что этот джарник пропал из зависимостей (без ошибок) пришлось самостоятельно

    7. всё. Все 100+ тестов (преимущественно пользовательских/внешних/функциональных/е2е) прошли, приложение благополучно задеплоилось на тестовый стенд. И потом обошлось без факапов в проде

    Всё это я сделал часа за два грустным вечером 5-ого января:)

    Возможно секрет в том, что проект у меня был на Spring Data JDBC:)


    1. novoselov
      16.12.2024 14:17

      А теперь представьте проект на 3+ млн. строк кода и 100+ разработчиков которые за 15+ лет несколько раз поменялись и трижды переписали компоненты с разной степенью успеха и с соответствущими технологическими наслоениями.


      1. jdev
        16.12.2024 14:17

        К такому проекту я и на пушечный выстрел не подойду:)

        Но если проект на 3M строк и 100 разработчиков попилен на 30 сервисов по 100К кода и покрыт качественными тестами - каждый сервис мигрируется также в пределах рабочего дня. Тот же проект, когда он был уже на 50К строк Котлин кода (и я его уже этом размере попилил на два сервиса) на минорные версии обновлялся за минуты.


  1. seregamorph
    16.12.2024 14:17

    Перешли сразу с 2.7 на 3.3? Если так, то почему решили пропустить 3.0?


    1. burov4j
      16.12.2024 14:17

      Да, перешли сразу. Не видел смысла делать обновление частями. Обычно мы так делаем только при обновлении БД.