NNODE LOOP LABruntime observatoryNNEON · Статьи на 90 языках
CONNECTING
v24.18.0linux/x64
Capability → contract → transport
13

Микросервисы: границы, сообщения и отказы

Разберите, зачем систему делят на сервисы, чем command отличается от event и какие проблемы появляются после сетевой границы.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Временная шкала

ГОТОВ
0 ms
События появятся здесьЗапустите выбранный сценарий
#ВРЕМЯИСТОЧНИКСОБЫТИЕ
Ожидаю запуск эксперимента…
ГЛАВА 13
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Микросервисы: границы, сообщения и отказы

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

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

ТЕХНИЧЕСКАЯ ОСНОВА

Микросервис — независимо развёртываемая application boundary, владеющая конкретной business capability и своими данными. Сервисы взаимодействуют синхронно через request-response или асинхронно через messages/events. Nest предоставляет transport abstraction, ClientProxy, @MessagePattern и @EventPattern, но не отменяет сетевые failures, schema evolution, delivery semantics и observability.

Зачем это знатьМикросервисы полезны, когда независимые команды, разные профили нагрузки или отдельные failure/deployment boundaries окупают распределённую сложность. Маленькой системе они часто вредят: добавляют сеть, брокер, tracing, contract testing и eventual consistency без реальной организационной выгоды.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01PRODUCERNest controller · use case
02CONTRACTcommand · event · schema
03TRANSPORTTCP · Kafka · RabbitMQ · NATS
04CONSUMERhandler · owned data · side effect
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Service boundary

Граница ответственности, deploy и владения данными вокруг business capability, а не случайного набора controllers.

02

Request-response

Producer отправляет request/command и ожидает ровно один ответ или ошибку в пределах timeout.

03

Event

Факт, уже произошедший в прошлом: OrderCreated. Publisher не требует business-ответа конкретного consumer.

04

Message broker

Посредник, который принимает, хранит и доставляет messages: Kafka, RabbitMQ, NATS, Redis Streams и другие.

05

Delivery semantics

Гарантии доставки: at-most-once, at-least-once или practically exactly-once через ограничения и idempotency.

06

Idempotency

Повтор одной операции с тем же ключом не создаёт второй business effect.

07

Eventual consistency

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

08

Distributed trace

Связанный путь request/messages через несколько процессов по traceId и spanId.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Найдите capability

    Граница строится вокруг бизнес-владения: orders, inventory, payments, а не вокруг технических folders.

  2. 02
    Определите contract

    Message содержит version, operationId/correlationId и минимальный payload без утечки внутренней schema.

  3. 03
    Выберите взаимодействие

    Request-response нужен немедленный ответ; event подходит независимой реакции после свершившегося факта.

  4. 04
    Пересеките transport

    Serializer превращает message в bytes, transport доставляет его другому process, consumer десериализует payload.

  5. 05
    Обработайте failure

    Timeout, retry, circuit breaker и dead-letter policy ограничивают partial failure.

  6. 06
    Сделайте повтор безопасным

    Consumer сохраняет operationId или использует UNIQUE constraint, чтобы duplicate delivery не повторил side effect.

  7. 07
    Свяжите наблюдаемость

    Logs, metrics и traces передают correlation/trace context через каждую message boundary.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Микросервис не равен маленькому REST API

Размер кода вторичен. Важны самостоятельное business ownership, deploy boundary и данные, которые нельзя менять в обход owner-а.

02

Сеть — часть программы

Вызов может завершиться на сервере, но timeout произойдёт у клиента. Поэтому «не получил ответ» не означает «операция не была выполнена».

03

At-least-once означает duplicates

Broker может повторно доставить message после сбоя acknowledgement. Consumer обязан быть идемпотентным.

04

Events нельзя изменять задним числом

Published contract уже читают независимые consumers. Добавляйте совместимые fields, versioning и migration strategy.

05

Database per service — ownership, не обязательно server

Сервисы не должны напрямую менять чужие tables. Физически базы могут жить в одном cluster, но schemas и credentials разделяют владение.

06

TCP runtime — учебный transport

