Хабрахабр

День, когда Dodo IS остановилась. Асинхронный сценарий

Привет, Хабр! Каждый SRE в нашей команде когда-то мечтал спокойно спать по ночам. Мечты имеют свойство сбываться. В этой статье я расскажу про это и про то, как мы достигаем производительности и устойчивости своей системы Dodo IS.

Цикл статей про крушение системы Dodo IS*:

День, когда Dodo IS остановилась. 1. День, когда Dodo IS остановилась. Синхронный сценарий.
2. Асинхронный сценарий.

* Материалы написаны на основе моего выступления на DotNext 2018 в Москве.

В прошлой статье мы рассмотрели проблемы блокирующего кода в парадигме Preemptive Multitasking. Предполагалось, что необходимо переписать блокирующий код на async/await. Так мы и сделали. Теперь поговорим о том, какие проблемы возникли, когда мы сделали это.

Вводим термин Concurrency

Перед тем, как подобраться к asynс, необходимо ввести термин Concurrency.

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

Тем, кто столкнулся с термином Concurrency впервые, я рекомендую видео Роба Пайка. Concurrency – это когда мы имеем дело со многими вещами одновременно, а Parallelism – когда делаем много вещей одновременно.

Одна из таких вещей – вычисления на нескольких процессорах. В компьютерах не так уж много вещей происходят параллельно. Степень параллелизма ограничена количеством потоков CPU.

Эта модель остается полезной до тех пор, пока мы понимаем, что имеем дело именно с моделью Concurrency, а не с параллелизмом. На самом деле Threads – это часть концепции Preemptive Multitasking, один из способов моделировать Concurrency в программе, когда мы полагаемся на операционную систему в вопросе Concurrency.

По сути это Cooperative Multitasking – модель сама по себе вообще не учитывает параллелизм. Async/await – это синтаксический сахар для State Machine, другая полезная модель Concurrency, которая может работать в однопоточной среде. В сочетании с Multithreading, мы получаем одну модель поверх другой, и жизнь сильно усложняется.

Сравнение работы двух моделей

Как всё работало в модели Preemptive Multitasking

Допустим, у нас 20 Threads и 20 запросов в обработке в секунду. На картинке видно пик – 200 запросов в системе одновременно. Как такое могло произойти:

  • запросы могли сгруппироваться, если 200 клиентов нажали кнопку в один и тот же момент;
  • сборщик мусора мог остановить запросы на несколько десятков миллисекунд;
  • запросы могли задержаться в какой-либо очереди, если прокси поддерживает очередь.

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

Connection Pool в MySql тоже 20, потому что Threads=20. Предположим, что умный алгоритм Thread Pool (а там есть элементы машинного обучения) решил, что пока нет резона увеличивать количество Threads. Соответственно, нам нужно только 20 соединений к SQL.

Сервер уже получил эти запросы, но еще не выполнил. В этом случае, уровень Concurrency сервера с точки зрения внешней системы = 200. Итак, мы имеем дело со степенью Concurrency = 20. Однако для приложения, работающего в парадигме Multithreading, количество одновременных запросов ограничено текущим размером Thread Pool = 20.

Как всё теперь работает в модели async

Здесь нет никакой очереди перед тем, как создать Task, и запрос поступает в обработку сразу. Посмотрим, что произойдет в приложении, работающем с async/await при той же нагрузке и распределении запросов. Поскольку Thread быстро возвращается в Thread Pool, нам не нужно большого количества Threads для обработки. Конечно, на короткое время используется Thread из ThreadPool, и первая часть запроса, до обращения в базу данных, выполняется сразу. На этой диаграмме мы не отображаем Thread Pool вообще, он прозрачен.

Внешняя картина такая же – уровень Concurrency = 200. Что это будет означать для нашего приложения? Раньше запросы «толпились» в очереди ThreadPool, теперь степень Concurrency приложения тоже равна 200, потому как у нас нет никаких ограничений со стороны TaskScheduler. При этом ситуация внутри поменялась. Мы достигли цели async – приложение «справляется» практически с любой степенью Concurrency! Ура!

Последствия: нелинейная деградация системы

Приложение стало прозрачным с точки зрения Concurrency, поэтому теперь Concurrency проецируется на базу данных. Теперь нам нужен пул соединений такого же размера = 200. База данных – это CPU, память, сеть, хранилище. Это такой же сервис со своими проблемами, как и любой другой. Чем больше запросов одновременно пытаемся выполнить, тем медленнее они выполняются.

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

Почему так происходит?

Причины второго порядка:

  • теперь базе данных нужно одновременно держать в памяти структуры данных для обслуживания большего количества запросов;
  • теперь базе данных нужно обслуживать коллекции большего размера (а это алгоритмически невыгодно).

Причина первого порядка:
В итоге async борется против ограниченных ресурсов и … побеждает! База данных не выдерживает и начинает замедляться. От этого сервер дополнительно наращивает Concurrency, и система уже не может выйти с честью из этой ситуации.

