Привет, Хабр! Я Кирилл Рождественский, тим-лид в компании TCP-Soft. Это завершающая часть серии статей про миграцию с монолита на микросервисы. Мы готовим их совместно с коллегами из Mango Office, с которыми создаем общий продукт, поэтому статьи публикуются в их блоге.
Первую статью, про общие понятия и предпосылки миграции, можно посмотреть здесь. Вторую, про 7 миграционных шаблонов, — здесь. В этой, третьей, статье мы разберем самый больной вопрос перехода с монолитной на микросервисную архитектуру: «Как быть с данными?».
Итак, если раньше у нас был монолит с несколькими модулями, которые использовали общую базу, как быть с ней сейчас? Вот какие есть варианты.
Использование общей базы
Самый простой способ — ничего не менять в плане владения данными: и монолит, и микросервис могут использовать имеющуюся базу. Просто в реализации, но сложно в поддержке. Среди минусов — опасность поломать что-то своему «соседу», а также последующие сложности с миграцией.
Однако есть случаи, когда этот паттерн может быть достаточно полезен:
Если данные носят справочный характер (readonly). Например, в базе хранится справочник с кодами стран или валют, которые меняются довольно редко.
База представляет собой внешний интерфейс микросервиса. Дальше — об этом чуть подробнее.
Представление (view) как интерфейс
Этот паттерн будет полезен, когда мы хотим, чтобы микросервисы работали с данными напрямую, но при этом нужен способ сохранить один общий источник истины (например, наш старый добрый монолит). Когда такое может понадобиться?
Когда нет возможности вносить существенные изменения в код монолита, но необходимо выставить наружу интерфейс для работы с данными.
Когда мы находимся только в самом начале пути перехода на микросервисную архитектуру и хотим как можно быстрее выпуститься в прод, но не хотим закапываться в рефакторинг старого кода.
Во всех остальных случаях этот паттерн можно считать антипаттерном, так как он обладает рядом существенных недостатков: мы все также зависим от корневой базы, не владея данными, не можем экспериментировать с хранилищами и т.д. В конечном счете стоит стремиться к тому, чтобы у каждого сервиса было свое хранилище.
В плане реализации все достаточно просто. Вместо создания отдельной БД для каждого микросервиса с последующей миграцией данных мы создаем представления и отображаем в них необходимые срезы данных. Каждый сервис обращается к своему представлению и читает из него данные так, будто работает с независимой таблицей (хотя на самом деле это не так, а обманывать плохо).
База данных как интерфейс
В качестве интерфейса можно использовать не только представление, как в предыдущем паттерне, но и целую базу. Однако в реализации этот паттерн несколько сложнее. Суть в следующем: у сервиса/монолита появляется два хранилища данных. Одно внутреннее, в котором данные хранятся в сыром виде и используются для нужд самого сервиса, и одно внешнее, с которым могут взаимодействовать внешние службы. Ясное дело, что внутреннее и внешнее хранилища периодически должны синхронизироваться (что значительно усложняет реализацию).
Зачем это нужно? Причин может быть несколько:
Мы не можем менять код монолита, а он в свою очередь привык работать с данными через таблицу. Тогда мы реализуем паттерн со стороны микросервиса: выставляем наружу таблицу в том виде, в котором ее хочет видеть монолит и синхронизируем с ней внутреннее хранилище. В этом случае паттерн выглядит больше как костыль, но иногда это может быть единственным выходом.
Мы хотим выполнять сложную аналитику, но не хотим нагружать основную базу. Тогда целесообразно выделить базу, заточенную под OLAP, и периодически перекачивать данные в нее. С этой выделенной базой в свою очередь будет работать аналитический сервис. Данный кейс использования выглядит уже менее костыльным и вполне применимым.
Еще одно существенное преимущество этого шаблона перед предыдущим — в том, что базы могут быть совершенно разными и приспособленными под конкретный кейс использования.
Служба-обертка
Паттерн, который довольно распространен. Иногда мы даже не задумываемся, что применяем именно его. Суть проста: сервис обращается не напрямую к базе, а использует API промежуточного сервиса, основная цель которого как раз таки во взаимодействии с БД. Такая служба-обертка может показаться избыточной, однако несет в себе ряд существенных преимуществ:
На стороне службы может быть реализована некоторая дополнительная логика: мы можем выполнять дополнительную валидацию данных, можем форматировать вывод (возвращать красивый json вместо сырых табличных данных), в конце концов, можем логировать действия пользователей в удобном для нас виде.
Мы по сути отвязываемся от БД. Если когда-нибудь мы захотим перейти на другую СУБД (например, более быструю), для сервисов-потребителей этот переход останется прозрачным. Они как использовали API, так и продолжат, а что творится под капотом — для них не имеет значения.
Выставление агрегата
Довольно применимый шаблон, хотя по-хорошему всегда должен являться только временным решением при переходе к новой микросервисной архитектуре. Он позволяет быстрее переключиться на микросервисы, сохранив логику в монолите.
Представим, что у нас есть функционал адресной книги, и на данный момент он полностью реализован внутри монолита. Также у нас есть ряд модулей, которые с этой адресной книгой взаимодействуют — получают информацию о клиентах, например. Теперь представим, что именно эти модули мы и решили вынести в отдельные сервисы.
Как в таком случае нам сохранить взаимодействие этих сервисов с адресной книгой? Вариантов несколько: либо заставить все микросервисы напрямую ходить в базу (что противоречит принципам построения микросервисной архитектуры), либо вынести адресную книгу в отдельную службу и взаимодействовать с ней по API.
Если вынесение целого модуля в отдельный микросервис обойдется дорого, а новый функционал нужен прямо сейчас, можно поступить следующим образом. Сами данные оставить во владении монолита, при этом наружу выставить интерфейс взаимодействия с ними (это и называется выставлением агрегата). Вынесенные микросервисы начнут взаимодействовать с адресной книгой через интерфейс, будто это полноценный выделенный сервис.
В будущем данное решение, конечно же, подлежит рефакторингу: логично предположить, что мы захотим иметь полноценный самодостаточный сервис адресной книги.
Вопрос синхронизации данных
Так как у монолита и микросервисов должны быть свои обособленные хранилища, возникает вопрос: если мы запустим монолит и микросервис одновременно, и они будут писать данные в свои собственные базы, как эти данные свести вместе. Вариантов несколько, идеальный лично мне пока неизвестен.
Переключение владельца
Один из самых простых методов, при котором во время переезда на микросервис хранилище данных не меняется. Сначала у нас монолит, который владеет данными в определенной базе (может читать и изменять данные). Микросервис, пока разрабатывается, тоже может читать из нее данные, а вот писать — нет (писать в таком случае можно через интерфейс монолита).
Когда наступает день икс, мы просто переводим сервис в режим чтения/записи, а монолит в свою очередь перестает взаимодействовать с базой. Данными теперь он может манипулировать только через API сервиса.
Есть несколько аспектов, когда такой паттерн работать не будет, либо будет работать неправильно:
У монолита и микросервиса разные специфики хранения данных. Если мы переключимся подобным образом, вполне возможно, что нам потребуется тотальный рефакторинг схемы хранения данных на боевой базе.
Единовременное переключение не всегда возможно. Оно, например, полностью исключает такие полезные решения, как «канареечный релиз» или «параллельное исполнение» (см. предыдущую статью).
У нас будут большие проблемы, если что-то пойдет не так. Например, если на стороне микросервиса будет обнаружена критическая ошибка, которая могла испортить данные, и нам придется откатываться на предыдущую реализацию.
Синхронизация приложением
В этом шаблоне все начинается не с разработки микросервисов, а с продумывания процесса миграции. Сам же переход начинается еще до внедрения микросервиса, и даже, возможно, до его разработки.
Работает это следующим образом:
Заводим новую базу со своей архитектурой, схемами и т.д. Впоследствии ее будет использовать сервис в качестве основной.
Тушим монолит и перекачиваем все данные в новую базу. Важно отметить, что не всегда это можно выполнить за один этап ввиду потенциально огромного объема данных. В таком случае миграционные работы могут проводиться с некоторой периодичностью на протяжении длительного времени. Например, сервис тушится на один час каждую ночь на протяжении месяца.
Возвращаем монолит к жизни, но теперь он пишет данные одновременно в две базы, причем читает только из одной — своей старой.
Если видим, что данные сходятся и методы работают правильно, можем полноценно переключаться на использование новой базы. Правильным решением в данном случае будет предусмотреть возможность отката. Для этого после переключения на новую БД мы должны продолжать писать данные и в старую. Тогда в случае проблемы мы сможем переключиться обратно на нее, ничего не потеряв.
Нюанс этого паттерна в том, что он подразумевает прежде всего одномоментное переключение. Если мы решим развернуть монолит и микросервис параллельно, возникнет ситуация как на картинке ниже: «все пишут везде и читают отовсюду». Это неизбежно существенно усложняет архитектуру и ведет к появлению ошибок (которые не так-то просто отловить). Кроме того, вполне очевидным недостатком данного подхода является неизбежный простой сервиса.
Трассировочная запись
Еще один вариант синхронизации на уровне приложения. Принцип работы следующий:
Как и в предыдущем случае создаем новую базу для дальнейшего использования микросервисом.
Придумываем, как можно логически разбить данные на группы. Например, по таблицам, id клиентов, регионам и т.д.
Приложение (монолит) во время своей работы начинает перекачивать данные в новую БД обозначенными ранее группами.
Как только перенесен один кластер, новая база становится источником истины для этого кластера. Например, мы перенесли все организации из адресной книги, и теперь приложение работает с организациями из базы, а со всем остальным продолжает работать по-старому. Важно помнить, что для сохранения возможности отката мы должны продолжать писать данные в старую базу.
Внедряем микросервис, который работает с новой базой — временно только читает, а записывает данные через интерфейс монолита. То есть на этом этапе данные в БД сервиса попадают только через синхронизацию со стороны монолита.
Когда заканчивается миграция всех разрезов (схем, таблиц), мы можем полностью переключаться на микросервис.
Как видим, данный паттерн достаточно непрост в реализации, что усложняет его поддержку и увеличивает количество потенциальных ошибок. Использовать его стоит только в том случае, если простой сервиса является критически недопустимым.
Синхронизация внешним сервисом
Наиболее сложный, но при этом функциональный подход. Хорошим примером в данном случае будет использование Debezium’а (что в принципе не исключает каких-либо других, в том числе самописных, решений). Его и рассмотрим буквально в двух словах.
В этом шаблоне все сводится к тому, что мы заводим некоторый передаточный компонент, через который данные будут мигрировать в новую БД. В случае с Debezium — это Kafka.
Процесс следующий:
Запускаем миграционный сервис, который вычитывает данные из старой базы и перекладывает в топик Kafka (аналогично трассировочному паттерну, только в данном случае пунктом назначения является не база).
Параллельно Debezium слушает журнал транзакций БД монолита и складывает все изменения все в тот же Kafka-топик.
Заводим новый синхронизационный сервис, который подписывается на топики Kafka и на основании событий актуализирует данные в новой БД. На стороне этого сервиса также могут запускаться различные механизмы валидации, логирования, мержа и т.д.
Новый микросервис начинает работать с новой БД. Полезно на первых этапах позаботиться о возможности отката. Для этого необходимо, чтобы сервис мог редактировать данные через API монолита, а данные из нового сервиса только читать.
Этот паттерн, несмотря на свою гибкость, выглядит достаточно монструозным. Мы вводим несколько новых компонентов в инфраструктуру, что само собой увеличивает количество точек отката. Кроме того, обратная синхронизация в данном случае тоже не вполне тривиальная задача.
Общий вывод для цикла статей
В завершение отмечу: не существует универсального решения, которое подошло бы во всех случаях без исключения. Как обычно, все строится на компромиссах. Однако один общий совет я все же могу дать: всегда надо руководствоваться принципами минимализма и достаточности, то есть выбирать наиболее простой вариант, допустимый в данной конкретной ситуации. Например, если у нас простой сервис, то незачем ломать себе голову трассировкой.
Ниже оставлю список материалов, полезных для дальнейшего погружения в тему:
Книга «Микросервисы. Паттерны разработки и рефакторинга», автор — Крис Ричардсон.
Книга «От монолита к микросервисам», автор — Сэм Ньюмен.
Справочная документация по Debezium: https://debezium.io/documentation.
На этом заканчиваем нашу серию статей. Надеюсь, было полезно и интересно. Если вам есть что добавить, вы хотите поделиться мыслями и обсудить какие-то отдельные моменты, приглашаю в комментарии. Мой ник на Хабре, под которым я отвечаю в комментариях: @vi_ki_ng.
Подписывайтесь на наши соцсети:
Аккаунты Mango Office
ВКонтакте: https://vk.com/mangotelecom
Телеграм: https://t.me/mango_office
Аккаунты TCP-Soft
Instagram*: https://www.instagram.com/tcp_soft
Facebook*: https://www.facebook.com/tcpsoftminsk
* Продукт компании Meta, признанной в РФ экстремистской организацией