Лаборатория показывает настоящий Nest transport без внешней инфраструктуры. Для durable delivery production выбирает Kafka/RabbitMQ/NATS по требованиям.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
@Controller()
export class InventoryMessages {
  @MessagePattern({ cmd: 'inventory.reserve' })
  reserve(@Payload() command: ReserveInventoryCommand) {
    return this.inventory.reserve(command);
  }
}

const reservation = await firstValueFrom(
  inventoryClient
    .send({ cmd: 'inventory.reserve' }, command)
    .pipe(timeout(2_000)),
);
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/microservices-lab.js
microservice236 строк
import 'reflect-metadata';
import net from 'node:net';
import {
  Controller,
  Dependencies,
  Module,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
  ClientProxyFactory,
  EventPattern,
  MessagePattern,
  Payload,
  RpcException,
  Transport,
} from '@nestjs/microservices';
import {
  firstValueFrom,
  lastValueFrom,
  timeout,
} from 'rxjs';

const LAB_TRACE = Symbol('MICROSERVICES_LAB_TRACE');

async function reserveTcpPort() {
  const server = net.createServer();
  await new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', resolve);
  });
  const address = server.address();
  const port = typeof address === 'object' ? address.port : null;
  await new Promise((resolve) => server.close(resolve));
  if (!port) throw new Error('Не удалось выбрать локальный TCP-порт');
  return port;
}

class InventoryMessageController {
  constructor(trace) {
    this.trace = trace;
    this.reservations = new Map();
  }

  reserve(command) {
    this.trace.emit(
      'consumer',
      'request',
      `Inventory получил command inventory.reserve для order=${command.orderId}`,
    );

    const existing = this.reservations.get(command.operationId);
    if (existing) {
      this.trace.emit(
        'idempotency',
        'duplicate',
        `Повтор operationId=${command.operationId} вернул прежний reservation`,
      );
      return { ...existing, reused: true };
    }

    if (command.sku === 'sold-out') {
      throw new RpcException({
        code: 'OUT_OF_STOCK',
        message: 'Товар закончился',
      });
    }

    const reservation = {
      reservationId: `reservation-${this.reservations.size + 1}`,
      operationId: command.operationId,
      reused: false,
    };
    this.reservations.set(command.operationId, reservation);
    return reservation;
  }

  orderCreated(event) {
    this.trace.emit(
      'event-consumer',
      'event',
      `Notification получил event order.created для order=${event.orderId}`,
    );
  }
}

Dependencies(LAB_TRACE)(InventoryMessageController);
Controller()(InventoryMessageController);

const reserveDescriptor = Object.getOwnPropertyDescriptor(
  InventoryMessageController.prototype,
  'reserve',
);
MessagePattern({ cmd: 'inventory.reserve' })(
  InventoryMessageController.prototype,
  'reserve',
  reserveDescriptor,
);
Payload()(
  InventoryMessageController.prototype,
  'reserve',
  0,
);

const eventDescriptor = Object.getOwnPropertyDescriptor(
  InventoryMessageController.prototype,
  'orderCreated',
);
EventPattern('order.created')(
  InventoryMessageController.prototype,
  'orderCreated',
  eventDescriptor,
);
Payload()(
  InventoryMessageController.prototype,
  'orderCreated',
  0,
);

class MicroservicesLabModule {}
Module({
  controllers: [InventoryMessageController],
})(MicroservicesLabModule);

