Русский
Русский
English
Статистика
Реклама

Высокая производительность

Тестирование производительности и краткий обзор HPE Nimble Storage Adaptive Flash HF60

13.06.2021 08:21:39 | Автор: admin

Хочется пролить свет на интересную линейку систем хранения данных HPE Nimble Storage Adaptive Flash и попытаться раскрыть вопрос почему маркетологи решили его назвать Adaptive Flash, а не более традиционно - Hybrid Flash. Судя по поиску, существует не так много обзоров и статей, посвященных Nimble, поэтому надеюсь, что этот материал будет полезен интересующимся данной темой.

В мое распоряжение попал массив с флагманскими контроллерами - HF60. Это СХД 5-го поколения (Nimble Gen5), но уже по состоянию на 04.05.2021 компания HPE анонсировала (пока только AllFlash) 6-е поколение (Nimble Gen6), которое будет называться Allerta 6000. Adaptive Flash 6-го поколения - анонс ожидается летом 2021. Так что пока наш подопытный последнего (актуального) поколения.

Итак, чем же интересен HPE Nimble Adaptive Flash?

Давайте начнем издалека. Компания Nimble Storage берет свое начало в 2008 году и уже в 2014 наделала много шуму, объявив о революционном достижении (на тот момент) доступность систем хранения данных превысила 99,999%. В 2016 году этот показатель уже составлял 99,999928%. Традиционно, столь успешные стартапы поглощаются более крупными компаниями. Так и случилось с Nimble - в 2017 году компания влилась в ряды Hewlett Packard Enterprise. За счет чего им удалось получить такие цифры доступности (причем это не лабораторные, а реальные данные)? Если совсем коротко: архитектура и надстройка в виде аналитической платформы InfoSight. Давайте остановимся на каждом пункте чуть подробнее.

Архитектура

Если Вам лень читать, можете посмотреть видео (на англ.):

СХД Nimble в своей основе использует архитектуру CASL (Cache Accelerated Sequential Layout). В основу архитектуры заложено:

  1. Active/Standby контроллеры. Большинство других систем с двумя контроллерами Active/Active демонстрируют производительность с нулевым запасом мощности, поэтому, если один из контроллеров недоступен - вы уведёте половину скорости...

  2. Функционал редупликации/компрессии/шифрования в режиме онлайн (на лету). Подробнее как это работает ниже в описании операций чтения и записи.

  3. RAID Tripple Parity+ с фиксированным стартовым набором дисков 21шт HDD + 6шт SSD. Массив выдерживает выход из строя 3 любых дисков из одной RAID группы. Но лучшее качество Triple+ RAID не в том, что он защищает от потери любых 3 дисков. Крутая часть это +. Это позволяет системе не иметь проблем с целостностью данных, даже если на каждом отдельном диске в системе возникают ошибки параллельного чтения секторов и три диска были потеряны. Это уровень устойчивости к коррелированным по времени ошибкам, который ни один другой массив в мире даже близко не может предложить.

  4. Защита данных через каскадные многоступенчатые контрольные суммы.

  5. Память с защитой по питанию NVRAM.

  6. SSD кэш на чтение (в случае с Adaptive Flash). Важно отметить, что RAID для SSD не используется, т. к. задача кэша дать максимальную скорость. Насчет надежности можно не беспокоиться, поскольку данные в кэш копируются с защищенных RAID-ом TripleParity+ HDD (об этом ниже).

Рассмотрим алгоритмы чтения и записи

Процесс записи на массивы Adaptive FlashПроцесс записи на массивы Adaptive Flash

Распишем процесс записи:

  1. Запись от разных приложений разными блоками;

  2. NimbleOS принимает запись на NVDIMM активного контроллера;

  3. NimbleOS зеркалирует NVDIMM активного контроллера в NVDIMM Standby контроллера;

  4. NimbleOS подтверждает запись хосту;

  5. Блоки копируются в DRAM, где и происходит магия архитектуры CASL, а именно:

    a. Дедупликация с переменным блоком;

    b. Сжатие с переменным блоком;

    c. Блоки собираются в страйпы размером 10 Мбайт;

    d. Страйпы последовательно пишутся на HDD;

  6. Достойные кэша блоки или блоки из Pinned томов копируются на SSD кэш + блоки индексируются (индекс пишется на SSD и HDD в фоне). Есть 3 настройки кеширования которые можно выставить на каждый том:

    Default данные кэшируются в автоматическом режиме по выбору NimbleOS;

    Pinned настройка, которая по умолчанию копирует все данные тома на SSD диски и мы получаем All Flash на чтение;

    OFF - выключенное кеширование, т. е. SSD диски не задействованы вообще. Полезно для томов, где нет необходимости ускорять чтение;

Какие преимущества имеет такая запись?

Во-первых: количество операций Random write в бэкенде системы минимизировано. По сути, большинство операций происходит в оперативной памяти кэша контроллеров, компрессия выполняется после дедупликации, таким образом число операций ввода-вывода на дисках SSD/HDD сведено к минимуму.

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

Процесс чтения в массивах Adaptive FlashПроцесс чтения в массивах Adaptive Flash

Рассмотрим, что происходит при чтении:

  1. Запрашиваем блок в NVDIMM. Если данные там отдаем хосту;

  2. Если блока нет, читаем из DRAM;

  3. Если блока нет, читаем с SSD:

    a. Если блок есть, проверяем контрольную сумму, разжимаем;

    b. Регидрируем и отдаем хосту;

  4. Если блока нет, читаем с HDD, блок ищем через индекс;

    a. Если блок есть, проверяем контрольную сумму, разжимаем;

    b. Регидрируем и отдаем хосту;

  5. Если блок достоин кэша, копируем на SSD;

Какие преимущества дает такой алгоритм чтения?

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

Во-вторых: согласно живой статистике, массив больше 95% попадает в кэш. Иными словами, имея в запасе всего 1020% SSD дисков, массив будет обеспечивать скорость All Flash больше 95% времени. Важно отметить, что это не означает что оставшиеся 5% времени данные будут читаться медленно с механических дисков. Дисков в стартовом наборе - 21 шт., при чтении их скорость суммируется. Будет иметь место то, что при чтении с HDD дисков задержка будет немного больше чем 1мс. Но не забываем, что этого легко избежать настройкой кэширования индивидуально для каждого тома.

Резюмируя по архитектуре:

  1. Данные защищены от выхода из строя 3-х любых накопителей;

  2. Данные дедуплицируются и сжимаются в памяти до записи на диски без существенного падения производительности;

  3. Данные пишутся быстро: благодаря алгоритму архитектуры CASL;

  4. Данные читаются быстро: кэшируются на SSD диски для ускорения чтения и здесь нет никакого тирринга данных как в традиционных гибридах;

  5. Данные можно дополнительно защищать шифрованием;

  6. Гибкие настройки. Можно вкл/выкл индивидуально для каждого тома: дедуп; компрессия; шифрование; кеширование; ограничение по IOPS или пропускной способности (или того и другого) и прочее;

  7. Гибкие возможности масштабирования емкости/производительности:

  • возможность масштабировать емкость (добавлять дисковые полки);

  • емкость и производительность (добавлять еще один массив в кластер, до 4 шт);

  • производительность (заменить контроллеры на более производительные).

InfoSight

Тезисы:

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

  • Доступность СХД HPE Nimble 99,9999%, достигается благодаря применению InfoSight.

  • Миссия HPE InfoSight: сохранять маниакальный фокус на предоставлении заказчику такой поддержки, чтобы все завидовали!

Данная технология позволяет СХД непрерывно в автоматическом режиме собирать большое количество данных как о собственном состоянии, так и об окружающей виртуальной инфраструктуре (подключенные сети, серверы, платформы виртуализации), это важно, поскольку более половины сервисных инцидентов, как правило, не связаны с СХД. Затем эти показатели отправляются в облачную систему, где с помощью сложных аналитических алгоритмов выявляются текущие проблемы и делаются прогнозы о будущем состоянии инфраструктуры. На основе данных выводов предоставляются автоматические исправления и рекомендации для администратора СХД.

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

По данным HPE, при использовании InfoSight 86% проблем разрешаются без участия ИТ-службы. Сюда входят инциденты как с самой СХД, так и с окружающей инфраструктурой.

InfoSight позволяет значительно сократить время на поиск проблемных узлов инфраструктуры в случае деградации производительности. Система в удобном графическом виде показывает текущее время отклика и статистику задержек за определенный период по проблемной ВМ не только относительно самой СХД, но и сети передачи данных SAN, а также приложений гипервизора. Отклонение каких-либо показателей в кратчайшие сроки позволит определить узкое место инфраструктуры. Не нужно переключаться между несколькими системами мониторинга, все показатели доступны в едином портале, так как InfoSight интегрируется с VMware VCenter. В процессе диагностики собирается только служебная информация, собственные данные заказчика не затрагиваются. Информация передается по защищенному SSL каналу.

Некоторые примеры передаваемых данных:

  • Серийный номер массива.

  • Базовая информация о работоспособности (health check).

  • Системные журналы событий.

  • Параметры настроек системы.

  • Статистика работы системы.

Самое вкусное на десерт - тестирование

Тестовая конфигурация:

  • СХД Nimble HPE Nimble Storage Adaptive Flash HF60 / 21x2TB HDD / 5.76TB (6x960GB) SSD Cache / 4x16GB FC на каждый контроллер;

  • Хост 1: Сервер HPE DL160 Gen10 / 1x Xeon 4210R / 6x16GB DDR4-R / 2xSN1100Q 16Gb 2p FC / 2x500W;

  • Хост 2: Сервер HPE DL325 Gen10 / 1x EPYC 7551P / 8x16GB DDR4-R / 2xSN1100Q 16Gb 2p FC / 2x500W;

  • Подключение серверов напрямую к СХД, т. к. под рукой не было коммутаторов Fibre Channel. Поэтому в серверах по 2шт двухпортовых карточки, чтоб загрузить все 4 порта на каждом контроллере СХД;

  • VMware vSphere 7;

  • Тестирование с помощью HCIbench 2.5.3;

Для начала нам было интересно сравнить наш Adaptive Flash HF60 с протестированным All Flash AF40:

Нагружаем наш массив аналогичными параметрами и в конце сводим результаты.

Первый тест: 384 потока/100%, получаем результаты:

Второй тест: 192 потока/100% чтение, получаем результаты:

Третий тест: 192 потока/100% запись, получаем результаты:

Сравниваем результаты Adaptive Flash HF60 vs All Flash AF40:

Пару слов о полученных результатах

Получаем то, что СХД с медленными 7,2k дисками дает большую производительность и меньшое время отклика чем All Flash массив. Как так? Все дело в контроллерах и архитектуре. В нашем случает стоят более производительные контроллеры и за счет магии архитектуры CASL гибриды Adaptive Flash показывают производительности сопоставимую с All Flash (контролеры используются одинаковые HF20=AF20, HF40=AF40, HF60=AF60, разница HF и AF только в конфигурации дисках). Причем скорость и задержки HF60 выигрывает при записи, где в Adaptive Flash никак не задействованы SSD.

За то время что у нас был массив мы смогли сравнить еще с одной конфигурацией All Flash XS5226D СХД QSAN. К нам попал такой результат тестирования:

Единственное, что мы не смогли повторить 464 потока при 32-х виртуалках. Поэтому сделали 448 и 480.

448/480 одновременных потоков серьезная нагрузка. Можно отметить, что здесь массив вполне играет наравне с дешевым All Flash. У QSAN очень много непонятных пиков и провалов по задержке. Поэтому Nimble существенно выигрывает по 95-му перцентилю.

Эксперименты с дудепликацией и компрессией

При 100% записи производительность проседает некритично ~ 20%. При 100% чтении почти ничего не меняется, что вполне логично. Гораздо интереснее 70/30. При наличии нулевых блоков операции чтения ускоряются при включенной компрессии.

Итого: почему его назвали Adaptive Flash а не Hybrid Flash?

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

Могу еще добавить, что наши инженеры скептически относились к данному массиву до тестирования. После первых предварительных результатов (~94k IOPS и задержки 5мс) на 100% чтение Возник спор, т. к. 94k полученных и 300k теоретических сильно отличались. Инженеры стояли на том, что невозможно получить больше на дисках 7200. После проверки конфигурации оказалось, что для тестовых машин было выключено кеширования и чтение не ускорялось вообще. После правильной настройки все взлетело. И как только они не пытались убить скорость массива не получалось (в рамках разумного естественно). В итоге лишь в условиях личного опыта у них поменялось мнение. К чему это? К тому что очень полезно брать железяку на тест, когда ее предлагают (да еще и бесплатно).

Подробнее..

Перевод Как создать архитектуру для работы с высокой нагрузкой вашего веб-проекта?

26.05.2021 16:17:30 | Автор: admin

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

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

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

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

Ознакомьтесь с некоторыми фактами о высокой нагрузке:

  • Высокая нагрузка возникает, когда один физический сервер становится не в состоянии эффективно выполнять обработку данных.

  • Если один экземпляр (instance) одновременно обслуживает 10 000 соединений - это высокая нагрузка. Высокая нагрузка - это одновременное обслуживание тысяч и миллионов пользователей

  • Если вы развертываете веб-решение на AWS (Amazon Web Services), Microsoft Azure или Google Cloud Platform, вы поддерживаете архитектуру для работы с высокой нагрузкой.

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

  • Медленная или бесконечная загрузка страниц

  • Случайные ошибки

  • Разрывы соединений с веб-сервером

  • Неполная загрузка контента

  • Снижение активности пользовательской аудитории

  • Потери клиентов

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

Принципы построения высокоэффективных решений

Динамика и гибкость

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

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

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

Постепенный рост проекта

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

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

Используйте облако для размещения новых проектов, поскольку оно позволяет снизить стоимость сервера, упрощает управление им и легко развертывает приложения.

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

Масштабирование веб-решения - это постепенный процесс, состоящий из 4 основных этапов:

  • Анализ нагрузки

  • Определение областей, на которые в основном воздействует нагрузка

  • Передача этих областей отдельным узлам и их оптимизация

  • Анализ нагрузки

Разработка масштабируемой архитектуры веб-проекта

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

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

Разделение базы данных

Чаще всего первым узлом, который подвергается нагрузке, является база данных. Каждый запрос от пользователя к приложению - это, как правило, от 10 до 100 запросов к базе данных. Вынесение базы данных на отдельный сервер повысит ее производительность и снизит негативное воздействие на другие компоненты (PHP, Nginx и т.д.).

Миграция базы данных

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

  • Используйте простое решение - разместите объявление о планируемых работах на сайте и сделайте перенос. Лучше сделать это ночью, когда активность пользовательской аудитории минимальна.

  • Используйте репликацию для синхронизации данных с одного сервера на другой. После настройки необходимо изменить IP-адрес базы данных в приложении на новый сервер. Затем - выключить старый сервер.

Разделение веб-сервера

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

Тогда Nginx сам отдаст статические файлы, а PHP-сервер будет занят только обработкой скриптов. Nginx обеспечивает подключение к бэкенду по IP-адресу:

server {server_name ruhighload.com;root /var/www/ruhighload;index index.php;location ~* .(php)$ {fastcgi_pass 10.10.10.1:9000;fastcgi_index index.php;include fastcgi_params;fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;}}

Имейте в виду, что если вы используете загрузку файлов, то для их хранения необходимо выделить отдельный узел.

Используйте несколько бэкендов

Когда нагрузка возрастает, веб-приложение начинает работать медленнее. В какой-то момент причина будет заключаться уже в самой имплементации. Чтобы избежать подобных проблем, следует использовать несколько бэкендов PHP.

При инсталляции бэкендов убедитесь, что они имеют одинаковую конфигурацию. Используйте Nginx для балансировки нагрузки между ними. Для этого следует определить список бэкендов в апстрим (upstream) и использовать его в конфигурации:

upstream backend {server 10.10.10.1;server 10.10.10.2;server 10.10.10.3;}server {server_name ruhighload.com;root / var / www / ruhighload;index index.php;location ~ * . (php) $ {fastcgi_pass backend;fastcgi_index index.php;include fastcgi_params;fastcgi_param SCRIPT_FILENAME $ document_root $ fastcgi_script_name;}}

Когда вы начнете использовать несколько бэкендов, запросы от одного и того же пользователя будут отправляться на разные серверы. Это потребует единого репозитория для всех сессий.

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

Очереди задач и балансировка DNS

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

DNS поддерживает балансировку по принципу Round Robin, позволяя указать несколько IP-адресов принимающих веб-серверов, называемых фронтендами. В данном случае необходимо инсталлировать несколько одинаковых фронтендов, чтобы DNS выдавал разные IP-адреса разным клиентам. Таким образом, вы обеспечите балансировку между фронтендами.

Файловые хранилища

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

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

Сделать это можно следующим образом:

  • Определить отдельный поддомен для файлового сервера.

  • Развернуть на сервере Nginx и небольшое приложение, которое может хранить файлы и обрабатывать их

  • Масштабирование путем добавления новых серверов и поддоменов (например, images1, images2, images3 и т.д.)

  • Перенести загрузку файлов на сторону клиента, чтобы форма отправляла запрос на определенный сервер.

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

Однако ключевым моментом является экономическая эффективность. Скажем, у вас 100 тысяч пользователей и один сервер. Значит, чтобы получить 130 тысяч из них, нужно разместить еще один сервер. Кажется, что это довольно сложно, не так ли?

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

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


Перевод материала подготовлен в рамках запуска курса "Highload Architect".

Всех желающих приглашаем на бесплатный демоурок Выбор архитектурного стиля (микросервисы, СОА и монолиты).

- ЗАПИСАТЬСЯ НА ДЕМОУРОК

Подробнее..

Перевод Простое ускорение Java с помощью Quarkus и JHipster

01.06.2021 20:15:10 | Автор: admin

К старту курса о разработке на Java делимся переводом вводной статьи о Quarkus "родной" для Kubernetes Java-платформе для создания высокопроизводительных веб-, бессерверных (serverless) и нативных приложений (оптимизированных для используемых микропроцессоров). В ней используются предварительная компиляция AOT и агрессивная оптимизация, например сканирование путей к классам, перезагрузка конфигурации и предварительная конфигурация самозагрузки приложения в процессе сборки. Результатом становится впечатляющая скорость загрузки. Другими словами, приложения, созданные с Quarkus, запускаются не просто быстро, а очень быстро!


Так же как для платформ Spring и Micronaut, в Quarkus можно использовать преимущество GraalVM для преобразования JVM-приложений в нативные исполняемые файлы, что ещё больше повышает их быстродействие.

Такой прирост производительности позволяет этой Java-платформе конкурировать с бессерверными, облачными и созданными на основе Kubernetes средами, создавая приложения Supersonic Subatomic Java. В Quarkus используются стандарты Java (такие, как MicroProfile, JAX-RS), а также лучшие из существующих Java-библиотек (например, Hibernate и Vert.x). Есть даже поддержка аннотаций Spring.

Quarkus + JHipster = простое ускорение Java

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

JHipster это сопровождаемая сообществом разработчиков полнофункциональная платформа разработки, позволяющая создавать, развивать и развёртывать веб-приложения и ориентированные на микросервисы архитектуры. Стандартной серверной платформой в JHipster является Spring Boot, но постоянно появляются всё новые и новые возможности. Одним из таких вариантов стала blueprint-схема JHipster Quarkus.

Помимо прочего, одной из ключевых возможностей, определяющих успех JHipster, стала его расширяемость с помощью blueprint-схем. Blueprint-схема действует как плагин JHipster и позволяет вам переопределять стандартное поведение, настраивая его на свой вкус.

Например, blueprint-схема Kotlin с JHipster является популярным дополнением, которое любят использовать разработчики. Использование blueprint-схемам многократно расширяет границы возможностей. Вот почему в JHipster есть даже реализации без Java (такие, как Node + NestJS и .NET Core). Эта публикация познакомит вас с основными шагами по использованию JHipster, blueprint-схемой Quarkus, а также протоколом OAuth для создания оптимизированного для работы на конкретных микропроцессорах и безопасного полнофункционального приложения.

Создание Java-приложения с Quarkus

Прежде чем приступить к работе, необходимо установить несколько инструментов.

Предварительные требования:

Установите JHipster и его blueprint-схему Quarkus, используя npm:

# Install JHipster globallynpm install -g generator-jhipster@6.10.5# Install the JHipster Quarkus blueprintnpm install -g generator-jhipster-quarkus@1.1.1

Команда jhipster-quarkus сокращение для jhipster --blueprints quarkus. Посмотреть все варианты её синтаксиса можно с помощью команды --help.

$ jhipster-quarkus --help

Создание приложения в среде JHipster Quarkus

mkdir okta-jhipster-quarkus-example && cd okta-jhipster-quarkus-example# oh-my-zsh users: take okta-jhipster-quarkus-example

Чтобы запустить мастер создания приложений, можно выполнить команду jhipster-quarkus:

jhipster-quarkus

В этой вводной статье основным вопросом будет выполнение аутентификации.

Среда JHipster Quarkus позволяет использовать JWT (с возможностью управления пользователями в базе данных приложений) или аутентификацию OAuth 2.0/OIDC с применением поставщиков идентификационной информации, таких как Keycloak и Okta. OIDC расшифровывается как OpenID Connect, этот протокол представляет собой тонкий слой поверх протокола аутентификации OAuth 2.0. Его основная задача обеспечить аутентификацию и идентификацию пользователя.

Ниже представлен пример ответов на вопросы мастера создания приложений.

После того как вы ответите на все эти вопросы, JHipster создаст код вашего приложения и выполнит команду npm install.

{  "generator-jhipster": {    "promptValues": {      "packageName": "com.mycompany.myapp",      "nativeLanguage": "en"    },    "jhipsterVersion": "6.10.5",    "applicationType": "monolith",    "baseName": "jhipster",    "packageName": "com.mycompany.myapp",    "packageFolder": "com/mycompany/myapp",    "serverPort": "8080",    "authenticationType": "oauth2",    "cacheProvider": "no",    "enableHibernateCache": true,    "websocket": false,    "databaseType": "sql",    "devDatabaseType": "h2Disk",    "prodDatabaseType": "mysql",    "messageBroker": false,    "buildTool": "maven",    "embeddableLaunchScript": false,    "useSass": true,    "clientPackageManager": "npm",    "clientFramework": "angularX",    "clientTheme": "none",    "clientThemeVariant": "",    "creationTimestamp": 1614834465776,    "jhiPrefix": "jhi",    "entitySuffix": "",    "dtoSuffix": "DTO",    "otherModules": [      {        "name": "generator-jhipster-quarkus",        "version": "1.1.1"      }    ],    "enableTranslation": true,    "nativeLanguage": "en",    "languages": ["en"],    "blueprints": [      {        "name": "generator-jhipster-quarkus",        "version": "1.1.1"      }    ]  }}

В этом файле содержатся все ответы на исходные вопросы мастера JHipster, что позволит создать приложение без дополнительных запросов.

Убедитесь, что выполняемая с помощью Keycloak аутентификация OAuth 2.0/OIDC действительно работает

Помимо прочих файлов JHipster Quarkus создаёт файлы для инструментального средства Docker Compose, помогающие загрузить среду разработки, настроенную для вашего только что созданного приложения. При этом даже выполняется импорт данных Keycloak, заданных по умолчанию для пользователей и приложений, и вам не придётся делать это самим.

https://gitlab.webant.ru/russia_quiz/frontend/-/merge_requests

Если Keycloak запущен и работает, вы сможете выполнить вход в систему. Запустите ваше приложение с помощью Maven:

./mvnw

Вы можете видеть, что приложение запускается за 3,351 с, также выводится обширный список расширений Quarkus (включая oidc). Перейдите по адресу http://localhost:8080 в предпочитаемом вами браузере и нажмите ссылку sign in (вход).

Вы будете перенаправлены в Keycloak для входа в систему. При запросе идентификационных данных введите admin/admin.

После успешного прохождения аутентификации вы будете перенаправлены обратно в ваше приложение Quarkus.

Как работает поддержка протокола аутентификации OAuth 2.0 в JHipster Quarkus

Одной из проблем при разработке blueprint-схем JHipster, таких как Quarkus, является необходимость найти правильный баланс между повторным использованием существующих механизмов и добавлением индивидуальных реализаций.

С самого первого дня своего появления JHipster осуществлял интеграцию всех современных платформ для создания веб-приложений (Angular, React или даже Vue.js), совмещая их с фактически стандартными Java-технологиями, такими как Spring Boot и Spring Cloud.

В реализации JHipster Quarkus OAuth 2.0 за основу взято URI-перенаправление login/oauth2/code/oidc, предполагающее использование платформы аутентификации Spring Security. Настоятельно рекомендуем выполнять аутентификации на стороне сервера, поскольку это намного безопаснее (так как у браузера нет необходимости управлять какими-либо идентификационными данными и хранить какие-либо маркеры доступа).

В среде JHipster Quarkus, чтобы повторно использовать без изменений клиентские приложения и реализовать на серверной стороне механизм аутентификации по протоколу OAuth 2.0, в бэкенде должны быть открыты два HTTP-маршрута. Вот почему в JHipster Quarkus предусмотрен контроллер UserOauth2Controller и настраиваемые свойства Quarkus OIDC, позволяющие обеспечить правильную работу всего механизма.

Это своего рода интеграционная ловушка, на которую разработчики не хотят тратить время. Коллективный разум сообщества JHipster, объединяющий мнения самых эффективных разработчиков, объясняет высокую популярность и качество данной платформы.

Объединение JHipster Quarkus с сервисом Okta

Из этого раздела вы узнаете, как использовать сервис Okta в качестве провайдера протокола аутентификации OAuth 2.0/OIDC. В Okta предусмотрены два варианта для конфигурирования OIDC-приложения. Это можно выполнить либо в консоли разработчика, либо с помощью инфраструктуры Okta CLI.

Использование инфраструктуры Okta CLI для конфигурирования JHipster

Инфраструктура Okta CLI автоматизирует для вас все конфигурации JHipster и Okta. Для установки Okta CLI можно воспользоваться популярными менеджерами пакетов.

macOS (с помощью Homebrew):

brew install --cask oktadeveloper/tap/okta

Linux (с помощью Flatpak):

# Add Flathub repoflatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo# install the packageflatpak install com.okta.developer.CLI# add this to your appropriate dot filealias okta="flatpak run com.okta.developer.CLI"

Windows (с помощью Chocolatey):

choco install okta -version 0.8.0

Вы также можете просто передать его в оболочку bash:

curl https://raw.githubusercontent.com/okta/okta-cli/master/cli/src/main/scripts/install.sh | bash

Если у вас ещё нет аккаунта разработчика в Okta, откройте терминал, перейдите в каталог приложения Quarkus и выполните okta register. Если у вас есть аккаунт, выполните okta login.

$ okta registerFirst name: DanielLast name: PetismeEmail address: daniel.petisme@gmail.comCompany: OktaCreating new Okta Organization, this may take a minute:OrgUrl: https://dev-9323263.okta.comAn email has been sent to you with a verification code.Check your emailVerification code: 232819New Okta Account created!Your Okta Domain: https://dev-9323263.okta.comTo set your password open this link:https://dev-9323263.okta.com/welcome/drpt2SjbRAPR-gvVHhnm

Если у вас уже есть аккаунт разработчика Okta, выполните команду okta login. Затем из каталога приложения Quarkus выполните okta apps create jhipster. Примите предлагаемые по умолчанию предложения для перенаправления URI.

$ okta apps create jhipsterApplication name [okta-jhipster-quarkus-example]:Redirect URICommon defaults:  Spring Security - http://localhost:8080/login/oauth2/code/okta  Quarkus OIDC - http://localhost:8080/callback  JHipster - http://localhost:8080/login/oauth2/code/oidcEnter your Redirect URI(s) [http://localhost:8080/login/oauth2/code/oidc, http://localhost:8761/login/oauth2/code/oidc]:Enter your Post Logout Redirect URI(s) [http://localhost:8080/, http://localhost:8761/]:Configuring a new OIDC Application, almost done:Created OIDC application, client-id: 0oa5ozjxyNQPPbKc65d6Creating Authorization Server claim 'groups':Adding user daniel.petisme@gmail.com to groups: [ROLE_USER, ROLE_ADMIN]Creating group: ROLE_USERCreating group: ROLE_ADMIN

