Пять пробелов, которые не видны на архитектурных блок-схемах.
Пользователь нажимает «Place Order» (Сделать заказ).
API возвращает 200 OK. Заказ появляется в базе данных. На странице оформления заказа выводится сообщение «success» (успех). Извне всё выглядит нормально.
А потом в службу поддержки прилетает тикет.
Пользователю на почту так и не пришло письмо с подтверждением. Товар никуда не пошёл. В складской системе заказ так и не появился. Аналитика не зафиксировала покупку.
Команда столкнулась со странной проблемой.
Имеется заказ, существующий в одной подсистеме, а во всей остальной системе его нет.
Его нет в рамках на блок-схеме. Нет на стрелках, ведущих от сервиса к сервису. Нет на благополучном пути, который все рассматривали во время планёрок.
Он потерялся в зазорах между компонентами, где одна операция завершается успешно, а другая не завершается — и система оказывается в состоянии, которое при проектировании никто не предусмотрел.
Большинство ошибок при system design (далее — «проектирование систем», прим. пер.) скрываются в этих зазорах между рамками.
Они возникают, когда информация успешно фиксируется в базе данных, но событие так и не публикуется. Когда реплицирующая база данных не успевает за первичной. Когда один запрос инициирует десять нисходящих потоков задач. Когда очередь заполняется задачами быстрее, чем потребитель успевает их обрабатывать. Когда при, казалось бы, безобидном изменении схемы нарушается работа какого-то позабытого потребителя.
Большинство современных команд умеет работать с базами данных, очередями, кэшами, API, логами, дашбордами и облачной инфраструктурой. Сложнее изучить паттерны отказов, возникающих при взаимодействии этих инструментов.
Ниже рассмотрим пять концепций, помогающих развить чутьё к этим ошибкам.
1. Проблема двойной записи
Проблема двойной записи возникает, когда в рамках одного действия бизнес-логики требуется обновить две разные системы.
Распространённый пример такого рода — сохранение заказа в базе данных с одновременной публикацией события OrderCreated для брокера.
Поток задач выглядит просто:

Начинаем с подобного кода:

Выглядит разумно. Такой код может работать месяцами. Пройдёт любые тесты. Он без видимых проблем перенесёт низкий уровень трафика.
Но в продакшне всё происходит как обычно.
Записать информацию в базу данных удаётся, а опубликовать событие – нет.
Теперь заказ существует, только об этом никто не знает. Товар на складе не зарезервирован. Подтверждение по электронной почте не пришло. Служба доставки заказом не занимается. Кроме того, эта продажа не учтена в аналитике.
Теперь система существует одновременно в двух версиях реальности.
В одной сказано: «Заказ существует».
В другой же: «Нам об этом ничего не известно».
Это и есть проблема двойной записи.
Что на кону
В данном случае наиболее страшен не сам отказ, а насколько тихо он может происходить.
API по-прежнему может возвращать «success». База данных может выглядеть корректно. Возможно, в логах зафиксируется небольшая задержка в работе брокера, но она потеряется в ворохе тысяч успешных запросов. Но работа нижележащих систем теперь зависит от события, которое они так никогда и не получат.
Из-за этого пропадают целые потоки задач, нарушаются отчёты, команда поддержки разводит руками, а пользователю работа с сайтом начинает напоминать лотерею.
Вот почему решение «просто опубликуем событие» — неполное (с архитектурной точки зрения).
В системах, работа которых основана на событиях, мало просто опубликовать событие. В них нужно предусмотреть, как надёжно зафиксировать, что что-то действительно произошло, а также обеспечить, чтобы в конечном счёте информация об этом стала известна во всей системе.
Пошаговый разбор решения
Запись в базу данных и публикация события не могут существовать в отрыве друг от друга. Они соответствуют одному бизнес-факту:
Заказ был создан.
Как правило, такая задача решается при помощи паттерна «Transactional Outbox» (ящик исходящих транзакций).
Сервис не сохраняет заказ и не публикует событие напрямую, а сохраняет заказ и делает запись о событии в таблице исходящих транзакций. Всё это делается в рамках одной и той же транзакции базы данных.

Код приобретает вид:

Теперь и заказ, и запись о событии фиксируются вместе.
Если транзакция не пройдёт, то ни один из этих компонентов не сохранится. Если транзакция будет успешной, то будет сделана долговечная запись о том, что событие необходимо опубликовать.
Отдельный процесс читает информацию из таблицы исходящих и отправляет события брокеру.

Так мы превращаем хрупкую двухэтапную операцию в поток задач, рассчитанный на многократные попытки.
Путь выполнения запроса более не зависит от того, доступен ли брокер именно в тот момент, когда пользователь нажимает кнопку «Оформить заказ». Самое главное — надёжно сохранить информацию о том, что такое событие должно произойти.
Даже если брокер лёг, работает медленно или недоступен, информация о таком намерении сохранится в базе данных, и публикатор до этой записи доберётся.
Как с этим работать завтра
Публикатор, устроенный в стиле «ящика исходящих» обычно хорош при работе с брокерами и вебхуками. Но при работе с внешними API также бывает нужно предусмотреть ключи идемпотентности, политики повторных попыток и логику компенсации.
На наиболее критичных путях в вашем приложении (например, при создании заказа) набросайте, как именно заменить прямую публикацию записью в таблицу исходящих, так, чтобы эта запись происходила в рамках той же самой транзакции. Плюс предусмотрите ещё один маленький публикатор, который будет работать в фоновом режиме и очищать эту таблицу.
Реалистичные соображения
Паттерн «ящик исходящих» помогает повысить надёжность, но не устраняет всей сложности. Он просто меняет контур проблемы.
Например, источник может опубликовать событие — и отказать, не успев пометить, что оно опубликовано. После перезапуска он может повторно опубликовать то же самое событие.
Ящик исходящих транзакций не гарантирует строго однократную обработку. Он лишь гарантирует, что в бизнес-логике предусмотрена операция для надёжной записи намерения совершить событие. Публикация и потребление событий по-прежнему происходят, как минимум, однократно, поэтому потребители должны оставаться идемпотентными.
Вот почему в событийно-ориентированных системах так важна идемпотентность. Невозможно рассчитывать на то, что каждое сообщение прибудет всего один раз.
Также нужен мониторинг. Если таблица исходящих растёт, то публикатор может за ней не поспевать. В таком случае главная система принимает заказы быстрее, чем оставшаяся часть платформы успевает на них реагировать.
Проблема двойной записи помогает понять, как система может задвоиться в случаях, когда две операции позиционируются как одна. Но, стоит вам наладить надёжный поток событий — сразу начинают возникать новые проблемы, если различные представления данных рассинхронизируются.
Далее разберём концепцию, помогающую понять, почему даже при успешных операциях записи пользователь может видеть неактуальную картинку.
2. Разделение чтения и записи
Операции чтения и записи масштабируются по-разному.
Увеличивать количество считываний легко. Можно добавлять читающие реплики, кэшировать отклики, создавать материализованные представления, пользоваться индексом при поиске или размещать статические ресурсы в сети доставки контента (CDN).
С записями всё сложнее, поскольку запись меняет состояние системы. Как только состояние меняется, в системе необходимо решить, где источник истины, как будут решаться конфликты, и когда изменения подхватятся в других копиях.
Вот почему кажется, что масштабировать операции чтения проще, а при масштабировании записи требуются более глубокие проектировочные решения.
Большинство систем начинаются с одной базы данных.
Какое-то время это работает. Но затем продукт вырастает.
Из дашбордов запрашиваются большие таблицы. Мобильные приложения чаще опрашивают базу данных. Внутренние инструменты выкатывают тяжеловесные отчёты. Получается, что на первичную базу данных ложится слишком много работы.
Поэтому приходится добавлять читающие реплики.