export async function microservicesMessaging(emit) {
  const port = await reserveTcpPort();
  const module = {
    module: MicroservicesLabModule,
    providers: [
      {
        provide: LAB_TRACE,
        useValue: { emit },
      },
    ],
  };
  const options = {
    transport: Transport.TCP,
    options: {
      host: '127.0.0.1',
      port,
    },
  };
  const service = await NestFactory.createMicroservice(module, {
    ...options,
    logger: false,
  });
  const client = ClientProxyFactory.create(options);

  try {
    emit(
      'boundary',
      'start',
      'Запускаем отдельную Nest application boundary с TCP transport',
    );
    await service.listen();
    await client.connect();

    const command = {
      operationId: 'reserve-order-42',
      orderId: 'order-42',
      sku: 'node-book',
      quantity: 1,
    };
    emit(
      'producer',
      'send',
      'Checkout вызывает request-response pattern inventory.reserve и ждёт один ответ',
    );
    const first = await firstValueFrom(
      client
        .send({ cmd: 'inventory.reserve' }, command)
        .pipe(timeout(2_000)),
    );
    emit(
      'request-response',
      'result',
      `Получен reservation=${first.reservationId}; reused=${first.reused}`,
    );

    const duplicate = await firstValueFrom(
      client
        .send({ cmd: 'inventory.reserve' }, command)
        .pipe(timeout(2_000)),
    );
    emit(
      'idempotency',
      'result',
      `Повторный command не создал вторую запись; reused=${duplicate.reused}`,
    );

    emit(
      'producer',
      'emit',
      'Checkout публикует event order.created и не ждёт business-ответ consumer-а',
    );
    await lastValueFrom(
      client
        .emit('order.created', { orderId: command.orderId })
        .pipe(timeout(2_000)),
      { defaultValue: undefined },
    );

    try {
      await firstValueFrom(
        client
          .send(
            { cmd: 'inventory.reserve' },
            {
              ...command,
              operationId: 'reserve-order-43',
              orderId: 'order-43',
              sku: 'sold-out',
            },
          )
          .pipe(timeout(2_000)),
      );
    } catch (error) {
      const code = error?.code ?? 'REMOTE_ERROR';
      emit(
        'remote-error',
        'error',
        `Remote error пересёк transport boundary: ${code}`,
      );
    }

    emit(
      'architecture',
      'result',
      'Микросервисы дают независимые границы deploy и владения данными, но добавляют сеть, partial failures, contracts, observability и delivery semantics',
    );
  } finally {
    client.close();
    await service.close();
    emit('cleanup', 'done', 'TCP client и Nest microservice остановлены');
  }
}

Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

Consumer команды

Inventory отвечает на command и возвращает результат producer-у.

@Controller()
export class InventoryMessages {
  @MessagePattern({ cmd: 'inventory.reserve' })
  reserve(@Payload() command: ReserveInventoryCommand) {
    return this.inventory.reserve(command);
  }
}
  • Pattern — адрес сообщения внутри transport.
  • @Payload извлекает deserialized body.
  • Return сериализуется как response.
02

Producer request-response

Checkout отправляет command и ждёт ограниченное время.

const response = await firstValueFrom(
  inventoryClient
    .send(
      { cmd: 'inventory.reserve' },
      { operationId, orderId, sku, quantity },
    )
    .pipe(timeout(2_000)),
);
  • send создаёт Observable запроса.
  • firstValueFrom превращает первый response в Promise.
  • timeout ограничивает ожидание, но не обязательно отменяет consumer.
03

Consumer события

Notifications реагирует на уже созданный заказ.

@Controller()
export class OrderEvents {
  @EventPattern('order.created.v1')
  handle(@Payload() event: OrderCreatedV1) {
    return this.notifications.sendConfirmation(event);
  }
}
  • Имя события описывает свершившийся факт.
  • Version помогает эволюции contract.
  • Publisher не знает список consumers.
04

Publisher события

Сообщить независимым consumers после успешного commit.

this.events.emit('order.created.v1', {
  eventId: randomUUID(),
  occurredAt: new Date().toISOString(),
  orderId,
  customerId,
});
  • В production event обычно публикует outbox relay.
  • eventId используется для deduplication и tracing.
05

Idempotent consumer

Не выполнить side effect второй раз при duplicate delivery.

await db.query(
  `INSERT INTO processed_messages (consumer, message_id)
   VALUES ($1, $2)
   ON CONFLICT DO NOTHING
   RETURNING message_id`,
  ['email-confirmation', event.eventId],
);
  • UNIQUE(consumer, message_id) защищает от race.
  • Нулевой rowCount означает duplicate.
  • Marker и side effect должны иметь согласованную transaction boundary.
06

Transactional outbox

Не потерять event между database commit и broker publish.

await db.transaction(async (tx) => {
  const order = await orders.insert(tx, input);
  await outbox.insert(tx, {
    type: 'order.created.v1',
    aggregateId: order.id,
    payload: { orderId: order.id },
  });
});
  • Order и outbox row commit-ятся атомарно.
  • Отдельный relay повторяет broker publish.
  • Consumer всё равно должен выдерживать duplicate.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

Checkout строит длинную синхронную цепочку из четырёх сервисов

Nest CheckoutService создаёт order, затем последовательно ждёт Inventory, Payments и Notifications. Каждый remote call является обязательным для HTTP 200.

КОНТЕКСТ ИНЦИДЕНТА

Latency складывается, availability перемножается, а timeout после успешного payment оставляет неизвестный outcome. Повтор всего HTTP-запроса может списать деньги или зарезервировать stock второй раз.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
@Injectable()
export class CheckoutService {
  async checkout(input: CheckoutDto) {
    const order = await this.orders.create(input);

    await firstValueFrom(
      this.inventory.send('reserve', order),
    );
    await firstValueFrom(
      this.payments.send('charge', order),
    );
    await firstValueFrom(
      this.notifications.send('email', order),
    );

    return { ...order, status: 'completed' };
  }
}

HTTP request владеет распределённой цепочкой без общего transaction manager. Успех remote side effect и потерянный response неразличимы для producer-а.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
@Injectable()
export class CheckoutService {
  async checkout(input: CheckoutDto) {
    return this.db.transaction(async (tx) => {
      const order = await this.orders.createPending(tx, input);
      const eventId = randomUUID();

      await this.outbox.add(tx, {
        eventId,
        type: 'order.placed.v1',
        payload: { orderId: order.id },
      });

      return { orderId: order.id, status: 'pending' };
    });
  }
}

@Controller()
export class InventoryEvents {
  @EventPattern('order.placed.v1')
  handle(@Payload() event: OrderPlacedV1) {
    return this.idempotency.once(
      event.eventId,
      () => this.inventory.reserve(event.orderId),
    );
  }
}

Order и outbox event commit-ятся атомарно. Relay повторяет publish, а consumer принимает возможные duplicates через eventId. HTTP возвращает pending, потому что workflow сходится асинхронно.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

ClientProxy.send(pattern, data)
Создаёт Nest request-response Observable: producer ждёт один response или remote error.
firstValueFrom(observable)
Преобразует первый emitted response RxJS Observable в Promise для использования с await.
db.transaction(callback)
Выполняет order и outbox writes на одной DB connection с единым COMMIT или ROLLBACK.
outbox.add(tx, event)
Сохраняет будущий event в той же транзакции, не пытаясь атомарно координировать PostgreSQL и broker.
@EventPattern(name)
Регистрирует Nest consumer handler для event pattern выбранного transport.
idempotency.once(eventId, work)
Условный application helper: атомарно запоминает eventId и не повторяет work при duplicate delivery.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Это не универсальный рецепт: критический мгновенный ответ может требовать bounded request-response. Но email не должен удлинять checkout, а multi-service workflow требует saga/compensation и явных states.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • p99 checkout равен сумме p99 всех downstream calls.
  • Timeout клиента сопровождается успешным charge в payment logs.
  • Один correlationId проходит через длинную последовательную trace-цепочку.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Микросервисы автоматически делают систему масштабируемой.

НА САМОМ ДЕЛЕ

Они позволяют масштабировать boundaries независимо, но плохие contracts и общая БД сохраняют coupling.

МИФ

Каждый service может синхронно вызвать пять следующих.

НА САМОМ ДЕЛЕ

Длинная request-chain умножает latency и вероятность partial failure.

МИФ

Broker гарантирует отсутствие duplicates.

НА САМОМ ДЕЛЕ

Большинство практичных схем требуют idempotent consumers и deduplication.

МИФ

Общая database упрощает взаимодействие без последствий.

НА САМОМ ДЕЛЕ

Прямое изменение чужих tables уничтожает ownership и делает независимый deploy фиктивным.

МИФ

Начинать pet-project нужно сразу с десяти services.

НА САМОМ ДЕЛЕ

Сначала модульный монолит часто дешевле; boundary выносят после появления измеримой причины.

09 · САМОПРОВЕРКА

Ответьте своими словами

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

  1. Когда request-response лучше event, а когда хуже?
  2. Почему timeout не доказывает, что remote operation не выполнилась?
  3. Зачем message нужны eventId, operationId и version?
  4. Почему at-least-once consumer обязан быть идемпотентным?
  5. Как transactional outbox закрывает dual-write problem?
  6. Почему два services не должны напрямую менять одну business table?
  7. В каком случае модульный монолит будет разумнее микросервисов?