Хабрахабр

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

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

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

День, когда Dodo IS остановилась. Это первая статья из цикла про крушение системы, который я написал на основе своего выступления на DotNext 2018 в Москве:
1. Loading... Синхронный сценарий.
2.

Dodo IS

Система — большое конкурентное преимущество нашей франшизы, потому что франчайзи получают готовую модель бизнеса. Это ERP, HRM и CRM, всё в одном.

Ей пользуются менеджеры, клиенты, кассиры, повара, тайные покупатели, сотрудники колл-центра — все. Система появилась через пару месяцев после открытия первой пиццерии. Первая — для клиентов. Условно Dodo IS делится на две части. Вторая для партнёров-франчайзи, она помогает управлять пиццериями. Туда входят сайт, мобильное приложение, контакт-центр. Через систему проходят накладные от поставщиков, управление персоналом, вывод людей на смены, автоматический расчёт зарплат, онлайн обучение для персонала, аттестация управляющих, система проверки качества и тайных покупателей.

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

Производительность системы Dodo IS = Надёжность = Отказоустойчивость / Восстановление. Остановимся подробнее на каждом из пунктов.

Надёжность (Reliability)

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

Отказоустойчивость (Fault Tolerance)

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

Восстанавливаемость (Resilience)

Сбои отдельных компонент происходят каждый день. Это нормально. Важно, насколько быстро мы можем восстановиться после сбоя.

Синхронный сценарий отказа системы

Что это?

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

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

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

Чтобы обслуживать много заказов нам нужен правый вариант:

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

Упрощенно, preemptive multitasking можно было бы проиллюстрировать так:

Нам нужно пробуждать поток, усыплять его, а это накладные расходы. Цветные блоки – реальная работа, которую выполняет CPU, и мы видим, что полезной работы, обозначенной зеленым на диаграмме, довольно мало на общем фоне. Такое усыпление/пробуждение происходит при синхронизации на любых синхронизационных примитивах.

Насколько же сильно preemptive multitasking может повлиять на эффективность? Очевидно, эффективность работы CPU уменьшится, если разбавить полезную работу большим количеством синхронизаций.

Рассмотрим результаты синтетического теста:

В этом случае эффективность около 25%. Если интервал работы потока между синхронизациями около 1000 наносекунд, эффективность довольно мала, даже если количество Threads равно количеству ядер. Если же количество Threads в 4 раза больше, эффективность драматически падает, до 0,5%.

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

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

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

Что же происходит?

Обратите внимание на результат нагрузочного тестирования. В данном случае это было так называемое «тестирование на выдавливание».

Мы стараемся нащупать предел, после которого приложение откажется обслуживать запросы сверх своих возможностей. Суть теста в том, что используя нагрузочный стенд, мы подаём всё больше искусственных запросов в систему, пытаемся оформить как можно больше заказов в минуту. Именно так происходило бы реальной жизни, например — при обслуживании в ресторане, который переполнен клиентами. Интуитивно мы ожидаем, что система будет работать на пределе, отбрасывая дополнительные запросы. Клиенты сделали больше заказов, а система стала обслуживать меньше. Но происходит нечто иное. Такое происходит с многими приложениями, но должно ли так быть? Система стала обслуживать так мало заказов, что это можно считать полным отказом, поломкой.

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

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

Пути поиска проблемы

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

In-process locks

Вот типичный запрос в блокирующем приложении.

Это разновидность Sequence Diagram, описывающей алгоритм взаимодействия кода приложения и базы данных в результате какой-то условной операции. Мы видим, что делается сетевой вызов, потом в базе данных что-то происходит — база данных незначительно используется. Потом делается ещё один запрос. На весь период используется транзакция в базе данных и некий ключ, общий для всех запросов. Это может быть два разных клиента или два разных заказа, но один и тот же объект меню ресторана, хранящийся в той же базе данных, что и заказы клиентов. Мы работаем, используя транзакцию для консистенции, у двух запросов происходит Contention на ключе общего объекта.

