Я встречаю много плохих советов по системному дизайну. Один из классических постов — «держу пари, вы никогда не слышали об очередях „оптимизированный для LinkedIn и предназначенный, по‑видимому, для новичков в этой отрасли. Еще один хитрый трюк, оптимизированный для Twitter: „Вы никудышный инженер, если когда‑либо сохраняли логические значения в базе данных„. Но даже хорошие советы по проектированию системы могут оказаться плохими.“»

Что такое системный дизайн? На мой взгляд, если дизайн программного обеспечения — это то, как вы собираете строки кода, то системный дизайн — это то, как вы собираете сервисы. Примитивами проектирования программного обеспечения являются переменные, функции, классы и так далее. Основными элементами системного проектирования являются серверы приложений, базы данных, кэши, очереди, шины событий, прокси‑серверы и так далее

Признание хорошего дизайна

Как выглядит хороший системный дизайн? Он не всегда вызывает восторг. На практике кажется, что долгое время все шло нормально. Вы можете сказать, что у вас хороший дизайн, если у вас возникнут мысли типа «ха, это оказалось проще, чем я ожидал» или «Мне никогда не приходилось думать об этой части системы, все в порядке„. Парадоксально, но хороший дизайн непримечателен: плохой дизайн часто производит большее впечатление, чем хороший. Я всегда с подозрением отношусь к эффектно выглядящим системам. Если в системе есть механизмы распределенного консенсуса, множество различных форм событийной коммуникации, CQR и другие хитроумные приемы, мне интересно, есть ли какое‑то фундаментально неверное решение, которое компенсируется (или система просто чрезмерно продумана).“»

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

Состояние и его отсутствие

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

Рассмотрим нетривиальный пример, GitHub имеет внутренний API, который принимает PDF‑файл и возвращает его HTML‑рендеринг. Это настоящий сервис без учета состояния. Все, что записывается в базу данных, отслеживается с учетом состояния.

Вы должны попытаться свести к минимуму количество компонентов с отслеживанием состояния в любой системе. (В некотором смысле это очевидно, потому что вы должны попытаться свести к минимуму количество всех компонентов в системе, но компоненты с отслеживанием состояния особенно опасны.) Причина, по которой вам следует это сделать, заключается в том, что компоненты с отслеживанием состояния могут сами прийти в негодное состояние. Наш сервис рендеринга PDF‑файлов без сохранения состояния будет безопасно работать вечно, если вы будете делать в целом разумные вещи: например, запускать его в перезапускаемом контейнере, чтобы, если что‑то пойдет не так, его можно было автоматически отключить и восстановить в рабочем состоянии. Служба с отслеживанием состояния не может быть автоматически восстановлена подобным образом. Если в вашей базе данных обнаружена ошибочная запись (например, запись с форматом, который вызывает сбой в вашем приложении), вам необходимо вручную выполнить ее исправление. Если в вашей базе данных не хватает места, вам нужно найти какой‑то способ сократить количество ненужных данных или расширить их.

На практике это означает наличие одной службы, которая знает о состоянии, то есть взаимодействует с базой данных, и другими службами, которые выполняют операции без учета состояния. Избегайте использования пяти разных служб для записи в одну и ту же таблицу. Вместо этого пусть четыре из них отправляют запросы API (или генерируют события) в первую службу и сохраняют логику записи в этой одной службе. Если возможно, стоит сделать это и для логики чтения, хотя я не столь категоричен в этом вопросе. Иногда службам лучше быстро прочитать таблицу user_sessions, чем выполнять в 2 раза более медленный 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 =?.

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

Отправьте как можно больше запросов на чтение в реплики базы данных. В типичной настройке базы данных будет один узел записи и несколько реплик чтения. Чем меньше у вас будет операций чтения с узла записи, тем лучше — этот узел уже достаточно загружен, выполняя все операции записи. Исключение составляют случаи, когда вы действительно не можете мириться с задержкой репликации (поскольку реплики чтения всегда выполняются по крайней мере на несколько миллисекунд позже узла записи). Но в большинстве случаев задержку репликации можно обойти с помощью простых приемов: например, когда вы обновляете запись, но вам нужно использовать ее сразу после этого, вы можете ввести обновленные данные в память вместо немедленного повторного чтения после записи.

Остерегайтесь резких скачков запросов (особенно запросов на запись и, в частности, транзакций). Когда база данных перегружается, она начинает работать медленно, что делает ее еще более перегруженной. Транзакции и операции записи особенно «хороши» для перегрузки баз данных, поскольку требуют большого объема работы с базой данных для каждого запроса. Если вы разрабатываете сервис, который может генерировать массовые всплески запросов (например, какой‑либо API массового импорта), подумайте о регулировании ваших запросов.

Медленные операции, быстрые операции

Сервис должен выполнять некоторые действия быстро. Если пользователь взаимодействует с чем‑то (скажем, с API или веб‑страницей), он должен увидеть ответ в течение нескольких сотен миллисекунд. Но сервис должен выполнять другие действия, которые выполняются медленно. Некоторые операции просто занимают много времени (например, преобразование очень большого PDF в HTML). Общая схема для этой задачи заключается в том, чтобы выделить минимальный объем работы, необходимый для того, чтобы сделать что‑то полезное для пользователя, и выполнить остальную работу в фоновом режиме. В примере преобразования PDF в HTML вы могли бы сразу преобразовать первую страницу в HTML, а остальные поместить в очередь в фоновом режиме.

Что такое фоновое задание? На этот вопрос стоит ответить подробнее, потому что «фоновые задания» — это базовый элемент проектирования системы. У каждой технологической компании есть какая‑то система для выполнения фоновых заданий. Там будет два основных компонента: набор очередей, например, в Redis, и служба выполнения заданий, которая будет извлекать элементы из очередей и выполнять их. Вы ставите фоновое задание в очередь, помещая в очередь элемент типа {job_name, params}. Также можно запланировать выполнение фоновых заданий в установленное время (что полезно для периодической очистки или сводного анализа). Фоновые задания должны быть вашим первым выбором для медленных операций, потому что они, как правило, являются хорошо проторенным путем.

Иногда вы хотите запустить свою собственную систему очередей. Например, если вы хотите поставить в очередь задание на выполнение через месяц, вам, вероятно, не следует помещать элемент в очередь Redis. Постоянство Redis обычно не гарантируется в течение этого периода времени (и даже если это так, вы, вероятно, захотите иметь возможность запрашивать задания, которые будут поставлены в очередь в отдаленном будущем, таким образом, что это было бы сложно использовать с очередью заданий Redis). В этом случае я обычно создаю таблицу для нужной операции со столбцами для каждого параметра плюс столбец scheduled_at. Затем я использую ежедневное задание, чтобы проверить наличие этих элементов с помощью scheduled_at <= сегодня, и либо удалить их, либо пометить как завершенные после завершения задания.

Кэширование

Иногда операция выполняется медленно, потому что требуется выполнить дорогостоящую (то есть медленную) задачу, которая одинакова для всех пользователей. Например, если вы рассчитываете, сколько брать денег с пользователя за использование ресурсов, вам может потребоваться выполнить вызов API для просмотра текущих цен. Если вы взимаете плату с пользователей за использование (как это делает OpenAI за токен), это может (а) быть неприемлемо медленным и (б) привести к большому трафику для любого сервиса, обслуживающего цены. Классическим решением здесь является кэширование: вы просто просматриваете цены каждые пять минут и сохраняете значение в течение этого времени. Проще всего кэшировать в памяти, но также популярно использование какого‑нибудь быстрого внешнего хранилища ключей и значений, такого как Redis или Memcached (поскольку это означает, что вы можете совместно использовать один кэш на нескольких серверах приложений).

Типичная картина заключается в том, что младшие инженеры узнают о кэшировании и хотят кэшировать все, в то время как старшие инженеры хотят кэшировать как можно меньше. Почему это так?“ Это сводится к первому замечанию, которое я высказал об опасности сохранения состояния. Кэш — это источник состояния. В него могут попадать странные данные, или они могут не соответствовать действительности, или вызывать загадочные ошибки, предоставляя устаревшие данные, и так далее. Никогда не следует кэшировать что‑либо, не приложив сначала серьезных усилий для ускорения работы. Например, глупо кэшировать дорогостоящий SQL‑запрос, который не защищен индексом базы данных. Вам следует просто добавить индекс базы данных!

