Во многих компаниях микросервисы начинаются с небольшой уступки здравому смыслу. Новый сервис нужен быстро, а нужные данные уже лежат в общей базе. Значит, можно пока не делать API, не тащить события и не договариваться о контракте. Просто сходить SELECT-ом в чужую таблицу.
На короткой дистанции это и правда удобно. На длинной — это почти гарантированный способ собрать распределенный монолит.
Особенно хорошо это видно в банке. У вас есть общие сущности вроде клиентов, счетов, договоров. С ними одновременно живут несколько команд. Пока все читают одну и ту же схему, любая безобидная правка в базе перестает быть локальной. Кто-то меняет колонку, тип, ограничение или трактовку статуса — и это внезапно становится проблемой не одной команды, а сразу нескольких.
В какой-то момент независимые релизы заканчиваются. Вместо них начинается синхронизация. Перед каждым изменением нужно выяснять, кто еще сидит на этой таблице, кого надо предупредить, кто не успеет адаптироваться, кто попросит подождать до следующего окна. Архитектурно сервисы вроде бы разнесены. По факту все по-прежнему сидят на одной общей точке зависимости.
Именно поэтому правило Database per Service обычно формулируют без смягчений: данные сервиса — это его внутреннее состояние. Если другому сервису что-то нужно, он получает это через публичный контракт, а не через прямой доступ к таблицам.
На бумаге идея простая. На практике переход почти всегда оказывается неприятнее, чем выглядит в презентации.
Как мы вытаскивали счета из общей базы
На одном банковском проекте у нас была большая таблица accounts в монолитной postgresql-базе. В нее напрямую ходили примерно 10 команд. Пока все было относительно стабильно, это считалось нормой. Проблема стала очевидной в момент, когда мы захотели сделать партиционирование таблицы счетов и вынести её на отдельную базу.
Тогда выяснилось, что даже локальное изменение в модели счетов может зацепить слишком много потребителей. Стало понятно, что дальше так жить нельзя: управление счетами нужно выносить в отдельный Account Service и закрывать прямой доступ к базе.
Самое неприятное началось после этого решения. Выделить сервис — не значит просто перенести таблицу и отдать пару REST-методов. Раньше потребители решали свои задачи SQL-запросами, а теперь эти сценарии нужно было превратить в нормальный внешний контракт.
Сначала сделали базовый GET /accounts/{id} для точечного получения баланса и статуса. Это была самая простая часть.
Потом пришлось разбираться со списками. Консьюмеры привыкли к логике вида SELECT ... WHERE ... ORDER BY ... LIMIT, поэтому одного метода на чтение счета было мало. Нужен был полноценный GET /accounts с пагинацией, фильтрами, сортировкой и внятным набором параметров. То, что раньше жило в произвольных SQL разных команд, пришлось собирать в осмысленный API.
Но настоящая сложность была в JOIN.
Многие команды строили свои выборки через JOIN accounts с clients, чтобы фильтровать счета по атрибутам клиента. Типичный сценарий — подсчитать количество счетов в конткретном отделении или выбрать счета клиентов из нужного сегмента. Пока все данные лежали в одной базе, это был обычный рабочий SQL. После разделения баз такой путь исчез.
Чтобы не собирать эти выборки через каскад синхронных вызовов между сервисами, мы пошли в денормализацию. Account Service начал асинхронно получать события из Client Service через Kafka и хранить у себя минимальный набор клиентских атрибутов, нужных для поиска: идентификатор клиента, сегмент, регион и другие признаки, без которых потребительские сценарии просто не работали.
И здесь всплыл еще один кейс, который обычно недооценивают в момент проектирования. Понадобились методы получения не только списков счетов, но и количества счетов в географическом разрезе — по регионам, городам и другим клиентским атрибутам. Раньше это решалось обычными SQL-агрегациями поверх общего контура данных. После разделения сервисов такой запрос уже нельзя было честно собрать без локальной копии клиентских признаков.
В итоге нам пришлось дополнительно подтянуть в Account Service именно те клиентские атрибуты, которые были нужны для географических срезов и агрегатов. Иначе любой такой метод превращался либо в дорогую склейку нескольких сервисов на лету, либо в медленную и хрупкую конструкцию, которую трудно сопровождать.
Это, кстати, хороший отрезвляющий момент. Как только сервис должен отдавать не только CRUD, но и прикладные выборки, агрегаты и срезы, одной красивой границы по домену уже недостаточно. Почти всегда приходится строить локальные read-модели или держать денормализованные проекции под реальные сценарии чтения.
Что на самом деле меняется
Отказ от Shared Database не убирает сложность. Он просто переставляет ее.
Пока все сидят в одной базе, часть проблем решает сама СУБД. Там же живут транзакции, JOIN, строгая консистентность и ощущение, что система под контролем. После перехода к Database per Service эти удобства заканчиваются. Вместо них появляются другие обязательства: нужно жить с eventual consistency, думать про доставку событий, обрабатывать дубли, следить за порядком обновлений и уметь разбирать сквозные сбои.
То есть вопрос не в том, есть ли цена у изоляции данных. Цена есть, и она заметная. Вопрос в другом: где вы хотите держать связанность — в общей базе, на которую молча завязаны все, или в явных контрактах и интеграционных механизмах, которыми кто-то действительно владеет.
Для меня в этом и есть главная развилка. Shared Database кажется быстрым решением, пока система еще терпит. Потом оказывается, что вы сэкономили несколько недель в начале и годами расплачиваетесь за это скоростью изменений. Database per Service сложнее и дороже в реализации, зато делает границы честными. А в больших системах это обычно важнее, чем локальное удобство на старте.
Top comments (0)