Обсудить задачу

Где резать монолит: границы, контракты и события

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

Зачем вообще резать

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

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

Где проводить границы

Резать по слоям — отдельно «работа с базой», отдельно «логика», отдельно «API» — самый популярный способ получить распределённый монолит: сервисов стало больше, а выкатывать по-прежнему приходится всё вместе.

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

Контракт важнее реализации

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

GoКонтракт сервиса файлов
syntax = "proto3";

package files;

// Контракт лежит отдельно от реализации: поменять его незаметно нельзя.
service FileService {
    rpc Upload(stream UploadRequest) returns (UploadResponse);
    rpc ListFiles(ListFilesRequest) returns (ListFilesResponse);
    rpc DeleteFile(DeleteFileRequest) returns (DeleteFileResponse);
}

message UploadRequest {
    bytes  chunk = 1;   // файл идёт потоком, а не одним куском в памяти
    string folder = 2;
}

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

Что делать синхронно, а что событием

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

  • Проверить права — синхронно, ответ нужен сейчас
  • Отдать список файлов — синхронно
  • Пересчитать статистику после загрузки — событием
  • Отправить уведомление — событием
  • Записать в аналитику — событием
GoПотребитель Kafka: идемпотентность обязательна
// Потребитель события: пересчёт статистики после загрузки.
// Обработчик обязан быть идемпотентным — брокер доставляет
// «хотя бы один раз», и одно и то же событие придёт дважды.
func (c *Consumer) Handle(ctx context.Context, msg Message) error {
    if c.seen.Has(msg.ID) {
        return nil // уже обработали, тихо подтверждаем
    }

    if err := c.stats.Recalc(ctx, msg.UserID); err != nil {
        return fmt.Errorf("recalc stats: %w", err)
    }

    c.seen.Add(msg.ID)
    return nil
}

Выкатка: состояние описано в git

Пока выкатка — ручная операция, она остаётся редкой и страшной, а значит правки копятся и приезжают на прод пачками. Лечится описанием желаемого состояния в репозитории: инструмент сам приводит кластер к тому, что записано.

YAMLКорневое приложение окружения
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root-prod
  namespace: argocd
spec:
  source:
    path: apps/prod          # что должно быть развёрнуто
    targetRevision: HEAD
    directory:
      recurse: true
  syncPolicy:
    automated:
      prune: true            # убрать то, чего больше нет в git
      selfHeal: true         # вернуть состояние, если правили руками

Пункт «вернуть состояние, если правили руками» важнее, чем кажется: он превращает ручную правку на проде из незаметного расхождения в то, что откатится само и заставит внести изменение нормальным путём.

Чего ждать после распила

  • Выкатка одной части перестаёт трогать остальные
  • Нагруженные части масштабируются отдельно от спокойных
  • Отказ одного сервиса перестаёт быть отказом всей платформы
  • Появляются новые проблемы: сетевые задержки, согласованность данных, отладка через границы сервисов

Последний пункт не повод не резать, но повод не резать всё сразу. Начинать стоит с той части, которая меняется чаще остальных и мешает выкатывать всё прочее.

Думаете про распил

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

Посмотреть услугу: backend на Go Обсудить задачу