Я часто использую кэширование. Один из полезных приемов кэширования, который есть в наборе инструментов, — это использование запланированного задания и хранилища документов, такого как S3 или хранилище больших двоичных объектов Azure, в качестве крупномасштабного постоянного кэша. Если вам нужно кэшировать результат действительно дорогостоящей операции (скажем, еженедельный отчет об использовании для крупного клиента), возможно, вы не сможете поместить результат в Redis или Memcached. Вместо этого сохраните большой двоичный файл результатов с отметкой времени в хранилище документов и загружайте файл непосредственно оттуда. Как и долгосрочная очередь с поддержкой базы данных, о которой я упоминал выше, это пример использования идеи кэширования без специальной технологии кэширования.

События

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

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

Push и Pull

Когда вам нужно, чтобы данные передавались из одного места во множество других, есть два варианта. Самый простой — извлекать. Именно так работает большинство веб‑сайтов: у вас есть сервер, которому принадлежат некоторые данные, и когда пользователь этого хочет, он отправляет запрос (через свой браузер) на сервер, чтобы получить эти данные. Проблема здесь в том, что пользователи могут часто извлекать одни и те же данные — например, обновлять свой почтовый ящик, чтобы узнать, есть ли у них новые электронные письма, что приведет к удалению и перезагрузке всего веб‑приложения, а не только данных об электронных письмах.

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

Если мы говорим о фоновых службах, а не о пользователях с веб‑браузерами, то легко понять, почему перенос данных может быть хорошей идеей. Даже в очень большой системе может быть всего около сотни служб, которым требуются одни и те же данные. Для данных, которые не сильно меняются, гораздо проще выполнять сотню HTTP‑запросов (или RPC, или что‑то еще) всякий раз, когда данные меняются, чем обрабатывать одни и те же данные тысячу раз в секунду.

Предположим, вам действительно нужно предоставлять обновленные данные миллиону клиентов (как это делает GMail). Должны ли эти клиенты быть активными или нет? Это зависит. В любом случае, вы не сможете запустить все это с одного сервера, поэтому вам нужно будет подключить его к другим компонентам системы. Если вы выполняете push‑запросы, это, скорее всего, означает, что каждый push‑запрос помещается в очередь событий и множество обработчиков событий извлекают каждый из них из очереди и отправляют ваши push‑запросы. Если вы будете использовать pull, это будет означать установку нескольких (скажем, сотни) быстрых серверов кэширования с репликами чтения, которые будут располагаться перед вашим основным приложением и обрабатывать весь трафик.

Заключительные мысли

Главное, что я пытаюсь донести, — это то, что я сказал в начале этого поста: хороший системный дизайн — это не хитроумные уловки, а знание того, как использовать скучные, хорошо протестированные компоненты в нужном месте. Особенно в крупных технологических компаниях, где эти компоненты уже существуют в готовом виде (например, в вашей компании уже есть какая‑то шина событий, служба кэширования и так далее), Хороший дизайн системы будет выглядеть как ничто иное. Существует очень, очень мало областей, в которых требуется системный дизайн, о котором можно было бы рассказать на конференции. Они существуют! Я видел, как созданные вручную структуры данных позволяют создавать функции, которые в противном случае были бы невозможны. Но я видел, как это происходило всего раз или два за десять лет. Я каждый божий день сталкиваюсь со скучным системным дизайном работающих приложений.


Хороший системный дизайн редко бросается в глаза — но именно он отличает зрелого инженера от просто опытного. Если хочется не только понимать, как устроены масштабируемые системы, но и уметь их проектировать, — курс System Design в OTUS даст структуру, практику и реальные архитектурные кейсы уровня крупных IT-компаний.

Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:

  • 11 ноября: Влияние нефункциональных требований на архитектуру. Регистрация

  • 17 ноября: «Основные шаблоны проектирования в системном дизайне». Регистрация

Комментарии (0)