Конфигурация приложения Okta записывается здесь: /Users/daniel/workspace/okta-jhipster-quarkus-example/.okta.env

ПРИМЕЧАНИЕ: идентификаторы URI, перенаправленные http://localhost:8761*, предназначены для реестра JHipster, который часто используется при создании микросервисов с помощью JHipster. В инфраструктуре Okta CLI они добавляются по умолчанию. Они не требуются для приложения, создаваемого в этой вводной статье, но если их оставить, то никакого вреда точно не будет.

Инфраструктура Okta CLI создаст файл .okta.env в текущем каталоге. Если посмотреть на него, то вы увидите, что в нём содержатся несколько ключей и значений, предназначенных для протокола OIDC.

$ cat .okta.envexport QUARKUS_OIDC_AUTH_SERVER_URL="http://personeltest.ru/aways/dev-9323263.okta.com/oauth2/default"export QUARKUS_OIDC_CLIENT_ID="0oa5ozjxyNQPPbKc65d6"export QUARKUS_OIDC_CREDENTIALS_SECRET="KEJ0oNOTFEUEFHP7i1TELLING1xLm1XPRn"export QUARKUS_OIDC_AUTHENTICATION_REDIRECT_PATH="/login/oauth2/code/oidc"export JHIPSTER_OIDC_LOGOUT_URL="http://personeltest.ru/aways/dev-9323263.okta.com/oauth2/default/v1/logout"

Установите файл, чтобы задать переменные среды, и запустите ваше приложение с помощью Maven.

source .okta.env./mvnw

Обязательно добавьте \*.env в ваш файл .gitignore, чтобы в коммиты не попадал ваш секрет клиента.

Как только приложение будет запущено, в окне в режиме инкогнито откройте http://localhost:8080 и выполните вход. Вам будет предложено ввести свои идентификационные данные Okta.

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

Инфраструктура Okta CLI упрощает конфигурирование JHipster и выполняет следующие полезные операции.

  1. Создаётся приложение OIDC с правильным перенаправлением URI.

  2. Заводятся группы ROLE_ADMIN и ROLE_USER, требуемые для JHipster.

  3. Ваш текущий пользователь добавляется в группы ROLE_ADMIN и ROLE_USER.

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

Но вдруг вы не любите работать из командной строки? Не паникуйте, тут есть кому прикрыть вашу спину! Инфраструктура Okta CLI проста в использовании, но для тех, кому это необходимо, есть возможность выполнить настройки с помощью интерфейса пользователя. Именно поэтому я так подробно рассматриваю каждый шаг по настройке приложения OIDC, работающего с JHipster Quarkus.

Использование консоли разработчика Okta для настройки JHipster

Если у вас нет аккаунта разработчика Okta, необходимо нажать ссылку sign up (вход). Тут нет ничего сверхсложного: просто укажите имя, фамилию, адрес электронной почты, выберите надёжный пароль и всё вы готовы двигаться дальше. Выполнив вход в систему, вы попадаете в консоль разработчика:

Разверните вложенное меню Applications (Приложения) на панели навигации слева, затем выберите в меню Applications > Create App Integration (Приложения > Создать интеграцию приложений), чтобы запустить мастер создания приложений.

Выберите OIDC и Web Application. Затем нажмите кнопку Next (Далее).

Теперь вам необходимо будет указать некоторые настройки для приложения.

  • Name (Имя): можете указать любое имя на свой вкус, но разве JHipster Quarkus чем-то не подходит?

  • Login redirect URIs (Перенаправление URI при входе): определяет, будет ли Okta выполнять перенаправление в браузере клиента после аутентификации. Установите для него http://localhost:8080/login/oauth2/code/oidc, именно это значение настроено по умолчанию.

  • Logout redirect URIs (Перенаправление URI при выходе): http://localhost:8080 указывается, куда будет перенаправляться пользователь после выполнения выхода.

  • Group assignments (Назначения групп): определяет, какие группы могут использовать это приложение.

Используйте предлагаемые значения по умолчанию для всех остальных настроек. Завершив ввод требуемых настроек для своего приложения, нажмите Save (Сохранить). На экране отобразятся подробные сведения о вашем приложении.

Наиболее важными являются следующие значения:

  • идентификационные данные клиента (ID и секрет клиента). Они позволяют приложению Java выполнять аутентификацию в сервисах Okta для последующей обработки потоков аутентификации и авторизации пользователей;

  • домен Okta, из которого Quarkus будет выводить конечные точки адресов URL для протоколов OAuth/OIDC.

Создание групп пользователей

Теперь настало время для создания групп пользователей. По умолчанию для JHipster требуются две следующие группы:

  • ROLE_USER: для аутентифицированных пользователей;

  • ROLE_ADMIN: для аутентифицированных пользователей с административными правами для этого приложения.

В консоли разработчика выберите в меню Directory > Groups (Каталог > Группы). Нажмите Add Group (Добавить группу) и создайте группу ROLE_ADMIN

Теперь добавьте группу ROLE_USER.

Всё, вы завершили создание групп, теперь займёмся добавлением пользователей.

Создание пользователей

Чтобы увидеть различие между обычными и административным пользователями, создайте пользователей в каждой из групп. Используя консоль разработчика Okta, выберите в меню Directory > People (Каталог > Люди). Нажмите Add Person (Добавить человека). Начните с создания пользователя Administrator.

Основным моментом тут является привязка пользователя Administrator к группе ROLE_ADMIN. Чтобы определить исходный пароль, который пользователь должен будет изменить, исключительно в демонстрационных целях для данной вводной статьи использована стратегию выбора пароля Set by Admin (Задаётся администратором). В реальном проекте я рекомендую использовать стратегию Set by User (Задаётся пользователем) с активацией по электронной почте. Теперь добавим обычного пользователя.

Убедитесь, что пользователь User входит в группу ROLE_USER. Очень важно использовать действующий адрес электронной почты, так как он может понадобиться при восстановлении пароля. Выберите в меню Applications > JHipster Quarkus (Приложения > JHipster Quarkus) и нажмите Assignments (Назначения). Назначьте группам пользователей, которых только что создали.

Добавление заявки на группы к маркеру ID

Последнее, о чём необходимо вам позаботиться, это настроить заявку на группы, включающую группы пользователей в маркер ID. Выберите в меню Security > API (Безопасность > API), а затем нажмите default (по умолчанию). Щёлкните Claims > Add Claim (Заявки > Добавить заявку). Введите следующие значения:

  • Name (Имя): groups (группы);

  • Include in token type (Включить тип маркера): ID Token (Маркер ID);

  • Value type (Тип значения): groups (группы);

  • Filter (Фильтр): Matches regex with a value of .* (Соответствует регулярному выражению со значением .*).

Нажмите Create (Создать). Конфигурация Okta для JHipster готова!

Настройка Quarkus OIDC для Okta

В этот момент необходимо, чтобы приложение JHipster Quarkus уже было запущено и для использования Keycloak в качестве провайдера идентификационных данных для него. Давайте изменим настройки для работы с Okta. Во-первых, необходимо выйти из веб-приложения JHipster, чтобы предотвратить конфликт файлов cookie. Выберите Account > Sign Out (Аккаунт > Выход).

ПРИМЕЧАНИЕ: можно оставить приложение запущенным. Quarkus поддерживает работу в так называемом режиме для разработки (Dev Mode), обеспечивающем горячую перезагрузку любых исходных или ресурсных файлов при каждом их обновлении. Это исключительно удобно!

Откройте для редактирования файл src/main/resources/application.properties и найдите в нём раздел со следующей конфигурацией OIDC.

# OAuth 2.0 and OIDCquarkus.oidc.enabled=truequarkus.oidc.auth-server-url=http://localhost:9080/auth/realms/jhipster/%dev.quarkus.oidc.client-id=web_app%dev.quarkus.oidc.credentials.secret=web_appquarkus.oidc.application-type=hybridquarkus.oidc.authentication.scopes=profile,address,email,address,phone,offline_accessquarkus.oidc.authentication.cookie-path=/quarkus.oidc.authentication.redirect-path=/login/oauth2/code/oidcquarkus.oidc.authentication.restore-path-after-redirect=falsejhipster.oidc.logout-url=http://localhost:9080/auth/realms/jhipster/protocol/openid-connect/logout%test.quarkus.oidc.client-id=dummy%test.quarkus.oidc.application-type=service%test.jhipster.oidc.logout-url=some-dummy-logoutUrl

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

  • quarkus.oidc.auth-server-url: корневой URL для API Okta, полученный из домена приложения OIDC;

  • quarkus.oidc.client-id: ID клиента для приложения OIDC;

  • quarkus.oidc.credentials.secret: секрет клиента для приложения OIDC;

  • jhipster.oidc.logout-url: в JHipster браузер будет запускать выход из системы. Серверная сторона должна предоставлять эту информацию (пока её невозможно получить с помощью OIDC-поиска).

После того как вы обновите этот файл, ваши настройки должны выглядеть примерно так:

# OAuth 2.0 and OIDCquarkus.oidc.enabled=truequarkus.oidc.auth-server-url=https://dev-9323263.okta.com/oauth2/defaultquarkus.oidc.client-id=0oaajhdr9q9jxbBM95d6quarkus.oidc.credentials.secret=NEVERSHOWSECRETSquarkus.oidc.application-type=hybridquarkus.oidc.authentication.scopes=profile,address,email,address,phonequarkus.oidc.authentication.cookie-path=/quarkus.oidc.authentication.redirect-path=/login/oauth2/code/oidcquarkus.oidc.authentication.restore-path-after-redirect=falsejhipster.oidc.logout-url=https://dev-9323263.okta.com/oauth2/default/v1/logout

Перезапустите приложение, перейдите по адресу http://localhost:8080. Нажмите sign in (вход) и вы будете перенаправлены на страницу входа Okta.

Введите admin в качестве имени пользователя и пароль, который вы задали. Okta определит, что это ваш первый вход в систему, и предложит сменить пароль. После выполнения вы будете перенаправлены в ваше приложение Quarkus.

Переход в нативный формат с помощью Quarkus и GraalVM

Заключительным шагом в данной вводной статье будет упаковка приложения Java в виде нативного выполняемого файла (оптимизированного для используемого микропроцессора). И снова JHipster надёжно прикрывает ваши тылы, делая для вас всё необходимое. Просто выполните в Maven команду package с профилем native:

./mvnw package -Pnative -DskipTests

Вы знаете, мы знаем, все знают: мы пропустили этап тестирования в этом демонстрационном примере всё работает идеально. Но, пожалуйста, никогда не пропускайте тестирование в условиях реальной промышленной разработки.Если в используемый вами JDK не включён GraalVM, вы получите ошибку:

[error]: Build step io.quarkus.deployment.pkg.steps.NativeImageBuildStep#build threw an exception: java.lang.RuntimeException: Cannot find the `native-image` in the  GRAALVM_HOME, JAVA_HOME and System PATH. Install it using `gu install native-image`

Самым простым способом решения этой проблемы будет применение SDKMAN для установки Java 11 с GraalVM:

sdk install java 21.0.0.2.r11-grl

Затем выполните gu install native-image:

$ gu install native-imageDownloading: Component catalog from www.graalvm.orgProcessing Component: Native ImageDownloading: Component native-image: Native Image  from github.comInstalling new component: Native Image (org.graalvm.native-image, version 21.0.0.2)

Как только процесс завершится, перезапустите команду package в Maven:

./mvnw package -Pnative -DskipTests

На нативную компиляцию требуется немало времени. У вас может возникнуть желание прогуляться, выпить чашку-другую кофе или несколько раз отжаться. Но, если вы предпочитаете остаться за компьютером, можно потратить время на следующее:

  • ознакомиться с проектом JHipster Quarkus Blueprint;

  • исследовать сайт для разработчиков Okta;

  • изучить эти великолепные руководства по Quarkus.

Спустя примерно три минуты нативный выполняемый файл должен быть готов:

Запустите его как нативный выполняемый файл, используя команду target/*runner:

Ваше старое доброе Java-приложение запустится через 1 секунду! Помните, я рассказывал о приросте памяти? Ниже привожу команду, как посмотреть потребление памяти в мегабайтах:

$ ps -o pid,rss,command | grep --color jhipster | awk '{$2=int($2/1024)"M";}{ print;}'30951 46M ./target/jhipster-1.0.0-SNAPSHOT-runner31433 0M grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --color jhipster

Ваше приложение потребляет менее 50 МБ памяти. Перейдите по адресу http://localhost:8080 и убедитесь, что всё исправно работает. Теперь наслаждайтесь своим успехом!

Дальнейшая разработка с помощью JHipster Quarkus

Надеюсь, вы получили удовольствие от данной вводной статьи, пройдя все этапы создания нативного приложения и применяя для этого Java, Quarkus и JHipster. Не правда ли, впечатляет, как JHipster и Okta CLI делают для вас основную тяжёлую работу?! Вы можете найти пример, созданный в этой вводной статье, на GitHub. Если вы заинтересованы в дополнительном изучении blueprint-схем Quarkus, посмотрите проект generator-jhipster-quarkus, также размещённый на GitHub.

Совсем недавно Java считали медленным языком программирования, а вернее было бы сказать, что медленной была виртуальная машина Java, но это не помешало языку стать одним из самых востребованных по версии TIOBE. Сегодня Java становится всё быстрее, а это значит, что она ещё надолго сохранит способность конкурировать с новыми языками; кроме того, в последнее время наметилась тенденция к упрощению синтаксиса Java. Если вам интересен этот язык и работа с ним в перспективе, то вы можете посмотреть на программу обучения нашего курса по Java, где студенты за 14 месяцев получают всестороннюю подготовку к трудоустройству в качестве Java-разработчика.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Перевод Либо быстро, либо неправильно

02.06.2021 18:06:54 | Автор: admin
image


В 2018 году я упражнялся на Advent of Code (здесь вы можете посмотреть стримы моих решений). Каждый день в декабре они публикуют небольшую проблему, и вы должны написать программу, которая её решит. Обычно это занимает от пары минут до пары часов и это довольно весело, я рекомендую вам попробовать. Когда задача выполнена, она всегда доступна, не только в декабре.

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

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

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

Теперь перейдем к программному обеспечению. Легко назвать решения Advent Of Code ошибочными, когда они медленные, поскольку мы знаем, что быстрое решение должно существовать. С реальными проблемами никто этого не гарантирует.

За исключением некоторых случаев.

Собственно, довольно часто.

На самом деле, я бы сказал, почти всегда.

Давайте посмотрим. У меня есть библиотека под названием Datascript. Это устойчивая структура данных/база данных и так уж вышло, что она реализована для двух платформ: JS и JVM. Более того, она фактически написана на Clojure, и большая часть ее кодовой базы используется обеими платформами. Это означает, что мы знаем, что обе версии всегда совершают одни и те же действия. Есть небольшой слой, который покрывает специфичные для платформы детали, такие как типы данных и стандартная библиотека, но остальное является общим. Дело не в том, что одна версия является оригинальной, а другая неэффективным портом. Они обе играют в одну игру.

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

Давайте посмотрим на фактическое время, необходимое для компиляции кодовой базы и выполнения полного набора интеграционных тестов. Мы говорим о кодовой базе, которая составляет чуть более 9000 LOC, из которых 4000 тестов:

Clojure 1.10 on JVM:

  • REPL boot time: 1.5 sec
  • Compile time: 6.5 sec
  • Tests time: 0.45 sec


ClojureScript 1.10.439 with advanced compilation:

  • Compile time: 78 sec
  • Tests time: 1 sec


ClojureScript 1.10.439 without Google Closure compilation:

  • Compile time: 24 sec
  • Tests time: 1.3 sec


Итак, о чем нам говорят эти числа? По сути, для обработки одного и того же кода вы можете потратить ~8 секунд, 24 секунды или 78 секунд. Выбор за вами. Кроме того, запустив ту же программу, вы можете получить результат за полсекунды, одну секунду или почти полторы секунды.

Но подожди, Tonsky, их нельзя сравнивать! Это две большие разницы! Они созданы, чтобы делать совершенно разные вещи! Один из них работает в браузере!

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

Что так долго делают компиляторы ClojureScript/Google Closure? Они зря тратят ваше время, вот что. Конечно, никто не виноват, но, в конце концов, все это решение просто неверно. Мы можем делать то же самое намного быстрее, у нас есть доказательства, у нас есть средства для этого, но так уж получилось, что это не так. Но мы могли бы. Если бы захотели. Эти огромные накладные расходы, которые вы платите, вы платите зря. Вы ничего не получаете от JS, кроме удвоения времени выполнения и астрономического времени сборки.

То же самое относится ко всем языкам с ужасно долгим временем сборки. Дело не в том, что они не могли бы работать быстрее. Они просто предпочитают не делать этого. Программа на C ++ или Rust слишком долго компилируется? Что ж, OCaml, вероятно, мог бы скомпилировать эквивалентную программу менее чем за секунду. И это по-прежнему будет быстро на уровне машины.

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

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

Представьте себе: из Москвы в Новосибирск, сердце Сибири, летит самолет, который преодолевает 2800 километров за 4 часа. Еще есть поезд, который преодолевает такое же расстояние за три дня. В поезде нет душа, плохая еда, кровати, на которых нельзя спать. А самолет это комфортабельный современный самолет. Что бы вы выбрали? Цена такая же. Единственная разница это ваш комфорт и ваше время.

image

Если мы примем это как метафору разработки программного обеспечения, вы удивитесь, что программисты с радостью выбирают поезд. Они даже до хрипоты спорят, что есть неопровержимые причины для выбора поезда. Нет, они не возражают, чтобы компилятор не торопился поработать. Хотя существуют более быстрые способы добраться до того же пункта назначения. Легко запутаться, споря о деталях, но помните: мы все оказываемся в одном месте, независимо от того, на каком языке мы говорим.

Браузеры? Та же история. HTML довольно неэффективный способ разместить пиксели на экране. Компьютер, который мог бы отображать миллионы полигонов в кадре, может с трудом прогружать веб-страницу. Как и в случае с решениями Advent of Code, это не зависит от мощности вашего компьютера. И даже высокооптимизированный веб-код, основанный на Canvas и WebAssembly (Figma), заставляет вентиляторы моего Macbook крутиться при полной тишине при запуске собственного Sketch.

image

*похлопывает по крышке* Этот ПК способен запустить Crysis 3 в 4K на 144fps.
Но может ли он запустить Atom?


Просто существуют пределы того, насколько далеко может зайти это неправильное решение. Текстовые редакторы на Electron не могут менять размер окна в реальном времени и проседают по кадрам, когда вы просто двигаете курсором. Slack на iMac Pro будет таким же медленным и требовательным к памяти, как и на 12-дюймовом Macbook.

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

Я могу продолжать и продолжать. Следует помнить следующее: подумайте, что вы от этого получаете. Сопоставимы ли проблема и потраченные на нее ресурсы? Легко найти оправдания тому, почему дела обстоят так, как есть. Все они, вероятно, действительны, но это отговорки. Мы знаем, что возможны более быстрые программы, и это делает все остальное просто неправильным.
Подробнее..

Как реляционная СУБД делает JOIN?

03.06.2021 14:23:54 | Автор: admin

О чем эта статья и кому адресована?

С SQL работают почти все, но даже опытные разработчики иногда не могут ответить на простой вопрос. Каким образом СУБД выполняет самый обычный INNER JOIN?

С другой стороны - разработчики на C# или других ООП языках часто воспринимают СУБД как всего лишь хранилище. И размещать какие-то бизнес-правила в SQL - плохо. В противовес им создаются библиотеки вродеLinq2Db(не путаем сLinq2Sql- совершенно разные авторы и разные библиотеки). При ее использовании весь код пишется на C# и вы получаете все преимущества типизированного языка. Но это формальность. Затем этот код транслируется на SQL и выполняется на стороне СУБД.

Для того чтобы лучше разобраться как работает одинаковый код на SQL и на C# мы попробуем реализовать одно и то же на первом и на втором, а затем разберем как это работает. Если вы хорошо знаете что такоеNested Loop,Merge Join,Hash Join- вам скорее всего имеет смысл прочитать статью по диагонали. А вот если не знаете - статья для вас должна быть полезной.

Работа с несколькими коллекциями

Предположим, что у нас есть некоторый сервисный центр по техническому обслуживанию автомобилей - станция технического обслуживания (СТО). Есть две сущности:Person- клиенты сервисного центра иVisit- конкретное посещение данного центра.Personкроме идентификатора содержит имя, фамилию и статус активности (например, если клиент поменял машину на другую марку - он переводится в статус не активного и уже не будет в ближайшем времени посещать нас).Visitкроме идентификатора содержит в себе ссылку на клиента, дату визита и сумму, которую заплатил клиент за этот визит. Все вышеперечисленное можно было бы оформить с помощью следующих классов на C# для самого простейшего случая:

internal sealed class Person{    internal int Id { get; set; }    internal string FirstName { get; set; }    internal string LastName { get; set; }    internal bool IsActive { get; set; }}internal sealed class Visit{    internal int Id { get; set; }    internal int PersonId { get; set; }    internal DateTime Date { get; set; }    internal decimal Spent { get; set; }}// ...internal Person[] persons = new Person[];internal Visit[] visits = new Visit[];// ...

В базе данных (в дальнейшем мы будем использоватьPostgreSQL) для двух этих сущностей есть две таблицы с аналогичными полями:

create table public.visit(    id integer,    person_id integer,    visit_datetime timestamp without time zone,    spent money) tablespace pg_default;create table public.person(    id integer,    first_name character varying(100) COLLATE pg_catalog."default",    last_name character varying(100) COLLATE pg_catalog."default",    is_active boolean) tablespace pg_default;

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

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

Nested Loop

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

public decimal NestedLoop(){    decimal result = 0;    var upperLimit = new DateTime(2020, 12, 31);    foreach (var person in persons)    {        if (person.IsActive == false)        {            continue;        }                foreach (var visit in visits)        {            if (person.Id == visit.PersonId && visit.Date <= upperLimit)            {                result += visit.Spent;            }        }    }    return result;}

Эта идея анимирована ниже:

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

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

select setseed(0.777);delete from public.person;insert into public.person(id, first_name, last_name, is_active)select row_number() over () as id,substr(md5(random()::text), 1, 10) as first_name,substr(md5(random()::text), 1, 10) as last_name,((row_number() over ()) % 5 = 0) as is_activefrom generate_series(1, 5000);/*<-- 5000 это число клиентов*/delete from public.visit;insert into public.visit(id, person_id, visit_datetime, spent)select row_number() over () as id,(random()*5000)::integer as person_id, /*<-- 5000 это число клиентов*/DATE '2020-01-01' + (random() * 500)::integer as visit_datetime,(random()*10000)::integer as spentfrom generate_series(1, 10000); /* 10000 - это общее число визитов в СТО*/

В данном случае число клиентов CTOPравно 5000, число их визитовV- 10000. Дата визита, а также сам факт визита для клиента генерируются случайным образом из указанных диапазонов. Признак активности клиента выставляется для каждого пятого. В итоге мы получаем некоторый тестовый набор данных, приближенный к реальному. Для тестового набора нам интересна характеристика - число клиентов и посещений. Или(P,V)равное в нашем случае(5000, 10000). Для этого тестового набора мы сделаем следующее: выгрузим его в обьекты C# и с помощью цикла в цикле (Nested Loop) посчитаем суммарные траты наших посетителей. Как это определено в постановке задачи. На моем компьютере получаем приблизительно20.040 миллисекунд, затраченное на подсчет. При этом время получение данных из БД составило все те же самые20.27 миллисекунд. Что в сумме дает около40 миллисекунд. Посмотрим на время выполнения SQL запроса на тех же данных.

select sum(v.spent) from public.visit v                    join public.person p on p.id = v.person_idwhere v.visit_datetime <= '2020-12-31' and p.is_active = True

Все на том же компьютере получилось порядка2.1 миллисекундына все. И кода заметно меньше. Т.е. в 10 раз быстрее самого метода, не считая логики по извлечению данных из БД и их материализации на стороне приложения.

Merge Join

Разница в скорости работы в 20 раз наталкивает на размышления. Скорее всего Nested Loop не очень нам подходити мы должны найти что-то получше. И есть такой алгоритм НазываетсяMerge JoinилиSort-Merge Join. Общая суть в том, что мы сортируем два списка по ключу на основе которого происходит соединение. И делаем проход всего в один цикл. Инкрементируем индекс и если значения в двух списках совпали - добавляем их в результат. Если в левом списке идентификатор больше, чем в правом - увеличиваем индекс массива только для правой части. Если, наоборот, в левом списке идентификатор меньше, то увеличиваем индекс левого массива. Затратность такого алгоритмаO(N*log(N)).

Результат работы такой реализации радует глаз -1.4 миллисекундыв C#. Правда данные из базы данных еще нужно извлечь. А это все те же самые дополнительные20 миллисекунд. Но если вы извлекаете данные из БД, а затем выполняете несколько обработок, то недостаток постепенно нивелируется. Но можно ли подсчитать заданную сумму еще быстрее? Можно!Hash Joinпоможет нам в этом.

Hash Join

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

Видео работы Hash Join (на англ. языке)

Затратность алгоритмаO(N). В .NET стандартный Linq метод как раз его и реализует. В реляционных СУБД часто используются модификации этого алгоритма (Grace hash join,Hybrid hash join) - суть которых сводится к работе в условиях ограниченной оперативной памяти. Замер скорости работы в C# показывает, что этот алгоритм еще быстрее и выполняется за0.9 миллисекунды.

Динамический выбор алгоритма

Отлично! Похоже мы нашли универсальный алгоритм, который самый быстрый. Нужно просто использовать его всегда и не беспокоиться более об этом вопросе. Но если мы учтем еще и расход памяти все станет немного сложнее. Для Nested Loop - память не нужна, Merge Join - нужна только для сортировки (если она будет). Для Hash Join - нужна оперативная память.

Оказывается расход памяти - это еще не все. В зависимости от общего числа элементов в массивах скорость работы разных алгоритмов ведет себя по-разному. Проверим для меньшего числа элементов (P, V) равному (50, 100). И ситуация переворачивается на диаметрально противоположную:Nested Loopсамый быстрый -2.202 микросекунды, Merge Join -4.715 микросекунды, Hash Join -7.638 микросекунды. Зависимость скорости работы каждого алгоритма можно представить таким графиком:

Для нашего примера можно провести серию экспериментов на C# и получить следующую таблицу:

Method

Nested Loop

Merge Join

Hash Join

(10, 10)

62.89 ns

293.22 ns

1092.98 ns

(50, 100)

2.168 us

4.818 us

7.342 us

(100, 200)

8.767 us

10.909 us

16.911 us

(200, 500)

38.77 us

32.75 us

40.75 us

(300, 700)

81.36 us

52.54 us

54.29 us

(500, 1000)

189.58 us

87.10 us

82.85 us

(800, 2000)

606.8 us

173.4 us

172.7 us

(750, 5000)

1410.6 us

428.2 us

397.9 us

А что если узнать значения X1 и X2 и динамически выбирать алгоритм в зависимости от его значения для данных коллекций? К сожалению не все так просто. Наша текущая реализация исходит из статичности коллекции. Что нужно сделать, чтобы вставить еще один визит за 2020 год? В массив в коде на C#. В массив фиксированного размера он, очевидно, не поместится. Нужно выделять новый массив размером на один элемент больше. Скопировать туда все данные, вставлять новый элемент. Понятно, что это дорого. Как насчет того, чтобы заменить Array на List? Уже лучше, т.к. он предоставляет все необходимое API. Как минимум удобно, но если посмотреть на его реализацию - под капотом используется все тот же массив. Только резервируется памяти больше чем надо С запасом. Для нас это означает лишние траты памяти. LinkedList? Здесь должно быть все нормально. Давайте поменяем коллекцию и посмотрим что из этого получится.

Method

Nested Loop

Nested Loop with Linked List

(10, 10)

62.89 ns

262.97 ns

(50, 100)

2.188 us

8.160 us

(100, 200)

8.196 us

32.738 us

(200, 500)

39.24 us

150.92 us

(300, 700)

80.99 us

312.71 us

(500, 1000)

196.3 us

805.20 us

(800, 2000)

599.3 us

2359.1 us

(750, 5000)

1485.0 us

5750.0 us

Время выполнения не только изменилось. Сама кривая стала более крутой и с числом элементов время растет:

Таким образом мы приходим к понимаю, что время доступа к каждому конкретному элементу коллекции крайне важно. Одно из главных преимуществ реляционных СУБД в том, что они всегда готовы к добавлению новых данных в любой диапазон. При этом это добавление произойдет максимально эффективным образом - не будет релокации всего диапазона данных или т.п. Кроме того данные СУБД часто хранится в одном файле - таблицы и их данные. Если утрировать, то здесь также используется связанный список. В случае с PostgreSQL данные представлены в страницах (page), внутри страницы располагаются кортежи данных (tuples). В общих чертах вы можете себе это увидеть на картинках ниже. А если захотите узнать больше деталей, то ниже также есть и ссылка.

Более детально описано в первоисточникеЗдесь

Структура кортежа также адаптирована для хранения практически любых данных в таблице, на их обновление и вставку в любой участок диапазона:

Более детально описано в первоисточникеЗдесь.

В оперативную память попадают страницы, а попадают они вbuffer poolчерезbuffer manager. Все это сказывается на стоимости доступа к каждому конкретному значению таблицы. Вне зависимости от того что используетсяNested Loop,Merge JoinилиHash Join. Другой вопрос, что в зависимости от алгоритма число обращений может отличаться в разы. Поэтому реляционные СУБД подходят динамически к выбору алгоритма в каждом конкретном запросе и строят план запроса (Query Plan).

Сравним для большого числа элементов насколько будет отличаться время обработки с одним и тем же алгоритмом в БД и на C#. (P, V) будет равно (50000, 100000). В коде на C# загрузка данных из БД занимает145.13 миллисекунд. Дополнительно к этому выполнение самой логики сNested Loopна основе обычного массива -305.38 миллисекунд,Hash Join-36.59 миллисекунд. Для того чтобы проверить в СУБД такую же реализацию мы будем использовать такой скрипт:

set enable_hashjoin to 'off';--Заставляем БД использовать Nested Loopset enable_mergejoin to 'off';set enable_material to 'off';select sum(v.spent) from public.visit vjoin public.person p on p.id = v.person_idwhere v.visit_datetime <= '2020-12-31' and p.is_active = True

На аналогичных данных в БД сNested Loopзапрос выполнится за11247.022 миллисекунд. Что может говорить о сильно большем времени доступа к каждому конкретному элементу:

Но СУБД приходится заставлять работать так, чтобы она использовалаNested Loop. Изменим наш скрипт таким образом:

set enable_hashjoin to 'on';set enable_mergejoin to 'on';set enable_material to 'on';select sum(v.spent) from public.visit vjoin public.person p on p.id = v.person_idwhere v.visit_datetime <= '2020-12-31' and p.is_active = True

По-умолчанию для такого объема данных будет, конечно выбранHash Join:

И мы видим, что время выполнение составило25.806 миллисекунды, что сопоставимо по скорости с реализацией на C# и даже немного быстрее.

Как мы видим, СУБД может динамически подстраиваться под данные и автоматически выбирать алгоритм, наиболее подходящий в данной конкретной ситуации. Процесс выбора такого способа выполнения запроса возложен напланировщик запросов. В итоге он выдает план запроса, где четко расписано какой алгоритм использовать, какой использовать индекс и т.п.

Выводы

На примере простейшей задачи мы в общих чертах разобрали как работает типичная реляционная СУБД при реализации JOIN. Сравнивать коллекции C# и SQL не очень корректно, за внешней схожестью скрывается серьезное различие в предназначении. Реляционная СУБД призвана обеспечитьконкурентный доступ к данным максимально эффективным способом(при этом подразумевается, что сами данные могут постоянно модифицироваться). Кроме того, данные могут не помещаться в оперативную память и частично храниться на диске.

Более того, СУБД обязана обеспечить сохранность данных на постоянном носителе - одно из основных ее предназначений. При этом на получение данных СУБД динамически выбирает алгоритм, наиболее эффективный в данном случае. В C# аналагичных библиотек или реализаций просто нет И это показательно, т.к. лишь свидетельствует об отсутствии такой необходимости. Linq метод Join реализуетHash Join, который потенциально тратит больше оперативной памяти, но это просто не берется в расчет. Т.к. мало кого интересует применительно к решаемым задачам.

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

Подробнее..

Чиним проблемы нагрузок в Go с помощью настройки пула HTTP-соединений

09.06.2021 20:16:20 | Автор: admin

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

Проблема скрывалась внутри нашего API Gateway. Это сервис, который реализует паттерн Фасад и предоставляет единственное торчащее наружу окно к микросервисам.

В упрощенном виде его работу можно представить так:

  1. Проверить аутентификацию и авторизацию с помощью HTTP-запроса в сервис аутентификации

  2. Спроксировать запрос в нужный сервис, который определяем по пути и методу запроса пользователя

Иллюстрация работы API GatewayИллюстрация работы API Gateway

Конец декабря время роста нагрузок и числа ошибок

Настал конец декабря. Вместе с ним к нам в поддержку стали приходить вот такие обращения:

При работе в ЛК возникают постоянные ошибки системные по 10-20 раз на дню и больше. Просьба исправить и наладить работу площадки.

Ошибки возникали на стороне API Gateway. Мы полезли в логи за подробностями и увидели ошибки, похожие на таймауты обращения к сервису аутентификацию:

{err_type: context.deadlineExceededError, err: context deadline exceeded}{err_type: *errors.errorString, err: context canceled}

Трейсы в Jaeger показали ровно такую же картину мы не дожидались ответа от сервиса аутентификации за 2 секунды. Поэтому между нами и разработчиками сервиса аутентификации произошёл примерно такой диалог:

- Ребята, кажется, вы таймаутите. Вот трейс, на котором видно, что мы не дождались от вас ответа за 2 секунды.

- Ничего подобного, у нас все норм мы за 200 миллисекунд отвечаем в 99% запросов. А вот вы по какой-то причине часто преждевременно обрываете соединение.

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

Скриншот с множеством ошибок Cancelled by clientСкриншот с множеством ошибок Cancelled by client

Итого, мы имеем:

  1. Используемый нами сервис аутентификации стабильно отрабатывает за 200 миллисекунд.

  2. Многие наши обращения к этому сервису таймаутят за 2 секунды.

Причина проблемы: дефолтные настройки в Go

В это время один из авторов нашего API Gateway отметил, что очень давно заметил аномалию: сервис открывает неожиданно много соединений к удаленным портам. При запуске команды из-под контейнера видно:

$ ss -natp state time-wait | awk '{print $4}' | sort -nr | uniq -c | sort -nr | head1053 10.20.49.117:801030 10.20.49.92:801016 10.20.49.91:801014 10.20.54.129:801013 10.20.53.213:801008 10.20.53.173:80969 10.20.53.172:80

Эта команда показывает количество TCP-сокетов в состоянии TIME_WAIT до разных удалённых портов. Если коротко, то состояние TIME_WAIT это де-факто закрытое клиентом соединение. Linux по возможности предотвращает повторное использование этих пар на протяжении 60 секунд, чтобы защититься от того, что старые пакеты помешают вновь установленному TCP-соединению.

Но для нас важно другое. Само существование TCP-соединения означает, что соединение установилось и закрылось. Если такая ситуация происходит массово, то мы имеем дело с накладными расходами на DNS-резолвинг и установку соединения. В результате этого время HTTP-запроса может увеличиваться. Избежать эту проблему помогают пулы соединении. В Go для этои цели используется абстракция http.Transport.

Здесь мы вплотную приближаемся к истокам проблемы. Мы для всех клиентских запросов использовали http.DefaultTransport. Он обладает следующими параметрами:

var DefaultTransport RoundTripper = &Transport{    Proxy: ProxyFromEnvironment,    DialContext: (&net.Dialer{        Timeout:   30 * time.Second,        KeepAlive: 30 * time.Second,    }).DialContext,    ForceAttemptHTTP2:     true,    MaxIdleConns:          100,    IdleConnTimeout:       90 * time.Second,    TLSHandshakeTimeout:   10 * time.Second,    ExpectContinueTimeout: 1 * time.Second,}

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

  • MaxIdleConns число соединений, которое разрешается иметь в состоянии Idle (т.е. открытых TCP-соединений, которые в данный момент не используются);

  • IdleConnTimeout время, через которое закрываются такие неактивные соединения.

Однако в DefaultTransport совершенно не указан другой ключевой параметр MaxIdleConnsPerHost. Он отвечает за то, сколько неактивных TCP-соединений допускается устанавливать на один хост.

При этом если MaxIdleConnsPerHost не указан, тогда используется значение по умолчанию:

const DefaultMaxIdleConnsPerHost = 2

Поскольку мы использовали именно http.DefaultTransport для всех запросов, мы получили следующую проблему.

Представим, что нам одновременно понадобилось установить 10 соединений до сервиса аутентификации. Тогда хотя бы для 8 из них будут открыты и вскоре тут же закрыты TCP-соединения, из-за ограничения MaxIdleConnsPerHost. Если такая ситуация будет повторяться часто, у нас будет больше накладных расходов на один HTTP-запрос, поскольку для него понадобится новое соединение. Из-за этого вероятность таймаутов возрастает.

Решение: отдельный транспорт с особенными настройками

Чтобы решить проблему, мы сделали следующее:

  • Выделили отдельный транспорт под сервисы аутентификации и авторизации, чтобы транспорт под сервис аутентификации не вытеснял остальные сервисы.

  • Утилизировали выделенный пул на полную сделали так, чтобы значение MaxIdleConnsPerHost соответствовало значению MaxIdleConns:

func createOneHostTransport() *http.Transport {    result := http.DefaultTransport.(*http.Transport).Clone()    result.MaxIdleConnsPerHost = result.MaxIdleConns    return result}
График response time обращения к сервису аутентификацииГрафик response time обращения к сервису аутентификации

Тут видно значительное уменьшение 0.99-квантиля по времени обращения графиков (голубой цвет) с 2-3 секунд до менее 300 миллисекунд. Должен признать, даже после этого мы изредка видели таймауты при обращении к сервису аутентификации. Но теперь мы хотя бы видели эти же таймауты на графиках другого сервиса.

Но почему в Go такие настройки по умолчанию?

Возможно, у вас сейчас возник вопрос: зачем делать такие настройки, которые потом приходится исправлять? Неужели разработчики языка Go и библиотек к нему не подумали о том, как это будет использоваться на практике?

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

Если бы приложения могли говорить...

Чтобы лучше понять предназначение параметра MaxIdleConnsPerHost, представим, что компоненты системы научились говорить друг с другом. Тогда если бы мы выставили значение MaxIdleConnsPerHost равным MaxIdleConns, между нашим приложением и пулом соединений мог бы произойти такой диалог:

Приложение: http.Transport, привет! Нам тут сотня пользователей пришла одновременно и им всем вдруг срочно понадобилось посмотреть инфу о своём профиле. Установи, пожалуйста, сотню соединений к user-service

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

Приложение: Всё сработало, спасибо! Только вот теперь им всем вдруг захотелось посмотреть информацию о товаре. Пожалуйста, установи соединение с product-service.

http.Transport: Да, не вопрос держи. Но только я их у себя в пуле соединений хранить не буду, поскольку он уже полностью забит соединениями до user-service, которые тебе больше не нужны.

Приложение: (_)

Разбираемся с непонятными таймаутами в Go: чеклист

Если вдруг вы сталкиваетесь с непонятными таймаутами, попробуйте следующее:

  1. Проверьте метрики по скорости выполнения запросов к сторонним сервисам по HTTP. Если таких метрик нет, заведите пригодятся.

  2. Если видите расхождения в таймаутах клиента и сервера, проверьте количество соединений TIME_WAIT.

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

  4. Для хоста с неоправданно большим числом запросов к нему рассмотрите вариант заведения отдельного транспорта.

Что ещё почитать по теме

Подробнее..

Вебинар Вычисляем на видеокартах. Технология OpenCL

18.06.2021 12:04:53 | Автор: admin
22 июня в 18.30 (Мск) Яндекс.Практикум проведет открытый вебинар Вычисляем на видеокартах. Технология OpenCL. На вебинаре расскажем, как использовать видеокарту в качестве полноценного вычислительного устройства, мощности которого чаще всего простаивают.

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



Для кого?


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


В программе вебинара


1. История возникновения и особенности видеокарт


Почему устройство, созданное для графики в играх, идеально для прогнозирования погоды, майнинга криптовалюты и deep learning

2. Архитектура видеокарты


О написании кода, если у вас 4000 ядер, правильном использовании видеопамяти, локальных и глобальных размерах, рабочих группах и ворпах

3. Конкретные цифры: обзор технологий


  • CUDA vs OpenCL. Преимущества и недостатки этих технологий
  • Чем отличаются видеокарты компаний NVidia и AMD Radeon


4. Как программировать на OpenCL


  • Пишем первую программу для видеокарты
  • Разберём, что можно, а что нельзя на OpenCL
  • Посмотрим, как использовать OpenCL не только с C++, но и с другими языками программирования


5. Популярные алгоритмы


  • Базовые алгоритмы и принципы программирования через призму видеокарты
  • Разбор типичных проблем


6. Отладка, обфускация, тулинг


  • Что делать, если ваш код BSODит. Отладка на видеокарте: миф или реальность
  • Профилируем OpenCL и делаем его быстрее
  • Как защитить свой код: пара слов об обфускации


7. Q&A-сессия



Ведущий Георгий xjossy Осипов, разработчик в Лаборатории компьютерной графики и мультимедиа ВМК МГУ и автор факультета Разработчик C++ в Яндекс.Практикуме

Вебинар пройдёт 22 июня в 18.30 (Мск). Подробности и регистрация
Подробнее..

Производительность компилятора при работе с концептами в C20

20.06.2021 18:15:44 | Автор: admin

Привет, меня зовут Александр, я старший разработчик ПО в Центре разработкиOrionInnovation. Хочу признаться, я люблю рассказывать про C++ и не только на различных митапах и конференциях.Ивотядобрался доХабра. НаCppConfRussiaPiter2020 я рассказывал про концепты и послевыступленияполучилочень много вопросов про производительность компилятора при работе сними.Замеры производительности не были цельюмоегодоклада:мне было известно, что концепты компилируются с примерно такой же скоростью, что и обычные метапрограммы,адодетального сравнения я смог добраться совершенно недавно.Спешуподелиться результатом!

Несколько слов о концептах

Концептыпереосмыслениеметапрограммирования, аналогичноеconstexpr.Еслиconstexprэто про вычисление выраженийво время компиляции, будь то факториал, экспонента и так далее, то концептыэто про перегрузки, специализации, условия существования сущностей.Вобщем, про чистоеметапрограммирование. Иными словами, в C++20 появилась возможность писать конструкциибез единой, привычной для нас треугольной скобки, тем самым получая возможность быстро и читаемо описать какую-либо перегрузку или специализацию:

// #1void increment(auto & arg) requires requires { ++arg; }; // #2void increment(auto &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);  // Вызывается #1    increment(ni); // Вызывается #2}

О том, как всё это работает, есть море информации,например, отличный гайд "Концепты: упрощаем реализацию классов STD Utility" по мотивам выступления Андрея Давыдова на C++ Russia 2019. Ну а мы сфокусируемся на том, какой ценой достигается подобный функционал, чтобы убедиться, чтоэтонетолькопросто, быстро и красиво, ноещёи эффективно.

Описание эксперимента

Итак, мы будем наблюдать за следующими показателями:

  1. Время компиляции

  1. Размер объектного файла

  1. Количество символов в записи (или же количество кода), в некоторых случаях

Прежде чем мы начнём несколько важных уточнений:

  • Во-первых, при подсчёте количества символов в записи мы будем считать все не пустые.

  • Во-вторых, в данной статье мы посмотрим лишь на самые простые (буквально несколько строк) случаи, чтобы быть уверенными на 100%, что мы сравниваем абсолютно аналогичные фрагменты кода.

  • В-третьих, поскольку компилируемые примеры крайне просты, время компиляции выражается в десятках миллисекунд.Чтобы исключитьпогрешность, длявремени компиляции мы будем использовать усреднённые значения за100запусков.

В замерах будут участвовать clang 12.0.0 и g++ 10.3.0, как с полной оптимизацией, так и без неё.

В качестве операционной системы выступит Ubuntu 16.04, запущенная на Windows 10 через WSL2.На всякий случай прилагаю характеристики ПК:

Характеристики ПК
------------------System Information------------------         Operating System: Windows 10 Enterprise 64-bit (10.0, Build 19043) (19041.vb_release.191206-1406)                 Language: Russian (Regional Setting: Russian)      System Manufacturer: Dell Inc.             System Model: Latitude 5491                     BIOS: 1.12.0 (type: UEFI)                Processor: Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz (8 CPUs), ~2.3GHz                   Memory: 32768MB RAM      Available OS Memory: 32562MB RAM                Page File: 9995MB used, 27430MB available------------------------Disk & DVD/CD-ROM Drives------------------------      Drive: C: Free Space: 26.5 GBTotal Space: 243.0 GBFile System: NTFS      Model: SAMSUNG SSD PM871b M.2 2280 256GB

Эксперименты

Посленеобходимыхотступлениймы можем,наконец,начать эксперименты.

Эксперимент 1: Эволюция метапрограммирования

Для началапосмотрим на то, как компиляторы справляются с созданием перегрузки функции для инкрементируемых инеинкрементируемыхтипов данных аргумента. Компилируемый код для C++ 03, 17 и 20 представлены ниже. Один из показателей, а именнообъем кода, можно оценить уже сейчас: видно, что количество кода существенно сокращается по мере эволюции языка, уступая место читаемости и простоте.

Код
incrementable_03.cpp
// copied from boosttemplate<bool C, typename T = void>struct enable_if { typedef T type; };template<typename T>struct enable_if<false, T> {};namespace is_inc {    typedef char (&yes)[1]; typedef char (&no)[2];struct tag {};struct any { template &lt;class T&gt; any(T const&amp;); };tag operator++(any const &amp;);template&lt;typename T&gt;static yes test(T const &amp;);static no test(tag);template&lt;typename _T&gt; struct IsInc{    static _T &amp; type_value;    static const bool value = sizeof(yes) == sizeof(test(++type_value));};}template<typename T>struct IsInc : public is_inc::IsInc<T> {};template<class Ty>typename enable_if<IsInc<Ty>::value>::type increment(Ty &);template<class Ty>typename enable_if<!IsInc<Ty>::value>::type increment(Ty &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}
incrementable_17.cpp
#include <type_traits>template<class, class = std::void_t<>>struct IsInc : std::false_type {};template<class T>struct IsInc<T, std::void_t<decltype( ++std::declval<T&>() )>>    : std::true_type{};template<class Ty>std::enable_if_t<IsInc<Ty>::value> increment(Ty &);template<class Ty>std::enable_if_t<!IsInc<Ty>::value> increment(Ty &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}
incrementable_20.cpp
void increment(auto & arg) requires requires { ++arg; };void increment(auto &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}

Давайте взглянем на результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

Количество символов, шт

incrementable_03.cpp

clang

O0

43,02

1304

782

incrementable_17.cpp

clang

O0

67,46

1320

472

incrementable_20.cpp

clang

O0

43,42

1304

230

incrementable_03.cpp

clang

O3

47,21

1296

782

incrementable_17.cpp

clang

O3

77,77

1304

472

incrementable_20.cpp

clang

O3

45,70

1288

230

incrementable_03.cpp

gcc

O0

19,89

1568

782

incrementable_17.cpp

gcc

O0

34,71

1568

472

incrementable_20.cpp

gcc

O0

17,62

1480

230

incrementable_03.cpp

gcc

O3

18,44

1552

782

incrementable_17.cpp

gcc

O3

38,94

1552

472

incrementable_20.cpp

gcc

O3

18,57

1464

230

Как уже отмечалось ранее,количество кода существенно уменьшается по мере развития языка: c 782 до 472 и затем до 230.Разницапочти в 3,5 раза, если сравнитьС++20 и С++03 (на самом деле даже больше,т.к.порядка150170символов во всех примерахтестирующий код). Размеры объектного файла также постепенно уменьшаются. Что же современем компиляции? Странно, новремя компиляции 03 и 20 примерно равно, а вот в С++17в два раза больше. Давайте взглянем на код наших примеров: помимо всего прочего, в глаза бросается#includeв случае C++17. Давайте реализуемdeclval,enable_ifиvoid_tи проверим:

incrementable_no_tt.cpp
template<bool C, typename T = void>struct enable_if { typedef T type; };template<typename T>struct enable_if<false, T> {};template<bool B, typename T = void>using enable_if_t = typename enable_if<B, T>::type;template<typename ...>using void_t = void;template<class T>T && declval() noexcept;template<class, class = void_t<>>struct IsInc {    constexpr static bool value = false;};template<class T>struct IsInc<T, void_t<decltype( ++declval<T&>() )>>{    constexpr static bool value = true;};template<class Ty>enable_if_t<IsInc<Ty>::value> increment(Ty &);template<class Ty>enable_if_t<!IsInc<Ty>::value> increment(Ty &);struct Incrementable { Incrementable & operator++() { return *this; } };struct NonIncrementable {};void later() {    Incrementable     i;    NonIncrementable ni;    increment(i);    increment(ni);}

И давайте обновим нашу таблицу:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

Количество символов, шт

incrementable_03.cpp

clang

O0

43,02

1304

782

incrementable_17_no_tt.cpp

clang

O0

44,498

1320

714

incrementable_20.cpp

clang

O0

43,419

1304

230

incrementable_03.cpp

clang

O3

47,205

1296

782

incrementable_17_no_tt.cpp

clang

O3

47,327

1312

714

incrementable_20.cpp

clang

O3

45,704

1288

230

incrementable_03.cpp

gcc

O0

19,885

1568

782

incrementable_17_no_tt.cpp

gcc

O0

21,163

1584

714

incrementable_20.cpp

gcc

O0

17,619

1480

230

incrementable_03.cpp

gcc

O3

18,442

1552

782

incrementable_17_no_tt.cpp

gcc

O3

19,057

1568

714

incrementable_20.cpp

gcc

O3

18,566

1464

230

Время компиляции на 17 стандарте нормализовалось и стало практически равно времени компиляции 03 и 20, однако количество кода стало близко к самому тяжёлому, базовому варианту. Так что, если у вас есть под рукой C++20 и нужно написать какую-то простую мета-перегрузку,смело можно использовать концепты. Это читабельнее, компилируется примерно с такой же скоростью, а результат компиляции занимает меньше места.

Эксперимент 2: Ограничения для методов

Давайте взглянем на еще одну особенность: ограничение для функции или метода (в том числе и для конструкторов и деструкторов) на примере типаOptionalLike, имеющего деструктор по умолчанию в случае, если помещаемый объект тривиален, а иначедеструктор, выполняющийдеинициализациюкорректно. Код представлен ниже:

Код
optional_like_17.cpp
#include <type_traits>#include <string>template<typename T, typename = void>struct OptionalLike {    ~OptionalLike() {        /* Calls d-tor manually */    }};template<typename T>struct OptionalLike<T, std::enable_if_t<std::is_trivially_destructible<T>::value>>{    ~OptionalLike() = default;};void later() {    OptionalLike<int>         oli;    OptionalLike<std::string> ols;}
optional_like_20.cpp
#include <type_traits>#include <string>template<typename T>struct OptionalLike{    ~OptionalLike() {        /* Calls d-tor manually */    }    ~OptionalLike() requires (std::is_trivially_destructible<T>::value) = default;};void later() {    OptionalLike<int>         oli;    OptionalLike<std::string> ols;}

Давайте взглянем на результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

Количество символов, шт

optional_like_17.cpp

clang

O0

487,62

1424

319

optional_like_20.cpp

clang

O0

616,8

1816

253

optional_like_17.cpp

clang

O3

490,07

944

319

optional_like_20.cpp

clang

O3

627,64

1024

253

optional_like_17.cpp

gcc

O0

202,29

1968

319

optional_like_20.cpp

gcc

O0

505,82

1968

253

optional_like_17.cpp

gcc

O3

205,55

1200

319

optional_like_20.cpp

gcc

O3

524,54

1200

253

Мы видим, что новый вариант выглядит более читабельным и лаконичным (253 символа против 319 у классического), однако платим за это временем компиляции: оба компилятора как с оптимизацией, так и без показали худшее время компиляции в случае с концептами. GCC аж в 22,5 раза медленнее. При этом размер объектного файла уgccне изменяется вовсе, а в случаеclangбольше для концептов. Классический компромисс: либо меньше кода, но дольше компиляция, либо больше кода, но быстрее компиляция.

Эксперимент 3: Влияние использования концептов на время компиляции

Мы знаем, что накладывать ограничения на типможноиспользуя именованные наборы требований, они же концепты.Такжеможно указать требования непосредственно в момент объявления шаблонной сущности. Давайте посмотрим, есть ли разница с точки зрения компилятора. Компилировать будем следующие фрагменты:

Код
inline.cpp
template<typename T>void foo() requires (sizeof(T) >= 4) { }template<typename T>void foo() {}void later() {    foo<char>();    foo<int>();}
concept.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>void foo() requires IsBig<T> { }template<typename T>void foo() {}void later() {    foo<char>();    foo<int>();}

Сразу взглянем на результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

inline.cpp

clang

O0

38,666

1736

concept.cpp

clang

O0

39,868

1736

concept.cpp

clang

O3

42,578

1040

inline.cpp

clang

O3

43,610

1040

inline.cpp

gcc

O0

14,598

1976

concept.cpp

gcc

O0

14,640

1976

concept.cpp

gcc

O3

14,872

1224

inline.cpp

gcc

O3

14,951

1224

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

Эксперимент 4: Варианты ограничения функции

Теперь посмотрим на варианты наложения ограничения на шаблонные параметры на примере функций. Ограничить функцию можно аж четырьмя способами:

  • Имя концепта вместоtypename

  • Requires clauseпослеtemplate<>

  • Имя концепта рядом сauto

  • Trailing requiresclause

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

Код
instead_of_typename.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<IsBig T>void foo(T const &) { }template<typename T>void foo(T const &) {}void later() {    foo<char>('a');    foo<int>(1);}
after_template.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>    requires IsBig<T>void foo(T const &) { }template<typename T>void foo(T const &) {}void later() {    foo<char>('a');    foo<int>(1);}
with_auto.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>void foo(IsBig auto const &) { }template<typename T>void foo(auto const &) {}void later() {    foo<char>('a');    foo<int>(1);}
requires_clause.cpp
template<typename T>concept IsBig = sizeof(T) >= 4;template<typename T>void foo(T const &) requires IsBig<T> { }template<typename T>void foo(T const &) {}void later() {    foo<char>('a');    foo<int>(1);}

А вот и результаты:

Файл

Компиляция

Время, мс

Размер объектного файла, байт

function_with_auto.cpp

clang

O0

40,878

1760

function_after_template.cpp

clang

O0

41,947

1760

function_requires_clause.cpp

clang

O0

42,551

1760

function_instead_of_typename.cpp

clang

O0

46,893

1760

function_with_auto.cpp

clang

O3

43,928

1024

function_requires_clause.cpp

clang

O3

45,176

1032

function_after_template.cpp

clang

O3

45,275

1032

function_instead_of_typename.cpp

clang

O3

50,42

1032

function_requires_clause.cpp

gcc

O0

16,561

2008

function_with_auto.cpp

gcc

O0

16,692

2008

function_after_template.cpp

gcc

O0

17,032

2008

function_instead_of_typename.cpp

gcc

O0

17,802

2016

function_requires_clause.cpp

gcc

O3

16,233

1208

function_with_auto.cpp

gcc

O3

16,711

1208

function_after_template.cpp

gcc

O3

17,216

1208

function_instead_of_typename.cpp

gcc

O3

18,315

1216

Как мы видим, время компиляции отличается незначительно, однако мы можем заметить следующее:

  • Вариант с использованием имени концепта вместоtypenameоказался самым медленным во всех случаях.

  • Вариантыtrailing requiresclauseили использование концепта рядом сautoоказались самыми быстрыми.

  • Варианты, где присутствуетtemplate<>на510% медленнее остальных.

  • Размерыобъектных файлов изменяются незначительно, однако вариант с именем концепта вместоtypenameоказался самым объемным в случаеgcc, а вариант сautoоказался наименее объемным в случаеclang.