Синдром внезапной смерти сервера

Иногда происходит интересная ситуация. У нас есть сервер. Он работает себе такой, все в порядке. Ресурсов хватает, даже с запасом. Потом мы вдруг получаем сообщение от клиентов, что сервер тормозит. Смотрим в график и видим, что был какой-то всплеск активности клиентов, но теперь все в норме. Думаем на DOS атаку или совпадение. Сейчас вроде всё нормально. Только вот сервер продолжает тупить, причем все жестче, пока не начинают сыпаться таймауты. Через некоторое время другой сервер, который использует ту же базу данных тоже начинает загибаться. Знакомая ситуация?

Почему умерла система?

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

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

Моделируем ситуацию падения сервера

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

Системы, которые в любой ситуации предоставляют хоть какую-то информацию, и, вместе с тем, умеют анализировать post-mortem системы по отрывочным сведениям. SRE должны уметь изготавливать системы мониторинга мало подверженные такому эффекту. Для учебных целей мы в рамках данной статьи используем несколько другой подход.

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

Такой подход позволит:

  • рассмотреть то, что произойдет в лучшем случае;
  • снять точные метрики.

Навигация по графику:

  • синий – количество запросов к серверу;
  • зеленый – ответы сервера;
  • желтый – таймауты;
  • темно-серый – запросы, которые зря тратили ресурсы сервера, так как клиент не дождался ответа по таймауту. Иногда клиент может сообщить об этом серверу обрывом соединения, но в общем случае такая роскошь может быть технически неосуществима, например, если сервер делает CPU-bound работу, без кооперации с клиентом.

Обычно график заказов в наших пиццериях плавненько растет утром и убывает вечером. Почему график запросов клиента (синий на диаграмме) получился именно таким? Эта форма графика выбрана для модели не случайно, а по поводу. Но мы наблюдаем три пика на фоне обычной равномерной кривой. Модель родилась при расследовании реального инцидента с сервером контакт-центра пиццерий в России во время чемпионата мира по футболу.

Кейс «Чемпионат мира по футболу»

Мы сидели и ждали, что у нас будет больше заказов. К Чемпионату подготовились, теперь серверы смогут пройти проверку на прочность.

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

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

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

Болельщиков и систему ждет пенальти. Третий пик наступит, когда матч закончится.

Разбираем причины падения сервера

Что произошло? Сервер мог удерживать 100 условных запросов. Мы понимаем, что он рассчитан на эту мощность и больше не выдержит. Приходит пик, который сам по себе не такой большой. Зато серая область Concurrency гораздо выше.

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

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

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

Видно, что Concurrency зашкаливает. Еще раз весь график. Появляется огромная гора.

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

Самый большой пик нагрузки-то давно уже прошёл! Но откуда взялась такая огромная гора?

Закон Литтла

Закон Литтла регулирует Concurrency.

L (количество клиентов внутри системы) = λ (скорость их пребывания) ∗ W (время, которое они проводят внутри системы)

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

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

Это те, которые реально выполняются. Зеленых запросов мало. Между таймами у нас обычное количество запросов, ситуация стабильная. Синие – те, которые поступают. Сервер уже не справится с этой ситуацией сам. Но Concurrency все равно растет. Это значит, что скоро он упадет.

Смотрим на интеграл константы. Но почему Concurrency растет? У нас ничего не меняется в системе, но интеграл выглядит как линейная функция, которая растет только вверх.

Поиграем?

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

Игра № 1

Предусловия: На сервер поступают запросы, каждый требует три периода обработки на CPU. Ресурс CPU делится равномерно между всеми задачами. Это похоже на то, как расходуется ресурс CPU при Preemptive Multitasking. Число в клетке означает количество работы, оставшейся после данного такта. За каждый условный такт поступает новый запрос.

Всего 3 единицы работы, по завершении первого периода обработки осталось 2 единицы. Представьте, что поступил запрос.

Они сделали по единице работы по первым двум запросам. Во втором периоде наслаивается еще один запрос, теперь оба CPU заняты. Осталось доделать 1 и 2 единицы для первого и второго запроса соответственно.

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

Добавляется четвертый запрос, и теперь степень Concurrency уже 4, так как всем четырем запросам потребовался ресурс в этом периоде. Дальше еще интереснее! Тем временем, первый запрос к концу четвертого периода уже выполнился, в следующий период он не переходит, и ему осталось 0 работы для CPU.

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

Степень Concurrency по прежнему 4, но мы видим, что в пятом столбике оставшейся работы в сумме больше. Поступает пятый запрос. Это происходит, потому что в четвертом столбике оставалось больше несделанной работы, чем в третьем.

Ждем ответов.
– Сервер, алё!
–… Продолжаем еще три периода.

– Ваш звонок очень важен для нас…

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

Я не рисовал дальше, потому что время ответа на третий запрос уже не влезет в картинку. Степень Concurrency выросла уже втрое, и ничто не предвещает, что ситуация изменится к лучшему.

Game over. Наш сервер вошел в нежелательное состояние, из которого он никогда не выйдет самостоятельно.

Чем характеризуется GameOver-состояние сервера?

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

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

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

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

Возьмем, например, ночной клуб. Когда занимаешься архитектурой распределенной системы, полезно подумать, как обычные люди решают такие проблемы. Вышибала справляется с проблемой просто: смотрит, сколько человек внутри. Он перестанет функционировать, если туда зайдет слишком много людей. Новый гость придет и оценит размер очереди. Один вышел – другого запускает. Что если применить этот алгоритм для сервера? Если очередь большая, уйдет домой.

Давайте поиграем еще раз.

Игра № 2

Предусловия: Опять у нас два CPU, такие же задачи по 3 единицы, поступающие каждый период, но теперь мы поставим вышибалу, и задачи будут умные — если они видят, что длина очереди 2, то сразу идут домой.

В этом периоде он стоит в очереди. Пришел третий запрос. Дробных чисел в остатках нет, потому что два CPU выполняют две задачи, по единице за период. У него число 3 по окончании периода.

Третий стоит в очереди и не считается. Хотя у нас три запроса наслаиваются, степень Concurrency внутри системы = 2.

Пришел четвертый – та же картина, хотя уже накопилось больше оставшейся работы.



В шестом периоде третий запрос выполнился с отставанием на треть, и степень Concurrency уже = 4.

Больше она не может вырасти, ведь мы установили на это явный запрет. Степень Concurrency возросла в два раза. С максимальной скоростью выполнились только два первых запроса – те, кто пришел в клуб первыми, пока для всех хватало места.

Поэтому те, кто был внутри спокойно развлекались. Жёлтые запросы находились в системе дольше, но они стояли в очереди и не оттягивали на себя ресурс CPU. Это невыполненный запрос: Это могло продолжаться и дальше, пока не пришел мужик и не сказал, что в очереди стоять не будет, а лучше отправится домой.

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

Мы видим, что простое ограничение уровня Concurrency устраняет проблему жизнеспособности сервера.

Как увеличить жизнеспособность сервера через ограничение уровня Concurrency

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

const int MaxConcurrency = 100;
SemaphoreSlim bulkhead = new SemaphoreSlim(MaxConcurrency, MaxConcurrency); public async Task ProcessRequest()
try { await ProcessRequestInternal(); return; } finally { bulkhead.Release(); }
}

Чтобы создать ограниченную очередь, нужно два семафора. Для этого подходит библиотека Polly, которую рекомендует Microsoft. Обратите внимание на паттерн Bulkhead. Дословно переводится как «переборка» – элемент конструкции, позволяющий судну не тонуть. Если честно, я считаю, что лучше подходит термин «вышибала». Важно то, что этот паттерн позволяет серверу выживать в безнадежных ситуациях.

Например, мы определили, что это 100. Сначала мы на нагрузочном стенде выдавливаем из сервера все, что можно, пока не определяем, сколько запросов он может держать. Ставим bulkhead.

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

  1. Если поведение сервера стабильно зависит от нагрузки с точки зрения ресурсов, то это число может приближаться к пределу.
  2. Если среда подвержена флуктуациям нагрузки, следует выбрать более консервативное число, с учетом размера этих флуктуаций. Такие флуктуации могут возникать по разным причинам, например, для среды исполнения с GC характерны небольшие пики нагрузки на CPU.
  3. Если сервер выполняет периодические задачи по расписанию, это также следует учесть. Можно даже разработать адаптивную переборку, которая будет вычислять, сколько можно давать одновременно запросов без деградации сервера (но это уже выходит за рамки данного исследования).

Эксперименты с запросами

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

Вся эта серая куча однозначно коррелирует с падением сервера. Серое – смерть для сервера. Попробуем просто срезать это и посмотреть, что произойдет. Кажется, что какое-то количество запросов пойдет домой, просто не выполнится. Но сколько?

100 внутри, 100 снаружи


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

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

100 внутри, 500 снаружи

Это те запросы, которые еще долго потом выполняются. Стало получше, но вырос хвост.

100 внутри, 1000 снаружи

Раз уж что-то стало лучше, попробуем довести до абсурда. Разрешим длину очереди в 10 раз больше, чем можем обслуживать одновременно:

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

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

Есть и еще один риск – другие компоненты системы могут быть не рассчитаны на такое поведение сервера, и, как мы уже знаем, Concurrency проецируется на клиентов.

Поэтому возвращаемся к первому варианту «100 на 100» и думаем, как масштабировать наши мощности.

Победитель – 100 внутри, 100 снаружи

¯\_(ツ)_/¯

В то же время – это 100% деградация по времени выполнения запроса. При таких параметрах самая большая деградация по времени выполнения – ровно в 2 раза от «номинала».

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

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

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

Диагноз и лечение

Проблему Uncontrolled Concurrency мы лечим с помощью Bulkhead Isolation.
Этот метод, как и другие, рассматриваемые в этой серии статей, удобно реализовывать библиотекой Polly.

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть