Микросервисы: границы, сообщения и отказы
Разберите, зачем систему делят на сервисы, чем command отличается от event и какие проблемы появляются после сетевой границы.
Временная шкала
Разбираем: Микросервисы: границы, сообщения и отказы
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Монолит похож на один большой офис: отделы находятся в одном здании и могут быстро разговаривать, но переезд или авария затрагивает всех. Микросервисы — несколько самостоятельных офисов с чёткими зонами ответственности. Они общаются по сети, поэтому каждый разговор может задержаться, повториться или не дойти.
Микросервис — независимо развёртываемая application boundary, владеющая конкретной business capability и своими данными. Сервисы взаимодействуют синхронно через request-response или асинхронно через messages/events. Nest предоставляет transport abstraction, ClientProxy, @MessagePattern и @EventPattern, но не отменяет сетевые failures, schema evolution, delivery semantics и observability.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Service boundary
Граница ответственности, deploy и владения данными вокруг business capability, а не случайного набора controllers.
Request-response
Producer отправляет request/command и ожидает ровно один ответ или ошибку в пределах timeout.
Event
Факт, уже произошедший в прошлом: OrderCreated. Publisher не требует business-ответа конкретного consumer.
Message broker
Посредник, который принимает, хранит и доставляет messages: Kafka, RabbitMQ, NATS, Redis Streams и другие.
Delivery semantics
Гарантии доставки: at-most-once, at-least-once или practically exactly-once через ограничения и idempotency.
Idempotency
Повтор одной операции с тем же ключом не создаёт второй business effect.
Eventual consistency
Сервисы некоторое время могут видеть разные версии состояния, но сходятся после доставки событий.
Distributed trace
Связанный путь request/messages через несколько процессов по traceId и spanId.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Найдите capability
Граница строится вокруг бизнес-владения: orders, inventory, payments, а не вокруг технических folders.
- 02Определите contract
Message содержит version, operationId/correlationId и минимальный payload без утечки внутренней schema.
- 03Выберите взаимодействие
Request-response нужен немедленный ответ; event подходит независимой реакции после свершившегося факта.
- 04Пересеките transport
Serializer превращает message в bytes, transport доставляет его другому process, consumer десериализует payload.
- 05Обработайте failure
Timeout, retry, circuit breaker и dead-letter policy ограничивают partial failure.
- 06Сделайте повтор безопасным
Consumer сохраняет operationId или использует UNIQUE constraint, чтобы duplicate delivery не повторил side effect.
- 07Свяжите наблюдаемость
Logs, metrics и traces передают correlation/trace context через каждую message boundary.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Микросервис не равен маленькому REST API
Размер кода вторичен. Важны самостоятельное business ownership, deploy boundary и данные, которые нельзя менять в обход owner-а.
Сеть — часть программы
Вызов может завершиться на сервере, но timeout произойдёт у клиента. Поэтому «не получил ответ» не означает «операция не была выполнена».
At-least-once означает duplicates
Broker может повторно доставить message после сбоя acknowledgement. Consumer обязан быть идемпотентным.
Events нельзя изменять задним числом
Published contract уже читают независимые consumers. Добавляйте совместимые fields, versioning и migration strategy.
Database per service — ownership, не обязательно server
Сервисы не должны напрямую менять чужие tables. Физически базы могут жить в одном cluster, но schemas и credentials разделяют владение.
TCP runtime — учебный transport
Лаборатория показывает настоящий Nest transport без внешней инфраструктуры. Для durable delivery production выбирает Kafka/RabbitMQ/NATS по требованиям.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
@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)),
);Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
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.
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.
Consumer события
Notifications реагирует на уже созданный заказ.
@Controller()
export class OrderEvents {
@EventPattern('order.created.v1')
handle(@Payload() event: OrderCreatedV1) {
return this.notifications.sendConfirmation(event);
}
}- Имя события описывает свершившийся факт.
- Version помогает эволюции contract.
- Publisher не знает список consumers.
Publisher события
Сообщить независимым consumers после успешного commit.
this.events.emit('order.created.v1', {
eventId: randomUUID(),
occurredAt: new Date().toISOString(),
orderId,
customerId,
});- В production event обычно публикует outbox relay.
- eventId используется для deduplication и tracing.
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.
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.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
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.
Популярные заблуждения
Миф слева, корректная модель справа.
Микросервисы автоматически делают систему масштабируемой.
Они позволяют масштабировать boundaries независимо, но плохие contracts и общая БД сохраняют coupling.
Каждый service может синхронно вызвать пять следующих.
Длинная request-chain умножает latency и вероятность partial failure.
Broker гарантирует отсутствие duplicates.
Большинство практичных схем требуют idempotent consumers и deduplication.
Общая database упрощает взаимодействие без последствий.
Прямое изменение чужих tables уничтожает ownership и делает независимый deploy фиктивным.
Начинать pet-project нужно сразу с десяти services.
Сначала модульный монолит часто дешевле; boundary выносят после появления измеримой причины.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Когда request-response лучше event, а когда хуже?
- Почему timeout не доказывает, что remote operation не выполнилась?
- Зачем message нужны eventId, operationId и version?
- Почему at-least-once consumer обязан быть идемпотентным?
- Как transactional outbox закрывает dual-write problem?
- Почему два services не должны напрямую менять одну business table?
- В каком случае модульный монолит будет разумнее микросервисов?