Эксперимент 5: Влияние сложности концепта на время компиляции

Последнее, что мырассмотрим в рамкахданной статьи, и, наверное, самое интересное: влияние сложности концепта на время компиляции. Давайте возьмём и скомпилируем следующие примеры, где сложность используемого концепта (количество проверок или условий) возрастает от первого к последнему.

Код
concept_complexity_1.cpp
template<typename T>concept ConceptA = sizeof(T) >= 1;template<typename T>concept TestedConcept = ConceptA<T>;void foo(TestedConcept auto const &) {}void foo(auto const &) {}void later() {    int i { 0 };    int * ip = &i;    foo(i);    foo(ip);}
concept_complexity_2.cpp
template<typename T>concept ConceptA = sizeof(T) >= 1;template<typename T>concept ConceptB =  requires(T i, int x) {    { i++     } noexcept -> ConceptA;    { ++i     } noexcept -> ConceptA;    { i--     } noexcept -> ConceptA;    { --i     } noexcept -> ConceptA;    { i + i   } noexcept -> ConceptA;    { i - i   } noexcept -> ConceptA;    { i += i  } noexcept -> ConceptA;    { i -= i  } noexcept -> ConceptA;    { i * i      } noexcept -> ConceptA;    { i / i      } noexcept -> ConceptA;    { i % i      } noexcept -> ConceptA;    { i *= i     } noexcept -> ConceptA;    { i /= i     } noexcept -> ConceptA;    { i %= i     } noexcept -> ConceptA;    { i |  i     } noexcept -> ConceptA;    { i &  i     } noexcept -> ConceptA;    { i |= i     } noexcept -> ConceptA;    { i &= i     } noexcept -> ConceptA;    { ~i          } noexcept -> ConceptA;    { i ^  i      } noexcept -> ConceptA;    { i << x      } noexcept -> ConceptA;    { i >> x      } noexcept -> ConceptA;    { i ^=  i      } noexcept -> ConceptA;    { i <<= x      } noexcept -> ConceptA;    { i >>= x      } noexcept -> ConceptA;};template<typename T>concept ConceptC =  requires(T i, int x) {    { i++     } noexcept -> ConceptB;    { ++i     } noexcept -> ConceptB;    { i--     } noexcept -> ConceptB;    { --i     } noexcept -> ConceptB;    { i + i   } noexcept -> ConceptB;    { i - i   } noexcept -> ConceptB;    { i += i  } noexcept -> ConceptB;    { i -= i  } noexcept -> ConceptB;    { i * i      } noexcept -> ConceptB;    { i / i      } noexcept -> ConceptB;    { i % i      } noexcept -> ConceptB;    { i *= i     } noexcept -> ConceptB;    { i /= i     } noexcept -> ConceptB;    { i %= i     } noexcept -> ConceptB;    { i |  i     } noexcept -> ConceptB;    { i &  i     } noexcept -> ConceptB;    { i |= i     } noexcept -> ConceptB;    { i &= i     } noexcept -> ConceptB;    { ~i          } noexcept -> ConceptB;    { i ^  i      } noexcept -> ConceptB;    { i << x      } noexcept -> ConceptB;    { i >> x      } noexcept -> ConceptB;    { i ^=  i      } noexcept -> ConceptB;    { i <<= x      } noexcept -> ConceptB;    { i >>= x      } noexcept -> ConceptB;};template<typename T>concept ConceptD =  requires(T i, int x) {    { i++     } noexcept -> ConceptC;    { ++i     } noexcept -> ConceptC;    { i--     } noexcept -> ConceptC;    { --i     } noexcept -> ConceptC;    { i + i   } noexcept -> ConceptC;    { i - i   } noexcept -> ConceptC;    { i += i  } noexcept -> ConceptC;    { i -= i  } noexcept -> ConceptC;    { i * i      } noexcept -> ConceptC;    { i / i      } noexcept -> ConceptC;    { i % i      } noexcept -> ConceptC;    { i *= i     } noexcept -> ConceptC;    { i /= i     } noexcept -> ConceptC;    { i %= i     } noexcept -> ConceptC;    { i |  i     } noexcept -> ConceptC;    { i &  i     } noexcept -> ConceptC;    { i |= i     } noexcept -> ConceptC;    { i &= i     } noexcept -> ConceptC;    { ~i          } noexcept -> ConceptC;    { i ^  i      } noexcept -> ConceptC;    { i << x      } noexcept -> ConceptC;    { i >> x      } noexcept -> ConceptC;    { i ^=  i      } noexcept -> ConceptC;    { i <<= x      } noexcept -> ConceptC;    { i >>= x      } noexcept -> ConceptC;};template<typename T>concept TestedConcept = ConceptA<T> && ConceptB<T> && ConceptC<T> && ConceptD<T>;void foo(TestedConcept auto const &) {}void foo(auto const &) {}void later() {    int i { 0 };    int * ip = &i;    foo(i);    foo(ip);}
concept_complexity_3.cpp
template<typename T>concept ConceptA = sizeof(T) >= 1;template<typename T>concept ConceptB =  requires(T i, int x) {    { i++     } noexcept -> ConceptA;    { ++i     } noexcept -> ConceptA;    { i--     } noexcept -> ConceptA;    { --i     } noexcept -> ConceptA;    { i + i   } noexcept -> ConceptA;    { i - i   } noexcept -> ConceptA;    { i += i  } noexcept -> ConceptA;    { i -= i  } noexcept -> ConceptA;    { i * i      } noexcept -> ConceptA;    { i / i      } noexcept -> ConceptA;    { i % i      } noexcept -> ConceptA;    { i *= i     } noexcept -> ConceptA;    { i /= i     } noexcept -> ConceptA;    { i %= i     } noexcept -> ConceptA;    { i |  i     } noexcept -> ConceptA;    { i &  i     } noexcept -> ConceptA;    { i |= i     } noexcept -> ConceptA;    { i &= i     } noexcept -> ConceptA;    { ~i          } noexcept -> ConceptA;    { i ^  i      } noexcept -> ConceptA;    { i << x      } noexcept -> ConceptA;    { i >> x      } noexcept -> ConceptA;    { i ^=  i      } noexcept -> ConceptA;    { i <<= x      } noexcept -> ConceptA;    { i >>= x      } noexcept -> ConceptA;};template<typename T>concept ConceptC =  requires(T i, int x) {    { i++     } noexcept -> ConceptB;    { ++i     } noexcept -> ConceptB;    { i--     } noexcept -> ConceptB;    { --i     } noexcept -> ConceptB;    { i + i   } noexcept -> ConceptB;    { i - i   } noexcept -> ConceptB;    { i += i  } noexcept -> ConceptB;    { i -= i  } noexcept -> ConceptB;    { i * i      } noexcept -> ConceptB;    { i / i      } noexcept -> ConceptB;    { i % i      } noexcept -> ConceptB;    { i *= i     } noexcept -> ConceptB;    { i /= i     } noexcept -> ConceptB;    { i %= i     } noexcept -> ConceptB;    { i |  i     } noexcept -> ConceptB;    { i &  i     } noexcept -> ConceptB;    { i |= i     } noexcept -> ConceptB;    { i &= i     } noexcept -> ConceptB;    { ~i          } noexcept -> ConceptB;    { i ^  i      } noexcept -> ConceptB;    { i << x      } noexcept -> ConceptB;    { i >> x      } noexcept -> ConceptB;    { i ^=  i      } noexcept -> ConceptB;    { i <<= x      } noexcept -> ConceptB;    { i >>= x      } noexcept -> ConceptB;};template<typename T>concept ConceptD =  requires(T i, int x) {    { i++     } noexcept -> ConceptC;    { ++i     } noexcept -> ConceptC;    { i--     } noexcept -> ConceptC;    { --i     } noexcept -> ConceptC;    { i + i   } noexcept -> ConceptC;    { i - i   } noexcept -> ConceptC;    { i += i  } noexcept -> ConceptC;    { i -= i  } noexcept -> ConceptC;    { i * i      } noexcept -> ConceptC;    { i / i      } noexcept -> ConceptC;    { i % i      } noexcept -> ConceptC;    { i *= i     } noexcept -> ConceptC;    { i /= i     } noexcept -> ConceptC;    { i %= i     } noexcept -> ConceptC;    { i |  i     } noexcept -> ConceptC;    { i &  i     } noexcept -> ConceptC;    { i |= i     } noexcept -> ConceptC;    { i &= i     } noexcept -> ConceptC;    { ~i          } noexcept -> ConceptC;    { i ^  i      } noexcept -> ConceptC;    { i << x      } noexcept -> ConceptC;    { i >> x      } noexcept -> ConceptC;    { i ^=  i      } noexcept -> ConceptC;    { i <<= x      } noexcept -> ConceptC;    { i >>= x      } noexcept -> ConceptC;};template<typename T>concept ConceptE =  requires(T i, int x) {    { i++     } noexcept -> ConceptD;    { ++i     } noexcept -> ConceptD;    { i--     } noexcept -> ConceptD;    { --i     } noexcept -> ConceptD;    { i + i   } noexcept -> ConceptD;    { i - i   } noexcept -> ConceptD;    { i += i  } noexcept -> ConceptD;    { i -= i  } noexcept -> ConceptD;    { i * i      } noexcept -> ConceptD;    { i / i      } noexcept -> ConceptD;    { i % i      } noexcept -> ConceptD;    { i *= i     } noexcept -> ConceptD;    { i /= i     } noexcept -> ConceptD;    { i %= i     } noexcept -> ConceptD;    { i |  i     } noexcept -> ConceptD;    { i &  i     } noexcept -> ConceptD;    { i |= i     } noexcept -> ConceptD;    { i &= i     } noexcept -> ConceptD;    { ~i          } noexcept -> ConceptD;    { i ^  i      } noexcept -> ConceptD;    { i << x      } noexcept -> ConceptD;    { i >> x      } noexcept -> ConceptD;    { i ^=  i      } noexcept -> ConceptD;    { i <<= x      } noexcept -> ConceptD;    { i >>= x      } noexcept -> ConceptD;};template<typename T>concept ConceptF =  requires(T i, int x) {    { i++     } noexcept -> ConceptE;    { ++i     } noexcept -> ConceptE;    { i--     } noexcept -> ConceptE;    { --i     } noexcept -> ConceptE;    { i + i   } noexcept -> ConceptE;    { i - i   } noexcept -> ConceptE;    { i += i  } noexcept -> ConceptE;    { i -= i  } noexcept -> ConceptE;    { i * i      } noexcept -> ConceptE;    { i / i      } noexcept -> ConceptE;    { i % i      } noexcept -> ConceptE;    { i *= i     } noexcept -> ConceptE;    { i /= i     } noexcept -> ConceptE;    { i %= i     } noexcept -> ConceptE;    { i |  i     } noexcept -> ConceptE;    { i &  i     } noexcept -> ConceptE;    { i |= i     } noexcept -> ConceptE;    { i &= i     } noexcept -> ConceptE;    { ~i          } noexcept -> ConceptE;    { i ^  i      } noexcept -> ConceptE;    { i << x      } noexcept -> ConceptE;    { i >> x      } noexcept -> ConceptE;    { i ^=  i      } noexcept -> ConceptE;    { i <<= x      } noexcept -> ConceptE;    { i >>= x      } noexcept -> ConceptE;};template<typename T>concept ConceptG =  requires(T i, int x) {    { i++     } noexcept -> ConceptF;    { ++i     } noexcept -> ConceptF;    { i--     } noexcept -> ConceptF;    { --i     } noexcept -> ConceptF;    { i + i   } noexcept -> ConceptF;    { i - i   } noexcept -> ConceptF;    { i += i  } noexcept -> ConceptF;    { i -= i  } noexcept -> ConceptF;    { i * i      } noexcept -> ConceptF;    { i / i      } noexcept -> ConceptF;    { i % i      } noexcept -> ConceptF;    { i *= i     } noexcept -> ConceptF;    { i /= i     } noexcept -> ConceptF;    { i %= i     } noexcept -> ConceptF;    { i |  i     } noexcept -> ConceptF;    { i &  i     } noexcept -> ConceptF;    { i |= i     } noexcept -> ConceptF;    { i &= i     } noexcept -> ConceptF;    { ~i          } noexcept -> ConceptF;    { i ^  i      } noexcept -> ConceptF;    { i << x      } noexcept -> ConceptF;    { i >> x      } noexcept -> ConceptF;    { i ^=  i      } noexcept -> ConceptF;    { i <<= x      } noexcept -> ConceptF;    { i >>= x      } noexcept -> ConceptF;};template<typename T>concept TestedConcept = ConceptA<T> && ConceptB<T> && ConceptC<T> && ConceptD<T> &&                                       ConceptE<T> && ConceptF<T> && ConceptG<T>;void foo(TestedConcept auto const &) {}void foo(auto const &) {}void later() {    int i { 0 };    int * ip = &i;    foo(i);    foo(ip);}

Давайте взглянем на результат:

Файл

Компиляция

Время, мс

Количество символов, шт

concept_complexity_1.cpp

clang

O0

37,441

201

concept_complexity_2.cpp

clang

O0

38,211

2244

concept_complexity_3.cpp

clang

O0

39,989

4287

concept_complexity_1.cpp

clang

O3

40,062

201

concept_complexity_2.cpp

clang

O3

40,659

2244

concept_complexity_3.cpp

clang

O3

43,314

4287

concept_complexity_1.cpp

gcc

O0

15,352

201

concept_complexity_2.cpp

gcc

O0

16,077

2244

concept_complexity_3.cpp

gcc

O0

18,091

4287

concept_complexity_1.cpp

gcc

O3

15,243

201

concept_complexity_2.cpp

gcc

O3

17,552

2244

concept_complexity_3.cpp

gcc

O3

18,51

4287

Чего и следовало ожидать, в общем случае существенное увеличение сложности концепта (обратите внимание, что концепты в примерах рекурсивные,и каждый последующий включает многократные отсылки к предыдущим) приводит к увеличению времени компиляциилишьна 515%.

Заключение

В результате вышеописанных экспериментов мы можем сделать следующие выводы:

  • Концепты позволяют создавать более читабельный код, который компилируется в меньший объектный файл, по сравнению с классическимметапрограммированием.

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

  • Время компиляции прямо пропорционально сложности концептов/constraint'ов.

Post Scriptum

Во-первых, к статье прилагаюссылку на гитхаб, пройдя по которой вы можете найтискриптыдля запуска тестов,а такжеиспользуемыев статьефрагменты кода и повторить некоторые (а может и все) тесты локально.

Ну а во-вторых,мне быочень хотелосьувидеть, как ведут себя компиляторы с более сложными конструкциями.Если вы знаете/придумали подходящиепримеры, смелопишите о них в комментариях,ияс радостьюпроизведузамеры.

Подробнее..

Топ-5 софт-навыков дизайнера в банке

04.06.2021 14:18:19 | Автор: admin

Соавтор:Кузнецова Юлия Андреевна - UX-писатель Экосистемы РСХБ

Каким должен быть дизайнер в банке, чтобы и продукт хороший создавал, и коллеги не жаловались. Смотрим через призму софт-навыков вместе с UX-дизайнерами РСХБ.

#1 Коммуникабельность: не просто коллега человек

Дизайнер отличная профессия для интровертов. Рисуешь интерфейсы, слушаешь музыку, ни с кем не общаешься На самом деле, нет!

Работая в банке, ты участвуешь в большом количестве созвонов, слушаешь коллег, предлагаешь свои идеи. Споришь. Много споришь. Убеждаешь. Идешь на компромисс. Вместе с тобой над продуктом работают много других сотрудников продакт-менеджеры, аналитики, разрабы, редакторы, юристы ты должен уметь услышать каждого и найти общий язык.

Общаясь с разными специалистами, ты лучше понимаешь, как устроена работа на проекте и начинаешь понимать, что из твоих решений сработает и поможет банковским клиентам, а что вряд ли будет реализовано и сделано разве что покрасоваться на Dribbble.

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

#2 Презентация: готов объяснить свою работу

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

Крутой дизайнер умеет переключить фокус с творческого и ранимого художника на хладнокровного адвоката. Если кто-то говорит: Это плохо сделано, это не значит, что ты плохой. Это значит, что коллега видит в данном решении слабые стороны. Продвинутый дизайнер отключает эмоции и включает голову. Объясняет и обосновывает свою позицию. Задаёт вопросы, внимательно слушает, делает все, чтобы добраться до истины и хорошего продукта.

Итог: Фильтруй плохой фидбек и умей доказать, какую ценность несет твое решение для пользователя и бизнеса, желательно предоставив проверенные факты и исследования.

#3 Аналитика: думает не только о красоте

Немного психолог, аналитик и исследователь банковский дизайнер работает не просто над сайтом с красивыми картиночками, он создает сложный финансовый продукт, в котором пользователь должен чувствовать, что его активы в безопасности.

Хороший дизайнер постоянно анализирует, как пользователи будут взаимодействовать с интерфейсом, удобно ли ему переводить деньги другу, перечислять зарплату сотрудникам или подавать заявку на кредит.

С одной стороны, ты должен на интуитивном уровне понимать психологию взаимодействия с интерфейсом. А с другой всегда остаешься скептиком и тестировать свои гипотезы. Порой пользователи воспринимают ту или иную функцию абсолютно непредсказуемо и не понимают твоей задумки. Одна только фраза Мне нравится. Думаю пользователям понравится тоже не поможет. Нужна аналитика. Много аналитики.

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

#4 Понимание бизнес-процесса: переводит с банковского языка на человеческий

Юный дизайнер: мастерски работает в Фигме, любит компоненты, боготворит филигранную верстку.

Продвинутый дизайнер: понимает, как устроен бизнес и финансы.

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

Знает о банке

Знает о пользователе

как устроены процессы внутри банка

над чем работает банк

какие боли клиентов решает

как себя позиционирует и каких принципов придерживается

как работают конкуренты

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

с какими проблемами сталкивается

что делает, чтобы решить эти проблемы

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

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

#5 Любознательность: постоянно учится новому

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

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

Подробнее..

Обзор счётчиков Морриса

28.05.2021 14:21:34 | Автор: admin
Подсчёт количества элементов в потоке ограниченными средствами в представлении художникаПодсчёт количества элементов в потоке ограниченными средствами в представлении художника

Мы рады представить вашему вниманию заметку, написанную инженером Qrator Labs Дмитрием @dimak24Камальдиновым. Если вы хотите быть частью нашей команды Core и заниматься подобными задачами - пишите нам наhr@qrator.net.

1 Введение

При реализации потоковых алгоритмов часто возникает задача подсчёта каких-то событий: приход пакета, установка соединения; при этом доступная память может стать узким местом: обычный n -битный счётчик позволяет учесть не более 2^n - 1 событий. Одним из способов обработки большего диапазона значений, используя то же количество памяти, является вероятностный подсчёт. В этой статье будет предложен обзор известного алгоритма Морриса, а также некоторых его обобщений.

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

Мы начнём с разбора простейшего алгоритма вероятностного подсчёта, выделим его недостатки (раздел 2). Затем (раздел 3) опишем алгоритм, впервые преложенный Робертом Моррисом в 1978 году, укажем его важнейшие свойства и приемущества. Для большинства нетривиальных формул и утверждений в тексте присутствуют наши доказательства интересующийся читатель сможет найти их под спойлером. В трёх последующих разделах мы изложим полезные расширения классического алгоритма: вы узнаете, что общего у счётчиков Морриса и экспоненциального распада, как можно уменьшить ошибку, пожертвовав максимальным значением, и как эффективно обрабатывать взвешенные события.

2 Вероятностный подсчёт, проблемы тривиального подхода

Основная идея вероятностного подсчёта учёт очередного события с некоторой вероятностью. Рассмотрим сначала пример с постоянной вероятностью обновления:

\begin{equation*} C_{n+1} = \left\{\begin{array}{ll} C_n + 1 & \textit{с вероятностью $p = const$} \\ C_n & \textit{с вероятностью $1-p$.} \end{array}\right. \end{equation*}

Где черезC_nобозначено значение счётчика послеn-ой попытки обновления. Несложно понять, чтоC_nзадаёт количество успехов среди n независимых испытаний Бернулли. Иначе говоря,C_nимеетбиномиальное распределение:

\begin{align*} C_n \sim Bin(&n, p) \\ {\sf E} C_n = &np \\ {\sf D} C_n = &np(1-p). \end{align*}

В качестве оценки числа событий n естественно использовать \widehat{n}(C) := C/p . Ясно, что такая оценка несмещённая: {\sf E}\widehat{n}(C_n) = ({\sf E}C_n)/p = np/p = n .

Описанный подход имеет несколько недостатков: во-первых, он позволяет учесть лишь в 1/p = const раз больше событий по сравнению с простым инкрементальным счётчиком, чем заметно уступает счётчикам Морриса, как мы увидим далее. Во-вторых, относительная ошибка оценки велика при маленьких n . Например, при n=1 она вообще равна 100%. Оценить относительную ошибку можно с помощью коэффициента вариации:

\begin{align*} {\sf CV}\widehat{n}(C_n) = \frac{\sqrt{{\sf D}\widehat{n}(C_n)}}{{\sf E} \widehat{n}(C_n)} = \sqrt{\frac{1-p}{pn}}. \end{align*}

3 Счётчики Морриса

Счётчик Морриса обновляется с вероятностью, зависящей от текущего значения: первое обновление происходит с вероятностью 1, следующее с вероятностью 1/2, потом 1/4 и т.д.:

\begin{equation*} C_{n+1} = \left\{\begin{array}{ll} C_n + 1 & \textit{с вероятностью $2^{-C_n}$} \\ C_n & \textit{с вероятностью $1 - 2^{-C_n}$.} \end{array}\right. \end{equation*}

Как по значению счётчика оценить, сколько было событий? Обновление счётчика с x на x + 1 происходит в среднем за 2^x итераций, отсюда оценка числа событий:

\widehat{n}(C) := \sum_{i=0}^{C-1} 2^{i} = 2^C - 1.

Важнейшими свойствами этой оценки являются

  • её несмещённость, то есть значение оценки после n вероятностных обновлений в среднем равно n ,

Доказательство
\begin{align*} {\sf E}\left(2^{C_n} | C_{n-1} = y\right) =& \underbrace{2^{y + 1} 2^{-y}}_{\textit{счётчик обновился}} + \underbrace{2^y (1 - 2^{-y})}_{\textit{значение не изменилось}} = 2^y + 1 \implies \\ {\sf E}\left(2^{C_n}|C_{n-1}\right) =& 2^{C_{n-1}} + 1 \implies {\sf E}2^{C_n} = {\sf E}2^{C_{n-1}} + 1 = {\sf E}2^{C_{n-2}} + 2 = \dots \end{align*}

В самом начале значение счётчика равно 0: C_0 = 0 , тогда {\sf E}2^{C_0} = 2^{0} = 1 , а значит {\sf E} 2^{C_n} = \dots = n + 1 . Итого {\sf E}\widehat{n}(C_n) = {\sf E}2^{C_n} - 1 = n .

  • а также независимость её относительной ошибки от n .

Доказательство

Сначала посчитаем дисперсию \widehat{n}(C_n) . Согласно известной формуле, {\sf D}\widehat{n} = {\sf E}\widehat{n}^2 - ({\sf E}\widehat{n})^2 . Мы уже знаем, что {\sf E}\widehat{n}(C_n) = n . Осталось получить {\sf E}\widehat{n}(C_n)^2 . Заметим, что

\begin{align*} {\sf E}\left(2^{C_n} - 1\right)^2 = {\sf E}2^{2C_n} - 2(n+1) + 1 = {\sf E}2^{2C_n} - 2n - 1. \end{align*}

Таким образом, остаётся узнать, чему равно {\sf E}2^{2C_n} .

\begin{align*} {\sf E}\left(2^{2C_n} | C_{n-1} = y\right) &= \underbrace{2^{2(y + 1)} 2^{-y}}_{\textit{счётчик обновился}} + \underbrace{2^{2y}(1 - 2^{-y})}_{\textit{значение не изменилось}} = \\ 3 \cdot 2^y +2^{2y} &\implies {\sf E}\left(2^{2C_n} | C_{n-1}\right) = 3 \cdot 2^{C_{n-1}} + 2^{2C_{n-1}} \implies \\ {\sf E} 2^{2C_n} = 3n &+ {\sf E}2^{2C_{n-1}} = 3n + 3(n-1) + {\sf E}2^{2C_{n-2}} = \dots = \sum_{i=1}^n 3i \end{align*}

Итого

\begin{equation*} {\sf D}\widehat{n}(C_n) = \frac{3n(n+1)}{2} - 2n - 1 - n^2 = \frac{n^2 - n - 2}{2} \end{equation*}\begin{equation*} {\sf CV}\widehat{n}(C_n) = \frac{\sqrt{{\sf D}\widehat{n}(C_n)}}{{\sf E}\widehat{n}(C_n)} = \sqrt{\frac{n^2-n-2}{2n^2}} \sim \frac{1}{\sqrt{2}}. \end{equation*}

Таким образом, относительная ошибка оценки числа событий при использовании счётчика Морриса ограничена, примерно постоянна и почти не зависит от n .

Оценить вероятность больших отклонений от среднего можно с помощью неравенства Чебышёва:

\begin{align*} {\sf P}(|\widehat{n}(C_n) - n| \ge \varepsilon n) \le \frac{{\sf D}\widehat{n}(C_n)}{\varepsilon^2n^2} \sim \frac{1}{2\varepsilon^2}. \end{align*}
  • отметим здесь также, что покрываемый диапазон количества событий при использовании {\bf X} бит памяти будет 2^{2^{\bf X}} - 1 . Иначе говоря, для учёта n событий достаточно \mathcal{O}(\log \log n) бит памяти!

Рис. 1: Относительная ошибка оценки числа событий при использовании биномиальных счётчиков и счётчиков Морриса (для построения графика sample_size=100 раз запускался процесс обновления счётчика, затем каждой точке n сопоставлялось среднее значение по этой выборке относительной ошибки после n обновлений)Рис. 1: Относительная ошибка оценки числа событий при использовании биномиальных счётчиков и счётчиков Морриса (для построения графика sample_size=100 раз запускался процесс обновления счётчика, затем каждой точке n сопоставлялось среднее значение по этой выборке относительной ошибки после n обновлений)

4 Взвешенные обновления

Счётчики Морриса можно использовать для подсчёта взвешенных событий: например, для суммирования размеров приходящих пакетов. Простейший способ обновление счётчика <вес события> раз приводит к линейной по весу сложности обновления.

Заметим, что в каждый момент времени мы знаем, как распределено количество неудач до ближайшего изменения счётчика, этогеометрическое рапределение:

\begin{align*} {\sf P}(n_{fail} = k) = (1 - 2^{-C})^k 2^{-C} \implies n_{fail} \sim Geom(2^{-C}). \end{align*}

Учитывая этот факт, мы можем ускорить алгоритм, сразу пропуская все подряд идущие неудачные попытки обновления:

5 Распад

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

Идея очень проста: будем периодически вычитать 1 из счётчика. Напомним, что значение C оценивает количество событий как \sum_{i=0}^{C-1} 2^i = 2^C - 1 =: n_0 . Соответственно, после вычитания 1 оценка станет 2^{C-1} - 1 = \frac{1}{2}n_0 - \frac{1}{2} . Иначе говоря, вычитание 1 уменьшает оценку в \sim 2 раза! Мы можем добиться распада ровно в 2 раза, обновив с вероятностью \frac{1}{2} счётчик сразу после вычитания 1 :

6 Мантисса и экспонента

Рассмотрим следующее обобщение счётчика Морриса: вероятность обновления уменьшается в2раза не на каждое успешное обновление, а на каждые{\bf X}. Возможной реализацией такого подхода будет разделение битов счётчика на две части:экспоненту, отвечающую за вероятность, имантиссу, считающую количество успешных обновлений при текущей вероятности (чем-то похоже на представлениечисел с плавающей точкой).

Рис. 2: разбиение битов счётчика на мантиссу (M=5 бит) и экспоненту (E=3 бит)Рис. 2: разбиение битов счётчика на мантиссу (M=5 бит) и экспоненту (E=3 бит)

Введём обозначения для мантиссы и экспоненты:

\begin{align*} {\rm e}(C) &= C \gg {\bf M} \\ {\rm m}(C) &= C \& (2^{\bf M} - 1). \end{align*}

Теперь правило обновления и оценка числа событий записывается так:

\begin{align*} C_{n+1} &= \left\{\begin{array}{ll} C_n + 1, & \textit{с вероятностью $2^{-{\rm e}(C)}$} \\ C_n, & \textit{с вероятностью $1 - 2^{-{\rm e}(C)}$} \end{array}\right. \\ \widehat{n}(C) &= (2^{{\rm e}(C)} - 1)2^{\bf M} + 2^{{\rm e}(C)}{\rm m}(C). \end{align*}

Например, на рисунке 2 значение счётчика C = 89 , мантисса и экспонента равны {\rm m} = 25 и {\rm e} = 2 соответственно. А значит число событий оценивается как \widehat{n} = (2^2 - 1) \times 2^5 + 2^2 \times 25 = 196 .

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

Описанный вариант счётчиков Морриса обладает следующими свойствами:

  • покрываемый диапазон значений равен

\begin{align*} {\bf N}_{max} = 2^{2^{\bf E} + {\bf M}} - \left(2^{2^{\bf E} - 1} + 2^{\bf M}\right); \end{align*}Доказательство

Покрываемый диапазон получить несложно, подставив в формулу для \widehat{n} максимальные возможные значения {\rm m} = 2^{\bf M} - 1 и {\rm e} = 2^{\bf E} - 1 :

\begin{align*} {\bf N}_{max} = \left(2^{2^{\bf E} - 1} - 1\right)2^{\bf M} + 2^{2^{\bf E} - 1}\left(2^{\bf M} - 1\right) = 2^{2^{\bf E} + {\bf M}} - \left(2^{2^{\bf E} - 1} + 2^{\bf M}\right) \end{align*}
  • новая оценка \widehat{n} также несмещённая: {\sf E}\widehat{n}(C_n) = n ;

Доказательство

Аналогично доказательству утверждения для обычных счётчиков Морриса посчитаем условное мат.~ожидание {\sf E}(C_n | C_{n - 1} = y) . Причём сделаем это отдельно для y \equiv -1 \pmod{2^{\bf M}} (вероятность обновления изменится в случае успеха) и y \not\equiv -1 \pmod{2^{\bf M}} .

\begin{align*} y \not\equiv -1 \pmod{2^{\bf M}} &\implies {\rm e}(y + 1) = {\rm e}(y), \quad {\rm m}(y + 1) = {\rm m}(y) + 1 \\ &\implies \widehat{n}(y + 1) = \widehat{n}(y) + 2^{{\rm e}(y)} \\ y \equiv -1 \pmod{2^{\bf M}} & \implies {\rm e}(y + 1) = {\rm e}(y) + 1,\\ &\phantom{\implies} \phantom{Z} {\rm m}(y) = 2^{\bf M} - 1, \quad {\rm m}(y + 1) = 0 \implies \\ \widehat{n}(y + 1) = \widehat{n}(y) &- 2^{{\rm e}(y)}\left(2^{\bf M} - 1\right) + 2^{{\rm e}(y) + {\bf M}} = \widehat{n}(y) + 2^{{\rm e}(y)} \end{align*}

В обоих случаях

\begin{align*} {\sf E}(2^{C_n} | C_{n-1} = y) &= \widehat{n}(y)\left(1 - 2^{-{\rm e}(y)}\right) + \left(\widehat{n}(y) + 2^{{\rm e}(y)}\right)2^{-{\rm e}(y)} = \widehat{n}(y) + 1. \end{align*}
  • коэффициент вариации (и относительная ошибка) становятся ещё меньше! Мы не знаем его точное значение, но имеется такая оценка (сравните её с аналогичной для классического счётчика Морриса):

\begin{align*} {\sf CV}\widehat{n}(C_n) \le 2^{-\frac{{\bf M} + 1}{2}}. \end{align*}Рис. 3: Чтение и обновление счётчика Морриса с ненулевой мантиссойРис. 3: Чтение и обновление счётчика Морриса с ненулевой мантиссой

Алгоритмы взвешенных обновлений и распада тоже немного изменятся.

Аналогично подходу, описанному в разделе 4, чтобы эффективно выполнить взвешенное обновление, нужно понять, через сколько попыток обновления вероятность изменится. При отсутствии мантиссы вероятность меняется после каждого успешного обновления, теперь же после каждых2^{\bf M}. А значит нам нужно распределение количества испытаний Бернулли (искомое число событий), среди которых ровно2^{\bf M}успешных. Такое распределение называетсяотрицательным биномиальным.

Замечание.Существует несколько определений отрицательного биномиального распределения. Будем придерживаться написанного в русскоязычной википедии, где сказано, что это распределение задаёт не общее число событий, а число неудач. Тогда чтобы получить число событий, нужно к случайной величине добавить количество успехов:

\begin{align*} n \sim \underbrace{ NegativeBinomial\left(2^{-{\rm e}(C)}, 2^{\bf M} - {\rm m}(C)\right)}_{\textit{неудач}} + \underbrace{\left(2^{\bf M} - {\rm m}(C)\right)}_{\textit{успехов}}. \end{align*}

Чтобы не генерировать отрицательное биномиальное распределение, можно загрубить алгоритм, всегда выбирая среднее этого распределения:

Чтобы выполнить распад, будем вычитать 1 из экспоненциальной части (иначе говоря, 2^{\bf M} из значения счётчика). Тогда

\begin{align*} \widehat{n}(C - 2^{\bf M}) = (2^{{\rm e}(C) - 1} - 1)2^{\bf M} + 2^{{\rm e}(C) - 1}{\rm m}(C) = 1/2\widehat{n}(C) - 2^{{\bf M} - 1}. \end{align*}

Заметим, что при {\bf M} > 0 (а именно этот случай изучается в этом разделе) 2^{{\bf M} - 1} \in \mathbb{N} . Поэтому исправить оценку можно, вероятностно увеличив счётчик на 2^{{\bf M} - 1} :

7 Заключение

Счётчики Морриса это очень круто! Пользуйтесь!

Последний раздел не содержит полезных на практике результатов, но...

8 Обобщённые вероятностные счётчики

В общем виде правило обновления счётчика определяется функцией вероятности F : C \mapsto [0, 1] :

\begin{equation*} C_{n+1} = \left\{\begin{array}{ll} C_n + 1 & \textit{с вероятностью $F(C_n)$} \\ C_n & \textit{с вероятностью $1 - F(C_n)$.} \end{array}\right. \end{equation*}

В случае счётчиков Морриса F(C_n) = 2^{-C_n} . При изучении вероятностных счётчиков мы попробовали взять линейную функцию вероятности: F(C_n) = 1 - \frac{C_n}{C_{max}} ( C_{max} некоторая константа, максимальное значение счётчика). Это привело к некоторым интересным результатам, которые будут кратко изложены в этом разделе.

Прежде чем представить эти результаты, напомним о двух математических сущностях. Полный однородный симметрический многочленстепениnотmпеременныхx_1, \dots, x_m это сумма всех возможных одночленов степениnот этих переменных с коэффициентами1:

\begin{align*} \mathcal{H}_n(x_1, \dots x_m) = \sum_{\begin{array}{cc} & 0 \le i_j \le n \\ & i_1 + \dots + i_m = n \end{array}} x_1^{i_1} \cdots x_m^{i_m}. \end{align*}

Другая конструкция, которая нам понадобится, число Стирлинга второго рода. Один из способов определить это число комбинаторный: число Стирлинга второго рода \genfrac\{\}{0pt}{}{n}{m} количество разбиений n -элементного множества на m непустых подмножеств. Числа Стирлинга связаны с полными симметрическим многочленами:

\begin{align*} \genfrac\{\}{0pt}{}{n+m}{m} = \mathcal{H}_m(1, 2, \dots, m). \end{align*}

С помощью \mathcal{H} можно записать в общем виде вероятность того, что после n попыток обновления значение счётчика равно m :

\begin{align*} {\sf P}(C_n = m) = \left[\prod_{k=0}^{m-1} F(k) \right] \mathcal{H}_{n-m}(1-F(1), \dots 1-F(m)). \end{align*}

Эту формулу легко доказать по индукции, но также она несложно следует из простых комбинаторных рассуждений: фактически в ней записано, что для того, чтобы получить значение m , нужны m успешных обновлений: с 0 до 1 , с 1 до 2 и т.д., за это отвечает множитель \prod_{k=0}^{m-1} F(k) , и n - m неудачных, которые разбиваются на m групп: i_1 неудачных обновлений с 1 до 2 , i_2 с 2 до 3 и т.д., что выражено \mathcal{H}_{n-m} .

Наконец, при использовании линейной функции вероятности F(C) = 1 - \frac{C}{C_{max}} вероятность принимает вид

\begin{equation*} {\sf P}(C_n = m) = \genfrac\{\}{0pt}{}{n}{m} \frac{C_{max}!}{(C_{max} - m)!C_{max}^n}. \end{equation*}

Это и есть анонсированный выше интересный результат. Мы заметили число Стирлинга в значении вероятности достаточно случайно и до сих пор не знаем, можно ли обосновать его присутствие комбинаторынми рассуждениями (опираясь, на его комбинаторное определение). Другой вопрос можно красиво записать аналогичную вероятность для счётчиков Морриса.

Математическое ожидание и дисперсияC_n(доказательства опускаем, но их несложно получить, используя формулу для{\sf P}и свойства чисел Стирлинга).

\begin{align*} {\sf E}C_n &= C_{max}\left(1 - \left(1 - \frac{1}{C_{max}}\right)^n\right) \\ {\sf D}C_n &= C_{max}^2\left(\frac{1}{C_{max}}\left(1 - \frac{1}{C_{max}}\right)^n + \left(1 - \frac{1}{C_{max}}\right)\left(1 - \frac{2}{C_{max}}\right)^n - \left(1 - \frac{1}{C_{max}}\right)^{2n}\right). \end{align*}

Оценить число событий можно по методу моментов:

\begin{equation*} \widehat{n}_{MM} := \frac{\ln \left(1 - \frac{C_n}{C_{max}}\right)}{\ln \left(1 - \frac{1}{C_{max}}\right)} \end{equation*}

или аналогично счётчикам Морриса, учитывая, что 1 + 1/2 + \dots + 1/n \sim \ln n :

\begin{equation*} \widehat{n} := C_{max}\ln \left(\frac{1}{1 - \frac{C_n}{C_{max}}}\right). \end{equation*}

Список литературы

[1] Morris, R.Counting large numbers of events in small registersCommunications of the ACM, 1978 21(10), 840-842.

[2]http://gregorygundersen.com/blog/2019/11/11/morris-algorithm/хороший обзор счётчиков Морриса на английском языке

[3]http://personeltest.ru/aways/habr.com/ru/company/qrator/blog/334354/ наша статья про EMA-счётчики

[4]http://personeltest.ru/aways/habr.com/ru/post/208268/ сторонний материал про счётчики Морриса

Подробнее..

Перевод Развеиваем мифы об управлении памятью в JVM

28.05.2021 16:10:14 | Автор: admin

В серии статей я хочу опровергнуть заблуждения, связанные с управлением памятью, и глубже рассмотреть её устройство в некоторых современных языках программирования Java, Kotlin, Scala, Groovy и Clojure. Надеюсь, эта статья поможет вам разобраться, что происходит под капотом этих языков. Сначала мы рассмотрим управление памятью в виртуальной машине Java (JVM), которая используется в Java, Kotlin, Scala, Clojure, Groovy и других языках. В первой статье я рассказал и разнице между стеком и кучей, что полезно для понимания этой статьи.

Структура памяти JVM


Сначала давайте посмотрим на структуру памяти JVM. Эта структура применяется начиная с JDK 11. Вот какая память доступна процессу JVM, она выделяется операционной системой:


Это нативная память, выделяемая ОС, и её размер зависит от системы, процессор и JRE. Какие области и для чего предназначены?

Куча (heap)


Здесь JVM хранит объекты и динамические данные. Это самая крупная область памяти, в ней работает сборщик мусора. Размером кучи можно управлять с помощью флагов Xms (начальный размер) и Xmx (максимальный размер). Куча не передаётся виртуальной машине целиком, какая-то часть резервируется в качестве виртуального пространства, за счёт которого куча может в будущем расти. Куча делится на пространства молодого и старого поколения.

  • Молодое поколение, или новое пространство: область, в которой живут новые объекты. Она делится на рай (Eden Space) и область выживших (Survivor Space). Областью молодого поколения управляет младший сборщик мусора (Minor GC), который также называют молодым (Young GC).
    • Рай: здесь выделяется память, когда мы создаём новые объекты.
    • Область выживших: здесь хранятся объекты, которые остались после работы младшего сборщика мусора. Область делится на две половины, S0 и S1.
  • Старое поколение, или хранилище (Tenured Space): сюда попадают объекты, которые достигли максимального порога хранения в ходе жизни младшего сборщика мусора. Этим пространством управляет старший сборщик (Major GC).

Стеки потоков исполнения


Это стековая область, в которой на один поток выделяется один стек. Здесь хранятся специфические для потоков статичные данные, в том числе фреймы методов и функций, указатели на объекты. Размер стековой памяти можно задать с помощью флага Xss.

Метапространство


Это часть нативной памяти, по умолчанию у неё нет верхней границы. В более ранних версиях JVM эта память называлась пространством постоянного поколения (Permanent Generation (PermGen) Space). Загрузчики классов хранили в нём определения классов. Если это пространство растёт, то ОС может переместить хранящиеся здесь данные из оперативной в виртуальную память, что может замедлить работу приложения. Избежать этого можно, задав размер метапространства с помощью флагов XX:MetaspaceSize и -XX:MaxMetaspaceSize, в этом случае приложение может выдавать ошибки памяти.

Кеш кода


Здесь компилятор Just In Time (JIT) хранит скомпилированные блоки кода, к которым приходится часто обращаться. Обычно JVM интерпретирует байткод в нативный машинный код, однако код, скомпилированный JIT-компилятором, не нужно интерпретировать, он уже представлен в нативном формате и закеширован в этой области памяти.

Общие библиотеки


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

Использование памяти JVM: стек и куча


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

class Employee {    String name;    Integer salary;    Integer sales;    Integer bonus;    public Employee(String name, Integer salary, Integer sales) {        this.name = name;        this.salary = salary;        this.sales = sales;    }}public class Test {    static int BONUS_PERCENTAGE = 10;    static int getBonusPercentage(int salary) {        int percentage = salary * BONUS_PERCENTAGE / 100;        return percentage;    }    static int findEmployeeBonus(int salary, int noOfSales) {        int bonusPercentage = getBonusPercentage(salary);        int bonus = bonusPercentage * noOfSales;        return bonus;    }    public static void main(String[] args) {        Employee john = new Employee("John", 5000, 5);        john.bonus = findEmployeeBonus(john.salary, john.sales);        System.out.println(john.bonus);    }}

Здесь вы можете увидеть, как исполняется вышеприведённая программа и как используются стек и куча:

https://files.speakerdeck.com/presentations/9780d352c95f4361bd8c6fa164554afc/JVM_memory_use.pdf

Как видите:

  • Каждый вызов функции добавляется в стек потока исполнения в качестве фреймового блока.
  • Все локальные переменные, включая аргументы и возвращаемые значения, сохраняются в стеке внутри фреймовых блоков функций.
  • Все примитивные типы вроде int хранятся прямо в стеке.
  • Все типы объектов вроде Employee, Integer или String создаются в куче, а затем на них ссылаются с помощью стековых указателей. Это верно и для статичных данных.
  • Функции, которые вызываются из текущей функции, попадают наверх стека.
  • Когда функция возвращает данные, её фрейм удаляется из стека.
  • После завершения основного процесса объекты в куче больше не имеют стековых указателей и становятся потерянными (сиротами).
  • Пока вы явно не сделаете копию, все ссылки на объекты внутри других объектов делаются с помощью указателей.

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

Управление памятью JVM: сборка мусора


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

JVM управляет куче с помощью сборки мусора. Чтобы освободить место для создания нового объекта, JVM очищает память, занятую потерянными объектами, то есть объектами, на которые больше нет прямых или опосредованных ссылок из стека.


Сборщик мусора в JVM отвечает за:

  • Получение памяти от ОС и возвращение её ОС.
  • Передачу выделенной памяти приложению по его запросу.
  • Определение, какие части выделенной памяти ещё используются приложением.
  • Затребование неиспользованной памяти для использования приложением.

Сборщики мусора в JVM работают по принципу поколений (объекты в куче группируются по возрасту и очищаются во время разных этапов). Есть много разных алгоритмов сборки мусора, но чаще всего применяют Mark & Sweep.

Сборщик мусора Mark & Sweep


JVM использует отдельный поток демона, который работает в фоне для сборки мусора. Этот процесс запускается при выполнении определённых условий. Сборщик Mark & Sweep обычно работает в два этапа, иногда добавляют третий, в зависимости от используемого алгоритма.


  • Разметка: сначала сборщик определяет, какие объекты используются, а какие нет. Те, что используются или доступны для стековых указателей, рекурсивно помечаются как живые.
  • Удаление: сборщик проходит по куче и убирает все объекты, которые не помечены как живые. Эти места в памяти помечаются как свободные.
  • Сжатие: после удаления неиспользуемых объектов все выжившие объекты перемещают, чтобы они были вместе. Это уменьшает фрагментацию и повышает скорость выделения памяти для новых объектов.

Такой тип сборщиков ещё называют stop-the-world, потому что пока они убираются, возникают паузы в работе приложения.

JVM предлагает на выбор несколько разных алгоритмов сборки мусора, и в зависимости от вашего JDK может быть ещё больше вариантов (например, сборщик Shenandoah в OpenJDK). Авторы разных реализаций стремятся к разным целям:

  • Пропускная способность: время, затраченное на сборку мусора, а не работу приложения. В идеале, пропускная способность должна быть высокой, то есть паузы на сборку мусора короткие.
  • Длительность пауз: насколько долго сборщик мусора мешает исполнению приложения. В идеале, паузы должны быть очень короткими.
  • Размер кучи: в идеале, должен быть маленьким.

Сборщики в JDK 11


JDK 11 это текущая версия LTE. Ниже приведён список доступных в ней сборщиков мусора, и JVM выбирает по умолчанию один из них в зависимости от текущего оборудования и операционной системы. Мы всегда можем принудительно выбрать какой-либо сборщик с помощью переключателя -XX.

  • Серийный сборщик: использует один поток, эффективен для приложений с небольшим количеством данных, наиболее удобен для однопроцессорных машин. Его можно выбрать с помощью -XX:+UseSerialGC.
  • Параллельный сборщик: нацелен на высокую пропускную способность и использует несколько потоков, чтобы ускорить процесс сборки. Предназначен для приложений со средним или большим количеством данных, исполняемых на многопоточном/многопроцессорном оборудовании. Его можно выбрать с помощью -XX:+UseParallelGC.
  • Сборщик Garbage-First (G1): работает по большей части многопоточно (то есть многопоточно выполняются только объёмные задачи). Предназначен для многопроцессорных машин с большим объёмом памяти, по умолчанию используется на большинстве современных компьютеров и ОС. Нацелен на короткие паузы и высокую пропускную способность. Его можно выбрать с помощью -XX:+UseG1GC.
  • Сборщик Z: новый, экспериментальный, появился в JDK11. Это масштабируемый сборщик с низкой задержкой. Многопоточный и не останавливает исполнение потоков приложения, то есть не относится к stop-the-world. Предназначен для приложений, которым необходима низкая задержка и/или очень большая куча (на несколько терабайтов). го можно выбрать с помощью -XX:+UseZGC.

Процесс сборки мусора


Вне зависимости от того, какой выбран сборщик, в JVM используется два вида сборки младший и старший сборщик.

Младший сборщик


Он поддерживает чистоту и компактность пространства молодого поколения. Запускается тогда, когда JVM не может получить в раю необходимую память для размещения нового объекта. Изначально все области кучи пусты. Рай заполняется первым, за ним область выживших, и в конце хранилище.

Здесь вы можете увидеть процесс работы этого сборщика:

https://files.speakerdeck.com/presentations/f4783404769145f4b990154d0cc05629/JVM_minor_GC.pdf

  1. Допустим, в раю уже есть объекты (блоки с 01 по 06 помечены как используемые).
  2. Приложение создаёт новый объект (07).
  3. JVM пытается получить необходимую память в раю, но там уже нет места для размещения нового объекта, поэтому JVM запускает младший сборщик.
  4. Он рекурсивно проходит по графу объектов начиная со стековых указателей и помечает используемые объекты как (используемая память), остальные как мусор (потерянные).
  5. JVM случайно выбирает один блок из S0 и S1 в качестве целевого пространства (To Space), пусть это будет S0. Теперь сборщик перемещает все живые объекты в целевое пространство, которое было пустым, когда мы начали работу, и повышает их возраст на единицу.
  6. Затем сборщик очищает рай, и в нём выделяется память для нового объекта.
  7. Допустим, прошло какое-то время, и в раю стало больше объектов (блоки с 07 по 13 помечены как используемые).
  8. Приложение создаёт новый объект (14).
  9. JVM пытается получить в раю нужную память, но там нет свободного места для нового объекта, поэтому JVM снова запускает младший сборщик.
  10. Повторяется этап разметки, который охватывает и те объекты, что находятся в пространстве выживших в целевом пространстве.
  11. Теперь JVM выбирает в качестве целевого свободный блок S1, а S0 становится исходным. Сборщик перемещает все живые объекты из рая и исходного в целевое (S1), которое было пустым, и повысил возраст объектов на единицу. Поскольку некоторые объекты сюда не поместились, сборщик переносит их в хранилище, ведь область выживших не может увеличиваться, и этот процесс называют преждевременным продвижением (premature promotion). Такое может происходить, даже если свободна одна из областей выживших.
  12. Теперь сборщик очищает рай и исходное пространство (S0), а новый объект размещается в раю.
  13. Так повторяется при каждой сессии младшего сборщика, выжившие перемещаются между S0 и S1, а их возраст увеличивается. Когда он достигает заданного максимального порога, по умолчанию это 15, объект перемещается в хранилище.

Мы рассмотрели, как младший сборщик очищает память в пространстве молодого поколения. Это процесс типа stop-the-world, но он настолько быстрый, что его длительностью обычно можно пренебречь.

Старший сборщик


Следит за чистотой и компактностью пространства старого поколения (хранилищем). Запускается при одном из таких условий:

  • Разработчик вызывает в программе System.gc() или Runtime.getRunTime().gc().
  • JVM решает, что в хранилище недостаточно памяти, потому что оно заполнено в результате прошлых сессий младшего сборщика.
  • Если во время работы младшего сборщика JVM не может получить достаточно памяти в раю или области выживших.
  • Если мы задали в JVM параметр MaxMetaspaceSize и для загрузки новых классов не хватает памяти.

Процесс работы старшего сборщика попроще, чем младшего:

  1. Допустим, прошло уже много сессий младшего сборщика и хранилище почти заполнено. JVM решает запустить старший сборщик.
  2. В хранилище он рекурсивно проходит по графу объектов начиная со стековых указателей и помечает используемые объекты как (используемая память), остальные как мусор (потерянные). Если старший сборщик запустили в ходе работы младшего сборщика, то его работа охватывает пространство молодого поколения (рай и область выживших) и хранилище.
  3. Сборщик убирает все потерянные объекты и возвращает память.
  4. Если в ходе работы старшего сборщика в куче не осталось объектов, JVM также возвращает память из метапространства, убирая из него загруженные классы, если это относится к полной сборке мусора.

Заключение


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

Но для большинства JVM-разработчиков (Java, Kotlin, Scala, Clojure, JRuby, Jython) этого объёма информации будет достаточно. Надеюсь, теперь вы сможете писать более качественный код, создавать более производительные приложения, избегая различных проблем с утечкой памяти.

Ссылки


Подробнее..

SQLAlchemy а ведь раньше я презирал ORM

05.06.2021 22:17:23 | Автор: admin

Так вышло, что на заре моей карьеры в IT меня покусал Oracle -- тогда я ещё не знал ни одной ORM, но уже шпарил SQL и знал, насколько огромны возможности БД.

Знакомство с DjangoORM ввело меня в глубокую фрустрацию. Вместо возможностей -- хрена с два, а не составной первичный ключ или оконные функции. Специфические фичи БД проще забыть. Добивало то, что по цене нулевой гибкости мне продавали падение же производительности -- сборка ORM-запроса не бесплатная. Ну и вишенка на торте -- в дополнение к синтаксису SQL надо знать ещё и синтаксис ORM, который этот SQL сгенерирует. Недостатки, которые я купил за дополнительную когнитивную нагрузку -- вот уж где достижение индустрии. Поэтому я всерьёз считал, что без ORM проще, гибче и в разы производительнее -- ведь у вас в руках все возможности БД.

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

Опыт и как результат субъективная система взглядов

Я занимался оптимизацией SQL-запросов. Мне удавалось добиться стократного и более уменьшения cost запросов, в основном для Oracle и Firebird. Я проводил исследования, экспериментировал с индексами. Я видел в жизни много схем БД: среди них были как некоторое дерьмо, так и продуманные гибкие и расширяемые инженерные решения.

Этот опыт сформировал у меня систему взглядов касательно БД:

  • ORM не позволяет забыть о проектировании БД, если вы не хотите завтра похоронить проект

  • Переносимость -- миф, а не аргумент:

    • Если ваш проект работает с postgres через ORM, то вы на локальной машине разворачиваете в докере postgres, а не работаете с sqlite

    • Вы часто сталкивались с переходом на другую БД? Не пишите только "Однажды мы решили переехать..." -- это было однажды. Если же это происходит часто или заявленная фича, оправданная разными условиями эксплуатации у разных ваших клиентов -- милости прошу в обсуждения

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

  • Структура таблиц определяется вашими данными, а не ограничениями вашей ORM

Естественно, я ещё и код вне БД писал, и касательно этого кода у меня тоже сформировалась система взглядов:

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

  • Контроллер, выполняющий за один сеанс много обращений к БД -- это очень тонкий лёд

  • Я избегаю повсеместного использования ActiveRecord -- это верный способ как работать с неконсистентными данными, так и незаметно для себя сгенерировать бесконтрольное множество обращений к БД

  • Оптимизация работы с БД сводится к тому, что мы не читаем лишние данные. Есть смысл запросить только интересующий нас список колонок

  • Часть данных фронт всё равно запрашивает при инициализации. Чаще всего это категории. В таких случаях нам достаточно отдать только id

  • Отладка всех новых запросов ORM обязательна. Всегда надо проверять, что там ORM высрала (тут пара сочных примеров), дабы не было круглых глаз. Даже при написании этой статьи у меня был косяк как раз по этому пункту

Идея сокращения по возможности количества выполняемого кода в контроллере приводит меня к тому, что проще всего возиться не с сущностями, а сразу запросить из БД в нужном виде данные, а выхлоп можно сразу отдать сериализатору JSON.

Все вопросы данной статьи происходят из моего опыта и системы взглядов

Они могут и не найти в вас отголоска, и это нормально

Мы разные, и у нас всех разный фокус внимания. Я общался с разными разработчиками. Я видел разные позиции, от "да не всё ли равно, что там происходит? Работает же" до "я художник, у меня справка есть". При этом у некоторых из них были другие сильные стороны. Различие позиций -- это нормально. Невозможно фокусироваться на всех аспектах одновременно.

Мне, например, с большего без разницы, как по итогу фронт визуализирует данные, хотя я как бы фулстэк. Чем я отличаюсь от "да не всё ли равно, что там происходит"? Протокол? Да! Стратегия и оптимизация рендеринга? Да! Упороться в WebGL? Да! А что по итогу на экране -- пофиг.

Знакомство в SQLAlchemy

Первое, что бросилось в глаза -- возможность писать DML-запросы в стиле SQL, но в синтаксисе python:

order_id = bindparam('order_id', required=True)return \    select(        func.count(Product.id).label("product_count"),        func.sum(Product.price).label("order_price"),        Customer.name,    )\    .select_from(Order)\    .join(        Product,        onclause=(Product.id == Order.product_id),    )\    .join(        Customer,        onclause=(Customer.id == Order.customer_id),    )\    .where(        Order.id == order_id,    )\    .group_by(        Order.id,    )\    .order_by(        Product.id.desc(),    )

Этим примером кода я хочу сказать, что ORM не пытается изобрести свои критерии, вместо этого она пытается дать нечто, максимально похожее на SQL. К сожалению, я заменил реальный фрагмент ORM-запроса текущего проекта, ибо NDA. Пример крайне примитивен -- он даже без подзапросов. Кажется, в моём текущем проекте таких запросов единицы.

Естественно, я сразу стал искать, как тут дела с составными первичными ключами -- и они есть! И оконные функции, и CTE, и явный JOIN, и много чего ещё! Для особо тяжёлых случаев можно даже впердолить SQL хинты! Дальнейшее погружение продолжает радовать: я не сталкивался ни с одним вопросом, который решить было невозможно из-за архитектурных ограничений. Правда, некоторые свои вопросы я решал через monkey-patching.

Производительность

Насколько крутым и гибким бы ни было API, краеугольным камнем является вопрос производительности. Сегодня вам может и хватит 10 rps, а завтра вы пытаетесь масштабироваться, и если затык в БД -- поздравляю, вы мертвы.

Производительность query builder в SQLAlchemy оставляет желать лучшего. Благо, это уровень приложения, и тут масштабирование вас спасёт. Но можно ли это как-то обойти? Можно ли как-то нивелировать низкую производительность query builder? Нет, серьёзно, какой смысл тратить мощности ради увеличения энтропии Вселенной?

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

Для SQLAlchemy тоже есть обходные пути, и их сразу два, и оба сводятся к кэшированию по разным стратегиям. Первый -- применение bindparam и lru_cache. Второй предлагает документация -- future_select. Рассмотрим их преимущества и недостатки.

bindparam + lru_cache

Это самое простое и при этом самое производительное решение. Мы покупаем производительность по цене памяти -- просто кэшируем собранный объект запроса, который в себе кэширует отрендеренный запрос. Это выгодно до тех пор, пока нам не грозит комбинаторный взрыв, то есть пока число вариаций запроса находится в разумных пределах. В своём проекте в большинстве представлений я использую именно этот подход. Для удобства я применяю декоратор cached_classmethod, реализующий композицию декораторов classmethod и lru_cache:

from functools import lru_cachedef cached_classmethod(target):    cache = lru_cache(maxsize=None)    cached = cache(target)    cached = classmethod(cached)    return cached

Для статических представлений тут всё понятно -- функция, создающая ORM-запрос не должна принимать параметров. Для динамических представлений можно добавить аргументы функции. Так как lru_cache под капотом использует dict, аргументы должны быть хешируемыми. Я остановился на варианте, когда функция-обработчик запроса генерирует "сводку" запроса и параметры, передаваемые в сгенерированный запрос во время непосредственно исполнения. "Сводка" запроса реализует что-то типа плана ORM-запроса, на основании которой генерируется сам объект запроса -- это хешируемый инстанс frozenset, который в моём примере называется query_params:

class BaseViewMixin:    def build_query_plan(self):        self.query_kwargs = {}        self.query_params = frozenset()    async def main(self):        self.build_query_plan()        query = self.query(self.query_params)        async with BaseModel.session() as session:            respone = await session.execute(                query,                self.query_kwargs,            )            mappings = respone.mappings()        return self.serialize(mappings)
Некоторое пояснение по query_params и query_kwargs

В простейшем случае query_params можно получить, просто преобразовав ключи query_kwargs во frozenset. Обращаю ваше внимание, что это не всегда справедливо: флаги в query_params запросто могут поменять сам SQL-запрос при неизменных query_kwargs.

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

Сколько же памяти я заплатил за это? А немного. На все вариации запросов я расходую не более мегабайта.

future_select

В отличие от дубового первого варианта, future_select кэширует куски SQL-запросов, из которых итоговый запрос собирается очень быстро. Всем хорош вариант: и высокая производительность, и низкое потребление памяти. Читать такой код сложно, сопровождать дико:

stmt = lambdas.lambda_stmt(lambda: future_select(Customer))stmt += lambda s: s.where(Customer.id == id_)

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

Наброски фасада, решающего проблему дикого синтаксиса

По идее, future_select через FutureSelectWrapper можно пользоваться почти как старым select, что нивелирует дикий синтаксис:

class FutureSelectWrapper:    def __init__(self, clause):        self.stmt = lambdas.lambda_stmt(            lambda: future_select(clause)        )        def __getattribute__(self, name):        def outer(clause):            def inner(s):                callback = getattr(s, name)                return callback(clause)                        self.stmt += inner            return self        return outer

Я обращаю ваше внимание, что это лишь наброски. Я их ни разу не запускал. Необходимы дополнительные исследования.

Промежуточный вывод: низкую производительность query builder в SQLAlchemy можно нивелировать кэшем запросов. Дикий синтаксис future_select можно спрятать за фасадом.

А ещё я не уделил должного внимания prepared statements. Эти исследования я проведу чуть позже.

Как я открывал для себя ORM заново

Мы добрались главного -- ради этого раздела я писал статью. В этом разделе я поделюсь своими откровениями, посетившими меня в процессе работы.

Модульность

Когда я реализовывал на SQL дикую аналитику, старой болью отозвалось отсутствие модульности и интроспекции. При последующем переносе на ORM у меня уже была возможность выкинуть весь подзапрос поля FROM в отдельную функцию (по факту метод класса), а в последующем эти функции было легко комбинировать и на основании флагов реализовывать паттерн Стратегия, а также исключать дублирование одинакового функционала через наследование.

Собственные типы

Если данные обладают хитрым поведением, или же хитро преобразуются, совершенно очевидно, что их надо выкинуть на уровень модели. Я столкнулся с двумя вопросами: хранение цвета и работа с ENUM. Погнали по порядку.

Создание собственных простых типов рассмотрено в документации:

class ColorType(TypeDecorator):    impl = Integer    cache_ok = True    def process_result_value(self, value, dialect):        if value is None:            return        return color(value)    def process_bind_param(self, value, dialect):        if value is None:            return        value = color(value)        return value.value

Сыр-бор тут только в том, что мне стрельнуло хранить цвета не строками, а интами. Это исключает некорректность данных, но усложняет их сериализацию и десериализацию.

Теперь про ENUM. Меня категорически не устроило, что документация предлагает хранить ENUM в базе в виде VARCHAR. Особенно уникальные целочисленные Enum хотелось хранить интами. Очевидно, объявлять этот тип мы должны, передавая аргументом целевой Enum. Ну раз String при объявлении требует указать длину -- задача, очевидно, уже решена. Штудирование исходников вывело меня на TypeEngine -- и тут вместо примеров использования вас встречает "our source code is open 24/7". Но тут всё просто:

class IntEnumField(TypeEngine):    def __init__(self, target_enum):        self.target_enum = target_enum        self.value2member_map = target_enum._value2member_map_        self.member_map = target_enum._member_map_    def get_dbapi_type(self, dbapi):        return dbapi.NUMBER    def result_processor(self, dialect, coltype):        def process(value):            if value is None:                return            member = self.value2member_map[value]            return member.name        return process    def bind_processor(self, dialect):        def process(value):            if value is None:                return            member = self.member_map[value]            return member.value        return process

Обратите внимание: обе функции -- result_processor и bind_processor -- должны вернуть функцию.

Собственные функции, тайп-хинты и вывод типов

Дальше больше. Я столкнулся со странностями реализации json_arrayagg в mariadb: в случае пустого множества вместо NULL возвращается строка "[NULL]" -- что ни под каким соусом не айс. Как временное решение я накостылил связку из group_concat, coalesce и concat. В принципе неплохо, но:

  1. При вычитывании результата хочется нативного преобразования строки в JSON.

  2. Если делать что-то универсальное, то оказывается, что строки надо экранировать. Благо, есть встроенная функция json_quote. Про которую SQLAlchemy не знает.

  3. А ещё хочется найти workaround-функции в объекте sqlalchemy.func

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

Мне заказчик разрешил опубликовать код целого модуля!
from sqlalchemy.sql.functions import GenericFunction, register_functionfrom sqlalchemy.sql import sqltypesfrom sqlalchemy import func, literal_columndef register(target):    name = target.__name__    register_function(name, target)    return target# === Database functions ===class json_quote(GenericFunction):    type = sqltypes.String    inherit_cache = Trueclass json_object(GenericFunction):    type = sqltypes.JSON    inherit_cache = True# === Macro ===empty_string = literal_column("''", type_=sqltypes.String)json_array_open = literal_column("'['", type_=sqltypes.String)json_array_close = literal_column("']'", type_=sqltypes.String)@registerdef json_arrayagg_workaround(clause):    clause_type = clause.type    if isinstance(clause_type, sqltypes.String):        clause = func.json_quote(clause)    clause = func.group_concat(clause)    clause = func.coalesce(clause, empty_string)    return func.concat(        json_array_open,        clause,        json_array_close,        type_=sqltypes.JSON,    )def __json_pairs_iter(clauses):    for clause in clauses:        clause_name = clause.name        clause_name = "'%s'" % clause_name        yield literal_column(clause_name, type_=sqltypes.String)        yield clause@registerdef json_object_wrapper(*clauses):    json_pairs = __json_pairs_iter(clauses)    return func.json_object(*json_pairs)

В рамках эксперимента я также написал функцию json_object_wrapper, которая из переданных полей собирает json, где ключи -- это имена полей. Буду использовать или нет -- ХЗ. Причём тот факт, что эти макроподстановки не просто работают, а даже правильно, меня немного пугает.

Примеры того, что генерирует ORM
SELECT concat(  '[',  coalesce(group_concat(product.tag_id), ''),  ']') AS product_tags
SELECT json_object(  'name', product.name,  'price', product.price) AS product,

PS: Да, в случае json_object_wrapper я изначально допустил ошибку. Я человек простой: вижу константу -- вношу её в код. Что привело к ненужным bindparam на месте ключей этого json_object. Мораль -- держите ORM в ежовых рукавицах. Упустите что-то -- и она вам такого нагенерит! Только literal_column позволяет надёжно захардкодить константу в тело SQL-запроса.

Такие макроподстановки позволяют сгенерировать огромную кучу SQL кода, который будет выполнять логику формирования представлений. И что меня восхищает -- эта куча кода работает эффективно. Ещё интересный момент -- эти макроподстановки позволят прозрачно реализовать паттерн Стратегия -- я надеюсь, поведение json_arrayagg пофиксят в следующих релизах MariaDB, и тогда я смогу своё костылище заменить на связку json_arrayagg+coalesce незаметно для клиентского кода.

Выводы

SQLAlchemy позволяет использовать преимущества наследования и полиморфизма (и даже немного иннкапсуляции. Флеш-рояль, однако) в SQL. При этом она не загоняет вас в рамки задач уровня Hello, World! архитектурными ограничениями, а наоборот даёт вам максимум возможностей.

Субъективно это прорыв. Я обожаю реляционные базочки, и наконец-то я получаю удовольствие от реализации хитрозакрученной аналитики. У меня в руках все преимущества ООП и все возможности SQL.

Подробнее..

Как мы весь интернет сканировали

20.06.2021 16:11:04 | Автор: admin

Всем привет! Меня зовут Александр и я пишу код для 2ip.ru. За добрую половину сервисов можно пинать меня, готов отбиваться. Cегодня я хочу немного рассказать про переделку одного нашего старого сервиса. Это конечно не "big data", но всё равно довольно большие объемы информации, поэтому думаю будет интересно.

Речь пойдет про Сайты на одном IP, который как вы уже догадались, позволяет узнать все домены зарегистрированные на одном IP. Довольно удобно посмотреть кто присосался к вашему серверу (да, есть и такие), ну или чужому (например shared хостинг).

Как это всегда работало? Мы ходили в Bing с большого пула адресов и парсили выдачу по специальному запросу. Да, решение так себе, но что было то было. Было, потому что бинг прикрутил гайки и мы решили всё это сделать по человечески.

Своя база

Что если взять и спарсить весь интернет? В общем то не проблема, но мы не Google и больших ресурсов для кролинга не имеем. Или имеем?

Есть сервер с 12 ядрами и 64 гигами памяти, а в арсенале MySQL, PHP, golang и куча всяких фреймворков. Очевидно, что благодаря горутинам можно достичь неплохих результатов. Golang быстр и требует минимум ресурсов. По базе вопросы, потянет ли это все обычный MySQL?

Пробуем.

Делаем прототип

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

Итак на моем диске CSV файл размером 5 ГБ, дело за малым, написать масс ресолвер, который будет читать строку за строкой, а на выход в STDOUT, отдавать пару "домен - IP адрес"

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

Несколько часов работы и мой демон на гоу готов. Мэйн получился примерно такой:

func main() {    file, err := os.Open("domains.txt")    if err != nil {        log.Fatal(err)    }    defer file.Close()    maxGoroutines := 500    guard := make(chan struct{}, maxGoroutines)    scanner := bufio.NewScanner(file)    for scanner.Scan() {        guard <- struct{}{}        host := scanner.Text()        go func(host string) {            resolve(host)            <-guard        }(host)    }    if err := scanner.Err(); err != nil {        log.Fatal(err)    }}

Суть в том, что мы можем набрать пул в 500 горутинов и непрерывно обрабатывать большое количество данных в единицу времени, подгружая равномерно все 12 ядер сервера.

Функция resolve опущена, но кратко это обычный ресолвер IP с выдачей результата в STDOUT. Обращаемся к DNS, получаем A записи, выдаем результат.

DNS

Прогуглив немного я понял, что большие DNS особо не лимитируют количество запросов с одного IP, но кто знает. Поэтому было принято решение поднять undbound из Docker.

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

Второй вариант Google DNS, тот который четыре восьмерки, оказался гораздо быстрее. У меня были опасения по лимитам в 500 запросов в секунду но по факту их нет.

Тестируем в localhost и на проде

Нельзя сказать что на тестовом ноутбуке граббер работал быстро. 500 горутинов машина не потянула, процесс падал через несколько секунд. Зато все кардинально поменялось на боевом сервере.

1000 горутинов упали на 12 ядрах, а вот 500 практически не грузили проц и работали стабильно. Мощность получилась на уровне ~2000 доменов в секунду.

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

В конечном счёте я оставил процесс в tmux и через трое суток получил CSV размером 10 Гб. Идём дальше.

Ура! Переходим к следующему шагу.

База данных

Я создал таблицу domain_ip, в которой всего два столбца домен и IP. Оба не уникальны, на один домен может приходиться несколько IP адресов.

IP - это обычный BIGINT domain - VARCHAR 255

Индексы

Очевидно, что выборка из 260 млн записей это довольно большая работа. Поэтому без индексов не обойтись, поиск у нас по IP адресу, значит его и индексируем.

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

Партиципирование

Это метод разделения больших таблиц на мелкие и в дальнейшем уже обращение по нужному адресу сразу к конкретной таблице.

Я разделил весь пул IP адресов на 20 таблиц с шагом 200 млн. Получилось примерно так:

ALTER TABLE domain_ip PARTITION BY RANGE COLUMNS (ip)  (    PARTITION p0 VALUES LESS THAN (200000000),    PARTITION p1 VALUES LESS THAN (400000000),    PARTITION p2 VALUES LESS THAN (600000000),    PARTITION p3 VALUES LESS THAN (800000000),    PARTITION p4 VALUES LESS THAN (1000000000),    PARTITION p5 VALUES LESS THAN (1200000000),    PARTITION p6 VALUES LESS THAN (1400000000),    PARTITION p7 VALUES LESS THAN (1600000000),    PARTITION p8 VALUES LESS THAN (1800000000),    PARTITION p9 VALUES LESS THAN (2000000000),    PARTITION p10 VALUES LESS THAN (2200000000),    PARTITION p11 VALUES LESS THAN (2400000000),    PARTITION p12 VALUES LESS THAN (2600000000),    PARTITION p13 VALUES LESS THAN (2800000000),    PARTITION p14 VALUES LESS THAN (3000000000),    PARTITION p15 VALUES LESS THAN (3200000000),    PARTITION p16 VALUES LESS THAN (3400000000),    PARTITION p17 VALUES LESS THAN (3600000000),    PARTITION p18 VALUES LESS THAN (3800000000),    PARTITION p19 VALUES LESS THAN (4000000000),    PARTITION p20 VALUES LESS THAN (MAXVALUE) );

И как вы поняли это сработало, иначе зачем эта статья? :)

Импорт

Кто работал с MySQL знает, что вливать большие дампы данных это довольно долгая операция. За долгие годы работы я не нашел ничего лучше, чем импорт данных из CSV. Выглядит это примерно так:

LOAD DATA INFILE '/tmp/domains.csv' IGNORE INTO TABLE domain_ipFIELDS TERMINATED BY ',' LINES TERMINATED BY '\n'

Машина переваривает CSV размером ~10 Гб за 30 минут.

Финал

Как результат получился вот такой милый сервис. Выборка из ~300 миллионов записей происходит мгновенно на довольно скромном по нынешним меркам сервере. Оперативной памяти нужно под это всё порядка 8 Гб.

Теперь можно узнать например, что к IP 8.8.8.8 человечество прицепило 8194 домена, ну или придумайте сами ... ;-)

Спасибо за внимание.

Подробнее..

Как масштабировать разработку при 400 000 RPM и не надорваться

04.06.2021 16:20:58 | Автор: admin
Если бизнес идет вверх, тозапросы инагрузка наразработку увеличиваются вразы. Рано или поздно каждый управленец сталкивается свыбором издвух крайностей: встать насторону бизнеса, двигать продукт идемотивировать разработчиков бесконечным техдолгом или дать свободу разработке ипотерять контроль над задачами бизнеса.

Mindbox 15лет развивает B2B-продукт ивырос с3до70человек вразработке. Мытестировали разные подходы кмасштабированию иготовы поделиться опытом, чтобы вам непришлось наступать натеже грабли. Ниже расскажу, как попробовали полную автономию команд ицентрализацию, роняли надежность, демотивировали команды, как врезультате сэтим справились ивыработали свою систему масштабирования.

По материалам выступления на Agile Days 2021:



Надежность как ядро разработки


Чтобы оценить, окаких масштабах разработки инагрузке пойдет речь, расскажу оразмере компании.

ВMindbox одна изсамых нагруженных разработок вРоссии, нопри этом она сохраняет высокую надежность. Когда покупатель пробивает чек накассе вБургер Кинге или аптеке Ригла, транзакция идет кнам. За200 миллисекунд мырассчитываем суммы иотвечаем кассе. Если сервис упал, томного людей повсей стране 24/7 становятся несчастны.

Запоследние 34 года бизнес растет по4050% вгод инагрузка удваивается ежегодно. Внешне всё отлично, ноуMindbox был длинный период становления, который влиял намасштабирование разработки.

Масштаб бизнеса и разработки




Эволюция разработки




Как работает автономия ицентрализация разработки


Проблема масштабирования разработки сводится кпоиску баланса между автономией ицентрализацией. Есть две организационные крайности:

  1. Автономия. Вомногих компаниях победили автономные инженеры инет никаких сроков. Разработка постоянно закрывает секретный техдолг, абизнес непонимает, как решать крупные задачи. Это заканчивается революцией: бизнес теряет терпение ивносит радикальные изменения впроцессы.
  2. Централизация. Другая крайность когда побеждает бизнес. Дедлайны спускаются наразработку сверху, задачи бизнеса решаются, нокопится техдолг. Потом процессы опять замедляются иистория заканчивается революцией: предлагают переписать код снуля или продать компанию.

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

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

Как внедрили автономию


С2007 по2013 год было мало клиентов ибизнес рос медленно, потому что писали большой исложный продукт. При этом управление разработкой было простым: один главный бизнес-эксперт иглавный архитектор это я и34команды. Делал статусы раз внеделю, потом раз вдве недели ходил покомандам эффективно илегко.

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

Бирюзовая компания вРоссии: открытые зарплаты, самоуправление, прозрачность иошибки
Фредерик Лалу: Открывая организации будущего

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

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

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

Как внедрили централизацию


Централизовали управление. Прочитали книгу оLeSS (Large Scale Scrum), сходили натренинг ирешили централизовать разработку: внедрить общий roadmap, единое управление иразгрумить эпик надежности.

Врезультате немного улучшили надежность иroadmap начал продвигаться. Мыразделили его набольшие куски, установили очередность продуктов иработали над ними силами всех шести команд.

Внедрили LeSS ироль CTO. Надежность разово починили, ноостался монолит на2миллиона строк кода. Несмотря наточто мыцентрализованно внедрили roadmap ивкаждой команде работали эксперты поинфраструктуре, техдолг ипроблема корневой надежности децентрализованно нерешались.

Так мысоздали роль CTO (chief technical officer), хотя доэтого небыло менеджмента, отвели30% ресурса натехдолг ивнедрили LeSS. Это значит, что70% разработчиков занимались roadmap бизнеса, а30% техническим roadmap, который определяет CTO. Врезультате техдолг начал сокращаться, имыувидели положительные изменения.

LeSS Scrum набольших масштабах

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

Демотивировали инженеров именеджеров попродукту. Следом случилась ещё одна черная пятница, исервис снова лег. Это было грустно, хотя мыуже видели, что есть положительные изменения. Ноэто было неглавным.

Главное, что год мыпрожили врежиме LeSS изаметили негативные эффекты для компании. Инженеры именеджеры попродукту были демотивированы. Уинженеров небыло домена ичувства собственности, как увавтономных команд: три месяца они работали над одним продуктом, потом три месяца над другим. Менеджеры попродукту загрустили, потому что roadmap планируется централизованно иtime tomarket стал огромным. Нельзя было взять ивнедрить небольшую доработку для клиента, потому что roadmap управляют централизованно.

Как нашли баланс между автономией ицентрализацией


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

Вернули команду инфраструктурной платформы. Фактически инфраструктура это тоже внутренний продукт, аCTO выполняет роль менеджера попродукту, поэтому выделили отдельную команду под инфраструктуру.

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

Оставили30% ресурса команды налокальный техдолг. Мыдоговорились одвухуровневом разделении. Наверхнем уровне30% всего ресурса разработки отдали CTO наинфраструктурную команду итехнический roadmap. Ещё30% отдали натехдолг, который приоритезирует команда. Фактически смомента, когда начались проблемы снадежностью имасштабированием, почти50% всего ресурса это технические задачи.

Техдолг ~30% платформы и 30% команды


около 50% в целом



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

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


Из LeSS оставили кросс-командный рефайнмент, чтобы снять риск монолита и управлять roadmap

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

Несмотря наточто впрошлый раз мыупали вчерную пятницу, было ощущение, что платформа стала надежнее имыдвижемся вверном направлении. Новая метрика доказала наши наблюдения надежность компании действительно росла.

Нарушения SLA среднего клиента вмесяц надежность повышается




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

Создали роль Scrum-мастера. После того как разобрались срисками вдецентрализованном roadmap, справились стехдолгом инадежностью, решили повышать эффективность разработки. Для этого создали роль Scrum-мастера, который собирает весь опыт разработки (developer experience) изаносит вспециальную форму все препятствия ипричины, мешающие разработке. Потом поаналогии состатусами понадежности Scrum-мастера централизованно обсуждают сCTO задачи замесяц, приоритезируют ихичасть добавляют втехдолг.

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

Виртуальная команда (круг) управления




Ритуалы управления



Круг управления помогает аккумулировать кросс-командные аспекты: надежность, стоимость железа, найм, developer experience. Для этого проводятся встречи ритуалы управления


Определили метрики разработки. Пока мымасштабировали разработку, нас волновал один вопрос: какие метрики позволяют оценить разработку ичто нужно оптимизировать. Это было неочевидно.

Мызнали, что скорость иroadmap нельзя измерять, потому что есть проблемы стехдолгом иэто только демотивирует разработчиков. Науровне стратегии разработки мысформулировали, что цель разработки оптимизировать непрерывный запуск продуктов (time tomarket) врамках ограничений надежности, стоимости железа ибез увеличения технического долга. Ировно такиеже ожидания сформировали для команды. Команда должна непрерывно поставлять фичи, увеличивать time tomarket, нопри этом поддерживать определенные обязательства понадежности, SLA истоимости.

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

Показатель эффективности


врамках SLA, стоимости железа ибез увеличения техдолга
Разработка Команда
Непрерывный запуск и оптимизация time to market новых продуктов, которыми можно гордиться Непрерывный релиз и оптимизация time to market инкрементов, которые принял клиент на продакшене


Какую выработали систему масштабирования разработки


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

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

Анонс оперативной памяти Kingston FURY для оверклокеров и не только

02.06.2021 18:06:54 | Автор: admin
Привет, Хабр! Начало июня ознаменовалось анонсом комплектов оперативной памяти, которые будут производиться компанией Kingston Technology под известным брендом FURY. По сути, Kingston переименовала линейку игровых комплектующих HyperX и сделала своеобразный перезапуск модельного ряда, в который, помимо оперативной памяти DDR4, войдут решения, сертифицированные по спецификациям DDR5, и твердотельные накопители потребительского класса.



Kingston FURY: геймерские комплектующие топового уровня


Основным драйвером в реализации новой продукции стал многолетний инженерный опыт проектирования микросхем и оптимизации производственных процессов, а также экспертные оценки со стороны клиентского сервиса. Да и мы с вами понимаем, что Kingston Technology обладает достаточным потенциалом и мощностями, чтобы в очередной раз покорить рынок.

Конкурентная борьба набирает обороты, когда на горизонте маячит массовое производство оперативной памяти DDR5, когда мы видим приемлемый ценник за гигабайт пространства для передовых SSD (которые с каждым новым релизом становятся еще востребованнее и еще доступнее). Если вспомнить об огромном количестве наград, которые получала продукция HyperX с момента основания бренда в 2002 году, нетрудно предположить, что новые решения Kingston не просто станет очередным конкурентом в битве топовых брендов, но и объективно может претендовать на очередное звание лидера рынка. Как ни крути, а успех ОЗУ в лице той же HyperX FURY это в первую очередь заслуга инженеров, производства и службы тестирования компании Kingston.


Kingston FURY DDR3, DDR4 и DDR5


Среди анонсированных вариантов оперативной памяти предусмотрены не только решения текущего стандарта DDR4, но и комплекты памяти DDR3, а также DDR5 (их релиз ожидается в 4 квартале 2021 года и вызывает особый интерес). Оперативка будет представлена в следующих производственных сериях: Kingston FURY Renegade, Kingston FURY Beast и Kingston FURY Impact. Информации пока мало, поэтому давайте коротенечко о каждой.



1. Kingston FURY Renegade топовая оперативная память, которая будет характеризоваться высокими частотами и низкими задержками, что позволит обеспечить оптимальную производительность в играх ААА-класса. Производитель заявляет, что разгонный потенциал модулей DDR4 сможет достигать внушительных 5333 МГц. А еще планки будут оснащаться брутальными радиаторами с RGB-подсветкой (для тех, кто любит строгий дизайн, обещают варианты без свечения с классическим дизайном).

2. Kingston FURY Beast комплекты оперативной памяти из данной серии относятся к сегменту среднего класса и будут доступны большинству геймеров и энтузиастов. Главная цель устройств из модельного ряда Beast обеспечить максимально допустимую производительность на компьютерах пользователей за как можно более низкий ценник. В этой серии мы встретим модули памяти стандарта DDR3 и DDR4 с максимальной частотой до 3733 МГц. По части визуального оформления производитель также предусматривает решения с подсветкой и без нее.

3. Kingston FURY Impact здесь так и напрашивается описание самая бюджетная ОЗУ начального уровня, но нет. В этой серии будет выпускаться оперативная память SO-DIMM для ноутбуков, моноблоков и мини-ПК, что говорит о меньшем размере печатной платы и, возможно, иной компоновке чипов и схем питания. Производитель заявляет поддержку стандартов DDR3 и DDR4 с максимальной частотой до 3200 МГц.

Так что же с DDR5?




Этот вопрос терзает всех еще с осени 2020 года. Первые упоминания об оперативной памяти DDR5 появились в СМИ около четырех лет назад, но только в конце 2020 года на нас обрушился шквал анонсов. Kingston Technology не стали уподобляться большинству, пытаясь сыграть на хайпе, и скромно рассказали о своей оперативке нового поколения в начале мая 2021 года.

Первые референсы памяти DDR5 уже готовы и отправлены производителям материнских плат для тестирования. Доподлинно известно, что новая ОЗУ получит поддержку профилей XMP и оптимизированную схему питания. В совокупности новые подходы должны обеспечить максимальную гибкость при разгоне.

Если верить прогнозам IT-экспертов, продажи модулей памяти DDR5 составят 44% от рынка оперативной памяти в 2021 году. На рынках мобильных устройств и центров обработки данных внедрение новых стандартов может пойти еще быстрее. Что касается потребительских решений: новые платформы с поддержкой DDR5 появятся не раньше осени 2021 года. Ожидается, что первыми мы увидим решения от Intel, а в 2022 году и от AMD. Kingston же планирует запустить продажи новой памяти в третьем квартале 2021 года. Тогда мы сможем полноценно в ней покопаться и протестировать ее. А на текущий момент мы ждем появления уже анонсированных решений, о которых обязательно напишем в нашем блоге на Хабре!



Для получения дополнительной информации о продуктах Kingston Technology обращайтесь на официальный сайт компании.
Подробнее..

Как мы тестировали web систему с требованием в 42 000 пользователей

07.06.2021 08:09:22 | Автор: admin

Web-система. Ver 1.0

К нам обратился клиент, который хотел выполнить тестирование своей системы на возможность выдержать работу 42 000 пользователей. Они представили нам свой продукт и требования работы данного продукта:

  • Опросник международного уровня;

  • Содержал около 5 вкладок для перехода и переключение языка;

  • Срок работы опросника всего неделю, но судя по требованиям он должен был быть максимально стабильным и работать безотказно;

  • Ручное время выполнения сценария пользователем должно было составлять примерно 50 - 60 секунд по заявлениям заказчика.

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

И так, что же было у них под капотом:

  • Каждый переход по вкладке динамически загружал данные по технологии WebSocket;

  • На странице использовался JS, CSS;

  • Около 50 - 60 картинок;

  • Файл языкового пакета.

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

У нас новомодная технология и наша система высокопроизводительная... ?!

И так получив систему, мы были ознакомлены и с предоставляемым профилем нагрузки (клиент хочет, клиент платит - кто мы такие, чтобы спорить). Профиль нагрузки был амбициозный: начинаем с 625(!!!) пользователей и добавляем каждые 10 секунд еще столько же. Длительность теста 1 час.

Почему была выбрана цифра 625 для меня до сих пор загадка. Данный тест показал что за час не удалось полностью успешно завершиться ни одному (!) пользователю. Все отработали с ошибками.

В результате долгих обсуждение и обдумывания полученных результатов была попытка изменить профиль.

375 пользователей и добавлять столько же каждые 2 секунды. Длительность выполнения теста 30 минут.

Я не понимаю на что они надеялись поменяв параметры теста таким образом. Но на удивление результат работы был следующим: есть успешно выполненные, но среднее время выполнения сценария около 24 минут. Фактически полностью успешно выполнялись только те, что стартовали вначале.

Провал

В результате клиент был не удовлетворен результатами тестирования. Долгое время выполнения сценария, максимальное количество одновременно работающих пользователей составляло всего 500. Дальше начались отказы в доступе - 503 ошибки сервера.

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

При таком отображении для рядового пользователя работать было не возможно. В результате тест был признан провалившимся. Web - система не смогла выдержать требуемую нагрузку. Проблема заключалась в том, что данная технология создавала большое количество подключений и удерживала их долго время. И чем больше пользователей прибывало, тем быстрее заканчивались существующие ресурсы. Но заказчику в любом случае необходимо было реализовать web-систему и предоставить ее на всеобщее обозрение.

Web-система. Ver. 2.0

Более недели мы не получали от них никакой информации. Они к нам вернулись с полностью переработанным движком системы.

Дизайн остался старый, те же переходы по вкладкам, те же картинки. Но был выполнен отказ от динамической загрузки данных, отказались от технологии WebSocket. Все необходимые данные загружались при загрузке первой страницы. Переходы по вкладкам были выполнены с помощью JS и отображались исключительно на стороне пользователя системы. Данные теперь отправлялись единожды общим пакетом, когда опрос был полностью пройден и нажата необходимая кнопка. Единственным дополнительным переходом осталась загрузка другого языка при его смене. Таким образом они сократили количество используемых соединений с сервером во много раз.

Мы все переделали. Уж теперь точно полетит?!

Попробовали выполнять более агрессивные профили нагрузки. Сразу запускали 20 000 пользователей, потом 10 000. Но такое большое количество одновременно подключаемых пользователей заваливало сервер сразу же. Новые сервера опять же не успевали загружаться. В результате длительных дискуссий пришли к следующему профилю нагрузки: 150 пользователей и добавлять по 150 каждые 5 секунд. Длительность теста 40 минут.

В результате среднее время выполнения сценария 1 минута 20 секунд. Удалось значительно снизить количество ошибок подключения к Web-системе.

Да, теперь первая страница грузится дольше, чем в первом варианте. Но время выполнения всего сценария сократилось с 22 минут до 1 минуты и 20 секунд. Сервера начинали выдавать ошибки 503 только когда количество одновременно работающих пользователей перешло отметку в 5 000. Таким образом количество пользователей увеличилось в 10 раз с 500 до 5 000.

Времени для дальнейшей оптимизации уже не оставалось, т.к. через 2 дня у клиентов был релиз.

Результат

В результате ими принято решение признать тест успешным. Им не удалось достичь показателя в 50 - 60 секунд на один сценарий, им не удалось достичь 42 000 пользователей. Но они смогли сократить среднее время выполнения сценария и увеличить количество одновременно работающих пользователей в один момент времени.

Заключение

В заключение хочется отметить два момента.

В свете динамических развивающихся frameworkов (react, angular, node, sping и др.), не всегда стоит их применять для разработки продуктов. Порой javascript будет работать намного эффективнее, чем что-то еще. Необходимо инструменты реализации подбирать под задачу, и думать как ваша система будет работать. В указанном мной случае исполнение кода JS на стороне клиента оказалось на много производительнее, чем исполнение кода на стороне сервера от множества пользователей.

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

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

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

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

Подробнее..

Перевод Инженерная надежность и отказоустойчивость распределенной системы

08.06.2021 16:18:29 | Автор: admin

Это гостевая публикация отПэдди Байерса (Paddy Byers), сооснователя и технического директораAbly платформы для стриминга данных в реальном времени. Оригинал статьи опубликован вблоге Ably.

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

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

Для начала дадим несколько определений:

Надежность cтепень того, насколько пользователи могут положиться на продукт или сервис для решения своих задач.Доступностьиустойчивостьявляются видами надежности.
Доступность степеньготовностипродукта или сервиса к эксплуатации по требованию. Это понятие часто сcодят к обеспечению необходимого излишка ресурсов с учетомстатистически независимых отказов.
Устойчивость cпособность продукта или сервисасоответствовать заявленным характеристикамв процессе использования. Это значит, что система не просто готова к эксплуатации: благодаря дополнительным мощностям, предусмотренным в ходе проектирования,она может продолжать работать под нагрузкой, как и ожидают пользователи.
Отказоустойчивость способность системы сохранятьнадежность(доступность и устойчивость) при отказе отдельных компонентов или сбоях в подсистемах.

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

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

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

Доступность, устойчивость и состояние компонентов системы

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

В физическом мире традиционно различают:

  • ситуации, угрожающие доступности, когда можно остановить и затем возобновить работу сервиса например, остановить автомобиль, чтобы сменить покрышку;

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

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

В контексте распределенных систем, таких как Ably, существует аналогичное различие между компонентами, работающимис сохранением состоянияибез сохранения состояния.

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

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

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

Отказы неизбежны и естественны

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

В отличие от физического мира, отказы в цифровых системах обычно не определяются двумя состояниями. Обычные показатели устойчивости компонентов (например, среднее время безотказной работы) к таким системам не применимы. Сервисы выходят из строя постепенно, после нескольких сбоев. Классический пример здесь задача византийских генералов.

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

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

Сервисы без сохранения состояния

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

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

Однако простого наличия дополнительных ресурсов недостаточно их нужно эффективно использовать. Необходимо иметь возможность определять их доступность и распределять между ними нагрузку.

Таким образом, надо задать себе следующие вопросы:

  • Как сохранить работоспособность системы после разных типов отказов?

  • Какой уровень избыточности возможно обеспечить?

  • Какие ресурсы и показатели производительности необходимы, чтобы его поддерживать?

  • Каких эксплуатационных расходов требует управление этим уровнем избыточности?

Выбранное решение должно удовлетворять следующим критериям:

  • Требования клиентов к высокой доступности сервиса

  • Уровень эксплуатационных расходов для бизнеса

  • Инженерная целесообразность

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

Чтобы увеличить степень статистической независимости отказов в Ably,мы распределили мощности по множественным зонам доступности в разных регионах. Оказывать услуги из нескольких зон доступности в одном регионе проще AWS делает это практически без усилий. Зоны доступности в целом хорошо позволяют поддерживать независимость отказов, но их создание само по себе предполагает обеспечение необходимой избыточности для поддержания высокого уровня доступности.

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

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

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

Сервисы с сохранением состояния

В Ablyустойчивость определяется бесперебойной работойсервисов с сохранением состояния, и добиться этого намного сложнее, чем просто обеспечить доступность.

Работа таких сервисов напрямую зависит от конкретного состояния, которое устанавливается при каждом обращении к ним. Стабильность этих состояний обеспечивает корректную работу сервиса на уровне целого слоя. Это значит, что отказоустойчивости сервисов без сохранения состояния можно добиться, применяя к ним стандартные категории устойчивости. Чтобы не потерять текущие состояния в случае отказа, необходимо обеспечить постоянный уровень избыточности. Выявление и исправление дефектов позволяет устранить возможность возникновения византийских отказов с помощью механизмов достижения консенсуса.

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

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

В Ably мы предоставляем достаточно резервных мощностей для ресурсов без сохранения состояния, чтобы удовлетворить требования клиентов к доступности. Однако объектам с сохранением состояния необходимы не только дополнительные ресурсы, но и четкие инструкции по их использованию, чтобы обеспечить гарантированную функциональную бесперебойность сервиса.

Например, если обработка информации для определенного канала ведется на определенном инстансе, входящем в кластер, то в случае отказа этого инстанса (что приведет к изменению роли канала) для продолжения работы понадобятся дополнительные механизмы.

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

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

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

В течение какой доли времени сервис может принимать (и обрабатывать) сообщения, а не отклонять их?

Минимальный целевой показатель доступности для нас 99,99; 99,999 или даже 99,9999%.

Если вы попытались опубликовать сообщение и мы уведомили вас, что попытка не удалась, это говорит онехватке доступности. Это плохо, но, по крайней мере, вы в курсе ситуации.

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

Архитектурный подход к обеспечению устойчивости

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

Размещение роли с сохранением состояния

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

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

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

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

Это становится возможным благодаря последовательному алгоритму размещения в хеш-кольце

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

Выявить, хешировать, продолжить

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

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

Слой обеспечения постоянства канала

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

Во-первых, данные о приеме сообщения записываютсякак минимум в двух разных зонах доступности. Во-вторых, к этим зонам доступности предъявляются те же требования избыточности при последующем процессе обработки данных. Это главный принцип работы слоя обеспечения постоянства в Ably: мы записываем сообщение в нескольких местах и следим за тем, чтобы процесс записи был транзакционным. Мы прекращаем его, только убедившись, что попытка записи быланеудачнойилиоднозначно успешной. Зная это, мы можем быть уверены, что дальнейшая обработка будет произведена даже при отказе ролей, ответственных за этот процесс.

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

Благодаря нашей математической модели при отказе узла мы точно знаем, сколько времени уйдет на выявление причины сбоя, достижение консенсуса и последующего перемещения роли. Эти данные вкупе с частотой отказов для каждой зоны доступности позволяют создать вероятностную модель возникновения комплексного сбоя, который может привести к потере текущего состояния сервиса. Описанные базовые принципы позволяют нам гарантироватьустойчивость работы сервиса с вероятностью 99,999999%.

Вопросы внедрения отказоустойчивости

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

Достижение консенсуса в глобально распределенных системах

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

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

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

Работоспособность не определяется двумя состояниями

Классическая теория, которая легла в основу разработки протоколов Paxos и Raft, базируется на том, что при отказе или проблемах с работоспособностью сущности не просто сбоят или перестают отвечать на запросы. Вместо этого они могут выдавать отдельные симптомы неполадок, такие как отклонение запросов, высокий процент ошибок или в целом нестабильное и некорректное поведение (см.Задача византийских генералов). Эта проблема стоит очень остро и касается даже взаимодействия клиентов с сервисом.

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

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

Проблема доступности ресурсов

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

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

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

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

Проблема масштабирования ресурсов

Добавим к сказанному выше, что проблема может возникнуть не только с доступностью ресурсов, но и вследствие частоты запросов на их масштабирование. В стабильной, работоспособной системе у вас может бытьNканалов,Nподключений,Nсообщений иNмощностей для их обработки. Теперь представим, что произошел отказ, который привел к сбою в работе кластера изNинстансов. Если объем работ, необходимых для компенсации сбоя, составляет N, то затраты на поддержку резервных мощностей примут и вовсе неподъемный объем, так что единственным возможным решением станет переключение на другой регион или кластер.

Тривиальные механизмы обеспечения отказоустойчивости могут демонстрировать сложность O(N) или даже хуже, и это необходимо учитывать при поиске решения проблемы. Это еще одно напоминание о том, что отказ может произойти не только в результате поломки его причиной может стать слишком большой масштаб, сложность инфраструктуры и другие факторы нерационального использования ресурсов.

Заключение

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

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

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

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

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

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

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


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

Подробнее..

На пути к вершине Магма и Кузнечик на Эльбрусе

17.06.2021 16:13:58 | Автор: admin

В последнее время всё чаще появляются статьи о производительности российских процессоров Эльбрус на различных задачах. Тема криптографии пока что остаётся за кадром, хотя в разное время были упоминания то о высоких возможностях Эльбруса (некий ГОСТ лучше в 9 раз на Эльбрус-4С, чем на Intel Core i7-2600), то о плохой оптимизации компилятора и, соответственно, крайне низкой скорости реализованных алгоритмов (Кузнечик в 100 раз медленнее, чем на Intel?). Предлагаю наконец разобраться, что может Эльбрус, на примере двух ГОСТ алгоритмов симметричного шифрования.

Чтобы статья не вышла слишком большой, будем считать, что читатель имеет общее представление об архитектурах процессоров, в том числе знает об Эльбрусе. Если же нет, на сайте разработчика (компании МЦСТ) есть отличное руководство по программированию и книга об архитектуре в целом. Именно с этих материалов и началось моё знакомство с Эльбрусами. Также отмечу, что в современных процессорах очень много различных механизмов и особенностей, так что в статье буду касаться только тех, которые, на мой взгляд, важны при реализации выбранных алгоритмов.

Что может предложить архитектура Эльбрус

Для выполнения арифметических операций у Эльбруса есть 6 АЛУ (арифметико-логических устройств), способных выполнять операции параллельно. Начиная с версии 5 архитектуры появилась поддержка упакованных (SIMD) инструкций над 128-битными регистрами.

Для хранения промежуточных результатов присутствует большой регистровый файл: суммарно в процедуре можно использовать более 200 (64-битных) регистров общего назначения. Для SIMD вычислений используются те же самые регистры, а не отдельные, как это часто бывает. Соответственно, с 5 версии архитектуры все регистры стали 128-битными.

Задачу симметричного шифрования можно отнести к потоковой обработке массива данных. Для таких ситуаций в Эльбрусе есть механизм асинхронной подкачки данных из памяти APB (Array Prefetch Buffer). Использование этого механизма позволяет вовремя подгружать данные из памяти, не теряя время на кэш-промахи.

Выбор реализаций

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

Правда, о производительности ГОСТ алгоритмов обычно говорят только в контексте семейства x86-64, другие архитектуры мало кого интересуют. Но это не беда: мне показалось, что при знании команд ассемблера x86-64 ознакомиться с набором целочисленных и логических инструкций Эльбруса проще, чем, скажем, с ARM-овым. То есть прослеживаются определённые параллели, особенно, в области SIMD инструкций, и даже прямые аналоги. В остальном, конечно, у них нет ничего общего.

Итак, для Магмы известна эффективная реализация режимов, допускающих параллельную обработку блоков, то есть когда несколько блоков могут шифроваться независимо друг от друга. Это, например, режимы ECB, CTR, MGM. При этом скорость конкурирует с AES, для которого на x86-64 есть аппаратная поддержка. Реализация заточена именно под параллельную обработку, в случае последовательной (режимы с зацеплением) используются другие подходы. Мне интересно добиться максимальной скорости, поэтому я ограничился только случаем параллельной обработки.

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

Тестовые машины

То же самое в текстовом виде

Процессор

Версия арх-ры

Кол-во ядер

Тактовая частота

L1d

L1i

L2

L3

Эльбрус-4С

E2Kv3

4

0.75 ГГц

4 x 64 КБ

4 x 128 КБ

4 x 2 МБ

Нет

Эльбрус-1С+

E2Kv4

1

0.985 ГГц

1 x 64 КБ

1 x 128 КБ

1 x 2 МБ

Нет

Эльбрус-8С

E2Kv4

8

1.2 ГГц

8 x 64 КБ

8 x 128 КБ

8 x 512 КБ

16 МБ

Эльбрус-8СВ

E2Kv5

8

1.55 ГГц

8 x 64 КБ

8 x 128 КБ

8 x 512 КБ

16 МБ

Эльбрус-2С3

E2Kv6

2

2 ГГц

2 x 64 КБ

2 x 128 КБ

2 x 2 МБ

Нет

Эльбрус-16С

E2Kv6

16

2 ГГц

16 x 64 КБ

16 x 128 КБ

8 x 1 МБ

32 МБ

Магма

В случае x86-64 быстрая реализация Магмы опирается на использование расширений AVX и AVX2. При этом учитывается наличие в процессоре нескольких АЛУ и возможность параллельного исполнения до 3 векторных инструкций за один такт. Естественно, планирование параллельного исполнения остаётся на откуп процессора.

В случае же Эльбруса есть возможность явно распланировать параллельное исполнение. Опуская некоторые детали, можно считать, что на 3 и 4 поколении возможно исполнить 6 целочисленных векторных операций над 64-битными регистрами, а начиная с 5 поколения 4 векторных операции уже над 128-битными регистрами.

Для Эльбруса я написал собственную реализацию Магмы. Она использует те же идеи, что и исходная под x86-64, но при этом адаптирована под другой набор инструкций. Рассматривал перспективу написания на ассемблере и даже пробовал, но довольно быстро осознал, что ассемблер у Эльбруса достаточно сложный в плане программирования на нём (например, есть много нюансов по размерам задержек и зависимостям инструкций, которые тяжело учесть вручную). При этом оптимизирующий компилятор делает свою работу действительно хорошо: переставляет инструкции в рамках большого окна и при подборе опций компиляции выдаёт плотность кода, которая не отличается от теоретических оценок на количество инструкций и тактов. Так что я остановился на реализации на языке Си с использованием intrinsic функций для доступа к некоторым инструкциям процессора.

Для измерения скорости был выбран режим ECB. Обычно именно он (или даже его упрощения) используется при сравнении производительности, а скорость других режимов можно оценить на базе полученных результатов, отличия несущественны. Речь идёт о реализации базового алгоритма шифрования, поэтому накладные расходы от смены ключа также не учитываются. Объём данных для замера порядка 1 ГБ. Естественно, шифрование на одном ядре. Для многоядерной машины можно умножить результат на количество ядер и получить близкую к реальности оценку скорости. По крайней мере, во всех сравнениях я видел именно такую зависимость. Полученные результаты в таблице ниже:

То же самое в текстовом виде

Процессор

Скорость на невыровненных данных

Скорость на выровненных данных

Производительность

Эльбрус-4С

116 МБ/с

137 МБ/с

5.2 такт/байт

Эльбрус-1С+

151 МБ/с

179 МБ/с

5.2 такт/байт

Эльбрус-8С

185 МБ/с

220 МБ/с

5.2 такт/байт

Эльбрус-8СВ

402 МБ/с

520 МБ/с

2.8 такт/байт

Эльбрус-2С3

669 МБ/с

670 МБ/с

2.8 такт/байт

Эльбрус-16С

671 МБ/с

672 МБ/с

2.8 такт/байт

Здесь под выровненными данными подразумевается выравнивание по границе 8 байтов для E2Kv3/E2Kv4 и 16 байтов для E2Kv5/E2Kv6. При наличии такого выравнивания (на версиях до 6) работает механизм APB и данные для шифрования эффективно подкачиваются из памяти. При этом с версии 6 APB уже не требует выравнивания данных, поэтому при любом расположении данных достигается максимальная скорость. Для невыровненных данных на предыдущих версиях архитектуры я не провёл достаточно исследований, так что значения в этом столбце таблицы можно считать нижней границей.

Для сравнения приведу результаты из статьи с описанием базовой реализации на Intel Core i3-7100 @ 3.9 ГГц. При использовании AVX 458 МБ/с, 8.1 такт/байт; AVX2 1030 МБ/с, 3.6 такт/байт. Так что по абсолютной скорости Эльбрус достаточно близок к современным процессорам Intel (это при значительной разнице в тактовой частоте!) и превосходит x86-64 с AVX в тактах более чем в 1.5 раза (для 3 и 4 поколения), а x86-64 с AVX2 в 1.3 раза (для 5 поколения).

Кузнечик

По сравнению с Магмой, структура Кузнечика является более сложной. Несмотря на то, что удалось декомпозировать нелинейное преобразование S, техники реализации, основанные на широком использовании SIMD-инструкций, пока что отстают от "классической" реализации со склеенным LS (линейным и нелинейным) преобразованием и таблицей предвычислений размером 64 КБ (упоминается в статье под именами с LS или более простое описание на Хабре).

В случае x86-64 Кузнечик эффективнее всего реализуется с использованием AVX-инструкций (удобно работать со 128-битными регистрами, так как длина блока и размер значений в таблице равны в точности 128 битам). При этом для вычислений адресов в таблице не удаётся воспользоваться эффективной адресацией Scale-Index-Base-Displacement (именование из статьи), так как в качестве Scale нужно значение 16, а максимально возможное 8. На Эльбрусе можно ожидать конкурирующих результатов за счёт большого кэша L1d (64 КБ) и наличия 4 АЛУ, обеспечивающих произвольный доступ к памяти (насколько мне известно, у абсолютного большинства процессоров x86-64 2 порта для загрузки данных).

Как и в случае с Магмой, для Кузнечика я написал отдельную реализацию на Си под Эльбрус, чтобы добиться максимальных результатов. Начиная с 5 версии архитектуры я явным образом использовал тип __v2di (см. e2kintrin.h в составе компилятора), чтобы быть уверенным, что получится использовать регистры как 128-битные.

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

Итак, в случае строго последовательной обработки данных:

То же самое в текстовом виде

Процессор

Скорость на невыровненных данных

Скорость на выровненных данных

Производительность

Эльбрус-4С

52 МБ/с

69 МБ/с

10.4 такт/байт

Эльбрус-1С+

63 МБ/с

90 МБ/с

10.4 такт/байт

Эльбрус-8С

80 МБ/с

110 МБ/с

10.4 такт/байт

Эльбрус-8СВ

95 МБ/с

150 МБ/с

9.9 такт/байт

Эльбрус-2С3

170 МБ/с

171 МБ/с

11 такт/байт

Эльбрус-16С

171 МБ/с

172 МБ/с

11 такт/байт

Для сравнения результаты из статьи (лучшие из опубликованных) на Intel Core i7-6700 @ 4 ГГц 170МБ/с, 22.4 такт/байт. В отличие от Магмы, можно говорить о сопоставимой абсолютной скорости и преимуществе в тактах более чем в 2 раза.

В таблице заметен интересный момент: результаты ощутимо колеблются среди последних 3 версий Эльбруса. Такое поведение я заметил буквально недавно и начал обсуждение с коллегами из МЦСТ, так что есть надежда, что результаты удастся немного улучшить при доработке компилятора.

С параллельной обработкой ситуация намного интереснее:

То же самое в текстовом виде

Процессор

Скорость на невыровненных данных

Скорость на выровненных данных

Производительность

Эльбрус-4С

78 МБ/с

83 МБ/с

8.6 такт/байт

Эльбрус-1С+

102 МБ/с

108 МБ/с

8.7 такт/байт

Эльбрус-8С

126 МБ/с

133 МБ/с

8.6 такт/байт

Эльбрус-8СВ

248 МБ/с

291 МБ/с

5.1 такт/байт

Эльбрус-2С3

453 МБ/с

454 МБ/с

4.2 такт/байт

Эльбрус-16С

454 МБ/с

455 МБ/с

4.2 такт/байт

И традиционное сравнение с Intel Core i7-6700 @ 4 ГГц: на нём достигается 360 МБ/с, 10.6 такт/байт. В отличие от случая последовательной обработки, у E2Kv3 и E2Kv4 преимущество Эльбруса не такое большое, предположительно из-за того, что реализация обработки нескольких блоков вместе обладает более высокой степенью параллельности и планировщику на x86-64 легче справиться с выявлением независимых операций. А вот появление у 5 поколения Эльбруса 128-битных регистров и загрузок из памяти позволяет ему сохранить преимущество в тактах более чем в 2 раза.

Сравнивать E2Kv6 с i7-6700 оказалось несолидно, поэтому я взял ассемблерную реализацию режима ECB и провёл собственный замер. В статье с описанием результатов на i7-6700 данные шифруются на месте, без работы с памятью, поэтому у честного режима ECB результат чуточку хуже: на самом мощном из доступных мне процессоров Intel Core i7-9700K @ 4.7 ГГц вышло 411 МБ/с, 10.9 такт/байт. Эльбрус оказался быстрее, обеспечивая преимущество в тактах в 2.5 раза.

Заключение

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

За время изучения архитектуры Эльбруса у меня сложилось впечатление, что многие полезные инструкции исторически добавлялись для обеспечения работы двоичного транслятора, но ситуация изменилась с 5 версии: Эльбрус начал больше развиваться собственным путём. Эту положительную динамику невозможно не отметить.

С другой стороны, сложившаяся похожесть ряда инструкций упрощает разработку и оптимизацию под Эльбрус. Можно сказать, что эта статья предлагает простой способ портирования и оптимизации алгоритмов под Эльбрус: достаточно взять хорошо зарекомендовавший себя на Intel/AMD алгоритм и немного адаптировать его под Эльбрус. Я искренне верю, что в результате практически любой алгоритм должен работать по крайней мере не хуже, чем в разницу тактовых частот.

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

P.S.

Эта статья написана по мотивам моего устного доклада на конференции РусКрипто. По ссылке можно найти презентацию, которая является краткой выжимкой с основными результатами на тот момент. К моменту же написания статьи удалось улучшить некоторые результаты, а также проверить реализации на новом поколении процессоров.

Несмотря на то, что для получения описанных результатов мне удалось разобраться с Эльбрусом на основании только открытой информации и документации к компилятору, я хочу выразить благодарность сотрудникам МЦСТ, в особенности, Александру Трушу, за ответы на периодически возникавшие у меня вопросы и, конечно, за предоставление удалённого доступа к новым процессорам.

Подробнее..

Следствие вели пропажа FC-линков HBA Emulex на сервере Atos BullSequana S1600

18.06.2021 20:15:41 | Автор: admin

Привет, Хабр! Мы постоянно проводим тесты различных софтверных решений на нашем оборудовании, и иногда простая, казалось бы, задача разворачивается на недели. Как раз о таком случае сегодня и пойдет речь. Главный герой нашего рассказа - Павел, технический консультант компании Atos в России.

Рыцари Постгрес тестируют

Итак, на сервере Atos BullSequana S1600 (16 процессоров Intel Platinum 8260), разделенном логически на 2 половинки по 8 сокетов, установлено 4 HBA Emulex LPe31002-M6 (2х-портовые, 16 Гбит), по 2 на каждой половине. FC-линки подключены через 2 MDS-свитча производства Cisco, и с помощью multipath предоставляют системе один диск объемом 6 Тб. В самом начале тестов каждая карта была подключена всего одним линком, но потом, в ходе диагностики, для большей надежности и вообще повышения крутизны подключили все порты. Итого, на каждой половинке сервера оказалось по 4 FC-линка. Во время тестов работы с диском не было.

ОС на обеих половинках на момент старта нашего повествования CentOS Linux release 7.7.1908 с ядром: 3.10.0-1062.12.1.el7

Версия FW карт - 12.6.240.40 (рекомендованная Atos, обновлялась в процессе работ).

Версия драйвера lpfc (судя по всему, родная, из коробки ОС) 12.0.0.13.

Объём доступной памяти всего-навсего 4096 Гб на каждой половинке, с учетом резервирования части памяти под нужды железа под ОС остается 3968 Гб.

Все началось с того, что специалисты по СУБД Postgres решили протестировать железо с помощью stress-ng пакета, в попытке доказать, что наше оборудование не выдерживает нагрузки (у них были инциденты, в рамках расследования которых всё и завертелось).

Параметры стресс-теста взяты "замечательные", вот команда запуска

stress-ng --vm-rw 1000 --vm-rw-bytes 2500G --verify --metrics-brief -t 60m

По документации, такие параметры означают, что стартовали 1000 процессов (start N workers that transfer memory to/from a parent/child), дали по 2500Гб оперативной памяти каждому (mmap N bytes per vm-rw worker) и сказали обмениваться с помощью функций Линукса process_vm_writev и process_vm_readv, а результат обмена проверять на ошибки, и так час. Ошибок при передаче данных не возникало, но вот проблемы с ОС и FC-линками были.

Позже, надо сказать, тестировали с еще более забавными параметрами stress-ng --vm-rw 2000 --vm-rw-bytes 3500G --verify --metrics-brief -t 10m.

Линукс крут. Такие параметры означают, что почти все время Линукс тратил на переключение процессов, обращения к памяти и передачу данных между NUMA-нодами, и сам по себе не падал, но люто тормозил. После небольшой настройки наше оборудование тоже стало справляться с такой нагрузкой без падений (Тормоза? Кто сказал тормоза?), но вот FC-линкам действительно становилось плохо они отваливались, один за другим, и победить это настройками не удавалось.

Со стороны свитчей это выглядело примерно так:

MDS1

2021 Feb 15 23:43:57 dn-MDS-C9148S-1 %PORT-5-IF_DOWN_LINK_FAILURE: %$VSAN 2221%$ Interface fc1/15 is down (Link failure loss of signal)

2021 Feb 15 23:45:24 dn-MDS-C9148S-1 %PORT-5-IF_DOWN_LINK_FAILURE: %$VSAN 2221%$ Interface fc1/27 is down (Link failure loss of signal)

MDS2

2021 Feb 15 23:21:54 dn-MDS-C9148S-2 %PORT-5-IF_DOWN_LINK_FAILURE: %$VSAN 2222%$ Interface fc1/27 is down (Link failure loss of signal)

2021 Feb 16 00:00:02 dn-MDS-C9148S-2 %PORT-5-IF_DOWN_LINK_FAILURE: %$VSAN 2222%$ Interface fc1/15 is down (Link failure loss of signal)

Техподдержка врёт (ну или добросовестно заблуждается).

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

Сначала, по просьбе Паши, заказчик поставил Emulex OneCommand Manager Command Line Interface и попробовал некоторые команды, например, получить список HBA, проверить статус портов, принудительно включить порт, перезагрузить HBA-карту.

Ничего из этого не помогло, но стало известно, что точный статус порта User Offline, позже, проанализировав выводы команд, техподдержка Emulex дала вот такой ответ по поводу статуса порта User Offline:

The Port state goes to User-offline, when the port bring-up fails even after reset. This is done by FC Driver. The reason for port bring-up failure could be due to various reasons (May be link issue (or) switch F-Port issue (or) HBA N-Port issue (or) authentication issue etc.)..

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

Первым делом в техподдержку отправили здоровенный кусок информации в виде логов, собранных специальным инструментом OneCapture. Поскольку карты были более-менее здоровы (за минусом портов), набор логов собрался (хотя и поразил объемами два пакета логов, в 9 и 36 ГИГАБАЙТ), и меньший из них послали доблестным специалистам техподдержки.

Логов не хватило.

Позволим себе процитировать:

The issue here is that the link state went to LPFC_HBA_ERROR state because of which board_mode is showing port status output as error.

Driver will not be able to post mailbox if link state is in error state and it will start throwing errors.

To debug further, our Development team needs more driver logs with log-verbosity set to 0x1ffff on the errored port.

*Steps to follow to collect logs:

==============

1. set the verbosity log level using HBACMD # hbacmd setDriverParam 10:00:00:10:**:**:**:** L P log-verbose 0x1ffff

2.Reset the port so that the port initialization events start # hbacmd reset 10:00:00:10:**:**:**:** (In case the boot mode is enabled, disable it using below command and then retry 2) (((#hbacmd EnableBootCode 10:00:00:10:**:**:**:** D) ))

3. After few seconds if collect the onecapture again using below options to skip Linux crash dump collection. This will give compelete faster and less file size, as crash dump is skipped.

#./OneCapture_Linux.sh --FullCapture --Adapters=all --NoCrashDump

4. After this Please collect HBA dump as well. Reason, onecapture failed to collect dump in previous attempt.

# hbacmd dump 10:00:00:10:**:**:**:**

Затем произошла перезагрузка, и линки восстали из мертвых (и даже не пахли). FW карт обновили до версии в описании, а техподдержка Emulex обрадовалась.

Но потом тестирование продолжили и линки начали вновь падать. Стало ясно, что трабл не решен, и логи пришлось собирать заново.

Заказчик, которому переслали инструкции выше, ухмыляясь и хмыкая (а вы бы не ухмылялись?), собрал новые логи... Но и тут не обошлось без проблем выставить флаг более подробных логов не получилось, потому что:

Это, кстати, удалось победить командой echo 0x1ffff > /sys/class/scsi_host/host16/lpfc_log_verbose.

"Не хочешь таблетку вот тебе свечка. Организм тот же пути разные..."

Логи были собраны, и техподдержка Emulex удалилась. Надо сказать, что на анализ логов они потратили всего лишь день.

Ответ был прекрасен:

Our Development team has analyzed the logs and gav below analysis:

====

Below sequence of events have forced the port to offline state:

1. IOCB time out error occurred and the IO abort was issued.

2. SCSI layer issued Device Reset of the LUN.

3. Bus reset performed by driver.

4. After the reset, driver failed to port sgl with -EIO error and brought the port offline.

There were also some kernel traces as well regarding tainted kernel (oracle linux)

wn2pynn00db0022 kernel: Tainted: G OE 4.14.35-1818.3.3.el7uek.x86_64 #2

=====

Our development team believes that, these logs indicate a possible scsi midlayer issue and not LPFC-driver or HBA-Firmware issue. Proper kernel upgrade may be required to resolve this issue.

Конечно, были попытки понять, о чем они пишут, но успехом это не увенчалось. Да, этими заклинаниями только Ктулху будить...

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

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

Заказчик, ядро и уже наша техподдержка

Заказчик отзывчив ядро обновили, до версии 3.10.0-1160.15.2.el7. И запустили тест. Линки упали. Доблестные рыцари Постгреса радостно потирали лапки (это было видно по письмам, хотя, это могли быть галлюцинации от неумеренного общения с техподдержкой разных уровней).

Итак, линки все еще падают непонятно от чего, поддержки ОС нет (CentOS же), разобраться в настройках драйвера самостоятельно это угробить кучу времени без шансов на успех (вы видели тот талмуд?! - Вот он).

Наша глобальная поддержка (да, мы обратились еще и к нашим глобалам), тоже не сказала ничего нового железо в порядке, карты в порядке, идите в техподдержку ОС, но можете попробовать обновить все до последних версий. Или установить корпоративную ОС с поддержкой, и тогда пойти в техподдержку ОС...

Что забыли потрогать

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

За всё время этой проблемы пошатали и потрогали всё саму ОС, FW, прошивку HW сервера, настройки HW сервера, параметры GRUB, настройки фабрики, свитчей и линков...

Всё, кроме драйвера lpfc.

Терять было уже нечего, и помимо рекомендации перейдите на другую ОС мы попросили еще и обновить драйвер, до последней версии на сайте Emulex до 12.8.340.9.

И это помогло! После обновления драйвера FC-линки больше не падали. От выдоха облегчения чуть не свалился монитор, а сам Паша (реактивный эффект, ага) чуть не упал со стула.

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

Возможно, под поверхностью все еще притаился монстр какой-нибудь упущенный фактор, но пока (уже больше 3-х месяцев) все тихо.

Итоги

  1. Удалось победить проблему падения FC-линков обновлением FW, драйвера и ядра до последних (или рекомендованных) версий.

  2. Техподдержка врёт (ну или добросовестно заблуждается), поэтому приходится старательно все проверять самому.

  3. Трогать и шатать при траблшутинге надо всё!

Подробнее..

Ваш безлимит как увеличить пропускную способность автомерджа

21.06.2021 14:12:41 | Автор: admin

Отыщи всему начало, и ты многое поймёшь (Козьма Прутков).

Меня зовут Руслан, я релиз-инженер в Badoo и Bumble. Недавно я столкнулся с необходимостью оптимизировать механизм автомерджа в мобильных проектах. Задача оказалась интересной, поэтому я решил поделиться её решением с вами. В статье я расскажу, как у нас раньше было реализовано автоматическое слияние веток Git и как потом мы увеличили пропускную способность автомерджа и сохранили надёжность процессов на прежнем высоком уровне.

Свой автомердж

Многие программисты ежедневно запускают git merge, разрешают конфликты и проверяют свои действия тестами. Кто-то автоматизирует сборки, чтобы они запускались автоматически на отдельном сервере. Но решать, какие ветки сливать, всё равно приходится человеку. Кто-то идёт дальше и добавляет автоматическое слияние изменений, получая систему непрерывной интеграции (Continuous Integration, или CI).

Например, GitHub предлагает полуручной режим, при котором пользователь с правом делать записи в репозиторий может поставить флажок Allow auto-merge (Разрешить автомердж). При соблюдении условий, заданных в настройках, ветка будет соединена с целевой веткой. Bitbucket поддерживает большую степень автоматизации, накладывая при этом существенные ограничения на модель ветвления, имена веток и на количество мерджей.

Такой автоматизации может быть достаточно для небольших проектов. Но с увеличением количества разработчиков и веток, ограничения, накладываемые сервисами, могут существенно повлиять на производительность CI. Например, раньше у нас была система мерджа, при которой основная ветка всегда находилась в стабильном состоянии благодаря последовательной стратегии слияний. Обязательным условием слияния была успешная сборка при наличии всех коммитов основной ветки в ветке разработчика. Работает эта стратегия надёжно, но у неё есть предел, определяемый временем сборки. И этого предела оказалось недостаточно. При времени сборки в 30 минут на обработку 100 слияний в день потребовалось бы более двух суток. Чтобы исключить ограничения подобного рода и получить максимальную свободу выбора стратегий мерджа и моделей ветвления, мы создали собственный автомердж.

Итак, у нас есть свой автомердж, который мы адаптируем под нужды каждой команды. Давайте рассмотрим реализацию одной из наиболее интересных схем, которую используют наши команды Android и iOS.

Термины

Main. Так я буду ссылаться на основную ветку репозитория Git. И коротко, и безопасно. =)

Сборка. Под этим будем иметь в виду сборку в TeamCity, ассоциированную с веткой Git и тикетом в трекере Jira. В ней выполняются как минимум статический анализ, компиляция и тестирование. Удачная сборка на последней ревизии ветки в сочетании со статусом тикета To Merge это однo из необходимых условий автомерджа.

Пример модели ветвления

Испробовав разные модели ветвления в мобильных проектах, мы пришли к следующему упрощённому варианту:

На основе ветки main разработчик создаёт ветку с названием, включающим идентификатор тикета в трекере, например PRJ-k. По завершении работы над тикетом разработчик переводит его в статус Resolved. При помощи хуков, встроенных в трекер, мы запускаем для ветки тикета сборку. В определённый момент, когда изменения прошли ревью и необходимые проверки автотестами на разных уровнях, тикет получает статус To Merge, его забирает автоматика и отправляет в main.

Раз в неделю на основе main мы создаём ветку релиза release_x.y.z, запускаем на ней финальные сборки, при необходимости исправляем ошибки и наконец выкладываем результат сборки релиза в App Store или Google Play. Все фазы веток отражаются в статусах и дополнительных полях тикетов Jira. В общении с Jira помогает наш клиент REST API.

Такая простая модель не только позволила нам построить надёжный автомердж, но и оказалась удобной для всех участников процесса. Однако сама реализация автомерджа менялась несколько раз, прежде чем мы добились высокой производительности и минимизировали количество побочных эффектов: конфликтов, переоткрытий тикетов и ненужных пересборок.

Первая версия: жадная стратегия

Сначала мы шли от простого и очевидного. Брали все тикеты, находящиеся в статусе To Merge, выбирали из них те, для которых есть успешные сборки, и отправляли их в main командой git merge, по одной.

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

Наличие в TeamCity актуальной успешной сборки мы проверяли при помощи метода REST API getAllBuilds примерно следующим образом (псевдокод):

haveFailed = False # Есть ли неудачные сборкиhaveActive = False # Есть ли активные сборки# Получаем сборки типа buildType для коммита commit ветки branchbuilds = teamCity.getAllBuilds(buildType, branch, commit)# Проверяем каждую сборкуfor build in builds:  # Проверяем каждую ревизию в сборке  for revision in build.revisions:    if revision.branch is branch and revision.commit is commit:      # Сборка актуальна      if build.isSuccessful:        # Сборка актуальна и успешна        return True      else if build.isRunning or build.isQueued        haveActive = True      else if build.isFailed:        haveFailed = Trueif haveFailed:  # Исключаем тикет из очереди, переоткрывая его  ticket = Jira.getTicket(branch.ticketKey)  ticket.reopen("Build Failed")  return Falseif not haveActiveBuilds:  # Нет ни активных, ни упавших, ни удачных сборок. Запускаем новую  TriggerBuild(buildType, branch)

Ревизии это коммиты, на основе которых TeamCity выполняет сборку. Они отображаются в виде 16-ричных последовательностей на вкладке Changes (Изменения) страницы сборки в веб-интерфейсе TeamCity. Благодаря ревизиям мы можем легко определить, требуется ли пересборка ветки тикета или тикет готов к слиянию.

Важно, что ревизию можно (а часто даже необходимо) указывать в запросе на добавление новой сборки в очередь в параметре lastChanges, потому что в противном случае TeamCity может выбрать устаревшую ревизию ветки при запуске сборки. Как будет показано ниже, указывать ревизию необходимо в тех случаях, если, например, логика вне TeamCity основана на поиске сборок на конкретных коммитах (наш случай).

Так как после перевода тикета в статус готовности (в нашем примере Resolved) соответствующая ветка, как правило, не меняется, то и сборка, ассоциированная с тикетом, чаще всего остаётся актуальной. Кроме того, сам факт нахождения тикета в статусе To Merge говорит о высокой вероятности того, что сборка не упала. Ведь при падении сборки мы сразу переоткрываем тикет.

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

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

Конфликты слияния

Если изменить одну и ту же строку кода в разных ветках и попытаться соединить их в main, то Git попросит разрешить конфликты слияния. Из двух вариантов нужно выбрать один и закоммитить изменения.

Это должно быть знакомо практически каждому пользователю системы контроля версий (VCS). Процессу CI, так же, как и любому пользователю VCS, нужно разрешать конфликты. Правда, делать это приходится немного вслепую, в условиях почти полного непонимания кодовой базы.

Если команда git merge завершилась с ошибкой и для всех файлов в списке git ls-files --unmerged заданы обработчики конфликтов, то для каждого такого файла мы выполняем парсинг содержимого по маркерам конфликтов <<<<<<<, ======= и >>>>>>>. Если конфликты вызваны только изменением версии приложения, то, например, выбираем последнюю версию между локальной и удалённой частями конфликта.

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

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

Логические конфликты

А может ли случиться так, что, несмотря на успешность сборок пары веток в отдельности, после слияния их с main сборка на основной ветке упадёт? Практика показывает, что может. Например, если сумма a и b в каждой из двух веток не превышает 5, то это не гарантирует того, что совокупные изменения a и b в этих ветках не приведут к большей сумме.

Попробуем воспроизвести это на примере Bash-скрипта test.sh:

#!/bin/bashget_a() {    printf '%d\n' 1}get_b() {    printf '%d\n' 2}check_limit() {    local -i value="$1"    local -i limit="$2"    if (( value > limit )); then        printf >&2 '%d > %d%s\n' "$value" "$limit"        exit 1    fi}limit=5a=$(get_a)b=$(get_b)sum=$(( a + b ))check_limit "$a" "$limit"check_limit "$b" "$limit"check_limit "$sum" "$limit"printf 'OK\n'

Закоммитим его и создадим пару веток: a и b.
Пусть в первой ветке функция get_a() вернёт 3, а во второй get_b() вернёт 4:

diff --git a/test.sh b/test.shindex f118d07..39d3b53 100644--- a/test.sh+++ b/test.sh@@ -1,7 +1,7 @@ #!/bin/bash get_a() {-    printf '%d\n' 1+    printf '%d\n' 3 } get_b() {git diff main bdiff --git a/test.sh b/test.shindex f118d07..0bd80bb 100644--- a/test.sh+++ b/test.sh@@ -5,7 +5,7 @@ get_a() { }  get_b() {-    printf '%d\n' 2+    printf '%d\n' 4 }  check_limit() {

В обоих случаях сумма не превышает 5 и наш тест проходит успешно:

git checkout a && bash test.shSwitched to branch 'a'OKgit checkout b && bash test.shSwitched to branch 'b'OK

Но после слияния main с ветками тесты перестают проходить, несмотря на отсутствие явных конфликтов:

git merge a bFast-forwarding to: aTrying simple merge with bSimple merge did not work, trying automatic merge.Auto-merging test.shMerge made by the 'octopus' strategy. test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-)bash test.sh7 > 5

