image


Когда я только начинал работать с микросервисами, я чрезмерно буквально следовал общему правилу «не допускайте, чтобы два сервиса совместно использовали один источник данных».
Этот тезис фигурирует повсюду в Интернете как заповедь: «да не раздели ты базу данных между двумя сервисами» и, определённо, в нём есть смысл. Сервис должен владеть собственными данными и иметь возможность свободно менять их схему так, как будет сочтено нужным, не меняя при этом API, направленный вовне.

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

Почему совместное использование источника данных – это плохо


Приведу пример: сервис Products должен владеть таблицей products и всеми записями в ней. Сервис предоставляет эти данные другим командам через API, при этом используется запрос GraphQL products, при этом, для создания этих записей применяется мутация createProduct.
Сервис Products владеет источником истины по всем продуктам, и ни одна другая команда, ни в коем случае, не должна иметь прямого доступа к этому источнику. Если вы хотите взять оттуда данные, то вы должны запросить эти данные у сервиса Products через контракт (API), которому подчиняются обе стороны. Ни при каких условиях вы не должны открывать прямого доступа к вашей базе данных, иначе больше не сможете свободно вносить изменения в её схему. Я убедился в этом на горьком опыте.

Разделяемость данных – это нормально


Но реальность такова, что сервисам всегда требуются данные, принадлежащие другим сервисам.
Например, сервису Trip (поездка) требуется доступ к информации о пассажирах (от сервиса Passenger) и водителях (от сервиса Driver) — только так можно будет создавать обзоры туристических маршрутов.

image

Простая архитектура с тремя сервисами

Сервис Trip синхронно запрашивает у каждого из сервисов те данные, которыми эти сервисы обладают, это требуется для удовлетворения исходного запроса (getTrips). При этом можно не сомневаться в свежести полученных данных, и при этом запрашивающий клиент получает строго согласованное представление данных (вероятно, некоторые из вас уже догадываются, к чему я клоню ;).

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

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

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

Синхронность и строгая согласованность не масштабируются


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

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

image


Вот так может выглядеть поток информации при использовании синхронных запросов

1. Пользователь справился с задачей и выполняет мутацию completeChallenge, которая изменяет сервис Challenge

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

3. Сервис Leaderboard запрашивает сервис User, чтобы тот отобразил имена и аватары пользователей и перестроил таблицу лидеров, переведя её в новое состояние

4. Сервис Leaderboard убеждается, что лидер в таблице изменился, о чём сообщает сервису Notification, чтобы тот мог уведомить участников, что теперь лидер новый

5. Сервис Notification запрашивает у сервиса User обновлённый список почтовых адресов пользователей, входящих в данный конкретный список лидеров, чтобы разослать участникам сообшения о том, что они в этот список попали

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

Наконец, при появлении каждой дополнительной зависимости в цепочке запросов повышается вероятность, что откажет вся цепочка запросов. Если в цепочку входит пять сервисов с уровнем SLA 99,9% (период недоступности порядка 9 часов в год), то совокупный уровень SLA составляет 99,5%. А это означает почти двое суток недоступности в год! Всех этих недостатков можно избежать, задав себе вопрос: а действительно ли всем сервисам нужны максимально свежие данные?

Сервису Notification (шаг 5) — определённо нужны. Если у пользователя изменился адрес, и сервису Notification это не известно, то существует риск, что письмо уйдёт по неверному адресу, а тот пользователь, которому оно предназначено, его не получит.

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

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

Что такое согласованность в конечном счёте


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

У меня в голове сложилась картинка, когда я взглянул на эту ситуацию с точки зрения сервиса базы данных, который собирает метеорологические данные для прогноза погоды с публичного API. Если, например, пользователь из Приштины или из Берлина захочет узнать погоду, я не буду всякий раз извлекать новые данные, а буду кэшировать их (возможно, по нескольку раз в день) и вести учёт в локальной таблице, а по мере необходимости выдавать эти данные интересующимся пользователям. Я иду на компромисс и на согласованность в конечном счёте, так как моим пользователям не столь важно видеть самые свежие данные — вполне нормально, если эта информация была получена несколько часов назад.

Вернёмся к примеру с сервисом Challenge: можно избавиться от многих синхронных зависимостей между ним и сервисом User, просто поддерживая локальные копии данных о пользователях на конкретных сервисах:
  • Сервис Leaderboard может поддерживать локальную копию информации о пользователях, чтобы снизить количество запросов, которые необходимо направить к сервису User. Никого не волнует, если его данные окажутся немного устаревшими, старенький аватар погоды не испортит.
  • То же касается сервиса Challenge; допустим, если ему предоставляется запрос getChallengeDetails, а в ответ требуется отобразить имена и аватары участников текущего челленджа, то он может выдать согласованные в конечном счёте данные и из собственной таблицы пользователей.
  • Сервис Notification в этом отношении чуть более чувствителен, но и в нём можно задействовать совместное использование данных, чтобы открепить его от сервиса Users. Он может локально поддерживать таблицу пользователей и обновлять данные о них настолько оперативно, насколько это возможно — в частности, чтобы адреса их электронной почты были максимально свежими.

Хотя, мы и не обсудили подробно, как именно сервисы разделяют данные (это тема для другой статьи), приведу в заключение пример целой архитектуры, в которой одновременно используется подтягивание событий из источников (event sourcing) и кэширование. Вот примерно могла бы выглядеть такая архитектура:

image


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

Если вас интересуют дополнительные примеры, посмотрите статью How to Share Data Between Microservices on High Scale от Ширан Метсуяним, которая работает программистом в Fiverr. Это отличный пост, демонстрирующий, как не снижать надёжность системы, добавляя в неё новый сервис. Сначала в нём излагаются ограничивающие факторы, а затем обсуждаются компромиссы, на которые приходится идти при выборе между синхронными, асинхронными и гибридными решениями.

Заключение


В этом посте я хотел донести, что на самом деле означает «избегать совместного использования данных» — на самом деле, имеется в виду, что нельзя совместно использовать источник истины. Совершенно нормально держать на сервисе копию данных с другого сервиса, такая стратегия отвечает принципам «согласованности в конечном счёте».

P.S Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.

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


  1. nronnie
    27.05.2024 13:11
    +1

    Да, все очень правильно. Если мы возьмем соответствие между DDD и микросервисами, то "крупным планом" у нас отдельный микросервис это отдельный "bound context". Одна и та же сущность может присутствовать при этом в различных "bond context"-ах (причем в каждом даже иметь свою собственную отдельную модель). Так что это не просто ничего плохого, а, наоборот, очень даже логично если отдельный микросервис имеет свою собственную копию одной и той же сущности.