Он, по факту, ничего не делает. Посмотрим, как это масштабируется.

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

Мы с этим будем бороться вот так.

var fallback = FallbackPolicy<OptionalData> .Handle<OperationCancelledException>() .FallbackAsync<OptionalData>(OptionalData.Default); var optionalDataTask = fallback .ExecuteAsync(async () => await CalculateOptionalDataAsync()); //… var required = await CalculateRequiredData(); var optional = await optionalDataTask; var price = CalculatePriceAsync(optional, required);

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

Условно назовем её optionalData. Раз разницы нет, мы можем сделать вот такую вещь. У нас есть fallback — значение, которое мы берём из кэша или передаём какое-то значение по умолчанию. То есть какое-то значение, без которого мы можем обойтись. Мы жёстко будем дожидаться его, а уже потом будем ждать ответа на запросы необязательных данных. И для самой главной операции (переменная required) мы будем делать await. Есть ещё один существенный момент — эта операция вообще может не выполниться по какой-то причине. Это нам позволит ускорить работу. Если операция не смогла выполниться, делаем fallback. Предположим — код этой операции не оптимален, и в данный момент там есть баг. А дальше мы работаем с этим как с обычным значением.

DB Locks

Примерно такой расклад у нас получается, когда мы переписали на async и поменяли модель консистентности.

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

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

Sync IO

Иногда нам нужны файловые логи. Удивительно, но система логирования может дать такие неприятные сбои. Latency на диске в Azure — 5 миллисекунд. Если мы пишем подряд файл, это всего лишь 200 запросов в секунду. Всё, приложение остановилось.

78% всех Threads — один и тот же call stack. Просто волосы дыбом встают, когда видишь это — более 2000 Threads расплодилось в приложении. Этот монитор разграничивает доступ к файлу, куда мы все логируем. Они остановились на одном и том же месте и пытаются войти в монитор. Мы делаем асинхронный target и пишем в него. Конечно же такое надо выпиливать.

Вот что нужно делать в NLog, чтобы его сконфигурировать. Конечно какое-то количество сообщений в логе можем потерять, но что важнее для бизнеса? А асинхронный target пишет в настоящий файл. Наверное, лучше потерять несколько сообщений в логе сервиса, который ушёл в отказ и перезагрузился. Когда система упала на 10 минут, мы потеряли миллион рублей.

Всё очень плохо

Contention — большая проблема многопоточных приложений, которая не позволяет просто масштабировать однопоточное приложение. Источники Contention нужно уметь идентифицировать и устранять. Большое количество Threads губительно для приложений, и блокирующие вызовы нужно переписывать на async.

Довольно часто кто-нибудь подходит и спрашивает: «Слушай, мы уже две недели переписываем, уже почти все async. Мне пришлось переписать немало legacy с блокирующих вызовов на async, я сам частенько выступал инициатором такой модернизации. Оно станет ещё медленнее. А на сколько оно станет быстрее работать?» Ребята, я вас расстрою — оно не станет быстрее работать. В одном из наших проектов — примерно +5% к использованию CPU и нагрузка на GC. Ведь TPL — это одна конкурентная модель поверх другой — cooperative multitasking over preemptive multitasking, и это накладные расходы.

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

Тут встает вопрос — а надо ли переписывать?

Мы увидели, что количество Threads может отрицательно влиять на производительность, поэтому нужно освободиться от необходимости увеличивать количество Threads для повышения Concurrency. Синхронный код переписывают на async для того, чтобы отвязать модель конкурентного исполнения процесса (Concurrency Model), и отвязаться от модели Preemptive Multitasking. Даже если у нас есть Legacy, и мы не хотим переписывать этот код — это главная причина его переписать.

Если вы такие проблемы обнаружите в своем блокирующем приложении, то самое время от них избавиться еще до переписывания на async, потому что там они не пропадут сами по себе. Хорошая новость напоследок — мы теперь кое-что знаем о том, как избавиться от тривиальных проблем Contention блокирующего кода.

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

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

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

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

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