Было бы проще, если бы вместо get_a() и get_b() использовались присваивания: a=1; b=2, заметит внимательный читатель и будет прав. Да, так было бы проще. Но, вероятно, именно поэтому встроенный алгоритм автомерджа Git успешно обнаружил бы конфликтную ситуацию (что не позволило бы продемонстрировать проблему логического конфликта):

git merge a Updating 4d4f90e..8b55df0Fast-forward test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-)git merge b Auto-merging test.shCONFLICT (content): Merge conflict in test.shRecorded preimage for 'test.sh'Automatic merge failed; fix conflicts and then commit the result.

Разумеется, на практике конфликты бывают менее явными. Например, разные ветки могут полагаться на API разных версий какой-нибудь библиотеки зависимости, притом что более новая версия не поддерживает обратной совместимости. Без глубоких знаний кодовой базы (читай: без разработчиков проекта) обойтись вряд ли получится. Но ведь CI как раз и нужен для решения таких проблем.

Конечно, от разрешения конфликта мы никуда не уйдём кто-то должен внести правки. Но чем раньше нам удастся обнаружить проблему, тем меньше людей будет привлечено к её решению. В идеале потребуется озадачить лишь разработчика одной из конфликтующих веток. Если таких веток две, то одна из них вполне может быть соединена с main.

