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 держать он не собирается.


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


Выбрав правильную БД, и запустив приложение в режиме релиза я смог добиться почти 150RPS:


step ok stats
request count all = 8976, ok = 8976, RPS = 149.6
latency min = 24.44, mean = 1080.12, max = 2173.79, StdDev = 399.85
latency percentile 50% = 1029.63, 75% = 1117.18, 95% = 2068.48, 99% = 2146.3

На пустой странице, максимальное количество запросов в секунду которых удалось добиться, составило 2784 (релиз нам принес почти +40%, разница очевидна):


step ok stats
request count all = 180000, ok = 167058, RPS = 2784.3
latency min = 0.39, mean = 339.22, max = 7625.08, StdDev = 1242.98
latency percentile 50% = 12.16, 75% = 31.76, 95% = 3411.97, 99% = 6942.72

Так что можем фиксировать результаты: локально мой ноутбук держит 2.7K RPS на пустой странице и 150 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мс, что внушает надежду. Напомню, что мой ноут держит 2700 RPS на пустой странице и 150RPS на тестовой странице с пользователями. Предлагаю с чего-то подобного и начать:


Даем нагрузку в 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:


Index scan


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


Еще статистика по локальному тестированию

EF ASYNC


step ok stats
request count all = 9523, ok = 9523, RPS = 158.7
latency min = 31.1, mean = 1018.09, max = 3319.5, StdDev = 461.32
latency percentile 50% = 978.94, 75% = 1325.06, 95% = 1795.07, 99% = 2140.16

SQL Sync


Rate: 2300, during: 00:01:00


step ok stats
request count all = 138000, ok = 125883, RPS = 2098
latency min = 3.1, mean = 698.66, max = 8232.34, StdDev = 1336.41
latency percentile 50% = 125.38, 75% = 479.49, 95% = 3049.47, 99% = 7213.06

step fail stats
request count all = 138000, fail = 12117, RPS = 202
latency min = 378.79, mean = 5606.03, max = 8208.32, StdDev = 1144.1
latency percentile 50% = 5529.6, 75% = 6074.37, 95% = 7249.92, 99% = 7536.64

SQL Async


Rate: 2400, during: 00:01:00


step ok stats
request count all = 144000, ok = 136391, RPS = 2273.2
latency min = 1.91, mean = 1308.2, max = 7596.02, StdDev = 1579.3
latency percentile 50% = 492.03, 75% = 1988.61, 95% = 4415.49, 99% = 6520.83

step fail stats
name index.html
request count all = 144000, fail = 7609, RPS = 126.8
latency min = 181.63, mean = 6122.8, max = 7573.97, StdDev = 762.05
latency percentile 50% = 6316.03, 75% = 6590.46, 95% = 7204.86, 99% = 7340.03

status codes for scenario: simple_http

Апдейт


Cтатья обновлена в части локального тестирования из-за допущеных ошибок. Часть, посвященная облачному тестированию, перепроверена и осталась без изменений. Я так же благодарю mvv-rus, nobody8, @Fullborg и других хабровчан, которые своими детальными комментариями подтолкнули меня протестировать все еще раз и найти ошибку.


Спасибо вам большое!


Drag13

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


  1. dimuska139
    15.10.2021 10:48
    +7

    Чего-то странно вообще. У меня сейчас VPS за 519 рублей в месяц. Там болтается пет-проект с PostgreSQL и API (NestJS) - прямо рядом. Сейчас через ab дёрнул один из API-эндпоинтов - 215.23 rps. Запрос в БД тоже есть, и я там даже с оптимизацией не заморачивался вообще никак. У меня там правда не like, а просто выборка по индексу. Цена вопроса - 519 рублей в месяц. У вас как-то всё слишком дорого вышло. Либо я что-то не так понял.

    Server Software:        nginx/1.18.0
    Server Hostname:        <мой сайт>
    Server Port:            443
    SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-CHACHA20-POLY1305,2048,256
    Server Temp Key:        X25519 253 bits
    TLS Server Name:        <мой сайт>
    
    Document Path:          /api/v2/posts/561-kak-pravilno-chistit-kletku-khomiaka
    Document Length:        7247 bytes
    
    Concurrency Level:      100
    Time taken for tests:   46.463 seconds
    Complete requests:      10000
    Failed requests:        0
    Total transferred:      76000000 bytes
    HTML transferred:       72470000 bytes
    Requests per second:    215.23 [#/sec] (mean)
    Time per request:       464.628 [ms] (mean)
    Time per request:       4.646 [ms] (mean, across all concurrent requests)
    Transfer rate:          1597.38 [Kbytes/sec] received
    
    Connection Times (ms)
                  min  mean[+/-sd] median   max
    Connect:      223  259  51.1    252    1454
    Processing:    81  198  95.1    160     810
    Waiting:       81  198  95.0    160     810
    Total:        313  458 106.0    419    1697
    
    Percentage of the requests served within a certain time (ms)
      50%    419
      66%    461
      75%    511
      80%    542
      90%    596
      95%    642
      98%    707
      99%    755
     100%   1697 (longest request)


    1. Drag13 Автор
      15.10.2021 11:04
      +2

      Чего-то странно вообще

      Да, есть такое. Я мог бы даже статью переназвать так :)


      Но, думаю тут проблема в самом запросе от EF. Как видите, даже с LIKE мы уже вывозим сотню за 30 долларов. А на индексах, когда мы проверяем полное вхождение, будет еще быстрее.


      1. dimuska139
        15.10.2021 11:12
        +2

        Если вы говорите об индексах и LIKE, то при чём тут EF? EF же только генерирует запрос и маппит его результаты на объекты, а сам запрос выполняет БД, и индексы участвуют именно в момент выполнения запроса в БД (на её стороне). Тем более, если у вас LIKE по началу значения поля DisplayName, то индекс и так может использоваться, если у вас он есть, и выполняются условия для того, чтобы БД решила его использовать.

        даже с LIKE мы уже вывозим сотню за 30 долларов

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


        1. Drag13 Автор
          15.10.2021 11:32

          Если вы говорите об индексах и LIKE, то при чём тут EF?

          Потому что EF не генерит LIKE код для того c# с которого я начинал.


          И опять-таки даже это крайне дорого для такой производительности

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


        1. Drag13 Автор
          15.10.2021 13:27
          +1

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


          1. dimuska139
            15.10.2021 14:02

            А вот этого не знал, кстати


      1. dimuska139
        15.10.2021 11:18

        Может быть, у вас пул соединений для БД не используется?


        1. Drag13 Автор
          15.10.2021 11:33

          Тогда мы бы не держали 300 RPS на s1. Я думаю, что дело все таки в выделеных мощностях.


        1. Szer
          15.10.2021 13:15

          ADO.net пулит коннекшны сам


    1. andreyverbin
      15.10.2021 11:56
      +3

      Ничего странного, запрос автора делает full table scan, так как он просит StartsWith(x). Потому БД и стала узким горлышком. 10 vCore не помогли потому, что БД не умеет выполнять запрос на нескольких ядер. Судя по данным, запрос автора все читал с диска. Ваш запрос работает с индексом, который, скорее всего, полностью помещается в памяти.


      1. Drag13 Автор
        15.10.2021 12:00
        +1

        Наверное я что-то не понимаю, но в плане выполнения указан Clustered Index Scan. Разве это не означает был использован индекс?


        1. sergix
          15.10.2021 12:57
          +2

          Scan -прохождение по липесточкам осенью чтобы найти желтый красивый среди дерева, seek поиск в желтых липесточках красивых.
          Сделайте вычисляемую колонку с хранением(PERSISTED) и сделайте по ней индекс назовите StartWith и сравните с этим полем Users.Where(x=>x.StartWithFromBase==key). Ну или прям на вводе данных выделите эту логику.
          Раз у вас така операция произошла то почему бы не разделить эти ключи в отдельное поле? Как так получилось что в реляционном виде вам нужна операция над строками? В общем похоже на ошибку БЛ.

          p.s. ещё есть жаркие споры по поводу возможности использовать full text index для такого дела, но я не уверен точно на счет результатов для лайка. Слышал разное мнение.


          1. Drag13 Автор
            15.10.2021 13:06
            +1

            Нет, ошибки тут нет. Приложение тестовое, и я специально не использовал PK, что бы сильнее нагрузить базу.


        1. Fulborg
          15.10.2021 13:03
          +3

          Clustered Index — по сути определяет то как данные хранятся на диске, и содержит в себе все данные таблицы. С точки зрения выполнения Scan по нему — это почти аналогично Table Scan'у (который на MSSQL будет только в случае таблицы без кластерного индекса).
          Странно что SQL не пытается использовать созданный индекс по DisplayName. Зависит от таблицы, объема столбцов и селективности выборки конечно, но как вариант, для полноты эксперимента — можно попробовать указать его в запросе хинтом принудительно и посмотреть план исполнения. Возможно что-то не так с индексом, и он не подходит для этого запроса.


          1. Drag13 Автор
            15.10.2021 13:12

            О как, не знал что скан по кластерному индексу так плох. Спасибо за наводку.


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


            1. Fulborg
              15.10.2021 13:42
              +2

              В случае Like — индекс не выйдет использовать, если like написан как «like '%a%'». В случае «like 'a%'» — он может быть использован. Это ограничение исходит из того, что индекс — по сути справочник со ссылками на «части» данных. Для строк — он строится начиная с начала строки, грубо говоря:
              — index root.
              — s[0]<«n» — go to index_1
              — s[0] == «n» — go to index_2
              — s[0] > «n» — go to index_3
              В случае like %a% — неизвестно с чего начинается строка и SQL не может пойти в нужное поддерево индекса, надо обойти все записи в таблице.
              В случае like a% — начало известно, по нему можно отсечь те ветки индекса которые точно не подходят, и проверить только оставшиеся.



        1. mvv-rus
          15.10.2021 19:59

          Сложно сказть точно, не имея перед глазами структуры таблицы, но, судя по картинкам, у вас первичный ключ таблицы не DisplayName, по началу (первым 3 символам) которого вы отбираете записи, а некий Users_ID. Если так, то индексно-последовательная структура хранения таблицы (в порядке первичного ключа) ничем вам в отборе нужных записей помочь не может, и Clustered Index Scan — он только называется так красиво, а по сути в данном случае он полностью аналогичен Full Table Scan.
          Для оптимизации ваших запросов (на выборку) можно попробовать добавить индекс по DisplayName — благо обновлений этой таблицы у вас, как я понял, нет, а потому и нет накладных расходов на ведение этого индекса (но в реальной жизни они иногда существенны).
          При создании БД можно было бы указать EF на необходимость создания этого индекса, добавив атрибут [Index] к свойству DisplayName вашего класса данных (он называется Users, если таблица в БД создавалась с имене по умолчанию).
          Поможет ли это добавление при уже созданной базе без выполнения миграции — это я сказать не возьмусь.
          PS Посмотреть в плане запроса, используется ли кластерный индекс именно как индекс, можно по свойству Ordered его элемента в плане (в статье по ссылке в комментарии от andreyverbin выше про это хорошо показано)
          .


          1. Drag13 Автор
            15.10.2021 20:45

            первичный ключ таблицы не DisplayName, по началу (первым 3 символам) которого вы отбираете записи, а некий Users_ID

            Первичный ключ в таблице просто Id, на DisplayName создан индекс,

            Это подтверждается с помощью:

            SELECT * 
            FROM sys.indexes 
            WHERE object_id = OBJECT_ID('DBO.Users')

            Который показывает наличие двух индексов.

            Что касается создания БД - она создавалась из бекапа и только потом создавался контекст.


            1. mvv-rus
              15.10.2021 22:04

              Ну да, не видя струтуры базы, ошибиться в предположениях мне было несложно. Тем более, что вы EF используете как database-first, где ytdtljvfz vyt структура БД определяет все.
              А почему этот имеющийся индекс по DisplayName не используется в запросах (по плану выполнения это видно) — это я действительно не понимаю.
              Впрочем, у планировщика запросов СУБД всегда могут быть свои резоны. Например (но ваш ли это случай — я это утверждать не готов), он не может оценить селективность индекса по начальной подстроке, по которой идет выборка, и действует из наихудшего предположения, что она крайне низкая — а потому последовательный просмотр таблицы лучше и планировщик выбирает его. А не будучи DBA, я даже не знаю, как с этим бороться (и можно ли вобще) в вашей конкретной СУБД.


              1. Drag13 Автор
                15.10.2021 22:11

                Я разобрался с планами благодаря вашей подсказке. У меня локально поднято несколько БД, одна из них без индекса, как вы и предполагали. Скриношты планов были сделаны именно нее. А наличие индекса я проверял на облачной БД, там он действительно есть. Сейчас я сделал скриншоты планов из БД в Azure, там совсем другая картина.


                Приношу свои извинения за путаницу.


      1. dimuska139
        15.10.2021 12:15
        +1

        запрос автора делает full table scan, так как он просит StartsWith(x)

        А где тут связь? При использовании StartsWith разве не может быть задействован индекс? Это ж по сути LIKE 'blabla%'. Там ещё вопрос правда в количестве записей. Если их мало, БД может и не использовать индекс, посчитав, что фулскан таблицы будет оптимальнее. По крайней мере, в PostgreSQL так, насчёт MSSQL не знаю.


        1. andreyverbin
          15.10.2021 17:08
          +1

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


          1. granit1986
            15.10.2021 20:25
            +1

            если сделать Like 'blabla%' то поможет


            1. Drag13 Автор
              15.10.2021 16:54

              11000 queries/s с 4% CPU utilization — это про один сервер который как раз StackOverflow обслуживает.

              11k это пик, так-то ~6.1k


              Кэш не для для SQL
              А зачем им кеш? Мне правда интересно.

              async нужен в основном чтобы ваш тестовый скрипт не стал бутылочным горлышком

              Возможно это причина почему я не смог загрузить на 100% дорогую базу. В случае когда мы видим полное исчерпание DTU — значит увеличение кол-ва запросов к ней ничего не даст и async нам тут бонусов не даст.


        1. mvv-rus
          15.10.2021 20:08

          У автора, судя по всему, кластерный индекс — не по тому полю, по которому происходит выборка: кластерный индекс всегда хранит данные, упорядоченные по первичному ключу (это еще называется индексно-последовательная схема хранения)


        1. Drag13 Автор
          15.10.2021 16:32

          До AWS в ближайшее время руки точно не дотянуться. А вот async-и я тестировал на варианте с EF:


           private Task<User[]> GetUsersEFAsync(string key)
                  {
                      return _ctx.Users
                          .Where(x => x.DisplayName.Contains(key))
                          .OrderBy(x => x.DisplayName)
                          .Take(25)
                          .ToArrayAsync();
                  }

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


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


          А по StackOverflow — у них и кеш есть, и 4 SQL сервера. Я на их фоне как бедный родственник :)


  1. anonymous
    00.00.0000 00:00


  1. Dotarev
    15.10.2021 11:47
    +2

    но чего не сделаешь ради красивых цифр

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


    1. Drag13 Автор
      15.10.2021 12:00
      +1

      Хороший вопрос — но нет, не тестировал. Если будет время вечером запущу тесты и добавлю в статью.


  1. anonymous
    00.00.0000 00:00


  1. anonymous
    00.00.0000 00:00


  1. anonymous
    00.00.0000 00:00


  1. anonymous
    00.00.0000 00:00


  1. Drag13 Автор
    15.10.2021 19:24

    Спасибо


  1. devlev
    15.10.2021 14:20
    +3

    MSSQL и EF это отдельный, уникальный и дивный мир! Я в вебе с тех самых времен, когда верстали еще под IE6, и навыки верстака оценивались знаниями как выравнить блочный элемент по центру, как сделать прозрачность или как не словить баг с margin.

    Прошло 15 лет и ничего не поменялось. Я по прежнему занимаюсь шаманством. Переписываю запрос LINQ c `.where(n => n.PropABool && !n.PropBBool)` на `.where(n => n.PropABool == true && n.PropBBool == false)`

    Запрос вроде вашего `.Where(x => x.DisplayName.StartsWith(key))` это вообще не позволительная роскош! Нет, тут только полнотекстовый поиск, там все норм со скоростью работы, а обычный like использовать на больших полях опасно!

    Вот еще такой пример: `.Where(n => !n.Items.Any())` или `.Where(n => n.Items.Count() == 0)`. Вопрос к знатокам, какой будет работать шустрее?


    1. read_maniak
      15.10.2021 15:40

      Я бы предположил, что n.Items.Any() будет быстрее. По логике там не требуется весь "список" просчитывать, достаточно наличия первого совпадения. Так ведь?


      1. dimuska139
        15.10.2021 16:26

        Надо смотреть, что там за запрос генерируется в обоих случаях, иначе это гадание на кофейной гуще. Если !n.Items.Any() превращается в NOT EXISTS, то это будет самый оптимальный вариант.


      1. devlev
        15.10.2021 16:53
        +1

        В моем случае .Where(n => n.Items.Count() == 0)работало шустрее в разы. Да и в дальнейшем я понял что лучше в LINQ Any вообще не использовать и делать все на Count. К сожалению пока нет возможности сделать ToString обоих запросов.


        1. force
          15.10.2021 19:13
          +1

          Отрицание с Any - это мина замедленного действия. На подобном запросе разваливался MySQL, Postgre в зависимости от фазы луны, оптимизатор MSSQL обычно догадывается до LEFT JOIN, но видно не всегда.

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

          Ну и иногда стоит играться с First/Single - в зависимости от ORM и базы данных могут весьма разные результаты получаться.


  1. navferty
    15.10.2021 15:05
    +2

    Я бы еще предложил проверить, как повлияет на производительность изменение выборки на асинхронную: await <...>.ToArrayAsync() вместо блокирующего .ToArray().

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


    1. Drag13 Автор
      15.10.2021 16:56

      Локально на EF ничего не дало. Впрочем вечером сделаю еще два теста локально, посмотрим.


    1. Drag13 Автор
      15.10.2021 19:07

      В конце статьи под спойлером еще статистика.

      Если кратко то:

      • На SQL разогнались почти до 75 RPS

      • Асинк на РПС не влияет. Видимо все таки упираемся в базу, а не количество запросов.


    1. Drag13 Автор
      16.10.2021 11:04
      +1

      Часть про локальное тестирование была отредактирована из-за допущеной во время локальных тестов ошибки.

      Свежие тесты показали что локально:

      • На EF мы держим 150RPS

      • EF Async - 160 RPS

      • На SQL Sync - 2098 при нагрузке в 2300

      • На SQL Async - 2273.2 при нагрузке в 2400

      На планах исполнения теперь отчетливо видно использования Index scan для EF и Index seek для SQL что и объясняет разницу.

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


  1. MDiMaI666
    15.10.2021 16:25

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


    1. Drag13 Автор
      15.10.2021 17:07

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

      • Что такое "популярные" сценарии не сильно понятно

      • Потом они обновят железо и/или конфигурации и все, тесты будут только обманывать

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


  1. granit1986
    15.10.2021 19:52

    Попробуйте сделать ApiController с асинхронным методом и вызывать ArrayToAsync + AsNoTracking.

    Кстати, для Like есть EF.Functions.Like()


    1. Drag13 Автор
      15.10.2021 20:47

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

      А что касается использования подсказки так я же об этом прямо в статье писал)


      1. vagon333
        16.10.2021 00:27
        +1

        Обычные домашние проекты, которые неспеша выходят на коммерческие.

        https://www.ebay.com/itm/353200398868 x2 и старые SAS SSD с большим остаточным ресурсом.


  1. jakobz
    15.10.2021 20:30
    +2

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


  1. vagon333
    15.10.2021 22:06

    Намучались с переносом .Net приложения с ORM NHibernate c дата центра в облако - ORM генерит массу запросов и если в облаке веб сервер и бд на разных машинах (а всегда на разных) то latency предсказать невозможно.

    Облака замечательны, но для своих пет-проектов за $120/мес разместил в датацентре 2 стареньких сервера на 40 ядер, 1.5тб памяти и 10тб SSD диска, 15 статических IP и давно отбил начальное вложение в б/у серверы. Все железо под моим контролем и производительность адекватна нагрузкам.


  1. anonymous
    00.00.0000 00:00


  1. tmin10
    15.10.2021 23:58

    Как-то хостил бота для скайпа в azure с использованием botframework. На пробном аккаунте ничего не делающий бот съел 7 тысяч рублей за месяц. С тех пор очень осторожно отношусь к облакам для личных проектов, vps выглядит куда как экономичнее (но больше настроек руками, конечно)


    1. MDiMaI666
      16.10.2021 04:20

      У меня была аналогичная ситуация. С тех пор использую только VPS с фиксированной ценой.


    1. Netforspeed
      20.10.2021 06:53

      А свою железку, в качестве сервера, так ни не попробовали? и зря. за 7 тысяч рублей можно 8 ядерный ноут купить с рук, SSD прикупить к нему, и радоваться.


      1. dimuska139
        20.10.2021 10:15

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


        1. tmin10
          20.10.2021 10:43

          Ботам может быть не нужен белый IP, если они работают через лонг пулинг. И хостить что-то на своём NAS вполне себе идея, если не нужен 99.99999% аптайм, конечно (или интернет упадёт или электричества не станет).


        1. Netforspeed
          20.10.2021 20:35

          Бэкап только на другой комп? А что с облаками? А что со вторым диском? Или внешним диском? Бесплатный скрипт и все автоматически. Так вот по моему опыту ноут без электричества не оставался на долго за пару-тройку лет. В нашем плотненько населенном микрорайоне это исключено, а вот vps арендованный, был недоступен двое суток, что и привело меня к закрытию вопроса аренды.

          Ip стоит 80 рублей в месяц, ну что об этом говорить...

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


      1. tmin10
        20.10.2021 10:41

        К счастью это были не реальные деньги, а пробный месячный кредит, да и бот мне особо был не нужен, просто пробовал технологию. А так конечно, для реального бота just 4 fun разумнее завести VPS за 100-200р в месяц.


  1. AlanDenton
    18.10.2021 16:00

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

    Краткий пример о чем речь:

    USE AdventureWorks2014
    GO
    
    SELECT AddressLine1
    FROM Person.[Address]
    WHERE AddressLine1 LIKE '%100%'
    
    ------------------------------------------------------------------
    
    USE [master]
    GO
    
    IF DB_ID('test') IS NOT NULL BEGIN
        ALTER DATABASE test SET SINGLE_USER WITH ROLLBACK IMMEDIATE
        DROP DATABASE test
    END
    GO
    
    CREATE DATABASE test COLLATE Latin1_General_100_CS_AS
    GO
    ALTER DATABASE test MODIFY FILE (NAME = N'test', SIZE = 64MB)
    GO
    ALTER DATABASE test MODIFY FILE (NAME = N'test_log', SIZE = 64MB)
    GO
    
    USE test
    GO
    
    CREATE TABLE t (
         ansi VARCHAR(100) NOT NULL
       , unicod NVARCHAR(100) NOT NULL
    )
    GO
    
    ;WITH
        E1(N) AS (
            SELECT * FROM (
                VALUES
                    (1),(1),(1),(1),(1),
                    (1),(1),(1),(1),(1)
            ) t(N)
        ),
        E2(N) AS (SELECT 1 FROM E1 a, E1 b),
        E4(N) AS (SELECT 1 FROM E2 a, E2 b),
        E8(N) AS (SELECT 1 FROM E4 a, E4 b)
    INSERT INTO t
    SELECT v, v
    FROM (
        SELECT TOP(50000) v = REPLACE(CAST(NEWID() AS VARCHAR(36)) + CAST(NEWID() AS VARCHAR(36)), '-', '')
        FROM E8
    ) t
    GO
    
    ------------------------------------------------------------------
    
    ALTER TABLE t
        ADD ansi_bin AS UPPER(ansi) COLLATE Latin1_General_100_BIN2
    
    ALTER TABLE t
        ADD unicod_bin AS UPPER(unicod) COLLATE Latin1_General_100_BIN2
    
    CREATE NONCLUSTERED INDEX ansi ON t (ansi)
    CREATE NONCLUSTERED INDEX unicod ON t (unicod)
    
    CREATE NONCLUSTERED INDEX ansi_bin ON t (ansi_bin)
    CREATE NONCLUSTERED INDEX unicod_bin ON t (unicod_bin)
    GO
    
    ------------------------------------------------------------------
    
    SET STATISTICS TIME, IO ON
    
    SELECT COUNT_BIG(*)
    FROM t
    WHERE ansi LIKE '%AB%'
    
    SELECT COUNT_BIG(*)
    FROM t
    WHERE unicod LIKE '%AB%'
    
    SELECT COUNT_BIG(*)
    FROM t
    WHERE ansi_bin LIKE '%AB%' --COLLATE Latin1_General_100_BIN2
    
    SELECT COUNT_BIG(*)
    FROM t
    WHERE unicod_bin LIKE '%AB%' --COLLATE Latin1_General_100_BIN2
    
    SET STATISTICS TIME, IO OFF


    1. Drag13 Автор
      18.10.2021 17:17
      +2

      Наверное я не очень внятно объяснил цель статьи. Задача не стоит выжать максимум любой ценой. Задача стоит оценить какую нагрузку держит облако в +- типичном сценарии с использованием типичных инструментов. И, заодно, посмотреть как изменятся результаты если поменять запрос, который создает ORM, на не сложный SQL. Все остальное - совсем за рамками этой статьи.

      За пример спасибо, за дичь обидно


      1. edo1h
        18.10.2021 17:20
        +1

        Задача стоит оценить какую нагрузку держит облако в +- типичном сценарии

        я бы не сказал, что сканирование большой таблицы — это типичный сценарий, скорее это то, чего на проде стараются избегать.


        1. Drag13 Автор
          18.10.2021 17:33
          +1

          Да я согласен :)

          Но с меня как с .NET программиста взятки гладки - код работает, я даже индекс накатил. А то, что вместо index seek получился index scan мы узнаем в лучшем случае из мониторинга, а в худшем от заказчиков, т.к. в план выполнения все кто не DBA смотрят, скажем так, не часто. Поэтому для меня этот код вполне подходит как типичный.

          Ну и DBA не всегда есть в команде, особенно если ты вообще один.


        1. mvv-rus
          18.10.2021 20:21

          я бы не сказал, что сканирование большой таблицы — это типичный сценарий, скорее это то, чего на проде стараются избегать.

          Все правильно вы пишете, но это, похоже, не тот случай, про который в статье: по крайней мере, в запросе, сгенеренном EF, у него Index Seek (который, подозреваю, ещё идет не с начала индекса, а сразу с нужного места и сразу выбирает запрошенные TOP N записей как раз в запрошенном в ORDER BY порядке то есть явлется практически оптимальным — но для проверки стоило бы посмотреть свойства этого оператора плана)+KeyLookup (потому что у него SELECT * ..., т.е. он забирает и те поля, которых нет в индексе).
          В первоначальном выложенном варианте — да, было полное сканирование таблицы, потому что автор тогда выложил планы запросов по таблице без индекса по полю, по начальной части которого у него идет отбор.
          PS Я не вижу, к сжалению, код шаблона для страницы, но если там выводятся только имена пользователей (поле DisplayName), то от KeyLookup, скорее всего, можно избавиться даже в сгенернном EF запросе, добавив в цепочку вызовов функций LINQ .Select(x=>x.DislplayName), чтобы получить в запросе SELECT [DisplayName]… (ну, и код шаблона для страницы подправить, чтобы учесть, что в него передаются только строки со значениями DisplayName).


          1. mvv-rus
            18.10.2021 20:52

            По поводу PS я, возможно, погорячился: не исключено, что там потребуется больше переделок, чтобы избавиться от SELECT * и KeyLookup, как его следствия.


  1. Netforspeed
    18.10.2021 17:17

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

    Вы ещё забыли упомянуть самую главную боль - негарантированность даже вот этого убогого результата.


    1. Drag13 Автор
      18.10.2021 17:19

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

      Вы ещё забыли упомянуть самую главную боль - негарантированность

      Ну какой-то SLA там есть, но результаты чуток прыгают, это да.


      1. Netforspeed
        20.10.2021 06:34

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


      1. Netforspeed
        20.10.2021 06:41

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


  1. TheAndrey
    18.10.2021 17:17

    @Drag13, не понял почему локально "плохой" код EF, так хорошо отрабатывал по сравнению с тем же плохим кодом на Azure.


    1. Drag13 Автор
      18.10.2021 17:21

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


  1. mvv-rus
    18.10.2021 20:03

    Обновленные планы выполнения запросов

    Похоже, вы вместо «Прямого запроса» повторно выложили план для варианта, сгенеренного EF.
    Проверьте, пожалуйста.


    1. Drag13 Автор
      18.10.2021 20:24

      Так и есть, выложил правильный. Спасибо.


      1. mvv-rus
        18.10.2021 20:49

        Вот так лучше: теперь видно, где сгенеренный EF запрос проигрывает, и примерно понятно, почему: видимо, на скорости сканирования индекса сказывается сложность выражения в WHERE, вычисляемого на основе значения индексированного поля.
        PS Но для полноценного сравнения было бы лучше, если бы число возвращаемых записей совпадало, а то сейчас у вас сгенеренный EF запрос возвращает 1 запись, а сделанный вручную (с другими параметрами, видимо) — 5


  1. kemsky
    22.10.2021 20:58

    Это очень, очень хороший пример того как внешне "простая" операция оказывается непростой внутри. Самое смешное, ваше ускорение добавило как минимум баг, а фактически уязвимость. Выражение для like надо эскейпить, иначе юзер может заслать вам строку которая начнется с %, например, и отключит индекс или придумает еще варианты для DDOS. В том числе и по этой причине EF Core и генерирует такой странный код. Можно почитать тут https://github.com/dotnet/efcore/issues/474. Расплатой становится не sargable WHERE, а значит в лучшем случае Index Scan вместо Seek, так как надо вычислять LEFT/LEN для каждого ряда. Нагрузка на IO растет в разы, растет и CPU и идет перерасход DTU.


    1. Drag13 Автор
      22.10.2021 21:10

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

      SQL инъекции это не смешно, так что спасибо что обратили на это внимание.
      Но в данном случае уязвимости нет, т.к. используется SQL параметры:

      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
          );

      Документация подтверждает безопасность данного подхода:

      Вы также можете создать DbParameter и указать его в качестве значения параметра. Поскольку используется обычный заполнитель параметров SQL, а не строковый заполнитель, можно безопасно использовать FromSqlRaw.


      1. kemsky
        22.10.2021 21:23

        Если строка будет начинаться с % то перестанет использоваться Index Seek и сервер ваш снова не сможет отдавать 100RPS, отказ в обслуживании, деградация. Попутно можно использовать другие фичи like чтобы еще сильнее нагрузить базу.


        1. Drag13 Автор
          22.10.2021 21:28

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