Обработка заказов — это один из самых сложных аспектов в e-commerce, особенно когда речь идёт о микросервисной архитектуре. Большинство существующих систем используют хореографию для управления заказами, что сложно реализовать и часто приводит к беспорядку. Бизнес-требования разбиты на множество мелких задач, и обеспечить отказоустойчивость бывает трудно. В таких системах часто возникает низкая прозрачность, поиск дефектов может занять дни, а внедрение новой функциональности — месяцы. Проблему можно решить с помощью платформы для оркестрации рабочих процессов.

Привет, Хабр! Меня зовут Евгений Конечный, сейчас я руковожу командами Search & Discovery в Uzum Market, а до этого отвечал за клиентское направления в Uzum Tezkor — узбекский сервис по доставке готовой еды и продуктов питания. В Uzum Tezkor мне выпала редкая возможность написать сервис для обработки заказов с нуля. Мы реализовали его на основе Temporal. В этой статье поговорим о том, как устроена платформа, в чём её удобство и почему она подходит для построения систем управления заказами. Я расскажу, с какими проблемами мы столкнулись при  разработке, дам несколько советов, приведу примеры инцидентов и расскажу, какие используются метрики для работы с базами данных, одним из самых узких мест при использовании Temporal. И ещё немного затрону модель акторов.

Последние девять лет я пишу на Go, поэтому все примеры кода в статье будут на этом языке.

Что такое OMS

Система управления заказами, или Order Management System (OMS), — это платформа для управления жизненным циклом заказа в виде автоматизированных бизнес-процессов. Она имеет ряд интеграций с системами логистики, эквайринга, склада и так далее, и представляет собой отдельный сервис, группу сервисов или даже несколько доменов.

С помощью OMS сотрудник-пользователь оформляет заказ и управляет его дальнейшими статусами: назначает курьера, организует доставку, обрабатывает платежи и возвраты средств в случае проблем. В сервисе предусмотрен сбор продуктовых и операционных метрик, взаиморасчёты с ресторанами и инструменты для поддержки клиентов. В нашем продукте все эти функции интегрированы в OMS.

Как выбрали Temporal

Разрабатывать сервис начали в январе, а первый релиз запланировали через два месяца. Мы были ограничены сроками и выбором: сделать собственную систему управления заказами или использовать готовые решения, представив заказ как бизнес-процесс, который нужно только организовать.

Готовое решение рассматривали как один из возможных путей. Системы вроде Camunda или Zeebe позволяют моделировать бизнес-процессы с помощью BPMN-диаграмм и управлять ими. Также существуют декларативные методы описания бизнес-процессов, как делает Conductor от Netflix. Однако самый действенный подход — это описание бизнес-процессов в виде кода. Метод реализован в системе Cadence, разработанной в Uber и позже ставшей основой для платформы Temporal.

Рассмотрев варианты, мы остановились на разработке собственного сервиса. Вначале я написал прототип, в котором допустил все возможные ошибки, но после дальнейшего тестирования и SWOT-анализа мы выбрали Temporal. Это надёжная, распределённая и масштабируемая платформа для оркестрации и автоматизации рабочих процессов. Все аспекты здесь интегрированы в единую систему. Это позволяет разработчикам сосредоточиться на логике бизнес-процессов и не заботиться о сложностях управления состоянием, обработке ошибок и масштабировании инфраструктуры.

Причины выбора платформы:

  • Нативная поддержка Go. Temporal — продукт с открытым исходным кодом, написанный на Go. Это позволяет изучать как его SDK, так и основной код.

  • Возможность сразу писать бизнес-логику. Не нужно создавать дополнительные службы вроде очередей или таймеров, можно сосредоточиться на рабочих процессах.

  • Возможность тестирования и дебага. Так как рабочий процесс представлен в виде функции первого порядка, её удобно тестировать и отлаживать. При локальном запуске рабочего процесса удобно использовать стандартные инструменты отладки.

  • Аудит-лог + UI. Инструменты для аудита и визуализации идут «из коробки».

  • Ориентация на событийную архитектуру.

  • Инструменты версионирования.

  • Возможность масштабирования.

  • Документация и поддержка сообщества. Сначала у нас было много вопросов, мы получили ответы напрямую от разработчиков Temporal. Это подкупило.

  • Опыт крупных компаний. Крупные игроки, такие как Netflix и Yum! Brands, также активно используют Temporal.

Дальше расскажу, как устроена платформа.

Рабочий процесс в Temporal

Рабочий процесс, или Workflow, имеет четыре базовых свойства: 

  • отказоустойчивость;

  • выполнение задач;

  • реакция на внешние события;

  • поддержка таймеров и тайм-аутов.

В Temporal рабочий процесс можно представить как функцию первого порядка, которая принимает какой-то аргумент и запускается. Сам процесс описывается программным кодом и следующими атрибутами:

  1. Activity — задачи, которые необходимо выполнить в рамках процесса. Обычно они зависят от других компонентов системы, но не имеют состояния, в то время как рабочий процесс сохраняет состояние и независим. В Activity обычно оборачиваются все блокирующие операции, походы в другие микросервисы, запросы к базам данных. Эти действия должны быть идемпотентными, то есть повторное выполнение не должно влиять на результат.

  2. Signal — механизм реагирования на внешние события в рабочем процессе. Это способ коммуникации между рабочим процессом и внешним миром посредством уведомлений.

  3. Timers — таймеры и тайм-ауты, используемые для управления временем в рабочем процессе, например, для отложенного выполнения задач или ограничения времени на выполнение операций.

Workflow As Code 

Одна из концепций Temporal заключается в том, что бизнес-процессы, такие как создание заказа, программно описываются через код. Например, при создании заказа, можно кодировать шаги: проверка активности пользователя, получение корзины, выбор поставщика, создание платежа, применение промокода и так далее.

func Checkout(ctx workflow.Context, req *Request) (*models.Order, error) {
   var userProfile models.Profile
   if err := workflow.ExecuteActivity(ctx, GetUserProfile, req.CartId).Get(ctx, &userProfile); err != nil {
      return nil, err
   }
   if !userProfile.Active {
      return nil, errors.New("user is not active")
   }


   var cart models.Cart
   if err := workflow.ExecuteActivity(ctx, GetCart, req.CartId).Get(ctx, &cart); err != nil {
      return nil, err
   }


   // Если worker исполняющий workflow прервет исполнение кода на этой точке,
   // то Temporal перенесет состояние на другой worker и продолжит исполнение с этой же точки


   var payment models.Payment
   if err := workflow.ExecuteActivity(ctx, CreatePayment, cart.Total).Get(ctx, &payment); err != nil {
      return nil, err
   }


   // TODO: и дополнительные действия для создания заказа


   return &models.Order{}, nil
}

Temporal гарантирует надёжность процесса: если произойдет перезапуск или по каким-то причинам исполнение прервётся, то автоматически Temporal восстановит его состояние и обеспечит корректное продолжение.

Это достигается с помощью Stateful Execution Model, модели исполнения с сохранением состояния.

Stateful Execution Model

Как работает восстановление состояния:

  1. Определение рабочего процесса (Workflow Definition) отделено от его исполнения (Workflow Execution).

    Рабочий процесс делится на написанный код, выполняемый на воркерах, и текущее состояние, или исполнение, которое обрабатывается на сервере. Состояние процесса регулярно синхронизируется с сервером.

  2. Workflow Definition запрашивает Command и ожидает выполнение Awaitables.


    Код процесса получает команды от сервера, например: «продолжай выполнять Activity» или «остановись, Workflow перемещён на другой процесс».

  3. Command создаёт запись в Event History. Каждая команда, полученная от сервера, записывается как событие (Event) в историю, которая затем отправляется обратно в Temporal.

  4. Replay восстанавливает локальное состояние, сравнивая Command из Workflow Definition с содержимым Event History. Replay — это процесс, в котором Workflow пересматривает историю событий, сопоставляя каждую команду с соответствующим событием. Это позволяет системе понять, какие действия были выполнены и какие ещё предстоит выполнить. Replay доходит до конца истории событий, понимает, что ещё есть код, и в этот момент продолжает исполнение. Таким образом, он каждый раз восстанавливает своё состояние. Это похоже на «event sourcing», где вся история изменений используется для восстановления текущего состояния системы.

Инверсия исполнения

Одна из важных концепций Temporal — это инверсия исполнения (Inversion Execution). В отличие от «чёрного ящика», куда код в каком-то виде загружается для выполнения, Temporal позволяет разработчикам создавать собственные воркеры с использованием SDK, доступных для разных языков — Python, Go, PHP, TypeScript. Можно написать код, отдельно запустить воркер и подключиться к Temporal, которая развернута в собственной инфраструктуре (self-hosted) или в облаке.

При таком разделении разработчики отвечают за воркеры, а инфраструктурная команда отвечает за кластер Temporal. Воркеры и кластер Temporal могут масштабироваться независимо друг от друга.

Рабочие процессы и акторы

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

Рабочие процессы в Temporal делают примерно то же самое: обладают поведением, реагируют на сигналы, создавая другие рабочие процессы, отправляя сигналы другим процессам или меняя собственное поведение. При этом в Temporal реализована однонаправленная отправка сообщений через сигналы.

Сигнал в Temporal можно рассматривать как своего рода callback, позволяющий внешним системам взаимодействовать с рабочим процессом. В качестве примера приведу упрощённый код отмены заказа: система отправляет сигнал для запуска изменений:

workflow.
   NewSelector(ctx).
   AddReceive(
      workflow.GetSignalChannel(ctx, "Cancel"),
      func(c workflow.ReceiveChannel, more bool) {
         order.Status = models.OrderStatus_OrderStatusCancelled
      },
   ).Select(ctx)

Отправка и получение сообщений в рабочих процессах

Отправитель сигнала обычно не знает, применилось ли изменение и ожидалось ли оно вообще. Поэтому в Temporal есть несколько реализаций синхронной отправки сообщений:

  • One-Way Messages — односторонняя отправка, когда получатель не отправляет ответ обратно отправителю.

  • Message Passing — передача сообщений между компонентами системы, когда каждый компонент может реагировать на сообщения и передавать их дальше.

  • Update Handler — сравнительно новая механика, которая позволяет узнать о том, правильно ли изменение применилось к процессу.

    updateStatusHandler := func(ctx workflow.Context, orderStatus models.OrderStatus) error {
       order.Status = orderStatus
       return nil
    }
    workflow.SetUpdateHandler(ctx, "OrderStatus", updateStatusHandler)
  • Signal-Signal Pattern — когда рабочий процесс получает сигнал и в ответ отправляет другой сигнал. Этот способ раньше использовался для контроля применения изменений, например, при изменении статуса заказа.

    workflow.NewSelector(ctx).AddReceive(
       workflow.GetSignalChannel(ctx, "Cancel"),
       func(channel workflow.ReceiveChannel, more bool) {
          order.Status = models.OrderStatus_OrderStatusCancelled
          workflow.SignalExternalWorkflow(ctx, fmt.Sprintf("orders/%s", order.Id), "", "CancelOk", order.Status)
       },
    )
  • Signal-Query Pattern — подход, похожий на предыдущий, только вместо отправки ответного сигнала используется запрос состояния через функцию query. Так работает подтверждение о состоянии процесса.

    workflow.SetQueryHandler(ctx, "Status", func() (models.OrderStatus, error){
       return order.Status, nil
    })

Создание рабочих процессов в Temporal

Temporal обеспечивает эффективное взаимодействие между различными рабочими процессами, при этом разработчикам не нужно вводить дополнительных инструментов и контролей.

Механизмы, за счёт которых рабочие процессы взаимодействуют друг с другом:

  • Continue-As-New — позволяет рабочему процессу создавать сам себя или какой-то другой рабочий процесс в качестве продолжения.

    func EndlessOrder(ctx workflow.Context, order *models.Order) error {
       // TODO: тут какая-то логика
       return workflow.NewContinueAsNewError(ctx, EndlessOrder, order)
    }

    Для чего это нужно? Во-первых, у рабочего процесса есть ограничение в 20 тысяч событий в истории, а Continue-As-New позволяет его обойти. Во-вторых, когда нужно, чтобы один Workflow сменил другой.

  • Child Workflow, или дочерний рабочий процесс, создаётся и управляется из родительского. Он может функционировать автономно, но при этом сохраняет связь с родительским процессом.

    var childWE workflow.Execution
    if err := workflow.ExecuteChildWorkflow(ctx, Checkout).GetChildWorkflowExecution().Get(ctx, &childWE); err != nil {
       return nil, err
    }

    Дочерние рабочие процессы можно отвязывать через механику Abandon.

  • Abandon, или политика отказа, позволяет определить, как поведёт себя дочерний рабочий процесс, если родитель закроется.

    childWorkflowOptions := workflow.ChildWorkflowOptions{
       ParentClosePolicy: enums.PARENT_CLOSE_POLICY_ABANDON,
    }
    ctx = workflow.WithChildOptions(ctx, childWorkflowOptions)
    var childWE workflow.Execution
    if err := workflow.ExecuteChildWorkflow(ctx, Processing).GetChildWorkflowExecution().Get(ctx, &childWE); err != nil {
       return nil, err
    }

Архитектура OMS в Temporal

После выбора платформы мы столкнулись с дополнительными вопросами: как организовать постоянное хранение данных, настроить мониторинг и трассировку, а также обеспечить антикоррупционный слой для защиты данных?

База данных

Temporal поддерживает работу с пятью видами хранилищ: Elastic, MySQL, Postgres, Cassandra и SQLite. Elastic в основном используется для индексации и поиска по рабочим процессам, его оставили. Другой выбор стоял между Postgres и Cassandra. По умолчанию разработчики Temporal рекомендуют использовать Cassandra, так как её легче масштабировать. Но в нашей команде не было опыта работы с этой базой данных, поэтому остановились на связке Postgres и Elastic.

Что нужно учитывать при выборе БД для Temporal:

  • Ограничения системы. Temporal работает только с мастером, без возможности использовать реплики для чтения.

  • Высокая нагрузка на запись. У Temporal очень нагруженный паттерн использования, Write Heavy.

  • Учёт собственной нагрузки.

HTTP-сервер

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

Кроме этого, мы подняли Temporal UI, который позволяет удобно управлять рабочими процессами, отслеживать историю событий, состояние актуальных процессов, сбрасывать и принудительно завершать их.

Мониторинг

При выборе системы мониторинга у нас не было большого количества вариантов, так как мы уже использовали Datadog. К тому же он отлично интегрируется с Temporal. В Datadog мы используем трейсинг, профайлинг и логирование всех процессов, связанных с Temporal. При интеграции серьёзных проблем не возникло.

Воркеры

Вместе с Product-менеджерами мы нарисовали сложную BPMN-диаграмму, где отобразили процесс обработки заказа с учётом разных ролей. Получилась огромная схема, в которой выделили отдельные дорожки — они и есть воркеры.

Одним из короткоживущих рабочих процессов на диаграмме является Checkout. Он мог бы и не быть частью рабочего процесса, но здесь интересен встроенный механизм компенсации: например, возвращение промокода пользователю, если создание заказа не удалось. На случай неудачи здесь также предусмотрены дополнительные действия.

Далее идёт Processing — самый загруженный рабочий процесс из-за множества активностей, в том числе взаимодействия с ресторанами. Чтобы сэкономить ресурсы на разработку, мы объединили обработку заказа с процессингом оплаты в рамках одного рабочего процесса. Но правильнее было бы эти два процесса разделить. 

От Processing отделяются другие рабочие процессы, такие как логистическая обработка заказов (Delivery) с последующим назначением курьера (Courier Assign). А следом за ними — костыль Post Processing. Он используется, если по каким-то причинам надо отменить заказ, достигший финального состояния. По законодательству Узбекистана отмена должна произойти в течение 45 дней, поэтому постобработка на диаграмме имеет такой длинный хвост.

Теперь архитектура OMS выглядит следующим образом: две базы данных — Elastic и PostgreSQL, мониторинг с помощью Datadog, HTTP-сервер и воркеры, отображающие процесс обработки заказа.

Особенности разработки

Здесь я расскажу о том, что учесть и о чём подумать перед началом и в процессе разработки. Рекомендации основаны на ошибках, которые мы допустили сами.

Обёртка для Temporal Client

Создание обёртки для Temporal было первым, что мы не сделали изначально, но потом реализовали и значительно облегчили работу. Вот какие компоненты должны быть включены:

  • Logger. Temporal использует специальный logger, который учитывает, находится ли процесс в режиме replay. Стандартный logger надо адаптировать под специфику Temporal, чтобы логи были понятны и читаемы.

  • Metrics. Хорошей практикой будет интеграция метрик. Если используется Prometheus API, то по умолчанию с ним работает библиотека uber-go/tally от Uber. Её нужно сразу объявить и сконфигурировать так, чтобы не настраивать заново каждый раз.

  • Context Propagators. Важно обеспечить возможность проброса полей контекста. Если мы хотим использовать трейсинг, надо пробросить все поля, которые для него нужны. Лучше это делать сразу в одной библиотеке.