Превентивные меры

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

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

Вторая версия: последовательная стратегия

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

Git, по идее, как раз и является средством синхронизации. Но порядок попадания веток в main и, наоборот, main в ветки определяем мы сами. Чтобы определить точно, какие из веток вызывают проблемы в main, можно попробовать отправлять их туда по одной. Тогда можно выстроить их в очередь, а порядок организовать на основе времени попадания тикета в статус To Merge в стиле первый пришёл первым обслужен.

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

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

Но есть у этой схемы существенный недостаток: пропускная способность автомерджа линейно зависит от времени сборки. При среднем времени сборки iOS-приложения в 25 минут мы можем рассчитывать на прохождение максимум 57 тикетов в сутки. В случае же с Android-приложением требуется примерно 45 минут, что ограничивает автомердж 32 тикетами в сутки, а это даже меньше количества Android-разработчиков в нашей компании.

На практике время ожидания тикета в статусе To Merge составляло в среднем 2 часа 40 минут со всплесками, доходящими до 10 часов! Необходимость оптимизации стала очевидной. Нужно было увеличить скорость слияний, сохранив при этом стабильность последовательной стратегии.

Финальная версия: сочетание последовательной и жадной стратегий

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

Давайте вспомним идею жадной стратегии: мы сливали все ветки готовых тикетов в main. Основной проблемой было отсутствие синхронизации между ветками. Решив её, мы получим быстрый и надёжный автомердж!

