Или каждая несчастная компания с монолитом несчастлива по-своему.
Разработка системы Dodo IS началась сразу же, как и бизнес Додо Пиццы — в 2011 году. В основе лежала идея полной и тотальной оцифровки бизнес-процессов, причем своими силами, что еще тогда в 2011 году вызывало много вопросов и скептицизма. Но вот уже 9 лет мы идем по такому пути — с собственной разработкой, которая начиналась с монолита.
Эта статья — «ответ» на вопросы «Зачем переписывать архитектуру и делать такие масштабные и долгие изменения?» к предыдущей статье «История архитектуры Dodo IS: путь бэкофиса». Начну с того как начиналась разработка Dodo IS, как выглядела изначальная архитектура, как появлялись новые модули, и из-за каких проблем пришлось проводить масштабные изменения.
Серия статей «Что такое Dodo IS?» расскажет про:
Ранний монолит в Dodo IS (2011-2015 годы). (You are here)
Ресурсы для разработки первого модуля приема заказа были ограничены. Нужно было делать много, быстро и малым составом. Малый состав — это 2 разработчика, которые и заложили фундамент всей будущей системы.
Их первое решение определило дальнейшую судьбу технологического стека:
Backend на ASP.NET MVC, язык C#. Разработчики были дотнетчиками, этот стек был им знаком и приятен.
Фронтенд на Bootstrap и JQuery: интерфейсы пользователя на самописных стилях и скриптах.
База данных MySQL: без затрат на лицензии, простая в использовании.
Серверы на Windows Server, потому что .NET тогда мог быть только под Windows (Mono обсуждать не будем).
Физически это все выражалось в «дедике у хостера».
Архитектура приложения приема заказа
Тогда уже все говорили о микросервисах, а SOA лет 5 использовалось в крупных проектах, например, WCF вышел в 2006 году. Но тогда выбрали надежное и проверенное решение.
Вот оно.
Asp.Net MVC — это Razor, который выдаёт по запросу с формы или от клиента HTML-страницу с рендерингом на сервере. На клиенте уже CSS и JS-скрипты отображают информацию и, по необходимости, выполняют AJAX-запросы через JQuery.
Запросы на сервере попадают в классы *Controller, где в методе происходит обработка и генерация итоговой HTML-страницы. Контроллеры делают запросы на слой логики, называемый *Services. Каждый из сервисов отвечал какому-то аспекту бизнеса:
Например, DepartmentStructureService выдавал информацию по пиццериям, по департаментам. Департамент — это группа пиццерий под управлением одного франчайзи.
ReceivingOrdersService принимал и рассчитывал состав заказа.
А SmsService отправлял смс, вызывая API-сервисы по отправке смс.
Сервисы обрабатывали данные из базы, хранили бизнес-логику. В каждом сервисе был один или несколько *Repository с соответствующим названием. В них уже находились запросы к хранимым процедурам в базе и слой мапперов. В хранимках была бизнес-логика, особенно много в тех, которые выдавали отчетные данные. ОРМ не использовался, все полагались на написанный руками sql.
Еще был слой доменной модели и общих классов-хелперов, например, класс Order, хранивший заказ. Там же, в слое, находился хелпер для преобразования текста отображения по выбранной валюте.
Всё это можно представить такой моделью:
Путь заказа
Рассмотрим упрощенный первоначальный путь создания такого заказа.
Изначально сайт был статический. На нем были цены, а сверху — номер телефона и надпись «Хочешь пиццу — звони по номеру и закажи». Для заказа нам нужно реализовать простой flow:
Клиент заходит на статический сайт с ценами, выбирает продукты и звонит по номеру, который указан на сайте.
Клиент называет продукты, которые хочет добавить в заказ.
Называет свой адрес и имя.
Оператор принимает заказ.
Заказ отображается в интерфейсе принятых заказов.
Все начинается с отображения меню. Залогиненный пользователь-оператор в один момент времени принимает лишь один заказ. Поэтому draft-корзина может храниться в его сессии (сеанс пользователя хранится в памяти). Там объект Cart, в котором продукты и информация о клиенте.
Клиент называет продукт, оператор нажимает на + рядом с продуктом, и на сервер отправляется запрос. По продукту вытаскивается информация из базы и добавляется информация о продукте в корзину.
Примечание. Да, здесь можно не вытаскивать продукт из базы, а передавать с фронтенда. Но для наглядности я показал именно путь из базы.
Далее вводим адрес и имя клиента.
При нажатии «Создать заказ»:
Запрос отправляем в OrderController.SaveOrder().
Получаем Cart из сессии, там лежат продукты в нужном нам количестве.
Дополняем Cart информацией о клиенте и передаем в метод AddOrder класса ReceivingOrderService, где он сохраняется в базу.
В базе есть таблицы с заказом, составом заказа, клиентом и они все связаны.
Интерфейс отображения заказа идет и вытаскивает последние заказы и отражает их.
Новые модули
Прием заказа был важен и необходим. Нельзя сделать бизнес по продаже пиццы, если нет приема заказа для продажи. Поэтому система начала обрастать функционалом — примерно с 2012 по 2015 года. За это время появилось много различных блоков системы, которые я буду называть модулями, в противовес понятию сервиса или продукта.
Модуль — это набор функций, которые объединены какой-то общей бизнес-целью. При этом физически они находятся в одном приложении.
Модули можно назвать блоками системы. Например, это модуль отчетов, интерфейсы админки, трекер продуктов на кухне, авторизация. Это всё разные интерфейсы для пользователя, некоторые имеют даже различные визуальные стили. При этом все в рамках одного приложения, одного работающего процесса.
Технически модули оформлялись как Area (вот такая идея даже осталась в asp.net core). Там были отдельные файлы для фронтенда, моделей, а также свои классы контроллеров. В итоге система преобразовалась из такой…
…в такую:
Некоторые модули реализованы отдельными сайтами(executable project), по причине совсем уже отдельного функционала и частично из-за несколько отдельной, более сфокусированной разработки. Это:
Personal — личный кабинет сотрудника. Отдельно разрабатывался и имеет свою точку входа и отдельный дизайн.
fs — проект для хостинга статики. Позже мы ушли от него, переведя всю статику на CDN Akamai.
Остальные же блоки находились в приложении BackOffice.
Пояснение по названиям:
Cashier — Касса ресторана.
ShiftManager — интерфейсы для роли «Менеджер смены»: оперативная статистика по продажам пиццерии, возможность поставить в стоп-лист продукты, изменить заказ.
OfficeManager — интерфейсы для роли «Управляющий пиццерии» и «Франчайзи». Здесь собраны функции по настройке пиццерии, её бонусных акций, прием и работа с сотрудниками, отчеты.
PublicScreens — интерфейсы для телевизоров и планшетов, висящих в пиццериях. На телевизорах отображается меню, рекламная информация, статус заказа при выдаче.
Они использовали общий слой сервисов, общий блок доменных классов Dodo.Core, а также общую базу. Иногда еще могли вести по переходам друг к другу. В том числе к общим сервисам ходили и отдельные сайты, вроде dodopizza.ru или personal.dodopizza.ru.
При появлении новых модулей старались по максимуму переиспользовать уже созданный код сервисов, хранимых процедур и таблиц в базе.
Для лучшего понимания масштаба модулей, сделанных в системе, вот схема из 2012 года с планами развития:
К 2015 году всё на схеме и даже больше было в продакшн.
Прием заказа перерос в отдельный блок Контакт Центра, где заказ принимается оператором.
Появились общедоступные экраны с меню и информацией, висящие в пиццериях.
На кухне есть модуль, который автоматически воспроизводит голосовое сообщение «Новая пицца» при поступлении нового заказа, а также печатает накладную для курьера. Это сильно упрощает процессы на кухне, позволяет не отвлекаться на большое количество простых операций сотрудникам.
Блок доставки стал отдельной Кассой Доставки, где заказ выдавался курьеру, который предварительно встал на смену. Учитывалось его рабочее время для начисления зарплаты.
Параллельно с 2012 по 2015 появилось более 10 разработчиков, открылось 35 пиццерий, развернули систему на Румынию и подготовили к открытию точек в США. Разработчики уже не занимались всеми задачами, а были разделены на команды. каждая специализировалась на своей части системы.
Проблемы
В том числе из-за архитектуры (но не только).
Хаос в базе
Одна база — это удобно. В ней можно добиться консистентности, причем за счет средств, встроенных в реляционные базы. Работать с ней привычно и удобно, особенно, если там мало таблиц и немного данных.
Но за 4 года разработки в базе оказалось около 600 таблиц, 1500 хранимых процедур, во многих из которых была еще и логика. Увы, хранимые процедуры не приносят особого преимущества при работе с MySQL. Они не кэшируются базой, а хранение в них логики усложняет разработку и отладку. Переиспользование кода тоже затруднено.
На многих таблицах не было подходящих индексов, где-то, наоборот, было очень много индексов, что затрудняло вставку. Надо было модифицировать около 20 таблиц — транзакция на создание заказа могла выполняться около 3-5 секунд.
Данные в таблицах не всегда были в наиболее подходящей форме. Где-то нужно было сделать денормализацию. Часть регулярно получаемых данных была в колонке в виде XML-структуры, это увеличивало время выполнения, удлиняло запросоы и усложняло разработку.
К одним и тем же таблицам производились очень разнородные запросы. Особенно страдали популярные таблицы, вроде упоминавшейся таблицы orders или таблицы pizzeria. Они использовались для вывода оперативных интерфейсов на кухне, аналитики. Еще к ним обращался сайт(dodopizza.ru), куда в любой момент времени могло придти внезапно много запросов.
Данные не были агрегированными и много расчетов происходило на лету средствами базы. Это создавало лишние вычисления и дополнительную нагрузку.
Часто код ходил в базу тогда, когда мог этого не делать. Где-то не хватало bulk-операций, где-то надо было бы разнести один запрос на несколько через код, чтобы ускорить и повысить надежность.
Связность и запутанность в коде
Модули, которые должны были отвечать за свой участок бизнеса, не делали этого честно. Некоторые из них имели дублирование по функциям для ролей. Например, локальному маркетологу, который отвечает за маркетинговую активность сети в своем городе, приходилось пользоваться как интерфейсом «Админа» (для заведения акций), так и интерфейсом «Менеджера Офиса» (для просмотра влияния акций на бизнес). Конечно, внутри оба модуля использовали один сервис, который работал с с бонусными акциями.
Сервисы (классы в рамках одного монолитного большого проекта) могли вызывать друг друга для обогащения своих данных.
С самими классами-моделей, которые хранят данные, работа в коде велась различно. Где-то были конструкторы, через которые можно было указать обязательные поля. Где-то это делалось через публичные свойства. Конечно, получение и преобразование данных из базы было разнообразным.
Логика была либо в контроллерах, либо в классах сервисов.
Это вроде незначительные проблемы, но они сильно замедляли разработку и снижали качество, что приводило к нестабильности и ошибкам.
Сложность большой разработки
Трудности возникли и в самой разработке. Нужно было делать разные блоки системы, причем параллельно. Вместить нужды каждого компонента в единый код становилось все труднее. Было не просто договориться и угодить всем компонентам одновременно. К этому добавлялись ограничения в технологиях, особенно касаемо базы и фронтэнда. Нужно было отказываться от JQuery в сторону высокоуровневых фреймворков, особенно в части клиентских сервисов (сайт).
В каких-то частях системы могли бы использоваться базы, более подходящие для этого. Например, позднее у нас был прецедент перехода с Redis на CosmosDB для хранения корзины заказа.
Команды и разработчики, занимающиеся своей областью явно хотели большей самостоятельности для своих сервисов, как в части разработки, так и в части выкатки. Конфликты при мерже, проблемы при релизах. Если для 5 разработчиков эта проблема несущественна, то при 10, а уж тем более при планируемом росте, все стало бы серьёзнее. А а впереди должна была быть разработка мобильного приложения (она стартанула в 2017, а в 2018 было большое падение).
Разные части системы требовали разных показателей стабильности, но в силу сильной связности системы, мы не могли этого обеспечить. Ошибка при разработке новой функции в админке, вполне могла выстрелить в приеме заказа на сайте, ведь код общий и переиспользуемый, база и данные тоже едины.
Вероятно, можно было бы и в рамках такой монолитно-модульной архитектуры не допускать этих ошибок и проблем: сделать разделение ответственности, проводить рефакторинг как кода, так и базы данных, чётко отделять слои друг от друга, следить за качеством каждый день. Но выбранные архитектурные решения и фокус на быстром расширении функционала системы привели к проблемам в вопросах стабильности.
Как блог Сила ума положил кассы в ресторанах
Если рост сети пиццерий (и нагрузки) продолжался бы в том же темпе, то через некоторое время падения были бы уже такими, что система и не поднимется. Хорошо иллюстрирует проблемы, с которыми мы начали сталкиваться к 2015 году вот такая история.
В блоге «Сила ума» был виджет, который показывал данные по выручке за год всей сети. Виджет обращался к публичному API Dodo, которое предоставляет эти данные. Сейчас эта статистика доступна на http://dodopizzastory.com/. Виджет показывался на каждой странице и делал запросы по таймеру каждые 20 секунд. Запрос уходил в api.dodopizza.ru и запрашивал:
количество пиццерий в сети;
общую выручку сети с начала года;
выручку за сегодня.
Запрос на статистику по выручке шел сразу в базу и начинал запрашивать данные по заказам, агрегировать данные прямо на лету и выдавать сумму.
В эту же таблицу заказов ходили Кассы в ресторанах, выгружали список принятых за сегодня заказов, в неё же добавлялись новые заказы. Кассы делали свои запросы каждые 5 секунд или по обновлению страницы.
Схема выглядела так:
Однажды осенью, Федор Овчинников написал в свой блог длинную и популярную статью. На блог пришло очень много людей и стали внимательно всё читать. Пока каждый из пришедших человек читал статью, виджет с выручкой исправно работал и запрашивал API каждые 20 секунд.
API вызывало хранимую процедуру на расчет суммы всех заказов с начала года по всем пиццериям сети. Агрегация шла по таблице orders, которая очень популярна. В неё же ходят все кассы всех открытых ресторанов на тот момент. Кассы перестали отвечать, заказы не принимались. Ещё они не принимались с сайта, не появлялись на трекере, менеджер смены не мог увидеть их в своем интерфейсе.
Это не единственная история. К осени 2015 года каждую пятницу нагрузка на систему была критическая. Несколько раз мы выключали публичное API, а однажды, нам пришлось даже отключить сайт, потому что уже ничего не помогало. Был даже список сервисов с порядком отключения при серьезных нагрузках.
С этого времени начинается наша борьба с нагрузками и за стабилизацию системы (с осени 2015 до осени 2018). Именно тогда случилось «Великое падение». Дальше тоже иногда происходили сбои, некоторые были весьма чувствительными, но общий период нестабильности сейчас можно считать пройденным.
Бурный рост бизнеса
Почему нельзя было «сделать сразу хорошо»? Достаточно посмотреть на следующие графики.
Также в 2014-2015 было открытие в Румынии и готовилось открытие в США.
Сеть росла очень быстро, открывались новые страны, появлялись новые форматы пиццерий, например, открылась пиццерия на фудкорте. Всё это требовало значительного внимания именно к расширению функций Dodo IS. Без всех этих функций, без трекинга на кухне, учета продуктов и потерь в системе, отображения выдачи заказа в зале фудкорта, вряд ли бы мы сейчас рассуждали о «правильной» архитектуре и «верном» подходе к разработке.
Еще препятствиями для своевременного пересмотра архитектуры и вообще внимания к техническим проблемам, был кризис 2014 года. Такие вещи больно бьют по возможностям для роста команд, особенно для молодого бизнеса, каким была Додо Пицца.
Быстрые решения, которые помогли
Проблемы требовали решения. Условно, решения можно разделить на 2 группы:
Быстрые, которые тушат пожар и дают небольшой запас прочности и выигрывают нам время на изменения.
Системные и, поэтому, долгие. Реинжиниринг ряда модулей, разделение монолитной архитектуры на отдельные сервисы (большинство из них вполне не микро, а скорее макросервисы и про это есть доклад Андрея Моревского).
Сухой список быстрых изменений таков:
Scale up мастер базы
Конечно, первое, что делается для борьбы с нагрузками — увеличивается мощность сервера. Это делали для мастер базы и для веб серверов. Увы, это возможно лишь до некоторого предела, дальше становится слишком дорого.
ReadReplicaдля запросов на справочники. Применяется для чтения справочников, типа, города, улицы, пиццерии, продуктов (slowly changed domain), и в тех интерфейсах, где допустима небольшая задержка. Этих реплик было 2, мы обеспечивали их доступность также, как и мастера.
ReadReplica для запросов на отчеты. У этой базы доступность была ниже, но в неё ходили все отчеты. Пусть у них тяжелые запросы на огромные пересчеты данных, но зато они не влияют на основную базу и операционные интерфейсы.
Кэши в коде
Кэшей в коде нигде не было (вообще). Это приводило к дополнительным, не всегда нужным, запросам в нагруженную базу. Кэши были сначала как в памяти, так и на внешнем кэш-сервисе, это был Redis. Все инвалидировалось по времени, настройки указывались в коде.
Несколько серверов для бэкэнда
Бэкэнд приложения тоже надо было масштабировать, чтобы выдерживать повышенные нагрузки. Необходимо было сделать из одного iis-сервера кластер. Мы перенесли сессию приложений из памяти на RedisCache, что позволило сделать несколько серверов, стоящих за простым балансировщиком нагрузки с round robin. Сначала использовался тот же Redis, что и для кэшей, потом разнесли на несколько.
В итоге архитектура усложнилась…
…но часть напряженности удалось снять.
А дальше нужно было переделывать нагруженные компоненты, за что мы и взялись. Об этом мы расскажем в следующей части.