Короткий ответ
Система работает не набором компонентов, а движением данных через контуры записи, чтения и обработки. Если не описать скорость входа, точки накопления, границы истины, backpressure и конкуренцию за ресурсы, красивая схема не покажет будущие задержки и каскадную деградацию.3. System design. Потоки …, гхм, данных
_Штош. Тут должен быть мем с котом, но его не будет. Немного выбило меня из ритма повествования. Можно меня поздравить, я теперь совсем безработный, буду чилить и возможно даже больше писать "умных мюслей". А может, и не буду, а может, завтра уже буду работать в новом месте, а может, и нет. Посмотримс_
Ну ладно, минутка офтопа закончилась. Вернёмся к system design.
В прошлый раз я закончил статью анонсом большого практического материала. Хотел на практике показать, как получается архитектура на основании собранных требований. Материал написал, получилось много, сложно и как-то скомкано. Не получилось впихнуть невпихуемое в рамки одной статьи. Поэтому переобуваюсь в прыжке и предлагаю тебе продолжить знакомится с важными элементами system design.
На этот раз расскажу тебе про "Потоки данных", зачем стоит заранее подумать, куда и как текут данные, почему это знание значительно увеличит шансы на выживание проекта, и как не получить _потоки говны_ вместо данных.
Что за потоки и откуда и куда текут?
Есть одна особенность, которую я замечаю у разработчиков, у тех, кто хочет попробовать в архитектуру. Они все думают о системе как о наборе компонентов. Сервис А общается с Сервисом Б. Коллекция users связана с коллекцией balances. Тут мы очередь вкрячим, тут кеш бросим, и мониторинг сверху прикрутим, и чтоб графики красивые. Получается схема, которую можно показать коллегам и сказать: "_Смотрите, у меня современная, красивая архитектура!_"
Проблема в том, что такая схема не отражает жизнь системы. Она статична. Её можно повесить на стену, но она ничего не скажет о том, почему система начинает деградировать, где начинается нагрузка и что именно может сломаться.
Все забывают, что единственная настоящая работа системы, это преобразование данных из одного состояния в другое. Нет данных, нет и системы. И данные, в отличие от компонента на диаграмме, не стоят на месте. Их может быть много или мало, но в любом случае они как-то попадают в систему, движутся внутри неё, где-то оседают и в какой-то момент исчезают.
Это непрерывный поток. Можно сравнить с рекой или ручьём. В одном месте тихое спокойное течение, в другом месте пороги, стремительный бурный поток и водовороты, где-то разливы, а иногда это всё может превратиться в говнотечь, если вовремя не подумать о берегах.
Поэтому, когда я говорю "потоки данных", я вообще не говорю про Kafka streams, SSE, event sourcing или очередной модный паттерн. Я говорю про базовую механику системы. О том, что данные всегда в движении. И что именно это движение диктует архитектуру куда сильнее, чем выбранная база, фреймворк или микросервисная топология.
По опыту, большинство архитектурных ошибок появляются не из-за того, что выбрали «не ту БД» или «не тот язык». Ошибки появляются из-за того, что никто не задался вопросом: куда эти данные идут и что с ними происходит по пути.
Вот делаешь ты API, которое принимает пост. А дальше что? Он превращается в одну запись? В несколько? Нужно ли рассылать дальше? А подтверждение нужно? Кешировать до преобразования или после? Кто читает? Как часто? Как долго данные остаются актуальными? Как они валидируются?
На большую часть вопросов отвечают требования, для этого ты их и собирал. Важно формализовать поток. Но если ты не можешь ответить на эти вопросы, дальше нет смысла рисовать красивые куберы, очереди (прости господи), базы и реплики. Сколько ни старайся, данные всё равно потекут так, как им положено по природе процесса, а не по твоей диаграмме.
Важно понимать, что данные это не сущности. Данные это движение сущностей.
Пост, пользователь, лайк это не просто строка в таблице или документ в коллекции. Это срез конкретного состояния. Поток данных это не про срез. Это про историю изменений. Не «что лежит», а «что происходило».
А вот понять "что же там происходило", всегда сложнее, чем кажется.
Когда в систему попадает новый пост, это не просто "запись в бд", а момент, когда поток данных смещает нагрузку и меняет контекст.
Чтение поста тоже не про обычный findOne или SELECT. Это ожидание того, что кто-то подготовил данные для чтения, желательно быстрого. Если нет подготовки, значит, система может отдавать данные с задержкой и тормозить поток.
Удаление поста требует, чтобы все связанные данные были стёрты. Пропустил и вот данные уже не консистентны, могут быть ошибки.
Лайк поста это про контроль хаоса, два пользователя пытаются изменить одно и то же. Если не учесть движение потока, гонки могут стать нормой.
Лента вообще живёт, только благодаря предварительной сборке. Кеш, заранее рассчитанный срез, и у системы есть шансы уложиться в SLA.
Всё это не просто функции, это узлы движения данных. Сервис это место, где поток проходит. База это место, где поток оседает. Очередь это место, где поток задерживается.
На этапе проектирования важно видеть всю траекторию движения данных, чтобы не превратить архитектуру в набор случайных, но красивых решений. Даже при выборе правильных инструментов, база, кеш, протокол, если, не учтён поток данных, система начнёт деградировать со старта. Задержки будут расти, хвосты копится, воркеры будут душить CPU, p99 расти.
А что делать-то?
Когда начинаешь смотреть на систему как на средство управления потоком, а не как на набор красивых квадратиков, внезапно становится ясно: Архитектура то, не про сервисы. Она про движение. Сервисы, базы, очереди всего лишь инструменты управления потоком, через которые поток проходит, оседает, задерживается. Важная для понимания механика. Без этого знания будет сложно построить жизнеспособную систему.
Первое, что приходится принять: Поток всегда в движении. Независимо от того, понимаешь ты его или нет. Можешь хотеть идеальный write-API, предсказуемый кеш или волшебный микросервис, который решит все проблемы. Но данные будут идти по своему маршруту, диктуемому задачей, временем, нагрузкой и законами природы. Система будет вести себя так, как движутся данные, а не так, как ты её нарисовал.
Чтобы совладать с потоком, его нужно разложить на три составляющие части: контур записи, контур чтения и контур обработки данных. Каждая часть со своей динамикой, рисками и болевыми точками.
Часть первая: Контур записи
Это write-path. Здесь важно всё: порядок, момент фиксации истины, нагрузка, валидность, скорость реакции. Любая ошибка здесь, как трещина в фундаменте. Ты можешь её сразу не увидеть, но она обязательно проявится позже. Клеппман в DDIA хорошо пишет про то, что write-path это та зона, где принимаются необратимые решения. Если вход нестабилен, никакие кеши и шарды дальше этого не исправят. Вход обязан быть предсказуемым, иначе поток начинает рвать систему в самых неожиданных местах.
Часть вторая: Контур чтения
Вроде как всё просто: достал данные и отдал. Но по опыту, всё далеко не так. В реальности чтение это доминирующий поток. Пользователи читают в десятки раз чаще, чем пишут. И этот поток агрессивный: ему нужна скорость, консистентность в рамках контракта и минимальная логика. Если читать «как есть», напрямую из структуры записи, ничего не получится, дорого, долго, тяжело. Read-path требует данных, подготовленных заранее. Денормализация, кеш, заранее собранные агрегаты, это не оптимизация, это само условие того, что система вообще будет отвечать. Фаулер много писал про этот конфликт: чтение нельзя обслуживать теми же структурами, что запись, если ты претендуешь на SLA, а не на стартап-демку.
Часть третья: Контур обработки данных
Тут вообще обычно проходят мимо, забывают, что, данные в системе преобразуются, обрабатываются, меняются. А потом, ой, кто-то где-то задушил CPU и IO. И начинают поиск обычно с входа, контура записи. А там всё красиво и гладко, не падает, не тротлит. А ведь именно тут происходят самые тяжёлые операции: пересборка агрегатов, обновление статистик, пересчёт связей, материализация, очистка, TTL, миграция, индексация, репликация и много чего ещё. Это тот самый тихий поток, который незаметно работает сутками, пока однажды не попадает в неудачное окно и не начинает давить всю систему. Иногда достаточно одного крупного пересчёта, который внезапно совпал по времени с пользовательским пиком, чтобы p99 ушёл в стратосферу, а в логах появилось много нового и непечатного.
Поток данных порождает сложность, а система, в свою очередь, должна иметь внутренние механизмы, способные эту сложность компенсировать. Ага, да, мы опять пришли к закону Эшби про необходимое разнообразие. База она такая.
Если write-path, read-path и фон неразделены, если у них нет изоляции, буферов, независимых темпов, система становится хрупкой. Это и есть тот момент, когда малейший всплеск нагрузки превращается в неконтролируемую деградацию, а простая операция начинает складывать половину инфраструктуры.
Когда разделение потоков становится очевидным, архитектура начинает выстраиваться сама. Нужна быстрая лента, формируем представление данных заранее. Нужна консистентная запись, уделяем внимание входу, а не тому, как красиво выглядит микросервисная схема. Нужны тяжёлые отчёты, их место в фоне, в собственной вселенной, а не рядом с пользовательскими запросами. Нужны жёсткие SLA, чтение и запись никогда не должны жить в одной структуре данных.
И вот мысль, которую я хотел донести: Многие архитектурные решения не про "инструменты". Они про управление потоком данных. Меняешь форму данных, меняешь поток. Меняешь порядок событий, меняется поток. Убираешь в фон тяжёлые операции, подальше от hot path, меняешь поток. Всё сводится к управлению потоком. Архитектура вырастает из движения, а не наоборот.
Поэтому отвечая на вопрос «_что делать-то?_», ответ простой: Сначала понять свой поток данных, потом разделить его, затем разобраться, как система должна на этот поток реагировать. И только после этого, выбирать инструменты и рисовать стрелочки с квадратиками.
Только в таком порядке. Иначе получится красивая схема и поток говны по ней.
Пример. Своя CDN на монге и немного боли
Представь, что ты делаешь свою «бедную» CDN. Ничего космического, просто раздача статики: картинки, js, css. Пара edge-нод, один origin, всё это крутится рядом с основным приложением. Хочется контролировать версии, уметь быстро откатываться, иногда включать разные варианты для разных клиентов. И вот тут кто-то говорит знакомое: «_давайте всё положим в монгу, сто раз так делали_».
Схема рождается быстро. Файлы лежат где-то на диске или в s3, а в Mongo хранятся метаданные: путь, версия, флаги, список разрешённых клиентов, настройки кеша. Приходит запрос на статику, CDN-нода идёт в Mongo, достаёт документ, понимает, какую версию отдавать, смотрит, не выключен ли ресурс, и уже потом лезет в хранилище. Красиво, гибко, всё динамически настраивается.
На бумаге это выглядит даже красиво. На практике, ну такое.
Контур записи ещё терпимый. Разработчик выкатывает новую версию фронта, сервис релизов пишет пачку документов в Mongo, отмечает новую версию как активную, старую как «archived». Поток данных на входе относительно небольшой, немного всплесков, но монга это переваривает. Все довольны, все ходят и рассказывают, что у них «динамическая CDN с конфигом в базе».
Проблемы вылезают в контуре чтения. Любой запрос на статический ресурс начинает с чтения из Mongo. Даже если у тебя есть локальный кеш файлов на edge-ноде, ты всё равно сначала лезешь в базу: вдруг версию поменяли, вдруг ресурс выключили, вдруг флаг изменился. Пока аудитория маленькая, всё живёт. Как только прилетает бОльшая нагрузка, чтение превращается в поток запросов к Mongo, который, вообще-то, никто не проектировал под роль горячего конфига для CDN.
Монга начинает задыхаться. Планировщик запросов крутит лишние индексы, дисковые операции растут, p95 вылазит за пределы того, что фронт таскает без истерики. Ты добавляешь кэширование на уровне приложения, начинаешь держать конфиг в памяти, придумываешь инвалидацию. Поток чтения уже не выглядит простым: часть идёт в монгу, часть в локальный кеш, часть в соседний узел. И всё это держится на соплях и надежде, что где-то не забыли про invalidate.
А теперь подключается контур обработки.
Бизнес хочет удалять старые версии, чистить неиспользуемую статику, пересобирать манифесты. Пишешь фоновые задачи, которые ночами пробегают по Mongo, ищут старые документы, пересчитывают ссылки, сносят мусор. В какой-то момент этих задач становится много, они начинают работать не только ночью, но и «по расписанию» и «по кнопке». И вот уже поток фоновой обработки лезет в те же коллекции, что и горячее чтение CDN.
Получается красивая каша. Вход пишет новые версии. Чтение при каждом запросе лезет в Mongo, чтобы понять, что отдавать. Фон пересобирает всё это дело, чистит и переиндексирует. Контуры неразделены, темпы у всех разные, буферов почти нет. Достаточно одного неудачного совпадения: деплой, пара «ручных» скриптов по миграции метаданных и пик трафика. Итог предсказуем. Edge-ноды начинают встать на блокировках к монге, CDN «внезапно» перестаёт быть CDN и превращается в дорогой прокси к базе.
Если на это смотреть через призму потоков, картина другая.
На записи тебе вообще не нужна Mongo на hot-path. Нужно один раз зафиксировать факт: «появилась новая версия набора файлов с таким-то идентификатором». Этого достаточно. Подробный конфиг можно материализовать отдельно. В обработке ты спокойно пересобираешь манифесты, генерируешь простой, плоский формат настроек для каждой edge-ноды, кладёшь его в отдельную коллекцию или вообще в файловый снапшот. А контур чтения должен жить своей жизнью: брать уже готовый, прогретый конфиг целиком в память и не ходить в mongo на каждый чих.
В таком варианте поток записи остаётся тонким: немного изменений метаданных. Поток обработки тяжёлый, но изолированный: он пересобирает конфиги, не трогая горячие запросы. Поток чтения максимально простой: один раз в несколько секунд или минут нода подхватывает свежий снапшот конфига, а все пользовательские запросы работают по нему в памяти, не трогая ни Mongo, ни файловое хранилище лишний раз.
Снаружи два решения могут выглядеть похоже: «у нас CDN, конфиг в монге, всё динамическое». Но одно построено как «каждый запрос это маленькое приключение в базу», а второе уже как нормальная работа с потоком: истина пишется отдельно, конфиг готовится отдельно, чтение живёт на готовых срезах.
Формально те же компоненты, та же монга, те же edge-ноды. А по факту в одном случае у тебя постоянная война с пиками, а в другом система, которая хотя бы не стреляет себе в ногу каждый раз, когда кто-то открыл главную страницу.
Что в итоге?
Если смотреть на систему честно, становится видно: она держится не на компонентах, а на движении данных. Пока ты рассматриваешь архитектуру как набор сервисов, все решения остаются косметикой. Поток данных идёт как шёл, и именно он определяет, где появится задержка, где начнёт жрать CPU, где система начнёт деградировать.
Когда разбиваешь поток на контуры: запись, чтение, обработку. Все проблемы приобретают форму. Становится ясно, почему запись должна быть предсказуемой, чтение быстрым, а тяжёлая логика жить отдельно. Пример с CDN на монге это хорошо показывает: одинаковые технологии снаружи дают два совершенно разных поведения в зависимости от того, управляешь ты потоком или таскаешься за ним.
Все архитектурные решения на самом деле про одно: про управление движением данных. Меняешь поток, меняется система. Не меняешь, она меняет тебя.
Поэтому порядок такой же простой, как и беспощадный: увидеть поток, разделить поток, понять реакцию системы – и только потом выбирать инструменты.
В обратном порядке работает только красивая схема и поток говны поверх неё.
Следующая часть как раз будет про реакцию. Про то, что система делает, когда поток выходит из-под контроля. Про обратную связь. Про то, что это, и почему она так важна.
А пока совершенствуйся как специалист и никогда не останавливайся в обучении. Никогда не знаешь, где и когда, какие знания могут пригодиться.
Что еще почитать?