Теперь трафик, сопряжённый с чтением, распространяется на множество машин.
Это помогает.
Но такая стратегия привносит новую проблему: запаздывание при репликации.
Запись сначала попадает в первичную базу данных. Реплики подхватывают эти данные позже. Эта задержка может исчисляться миллисекундами, секундами, а в период инцидентов — минутами.
Что на кону
Из-за такого запаздывания некоторые операции чтения становятся неактуальными.
Пользователь создаёт заказ. Запись об этом фиксируется в первичной базе данных. Затем пользователь обновляет страницу. Приложение считывает реплику, в которой эта новая информация ещё не подхватилась.
Пользователю кажется, что заказ пропал. Вот, что происходит:
T0: Пользователь создаёт заказ
T1: Заказ фиксируется в первичной базе данных
T2: Пользователь считывает реплику
T3: Реплика подхватывает новую информацию
Между T1 и T3 система сама себе противоречит.
В первичной базе данных новый заказ есть, а в реплике – нет.
Это не значит, что реплики — зло. Просто в приложении возникла проблема с согласованием данных.
Пошаговый разбор решения
Для начала классифицируем операции чтения.
Не для каждой операции чтения нужны свежие данные. Некоторые такие операции действительно должны отражать содержание последней записи.
В других приемлема задержка.
Если при операциях чтения требуется строгая согласованность, обычно нужно удовлетворить такой круг требований:
— Покажи мне заказ, который я только что сделал.
— Успешно ли прошёл мой платёж?
— Какой баланс у меня на счёте?
— Могу ли я снять эту сумму?
Обычно результаты этих считываний должны поступать в первичную базу данных, либо нужна какая-то другая стратегия, которая гарантирует свежесть результатов.

Считывания, требующие согласованности в конечном счёте, актуальны при работе с такими данными как рекомендации, показ товаров, которые сейчас в тренде, поисковая выдача, аналитические дашборды и недавно просмотренные товары.
Для них обычно можно использовать реплики, кэши или проекции.
Типичная стратегия – «читай твои записи» (read-your-writes)
После того, как пользователь запишет данные — ненадолго переадресуем в первичную базу данных следующие операции считывания, выполняемые этим пользователем.
Таким образом мы упрощаем пользователю работу, а сами можем не кидать в первичную базу данных абсолютно все операции чтения.
Как с этим работать завтра
Проанализируйте ваш продукт и перечислите три таких запроса, по которым пользователю абсолютно необходимо выводить свежие данные (например, «покажи мой последний заказ», «актуальный баланс на счёте» или «состояние платежа»).
По каждой из этих категорий проверьте, не считывается ли сейчас такая информация из реплики или кэша. Если так — то пропишите на вашем уровне доступа к данным новое правило, по которому для таких считываний подходит только первичная база данных, а после записи ставьте флаг «read-your-writes».
Реалистичные соображения
При разделении чтения и записи мы снижаем давление на базу данных за счёт усложнения согласованности.
Итак, первичная база данных не так нагружается операциями чтения, но в таком случае требуется предусмотреть логику маршрутизации, мониторинг запаздывания, поведение при откатах, а также чётко решить, какие именно данные считать «достаточно свежими».
Если аналитический дашборд немного устарел – может быть, это нормально. Неактуальный баланс на счёте, вероятно, неприемлем. Если устарела рекомендация по товару — возможно, это не важно. Если же статус платежа неактуален, то команде поддержки немедленно прилетит тикет.
В хорошей системе не все запросы обрабатываются одинаково. Такая система соотносит требуемый уровень согласованности данных с бизнес-рисками.
При разделении чтения и записи осознаём, что масштабирование — это не только добавление копий, но и умение понимать, в каких случаях между этими копиями допустимо рассогласование.
Успешная операция записи зачастую запускает множество нисходящих операций. И здесь мы переходим к следующей концепции, которая порой становится неудобной.
3. Разветвление запросов (Fan Out)
Разветвление запросов (fan-out) происходит в случаях, когда одно действие инициирует множество нисходящих операций.
Там, где пользователь видит одну кнопку, система видит цепную реакцию.
Пользователь щёлкает «Place Order» (Сделать заказ). Чтобы обслужить это действие, платформа должна авторизовать платёж, зарезервировать товар, создать заказ на отправку, отправить на почту сообщение с подтверждением, обновить очки лояльности, выдать аналитику, сообщить, если будет замечена попытка мошенничества, синхронизировать CRM-данные и обновить рекомендации.
Архитектура может приобрести следующий вид:

Здесь простые требования к продукту превращаются в распределённые потоки задач.
Что на кону
При разветвлении запросов становится больше ситуаций, в которых запрос может частично отказать.
Если выполнение одного действия зависит от восьми нижележащих систем, то что произойдёт, если пятая из них откажет? Отменится ли от этого весь запрос? Будет ли повторная попытка? Продолжение? Компенсация? Пользователю придёт сообщение, что всё успешно? Прилетит уведомление в службу поддержки?
Без чёткого ответа на эти вопросы система оказывается в запутанном промежуточном состоянии.
Платёж прошёл успешно, а на складе товара не оказалось. Товар на складе нашёлся, а письмо с подтверждением не отправилось. Письмо отправилось, а аналитика не сработала. Время на проверку мошенничества истекло, но товар всё равно был отправлен.
Вместе с каждой зависимостью нарастает задержка, возникают новые варианты отказа и возрастает ответственность при эксплуатации. При разветвлении запросов выпячивается истинная стоимость внедрённых вами фич.
Вот почему бывает опасно синхронно разветвлять запросы. Чем больше работы вы укладываете на пути запроса, тем более хрупкой получается система с точки зрения пользователя.
Пошаговый разбор решения
Первым делом нужно отделить критически важную работу от второстепенной.
Критически важная работа должна быть выполнена до того, как пользователь получит подтверждение. Второстепенную работу можно отложить на потом.
Вот как может выглядеть критичный путь при оформлении заказа.
1. Валидация заказа
2. Авторизация платежа
3. Резервирование товара на складе
4. Сохранение заказа.
Этот путь должен быть максимально коротким.
Всё остальное можно делать асинхронно.

По завершении критического пути API может вернуть «success». Затем потребители событий займутся второстепенной работой.

Потребители отреагируют позже:

Спроектировав систем так, мы помогаем пользователю.
Заказ не должен обрываться только потому, что провайдер электронной почты временно недоступен. От аналитической системы не должно зависеть, будет ли доведено до конца оформление заказа.
Как с этим работать завтра
Берём одно пользовательское действие в вашей системе (например, «сделать заказ» иди «создать аккаунт») и выписываем все нижелещащие системы, которые оно затрагивает: платежи, электронную почту, аналитику, CRM, предотвращение мошенничества и т.д.
Помечаем, в каких из них действительно необходимо обеспечить успех, прежде, чем показать пользователю сообщение «success».
Затем берём одну некритичную операцию (например, аналитику или синхронизацию с CRM) и задвигаем её за событие, так, чтобы она могла выполняться асинхронно, а не по одному пути с запросом.
Реалистичные соображения
При асинхронном разветвлении запросов мы не избавляемся от отказов — просто они начинают происходить в другом месте и в другое время.
Теперь приходится обрабатывать дублирующиеся события, повторные попытки, очереди мёртвых писем, ID корреляции, а также обеспечивать видимость на уровне сервисов.
Например:

Без такой видимости отладка превращается в угадайку. Одна строка в логе к сервису не может объяснить распределённый поток задач.
Отслеживать задачи в потоки удобнее по корреляционным ID. Но они не заменяют ключей идемпотентности или таблицу обработанных событий.
Разветвление запросов подсказывает, что из-за одного пользовательского действия может возникать множество отдельных точек отказа. Когда эти отказы переходят в фоновый режим, необходимо знать, успевает ли система обрабатывать задачи.
На данном этапе начинаем узнавать истину из очередей.
4. Глубина очереди
Очередь не устраняет нагрузку, а переносит часть работы на будущее.
Это полезный компромисс, однако он может маскировать проблемы.
Очередь может поглощать всплески нагрузки, откреплять продьюсеров от потребителей и повышать надёжность систем. Но также очередь может скрывать отказы. Система выглядит исправно, а тем временем в ней тихо накапливается бэклог.
Большинство систем на основе очередей выглядят просто:

Продьюсер создаёт сообщения. Далее они сохраняются в очереди. Затем потребитель их обрабатывает.
Всё работает нормально, пока потребитель успевает за очередью.
Но представьте, что продьюсер отправляет 1000 сообщений в секунду, а потребитель обрабатывает 800 сообщений в секунду. Каждую секунду система опаздывает ещё на 200 сообщений.
Через 10 минут в очереди накапливается 120 000 лишних сообщений. А через час — 720 000 лишних сообщений.
Ничего не взорвалось. Ни один сервис не отказал. Но сейчас система работает гораздо медленнее, чем развивается реальная ситуация.
Вот почему так важна глубина очереди.
Что на кону
Если очередь растёт, это означает, что система принимает работу быстрее, чем успевает её выполнять.
Первое время кажется, что всё нормально. API по-прежнему откликается. Брокер продолжает принимать сообщения. Потребители успевают выполнять часть работы. Даже дашборды могут оставаться зелёными.
Но растёт и задержка, заметная пользователю. Письма приходят позже обычного. Обновления запасов запаздывают. Платежи одобряются не сразу. Аналитика явно устаревает.
В конечном счёте сообщения могут устареть, количество повторных попыток может возрасти, а отказы потребителей могут стать болезненнее. Очередь превращается из буфера в бэклог.
Растущая очередь – это не прогресс. Это отсрочка проблем.
Пошаговый разбор решения
Глубина очереди — это ещё не всё. Необходимо оценить несколько сигналов в совокупности:
-
Глубина очереди
-
Сколько самому старому сообщению
-
Темп работы продьюсера
-
Темп работы потребителя
-
Количество повторных попыток
-
Количество мёртвых писем
-
Доля ошибок в работе потребителя
По глубине очереди можно понять, сколько работы ещё остаётся. Самое старое сообщение подсказывает, насколько вы отстали от реальности.
Часто эта вторая метрика даже важнее.
Ничего страшного, если в очереди 100 000 сообщений, но потребители быстро её разбирают. С другой стороны, если перед вами очередь в 500 сообщений, но самому старому сообщению в ней шесть часов — то у вас серьёзная проблема.
Поможет простая модель:

Как только проблема обнаружена, есть варианты, как её решать.
Можно добавить потребителей, замедлить продьюсеров, частично снять некритичные задачи, разделить рабочие нагрузки на отдельные очереди или устранить корень проблемы — бутылочное горлышко, возникшее где-то внизу.
Самое важное – не добавлять потребителей вслепую.
Если потребители работают медленно, так как тормозят в ожидании записи в базу данных, из-за блокировок, пулов соединений или из-за ограничения скорости передачи данных в нисходящем направлении, то лишние потребители просто увеличат давление на уже имеющееся узкое место.

Теперь очередь опустошается быстрее, но база данных плавится.
Как с этим работать завтра
Для каждой очереди, используемой в продакшне, предусмотрите два вида оповещений: о глубине очереди и о возрасте самого старого сообщения (как долго сообщение дожидается своей очереди на обработку).
Для начала установите простое правило: «оповестить нас, если возраст самого старого сообщения превысит 5 минут». Затем посмотрите, что будет при следующем всплеске трафика, а только после этого попробуйте добавить потребителей.
Реалистичные соображения
В очередях требуется обратное давление. Обратное давление — это сигнал от потребителей продьюсеру: «притормози, нас завалило работой».
Когда потребители отстают, нужно дать сигнал об этом продьюсерам, и иногда система должна просто отбрасывать некритичную работу.

Иногда нужно отключать ресурсозатратные фичи. В других случаях — отдавать приоритет критичным рабочим нагрузкам.

В такой конфигурации критичную работу не приходится откладывать из-за некритичного трафика.
От того, как вы спроектируете очередь, зависит не только производительность — такие приоритеты диктует бизнес.
Глубина очереди подсказывает, что асинхронные системы могут отказывать медленно. Но, даже если сообщения ходят идеально, систему можно сломать ещё из-за самого контракта, заложенного в сообщении.
Здесь пора обсудить эволюцию схемы.
5. Эволюция схемы
Переименовав поле, можно потопить нижележащие сервисы. Изменив семантику, можно повредить данные, а пока это заметят, пройдёт не один день.
Это касается API, событий, таблиц баз данных, файлов и вообще чего угодно, что может читать система. Если от формы ваших данных зависит другая система, то такая форма данных превращается в контракт.
Выглядит безобидно.
Но отказать может любой потребитель, ожидающий total.
Это и есть эволюция схемы.
Что на кону
Изменение схемы часто приводит к болезненным отказам.
Продьюсер может работать идеально. Сервис успешно развёрнут. API может пройти все тесты. Событие даже может корректно опубликоваться.
Но один из нижележащих потребителей может отказать из-за того, что ожидал старого поля.
Хуже того, потребитель может не отказать, а прочитать null, пропустить вычисление, записать неверные данные или сгенерировать неверную метрику.
Из-за таких отказов система незаметно повреждается.
Повреждённый запрос замечаем мгновенно. Плохие данные могут распространяться днями, пока это проявится.
Пошаговый разбор решения
Проще всего в данном случае применить обратно-совместимое изменение.

Затем пошагово переносим потребителей. Ход миграции:
Шаг 1: продьюсер выдаёт как старое, так и новое поле emits.
Шаг 2: потребители переходят на новое поле.
Шаг 3: команда отслеживает, как используется старое поле.
Шаг 4: после миграции продьюсер удаляет старое поле.
Это паттерн «расширить/сжать».
Таким образом мы сохраняем работоспособность старых потребителей на тот период, пока новые потребители усваивают новый контракт. Также в таком случае обходимся без координации развёртывания с участием множества команд.
Реалистичные соображения
Эволюция схемы — это не только об именах полей, но и об их смысле.

Что в данном случае означает active?
Значит ли это, что пользователь создал аккаунт? Подтвердил свою электронную почту? Оформил платную подписку? Заходил в систему под своим логином хотя бы раз за последние 30 дней?
Не изменились ни имя поля, ни тип данных. Но смысл всё равно может сдвигаться.
Это коренное семантическое изменение.
Его сложнее отследить, чем недостающее поле. Нужно владеть продуктом, знать документацию, протестировать контракты и иметь чёткие определения событий.
Также нужно освоить дисциплину версионирования.
Если версионировать всё, то возникает путаница. Создавая v2, v3 и v4 для каждого небольшого изменения, вы надолго обременяете команду поддержки техническим долгомt.
Лучше такое правило:
Если имеющиеся потребители могут и далее работать без изменений, то, пожалуй, новая версия вам не нужна. Если для сохранения корректности какой-либо из них теперь должен работать иначе — то нужна.
Если можно спокойно добавить новое поле, не делайте новую версию. Если меняется значение поля — создайте новый контракт.
Эволюция схемы подводит нас к одному из самых важных выводов в области проектирования систем: системы зависят не только от данных, но и от того, что они значат.
Пять рассмотренных здесь концепций кажутся обособленными, но на самом деле они связаны
Проблема двойной записи помогает понять, как данные рассинхронизируются с событиями. Разделение чтения и записи объясняет, почему пользователь иногда может видеть неактуальные данные. Разветвление запросов иллюстрирует, как один запрос может породить множество точек отказа. Глубина очереди подсказывает, каким образом системы начинают запаздывать, не отказывая при этом явно. Эволюция схемы объясняет, каким образом в процессе изменения систем нарушаются контракты.
В совокупности все эти проблемы помогают понять, почему в продакшне системы могут отказывать, когда ничего этого не предвещало.
Зафиксировалась ли запись в базе данных?
Опубликовалось ли событие?
Получил ли его потребитель?
Не обработал ли потребитель одно событие дважды?
Подхватила ли реплика изменения?
Выросла ли очередь?
Изменилась ли схема?
Врёт ли дашборд?
Заметил ли это пользователь?
Если при работе с вашей системой вы не можете ответить на эти вопросы — изучите разделы этой статьи.
В них рассказано о реальной системе.
Не о том, что в рамках, а о том, что за их пределами.
Выводы
-
Не факт, что сохранение данных и публикация событий — это одна надёжная операция. Считайте их одним фактом бизнес-логики или проектируйте систему с учётом зазора между ними.
-
При масштабировании операций чтения обязательно приходится дополнительно решать, как согласовать данные. Реплики и кэши снижают нагрузку, но из-за их применения пользователь может видеть устаревшие данные.
-
Одно действие пользователя может превратиться в распределённый поток задач, чреватый частичными отказами. При разветвлении запросов необходимо чётко прописать критичные пути, повторные попытки, организовать идемпотентность и наблюдаемость.
-
Очереди откладывают работу, но не устраняют давления. Следите за глубиной очереди, возрастом старейшего сообщения и исправностью потребителей, пока бэклог не превратился в инцидент.
-
Если небрежно менять форму или смысл данных, то контракт данных нарушается. При эволюции схемы нужна обратная совместимость, также позаботьтесь о стратегии владения и миграции.
На архитектурных схемах показано, как стыкуются системы. Продакшн демонстрирует, в чём системы расходятся.
Всего доброго,
— Рауль
ссылка на оригинал статьи https://habr.com/ru/articles/1062842/