Раз нужно оценить общий вклад всех тикетов в статусе To Merge в main, то почему бы не слить все ветки в некоторую промежуточную ветку Main Candidate (MC) и не запустить сборку на ней? Если сборка окажется успешной, то можно смело сливать MC в main. В противном случае придётся исключать часть тикетов из MC и запускать сборку заново.

Как понять, какие тикеты исключить? Допустим, у нас n тикетов. На практике причиной падения сборки чаще всего является один тикет. Где он находится, мы не знаем все позиции от 1 до n являются равноценными. Поэтому для поиска проблемного тикета мы делим n пополам.

Так как место тикета в очереди определяется временем его попадания в статус To Merge, имеет смысл брать ту половину, в которой расположены тикеты с большим временем ожидания.

Следуя этому алгоритму, для k проблемных тикетов в худшем случае нам придётся выполнить O(k*log2(n)) сборок, прежде чем мы обработаем все проблемные тикеты и получим удачную сборку на оставшихся.

Вероятность благоприятного исхода велика. А ещё в то время, пока сборки на ветке MC падают, мы можем продолжать работу при помощи последовательного алгоритма!

Итак, у нас есть две автономные модели автомерджа: последовательная (назовём её Sequential Merge, или SM) и жадная (назовём её Greedy Merge, или GM). Чтобы получить пользу от обеих, нужно дать им возможность работать параллельно. А параллельные процессы требуют синхронизации, которой можно добиться либо средствами межпроцессного взаимодействия, либо неблокирующей синхронизацией, либо сочетанием этих двух методов. Во всяком случае, мне другие методы неизвестны.

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

Остаётся предотвратить все возможные случаи состояний гонки. Их много, но для понимания сути приведу несколько самых важных:

  1. SM-SM и GM-GM: между командами одного типа.

  2. SM-GM: между SM и GM в рамках одного репозитория.

Первая проблема легко решается при помощи мьютекса по токену, включающему в себя имя команды и название репозитория. Пример: lock_${command}_${repository}.

Поясню, в чём заключается сложность второго случая. Если SM и GM будут действовать несогласованно, то может случиться так, что SM соединит main с первым тикетом из очереди, а GM этого тикета не заметит, то есть соберёт все остальные тикеты без учёта первого. Например, если SM переведёт тикет в статус In Master, а GM будет всегда выбирать тикеты по статусу To Merge, то GM может никогда не обработать тикета, соединённого SM. При этом тот самый первый тикет может конфликтовать как минимум с одним из других.

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

Таким образом, мы получили своего рода неблокирующую синхронизацию.

Немного о TeamCity

В процессе реализации GM нам предстояло обработать много нюансов, которыми я не хочу перегружать статью. Но один из них заслуживает внимания. В ходе разработки я столкнулся с проблемой зацикливания команды GM: процесс постоянно пересобирал ветку MC и создавал новую сборку в TeamCity. Проблема оказалась в том, что TeamCity не успел скачать обновления репозитория, в которых была ветка MC, созданная процессом GM несколько секунд назад. К слову, интервал обновления репозитория в TeamCity у нас составляет примерно 30 секунд.

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

Кто-то посчитает решение очевидным, но я нашёл его не сразу. Оказывается, прикрепить ревизию к сборке при её добавлении в очередь можно при помощи параметра lastChanges метода addBuildToQueue:

<lastChanges>  <change    locator="version:{{revision}},buildType:(id:{{build_type}})"/></lastChanges>

В этом примере {{revision}} заменяется на 16-ричную последовательность коммита, а {{build_type}} на идентификатор конфигурации сборки. Но этого недостаточно, так как TeamCity, не имея информации о новом коммите, может отказать нам в запросе.

Для того чтобы новый коммит дошёл до TeamCity, нужно либо подождать примерно столько, сколько указано в настройках конфигурации корня VCS, либо попросить TeamCity проверить наличие изменений в репозитории (Pending Changes) при помощи метода requestPendingChangesCheck, а затем подождать, пока TeamCity скачает изменения, содержащие наш коммит. Проверка такого рода выполняется посредством метода getChange, где в changeLocator нужно передать как минимум сам коммит в качестве параметра локатора version. Кстати, на момент написания статьи (и кода) на странице ChangeLocator в официальной документации описание параметра version отсутствовало. Быть может, поэтому я не сразу узнал о его существовании и о том, что это 40-символьный 16-ричный хеш коммита.

Псевдокод:

teamCity.requestPendingChanges(buildType)attempt = 1while attempt <= 20:  response = teamCity.getChange(commit, buildType)  if response.commit == commit:    return True # Дождались  sleep(10)return False

О предельно высокой скорости слияний

У жадной стратегии есть недостаток на поиск ветки с ошибкой может потребоваться много времени. Например, 6 сборок для 20 тикетов у нас может занять около трёх часов. Можно ли устранить этот недостаток?

Допустим, в очереди находится 10 тикетов, среди которых только 6-й приводит к падению сборки.

Согласно жадной стратегии, мы пробуем собрать сразу все 10 тикетов, что приводит к падению сборки. Далее собираем левую половину (с 1 по 5) успешно, так как тикет с ошибкой остался в правой половине.

Если бы мы сразу запустили сборку на левой половине очереди, то не потеряли бы времени. А если бы проблемным оказался не 6-й тикет, а 4-й, то было бы выгодно запустить сборку на четверти длины всей очереди, то есть на тикетах с 1 по 3, например.

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

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

Примерно такой же алгоритм реализован в премиум-функции GitLab под названием Merge Trains. Перевода этого названия на русский язык я не нашёл, поэтому назову его Поезда слияний. Поезд представляет собой очередь запросов на слияние с основной веткой (merge requests). Для каждого такого запроса выполняется слияние изменений ветки самого запроса с изменениями всех запросов, расположенных перед ним (то есть запросов, добавленных в поезд ранее). Например, для трёх запросов на слияние A, B и С GitLab создаёт следующие сборки:

  1. Изменения из А, соединённые с основной веткой.

  2. Изменения из A и B, соединённые с основной веткой.

  3. Изменения из A, B и C, соединённые с основной веткой.

Если сборка падает, то соответствующий запрос из очереди удаляется, а сборки всех предыдущих запросов перезапускаются (без учёта удалённого запроса).

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

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

Но если преград человеческой мысли нет, то пределы аппаратных ресурсов видны достаточно отчётливо:

  1. Каждой сборке нужен свой агент в TeamCity.

  2. В нашем случае у сборки мобильного приложения есть порядка 15-100 сборок-зависимостей, каждой из которых нужно выделить по агенту.

  3. Сборки автомерджа мобильных приложений в main составляют лишь малую часть от общего количества сборок в TeamCity.

Взвесив все плюсы и минусы, мы решили пока остановиться на алгоритме SM + GM. При текущей скорости роста очереди тикетов алгоритм показывает хорошие результаты. Если в будущем заметим возможные проблемы с пропускной способностью, то, вероятно, пойдём в сторону Merge Trains и добавим пару параллельных сборок GM:

  1. Вся очередь.

  2. Левая половина очереди.

  3. Левая четверть очереди.

Что в итоге получилось

В результате применения комбинированной стратегии автомерджа нам удалось добиться следующего:

  • уменьшение среднего размера очереди в 2-3 раза;

  • уменьшение среднего времени ожидания в 4-5 раз;

  • мердж порядка 50 веток в день в каждом из упомянутых проектов;

  • увеличение пропускной способности автомерджа при сохранении высокого уровня надёжности, то есть мы практически сняли ограничение на количество тикетов в сутки.

Примеры графиков слияний за несколько дней:

Количество тикетов в очереди до и после внедрения нового алгоритма:

Среднее количество тикетов в очереди (AVG) уменьшилось в 2,5 раза (3,95/1,55).

Время ожидания тикетов в минутах:

Среднее время ожидания (AVG) уменьшилось в 4,4 раза (155,5/35,07).

Подробнее..

Категории

Последние комментарии

© 2006-2021, personeltest.ru