С посылом статьи на 100% согласен, к оформлению доказательств вопросы...
КОД каждого участника в студию! А так субъективному оценочному суждению привык не доверять, уж извините.
И мидла незаслуженно унизили) Ему задачки закрывать надо, быстро и более менее качественно. Ну нет у него 2-3 часа на неспешные раздумия, на таких как он IT держится.
И по соотношениям время исполнения/качество/цена работы в час он явно будет впереди.
А этому в принципе не верю (Сеньор с нейросетью 10 минут Все преимущества, что и без нейросетей 9/10 Все преимущества, что и без нейросетей). Нейросеть то та же. А значит выдаст тот же примитив. Ну и если сеньор за 9 минут 30 секунд реализовал все как боженька, то я Жанна Дарт Вейдер))))
Но вы почему-то делаете из сеньора какого-то зазнайку. Он по вашему постоянно съезжает с темы, начинает погружаться туда, куда не просили и действительно, как написано выше в комментах, забалтывать вопрос.
И не смотря на то что в статье действительно много полезных фактов в целом она не несет какой-то ценности для любого грейда кроме понимания - "Если сеньор такой - то я не сеньор"
Спасибо за код. Если это действительно из собесов, то тут поле не паханное))
Каюсь, сначала нашел не то что вы указали)
Но предлагаю в список добавить и эти (сори не читал комментарии может уже что-то есть):
Отсутствие версионирования (/v1/payments) - API без версии ломает обратную совместимость при любом изменении. Клиенты, завязанные на этой версии, перестанут работать. Вообще показывает способность к расширению
ResponseEntity<?> сырой дженерик теряет информацию о типе тела ответа. В Swagger/OpenAPI это превратится в object, клиенты не смогут сгенерировать типизированные модели
@RequestBody Map<String, Object> теряется контракт API. Нет @Valid, нет документации полей, нет контроля типов. Любое изменение структуры ломается в рантайме ClassCastException
new Date() java.util.Date - мутабельный, устаревший класс, не рекомендуется к использованию хотя и допустимф
p.setStatus("PENDING") строковый литерал для статуса - это нонсенс. Опечатка ("PENING") не будет поймана компилятором, думаю выводы сделаете сами)
!"OK".equals(r) Не совсем плохо но лучше - !Objects.equals("OK", r), более явно проверяет что r != null
Вызов kafka.sendбез обработки результата метод асинхронный, но исключения теряются. Нужно добавить callback или использовать синхронную отправку.
RuntimeException - слишком общее исключение. Оно не говорит вызывающему коду (и глобальному обработчику) о том, что именно пошло не так. Лучше - кастомную
Далее более архитектурная проблема:
@Transactional на методе контроллера и отсутствие сервисного слоя Когда вы ставите @Transactional на контроллер, контроллер берёт на себя ответственность за управление транзакциями - это нарушает принцип SR Что ждет с таким подходом: - нормально не протестировать - придется кучу зависимостей тянуть и все либо мокать либо подымать - невозможно переиспользовать логику, для написания похожей обработки - новая ручка - расширение приведет только к росту проблем
Вы утверждаете, что проблема сводится к двум спискам которые не влезают. Я показал, что даже когда список один, GC начинает деградировать задолго до OOM.
Разве это противоречит вашему тезису?
Нет, оно его дополняет. Это два уровня анализа одной проблемы - вы говорите о причине (два списка), я - о процессе (как именно это убивает JVM).
Оба наблюдения верны, и оба есть в статье.
Я учту все рассмотренные нюансы и сделаю следующий эксперимент чище
Я был бы признателен, если бы вы читали статью и результаты тестов внимательнее, прежде чем обвинять меня в заблуждениях.
Это реально сэкономило бы всем время.
Вы утверждаете, что причина проблемы в том, что даже один список из 1.5 млн записей не помещается в 512 МБ кучи. Это было бы убедительным аргументом, если бы не один маленький нюанс.
На тех же 512 МБ и с теми же 1.5 млн записей императивный стиль успешно отрабатывает и завершается без ошибок.
Данные из сводной таблицы, которую я привел в статье:
Если проблема исключительно в размере исходных данных, как вы предполагаете, и один список еле помещается в память, то императивный стиль тоже должен был упасть.
Однако он выживает, а функциональный нет. Это прямое доказательство того, что корень проблемы не в размере списка, а в разнице архитектуры стилей - жизненном цикле объектов, накоплении долгоживущих объектов и в неспособности GC своевременно очищать память.
Именно эта разница в поведении и является предметом исследования.
И именно для её демонстрации и предназначен синтетический пример. Я не даю вредных советов и нигде не утверждаю, что нагружать единственный хендлер миллионами записей это хорошая инженерная практика.
Цель статьи, которую вы, похоже, упустили это показать на изолированном примере саму механику проблемы.
Хотелось бы чтобы вы финально зафиксировали: предложенные для примера код и условия является синтетическими отражениями реальных, к сожалению периодически встречаемых, которые в высоконашруженных сервисах применять и писать НЕ РЕКОМЕНДУЕТСЯ.
Рассмотренные результаты - показательные примеры того, что может произойти если такие данные ИСПОЛЬЗОВАТЬ в hot path сервисах.
Остальные "если бы", "да кабы", находятся вне рассмотренного примера.
Адекватные они или нет решайте для себя лично, но для Kafka-консьюмеров, батчевой обработки и ETL-процессов 1.5 млн записей за один проход это норма.
Вы, кажется, немного не так поняли сценарий. У меня нет клиента, который качает 250 МБ по сети и обратно. Речь о серверной обработке консьюмеры, батчевые процессы, ценовые движки, там миллионы записей обрабатываются внутри сервиса, и вопрос аллокаций становится критичным.
Ну давайте по порядку: "Список из 1.5 млн отдельных объектов занимает ~250 мб уже сходу, не говоря уже о работе с этим списком далее" - вы пытаетесь свести проблему к "два списка по 250 МБ = 500 МБ = OOM".
Но в реальности OOM наступает не из-за того, что два списка не влезают, а из-за того, что GC не успевает подчищать промежуточный мусор между итерациями стрима.
Если посмотрите на файл с результатами выполнения упавшего декларатива, то наверно заметите: GC(0) 25M->7M(512M) куча почти пуста GC(1) 31M->16M(512M) занято всего 3% GC(2) 54M->32M(512M) занято ~6% GC(6) 219M->159M(512M) занято ~31% GC(7) 285M->212M(512M)занято ~41% GC(8) 347M->265M(512M) занято ~52%
Тут GC уже делает паузы по 11–12 мс, а Old Generation растёт. И это действительно предсказуемо, но если понимать о чем речь (как раз для этого статья).
"Но давайте по факту, обычно http request handler, или kafka consumer, обрабатывают лишь десятки элементов" - вообще не буду спорить. Но проектов миллионы а handler-ов и consumer-ов миллиарды, что не отменяет существования сценариев с миллионами записей - и статья как раз для таких случаев.
Более того, я явно, первым же выводом делю код на cold/warm/hot path и говорю: если это cold или warm path не замарачивайтесь, пишите как читается. Вы просто повторяете мой вывод не замечая этого.
"А когда у нас миллионы элементов разом, такие кейсы бездумно типичным кодом не обрабатывают" - может вы как опытный разработчик и не обрабатываете. Но есть те кто просто скопировал с ллм или старого кода, бездумно вставив типичный stream-пайплайн, прогнали его по локальным тестам и все хорошо. А потом падают.
Ну и "heap впритирку" - не знаю как вы, я взял за практику ограничивать память в контейнерах докера. Поэтому сценарий ну весьма реалистичен.
"Помимо .filter(), .toList(), есть мутабельный .removeIf(), и .forEach() если не хочется for loop" - вот тут однозначно согласен. Абсолютно верное замечание. В свою защиту скажу лишь, что я в статье нигде и не писал, что .toList() единственный способ. Я подсветил - вот популярный стиль, и вот его цена. Комментарии выше, да и опыт подтверждают .toList() один из самых популярных терминальных методов.
Я обязательно найду время и поиграю с .removeIf(), и .forEach(), а также обновления элементов в списке in place, спасибо за советы.
Да, я тоже заметил про версии Java, но взял самые актуальные. *Чешу голову*, надо было ширше варианты приложить)) Ну да ладно. Все равно за доп анализ спасибо)
Хорошие вопросы. Я про них думал когда писал, но что-то уже не хотелось перегружать статью.
На мой взгляд иммутабельные объекты могут улучшить оптимизацию за счет встроенного хешкода или при многопоточном взаимодействии за счет того что JVM уверен что объект неизменен. Но в рамках аллокаций - наверно нет, потому что как не крути новый объект.
Насчет таймингов - есть уверенность что не зименятся при замене. Что там, что там я насовал аллокаций. Но мысль интересная надо покрутить.
Ну и многопоточка это наверно то куда я загляну дальше)) Там именно иммутабельность сыграет значительную роль. Тут мы получается платим за бессмысленную потокобезопасность, ну а там другой расклад.
Поразмышлял над вашей статьей еще раз.
Оформил экспериментус. Дипсику скормил хороший промпт и вашу задачу.
Вот что он мне выдал
Скрытый текст
Теперь вывод: у сеньора промпт лучше. За 10 минут он еще кофе с сигаретой прикончить успел.
Полюбому
С посылом статьи на 100% согласен, к оформлению доказательств вопросы...
КОД каждого участника в студию!
А так субъективному оценочному суждению привык не доверять, уж извините.
И мидла незаслуженно унизили) Ему задачки закрывать надо, быстро и более менее качественно. Ну нет у него 2-3 часа на неспешные раздумия, на таких как он IT держится.
И по соотношениям время исполнения/качество/цена работы в час он явно будет впереди.
А этому в принципе не верю (Сеньор с нейросетью 10 минут Все преимущества, что и без нейросетей 9/10 Все преимущества, что и без нейросетей).
Нейросеть то та же. А значит выдаст тот же примитив. Ну и если сеньор за 9 минут 30 секунд реализовал все как боженька, то я Жанна Дарт Вейдер))))
Хоть это больше и реклама, спасибо за труд.
Но вы почему-то делаете из сеньора какого-то зазнайку. Он по вашему постоянно съезжает с темы, начинает погружаться туда, куда не просили и действительно, как написано выше в комментах, забалтывать вопрос.
И не смотря на то что в статье действительно много полезных фактов в целом она не несет какой-то ценности для любого грейда кроме понимания - "Если сеньор такой - то я не сеньор"
Хорошая статья, немного затянутая, но подробная. Могу посоветовать эту серию видео где данная Observabilty (в т.ч.) разбирается.
Спасибо за код. Если это действительно из собесов, то тут поле не паханное))
Каюсь, сначала нашел не то что вы указали)
Но предлагаю в список добавить и эти (сори не читал комментарии может уже что-то есть):
Отсутствие версионирования (
/v1/payments) - API без версии ломает обратную совместимость при любом изменении. Клиенты, завязанные на этой версии, перестанут работать. Вообще показывает способность к расширениюResponseEntity<?>сырой дженерик теряет информацию о типе тела ответа. В Swagger/OpenAPI это превратится в
object, клиенты не смогут сгенерировать типизированные модели@RequestBody Map<String, Object>теряется контракт API. Нет
@Valid, нет документации полей, нет контроля типов. Любое изменение структуры ломается в рантаймеClassCastExceptionnew Date()java.util.Date- мутабельный, устаревший класс, не рекомендуется к использованию хотя и допустимфp.setStatus("PENDING")строковый литерал для статуса - это нонсенс. Опечатка ("PENING") не будет поймана компилятором, думаю выводы сделаете сами)
!"OK".equals(r)Не совсем плохо но лучше - !Objects.equals("OK", r), более явно проверяет что
r != nullВызов
kafka.sendбез обработки результатаметод асинхронный, но исключения теряются. Нужно добавить callback или использовать синхронную отправку.
RuntimeException- слишком общее исключение. Оно не говорит вызывающему коду (и глобальному обработчику) о том, что именно пошло не так. Лучше - кастомнуюДалее более архитектурная проблема:
@Transactionalна методе контроллера и отсутствие сервисного слояКогда вы ставите
@Transactionalна контроллер, контроллер берёт на себя ответственность за управление транзакциями - это нарушает принцип SRЧто ждет с таким подходом:
- нормально не протестировать - придется кучу зависимостей тянуть и все либо мокать либо подымать
- невозможно переиспользовать логику, для написания похожей обработки - новая ручка
- расширение приведет только к росту проблем
В чем выгода по сравнению с кейклок или аутентик?
Я бы порекомендовал для затравки статьи описать проблему которую вы решили применением данного решения. Пока ценность только в отказе от спринга.
Это уже реальность. Ты опоздал с ожиданиями
Я не вижу причин продолжать это обсуждение.
Вы частично правы, но не до конца.
Вы утверждаете, что проблема сводится к двум спискам которые не влезают. Я показал, что даже когда список один, GC начинает деградировать задолго до OOM.
Разве это противоречит вашему тезису?
Нет, оно его дополняет. Это два уровня анализа одной проблемы - вы говорите о причине (два списка), я - о процессе (как именно это убивает JVM).
Оба наблюдения верны, и оба есть в статье.
Я учту все рассмотренные нюансы и сделаю следующий эксперимент чище
Уважаю вас, за вашу настойчивость.
Так упорно доказывать свою точку зрения не пытаясь вникать в даваемые ответы - достойно восхищения. Вы нашли "новые ворота".
Наверно уже все кто читал и не читал данную статью поняли вашу точку зрения. Да, и я тоже.
Да, вариант того что разработчик загрузит 1.5 миллиона записей в память ужасен, ненормален и всячески осуждаем.
НО! НЕ НЕВОЗМОЖЕН...
А это значит все последующие обсуждения глубоко бессмысленны...
Спасибо за идею про статью о выносе аллокаций из hot path — возможно, я её реализую
Очень профессиональный комментарий, да...
Я был бы признателен, если бы вы читали статью и результаты тестов внимательнее, прежде чем обвинять меня в заблуждениях.
Это реально сэкономило бы всем время.
Вы утверждаете, что причина проблемы в том, что даже один список из 1.5 млн записей не помещается в 512 МБ кучи. Это было бы убедительным аргументом, если бы не один маленький нюанс.
На тех же 512 МБ и с теми же 1.5 млн записей императивный стиль успешно отрабатывает и завершается без ошибок.
Данные из сводной таблицы, которую я привел в статье:
Императивный стиль: JDK 21 G1 - успешно. JDK 17 G1 - успешно.
Функциональный стиль: JDK 21 G1 - OOM. JDK 17 G1 - OOM.
Если проблема исключительно в размере исходных данных, как вы предполагаете, и один список еле помещается в память, то императивный стиль тоже должен был упасть.
Однако он выживает, а функциональный нет. Это прямое доказательство того, что корень проблемы не в размере списка, а в разнице архитектуры стилей - жизненном цикле объектов, накоплении долгоживущих объектов и в неспособности GC своевременно очищать память.
Именно эта разница в поведении и является предметом исследования.
И именно для её демонстрации и предназначен синтетический пример. Я не даю вредных советов и нигде не утверждаю, что нагружать единственный хендлер миллионами записей это хорошая инженерная практика.
Цель статьи, которую вы, похоже, упустили это показать на изолированном примере саму механику проблемы.
А при чем тут GO я вообще не понял...
Я не вижу причин продолжать этот диалог)
Хотелось бы чтобы вы финально зафиксировали: предложенные для примера код и условия является синтетическими отражениями реальных, к сожалению периодически встречаемых, которые в высоконашруженных сервисах применять и писать НЕ РЕКОМЕНДУЕТСЯ.
Рассмотренные результаты - показательные примеры того, что может произойти если такие данные ИСПОЛЬЗОВАТЬ в hot path сервисах.
Остальные "если бы", "да кабы", находятся вне рассмотренного примера.
Адекватные они или нет решайте для себя лично, но для Kafka-консьюмеров, батчевой обработки и ETL-процессов 1.5 млн записей за один проход это норма.
Вы, кажется, немного не так поняли сценарий. У меня нет клиента, который качает 250 МБ по сети и обратно. Речь о серверной обработке консьюмеры, батчевые процессы, ценовые движки, там миллионы записей обрабатываются внутри сервиса, и вопрос аллокаций становится критичным.
Ну а что заголовок кликбейтный - кто из нас без греха?))))))))))))
Ну давайте по порядку:
"Список из 1.5 млн отдельных объектов занимает ~250 мб уже сходу, не говоря уже о работе с этим списком далее" - вы пытаетесь свести проблему к "два списка по 250 МБ = 500 МБ = OOM".
Но в реальности OOM наступает не из-за того, что два списка не влезают, а из-за того, что GC не успевает подчищать промежуточный мусор между итерациями стрима.
Если посмотрите на файл с результатами выполнения упавшего декларатива, то наверно заметите:
GC(0) 25M->7M(512M) куча почти пуста
GC(1) 31M->16M(512M) занято всего 3%
GC(2) 54M->32M(512M) занято ~6%
GC(6) 219M->159M(512M) занято ~31%
GC(7) 285M->212M(512M)занято ~41%
GC(8) 347M->265M(512M) занято ~52%
Тут GC уже делает паузы по 11–12 мс, а Old Generation растёт. И это действительно предсказуемо, но если понимать о чем речь (как раз для этого статья).
"Но давайте по факту, обычно http request handler, или kafka consumer, обрабатывают лишь десятки элементов" - вообще не буду спорить. Но проектов миллионы а handler-ов и consumer-ов миллиарды, что не отменяет существования сценариев с миллионами записей - и статья как раз для таких случаев.
Более того, я явно, первым же выводом делю код на cold/warm/hot path и говорю: если это cold или warm path не замарачивайтесь, пишите как читается. Вы просто повторяете мой вывод не замечая этого.
"А когда у нас миллионы элементов разом, такие кейсы бездумно типичным кодом не обрабатывают" - может вы как опытный разработчик и не обрабатываете. Но есть те кто просто скопировал с ллм или старого кода, бездумно вставив типичный stream-пайплайн, прогнали его по локальным тестам и все хорошо. А потом падают.
Ну и "heap впритирку" - не знаю как вы, я взял за практику ограничивать память в контейнерах докера. Поэтому сценарий ну весьма реалистичен.
"Помимо .filter(), .toList(), есть мутабельный .removeIf(), и .forEach() если не хочется for loop" - вот тут однозначно согласен. Абсолютно верное замечание. В свою защиту скажу лишь, что я в статье нигде и не писал, что .toList() единственный способ. Я подсветил - вот популярный стиль, и вот его цена. Комментарии выше, да и опыт подтверждают .toList() один из самых популярных терминальных методов.
Я обязательно найду время и поиграю с .removeIf(), и .forEach(), а также обновления элементов в списке in place, спасибо за советы.
Да, я тоже заметил про версии Java, но взял самые актуальные. *Чешу голову*, надо было ширше варианты приложить))
Ну да ладно. Все равно за доп анализ спасибо)
Абсолютно верно! О чем и речь))
Что используя тот же toList(), нужно ЗНАТЬ чем это грозит))
Не понял, что не так?)
Хорошие вопросы. Я про них думал когда писал, но что-то уже не хотелось перегружать статью.
На мой взгляд иммутабельные объекты могут улучшить оптимизацию за счет встроенного хешкода или при многопоточном взаимодействии за счет того что JVM уверен что объект неизменен. Но в рамках аллокаций - наверно нет, потому что как не крути новый объект.
Насчет таймингов - есть уверенность что не зименятся при замене. Что там, что там я насовал аллокаций. Но мысль интересная надо покрутить.
Ну и многопоточка это наверно то куда я загляну дальше)) Там именно иммутабельность сыграет значительную роль. Тут мы получается платим за бессмысленную потокобезопасность, ну а там другой расклад.