[Пятничное] Сколько стоит держать 100 запросов в секунду в Azure на .NET Core MVC и MSSQL

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

How much does it cost to handle 100RPS with .NET MVC and Azure


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


И вот, спустя пять лет случается флешбэк — выходит статья о нагрузочном тестировании Azure, в которой автор добивается 4 запроса в секунду за 250$ в месяц. Тут уж я просто не мог пройти мимо. Ведь не может такого быть, чтобы второе по величине облако давало так мало за не самые маленькие деньги, правильно? Поэтому я очень быстро набросал простейшее веб приложение на .NET, накатил базу StackOverflow за 2010 год, запустил туда скромную нагрузку в 100 RPS и стал судорожно протирать свои глаза. Даже такую нагрузку мое приложение не держало, причем вообще. 50 RPS тоже оказались слишком высокой планкой, как, впрочем, и 25. И тут я понял, что так дело не пойдет — к вопросу надо подходить системно.


Итак, кому интересно сколько стоит 100 RPS в Azure с .NET Core MVC + .NET 5 + MSSQL на Kestrel — берите кофей и прошу под кат.


Дисклеймер

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


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


Что тестируем


Для тестирования я создал .NET Core 5 MVC приложение с двумя Razor страницами. Первая страница практически пуста, без логики, с минимальной разметкой и CSS который добавляется по умолчанию. С помощью этой страницы я хочу узнать сколько RPS приложение держит в принципе. Вторая страница содержит список из 25 пользователей имена (DisplayName) которых начинаются со случайно выбранных трех букв английского алфавита. Пользователей я буду брать из базы данных StackOverflow за 2010 год, всего в ней насчитывается 299_398 пользователей, что не очень много. Дополнительно я создал индекс на соответствующую колонку.


В коде это выглядит примерно так:


 var size = _symbols.Length; 
 var randomSymbols = new char[] {
                  _symbols[_rnd.Next(size)]
                , _symbols[_rnd.Next(size)]
                , _symbols[_rnd.Next(size)]
                };

var key = new string(randomSymbols);

var users = _ctx.Users
                            .Where(x => x.DisplayName.StartsWith(key))
                            .OrderBy(x => x.DisplayName)
                            .Take(25)
                            .ToArray();

Тест немного искусственный, но, сам сценарий поиска пользователя по имени (а не первичному ключу) вполне реален. Так что как некая попугаемерка тест подойдет.


Как тестируем


Тестировать я буду в два этапа. Сначала я запущу все локально что бы получить ориентировочные значения. После этого я задеплою проект в Azure и начну тестировать там, повышая или понижая мощность конфигурации пока не достигну 100 RPS. Характеристики моего ноутбука не самые топовые (Core i5-9400H @ 2.50GHz, 16GB RAM и SDD) так что будет интересно сравнить


Само тестирование будет идти по примерно следующему алгоритму:


  1. Даем желаемую нагрузку
  2. Если приложение с нагрузкой справляется, фиксируем результат
  3. Если приложение с нагрузкой не справляется, уменьшаем нагрузку в два раза
  4. Если приложение справляется с уменьшенной нагрузкой — увеличиваем ее на ~25% и повторяем цикл до тех пор, пока не зафиксируем значительное проседание RPS, после чего фиксируем предыдущий результат.
  5. Если приложение не справляется с уменьшенной нагрузкой, снова уменьшаем ее в два раза и переходим в п. 4. Если после повторного уменьшения приложение все равно не справляется — фиксируем результат

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


Чем тестируем


Инструментов для тестирования не мало — тут и Gatling и JMeter. Я же давно хотел попробовать NBomber — очень простой фреймворк для нагрузочного тестирования который написан на 100% F#. Как язык, F# мне давно нравится, и идея использовать его хоть как-то показалась мне очень заманчивой (спойлер — кода на F# было написано максимум десять строк, так что поиграться с F# так и не вышло)


Собственно, сам NBomber прост. Сначала мы описываем “шаги” нагрузочного тестирования, в котором указываем ресурсы, которые мы хотим "дернуть", а, затем на основе шагов конфигурируем сценарий указывая количество запросов и длительность тестирования.


Выглядит это примерно так (причем все взято из документации):
 let step = Step.create("index.html", 
                            timeout = seconds 5,
                            clientFactory = HttpClientFactory.create(), 
                            execute = fun context -> 
                            Http.createRequest "GET" url
                            |> Http.withHeader "Accept" "text/html"
                            |> Http.send context)

    let scenario = 
        Scenario.create "simple_http" [step]       
        |> Scenario.withWarmUpDuration(seconds 1)
        |> Scenario.withLoadSimulations [
                   InjectPerSec(rate = rate, during = seconds loadingTimeSeconds)
               ]    

В результате мы получаем пять файлов — логи, результаты в текстовом прдеставлении, те же результаты в маркдаун, в csv и html с красивыми графиками


красивое


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


У меня локально все работает


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


Итак, я подключил ноут к электросети, стартовал приложение и открыл страничку чтобы проверить приложение. Сюрпризов не было, страница с пользователями открывалась за 250-300мс. Убедившись, что все работает я начал давать нагрузку на пустую страницу. И вот первый результат: NBomber работает, мы уверенно держим 1000 RPS:


step ok stats
request count all = 60000, ok = 60000, RPS = 1000
latency min = 0.64, mean = 152.91, max = 857.91, StdDev = 145.81
latency percentile 50% = 120.32, 75% = 174.34, 95% = 527.87, 99% = 788.99

Тут мне стало интересно какой максимум я смогу выжать из своего ноута и это оказалось около 2000 RPS


step ok stats
request count all = 120000, ok = 118669, RPS = 1977.8
latency min = 0.96, mean = 250.03, max = 5486.01, StdDev = 460.46
latency percentile 50% = 176.26, 75% = 259.71, 95% = 454.14, 99% = 3129.34

После этого мне пришлось приостановить тестирование так как кулера уже гудели как шмели и я начал опасаться троттлинга. Дав ноутбуку передохнуть, я начал тестировать страницу с пользователями. Давать 1000 RPS я как-то постеснялся и решил начать с сотни. Результат меня удивил — под нагрузкой в 100 RPS, успешно завершилось всего 10% запросов:


step ok stats
request count all = 5561, ok = 580, RPS = 9.7
latency min = 228.51, mean = 3030.77, max = 4965.96, StdDev = 1223.88
latency percentile 50% = 3094.53, 75% = 3872.77, 95% = 4935.68, 99% = 4968.45

— Сюююююрприз — сказал бы мой ноутбук, если бы мог говорить и добавил бы что 100 RPS держать он не собирается.


Не беда подумал я и снизил нагрузку до 50RPS. Теперь все получилось гораздо лучше. Все запросы уложились в заданный пятисекундный таймаут, причем с большим запасом — самый длительный запрос длился все всего 1751 милисекунд:


step ok stats
request count all = 2997, ok = 2997, RPS = 50
latency min = 140.21, mean = 804.53, max = 1814.76, StdDev = 210.18
latency percentile 50% = 793.6, 75% = 891.39, 95% = 1009.15, 99% = 1751.04

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


Так что можем фиксировать результаты: локально мой ноутбук держит 2K RPS/пустой странице и 50 RPS на странице с выборкой пользователей из базы данных. Время двигаться к облакам, ведь там "все будет хорошо" (С)


Облачно — тучи сгущаются


Теперь, когда есть от чего отталкиваться можно посмотреть, что предлагает Azure. Интересно же сравнить свое родное железо с тем, облачным.


Для начала нужно создать "Web App" — "слот" для размещения веб приложения и выбрать для него тариф. От тарифа зависит вычислительные мощности, которые мы сможем использовать и некоторые дополнительные возможности, например геобалансировку. Я взял самый дешевый тарифный план, который поддерживает выделенные ресурсы — B1 со следующими характеристиками:


  • Вычислительная мощность — 100 ACU
  • Память — 1.75GB
  • Цена — 32$

Что такое ACU — никому не понятно. Рабочая частота, тип процессора, тип памяти, тип диска — все это тщательно скрыто. Единственное что понятно, что ACU это некие попугаи в которых можно сравнивать производительность между разными тарифными планами внутри самой Azure. Естественно, сравнить производительность, например с Amazon не выйдет. Понятно зачем это сделано, но раздражает сильно.


После разворота самого приложения нам нужно еще развернуть SQL сервер и SQL базу данных. Здесь все аналогично предыдущему пункту, только вместо CPU и Memory мы имеем DTU и размер хранилища. Несмотря на наличие бенчмарков от Microsoft, что такое DTU понятно чуть менее чем никак. Т.е. ничего не понятно кроме того, что 1 DTU стоит 1.5 доллара в месяц, а для базы данных которую ожидает серьезная нагрузка на CPU нужно брать уровень S3 или выше. S3 это 100 DTU, 150$ в месяц только за одну базу. Ну что ж, поверим специалистам и возьмем сразу S3, посмотрим, насколько она превзойдет мой ноутбук.


Подняв SQL сервер и восстановив базу из бэкапа (я так же накатил индекс и увеличил максимальное кол-во соединений с БД на всякий случай) я запустил еще чистенькую виртуалку в том же регионе. Для виртуалки я взял B4ms с 4 ядрами и 16GB оперативной памяти с Windows Server 2019 Datacenter gen 1 на борту. По идее это должно дать более-менее честную картинку, и мы не встрянем в проблему исчерпания TCP/IP соединений.


Облачно — начинается ливень


Приложение я поднял, базу данный развернул, страницу открыл — все работает как надо. Страница с пользователями открывается даже быстрее чем на моем ноутбуке — 200-250мс, что внушает надежду. Как вы помните, мой ноут держит 2000 RPS на пустой странице и 50RPS на тестовой странице с пользователями. Предлагаю с этого и начать.


Даем нагрузку в 2000 RPS на пустую страницу и… ничего. Такую нагрузку B1 не держит от слова совсем. Что хотя и обидно, но понятно, B1 все-таки рекомендуется для разработки, а не для нагрузочного тестирования. После нескольких итераций оказалось, что B1 справляется только с 300RPS


step ok stats
request count all = 18000, ok = 18000, RPS = 300
latency min = 3.95, mean = 419.46, max = 2070.85, StdDev = 198.27
latency percentile 50% = 375.81, 75% = 419.58, 95% = 935.94, 99% = 1068.03

Что, ж не будем мучать котов, и возьмем тариф посерьезнее, например P1V2 — 210 ACU, 3.5GB оперативной памяти за 49.57$.


На этом тарифном плане мы держим уже 1000RPS с наихудшим результатом в 1833мс. (а вот 1200 уже не держим)


step ok stats
request count all = 60000, ok = 60000, RPS = 1000
latency min = 3.21, mean = 565.14, max = 2547.46, StdDev = 338.81
latency percentile 50% = 549.89, 75% = 605.18, 95% = 1319.94, 99% = 1833.98

Не так хорошо, как мой ноутбук (надеюсь это прозвучало достаточно пафосно), но для экспериментов хватит. Зато наглядно видно, как смена тарифа влияет на способность держать нагрузку — в данном конкретном случае 20$ увеличили потолок по нагрузке более чем на 200%.


После этого я попробовал нагрузить страницу с пользователями на те же 50 ноутбучных RPS и опять меня ждал сюрприз — несмотря на то что мы взяли рекомендованный (пусть и минимальный) тариф для интенсивных вычислений, количество успешно выполненных запросов составило целых 0 единиц:


step ok stats
request count all = 2750, ok = 0, RPS = 0

Оказывается, что для сетапа за почти 200$ (150$ БД + 49.57$ веб сайт) 50RPS это слишком много. И дело 100% не в самом веб приложении, оно держит и 1000 RPS — проблема явно в той части за 150$ т. е. в базе данных и проверить это не сложно — Azure предоставляет большое количество метрик в том числе и по расходу DTU и это было первое что я пошел проверять:


нужно больше золота


Использование DTU — 100%. Получается, что проблема действительно с тарифом базы данных. Возможно, нужно просто дать еще сантик? К счастью, у нас выделяют бюджет на подобные эксперименты и я могу не опасаться за свой кошелек. Поэтому я просто увеличил тариф в четыре раза до S6 с 400 DTU за каких-то несчастных 600$ долларов в месяц и снова нагрузил его в 50 RPS. Спустя несколько заходов выяснилось, что сетап за 650$ такую нагрузку держит, а иногда вывозит даже целых 60RPS:


step ok stats
request count all = 3597, ok = 3597, RPS = 60
latency min = 641.15, mean = 1184.47, max = 2440.76, StdDev = 453.31
latency percentile 50% = 990.72, 75% = 1434.62, 95% = 2073.6, 99% = 2265.09

