- Автор темы
- #1

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

Крупноблочная схема организации бэкенда Яндекс Такси
Таким образом, отдельные микросервисы бэкенда мало что знают о существовании друг друга и изолированно от остальных решают свою узкоспециализированную задачу. Ядро же занимается координацией и синхронизацией работы этих компонентов.
В статье мы сфокусируемся на устройстве ядерного компонента процессинга Яндекс Такси, который называется Processing as a Service (ProcaaS). Именно его мы используем в качестве арбитра критичности бизнес‑логики и с помощью него обеспечиваем «плавную деградацию», что в большей мере влияет на отказоустойчивость.
Стоит отметить, что обеспечение отказоустойчивости — комплексный процесс, и в статье мы рассмотрим только отказоустойчивость на уровне бизнес‑логики. Такие аспекты, как отказоустойчивость сети, СУБД, разбиение на зоны доступности и прочее, останутся за скобками.
Фундаментальные проблемы сервиса
Над сервисом Яндекс Такси непрерывно работают десятки команд разработчиков, которые добавляют в приложение новые фичи, выпиливают старые и обновляют существующие.Основной вызов, с которым приходится сталкиваться, — это риски дестабилизации сервиса из‑за непрерывных изменений в нём. Многие из изменений не ограничиваются своими микросервисами, но прорастают в ProcaaS: какую ручку нужно дёрнуть, в какой момент времени, какие данные передать, какие данные забрать, как их трансформировать, насколько это критично и т. д. В общем случае процесс такой интеграции несёт риски, так как изменения затрагивают критичный контур цикла заказа: изменяется профиль нагрузки, вносимые изменения могут содержать ошибки, появляются новые, иногда непредсказуемые, зависимости между компонентами, бизнес‑логики, у которых могут быть неожиданные взаимовлияния.
Проблема усугубляется и тем, что бэкенд Такси сам по себе сложный. Он состоит из сотен микросервисов: некоторые написаны довольно давно, и их лучше лишний раз не трогать, а какие‑то, наоборот, активно развиваются. Очевидно, что такие расшатывания ядра цикла заказа не могут положительно влиять на его стабильность.
С другой стороны, останавливать или замораживать разработку непрактично и никому не выгодно. Наоборот, рациональной целью должно быть снижение издержек на разработку и сокращение TTM (time‑to‑market) для новых фичей. Однако эти показатели с течением времени могут ухудшаться из‑за роста сложности бэкенда.
Несмотря на то, что микросервисная архитектура позволяет инкапсулировать сложности кодовой базы и стоимость эксплуатации внутри микросервиса, такая архитектура часто способствует росту горизонтальных связей между микросервисами. Таким образом, хоть и отдельная небольшая команда может эффективно поддерживать и развивать один микросервис, у межкомандного взаимодействия может быть ощутимая стоимость. Из‑за этого внесение изменений становится дороже, так как придётся либо пробрасывать изменения во все зависимые микросервисы, либо делать изменения обратно‑совместимыми и бесконечно тратить ресурсы на поддержку старых версий. Таким образом, команда, разрабатывающая новую фичу, вынуждена блокироваться об другие команды, а другие команды — отвлекаться от своих задач, чтобы помочь смежникам.
Наконец, рост системы приводит к тому, что размывается ответственность между командами и теряется контроль над точками отказа. Микросервисы с второстепенной продуктовой логикой могут внезапно оказаться на критическом пути обработки заказа и приводить к частым отказам цикла заказа.
В случае инцидента придётся распутывать клубок зависимостей и искать ответственных, на это придётся тратить время — это увеличивает время митигации инцидента, что крайне критично для транзакционных сервисов вроде Такси. А если при интеграции не был предусмотрен специальный «рубильник» функциональности, то восстановить работу сервиса простым переключением конфига не получится — придётся полноценно чинить сломанный микросервис. И пока мы этого не сделаем, работа цикла заказа не восстановится.
Известные подходы к решению
Один из подходов к решению описанной выше проблемы — так называемая хореография, при которой центральный координирующий компонент отсутствует и микросервисы взаимодействуют друг с другом напрямую.
Как выглядит бэкенд Яндекс Такси, если реализовать его хореографией
Такая децентрализация существенно упрощает архитектуру на начальном этапе существования бэкенда, однако с ростом числа микросервисов растут и связи между ними, а направления движения данных становятся всё более неочевидными. Архитектура усложняется и становится дорогой в поддержке и расширении. Это приводит к тому, что разработчикам становится всё сложнее сохранять баланс между стабильностью и расширяемостью такой системы.
Неизбежно появляется дублирование в реализации стандартных задач: ретраи, фолбэки, лимиты, control plane. Они могут работать по‑разному в различных частях системы, быть плохо совместимыми друг с другом, требовать накладные ресурсы для поддержки и разные подходы в эксплуатации.
Чтобы сохранить контроль над такой системой, нужны строгие архитектурные регламенты, которым нужно следовать практически с начала жизни системы, потому что переделывать потом будет дорого. В дальнейшем такая архитектура может развиваться в сторону доменизации — когда группы микросервисов объединяются в изолированные домены.
Противоположный подход предполагает наличие в системе оркестратора, управляющего работой других микросервисов. Такой подход улучшает инкапсуляцию отдельных микросервисов, так как им не нужно больше знать друг о друге. Также он решает большинство проблем хореографии, но при этом сам становится центральной точкой отказа.
В наивной реализации оркестратор представляет собой обычный микросервис. В Яндекс Такси образца 2018–2019 годов процессинг был реализован именно в таком виде — как монолитный сервис на Python. Расширяемость обеспечивалась за счёт системы плагинов, которые добавлялись продуктовыми разработчиками для поддержки новых фичей в цикле заказа.
В разработческом процессе в данном случае не было почти ничего особенного.Нужно было написать код в общее место, покрыть тестами, собрать новую версию и выкатить её в тестинг и продакшен, контролируя отсутствие деградаций в новой версии. Всё это должен был выполнять продуктовый разработчик, не имеющий обширного опыта работы с процессингом.

