Что я узнал при использовании RPC в производственной среде

Я много работал с Удаленным вызовом процедур (RPC) за последние два года. Мы использовали этот подход для связи между нашими микросервисами. RPC определенно может быть полезен для этого. Однако это не всегда подходящее решение.

Я продемонстрирую, как можно реализовать RPC, и рассмотрю некоторые проблемы, с которыми мы столкнулись.

Реализация RPC

Допустим, у нас есть приложение для заказа такси. Когда пользователь запрашивает такси, нам необходимо рассчитать самый быстрый маршрут. Расчет самого быстрого маршрута - сложная задача. По этой причине мы перенесем эту логику в отдельный сервис, который при необходимости сможем масштабировать. Мы можем вызвать эту службу с помощью RPC.

RPC можно реализовать несколькими способами. Один из способов сделать это - использовать шаблон запрос-ответ. Мы можем реализовать этот шаблон с помощью брокера сообщений, такого как RabbitMQ.

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

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

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

После создания очереди мы отправляем команду, содержащую поля CorrelationId и ReplyTo. Сервер получит это сообщение, обработает его и отправит другое сообщение с тем же CorrelationId. Это сообщение будет отправлено в очередь ответов, указанную в поле ReplyTo.

Когда клиент получает сообщение в очереди ответов, он будет использовать CorrelationId для сопоставления сообщения с одним из его ожидающих запросов.

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

Обработка сбоев

Теперь, когда у нас есть реализация RPC, пора рассмотреть крайние случаи.

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

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

Также возможно, что запрос не удался. В этом случае нам нужно будет отправить сообщение, чтобы сообщить клиенту. Ожидание истечения тайм-аута не рекомендуется.

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

Некоторые преимущества такой реализации:

  • Масштабируемость. Если расчет самого быстрого маршрута занимает слишком много времени, мы можем легко масштабировать эту конкретную услугу.
  • Сервис, который рассчитывает самый быстрый маршрут, может быть оптимизирован для этой конкретной задачи.
  • Ждем ответа. Если что-то не удается, мы можем дать клиенту возможность повторить попытку.

RPC - не всегда хорошая идея

Бывают случаи, когда RPC может быть хорошим решением. Но не всегда. Давайте рассмотрим пару случаев, когда RPC не мог быть хорошим решением.

Долгосрочные задачи

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

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

Вот почему рекомендуется сделать таймаут RPC через пару секунд. Это обеспечит корректный выход из экземпляра при развертывании новой версии. Этот короткий тайм-аут делает невозможным выполнение длительных задач.

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

Лучший способ справиться с этими задачами - отправить команду или событие и сохранить состояние запроса в хранилище данных. Как только задача будет выполнена, она отправит событие, которое мы можем прослушать. Когда наступит событие, мы получим запрос из хранилища данных и продолжим его обработку. Этот подход не требует, чтобы сообщение обрабатывалось одним и тем же экземпляром.

Цепочка RPC

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

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

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

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

Однако определенно возможно объединить несколько служб в цепочку с помощью RPC, если все случаи сбоя обрабатываются правильно.

Безвозвратные действия

Будьте осторожны при использовании RPC для действий, которые следует выполнить только один раз. Вы не всегда сможете узнать, завершилась ли команда неудачно или успешно, когда истечет время ожидания. Кроме того, убедитесь, что службы, вызываемые с помощью RPC, могут корректно обрабатывать повторяющиеся команды.

Почему?

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

Это может быть проблемой при выполнении действий, которые следует выполнить только один раз. Например, создание счета-фактуры или отправка электронного письма.

Если вы действительно хотите использовать для этого RPC, вам следует реализовать какую-то проверку, чтобы убедиться, что сообщение не обрабатывается раньше. Вы можете хэшировать содержимое запроса и сохранить его в хранилище "ключ-значение". Если вы получили другое сообщение с тем же хешем, вы можете проигнорировать его.

Последние мысли

RPC может быть хорошей идеей, если все случаи сбоя обрабатываются правильно. Мне лично очень нравится следующее утверждение из этого учебника RabbitMQ:

В случае сомнений избегайте RPC.

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

Спасибо за чтение.