Сказать, что я был счастлив означает ничего не сказать. Это же просто "вау" думал я в тот момент. Всего 650 баксов в месяц и это работает почти как мой ноут (правда в случае с Azure не надо обслуживать железо, ОС и возиться с лицензиями что конечно плюс). Но даже с учетом этого радужными эти цифры не были. А ведь наша цель не 50, а 100 RPS.


Поэтому я снова пошел смотреть на метрики. Как выяснилось, в этот раз мы не исчерпали все выделенные DTU — судя по тому же графику, в пике мы использовали только 82% от их максимального количества. Но это наверняка не проблема с самим веб приложением, т. к. как мы уже знаем, что оно держит более 1000 запросов в секунду. Конечно, трансляция LinQ в SQL, десериализация, рендер самой страницы тоже занимает какое-то время, но вот не верю я в то, что проблема на уровне веб приложения. Тем более что проверить это достаточно просто — достаточно бросить еще одну монетку и взять тариф подороже. Если я прав — мы увидим рост производительности. Если нет — все останется, как и было и тогда я попробую взять побольше мощностей уже для веб приложения.


Сказано — сделано, берем тариф еще дороже: S7, 800 DTU, 1200$ в месяц. И нагружаем ее с тех же несчастных 50 RPS. Спустя несколько попыток результат получился следующий:


step ok stats
request count all = 6574, ok = 6574, RPS = 109.6
latency min = 692.16, mean = 968.23, max = 2067.65, StdDev = 288.53
latency percentile 50% = 911.36, 75% = 989.18, 95% = 1766.4, 99% = 1941.5

109RPS, максимальное время ожидания 1941мс, отличные результаты и за всего ничего — 1200$. Причем, если посмотреть графики потребления DTU/CPU/IOPS у нас везде есть запас. Например, DTU мы выбрали всего на 70%.


Почему так? Возможно, я просто выбрал не правильный подход? Ведь Azure предоставляет тарифы, основанные как на DTU так и на виртуальных ядрах. За 1140$ (чуть дешевле) можно взять 10 VCore и это явно стоит попробовать. Поэтому я поменял тип плана с DTU на VCore и снова дал нагрузку. Результат не удивил: меньше платишь — меньше получаешь. Теперь приложение держит только 97.6 RPS да и наихудшее время ожидания выросло до 2326мс:


step ok stats
request count all = 5853, ok = 5853, RPS = 97.6
latency min = 774.8, mean = 1154.58, max = 2362.44, StdDev = 423.61
latency percentile 50% = 983.04, 75% = 1180.67, 95% = 2121.73, 99% = 2326.53

Больше 97 RPS выжать мне не удалось, так что предлагаю зафиксировать следующий результат:


Для того, чтобы держать 100 RPS, используя Azure, ASP .NET Core MVC, .NET Core 5, и MS SQL на запросе вида


var users =  _ctx.Users
                .Where(x => x.DisplayName.StartsWith(key))
                .OrderBy(x => x.DisplayName)
                .Take(25)
                .ToArray();

Нам нужно потратить 49.57$ на хостинг самого веб приложения и 1200$ на БД. Много это или мало пусть каждый решает сам, но, если что — мое мнение обо всем происходящем можно найти в тегах.


Облачно — проглядывает солнце


1250$ за 100RPS звучит откровенно прискорбно. И, если честно, если бы я не видел это все своими глазами, то первым моим предположением было бы что кто-то изрядно накосячил. С другой стороны, быть может, я действительно где-то накосячил? Если вы помните, то проблема на относительно дешевом тарифе S3 была связана с исчерпанием квоты вычислительных ресурсов, что намекает на интенсивные вычисления на уровне БД. Но разве StartsWith такое уж сложное выражение? Давайте посмотрим запрос, в который было транслировано наше LinQ выражение (это можно сделать и в самом Azure или просто вызвав .ToQueryString()):


(@__key_0 nvarchar(4000),@__p_1 int)
SELECT TOP(@__p_1) *
FROM [Users] AS [u]
WHERE (@__key_0 = N'') OR (LEFT([u].[DisplayName], LEN(@__key_0)) = @__key_0)
ORDER BY [u].[DisplayName]

Вам не кажется, что WHERE выглядит немного сложно? Наверняка Entity Framework знает лучше меня (где я, а где зубры из EntityFramework) но что если переписать этот запрос на что-то попроще и выполнять его напрямую вместо трансляции из LinQ выражения, например на такое:


var p1 = new SqlParameter("@DisplayName", $"{key}%");
var query = _ctx.Users.FromSqlRaw(
    $"SELECT TOP 25 * FROM USERS WITH (NOLOCK) WHERE DisplayName LIKE @DisplayName ORDER BY DisplayName", p1
    );

Да, я еще и схитрил, добавив NOLOCK конструкцию, но чего не сделаешь ради красивых цифр. Кстати, вместо того что бы писать SQL напрямую, можно было бы использовать Microsoft.EntityFrameworkCore.EF.Functions.Like для подсказки EF что нужно использовать именно Like конструкцию вместо Left/Len.


Так что я изменил код, передеплоил приложение и снова вернулся к старо доброй S3 базе за 150$. Даю нагрузку в 50RPS и — база держит, и держит весьма неплохо — наихудший результат всего 540мс:


step ok stats
request count all = 3000, ok = 3000, RPS = 50
latency min = 107.72, mean = 228.22, max = 617.06, StdDev = 110.14
latency percentile 50% = 197.89, 75% = 295.17, 95% = 491.01, 99% = 540.67

Использование DTU тоже резко снизилось, на 50RPS мы используем всего 23%.


теперь можно построить зиккурат


Похоже, что проблема действительно была в запросе, но с этим можно будет разобраться попозже. А сейчас я просто добавлю еще нагрузку и посмотрим сколько она сможет выдержать: 75RPS, 100RPS, 150RPS, 300RPS! Нет, до 300 мы все-таки не дотянули, но 283 запроса в секунду вытянуть смогли. И это на тарифе, который до этого не держал даже 50RPS:


step ok stats
request count all = 17018, ok = 17018, RPS = 283.6
latency min = 217.56, mean = 1572.22, max = 4386.47, StdDev = 882.05
latency percentile 50% = 1172.48, 75% = 2332.67, 95% = 3295.23, 99% = 4112.38

Но нам же столько не надо! Оригинальный вопрос звучал сколько нам нужно денег что бы держать всего 100RPS, так что я начал поэтапно уменьшать тарифный план и выяснил, что с новым запросом, для 100RPS достаточно и базы данных уровня S1 — 20 DTU за 30$, причем с не самым плохим временем отклика — 776мс в наихудшем случае.


step ok stats
request count all = 6000, ok = 6000, RPS = 100
latency min = 212.99, mean = 376.42, max = 1219.67, StdDev = 153.35
latency percentile 50% = 320.51, 75% = 445.44, 95% = 736.77, 99% = 776.7

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


Выводы?


С выводами все не однозначно. С одной стороны, мне понадобилась 1250$ для того, чтобы выдержать с виду вполне безобидный запрос на не самой большой нагрузке. С другой стороны, с помощью той же Azure мне удалось сначала решить задачу и получить свои 100 RPS (пусть это и стоило денег), а потом уже найти узкое место и исправить его. С третьей стороны даже полторы тысячи долларов в месяц это не самая большая сумма для предприятия. С четвертой стороны 100 запросов в секунду тоже далеко не хайлоад.


Одно понятно точно — с базой данных и EF нужно быть осторожным, положить свое собственное приложение совсем не сложно.


Спасибо за внимание, надеюсь было интересно. Для желающих повторить мои приключения исходники доступны по ссылке на GitHub.


Посмотреть планы выполнения запросов

Для Entity Framework:


очень интересно


Для прямого SQL:


но ничего не понятно


Drag13

Источник: https://habr.com/ru/post/583084/


Интересные статьи

Интересные статьи

После распада группы «Анонимус» в 2016 году картина угроз стремительно изменялась. Постепенно это движение, делавшее ставку на атаки типа «отказ в обслуживании» (DoS) с использованием простых инструме...
*Gateway — шлюзAzure Active Directory Gateway — это обратный прокси-сервер, который работает с сотнями служб, входящих в Azure Active Directory (Azure AD). Если вы пользо...
Европа стремится сократить выбросы парниковых газов и стать углеродно-нейтральной к 2050 году. Но вопрос о том как лучше достичь этой цели вызывает серьезные споры. В кон...
В преддверии старта курса «Разработчик C#» подготовили перевод интересного материала. Async/Await — Введение Языковая конструкция Async/Await существует со времен C# версии 5.0 (2012) и б...