Pull to refresh

Мутации в микросервисах: применяем Temporal

Level of difficultyMedium
Reading time10 min
Views13K

Всем привет!
В прошлой статье была поставлена задача о надёжных мутациях и транзакциях в архитектуре Профи, в этой статье разберём один из вариантов решения — применить workflow-engine Temporal.

Почему именно Temporal

Впервые я наткнулся на Temporal в Technology Radar от ThoughtWorks, где запомнилась эта цитата:

Although we don’t recommend using distributed transactions in microservice architectures, if you do need to implement them or long-running Sagas, you may want to look at Temporal. https://www.thoughtworks.com/en-us/radar/platforms/temporal

Помимо Temporal существуют ещё десятки workflow engines, однако, если пройтись по ним и оставить только решения с приличным количеством звёзд, то список сильно сократится. Далее применяем фильтр по потенциальной подходимости нашим критериям и остаётся 2-3 кандидата, один из которых — Temporal. Фичи, которые сходу показались интересными:

  • Алгоритмы транзакций и мутации можно описывать кодом на Go, TypeScript, PHP, Python и т.п.

  • При этом код выполняется децентрализованно — Temporal только координирует.

  • При ошибках поддерживаются повторы.

  • Есть GUI, где можно отслеживать процессы и управлять транзакциями.

  • В целом неплохая документация.

Как работает Temporal

Если коротко и упрощённо то получается:

  • Есть сценарии (далее workflow).

  • Сценарии состоят из шагов (далее activity).

  • Temporal-кластер оркестрирует запуск workflow и activity

  • Сами workflow и activity непосредственно выполняются распределёнными воркерами.

  • Ну и есть приложения (в широком смысле), которые соединяются с Temporal-кластером и говорят — запусти такой-то workflow.

Если хотите разобраться в деталях, то рекомендую следующую последовательность:

Задача про вынос биллинга

Профи — это маркетплейс, где есть специалисты предоставляющие услуги и клиенты, а основной бизнес-сценарий выглядит так:

  • Клиент оставляет заказ на услугу.

  • Специалист, если заказ ему понравился, отправляет на него заявку, которая в общем случае — платная.

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

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

Какие тут есть проблемы:

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

  • Хранение финансовых и продуктовых данных в одной БД, конечно, не катастрофа. Однако данные, влияющие на финансовую отчётность и пробитие чеков лучше хранить отдельно от продуктовых данных у которых, так сказать, произвольный уровень качества :)

  • Если вдруг падает БД или моргнула сеть, то мы теряем корни заявки. Такое случается крайне редко, так что аргумент скорее теоретический, но круто иметь механизм, который допинает заявку в случае очень неприятных ситуаций.

А вот такую картинку хочется получить в итоге: разделить сценарий на шаги и выполнять их с надёжностью сравнимой с классическими MySQL транзакциями.

Собираем прототип

Очень круто то, что Temporal позволяет собрать прототип используя уже существующий код:

  • Поднимаем Temporal кластер;

  • Создаём Temporal-совместимые классы для workflow и activity;

  • Добавляем вызовы существующей бизнес-логики внутрь созданных классов;

  • Запускаем воркер, который выполняет workflow и activity.

  • Внедряем код запуска workflow в текущую бизнес-логику.

Запускаем кластер

Тут всё просто и хорошо описано в официальной инструкции — пара команд и сервер установлен. Для запуска сервера в dev-режиме вбиваем в консоль temporal server start-dev --ip 0.0.0.0 и проверяем, что по адресу ваш_хост:8233 отдаётся GUI:

Стартовый код

Примерно так выглядит изначальный код сценария заявки с царь-транзакцией:

<?php

class OrderApplier {

  public function apply(
    int $orderId,
    string $specId,
    OrderApplyParams $params,
  ): int
  {
    if (!$this->applyValidator->couldBeApplied($orderId, $specId, $params)) {
      throw new CouldNotApply();
    }
    $apply = null;
    $chatTask = null;
    $this->db->beginTransaction();
    try {
      $apply = $this->applyWriter->createApply($orderId, $specId, $params); 
      $this->ordersWriter->applyForOrder($orderId, $apply); 
      
      // Внутри billApply — Несколько INSERT-ов и UPDATE-ов фин-таблиц
      $this->billing->billApply($apply);

      // Cохраняем таску в outbox таблицу
      $chatTask = $this->tasks->createTask(new CreateApplyChatTask($apply)); 
      $this->db->commit();
    } catch (Throwable $e) {
      $this->db->rollBack();
      throw $e;
    }
    // В конце выполнения этой таски запускается следующая — пост-процессинг 
    // (сброс кешей, аналитика и т.п.)
    $this->tasks->queueTask($chatTask);
    return $apply;
  }
}

Создаём activities

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

Предварительная проверка (через имплементацию интерфейса)

<?php

#[ActivityInterface]
interface OrderApplyValidatorInterface {
  public function couldBeApplied(
    int $orderId,
    string $specId,
    OrderApplyParams $params
  ): bool;
}

