Ручная пила для PHP-монолита

—

от автора

Привет! Это третья и заключительная часть истории поиска надёжного способа работы с транзакциями в распределённых системах.

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

Статья может быть полезна разработчикам и архитекторам, которые задумываются о вынесении части бизнес-логики из монолита, написанного на PHP.

Задача и основные требования

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

  • Для этого нужна технология, которая позволит надёжно выполнять мутации и транзакции в распределённой системе.

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

Подробнее про постановку задачи можно почитать тут. 

Disclaimer: некоторый код в статье может не работать т.к. нужен только для демонстрации алгоритмов.

Для иллюстрации логики решения возьмём транзакцию-прототип одного из сценариев реального продукта. Наша задача — вынести метод purchase в отдельный микросервис.

<?php class Greeter {     function greet($name) {         $valid = $validator->validateName($name);         if (!$valid) {             return false;         }         $pdo->beginTransaction();         try {             $greeting = $repository->createGreeting($name);             $purchaser->purchase($greeting);              $sender->send($greeting);             $pdo->commit();             return $greeting;         } catch (Exception $e) {             $pdo->rollback();             return false;         }         } }

Логика решения

Основные тезисы

  • Делаем свой движок для старта, отслеживания, завершения и отката шагов т.к. больше не можем использовать ACID-транзакции.

  • Добавляем возможность повторять шаги т.к. теперь у нас распределённая система, со всеми вытекающими.

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

  • Упрощаем API движка, используя PHP-генераторы.

В результате транзакция выглядит примерно так:

<?php class Greeter {     function greet($name) {         $valid = yield $validatorTasks->validateName($name);         if (!$valid) {             return false;         }         $greeting = yield $repositoryTasks->createGreeting($name);         try {             $task = $purchaserTasks->purchase($greeting);             yield $task->setRetryPolicy(                 (new RetryPolicy())->setMaxRetries(3)             );         } catch (Exception $e) {             yield $reverterTasks->revert($greeting);             return false;         }         yield $senderTasks->send($greeting);         return $greeting;     }     }

Запускается так:

<?php  // Превращаем существующий класс-врарпер для шагов транзакции $greeterTasks = new Tasks(new Greeter()); $task = $greeterTasks->greet("John Doe");  // Создаём запуск с политикой повторов $run = $engine->createRun(     $task->setRetryPolicy(         (new RetryPolicy())->setMaxRetries(3)     ) );  // Запускаем и получаем результат $result = $engine->performRun($run); if ($result->isReady()) {     echo $result->Data(); } 

Повторяем вот так:

<?php // Получем id запуска из базы или очереди $runId = ... $run = $engine->getRun($runId); // Запускаем повтор и получаем результат $result = $engine->performRun($run); if ($result->isReady()) {     echo $result->Data(); } 

Получаем результаты в любой момент времени:

<?php $run = $engine->getRun($runId); $result = $engine->getRunResult($run);

Далее разберу как я пришёл к этому решению и дам ссылку на работающую библиотеку.

Этап 1: Отслеживаем состояние транзакции

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

<?php  // Убираем ACID-транзакцию и добавляем ручную обработку отката // $validator, $repository, $purchaser и т.п. — классы с бизнес-логикой  class Greeter {     function greet($name) {         $valid = $validator->validateName($name);         if (!$valid) {             return false;         }         $greeting = $repository->createGreeting($name);         try {             // Внутри purchase происходит GQL-вызов внешнего микросервиса              $purchaser->purchase($greeting);          } catch (Exception $e) {             $reverter->revert($greeting);             return false;         }         $sender->send($greeting);         return $greeting;     } }  // Здесь будем держать callable для запуска Greeter::greet class Transaction {     function __construct(callable $callable); }  // Конкретный запуск транзакции class Run {     function __construct(Transaction $tran); }  // Сам движок class Engine {     function createRun(Transaction $tran): Run {         // Тут нужно создать ID по которому можно будет          // отслеживать состояние транзакции и получать результат.     }      function performRun(Run $run) {         $this->start($run); // Пишем признак начала транзакции         $result = $run->task->callable(); // Запускаем Greeter::greet         $this->finish($run); // Сохраняем признак окончания         return $result;      } }  // Собираем всё вместе $greeter = new Greeter(); $tran = new Transaction(fn() => $greeter->greet('John Doe')); $run = $engine->createRun($tran); $result = $engine->performRun($run); 

В случае, если вызов purchase упадёт, у нас будет запись о начале транзакции, но не будет об окончании и мы сможем решить что делать дальше — повторить, откатить или, например, отправить на ручной разбор.

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

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

  • Откат $reverter->revert($greeting)тоже может упасть.

  • В случае повторного запуска выполняются все шаги, хотя некоторые уже были выполнены в исходном запуске.

  • В общем случае повторы выполняются асинхронно — мы можем получить непредсказуемое состояние данных, если между повторами изменился код Greeting::greet. Например, в новом коде мы стали иначе создавать $greeting.

Этап 2: Записываем и проверяем шаги