Share State VS Share Nothing

В начале разработки, когда мы писали Proof of Concept, казалось правильным управлять заказами через отдельную базу данных. Со временем стало понятно, что заказы могут храниться и непосредственно в Temporal, в рамках состояния рабочего процесса, без использования отдельной базы для активных заказов. Но при этом постоянное хранение по заказам всё равно требуется, нужно сохранять историю. Для этого мы направляем запросы клиентов через шину событий, записывая их в историю, а расчёты с партнёрами проводим через биллинг, куда отправляются заказы.

Такое решение имеет достоинства и недостатки. С одной стороны, нам не нужно беспокоиться о синхронизации данных в базе. С другой стороны, это усложняет внесение изменений в заказы и содержимое полей, так как нет инструментов для внесения корректив.

Polling и подходы к выполнению повторяющихся действий

Существуют две стратегии для повторяющихся действий в Temporal:

  1. Высокочастотные действия (Activities) с функцией контроля (Heartbeat) менее одной минуты. Эта стратегия подходит для частого выполнения, например, при постоянной проверке изменений у партнёра. Здесь можно использовать любой код, важно только регулярно отправлять сигналы, что процесс ещё активен.

    func (a *Activities) GetVendorStatus(ctx context.Context, req *temporal.PaymentStatusRequest) (*temporal.VendorOrderResponse, error) {
       for {
          resp, err := a.vendorsClient.GetOrder(ctx, req.Id)
          if err == nil && resp != nil && resp.Status != temporal.VendorOrderStatus_VendorOrderStatusNew {
             return resp, nil
          }
          activity.RecordHeartbeat(ctx)
          select {
          case <-ctx.Done():
             return nil, ctx.Err()
          case <-time.After(time.Second * 15):
          }
       }
    }

    Для всех высокочастотных действий с интервалом опроса меньше 60 секунд нужно использовать Activity с циклом внутри самой Activity и RecordHeartbeat, чтобы информировать рабочий процесс, что Activity ещё функционирует.

  2. Повторы низкочастотных действий для интервалов с помощью Retry Policy, равных или превышающих одну минуту. Такой подход можно использовать, когда не требуется частых обновлений. Я использовал этот метод, когда нужно было создать цикл рабочего процесса, который опрашивал заказ до достижения нужного состояния.

    workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
       StartToCloseTimeout: time.Second,
       RetryPolicy: &temporal.RetryPolicy{
          BackoffCoefficient: 1,
          InitialInterval:    time.Minute,
       },
    })
    
    
    var status models.VendorOrderStatus
    if err := workflow.ExecuteActivity(ctx, VendorOrderStatus, vendorOrderId).Get(ctx, &status); err != nil {
       return err
    }

    Этот способ хорош, но он создаёт много действий в истории событий и излишне нагружает OMS.

    for {
       if err = workflow.Sleep(ctx, time.Minute); err != nil {
          return err
       }
       vendorOrder, err := temporal.GetVendorOrder(ctx, &temporal.VendorOrderRequest{
          Id: vendorOrderResponse.Id,
       })
       if err != nil {
          return err
       }
       if vendorOrder.Status == temporal.VendorOrderStatus_VendorOrderCancelled {
          return errors.New("vendor cancel order")
       }
       if vendorOrder.Status > temporal.VendorOrderStatus_VendorOrderInDelivery {
          break
       }
    }

Fail State и управление ошибками

В Temporal мы оркестрируем события таким образом, чтобы адекватно реагировать на ошибки и предусмотреть случаи полного отказа системы. Любой заказ может перейти в состояние сбоя (Fail State), если его нельзя отменить или выполнить Activity. Причины могут быть разные, но ошибки такого рода и политику их обработки желательно предусмотреть. Ниже приведены примеры сбоев, с которыми столкнулся наш сервис:

  • Проблемы во внешних системах. Например, сервис доставил заказ, деньги списали, но чек напечатать не получается. В итоге заказ нужно отменить. Однажды система фискализации в Узбекистане вышла из строя на несколько дней, и нам пришлось отменять все заказы.

  • Ошибки в исходных данных. Например, лишние символы, пробелы, некорректный формат значений.

  • Некорректные данные из внешней системы. Однажды система фискализации начала отдавать тестовые чеки, и нам приходилось менять их вручную на реальные. Может показаться, что проблема решаема, если бесконечно пытаться отправить, ведь в какой-то момент система восстановится. Но это не лучшее решение, потому что рабочий процесс в Temporal зависает в этом Activity, и его нельзя нормально отменить, только прервать внутри UI. Поэтому есть другие подходы к управлению ошибками:  

    • Возможность отредактировать исходные данные через сигналы.

    • Ручное повторение Activity, если всё сломалось и нужно вернуться, откатиться, перепечатать чек или сделать что-то другое.

    • Эскалация в поддержку, когда заказ перешёл в состояние сбоя.

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

Следующее, о чём надо подумать, это ошибки недетерминированного поведения.

Non-Determinism в Temporal

Сообщения о недетерминизме в UI Temporal или в системных логах означает, что всё очень плохо.

Скорее всего, это происходит, когда Workflow перезапустился, идёт в режим replay, и то, что написано в нашем коде, уже не соответствует записям в истории событий. В этот момент Temporal обнаруживает несоответствия, видит, что код недетерминированный, и падает. Это опасно из-за возможных последствий:

  • Может привести к сбою заказов.

  • После хотфикса остаются проблемные заказы, созданные в «окно» между возникновением и устранением проблемы.

  • Перезапуск рабочего процесса довольно ограничен. Даже если откатить воркер, новые заказы, которые дошли до момента недетерминированного поведения, теперь станут недетерминированными. Это требует ручных манипуляций. Обычно мы исправляли это через terminate, то есть просто убивали эти Workflow и теряли заказы.

О том, как избежать недетерминизма, есть несколько практических советов:

  • Версионирование кода для управления изменениями. Версия создаёт в истории событий маркер, которым помечаются все события, и версией мы помечаем участок кода. Выглядит это таким образом:

    // Здесь мы добавляем бронирование стоков в уже существующие рабочие процессы,
    // так чтобы не сломать активные заказы
    v := workflow.GetVersion(ctx, "ReserveStock", workflow.DefaultVersion, 1)
    if v > 0 {
       if err := workflow.ExecuteActivity(ctx, ReserveStock, products).Get(ctx, nil); err != nil {
          return err
       }
    }

    Если рабочий процесс растянут во времени, например, обработка платежа занимает 40-45 дней, то требуется менеджмент и регулярная очистка старых и устаревших версий.

  • Использование Workflow Check. Необходимо использовать линтер, который показывает все недетерминированные моменты.

  • Использование replay-тестов. Они помогают избежать регрессии в рабочем процессе. Для этого нужно зайти в UI Temporal, скачать JSON с историей событий и использовать его в коде:

    replayer := worker.NewWorkflowReplayer()
    temporal.RegisterCustomerFlowWorkflow(replayer, customer.Register)
    err := replayer.ReplayWorkflowHistoryFromJSONFile(nil, filepath)
    require.NoError(s.T(), err)

    Это гарантирует, что изменения в коде не сломают старый рабочий процесс. Важно регулярно актуализировать replay-тесты. Была ситуация, когда все они устарели на несколько месяцев и не покрывали новые версии кода.

  • Использование побочных эффектов для недетерминированной логики. Например, для генерации order ID, чтобы идентифицировать заказ.

    encodedValue := workflow.SideEffect(ctx, func(ctx workflow.Context) interface{} {
       return uuid.NewString()
    })
    
    
    if err := encodedValue.Get(&order.Id); err != nil {
       return nil, err
    }

    Это позволяет закрывать случайные значения или UID. Создаётся отдельное событие в истории, в котором кешировано выполнение функции.

  • Использование правильных конструкций на Go. Необходимо предусматривать все недетерминированные моменты и те, что могут выпасть в момент replay. Применяйте следующие конструкции вместо стандартных в Go:

На другом языке конструкции могут быть другими, они обычно описаны в документации.

Рекомендации на этапе разработки 

Дам несколько советов о том, что учесть в работе с Temporal:

  • Напишите обертку для Temporal Client.

  • Выберите стратегию работы с состоянием.

  • Продумайте, как работать с каждым ретраем.

  • Предусмотрите, что делать в случае отказа, когда нельзя завершить рабочий процесс.

  • Избегайте недетерминированного поведения с помощью линтера, версионирования и replay-тестов.

Дополнительно надо подумать над спецификацией, то есть специфицировать activity, сигналы, запуск Workflow, чтобы потом по этим спецификациям сгенерировать код и сделать взаимодействие между рабочими процессами менее болезненным.

Оптимизация и производительность Temporal в проде

Одним из узких мест Temporal является БД, поэтому расскажу, как мы потребляем имеющиеся ресурсы при нашей нагрузке. Наша скромная виртуалка состоит из шести ядер и 24 гигабайтов памяти. На ней запущен отдельный instance Postgres для Temporal.

В начале графика виден всплеск нагрузки — это мы снимали бэкап. В остальное время БД не утилизируется, на CPU какой-то значимой нагрузки нет. При этом, если посмотреть на ночную активность (в Узбекистане ночью доставка еды не работает, курьеры спят, рестораны закрыты, нашим приложением в проде тоже никто не пользуется), видно, что Temporal греет воздух. Точнее, Temporal не исполняет никакие рабочие процессы, а просто проверяет, не появились ли в очереди задачи через поллинг базы данных.

Temporal достигает 100 запросов в секунду и 70 транзакций в секунду, когда, казалось бы, активности не должно быть. Днём, на пике, когда активно идут заказы, Temporal разгоняется до 1800 запросов в секунду и почти 700 транзакций в секунду.

Для сравнения, в моём «велосипеде» были бы пара запросов и пара транзакций в секунду, то есть в десятки раз меньше. Temporal очень активно использует базу данных.

Топ запросов за сутки выглядит примерно так:

Есть сотни тысяч запросов, но их суммарное время за сутки исполнения обычно не превышает 10 секунд. Нет каких-то «тяжёлых» запросов, которые могут оказать негативное влияние на систему. Мы видим большой запас для работы с Postgres. Но чтобы понять пределы производительности нашей базы данных, мы проводили стресс-тесты.

Стресс-тестирование

Temporal предлагает два варианта стресс-тестов, которыми пользуются сами авторы:

  1. Rabbit Scenario. Как известно, кролики любят размножаться, как и рабочие процессы. Temporal запускает в тесте рутовый процесс, потом большое количество дочерних, каждый из которых запускает ещё большее количество дочерних процессов. И в какой-то момент такая скачкообразная нагрузка должна уронить либо базу данных, либо кластер Temporal. 

Наш подход к тестированию больше похож на второй вариант, который называется «сценарий реактора».

  1. Reactor Scenario. Этот тест предполагает долговременный запуск рабочих процессов, которые выполняются параллельно и содержат большое количество activity. Temporal предлагает набор утилит для проверки производительности:

Разработчики Temporal в Slack рекомендуют использовать первую утилиту, Omes. Она представляет собой генератор нагрузки, где можно задать количество activity, сигналов и дочерних процессов для запуска. Таким образом можно нагрузить свой кластер Temporal.

Мы форкнули эту утилиту, так как её пока нельзя использовать как библиотеку, и немного модифицировали сценарий под себя, добавив sleep activity для имитации средней длительности ожидания наших activity. После запуска смотрели, что происходит с базой данных, с задержками, насколько растёт Transactions Per Second и прочие метрики. Полученные результаты показали, что на данный момент у нас есть большой запас мощности, и ни Temporal, ни Postgres не являются узкими местами. Поэтому можем двигаться дальше.

Я прикидывал на своём ноутбуке, какая будет ёмкость по заказам: сколько заказов я смогу обработать. Написал утилиту с помощью Omas и Temporalite (это бинарник, в который вшиты SQLite, Elastic и другие компоненты, то есть весь Temporal в одном бинаре). Запустил в in-memory режиме и прогнал на наших синтетических Workflow.

Предположительно, у меня было 660 заказов, прежде чем началась деградация. Возможно, это дало бы мне 36 тысяч заказов на ноутбуке, но это неправда.

Таким образом можно делать прикидки и оценивать, какой кластер Temporal выдержит определённые метрики и сколько заказов он сможет обработать в день.

Инциденты на проде

Бонус: теперь расскажу об инцидентах, которые у меня были в проде и запомнились больше всего.

PG Bouncer: TX Mode 79

Как-то раз зашёл в UI Temporal и начинал кликать по разным процессам. Некоторые из них отдавали 500-е ошибки, а некоторые открывались нормально.

Оказалось, в Temporal есть так называемый Sticky Cache (липкий кеш), который кеширует исполнение рабочего процесса на своей стороне и делает его доступным. Если процесс был кеширован, то он открывался, а если не кеширован, то система пыталась получить его из истории событий и падала.

Выяснилось, что наши DevOps’ы 20 минут назад переключили PG Bouncer в Transaction Mode, а Temporal использует Prepared Statement. В итоге всё развалилось, потому что мы это не протестировали и не объявили, что так делать нельзя. Было очень плохо, поправили, добавили мониторинг, чтобы избежать подобных инцидентов в будущем.

День, когда процессинг остановился

После коммерческого запуска у нас начался рост количества заказов, и вдруг приходит такое сообщение:

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

Выяснилось, что мы запустили PG Bouncer для пользователя Temporal в Postgres со стандартным количеством подключений и упёрлись в лимит в 24 подключения.

Настроили базу данных, конфигурацию Temporal, увеличили количество подключений до 100 и перезапустили. Сейчас система при большой нагрузке использует 30 подключений, но за этот рубеж не выходит. Разработчики Temporal подтвердили, что у них при тестировании также используется около 100 подключений, поэтому мы решили пока оставить этот параметр без изменений.

OOM на процессинге

Последний инцидент обошёлся без жертв. Каждый день примерно в одно и то же время приходил алерт из Datadog, сообщавший, что OOM-киллер убил наш воркер, отвечающий за процессинг.

Так как Temporal устойчив к перезапуску, казалось, что всё нормально и можно разобраться позже. В итоге я разбирался с этим гораздо дольше, чем следовало. Провёл continuous profiling, проверил утечки памяти — всё в порядке. Потом мне посоветовали разделить воркеры на те, которые отвечают за activity и Workflow. Проблема оказалась в воркере, отвечающем за процесс.

Когда я открыл документацию, на первой же странице было написано про Sticky Cache. По умолчанию он кеширует в себе 10 тысяч рабочих процессов для улучшения производительности. Каждый закешированный процесс потребляет горутину и какое-то количество ресурсов для хранения истории событий. Это приводило к превышению лимита памяти, выделенной для пода с воркером. Мы посчитали, сколько нам нужно для этого пода, поставили нужное количество памяти, и теперь всё работает без проблем — память не течёт.

Выводы

Использование Temporal в качестве решения для управления заказами может существенно улучшить процессы. Система эффективно справляется с большим количеством запросов и транзакций, хорошо управляет параллельными рабочими процессами и легко масштабируется для обработки растущих объёмов заказов. При наличии адекватного мониторинга Temporal позволяет быстро реагировать на инциденты.

Тем не менее, как и с любой другой системой, за Temporal необходимо следить: разобраться, как избегать недетерминированного поведения, настраивать мониторинг, проводить стресс-тесты, находить и устранять ошибки, а также продумывать архитектуру с учетом специфики платформы.

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

P. S. Если вы предпочитаете смотреть, а не читать, то вот видеозапись моего доклада, по которому написана эта статья:

Завтра, 3 декабря, я снова выступлю на HighLoad++ Conf2024. Там я расскажу про то, как современные распределенные системы подарили нам целый пласт интересных проблем, которые приходится решать в отказоустойчивых системах. Приходите!

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


  1. Atorian
    02.12.2024 14:29

    Спасибо за статью. А есть причина по которой не стали смотреть на aws step functions?


    1. 33rd Автор
      02.12.2024 14:29

      Мы не рассматривали службы AWS или других облачных платформ при проектировании, поэтому не могли на них завязываться. Если быть честным, то при старте разработки мы рассматривали только три варианта:

      • Camunda

      • Temporal

      • Свое решение с PG и Kafka


    1. alhimik45
      02.12.2024 14:29

      Ох, имел дело со Step Functions. Не понимаю как с ними работать, если у тебя Infrastructure as a Code. В итоге получались json на 1000 строк c описаниями workflow, упиханные в CloudFormation темплейты, изменения в которых нормально на ревью не отсмотришь. Да ещё процесс decision making упиханный в это json описание фиг покроешь тестами.

      Я в итоге вдохновляясь Temporal и Azure Durable Functions за пару дней написал библиотечку, которая позволяет описывать workflow как код, а в качестве движка выполнения использует единожды написанную общую Step Function. И жить сразу стало лучше.


  1. evgeniy_kudinov
    02.12.2024 14:29

    Интересный инструмент.
    А как код структурно размещаете в репозитории(несколько микросервисов) и производите деплой можете рассказать?