class OrderApplyValidator implements OrderApplyValidatorInterface {
    public function couldBeApplied(
      int $orderId,
      string $specId,
      OrderApplyParams $params
    ): bool { 
      ...
      // бизнес-логика проверки
      ...
      return true;
    }
}

Запись заявки в базу (без имплементации интерфейса)

<?php

class CreateApplyResult {
  public ?Apply $apply;
  public int $errorCode = 0;
}

#[ActivityInterface]
class OrderApplyCreator {
  public function createApply(
    int $orderId,
    string $specId,
    OrderApplyParams $params
  ): CreateApplyResult { 
    $this->db->beginTransaction();
    try {
      $apply = $this->applyWriter->createApply($orderId, $specId, $params); 
      $this->ordersWriter->applyForOrder($orderId, $apply); 
      $this->db->commit();
      return new CreateApplyResult($apply);
    } catch (Throwable $e) {
      $this->db->rollBack();
      return new CreateApplyResult(null, $e->getCode());
    }
  }
}

Биллинг

<?php

class BillApplyResult {
  public int $errorCode = 0;
}

#[ActivityInterface]
interface OrderApplyBillerInterface {
  public function billApply(Apply $apply): BillApplyResult;
}

class OrderApplyBiller implements OrderApplyBillerInterface {
    public function billApply(Apply $apply): BillApplyResult { 
      ...
      // локальная транзакция биллинга
      ...
      return new BillApplyResult(...);
    }
}

Создание чата

<?php

#[ActivityInterface]
interface OrderApplyChatCreatorInterface {
  public function createChat(Apply $apply): void;
}

class OrderApplyChatCreator implements OrderApplyChatCreatorInterface {
  public function createChat(Apply $apply): void { 
    ...
    // бизнес-логика создания чата
    ...
  }
}

Ну в общем понятно — постпроцессинг делается по аналогии.

Важный момент — все аргументы методов и возвращаемые значения должны быть либо сериализуемыми, либо имплементировать интерфейс Data Converter.

Создаём workflow

Как и в случае с activity, для workflow нужно создать Temporal-совместимый интерфейс, опять же — не обязательно, но мы сделаем как в официальных доках:

<?php

class ApplyForOrderResult {
  public ?Apply $apply;
  public int $errorCode = 0;
}

#[WorkflowInterface]
interface ApplyForOrderWorkflowInterface {
  #[WorkflowMethod("applyForOrder")]
  #[ReturnType(ApplyForOrderResult::class)]
  public function applyForOrder(
    int $orderId,
    string $specId,
    OrderApplyParams $params
  );
}

Далее создаём класс, который имплементирует этот интерфейс — в нём и реализуется основная логика нашей надёжной транзакции:

<?php

class ApplyForOrderWorkflow implements ApplyForOrderWorkflowInterface
{
  public function __construct()
  {
    // Создаём activity-стабы (RPC) для вызова activity-методов
    $this->validator = Workflow::newActivityStub(
      OrderApplyValidatorInterface::class,
      ActivityOptions::new()->withStartToCloseTimeout(
        \DateInterval::createFromDateString('3 seconds')
      )
    );

    $this->creator = Workflow::newActivityStub(
      OrderApplyCreator::class,
      //...
    );

    // Пример того, как можно создать activity-стаб для activity,
    // которая реализована в другом сервисе или на другом стеке
    $this->biller = Workflow::newUntypedActivityStub(
      ActivityOptions::new()->withStartToCloseTimeout(
        \DateInterval::createFromDateString('5 seconds')
      )
    );

    $this->chat = Workflow::newActivityStub(
      OrderApplyChatCreatorInterface::class,
      //...
    );
  }

  public function applyForOrder(
    int $orderId,
    string $specId,
    OrderApplyParams $params
  )
  {
    $couldBeApplied = yield $this->validator->couldBeApplied(
      $orderId, $specId, $params
    );
    if (!$couldBeApplied) {
      return new ApplyForOrderResult(null, 1);
    }

    $createResult = yield $this->creator->createApply(
      $orderId, $specId, $params
    );
    if ($createResult->errorCode != 0) {
      return new ApplyForOrderResult(null, $createResult->error_code);
    }

    // Пример того, как можно вызвать activity из другой кодовой базы — по имени.
    $billingResult = yield $this->biller->execute(
      'billApply', 
      [$createResult->apply], 
      BillApplyResult::class // Мапим результат на внутренний класс
    );
    if ($billingResult->errorCode != 0) {
      // 
      // @todo сюда можно добавить логику отката заявки т.к. биллинг не прошёл
      //
      return new ApplyForOrderResult(null, $billingResult->error_code); 
    }

    yield $this->chat->createChat($createResult->apply);

    return new ApplyForOrderResult($createResult->apply);
  }
}

На что здесь стоит обратить внимание:

  • При создании activity-стабов можно указать различные настройки — таймауты, рейт-лимиты и т.п.

  • Activity могут находиться в разных микросервисах и могут быть написаны на разных языках.

  • Конструкцию $foo = yield bar() можно воспринимать как аналогию await в TypeScript или Python. Если интересно, то можно почитать про механику этой непривычной для PHP конструкции.

Создаём и запускаем воркер

