Анализ на производителността и конфигуриране — мощен инструмент за проверка на съответствието на производителността за клиентите.
Анализът на производителността може да се прилага за идентифициране на тесните места в програмата, използвайки научен подход при проверката на експериментите по конфигуриране. Тази статия определя общия подход към анализа на производителността и конфигурирането, използвайки уеб сървър на Go като пример.
Go тук е особено подходящ, тъй като предлага инструменти за профилиране. pprof в стандартната библиотека.

Стратегия
Нека съставим обобщен списък за нашия структурен анализ. Ще се опитаме да използваме някои данни за вземане на решения, вместо да правим промени на базата на интуиция или предположения. За целта ще направим следното:
- Определяме границите на оптимизация (изисквания);
- Изчисляваме транзакционното натоварване за системата;
- Извършваме тест (създаваме данни);
- Наблюдаваме;
- Анализираме — всички ли изисквания са спазени?
- Настройваме научно, формулираме хипотеза;
- Извършваме експеримент за проверка на тази хипотеза.

Архитектура на прост HTTP сървър
За тази статия ще използваме малък HTTP сървър на Golang. Целият код от статията може да бъде намерен .
Анализираното приложение е HTTP сървър, който запитва Postgresql при всяко заявка. Допълнително, използваме Prometheus, node_exporter и Grafana за събиране и визуализиране на метрики от приложението и системата.

За опростяване, приемаме, че за хоризонтално мащабиране (и опростяване на изчисленията) всеки сервис и база данни се разгръщат заедно:

Определяме цели
На тази стъпка определяме целта. Какво се опитваме да анализираме? Как ще разберем, че е време да приключим? В тази статия ще предположим, че имаме клиенти и че нашият сервис ще обработва 10 000 заявки в секунда.
В подробно разгледани начина за избор и моделиране. Ще подходим по същия начин, ще изградим модели:
- Закъснение: 99% от заявките трябва да се изпълняват за по-малко от 60мс;
- Разходи: сервизът трябва да консумира минимална сума пари, която ще ни се стори разумно възможна. За целта максимализираме пропускателната способност;
- Планиране на капацитета: необходимо разбиране и документиране на бройката на инстанциите на приложението, които предстои да бъдат стартирани, включително общата функция за мащабиране, както и колко инстанции са необходими за удовлетворяване на началните изисквания за натоварване и осигуряване .
Закъснението може да изисква оптимизация в допълнение на анализа, но пропускателната способност очевидно трябва да бъде анализирана. При използване на процеса SRE SLO, изискването за забавяне произтича от клиента и или бизнеса, представени от собственика на продукта. И нашият сервис ще изпълни това задължение от самото начало без никакви настройки!
Настройка на тестова среда
С помощта на тестовата среда ще можем да предоставим дозирано натоварване на нашата система. За анализа ще бъде създадена информация за производителността на уеб услугата.
Транзакционно натоварване
Тази среда използва за създаване на настраиваема честота на HTTP исканията, докато не бъде спряно:
$ make load-test LOAD_TEST_RATE=50
echo "POST http://localhost:8080" | vegeta attack -body tests/fixtures/age_no_match.json -rate=50 -duration=0 | tee results.bin | vegeta reportНаблюдение
По време на изпълнението ще бъде приложено транзакционно натоварване. В допълнение към метриките на приложението (брой искания, забавяне при отговор) и операционната система (памет, CPU, IOPS) ще бъде стартирано профилиране на приложението, за да се установи къде в него има проблеми, а също и как се изразходва процесорното време.
Профилиране
Профилирането е тип измерване, който позволява да се види къде отива процесорното време при работа на приложението. То позволява да се определи къде точно и колко отива процесорното време:

Тези данни могат да се използват по време на анализа, за да се получи представа за безполезно изразходваното процесорно време и извършваната ненужна работа. Go (pprof) може да генерира профили и да ги визуализира под формата на flame graph, използвайки стандартния набор от инструменти. Ще обясня тяхното използване и ръководство за настройка малко по-долу в статията.
Изпълнение, наблюдение, анализ.
Ще проведем експеримент. Ще изпълняваме, наблюдаваме и анализираме, докато производителността ни устрои. Ще изберем произволно ниска стойност на натоварването, за да я приложим за получаване на резултатите от първоначалните наблюдения. На всяка следваща стъпка ще увеличаваме натоварването с определен коефициент на мащабиране, избран с малки разлики. Всеки пуск на натоварващо тестване се извършва с регулиране на броя на заявките: make load-test LOAD_TEST_RATE=X.
50 заявки в секунда

Обърнете внимание на двата горни графика. Горният ляв показва, че нашето приложение обработва 50 заявки в секунда (според него), а горният десен — продължителността на всяка заявка. И двата параметъра ни помагат да наблюдаваме и анализираме: вписваме ли се в нашите граници на производителност или не. Червената линия на графика HTTP Request Latency показва SLO от 60мс. По линията е очевидно, че сме много под максималното време за отговор.
Нека погледнем от страна на разходите:
10000 заявки в секунда / на 50 заявки на сървър = 200 сървъра + 1
Все още можем да подобрим този показател.
500 заявки в секунда
По-интересни неща започват да се случват, когато натоварването нарасне до 500 заявки в секунда:

Отново на левия горен график се вижда, че приложението фиксира обичайната натовареност. Ако не е така — има проблем със сървъра, на който е стартирано приложението. Графикът със забавянето на отговора, разположен горе вдясно, показва, че 500 заявки в секунда водят до забавяне на отговора от 25-40мс. 99-ти процентил все още шикозно вписва в SLO от 60мс, избрано по-горе.
От гледна точка на разходите:
10000 заявки в секунда / на 500 заявки на сървър = 20 сървъра + 1
Все още може да се подобри.
1000 заявки в секунда

Отличен старт! Приложението показва, че е обработило 1000 заявки в секунда, обаче границата по забавянето е нарушена от гледна точка на SLO. Това е видно от линията p99 на горния десен график. Въпреки че линията p100 е много по-висока, реалните забавяния надхвърлят максимума от 60мс. Нека се потопим в профилирането, за да разберем какво всъщност прави приложението.
Профилиране
За профилирането поставяме натоварването на 1000 заявки в секунда, след това използваме pprof за извличане на данни, за да разберем къде приложението убива процесорното време. Това може да се направи, активирайки HTTP endpoint pprof, след което запазете резултатите под товар с помощта на curl:
$ curl http://localhost:8080/debug/pprof/profile?seconds=29 > cpu.1000_reqs_sec_no_optimizations.profРезултатите могат да бъдат показани така:
$ go tool pprof -http=:12345 cpu.1000_reqs_sec_no_optimizations.prof
Графикът показва къде и колко приложенията изразходват процесорно време. От описанието на :
По оста X — попълване на стека, сортирано по азбучен ред (това не е време), оста Y показва дълбочината на стека, считано от нула в [top]. Всеки правоъгълник е кадър на стека. Колкото по-широк е кадърът, толкова по-често той присъства в стековете. Това, което е отгоре, работи на CPU, а по-долу са дъщерните елементи. Цветовете, като правило, не означават нищо и просто се избират на случаен принцип за различаване на кадрите.
Анализ — хипотеза
За настройка ще се фокусираме върху опитите да намерим безполезни разходи на процесорно време. Ще търсим най-големите източници на безполезни разходи и ще ги премахнем. А като се има предвид, че профилирането много точно разкрива къде точно приложението изразходва своето процесорно време, може да се наложи да направим това няколко пъти, а също така ще е необходимо да променим изходния код на приложението, да рестартираме тестовете и да наблюдаваме как производителността приближава зададеното.
Следвайки препоръките на Brendan Gregg, ще четем графика отгоре надолу. Всяка редица показва кадър на стека (вик на функция). Първата редица е входната точка на програмата, родител на всички останали повиквания (с други думи, всичките останали повиквания ще имат нея в своя стек). Следващата редица вече е различна:
![]()
Ако поставите курсора над името на функцията в графиката — ще се покаже общото време, през което е била в стека по време на отстраняване на грешки. Функцията HTTPServe е била там 65% от времето, другите функции runtime, runtime.mcall, mstart и gc, заемат останалото време. Интересен факт: 5% от общото време е изразходвано за DNS заявки:

Адресите, които програмата търси, принадлежат на Postgresql. Щракваме върху FindByAge:

Интересно е, че програмата показва, че всъщност има три основни източника, които добавят закъснения: отваряне и затваряне на връзки, заявки за данни и свързване с базата. На графиката е видно, че DNS заявките, отварянето и затварянето на връзки отнемат около 13% от общото време на изпълнение.
Гипотеза: Преработката на връзките с помощта на пул трябва да намали времето на един HTTP запрос, позволявайки по-висока пропускна способност и по-ниски забавяния..
Настройване на приложението — експеримент.
Обновяваме изходния код, опитваме се да премахнем връзката с Postgresql за всеки запрос. Първи вариант — използване на на ниво приложение. В този експеримент ние пул от връзки с помощта на sql драйвера за Go:
db, err := sql.Open("postgres", dbConnectionString)
db.SetMaxOpenConns(8)
if err != nil {
return nil, err
}Изпълнение, наблюдение, анализ.
След повторно стартиране на теста с 1000 запитвания в секунда е видно, че p99 по забавянията е в нормата със SLO от 60мс!
Каква е цената?
10000 запитвания в секунда / на 1000 запитвания на сървър = 10 сървъра + 1
Нека направим още по-добре!
2000 запитвания в секунда.

Удвояването на натоварването показва същото, горният ляв график показва, че приложението успява да обработи 2000 запитвания за секунда, p100 под 60мс, p99 отговаря на SLO.
От гледна точка на разходите:
10000 запитвания в секунда / на 2000 запитвания на сървър = 5 сървъра + 1
3000 запитвания в секунда.

Тук приложението може да обработи 3000 запитвания с p99 забавяне под 60мс. SLO не е нарушено, а цената е следната:
10000 запитвания в секунда / на 3000 запитвания на сървър = 4 сървъра + 1 (авторът е закръглил нагоре, прим. преводача)
Нека опитаме още един кръг анализ.
Анализ — хипотеза
Събираме и показваме резултатите от отстраняването на грешки на приложението при 3000 запитвания в секунда:

Все още 6% от времето се изразходва за установяване на връзки. Настройването на пула подобри производителността, но все още е видно, че приложението продължава да създава нови връзки с базата.
Гипотеза: Връзките, въпреки наличието на пул, все още се отхвърлят и почистват, така че приложението трябва да ги преустанови. Настройването на броя на изчакващите връзки на размера на пула трябва да помогне с забавянето, минимизирайки времето, което приложението прекарва за създаване на връзка..
Настройване на приложението — експеримент.
Опитваме да настроим равно на размера на пула (също описано ):
db, err := sql.Open("postgres", dbConnectionString)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)
if err != nil {
return nil, err
}Изпълнение, наблюдение, анализ.
3000 запитвания в секунда.

p99 под 60мс с значително по-нисък p100!

Проверка на flame graph показва, че установяването на връзка вече не е видно! Проверяваме по-подробно. pg(*conn).query — също не забелязваме установяване на връзка тук.

Заключение
Анализът на производителността е от съществено значение за разбирането на клиентските очаквания и нефункционалните изисквания. Чрез сравняване на наблюденията с клиентските очаквания, анализът може да помогне да се определи какво е приемливо и какво не е. Go предлага ефективни инструменти, вградени в стандартната библиотека, които правят анализа лесен и достъпен.
Източник: habr.com
