Короткий ответ
Архитектура держится на надёжности, масштабируемости и сопровождаемости, но максимизировать все три одновременно нельзя. Требования задают бюджет сложности: инженер осознанно решает, где платить инфраструктурой, где принимать ограничение масштаба, а где допускать деградацию.2. System design держится на трёх столпах
Допустим, ты успешно собрал требования, был посланы куда-нибудь пару раз, выбил из бизнеса конкретику и понял, что система должна делать и как себя вести. Теперь у тебя есть "документ" с цифрами, SLA, ограничениями, функциональными и нефункциональными требованиями.
Отлично. И вот ты смотришь на этот список, список длинный, много буков и цифров. Что дальше? Как из этого "документа" получить архитектуру?
Для начала нужно налить себе чего-нибудь, например, кофе. Закрыться у себя в кабинете, чтобы тебе никто не мог помешать. А дальше придётся вспомнить много теории и начать думать. Знаю, звучит сложно, особенно про думать, но придётся с этим как-то жить. По-другому никак.
А вспоминать лучше всего начать с трёх столпов любой архитектуры: надёжности, масштабируемости и сопровождаемости. И все три столпа это компромиссы. Каждый из них конфликтует с остальными. Улучшаешь один и ты убиваешь другие.
Не понимая этой базы, любая проектируемая система будет строиться заведомо с меньшими шансами на выживание в реальном мире. С каждым шагом ты будешь желать всё сжечь и уволиться всё больше и больше.
Поэтому предлагаю тебе устроиться поудобнее и почитать про базу system design.
Что там за столпы такие?
Начну сразу с ремарки: надёжность, масштабируемость и сопровождаемость это не красивые слова из ролика на ютубе или презентации. Это конкретные инженерные свойства системы, их можно измерить, потрогать, пощупать.
Надёжность — это способность системы работать в условиях, когда вот-вот всё упадёт и когда уже часть упала. Не "не падать никогда", а "падать предсказуемо и восстанавливаться быстро". Предсказуемая деградация функциональности. Какой таргет по доступности? Что происходит при отказе компонента? Как быстро система возвращается к нормальной работе? Можно ли и какие данные терять при сбое?
Масштабируемость — это способность системы справляться с ростом нагрузки без деградации качества. Не "держать любую нагрузку", а "держать запланированную нагрузку в рамках SLA". Как система ведёт себя при 2x, 10x, 100x росте трафика? Где появляются узкие места? Можно ли масштабировать по частям или только целиком?
Сопровождаемость — это способность системы изменяться без катастрофических последствий. Не "код должен быть красивым", а "изменения должны быть предсказуемыми и безопасными". Сколько времени уходит на добавление фичи? Как часто изменения ломают что-то ещё? Можно ли откатить изменения? Сколько людей нужно для поддержки системы?
Вот эти три свойства и определяют архитектуру. Все три свойства противоречат друг другу. Постоянная борьба за баланс и компромисс.
Почему же они конфликтуют?
Потому что мир конечен. У тебя ограниченный бюджет, ограниченное время, ограниченные ресурсы. И каждое решение, которое улучшает одно свойство, ухудшает другое.
Хочешь надёжность? Добавляешь реплики, валидацию, проверки, мониторинг. Пишешь код для контролируемой деградации, отлаживаешь управляемую остановку сервисов. Добавляешь код для работы с граничными случаями. Система становится сложнее, медленнее, дороже. Появляется больше абстракций. Сопровождаемость падает. Больше кода, больше конфигов, больше точек отказа.
Хочешь масштабируемость? Разносишь по серверам сервисы, добавляй кеши, настраиваешь шардинг, появляется желание завозить очереди, прости господи. Пишешь больше кода для обеспечения согласованности. Думаешь в сторону микросервисов и распределенки. Система становится сложнее, появляются новые типы сбоев, консистентность данных страдает. Надёжность падает. Больше компонентов, больше сетевых вызовов, больше вероятность упасть.
Хочешь сопровождаемость? Упрощаешь архитектуру, минимизируешь зависимости, пишешь простой код. Но система становится менее гибкой, хуже масштабируется, сложнее оптимизируется. Думаешь в сторону монолита. Масштабируемость падает. Масштабирование по частям становится практически невозможным, нельзя использовать некоторые инструменты.
К сожалению, таковы законы природы. Нельзя получить всё, везде и сразу. Постоянный торг, постоянный поиск компромиссов.
Пример
Давай покажу примеры, как эти компромиссы работают на практике.
Кеш vs консистентность
Начнём с классики. Хочешь скорость? Добавляй кеш. Данные читаются из памяти, ответы прилетают быстрее. Масштабируемость растёт, нагрузка на базу падает.
Но что происходит с консистентностью? Данные в кеше могут быть устаревшими. Пользователь видит старую информацию. Если кеш упал, время ответа кратно возрастает. Если кеш переполнился, часть данных вытесняется.
Можешь добавить инвалидацию кеша, но тогда кеш становится сложнее. Можешь использовать write-through, но тогда записи становятся медленнее. Можешь использовать eventual consistency, но тогда пользователи видят разные данные в момент времени.
Выбор зависит от требований. Для ленты новостей eventual consistency это нормально. Для банковского баланса совершенно неприемлемо.
Микросервисы vs простота
Хочешь независимые релизы и масштабирование по частям? Завози микросервисы. Каждый сервис можно разрабатывать, тестировать и деплоить отдельно. Команды не мешают друг другу.
Но что происходит со сложностью? Появляются сетевые вызовы, нужно думать про таймауты и ретраи. Руки тянутся к очередям, сложность x10. Данные размазываются по сервисам, консистентность становится проблемой. Отладка усложняется, практически неминуемая большая связность, один запрос проходит через десяток сервисов.
Можешь добавить service mesh, но тогда появляется ещё один слой абстракции. Можешь использовать event sourcing, но тогда система становится ещё сложнее. Можешь минимизировать количество сервисов, но тогда теряешь преимущества микросервисов.
Примерно тут ты вспоминаешь про саги. Способ склеить распределённые транзакции без 2PC. Каждая операция локальна, но цепочка координируется сообщениями. Если шаг падает, запускается компенсирующее действие. В теории красиво, на практике превращается в ад отложенных событий и "ручного" восстановления состояния. Любая ошибка в оркестрации, и данные начинают расходиться.
Выбор зависит от размера команды и сложности предметной области. Для стартапа из трёх человек микросервисы это оверинжиниринг. Для команды из ста человек монолит будет якорем.
Синхронность vs асинхронность
Хочешь простоту и предсказуемость? Делай всё синхронно. Запрос пришёл, обработался, ответ ушёл. Легко отлаживать, легко тестировать, легко понимать.
А что с производительностью? Медленная операция блокирует весь запрос. Если один компонент тормозит, тормозит вся система. Масштабировать можно только вертикально.
Можешь добавить очереди, прости господи, но тогда появляется eventual consistency. Можешь использовать неблокирующие вызовы, но тогда код становится сложнее. Можешь разбить операцию на этапы, но тогда нужно думать про компенсацию, привет саги.
Выбор зависит от требований к задержкам и пропускной способности. Для систем реального времени синхронность может быть критична. Для batch-обработки асинхронность это необходимость.
И как выбирать эти ваши компромиссы?
Ага, примерно тут, ты достаёшь из широких штанин, толщиною с консервную банку, хм, документ. Тот, что ты собирал с боем в прошлый раз. Требования. Каждое архитектурное решение должно быть обосновано конкретной строкой или абзацем в этом документе.
Если бизнес кричал «нужна скорость», теперь ты смотришь какой ценой. Где они согласились терпеть боль. Медленную запись? Потерю консистентности? Сложность кода?
Если требовали «надёжность», ты открываешь SLA и смотришь, что на самом деле под этим имелось в виду. Сколько минут даунтайма они готовы пережить, сколько данных потерять, какие ошибки простить.
Если просили «простоту» ищешь, для кого именно. Для разработчиков, чтобы быстрее пилить фичи? Для пользователей, чтобы не путались в интерфейсе? Или для эксплуатации, чтобы не вылавливать зомби-процессы ночью?
Тут появляется концепция "бюджета боли". У тебя есть ограниченный бюджет на сложность, на производительность, на надёжность. Ты не можешь получить всё сразу. Ты можешь только осознанно распределить этот бюджет.
Что за бюджет такой?
Представь, что у тебя есть сто деняк. Ты можешь потратить их на надёжность, на масштабируемость или на сопровождаемость. Но на всё сразу у тебя не хватает.
Если потратишь всё на надёжность, получишь систему, которая не падает, но тормозит и которую никто не понимает. Если потратишь всё на масштабируемость, получишь систему, которая держит нагрузку, но падает от любого чиха. Если потратишь всё на сопровождаемость, получишь систему, которую легко поддерживать, но которая не масштабируется.
Правильный подход потратить бюджет осознанно. Понять, что важнее для твоего конкретного случая, и вложиться в это. А в остальном принять компромиссы.
Пример: система уведомлений
Допустим, тебе нужно построить систему уведомлений. Бизнес говорит: "Нужно отправлять уведомления пользователям. Быстро, надёжно, много."
Ты собираешь требования и понимаешь:
- 100k пользователей, 10k уведомлений в день
- Время доставки не критично, можно в течение минуты
- Потеря 1% уведомлений допустима
- Команда из пяти человек, бюджет ограничен
Теперь ты можешь осознанно выбирать компромиссы:
Надёжность vs простота: Можно сделать простую систему, один процесс, одна база, прямой вызов. Но если процесс упадёт, часть задач потеряется. Можно добавить репликацию и мониторинг, но система станет сложнее.
Масштабируемость vs консистентность: Можно гарантировать, что каждое событие будет обработано ровно один раз, но тогда придётся решать вопросы дубликатов и идемпотентности. Можно выбрать eventual consistency, но тогда часть данных может устаревать или теряться.
Сопровождаемость vs производительность: Можно написать простой и прозрачный код, который легко читать и менять. Но он не выдержит пиков. Можно добавить кэширование, пайплайны и оптимизации, но код станет запутаннее и более хрупким.
Исходя из требований, ты выбираешь: простота важнее надёжности (1% потерь допустимы), eventual consistency важнее строгой консистентности (задержка не критична), простота важнее производительности (нагрузка невысокая).
Получается простая система: прямые вызовы, базовая обработка ошибок, минимальный контроль состояния. Не идеально, но соответствует заявленным требованиям.
Что в итоге
System design это не про то, чтобы найти "правильную" архитектуру, тем более не про "чистую". Правильной архитектуры не существует. Есть только архитектура, которая подходит под твои требования и ограничения, и даёт шанс на выживание проекта.
Три столпа: надёжность, масштабируемость и сопровождаемость. Три, казалось бы, простых таргета, которые есть в каждой системе, причиняют огромное количество боли. Особенно, если наступить не в тот компромисс или войти не в ту дверь.
Искусство настоящего архитектора - умело жонглировать этой болью, принимать решения, жертвовать, ради достижения поставленной цели.
Рождение архитектуры, в котором помогает system design, интересный, тяжёлый и завораживающий итеративный процесс, поучаствовать в котором я всем желаю.
В следующий раз попробую показать на примере, как последовательно от требований, от документа, толщиною с консервную банку, перейти к архитектурному решению с шансами на жизнь.
А пока читайте книги, всем добра.
Что еще почитать?
- 1. System design начинается с вопросов
- [High Scalability — Real World Architectures](http://highscalability.com/) - много интересных примеров
- Martin Fowler — Microservices Trade-Offs - про компромисы в микросервисах