Temporal работает через RoadRunner — высокопроизводительный инструмент, позволяющий эффективно запускать PHP-приложения в долгоживущем режиме.

Настраиваем RoadRunner

temporal:
  address: ${хост_порт_temporal_кластера}
  activities:
    num_workers: 8
  
rpc:
  enable: true
  listen: tcp://127.0.0.1:6001

server:
  command: "php worker.php"

Пишем скрипт воркера

<?php

...

$container = Container::getInstance();
$factory = WorkerFactory::create();
$worker = $factory->newWorker();

// Регистрируем какие workflow будет выполнять воркер
$worker->registerWorkflowTypes(
    ApplyForOrderWorkflow::class    
);

// Аналогично регистрируем activity

$worker->registerActivity(
    OrderApplyValidator::class,
    fn(ReflectionClass $class) => $container->get($class->getName())
);

... 

$worker->registerActivity(
    OrderApplyChatCreator::class,
    fn(ReflectionClass $class) => $container->get($class->getName())
);

$factory->run();

Запускам воркер через RoadRunner и слушаем очередь

Наблюдаем за воркерами

Запускаем workflow

Осталось последнее — добавить запуск workflow в бизнес логику и протестить, что всё работает. Workflow можно запускать синхронно и асинхронно. В нашем случае мы пойдём по синхронному пути т.к. фронтенд пока не готов к асинхронному созданию заявки, а для прототипа достаточно будет и синхронного варианта. Однако, 99% workflow, конечно же, должны работать асинхронно т.к. повторы, задержки и вот это вот всё.

Код синхронного запуска workflow выглядит так:

<?php
...
$result = $this->apply_workflow->applyForOrder(
  $orderId,
  $specId,
  $params
);
...

Тестируем создание заявки

Находим активного специалиста с балансом, заходим под ним в тестовый бекофис и отправляем заявку:

Заявка успешно отправлена, деньги списаны, чат создан:

Идём в GUI Temporal и смотрим на логи нашего workflow:

Обработка падений

Во время работы над прототипом я случайно и намеренно создавал ситуации, при которых workflow не отрабатывал:

  • Исключения и ошибки в коде activity.

  • Выключенные воркеры.

  • Недетерминированный код workflow.

  • Ошибка MySQL has gone away т.к. код не был приспособлен к долгоиграющим скриптам.

  • и т.п.

Все эти кейсы Temporal успешно отработал, даже сценарии с недетерминированным кодом, а вот так выглядит ошибки в GUI:

Вроде бы всё рассказал, пора подводить итоги.

Плюсы Temporal

Универсальность

  • Поддерживает весь наш стек — PHP, TypeScript и Python.

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

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

  • Более простой путь по интеграции ML-алгоритмов в наши бизнес-процессы.

  • В будущем можно запилить инструмент для продактов, которые смогут в GUI собирать бизнес-сценарии из activity и играть между ними в А/Б тесты.

Надёжность

  • Temporal изначально был разработан в Uber, также его используют такие бигтехи как Snap и Netflix — это внушает доверие.

  • Если workflow стартовал, то Temporal выполнит его до конца c учётом retry policy. Даже если упали воркеры, отвалились сервисы, что-то пошло не так — workflow будет выполнен.

  • Для activity можно определять rate limits и retry policy.

  • Ошибки можно обработать в коде workflow и тут же запустить откат-сценарии.

Разные плюшки

Минусы Temporal

Риск развития микросервисного монолита

  • Хоть номинально все RPC вызовы и проходят через один Temporal кластер, но гибких возможностей выстраивать политику кто и что может, вроде как, нельзя. Поэтому сервисы практически бесконтрольно могут дёргать друг-друга.

Высокий порог входа

  • Разработчикам обязательно нужен онбординг и обучение — едва ли можно сходу разобраться в том, как эффективно “программировать” на Temporal.

  • Поскольку Temporal становится одним из основных компонентов инфраструктуры, то devops-ы должны знать все детали и особенности его работы в production, а также уметь быстро чинить проблемы.

Разные неприятности

  • Нужно следить за зоопарком воркеров — за статусом, за актуальностью кода и т.п. Это важно как для прода, так и для окружения разработки.

  • Код workflow должен быть детерминирован, однако встроенная поддержка версионирования для workflow и воркеров позволяет реализовать обратную совместимость.

  • Сигнатуры методов workflow и activity в идеале не должны меняться.

  • 99% workflow будут запускаться асинхронно — нужно допиливать клиентов, чтобы статус процесса отображался корректно.

  • Оверхед на код — придётся писать больше кода, но во времена copilot-а это не такая уж и серьезная проблема.

Выводы

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

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

Спасибо что дочитали!
Пока :)

P.S. Если среди вас есть те, кто уже применяет Temporal, то буду благодарен за кейсы в комментариях.

Статьи из серии

Первая часть про постановку задачи: https://habr.com/ru/articles/770122/
Вторая часть про Temporal: эта статья
Третья часть про TSQM: https://habr.com/ru/articles/776794/

Tags:
Hubs:
Total votes 13: ↑12 and ↓1+13
Comments16

Articles