Схема монолитного процессинга с системой плагинов
В перспективе у такого монолитного процессинга два пути:
- либо он станет «коммунальным», что увеличит риски инцидентов, так как в него будет контрибьютить большое число разработчиков с разным уровнем экспертизы о процессинге и общей картине;
- либо он станет узким местом для разработки, если ответственность за сервис будет только у одной команды, имеющей максимальную экспертизу.
Для решения проблем монолитного процессинга зачастую предлагается вводить в систему транспортный компонент. Например, очередь сообщений или шину данных между оркестратором и продуктовыми микросервисами, чтобы оркестратор был поставщиком сигналов, на которые смогут реагировать микросервисы. В Яндексе для таких целей обычно используется LogBroker (подробнее про него можно почитать в одной из наших старых статей). Это действительно позволит избавить процессинг от частых изменений и протекания в него бизнес‑логики. Но такой подход не лишён недостатков:
- избыточность передающихся данных;
- ещё одна точка отказа;
- рост латенси, а это критичная метрика для Такси;
- нет деления по критичности — все сигналы идут в одну трубу;
- односторонняя связь — затруднительно вернуть какие‑то данные обратно в оркестратор, хотя часто нужно;
- невозможность сделать что‑то синхронно — мы не можем, например, реализовать ветвление флоу обработки, не дождавшись ответа какой‑нибудь ручки.
Схема монолитного процессинга с транспортным компонентом
Подход Яндекс Такси к организации процессинга
Наш цикл заказа такси построен на основе оркестратора, роль которого выполняет ProcaaS. Его начали разрабатывать в 2019 году, чтобы обеспечить высокую отказоустойчивость базового цикла заказа и его лёгкую расширяемость.Первое, что было принято, — это декларативный подход и уход от плагинов к описанию схемы на YAML. Вместо полной свободы делать в процессинге всё что угодно, продуктовым разработчикам было предложено описывать логику интеграции с циклом заказа при помощи набора предопределённых примитивов. Таким образом, возможности разломать процессинг произвольным кодом существенно уменьшились, и при этом стало возможным более строго развязать различные части цикла заказа друг от друга. YAML‑описания, которые пишут разработчики, хоть и изолированы друг от друга, но тем не менее совместимы между собой на уровне оркестратора. Благодаря этому, разработчикам не нужно думать про все возможные варианты взаимодействия с другими фичами и микросервисами и модальности их работы, например, при отказе каких‑либо компонент или экспериментах.
Процессинг выступает в роли солвера, который решает проблему корректности и оптимальности применения всего набора YAML‑описаний. Например, ProcaaS может выполнять какие‑то части логики параллельно, если сможет доказать, что при текущих условиях это не приведёт к нарушению инвариантов, чтобы ускорить обработку заказа. Либо может сериализовать какие‑то действия, если вычислит наличие зависимостей между ними. Аналогично ProcaaS автоматически принимает решения о включении и выключении «плавной деградации» (graceful degradation) в различных частях цикла заказа, чем обеспечивает отказоустойчивость.
Также мы разработали модель описания универсального флоу обработки заказа, которая покрывает все возможные сценарии интеграции фичей в цикл заказа. Флоу обработки заказа описывается в виде набора вложенных друг в друга элементов: пайплайнов, стадий и обработчиков. При помощи этих блоков можно описать базовые шаги обработки заказа, на которые затем можно добавлять элементы продуктовой логики. Например, так:
pipelines:
- id: new-order-created
...
- id: new-driver-found
stages:
- id: fetch-order
handlers:
- id: request-get-order-data
- id: perform-notifications
handlers:
- id: notify-driver
- id: notify-user
- id: update-order
handlers:
- id: request-post-order-data
- id: miscellaneous
handlers:
- id: update-statistics
- id: evaluate-bonus
...
- id: driver-rejected-offer
...
В примере выше можно проследить основную идею организации флоу: пайплайны содержат наборы стадий, а стадии содержат наборы обработчиков. Причём стадии, в отличие от обработчиков, всегда выполняются строго последовательно.
Пайплайны независимы друг от друга и активируются при получении ProcaaS события — сущности, которую ProcaaS получает через API от фронтенда приложения или другого микросервиса. Таким образом, вся работа процессинга заключается в приёме потока событий, выбора подходящего пайплайна для каждого и прогона для него соответствующих стадий.
При этом разработчики, отвечающие за базовый цикл заказа, контролируют корректность структуры пайпланов и стадий, а продуктовые разработчики добавляют свои обработчики в соответствующие стадии. В действительности YAML‑описания, конечно же, сложнее и могут содержать небольшие динамически вычисляемые фрагменты кода, написанные на небольшом DSL (Domain Specific Language).
Разработка DSL для вычисляемых выражений, которые можно вписывать прямо в YAML‑схему, была в каком‑то смысле шагом назад, в сторону программирования на произвольном императивном ЯП. Однако совсем обойтись без этого не получилось, потому что иначе было бы проблематично интегрироваться с существующими микросервисами и их API, переписывать которые было бы довольно дорогим удовольствием. К тому же в реальных приложениях требуется гибкость, чтобы учесть всё разнообразие бизнес‑логики. Похожие подходы используются, например, в AWS Step Functions и Google Workflows.
Этот компромисс позволил с одной стороны дать возможность продуктовым разработчикам быстрее реализовывать какие‑то простые вещи, например, трансформацию JSON‑данных или составлять сложные предикаты, а с другой стороны — контролировать протечки бизнес‑логики в процессинг, делая их более предсказуемыми и ограниченными. Например, в плане возможных багов, таких как бесконечные циклы исчерпания или утечки ресурсов, падения в корку, или непредсказуемых действий: например, неожиданных запросов и т. д.
Сам язык достаточно прост — он позволяет в функциональном стиле описывать выражения, которые вычисляются при каждом применении флоу для заказа. В первой версии у него даже не было синтаксиса, он был похож на смесь LISP и YAML. Вот пара примеров, как это выглядело:
handlers:
- id: example-handler
enabled#and
- value#xget: /event/payload/is-authorized
- value#contains:
haystack: ['econom', 'business']
needle#xget: /order/info/tariff
- value#not:
value#xget: /configs/SOME_IMPORTANT_SWITCH/enabled
request:
url: http://foo.example.com/v1/foo/bar
body:
user-id#xget: /order/user_id
tariff#xget: /order/info/tariff
...
Во второй версии язык обзавёлся Python‑подобным синтаксисом и стал больше похож на привычный ЯП, хотя семантика и общая приверженность подходу nocode/lowcode осталась неизменной:
handlers:
- id: example-handler
enabled: !$ |
event.payload['is-authorized'] and
order.info['tariff'] in ['econom', 'business'] and
configs['SOME_IMPORTANT_SWITCH'].enabled
request:
url: http://foo.example.com/v1/foo/bar
body:
user-id: !$ order.user_id
tariff: !$ order.info['tariff']
...
Всё это вместе сделало процессинг относительно простым компонентом, в который не требуется вносить каких‑то изменений и в котором не приходится иметь дело с разного рода рисками и затратами для обновления продуктовой логики Яндекс Такси.
Вся логика представлена в виде набора YAML‑файлов, в которых редко бывают конфликты, а изменения в них не страшно катить в прод, так как есть уверенность, что они не смогут что‑то значительно разломать.
Примеры работы ProcaaS
Рассмотрим на более конкретных примерах, как при помощи ProcaaS решаются проблемы, обозначенные в начале статьи.Пример 1. Организация кода и разработки
Сравним две реализации одного флоу, в данном случае часть флоу заказа Такси. Для начала рассмотрим, как это реализовывалось бы на Python (опустим некоторые детали для большей наглядности):def process_new_order(order_id):
order_document = _fetch_order_from_db(order_id)
candidate = _find_new_driver(order_document)
_assign_candidate(order_document, candidate)
order_document = _save_order_to_db(order_document)
_send_driver_notification(order_document)
_send_user_notification(order_document)
_hold_payment(order_document)
order_document = _save_order_to_db(order_document)
Как видно из листинга, обработка нового заказа состоит из нескольких шагов: получение документа заказа из базы, поиск исполнителя, отправка оповещений и так далее. Все эти функциональности реализованы в других микросервисах, а процессинг лишь делает запросы в них в нужном порядке и преобразует и передаёт данные между шагами. Какие проблемы могу возникнуть в таком коде?
Как я писал выше, возможность писать код «без заборов» довольно опасная, особенно когда в него контрибьютят сотни разработчиков. Несмотря на то, что ожидается, что каждая из функций будет только делать запросы в соответствующий микросервис, ничего не мешает сколько угодно усложнять код этих функций и, как следствие, вносить ошибки.
Другая проблема такого усложнения — это рост когнитивной нагрузки на разработчиков, потому что тут хоть и виден общий флоу обработки, вообще говоря, не очевидно, что скрывается в глубинах реализации этих функций. Также можно заметить, что разные части используют общий контекст — в данном случае документ заказа order_document. Такой контекст создаёт скрытые зависимости между компонентами, отследить которые довольно трудозатратно (потому что придётся делать это вручную), а неправильное понимание и поддержка этих зависимостей будет приводить к ошибкам.
Кроме того, хорошо бы иметь возможность управлять этим флоу. Например, некоторые действия можно выполнять параллельно (_send_driver_notification и senduser_notification) или уметь восстанавливаться после падения (которое может произойти в любой точке исполнения), чтобы не повторять уже выполненную работу. С первого взгляда это кажется несложной задачей, но при более детальном рассмотрении обнаруживается множество корнер‑кейсов. Например, что делать, если исполнение прервалось в распараллеленом участке? Нужно повторить его полностью или частично? А если упал некритичный элемент, который можно пропустить? И так далее.
Теперь рассмотрим аналогичный флоу, но реализованный при помощи ProcaaS:
pipelines:
- id: process-new-order
shared-state:
order: null
driver-id: null
stages:
- id: fetch-order-from-db
handlers:
- id: make-db-request
url: http://...
result:
order: !$ sources.make-db-request.response.order-document
- id: find-new-driver
handlers:
- id: make-lookup-request
url: ...
query:
order_id: !$ shared-state.order.id
tariff: !$ shared-state.order.tariff
...
result:
driver-id: !$ sources.make-lookup-request.response.driver-id
- id: assign-candidate
- id: make-driver-assignment-request
url: ...
query:
order_id: !$ shared-state.order.id
driver_id: !$ shared-state.driver-id
- id: save-order-to-db
...
- id: send-notifications
handlers:
- id: send-driver-notifications
...
- id: send-user-notifications
...
- id: hold-payment
...
- id: save-order-to-db
...
Данный пример выглядит более многословно по сравнению с предыдущим, в основном из‑за деталей реализации каждого блока, которые, впрочем, можно вынести в отдельные файлы. Однако такой подход решает проблемы, описанные выше:
- Минимально необходимые возможности: сделать запрос, преобразовать данные и передать данные через контекст shared-state. Разработчики, которые будут работать с этим флоу, точно знают, чего можно от него ожидать. При этом гораздо меньше шансов допустить ошибку, просто потому что возможностей писать код существенно меньше.
- Контекст более формализован, поэтому есть больше возможностей для статического анализа связей между стадиями и обработчиками.
- В случае ошибки ProcaaS автоматически сохранит контекст и прогресс выполнения, у нас это называется «чекпоинт», и потом продолжит с точки прерывания. Разработчикам не нужно об этом беспокоиться.
Пример 2. Эксплуатационные свойства (помощь SRE)
Из‑за используемого в ProcaaS описания флоу в виде дерева обработчиков появляются новые полезные при эксплуатации свойства: управляемость и наблюдаемость. Эти свойства больше всего полезны, когда случается инцидент. При инцидентах довольно критична скорость их митигации, которая в большой мере определяется быстротой анализа и локализации проблемы. В нашем случае, если отказывает какой‑то из обработчиков, дежурный сразу получает алерт, в котором точно указан путь до ломающего элемента:
Алерт об отказе обработчика
Аналогично структура флоу прорастает в логи и на дашборды, по которым можно быстро найти проблемный компонент:

Локализация проблемного компонента при помощи графиков.
Унифицированность организации флоу позволяет реализовать инструменты для управления компонентами этого флоу. Например, можно в реальном времени, то есть без деплоя, включать или отключать отдельные обработчики или целые стадии (aka рубильник), или устанавливать лимит частоты их запусков (rate limit), чтобы избегать перегрузки в самом ProcaaS или других микросервисах.
Пример 3. Автоматическая плавная деградация и восстановление
Другая полезная возможность — это автоматическая плавная деградация и восстановление (graceful degradation). Благодаря описанию флоу в виде дерева обработчиков, мы смогли реализовать механизм сбора статистики успешности их запусков и автоматического отключения, когда доля успешности падает ниже некоторого порога. При этом, когда работа обработчика восстанавливается, он автоматически возвращается во флоу. Таким образом, большинство мелких инцидентов митигируются автоматически и не влияют на базовую функциональность цикла заказа.На графике в примере выше представлен пример такого инцидента. У одного из обработчиков (в данном случае offer-timeout-handling-pipeline/user-hooks/exclude_contractor) случился отказ примерно в 14:59. Через минуту процессинг включил для этого обработчика фолбэк на 5 минут, тем самым отключив его на это время из контура цикла заказа Такси.

Пример включения и выключения автоматического фолбэка
В результате на графике ошибок обработки заказов виден небольшой всплеск ошибок, но из‑за того что ProcaaS оперативно отключил ломающий обработчик, удалось избежать большого инцидента.

Как выглядит всплеск ошибок, подавленных фолбэком
Разумеется, далеко не все обработчики можно просто взять и выключить. Существует набор критичных для цикла заказа обработчиков, поломка которых приведёт к полному отказу. Однако таких обработчиков единицы и они реализуют «базовый флоу» заказа (mission critical). Все остальные, а их у нас уже более 200, — это обработчики для второстепенной функциональности, поломка которых неприятна для сервиса, но не критична (business critical).
Выводы
Процессинг Яндекс Такси в своём текущем виде появился для решения проблемы стабильности работы сервиса, которая порождалась монолитным процессингом. Это действительно позволило существенно снизить количество инцидентов в цикле заказа Такси. Многолетняя эксплуатация показала жизнеспособность такого подхода.Однако решение не лишено недостатков. Пожалуй, самая острая проблема на данный момент — трудозатраты на изучение DSL‑процессинга. Как показала практика, даже настолько простой DSL требует ненулевое время, чтобы погрузиться в него и научиться им пользоваться.
Другая сложность — это неочевидность способа решения продуктовых задач при помощи ProcaaS. Например, должна ли некоторая функциональность быть выражена в виде отдельного обработчика или для неё нужна новая стадия или даже пайплайн? Или, например, допустимо ли реализовывать какую‑то простую бизнес‑логику прямо в ProcaaS или же ProcaaS должен выполнять исключительно транспортную функцию? К сожалению, текущая модель не даёт однозначных ответов на эти вопросы.
Похоже, что в будущем команду процессинга ожидают новые вызовы. Пока мы строили свой процессинг, в индустрии появились новые решения, такие как Cadence/Temporal, подходы которых отличаются от наших и которые поэтому ценны для нас, поскольку позволяют взглянуть на задачу под новым углом. Опыт альтернативных подходов всегда достоин изучения, и этот не станет исключением в нашем поиске новых путей развития цикла заказа Такси.
Пишите комментарии и делитесь опытом, если вы работали с аналогичными системами.