Чтобы двигаться дальше, нужно завернуть шаги транзакции в прокси-класс, который позволит:

  • Кешировать результаты работы бизнес-логики отдельных шагов.

  • Проверять детерминированность.

  • Ловить исключения, чтобы отправлять конкретный шаг на повтор.

Добавляем прокси-класс:

<?php  // Здесь будем держать callable для запуска бизнес-логики шагов class Task {     public function __construct(public callable $callable); }  // Вместо непосредственного вызова бизнес-логики шагов // создаём прокси-объекты, которые будут запускаться через движок class Greeter {     function greet(Run $run, string $name) {         $valid = $engine->performTask(           $run, new Task(fn() => $validator->validateName($name))         );         if (!$valid) {             return false;         }         $greeting = $engine->performTask(           $run, new Task(fn() => $repository->createGreeting($name))         );         try {             $engine->performTask(               $run, new Task(fn() => $purchaser->purchase($greeting))             );         // Ловим специальное исключение          // "шаг больше повторять не нужно, откатывай"         } catch (RollbackTask $e) {              $engine->performTask(               $run, new Task(fn() => $reverter->revert($greeting))             );             return false;         }              $engine->performTask(           $run, new Task(fn() => $sender->send($greeting))         );         return $greeting;     }     } 

Теперь мы можем уверенней вызывать Greeter::greet повторно, но вот что не нравится:

  • В коде самой транзакции слишком много завязок на движок — при изменении API может потребоваться переписывать все транзакции.

  • В greet появляется новый аргумент $run, который никак не относится к логике транзакции.

  • Для отката нужно ловить специальное исключение.

Этап 3: Из центра наружу

Кадр из сериала "Кремниевая долина"

Кадр из сериала «Кремниевая долина»

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

  • Останавливать и возобновлять работу функции с конкретного места, обозначенного ключевым словом yield.

  • Передавать в функцию данные через Generator::send— можно использовать конструкцию $foo = yield bar()и устанавливать в $foo нужные нам значения снаружи.

Таким образом мы превращаем нашу Greeter::greet в генератор шагов:

<?php  class Greeter {     function greet($name): Generator {         $valid = yield new Task(fn() => $validator->validateName($name));         if (!$valid) {             return false;         }                       $greeting = yield new Task(fn() => $repository->createGreeting($name));          try {             yield new Task(fn() => $purchaser->purchase($greeting));         } catch (Exception $e) {              yield new Task(fn() => $reverter->revert($greeting));             return false;         }                  yield new Task(fn() => $sender->send($greeting));         return $greeting;     }     }

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

А вот так упрощённо выглядит код движка:

<?php  $tran = new Transaction(fn() => $greeter->greet('John Doe')); $generator = $tran->callable();  ...  while (true) {     if ($generator->valid()) {         $task = $generator->current(); // Получаем очередной шаг из генератора         // @todo тут проверяем кеш для $task и детерминированность шага         try {           $result = $task->callable(); // Выполняем callable бизнес-логики         } catch (Exception $e) {           // @todo обработка ошибок и отправка на повтор           break;         }         $generator->send($result); // Передаём результат в генератор     } else {          $result = $generator->getReturn();         break;     } } 

Что тут происходит:

  • Генератор yield-ит прокси-объекты шагов.

  • Нам нужно либо выполнить бизнес-логику шага, либо взять из кеша + проверить детерминированность.

  • Выполнив шаг, мы возвращаем результат в генератор и двигаем его дальше.

В коде «стало попросторнее», продолжаем.

Этап 4: Очереди и предохранитель

База данных движка — это single source of truth для состояния транзакций и шагов, а очереди это опция, которая позволит быстрее выполнять повторы или запускать транзакции асинхронно.

Добавим такой интерфейс:

<?php  interface RunQueueInterface {     public function enqueueRun(Run $run); }

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

В API движка нужно предусмотреть метод, который селектит запуски, готовые к выполнению, например такой:

<?php  class Engine {     ...     function getScheduledRunIds(DateTime $until, int $limit): array {         ...         return $runIds;     }   }

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

Идея в том, что в каждом проекте могут быть разные фреймворки и чтобы эффективно вписаться в неизвестную инфраструктуру лучше использовать низкоуровневый интерфейс, а не opinionated решения.

Этап 5: Доводка

Первое, что напрашивается — добавить политику повторов, чтобы можно было указывать сколько попыток делать, с какими интервалами, факторы для exponential backoff и т.п. Например так:

<?php  $purchaseTask = new Task(fn() => $purchaser->purchase($greeting));  yield $purchaseTask->setRetryPolicy(     (new RetryPolicy())         ->setMaxRetries(3)         ->setMinInterval(1000) );

Далее избавляемся от бойлерплейта типа new Task(fn() => $repository->createGreeting($name)) внутри транзакций. Добавляем класс-враппер, который на лету будет создавать шаги из обычных методов класса:

<?php  class Tasks {     public function __construct(private object $object);      public function __call($method, $args) {         return new Task(fn() => $this->object->$method($args));     } }

Это позволяет писать вот такой код:

<?php  /** @var Validator */ $validatorTasks = new Tasks(new Validator());  ...   class Greeter {     function greet($name) {         $valid = yield $validatorTasks->validateName($name);         if (!$valid) {             return false;         }         ...         return $greeting;     }     } 

Обратите внимание, что с помощью phpdoc мы сделали живую ссылку на метод validateName через класс Tasks — можно пользоваться IDE-плюшками поиска использований, переходить внутрь метода и т.п.

Ещё вызывает вопросы класс Transaction. Зачем он нам? Если уже есть Task и движок может сам определять: это транзакция (генератор) или просто бизнес-логика, которую нужно надёжно выполнить — с обработкой ошибок, повторами и т.п. Поэтому избавляемся от Transaction:

<?php /** @var Greeter */ $greeterTasks = new Tasks(new Greeter()); $task = $greeterTasks->greet("John Doe"); $run = $engine->createRun($task); $result = $engine->performRun($run);

Что получилось в итоге

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

Решение реализовано в виде библиотеки TSQM c открытым кодом.

Основные сущности:

  • Tsqm — сам движок.

  • Task — прокси объект для бизнес-логики или генератор таких прокси-объектов.

  • Run — определяет запуск Task, отслеживает состояние.

  • RunResult — результат запуска транзакции с признаком готовности или ошибки.

Из документации пока только инструкция для разработчиков. Потыкать примеры можно через консольное приложение examples/app.php:

  • init:db — обнуляет и создаёт базу.

  • example:hello-world — пример транзакции со случайной ошибкой.

  • example:hello-world-simple — пример выполнения одного шага со случайной ошибкой.

  • list:scheduled — вывести список запланированных запусков.

  • run:scheduled — выполнить запланированные и подвисшие запуски, по сути — предохранитель.

  • run:one — выполнить конкретный запуск.

Пример вывода одной из команд

Пример вывода одной из команд

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

Цена использования

Одной из главных целей создания TSQM было дать инструмент, который легко встраивается в большие легаси-монолиты на PHP, чтобы выносить куски бизнес-логики в другие сервисы и базы. Решение получилось довольно лёгкое, но есть и ограничения:

MUST

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

  • Аргументы и возвращаемые значения шагов должны быть сериализуемыми.

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

<?php yield $sender->send($greeting); yield $sender->send($greeting);

* Это нужно, чтобы история была чистой — признак запуска и завершения конкретного шага пишется в базу только один раз. Можно, конечно, схлопывать шаги в движке, но надёжней использовать unique constraint в БД.

SHOULD

  • Если транзакция уходит на повтор, то нельзя получить её результат синхронно и клиенты должны это учитывать — проверять isReady() у объекта RunResult, сохранять runId и запрашивать результат позже.

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

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

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

Планы по развитию

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

Шорт лист планируемых фич:

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

  • Мониторинг и телеметрия.

  • Разные политики для повторов в том числе exponential backoff.

  • Конкретные реализации очередей для разных брокеров.

  • Встроенный скрипт предохранителя.

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

  • Сделать реализацию для других платформ — TypeScript, Go, Java и прочие.

FAQ

Почему не использовали Temporal?

После сборки прототипа на Temporal и написания статьи казалось — вот оно, решение найдено, нужно пробовать. Потом голова немного остыла и вот какие стоперы закрыли тему:

  • Комплексити. У нас уже есть отлаженный механизм межсервисного взаимодействия через единое API и GraphQL. Всё хорошо работает — авторизация, подписки, логирование, ревью API-схемы, окружение для разработки и т.п. Есть планы по развитию, народ в целом доволен. Поэтому не хочется влезать в историю с RPC и по-новой внедрять базовые механики.

  • Высокий порог входа. “Етить, комбайн этот Temporal, конечно” — вот что сказал наш главный админ. И действительно, чтобы правильно приготовить Temporal в проде, нужно перелопатить доки, всё понять, всё поднять и проверить под нагрузкой. Потом ещё сходить в разработку с мануалами, зарядить devops на стендовую-поддержку ну и пройти прочие радости внедрения новых технологий. Короче, долго и дорого.

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

В чём отличие TSQM от Temporal?

TSQM — это не workflow engine, он легче и проще, но менее универсальный — код шагов находится в одной кодовой базе, нет встроенной поддержки workflow, нет GUI и т.п.

TSQM — это оркестратор?

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

А где тут мутации?

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

Что за название такое, TSQM?

TSQM — это библиотека, которая уже несколько лет используется в Профи для запуска и обработки отложенных заданий. Публичный tsqm-php — это её реинкарнация в open source. Расшифровывается как TaSk Queue Manager, смысл библиотеки немного поменялся, но название я решил оставить и использовать как имя собственное.

Спасибо, что дочитали! Пока ?

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

Первая часть про постановку задачи

Вторая часть про Temporal

Третья часть про TSQM: эта статья


ссылка на оригинал статьи https://habr.com/ru/articles/776794/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *