Я часто вижу плохие советы по системному дизайну. Классический пример - посты в стиле «держу пари, вы никогда не слышали про очереди», заточенные под LinkedIn и рассчитанные, видимо, на новичков в индустрии. Другой пример - твиттерные «лайфхаки» в духе «вы ужасный инженер, если храните булевы значения в базе данных»1. Даже хорошие советы по системному дизайну иногда оказываются не такими уж хорошими. Я обожаю книгу Designing Data-Intensive Applications, но, по правде говоря, она не особенно полезна для большинства практических задач, с которыми сталкиваются инженеры.
Что такое системный дизайн? На мой взгляд, если дизайн программного обеспечения - это про то, как собирать строки кода, то системный дизайн - это про то, как собирать сервисы. Базовые строительные блоки софта - переменные, функции, классы и так далее. Базовые строительные блоки системного дизайна - это серверы приложений, базы данных, кэши, очереди, шины событий, прокси и прочее.
Этот текст - моя попытка в общих чертах изложить всё, что я знаю о хорошем системном дизайне. Многие конкретные решения приходят только с опытом, и этим опытом я не могу поделиться напрямую. Но я хочу зафиксировать хотя бы то, что можно сформулировать словами.
Признаки хорошего дизайна
Как выглядит хороший системный дизайн? Ранее я писал, что он выглядит, в первую очередь, невпечатляюще. На практике это похоже на ситуацию, когда долгое время просто ничего не ломается. Признак того, что перед вами хороший дизайн - мысли вроде: «Хм, оказалось проще, чем я думал» или «Я вообще никогда не думаю про этот кусок системы, он просто работает». Парадоксальным образом, хороший дизайн как бы самоустраняется: плохой дизайн часто выглядит куда эффектнее. Поэтому я всегда настороженно отношусь к системам, которые выглядят чересчур круто. Если я вижу в системе механизмы распределённого консенсуса, множество форм событийного взаимодействия, CQRS и прочие хитрые приёмы, мне сразу закрадывается мысль: «А не пытаются ли тут прикрыть какой-то изначально неправильный выбор?» (или система просто банально переусложнена).
Здесь я часто оказываюсь в меньшинстве. Инженеры смотрят на сложные системы с кучей интересных частей и думают: «Вау, вот это системный дизайн!». На самом деле, сложная система обычно говорит об отсутствии хорошего дизайна. Я говорю «обычно», потому что сложность иногда действительно оправдана. Я работал над системами, которые свою сложность заслужили. Но при этом любая работающая сложная система всегда эволюционирует из работающей простой. Начинать с нуля сразу со сложной системы - очень плохая идея.
Состояние и отсутствие состояния
Самое трудное в проектировании программных систем - это состояние. Если вы храните хоть какую-то информацию хотя бы какое-то время, вам приходится принимать массу непростых решений о том, как её сохранять, хранить и выдавать. Если же вы вообще ничего не храните, то ваше приложение считается «не храняющим состояние» (stateless)2. Нетривиальный пример: у GitHub есть внутренний API, который принимает PDF-файл и возвращает его HTML-представление. Это настоящий stateless-сервис. Всё, что пишет данные в базу, - уже сервис с состоянием.
В любой системе стоит стремиться к минимуму компонентов с состоянием. (В каком-то смысле это тривиально, ведь вообще количество компонентов лучше минимизировать. Но именно такие компоненты особенно опасны.) Причина проста: компоненты с состоянием могут оказаться в плохом состоянии. Наш stateless-сервис рендеринга PDF будет работать вечно, если делать вещи хотя бы в общих чертах разумно, - например, мы используем его в перезапускаемом контейнере так, чтобы при сбое его можно было автоматически убить и восстановить в рабочем виде. Stateful-сервис так «сам» не чинится. Если в вашей базе появится кривой объект (например, запись с форматом, который приводит к остановке приложения), вам придётся руками лезть и исправлять его. Если база переполнится, придётся придумывать, как чистить ненужные данные или расширять хранилище.
На практике это означает, что в системе должен быть один сервис, который работает с состоянием - то есть взаимодействует с базой данных, - а остальные сервисы выполняют stateless-логику. Избегайте ситуации, когда пять разных сервисов одновременно пишут в одну и ту же таблицу. Лучше пусть четыре из них шлют API-запросы (или генерируют события) в первый сервис, а уже он отвечает за запись. Если получится, стоит поступать так же и с чтением. Хотя здесь я менее категоричен. Иногда лучше, чтобы сервис напрямую сделал быстрый SELECT из таблицы user_sessions
, чем сделал в два раза медленнее HTTP-запрос к внутреннему сервису сессий.
Базы данных
Поскольку управление состоянием - это самая важная часть системного дизайна, то и ключевой компонент почти всегда тот, где это состояние живёт: база данных. Большую часть времени я работал с SQL-базами (MySQL и PostgreSQL), поэтому говорить буду именно о них.
Схемы и индексы
Если нужно что-то хранить в базе, первым делом определите таблицу с нужной вам схемой. Схема должна быть достаточно гибкой, потому что, когда у вас накапливаются тысячи или миллионы записей, менять её становится крайне болезненно. Но если сделать её слишком гибкой (например, складывать всё в один JSON-столбец value
или городить таблицы вида keys
и values
для произвольных данных), вы перекладываете массу сложности в код приложения (и почти наверняка получаете очень неприятные ограничения по производительности). Где провести грань - вопрос вкуса и контекста, но в целом я стараюсь, чтобы таблицы были читаемы человеком: можно визуально пройтись по схеме базы и понять, какие данные приложение хранит и зачем.
Если предполагается, что таблица вырастет больше пары строк, на ней должны быть индексы. Причём индексы стоит строить под самые частые запросы (например, если вы ищете по email
и type
, делайте индекс именно по этим полям). Индекс работает как вложенный словарь, так что поля с высокой кардинальностью лучше ставить первыми (иначе каждый поиск по индексу будет сканировать всех пользователей по type
, чтобы найти того, у кого нужный email
). При этом не надо индексировать всё подряд: каждый индекс добавляет накладные расходы на запись.
Узкие места
Доступ к базе данных очень часто становится бутылочным горлышком в высоконагруженных приложениях. И это верно даже тогда, когда вычислительная часть относительно неэффективна (например, Ruby on Rails на сервере типа Unicorn). Причина проста: сложные приложения делают слишком много вызовов к базе - сотни на каждый запрос, причём часто последовательно. Как этого избежать?
Когда обращаетесь к базе данных, пусть база сама делает работу. Почти всегда эффективнее поручить базе объединение данных, чем вытягивать их кусками и собирать в памяти. Нужны данные из нескольких таблиц - используйте JOIN
, а не десятки отдельных запросов с ручным склеиванием. Особенно внимательно следите за ORM: очень легко случайно вложить запрос внутрь цикла. Так незаметно превращается простой select id, name from table
в select id from table
и сотню запросов вида select name from table where id = ?
.
Иногда, всё же, запросы полезно разбивать. Редко, но бывает, что запрос настолько неуклюж, что базе проще выполнить два отдельных, чем один монструозный. Теоретически, всегда можно построить такие индексы и подсказки, чтобы база справилась сама. Но иметь в арсенале тактическое разделение запроса - полезный инструмент.
Максимум запросов-чтений отправляйте на базы-реплики. Типовая схема БД: один узел для записи и парочка реплик для запросов-чтений. Чем меньше нагрузка на основной write-узел, тем лучше - он и так занят. Исключение: если вам абсолютно критично отсутствие репликационного лага (реплики всегда отстают хотя бы на несколько миллисекунд). В большинстве случаев это обходится простыми трюками: например, если вы обновили запись и тут же хотите её использовать, можно заполнить свежие данные в памяти, не перечитывая их обратно из базы.
Думайте о вероятных всплесках запросов (особенно на запись и особенно с транзакциями). Когда база перегружена, она замедляется, а замедление её ещё сильнее перегружает. Транзакции и массовые записи особенно опасны: каждая требует от базы больших усилий. Если ваш сервис может генерировать такие пики (например, API для массового импорта), предусмотрите механизм троттлинга запросов.
Медленные операции, быстрые операции
Сервис обязан делать часть вещей быстро. Если пользователь взаимодействует с чем-то (например, с API или веб-страницей), он должен получить ответ в пределах нескольких сотен миллисекунд3. Но сервис также обязан выполнять и медленные вещи. Некоторые операции просто занимают много времени (скажем, преобразование огромного PDF в HTML). Общий паттерн здесь такой: выделить минимум работы, необходимый для того, чтобы пользователь получил пользу прямо сейчас, а остальное отдать в фон. В примере с PDF это может быть рендеринг первой страницы в HTML сразу, а остальное - через задачу в фоне.
Что такое фоновая задача (background job)? Стоит объяснить подробно, потому что фоновая задача - это один из базовых примитивов системного дизайна. В любой сложной системе есть какая-то возможность для их запуска. Обычно она состоит из двух частей: набор очередей (например, в Redis), и сервис-воркер, который забирает задания из очереди и исполняет их. Чтобы поставить задачу в фон, вы кладёте в очередь элемент вида {job_name, params}. Также можно планировать задачи на определённое время (удобно для периодических чисток или агрегирования отчётов). Фоновые задачи должны быть первым выбором для медленных операций, потому что этот путь хорошо протоптан и стабилен.
Иногда хочется сделать свою систему очередей. Например, если задачу нужно исполнить через месяц, класть её в Redis не стоит. Redis не гарантирует хранение такой давности. В таких случаях я обычно создаю таблицу в базе для отложенных операций с колонками для параметров и scheduled_at
. Затем раз в день запускаю процесс, который ищет записи с scheduled_at <= today
и либо удаляет их, либо помечает как завершённые после выполнения.
Кэширование
Иногда операция медленная не потому, что сама по себе сложная, а потому, что ей приходится каждый раз делать дорогую (и одинаковую для всех запросов) работу. Например, в биллинговом сервисе при расчёте стоимости нужно сходить в API за актуальными ценами. Если вы берёте оплату за использование (как OpenAI считает токены), это может быть (а) слишком медленно и (б) большой нагрузкой на сервис цен. Классическое решение - кэширование: обращаться за ценами, скажем, раз в 5 минут и хранить результат между вызовами. Проще всего кэшировать в памяти, но популярны и быстрые внешние key-value-хранилища вроде Redis или Memcached (так один кэш можно разделять между несколькими приложениями).
Обычно ситуация следующая: джуниоры, узнав о кэшировании, хотят кэшировать всё подряд; синьоры, наоборот, стараются кэшировать как можно меньше. Почему? Всё снова упирается в проблему состояния. Кэш - это тоже источник состояния. Он может хранить битые данные, рассинхронизироваться с «истиной», или давать странные баги из-за устаревших значений. Поэтому не стоит кэшировать, пока вы не попробовали ускорить операцию другими способами. Например, глупо кэшировать тяжёлый SQL-запрос, для которого просто не хватает индекса. Добавьте индекс - и не придётся кэшировать!
При этом кэшированием я пользуюсь часто. Один полезный трюк: использовать плановую задачу и хранилище документов (S3, Azure Blob и т.п.) как большой долговременный кэш. Если результат операции слишком тяжёлый (например, недельный отчёт по использованию для крупного клиента), он может не поместиться в Redis или Memcached. Тогда можно сохранить его в виде блоба с таймстемпом в S3 и отдавать напрямую оттуда. Как и в случае с базой для отложенных очередей, это пример того, как можно использовать идею кэширования, не применяя специальные кэширующие технологии.
События
Помимо кэширующей инфраструктуры и системы фоновых задач, почти в каждой сложной системе есть шина событий (event hub). Чаще всего это Kafka. По сути, шина событий - это та же очередь, что и для фоновых задач, но в ней лежит не «выполни эту задачу с такими параметрами», а «произошло вот это событие». Классический пример: при создании нового аккаунта публикуется событие new_account_created
, а дальше его читают разные сервисы и делают свою работу: сервис «отправить приветственное письмо», сервис «проверить на абузу», сервис «поднять инфраструктуру для аккаунта» и т. д.
Использовать события чрезмерно не стоит. Во многих случаях лучше, чтобы один сервис напрямую сходил к другому по API: все логи в одном месте, проще рассуждать, сразу видно ответ. События хороши там, где отправителю неважно, что именно потребители будут делать с событием, или когда событий много и они не особо чувствительны ко времени (например, сканирование на абьюз каждого нового твита).
Push и Pull
Когда данные нужно распределять из одного места во многие другие, есть два варианта. Самый простой - запрос (pull). Так работает большинство сайтов: есть сервер, владеющий данными, и когда пользователю они нужны, он делает запрос (через браузер), вытягивая их с сервера. Проблема в том, что пользователь может дергать одни и те же данные снова и снова. Например, обновлять почтовый ящик, чтобы проверить новые письма. В итоге браузер снова и снова вытягивает и перерисовывает весь интерфейс, а не только список писем.
Альтернатива - раздача (push). Вместо того чтобы позволять пользователям самим спрашивать данные, вы даёте им зарегистрироваться как клиентам, и когда данные меняются, сервер сам отправляет обновления вниз, к клиентам. Так работает Gmail: вам не нужно обновлять страницу ради новых писем - они появляются сразу при получении.
Если говорить не о пользователях в браузере, а о бэкенд-сервисах, то преимущество push видно ещё сильнее. Даже в очень большой системе обычно всего сотня сервисов, которым нужны одни и те же данные. Если данные редко меняются, проще сто раз отправить их «вниз» по HTTP (или RPC, или чем вы там пользуетесь) при каждом изменении, чем обслуживать тысячу запросов в секунду на чтение этих же данных.
А если вам нужно в реальном времени раздавать данные миллиону клиентов (как Gmail)? Пусть клиенты будут тянуть данные сами или вы будете их пушить - всё равно на одном сервере это не потянет, придётся масштабироваться через другие компоненты. В случае push это обычно значит: класть каждое событие в очередь и запускать группу воркеров, которые будут забирать их из очереди и рассылать клиентам. В случае pull это значит: поднять много (скажем, сотню) быстрых read-replica-кэшей перед основным приложением4 и переложить на них весь трафик чтения5.
Горячие пути
При проектировании системы есть масса способов, как пользователи могут с ней взаимодействовать и как через неё будут проходить данные. Легко перегрузиться от всех возможных сценариев. Хитрость в том, чтобы сосредоточиться на «горячих путях» (hot paths) - тех частях системы, которые наиболее критичны и которые будут обрабатывать больше всего данных. Например, в биллинговой системе с оплатой «по счётчику» горячими путями будут: та часть, которая решает, списывать ли деньги с клиента, и та часть, которая цепляется ко всем действиям пользователя на платформе, чтобы понять, сколько списывать.
Почему hot paths так важны? У них намного меньше «рабочих» вариантов реализации. Страницу с настройками биллинга можно сделать тысячью способов, и почти все будут более-менее приемлемыми. А вот обрабатывать огромный поток пользовательских действий можно только ограниченным числом адекватных способов. Плюс ошибки на горячих путях куда разрушительнее. Надо очень постараться, чтобы страница настроек положила весь продукт, но любая ошибка в коде, который вызывается на каждое действие пользователя, способна устроить серьёзные проблемы.
Логирование и метрики
Как понять, что в системе проблемы? Один урок, который я вынес от самых параноидальных коллег: логируйте агрессивно на путях, обрабатывающих ошибки. Если вы пишете функцию, которая проверяет кучу условий и решает, отдавать ли пользователю ошибку 422
, логируйте, какое именно условие сработало. Если пишете биллинговый код, логируйте каждое принятое решение (например: «не списываем за это событие, потому что X»). Многие инженеры этого не делают: кажется, что лишний бойлерплейт портит красивый код. Но делать всё равно стоит - потом спасибо скажете себе же, когда крупный клиент придёт жаловаться, что видит 422
. Даже если виноват сам клиент, вам всё равно придётся разобраться и объяснить, что именно он сделал не так.
Также у вас должно быть общее наблюдение за операционными характеристиками системы. Это загрузка CPU и оперативной памяти на хостах или в контейнерах, размеры очередей, среднее время на запрос или задачу и т. п. Для метрик, которые касаются пользователей (например, время обработки запроса), нужно смотреть не только среднее, но и p95/p99 (т. е. самые медленные 5% и 1% запросов). Даже один-два очень медленных запроса - это тревожный сигнал, потому что чаще всего они приходятся на ваших крупнейших и важнейших клиентов. Если смотреть только на среднее, легко пропустить ситуацию, когда для части пользователей сервис фактически становится непригодным.
Системный рубильник (killswitch), ретраи и отказоустойчивость
Я уже писал целый отдельный пост про системный рубильник (killswitch), поэтому не буду повторяться, а лишь напомню суть: нужно заранее продумывать, что произойдёт, если система начнёт падать всерьёз.
Ретраи - не волшебная таблетка. Важно убедиться, что вы не создаёте лишнюю нагрузку на другие сервисы, слепо повторяя неудачные запросы. Если есть возможность, оборачивайте частые API-вызовы в предохранитель (circuit breaker): если подряд приходит слишком много ответов 5xx
, перестаньте слать запросы на время, чтобы сервис успел восстановиться. Также нужно быть осторожным с повтором записывающих событий, которые могли как пройти, так и не пройти. Например, если вы отправили запрос «списать деньги с пользователя» и получили 5xx
, вы не знаете - списание уже произошло или нет. Классическое решение - добавлять ключ идемпотентности (idempotency key): специальный UUID, который сервис сохраняет. Если приходит повторный запрос с тем же ключом, он просто игнорируется, не выполняя операцию повторно.
Ещё важно решить, что делать, если часть системы недоступна. Допустим, у вас есть rate limiting, который проверяет в Redis, не превысил ли пользователь лимит. Redis внезапно лег. Что теперь? У вас два варианта: аварийно открыть сервис и пропустить запрос; или аварийно закрыть сервис и заблокировать запрос с ошибкой 429
.
Правильный выбор зависит от контекста. На мой взгляд, rate limiting почти всегда должен аварийно открытым - так проблемы в коде ограничения запросов не превращаются в глобальный пользовательский инцидент. А вот авторизация должна всегда аварийно закрываться - лучше отказать пользователю в доступе к своим данным, чем случайно дать доступ к чужим. Есть много пограничных случаев, где решение неочевидно, и это почти всегда неприятный компромисс.
Финальные мысли
Есть темы, которые я намеренно не затрагиваю. Например: когда распиливать монолит на микросервисы, когда использовать контейнеры или виртуалки, как делать трассировку, как проектировать API. Отчасти потому, что это не так важно (по моему опыту, монолиты вполне ок), отчасти потому, что слишком очевидно (трассировку использовать надо), а отчасти просто из-за нехватки времени (API-дизайн - штука сложная).
Главная мысль этой статьи та же, что и в начале: хороший системный дизайн - это не про хитрые трюки, а про умение правильно использовать скучные, проверенные временем компоненты. Я не сантехник, но подозреваю, что с водопроводом та же история: если вы делаете что-то слишком интересное, скорее всего, скоро окажетесь по уши в воде.
Особенно в больших компаниях, где почти всё уже есть «с полки» (своя шина событий, свой сервис кэша и так далее), хороший системный дизайн будет выглядеть как… скукота. Очень, очень мало областей, где стоит заниматься таким дизайном, который потом можно выносить в доклады на конференциях. Хотя они есть! Я видел, как самописные структуры данных открывали возможности, которых иначе бы просто не было. Но за десять лет я наблюдал это пару раз. Зато скучный системный дизайн я вижу каждый день.
1 Правильнее хранить временные метки и трактовать их наличие как «true». Я иногда так делаю, но не всегда - на мой взгляд, в том, чтобы схема базы данных была сразу читаемой, тоже есть своя ценность.
2 Технически любой сервис хранит какие-то данные хотя бы в оперативной памяти. Обычно же под этим подразумевается хранение информации вне цикла запрос–ответ (например, на диске, в базе данных). Если вы можете развернуть новую версию приложения просто запустив сервер приложений, то это сервис без состояния (stateless app).
3 Разработчики игр в Twitter любят говорить, что всё, что работает дольше 10 мс, недопустимо. Но, независимо от того, должно ли это быть так, фактически это не соответствует реальности: пользователи вполне принимают более медленные отклики, если приложение делает для них что-то действительно полезное.
4 Они быстрые, потому что им не нужно обращаться к базе данных так, как это делает основной сервер. Теоретически это может быть просто статический файл на диске, который отдаётся по запросу, или даже данные, хранящиеся в памяти.
5 Кстати, такие кэширующие серверы либо опрашивают ваш основной сервер (т.е. pulling), либо основной сервер сам отправляет им новые данные (т.е. pushing). Я не думаю, что здесь принципиально важно, какой вариант выбрать: push даёт более актуальные данные, но pull проще в реализации.
MrCooger
В индексах про хранение и удаление не упомянуто, тоже нужно учитывать, или это в запись входит? Я бы это только как insert/update бы интерпретировал