Эта статья будет полезна, если вы начинаете проект, который может перерасти в HL (HighLoad) или у вас уже есть проект, который имеет высокую нагрузку. Каждый пункт этого чек-листа поможет избежать определенных проблем, возникающих в процессе эксплуатации таких систем. И хотя некоторые пункты могут показаться довольно очевидными, а иные даже лишними, я рекомендую ознакомиться со всем списком, т.к. судя по статьям на хабре, периодически с некоторыми из этих проблем встречаются компании, которые уже обрели некоторую популярность. Дополняя систему каким то компонентом довольно просто забыть о таких вещах, как KeepAlive между двумя сервисами, а процессы изменения и дополнения в IT происходят постоянно.
Я не буду тут говорить про вертикальное и горизонтальное масштабирование, о микросервисах, балансировке нагрузки, важности тестирования и прочем таком. Будем считать, что читатели все это уже знают, ну а если кто-то не знает, пусть гуглит сейчас. Кроме того, тут вы не найдете инструкции, как проектировать и строить такие системы, цель этой статьи проста - собрать воедино какой-никакой удобоваримый чек-лист для HighLoad системы. Пункты взяты не с потолка - это результат исследовательской деятельности перемежающейся с личным опытом.
Структура этой статьи следующая - я разбил все пункты чек-листа на четыре части, в каждой из которых заголовок, соответствующий пункту, и некоторый сопроводительный текст из одного-двух абзацев, который может содержать совет. Под сопроводительным текстом вы найдете чекбокс, который содержит посыл в форме утверждения. Если вы можете подписаться под этим утверждением, то можете считать, что для вас этом чекбоксе галочка проставлена, если нет, попробуйте понять, почему нет и даже можете подискутировать в комментариях.
Маленькая ремарка: все пункты указанные ниже будут относиться не к высокой нагрузке (High Load) а к высокой производительности и отказоустойчивости (High Performance and Durability).
Проектирование архитектуры
Микросервисы и функциональное разделение
Я не говорю, что микросервисы - это панацея, и не говорю, что это must have. Но то, что монолит нужно правильно распилить (пусть на макросервисы или другие штуки со смешным названием) на отдельные функциональные приложения и заставить их работать независимо друг от друга, получая при этом возможность масштабирования отдельных узлов, повышая надежность системы в целом - это факт.
Важно при этом понимать, что отдельные узлы должны быть полностью независимы - иметь разные хранилища, разные доменные имена и масштабироваться независимо друг от друга.
[✔] моя система сегментирована и отдельные ее части независимы
Толстый клиент
Возможно, стоит подумать о балансировке на уровне приложения, если у вас что-то похожее на социальную сеть или интернет магазин, клиентская сторона может получать с главного сервера список dns ваших серверов и обращаться к ним по очереди или переключаться по мере необходимости, например, при длительном времени ответа или полном отказе одного из серверов. Так же для распределения нагрузки можно реализовать "толстый клиент" - собирать запросами сырые данные и выполнять рендер на стороне клиента. Конечно, этот пункт сильно зависит от бизнес-логики.
[✔] я продумал вариант использования "толстого клиента" и балансировки на стороне клиента
Отложенные вычисления и асинхронная обработка
Результаты вычислений, которые не требуются в режиме реального времени, а могут подождать секунду, минуту, час или два, легко могут мигрировать в фоновые процессы. Т.е. вы можете перенести сложные вычисления или вычисления огромных объемов информации в асинхронную обработку, пусть это будет какой то воркер, который будет обрабатывать очередь событий, а на клиенте в это время будет заглушка "подождите, данные в обработке...". Это могут быть просто сложные вычисления (на пару сотен миллисекунд), тут важно то, что клиентская сторона не будет держать активное соединение до вашего сервиса в состоянии ожидания, освобождая канал данных.
[✔] то, что можно отложить, выполняется асинхронно
Применение паттернов отказоустойчивости
Об этом пункте я как то позабыл, считая их само-собой разумеющимися при разработке распределенной системы. Однако, нельзя не заметить, что отсутствие реализации этих практик в коде распределенной системы делает эту систему уязвимой перед такими довольно распространенными ситуациями, как timeout вызовов внешней системы, перезапуск (или отказ) нижестоящих сервисов и другими "вредителями".
Cirquit Breaker
Bulkhead
Поведение по умолчанию
Спасибо одному из Старожилов Хабра за то, что напомнил мне об этом в комментариях.
[✔] я применил паттерны отказоустойчивости в своей системе
Хранилище данных
OLAP и OLTP
Никому не нужно объяснять, что тяжелые запросы влияют на все остальные запросы в базе данных. Т.е. если есть какой то плохо оптимизированный отчет, который обрабатывает большую кучу данных, состоит из множества вложенных запросов и еще делает множество блокировок, то параллельные запросы будут работать медленно. Что при этом помогает продолжать системе работать на одном и том же уровне производительности и не деградировать по скорости выполнения запросов? Разделение OLAP и OLTP запросов.
Просто используйте одну базу для работы с данными - чтение, обновление. А другую для построения запросов - она может работать на какой то реплике, которая может получать данные с задержкой в час или два или это может быть отдельная база данных другой структуры, которая содержит уже агрегированные данные. Самое главное - изоляция одной логики (выполнение запросов) от другой (получение аналитических данных) на физическом уровне.
[✔] OLAP и OLTP изолированы и выполняются на разных серверах
Денормализация и введение избыточности
Нормализация базы данных - это хорошо. Четкая структура позволяет вносить какие то правила соблюдения консистентности на уровне ядра базы данных. Внешние ключи, constraint-ограничения, может быть триггеры. Однако, когда тысячу раз в секунду приходит запрос, который максимально быстро должен сделать выборку из базы данных и на основе ее построить ответ, тут нужно искать компромисс.
если объект можно рассмотреть не как данные, а как метаданные, то его можно положить в поле json и избавиться от одного join;
если объект, который может повлиять на response, лежит в базе данных другого сервиса и он редко обновляется, то имеет смысл его дублировать в базе данных этого сервиса и синхронизировать при необходимости.
[✔] я денормализировал то, что можно и ввел избыточность там, где необходимо
Партиционирование
В работе с большим объемом данных очень важно, чтобы БД не делала лишних чтений, кроме того, запросы на партиционированных таблицах легко параллелятся ядром базы данных ну и операция delete - одна из самых тяжелых операций для БД становится самой легкой, если вы делаете простой truncate партиции. Если ваши данные time-series, как, например, новостная лента или записи в журнал, то львиная доля выборки будет приходиться на последние добавленные записи. В этом случае напрашивается партиционирование данных с ключом по дате создания.
[✔] продумано партиционирование таблиц, хранящих большие объемы данных, требующие частого чтения
Шардирование
С большим объемом данных так же поможет справиться шардирование. При этом ключевую роль будет играть не только хорошо продуманный ключ шардирования, но и механизм, реализующий шардирование. Из хороших практик могу предложить:
виртуальное шардирование - когда на самом первом уровне (близком к обработке реквеста) все поле данных разбито на максимальное количество шардов, например, на 840, а на уровне ниже реализован маппинг этих "логических" шардов на реальные физические серверы. В этом случае, когда один из логических шардов стал перерастать и получать лишнюю нагрузку - его легко перенести на другой сервер и просто изменить маппинг;
центральный диспетчер - весь механизм находится под управлением диспетчера, он знает, что и где лежит и выполняет роутинг на нужный шард (или проксирует), это самый гибкий механизм, который позволяет не только переносить данные между серверами, но и легко изменять ключ шардирования, однако при этом является единой точкой отказа.
[✔] большие данные распилены на шарды там, где это обосновано
Репликация и High Availability
Если операций записей в несколько раз меньше, чем операций чтения, можно продумать вариант репликаций баз данных, при этом реплики должны находиться не только на разных серверах, но и желательно размещение реплик в разных дата-центрах. Точно так же, как размещать статику в CDN, разумно размещать реплики базы данных (и даже инстансы приложения) в разных географических зонах, для уменьшения latency при передаче данных, при этом отправляя пишущие запросы в мастер-реплику, а читающие запросы в географически близкую slave-реплику.
[✔] географически-распределенная репликация настроена
Соединения к БД и баунсеры
Большое количество коннектов к базе данных - это потенциальная проблема. Я не имею в виду, что приложение может само по себе открыть 100 и более коннектов - это не обязательно. Тут может сыграть роль масштабирование сервиса. Представим, что наше приложение требует всего 4 коннекта к базе данных и работает в пяти экземплярах, обрабатывая 1000 RPS, но вот пошла DDoS атака и автоскейлер увеличил количество экземпляров до 100 - и теперь у нас уже 400 коннектов - база лежит. Для таких случаев существуют приложения-bouncer - они ставятся на стороне сервера и/или на стороне клиента и позволяют держать со стороны приложения много соединений при этом со стороны сервера остается мало соединений. Да, они накладывают некоторый оверхед, но это разумная плата за стабильность инфрастуктуры.
[✔] количество коннектов со стороны БД уменьшено
Код
Минимизация блокировок
Представим себе, что есть какое то общее место в памяти приложения, где для реализации конкурентности кусок кода обернут в критическую секцию. Понятное дело при этом реквесты выстраиваются в какую то неформальную очередь, а проще сказать, кто успел, тот и захватил критическую секцию, а остальные ждут ее освобождения. Хорошо, если потоков у вас 100 или 1000, но если их во много раз больше?
Самая удачная логика - это логика которая не требует блокировок, ее можно построить на каких нибудь каналах или пайпах, она будет отрабатываться в одном потоке, как если бы это выполнялось в критической секции. Если производительности не хватает, можно подумать о том, чтобы запустить два-три таких потока по принципу конвейера с согласованием где-то по окончанию обработки некоторого множества элементов.
[✔] при обработке конкурентных запросов с высоким RPS блокировки не используются
Короткие транзакции
Если про блокировки понятно, то про короткие транзакции будет понятно тоже. При обработке запросов с высокой RPS желательно использование нересурсоемкой логики, если у нас есть длинные транзакции, состоящие из нескольких пишущих запросов к БД, особенно, если в процессе выполнения используется несколько баз данных и тем более, если это все напоминает двухфазный коммит, то тут что-то не так и это явно не будет обладать высокой производительностью. Убедитесь, что вы разделили в своей архитектуре OLAP и OLTP, хорошо продумали архитектуру БД, и может быть рассмотрели вариант переместить в процедуру уровня SQL логику, состоящую из двух (или более) SQL запросов, обернутых в транзакцию на уровне приложения.
[✔] мои транзакции в БД короткие и легкие
Скажи нет ORM
Если у вас уже работает система обработки запросов с высоким RPS и вы при этом используете какой то ORM, то хорошо, если вы не испытываете небольшое чувство неловкости от того, что ORM строит плохо оптимизированные запросы, например выбирает все столбцы таблицы, вместо тех, которые действительно нужны, или строит вложенные запросы по какой то логике не использующей индексы. Что я хочу сказать?
Проектируя систему с учетом бизнес требований, низкой latency, с высокой пропускной способностью, мы стараемся построить базу данных так, чтобы можно было за минимальное кол-во чтений найти нужные данные, в каких то местах мы денормализируем данные, где то добавляем избыточности, и после всех этих осмысленных и тонких тюнингов архитектуры, использование ORM который фактически нужен только, чтобы не писать много кода при использовании БД, - это... я не знаю, это как докатить мяч до ворот и так и не забить гол.
[✔] я не использую ОРМ или использую самописный, за производительность которого отвечаю сам
Сериализация данных
Самописным сериализаторам и использованию бинарных протоколов ДА.
В системах с высоким RPS существенное время (это если проингегрировать его за какой то существенный промежуток времени) отнимает процесс сериализации/десериализации данных, не говоря уже об использовании памяти. Все крупные компании уже написали собственную реализацию сериализаторов, можно легко воспользоваться их опенсорсными разработками или написать свое. А еще лучше использовать бинарные протоколы, если ваш API не публичный или если вы поставляете библиотеки для работы с вашим API для популярных языков программирования.
[✔] сериализация минимизирована, затраты по памяти и ЦПУ оптимизированы в процессе кодирования/декодирования данных
Низкоуровневый тюнинг
Выравнивание полей в структурах, попадание в CPU кэш, снижение частоты alloc, free и UDP вместо TCP. Вся эта тяжелая артиллерия может быть большим запасом для оптимизации, если вычислительные мощности еще справляются, но продолжают быстро расти. А можно сразу писать код так, как будто у разработчика паранойя. Я не хотел сначала добавлять этот пункт, так как он необязательный, но кому-то может пригодиться.
[✔] ага, я прочитал эти страшные слова, давай дальше
Параллельное выполнение
Это не то же самое, что отложенная обработка. Такой подход к оптимизации встречается не часто и имеет место где-то на узловом сервисе, который собирает данные с нескольких сервисов уровнем ниже, чтобы потом собрать из них ответ. Смысл в том, что запросы в этом случае эффективно отправлять всем нижестоящим сервисам одновременно и после получения всех ответов строить ответ. Если есть реквесты со сложной логикой в несколько этапов, которые можно выполнить независимо друг от друга, можно параллелить вычисления непосредственно в коде, а можно выделить их в отдельные микросервисы. Эта оптимизация довольно непроста в реализации и редко встречается ситуация в которой она будет полезна.
[✔] нет запросов, выполнение которых можно параллелить или они есть, но с ними все в порядке
Кеширование
Каждый запрос не обязательно должен выполнять поход в базу данных, разумно применять кеширование в тех случаях, когда изменение данных происходит не регулярно, и это всем понятно. Главные требования к кешированию:
все должно работать без кеширования - т.е. если сервис кеша недоступен или произошел его рестарт, то приложение должно сходить в базу и всего делов;
кеширование используется только для ускорения работы - не нужно накладывать на кеширующий сервис какую то логику - счетчики, передача данных между сервисами или что-то другое;
кешировать следует только данные, а не итоги рендера - если есть данные, которые строят кусочек html для веб-страницы, кешировать следует данные, а не html код.
[✔] данные изменяемые нечасто кешируются правильно
Инфраструктура
Избыточность - горячее включение
В системах с высоким RPS отключение одного экземпляра сервиса, который запущен в двух экземплярах, приведет к двойной нагрузке на второй экземпляр и как следствие может привести к выходу из строя и второго экземпляра. Неплохой подход к обеспечению отказоустойчивости - это избыточность и горячее включение.
Представьте себе, что у нас вместо двух экземпляров постоянно включены три, при этом два из них получают активную нагрузку, а третий просто работает вхолостую. Если один из экземпляров будет выключен, балансировщик начнет лить траффик на этот резервный, который станет считать активным, при этом автоскейлинг запустит новый избыточный экземпляр.
[✔] горячее включение продумано для сервисов с высокой загрузкой
Логирование и профилирование
Такие вещи как opentracing теперь ни для кого не секрет, я надеюсь. В распределенных системах это отличный способ проследить весь роадмап запроса, фиксируя все входы и выходы и тайминги всех процедур на всех этапах обработки. А по графическим представлениям можно легко вычислить узкое место или слабое звено.
[✔] каждый запрос может профилироваться, логи достаточны
Мониторинг
Мониторинг - вещь обязательная. Без мониторинга любая система работает, как в темной кладовке - мы не знаем RPS, delay, количество активных соединений с базами данных и прочее. Это ад. TCP соединения, использование памяти, время ответа на запрос - это все мониторим в обязательном порядке.
[✔] я вижу графики RPS, response time, delays, использование памяти, кол-во блокировок и сетевых соединений и прочие метрики
Самодиагностика
Бесшовное развертывание невозможно без kubectl health-probe. Но об этом легко забыть. Представим себе выполнение RollingUpdate - один экземпляр новой версии сервиса поднимается, балансировщик начинает лить туда траффик, один экземпляр старой версии выключается. Окей, но на то, чтобы приложению закончить инициализацию, нужно некоторое время - инициализация всех коннектов, открытие портов, получение параметров и прочее.
Откуда kubectl узнает, что ваше приложение уже готово получать траффик? Без health-probe ваше приложение считается готовым сразу после старта и, как следствие, будет получать траффик. Кроме того это позволит кластеру понимать, когда ваш сервис вышел из строя или перегружен.
[✔] health-probe есть на всех сервисах
HTTP сессии и TCP
HTTP сессии и вообще все TCP соединения необходимо максимально переиспользовать. Это связано с WAIT_TIME состоянием сокетов в большей части и оверхедом на создение TCP соединений в меньшей. Для передачи данных между балансировщиком и сервисом (или между сервисами) необходимо включать KeepAlive и держать соединение (хотя бы минуту). Если есть другие TCP сессии, которые часто создаются/удаляются, нужно провести их мониторинг и рассмотреть возможность растянуть их сессии во времени.
Этот пункт мог бы попасть в подгруппу "КОД", но проблемы, которые создает решаемая в нем проблема, относятся скорее к инфраструктуре, чем к производительности кода.
[✔] сессии TCP проанализированы и максимально утилизируются
Заключение
Некоторые из этих пунктов нужно закладывать в архитектуру проекта с самого начала, некоторые могут быть выполнены уже в работающем проекте. Есть такие, которые возникают даже при относительно невысокой нагрузке. Именно неоднородность и неочевидность некоторых из этих пунктов навели меня на мысль о создании этой статьи. Я подумал, неплохо бы было иметь чек-лист для подобных систем. Но в процессе составления понял, что одними пунктами не обойтись - некоторые пункты требуют хотя бы краткого пояснения. Ну а если требуют некоторые - пришлось сделать пояснения для всех.
Комментарии (12)
DuD
13.10.2021 03:40+1Да, про логгирование есть, но я бы добавил прям отдельным пунктом:
Все без исключения запросы к внешним системам должны быть залоггированы (включая request/responce body на уровне DEBUG) и замониторены (rps, p75,p95,p99, error rate).
Это еще на этапе разработки сохранит десятки часов дебага и традиционного упражнения "покажите наши логи".
И еще бы наверное стоило добавить пункт:
Мое приложение пишет логи в json(или ином сериализованном формате) и их(логи) не приходится парсить регулярками чтобы нормально проанализировать.
devalio Автор
13.10.2021 05:46Спасибо за дополнения. А подскажите, куда сохраняете request/response?
mark_ablov
13.10.2021 07:01+1В elasticsearch на пару дней. Обычно этого достаточно чтобы понять проблему.
DuD
13.10.2021 11:50У нас есть Interceptor через который централизованно проходят все запросы. Он в свою очередь пишет все это просто в лог. На самом деле даже разбивать на отдельные поля в ELK не приходится. Хватает того что body запроса просто есть в поле message.
Из важных нюансов еще, важно разбивать любой запрос на 2 сообщения. Одно про request, второе про response, потому что response по итогу может и вовсе не случиться, зато будет request с которым можно будет ходить к коллегам и спрашивать, что же в нем не так и почему оно вызывает ошибку.
На проде мы такого не гоняем, а на деве нет такой нагрузки чтобы весь этот механизм хоть сколько нибудь сильно влиял на производительность.
Lexer11l
14.10.2021 17:32Спасибо! Отличный гайд, добавил в закладки
После нынешнего довольно небольшого проекта, читая, оглядываюсь назад и вспоминаю многие решения принятые на одном из прошлых хайлоад проектов. В части кода в особенности, заморочки с избыточностью, сериализацией, бинарными данными и ORM.
TyVik
10.11.2021 09:44+1Побуду адвокатом дьявола. Некоторые пункты nice to have - ваш высоконагруженный проект вполне может без них обойтись. Рассмотрим, к примеру, GitHub. Это один монолит на ruby: никаких микросервисов + использование ORM. И ничего, живёт, развивается и даже не сильно тормозит.
В моей практике highload всегда приходил внезапно: вслед за анонсом новой фичи, удачным твитом и т.п. И конечно же никакого партицирования или шардирования не было. Если закладывать их на начальном этапе, то поддерживать разработку будет дороже. Так что я б сказал что этот список хорош для демонстрации что ещё можно улучшить и настроить чтобы было легче жить.
devalio Автор
15.11.2021 15:52Дельный комментарий, спасибо
Но я тоже немного парирую: во многих чек-пунктах у меня структура такая: "[ ] я сделал {то-то то-то} или {я знаю, почему могу без этого}". Самое главное тут не выполнить пункт, а знать, что есть определенная идея/техника и что в моем конкретном случае она поможет или будет лишней.
На счет монолита на ruby: вообще не буду связываться - я бы с таким умер от инфаркта, но у некоторых получается, и я снимаю перед ними шляпу.
rjhdby
Неплохая подборка. Я бы ещё circuit breaker добавил
devalio Автор
Ой, вот, чего я забыл. Спасибо