В мире событийно-управляемой архитектуры часто возникает иллюзия, что для создания микросервиса обязательно нужно подключать Apache Flink, Kafka Streams или Spark. Однако правда заключается в том, что базовый шаблон производителя и потребителя (Basic Producer/Consumer — BPC) покрывает потребности огромного количества бизнес-сценариев.
BPC-микросервис — это, по сути, элегантно простой цикл: получить событие → применить бизнес-логику → опубликовать результат. В нём нет встроенных механизмов для водяных знаков, сложного планирования событий или распределённых журналов изменений. Но именно эта простота делает его невероятно гибким инструментом в руках грамотного архитектора.
Это десятая глава цикла. Она опирается на главу V (state stores, rebalance), главу VII (внешние vs внутренние state stores) и главу IX (FaaS как форма простоты). Здесь мы разберём, где BPC выигрывает у «тяжёлых» фреймворков, а где — проигрывает.
В основе BPC лежит использование стандартных клиентских библиотек брокера событий. Весь бизнес-процесс ограниченного контекста инкапсулирован в одном приложении. Это снижает порог входа, позволяет использовать любой язык программирования и избавляет команду от необходимости поддерживать кластеры сложных потоковых фреймворков.
// Базовый цикл BPC-микросервиса
while (running) {
ConsumerRecords<String, Event> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, Event> record : records) {
// Применяем кастомную бизнес-логику
OutputEvent output = processEvent(record.value());
// Публикуем результат
producer.send(new ProducerRecord<>("output-topic", output));
}
// Коммитим offset только ПОСЛЕ успешной обработки
consumer.commitSync();
}
Обратите внимание: commitSync() вызывается после обработки всего батча. Это ключевое отличие BPC от наивной реализации «poll → commit → process», которая при сбое теряет данные.
Фиксация offsets после обработки — то же правило, что обсуждалось в главе II для Kafka consumer groups. BPC не отменяет это правило, а делает его ещё более критичным: здесь нет автоматического управления offset'ами, которое предоставляют Kafka Streams или Flink.
Представьте старый монолит, который не может напрямую работать с потоками событий, но которому критически важны данные из них (например, фронтенд интернет-магазина, которому нужны актуальные цены и остатки).
Паттерн Sidecar позволяет запустить BPC-микросервис в одном контейнере с монолитом. «Коляска» потребляет события из брокера и тихо обновляет локальную БД монолита (или кэш), не требуя изменения ни строчки кода в самом наследии.
// Sidecar: читает поток цен, пишет в БД монолита
while (running) {
ConsumerRecords<String, PriceEvent> records = consumer.poll(...);
try (Connection conn = legacyDb.getConnection()) {
for (ConsumerRecord<String, PriceEvent> record : records) {
PriceEvent event = record.value();
// Upsert в таблицу монолита
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO prices (product_id, price) VALUES (?, ?) " +
"ON CONFLICT (product_id) DO UPDATE SET price = EXCLUDED.price"
);
stmt.setString(1, event.getProductId());
stmt.setBigDecimal(2, event.getPrice());
stmt.executeUpdate();
}
consumer.commitSync();
} catch (SQLException e) {
// Offset НЕ коммитим — повторим batch
log.error("Failed to update legacy DB", e);
}
}
Sidecar позволяет интегрировать legacy-систему в событийный мир без модификации её кода. Монолит продолжает работать как раньше, но теперь получает данные из потоков через промежуточный адаптер. Это прямая связь с главой IV (Data Liberation): Sidecar — один из инструментов освобождения данных.
Многие бизнес-процессы требуют наличия нескольких фактов, но порядок их поступления не имеет значения. Классический пример — книгоиздание.
Чтобы отправить книгу в печать, нужны три события: «Текст готов», «Обложка утверждена», «Цена установлена». BPC-сервис материализует эти факты в локальную таблицу. Как только все три ячейки заполнены (независимо от того, в каком порядке они пришли), сервис генерирует событие «Готово к печати».
-- Таблица состояния Gating
CREATE TABLE book_readiness (
book_id UUID PRIMARY KEY,
text_ready BOOLEAN DEFAULT FALSE,
cover_approved BOOLEAN DEFAULT FALSE,
price_set BOOLEAN DEFAULT FALSE,
ready_for_print BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP DEFAULT NOW()
);
-- Обработка события
UPDATE book_readiness
SET text_ready = TRUE
WHERE book_id = :bookId;
-- Проверка готовности
UPDATE book_readiness
SET ready_for_print = TRUE
WHERE book_id = :bookId
AND text_ready = TRUE
AND cover_approved = TRUE
AND price_set = TRUE
AND ready_for_print = FALSE
RETURNING book_id;
-- Если вернулась строка — публикуем событие ReadyForPrint
Обратите внимание на AND ready_for_print = FALSE: это защита от повторной публикации события при повторной обработке того же факта (идемпотентность).
Gating не требует event time. Здесь важен факт наличия, а не временная метка. Это принципиально отличает Gating от оконных операций из главы VI. Если бизнес-процессу важен только набор фактов, а не их порядок — Gating проще и надёжнее.
Зачем писать сложную логику агрегации в коде микросервиса, если это может сделать специализированная БД? Если ваш сценарий включает геопространственный поиск (например, поиск ближайших кафе на основе событий геолокации) или классификацию через ML-модель, BPC выступает лишь в роли «маршрутизатора».
// BPC как маршрутизатор к внешнему ML-сервису
while (running) {
ConsumerRecords<String, ImageEvent> records = consumer.poll(...);
for (ConsumerRecord<String, ImageEvent> record : records) {
ImageEvent event = record.value();
// Отправляем в ML-сервис
ClassificationResult result = mlService.classify(event.getImageUrl());
// Сохраняем результат
producer.send(new ProducerRecord<>(
"image-classifications",
new ClassificationEvent(event.getImageId(), result.getLabel())
));
}
consumer.commitSync();
}
Вся сложность инкапсулирована в инфраструктуре данных: ML-сервис, vector DB, геопространственный индекс. BPC здесь — тонкий слой оркестрации.
Объём событий часто зависит от времени суток (циклы сна и бодрствования пользователей), а вот размер состояния (например, количество уникальных пользователей) остаётся постоянным.
BPC позволяет элегантно решить эту проблему: ночью вы масштабируете количество инстансов обработки до нуля (экономя деньги), но внешнее хранилище состояний (например, DynamoDB или Redis) продолжает хранить профили всех пользователей. Утром BPC-инстансы просто просыпаются и продолжают читать состояния.
Состояние живёт в RocksDB на локальном диске инстанса. При масштабировании до нуля состояние теряется и восстанавливается из changelog — долго и дорого.
Состояние живёт в DynamoDB / Redis / PostgreSQL. Инстансы stateless: можно масштабировать до нуля и обратно без потери состояния.
Это прямое следствие архитектурного выбора: BPC использует только внешние state stores. В отличие от Kafka Streams, где state store защищён changelog и может быть восстановлен, BPC не имеет внутреннего состояния вообще. Цена — network latency к внешним хранилищам. Выигрыш — абсолютная гибкость масштабирования.
Что делать, если вам нужно сделать сложный JOIN трёх потоков в реальном времени, но ваш основной язык программирования не имеет хорошей библиотеки для этого?
Вы используете гибридный паттерн. BPC-микросервис запускает внешнее задание в потоковом фреймворке (например, в KSQL или Flink), который выполняет «грязную» работу по объединению данных. Результат сбрасывается в промежуточный поток событий, который ваш BPC-микросервис забирает и использует для обслуживания REST API или загрузки в витрину данных.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Raw Events │────>│ Flink Job │────>│ Joined │
│ (3 streams)│ │ (JOIN) │ │ Stream │
└─────────────┘ └──────────────┘ └──────┬──────┘
│
▼
┌─────────────┐
│ BPC Service │
│ (serve API) │
└─────────────┘
Flink решает узкую задачу, которая плохо ложится на BPC. BPC решает всё остальное: обслуживание API, запись в витрину, интеграцию с другими сервисами.
BPC-микросервис работает в режиме at-least-once: если обработка batch упала до коммита offset, тот же batch будет обработан снова. Значит, шаги должны быть идемпотентны.
event_id или бизнес-ключ как idempotency key. Сервис хранит таблицу обработанных ID и пропускает дубликаты.INSERT ... ON CONFLICT DO UPDATE. В DynamoDB — attribute_not_exists(event_id).SET price = 100, а не price += 100) — дубликаты не страшны.// Дедупликация через таблицу обработанных событий
Connection conn = db.getConnection();
conn.setAutoCommit(false);
try {
// Проверка дубликата и бизнес-операция в одной транзакции
PreparedStatement check = conn.prepareStatement(
"INSERT INTO processed_events (event_id) VALUES (?) " +
"ON CONFLICT DO NOTHING RETURNING event_id"
);
check.setString(1, event.getId());
ResultSet rs = check.executeQuery();
if (!rs.next()) {
// Уже обработано — пропускаем
conn.commit();
continue;
}
// Бизнес-операция
updateBusinessState(conn, event);
conn.commit();
} catch (SQLException e) {
conn.rollback();
// Offset НЕ коммитим
}
Каждый BPC-шаг, который может быть выполнен повторно, должен быть идемпотентен. Дедупликация должна жить в той же транзакции, что и бизнес-операция — иначе возможен сбой между ними.
Что происходит, когда BPC-контейнер получает SIGTERM при деплое? Если просто выключиться — broker будет ждать session.timeout.ms, прежде чем передаст partitions другому потребителю. Это создаёт искусственные задержки в обработке.
public class BpcService {
private volatile boolean running = true;
public void run() {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
running = false;
}));
try {
while (running) {
ConsumerRecords<String, Event> records = consumer.poll(...);
processRecords(records);
consumer.commitSync();
}
} finally {
// Ключевое: закрыть consumer → отказаться от partitions
consumer.close(Duration.ofSeconds(10));
producer.flush();
producer.close(Duration.ofSeconds(10));
db.close();
}
}
}
Правило: при любых сомнениях всегда очищайте соединения перед завершением. Это ускоряет rebalance и снижает нагрузку на broker.
BPC-микросервис на контейнере — это тоже consumer group. При деплое, при падении инстанса — rebalance. Все проблемы из главы V применимы и здесь.
| Стратегия | Эффект | Когда использовать |
|---|---|---|
| Eager rebalance | Все потребители останавливаются, партиции перераспределяются | Default, но устаревший |
| Cooperative sticky assignor | Только изменившиеся партиции перераспределяются | BPC с несколькими инстансами |
| Static group membership | Потребитель при рестарте получает те же partitions | BPC с предсказуемой нагрузкой |
// Настройка consumer для минимизации rebalance
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG,
"bpc-instance-" + System.getenv("HOSTNAME"));
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, "10000");
BPC в production без мониторинга — чёрный ящик. Что обязательно нужно измерять:
| Метрика | Что показывает | Порог алерта |
|---|---|---|
consumer_lag |
Отставание от хвоста потока | > 10000 или растёт за 15 мин |
processing_time_p99 |
99-й перцентиль обработки события | > SLA |
error_rate |
Доля упавших обработок | > 1% за 5 мин |
dlq_rate |
Доля событий, ушедших в DLQ | > 0 должно алертить |
commit_latency |
Время на commit offsets | > 1 сек |
rebalance_count |
Число rebalance за час | > 2 в час |
Минимум три алерта: (1) consumer lag выше порога, (2) error rate выше 1%, (3) DLQ не пуста. Без них вы узнаете о проблеме только от пользователей.
| Критерий | BPC | Kafka Streams | FaaS |
|---|---|---|---|
| Состояние | Внешнее (Redis, DB) | Внутреннее (RocksDB) + changelog | Внешнее (Redis, DB) |
| Event time / watermarks | Нет | Полная поддержка | Нет |
| Окна | Ручные | Tumbling, hopping, sliding, session | Ручные |
| Joins | Через внешнее state | Встроены (co-partitioned) | Через внешнее state |
| Exactly-once | Ручная идемпотентность | exactly_once_v2 | Ручная идемпотентность |
| Масштабирование | Ручное (K8s) | Автоматическое (partition-based) | Автоматическое (provider-managed) |
| Стоимость при простое | Нулевая (scale to zero) | Есть (кластер работает) | Нулевая (pay-per-invocation) |
| Сложность | Низкая | Средняя | Низкая |
Нужны watermarks / event time / windows?
├── Да → Kafka Streams или Flink
└── Нет ↓
Нужны сложные joins нескольких потоков?
├── Да → Kafka Streams или Flink (или гибридный BPC + Flink)
└── Нет ↓
Нужна детерминированная обработка с exactly-once?
├── Да → Kafka Streams (exactly_once_v2)
└── Нет ↓
Нагрузка переменная, нужна экономия при простое?
├── Да → FaaS (глава IX)
└── Нет ↓
BPC на контейнере + внешнее state
Прежде чем подключать Apache Flink или Kafka Streams и разворачивать полноценные кластеры, задайте себе вопрос: «Смогу ли я решить эту задачу с помощью простого цикла poll-process-produce и внешней базы данных?» В 80% случаев ответ будет «да».
Базовый шаблон не был бы универсальным, если бы не имел своих границ. Отказываясь от «тяжеловесных» и «легковесных» фреймворков, вы берёте на себя ответственность за реализацию сложных паттернов самостоятельно.
Если ваша бизнес-логика требует детерминированной обработки событий из разных потоков с учётом временных меток (водяных знаков), или если вам нужно строить сложные скользящие окна с точным контролем времени, BPC «из коробки» с этим не справится. Вам придётся писать собственные библиотеки для планирования событий и буферизации, что может свести на нет преимущество простоты.
Кроме того, в BPC-паттерне внешние хранилища состояний используются чаще внутренних. Это означает, что сетевые задержки (latency) при чтении/записи в БД становятся вашим главным врагом производительности, и архитектуру нужно проектировать с учётом кэширования и пакетной обработки.
| Глава | Фокус | Связь с текущей главой |
|---|---|---|
| II. Анатомия EDA | Consumer groups, offsets, идемпотентность | BPC использует те же принципы без фреймворка |
| IV. Data Liberation | Legacy-интеграция, CDC, событизация | Sidecar — один из инструментов освобождения данных |
| V. Механика потоковой обработки | State stores, rebalance, cooperative sticky | BPC применяет те же rebalance-стратегии |
| VII. Память микросервисов | Внутренние vs внешние state stores | BPC всегда использует внешние stores |
| IX. Бессерверная архитектура | FaaS в EDA | FaaS — тот же BPC, но managed |
| X. Базовый шаблон «Производитель-Потребитель» | Простота как стратегия | Когда BPC достаточно, а когда нужен фреймворк |
group.instance.id) для предсказуемых деплоевШаблон Basic Producer/Consumer — это рабочий инструмент, а не «упрощённая версия» для новичков. Он идеально подходит для интеграции с legacy-системами, для сценариев, где порядок событий не важен, и для случаев, когда основную вычислительную нагрузку берёт на себя уровень данных. Простота — высшая форма изощрённости.
Глава X цикла «Событийно-управляемая архитектура». Источники: Apache Kafka, Confluent, практические кейсы production-систем, опыт FinCodeCow.
In the world of event-driven architecture, there is often an illusion that building a microservice requires Apache Flink, Kafka Streams, or Spark. But the truth is that the Basic Producer/Consumer pattern (BPC) covers a huge number of business scenarios.
A BPC microservice is essentially an elegantly simple loop: receive event → apply business logic → publish result. It has no built-in mechanisms for watermarks, complex event scheduling, or distributed changelogs. But it is precisely this simplicity that makes it an incredibly flexible tool in the hands of a skilled architect.
This is the tenth chapter of the series. It builds on Chapter V (state stores, rebalance), Chapter VII (external vs internal state stores), and Chapter IX (FaaS as a form of simplicity). Here we explore where BPC wins over "heavy" frameworks and where it loses.
BPC is based on standard broker client libraries. The entire business process of a bounded context is encapsulated in a single application. This lowers the entry barrier, allows using any programming language, and frees the team from maintaining complex streaming framework clusters.
// Basic BPC microservice loop
while (running) {
ConsumerRecords<String, Event> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, Event> record : records) {
OutputEvent output = processEvent(record.value());
producer.send(new ProducerRecord<>("output-topic", output));
}
// Commit offset only AFTER successful processing
consumer.commitSync();
}
Committing offsets after processing is the same rule discussed in Chapter II for Kafka consumer groups. BPC does not cancel this rule — it makes it even more critical.
Imagine an old monolith that cannot work directly with event streams, but critically needs data from them. The Sidecar pattern allows running a BPC microservice in the same container as the monolith. The "sidecar" consumes events from the broker and quietly updates the monolith's local DB (or cache), without requiring a single line of code change in the legacy itself.
// Sidecar: reads price stream, writes to monolith DB
while (running) {
ConsumerRecords<String, PriceEvent> records = consumer.poll(...);
try (Connection conn = legacyDb.getConnection()) {
for (ConsumerRecord<String, PriceEvent> record : records) {
PriceEvent event = record.value();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO prices (product_id, price) VALUES (?, ?) " +
"ON CONFLICT (product_id) DO UPDATE SET price = EXCLUDED.price"
);
stmt.setString(1, event.getProductId());
stmt.setBigDecimal(2, event.getPrice());
stmt.executeUpdate();
}
consumer.commitSync();
} catch (SQLException e) {
// Offset NOT committed — retry batch
}
}
Sidecar allows integrating a legacy system into the event world without modifying its code. This is a direct link to Chapter IV (Data Liberation).
Many business processes require the presence of several facts, but the order of their arrival does not matter. A classic example is book publishing.
To send a book to print, three events are needed: "Text ready," "Cover approved," "Price set." A BPC service materializes these facts into a local table. As soon as all three cells are filled (regardless of the order they arrived in), the service generates a "Ready for Print" event.
-- Gating state table
CREATE TABLE book_readiness (
book_id UUID PRIMARY KEY,
text_ready BOOLEAN DEFAULT FALSE,
cover_approved BOOLEAN DEFAULT FALSE,
price_set BOOLEAN DEFAULT FALSE,
ready_for_print BOOLEAN DEFAULT FALSE
);
UPDATE book_readiness
SET ready_for_print = TRUE
WHERE book_id = :bookId
AND text_ready = TRUE
AND cover_approved = TRUE
AND price_set = TRUE
AND ready_for_print = FALSE
RETURNING book_id;
Gating does not require event time. What matters is the presence of a fact, not the timestamp. This fundamentally differs from windowing in Chapter VI.
Why write complex aggregation logic in microservice code if a specialized DB can do it? If your scenario includes geospatial search or ML-model classification, BPC acts as a "router": it takes the event from the stream, sends it to a vector DB or ML service, and stores the result.
Event volume often depends on time of day, while state size (e.g., number of unique users) stays constant. BPC solves this elegantly: at night you scale processing instances down to zero (saving money), but the external state store (DynamoDB or Redis) continues to hold profiles. In the morning, BPC instances wake up and continue reading states.
State lives in RocksDB on local disk. Scaling to zero loses state; recovery from changelog is slow and costly.
State lives in DynamoDB / Redis / PostgreSQL. Instances are stateless: scale to zero and back without losing state.
What if you need a complex JOIN of three streams in real time, but your primary language lacks a good library for it? Use the hybrid pattern. A BPC microservice launches an external job in a streaming framework (KSQL, Flink), which does the "dirty" join work. The result is flushed to an intermediate event stream, which your BPC microservice consumes and uses for REST API or data mart.
event_id as key. Service keeps a table of processed IDs and skips duplicates.INSERT ... ON CONFLICT DO UPDATE. In DynamoDB — attribute_not_exists(event_id).SET price = 100, not price += 100) — duplicates are harmless.What happens when a BPC container receives SIGTERM on deploy? If it just shuts down, the broker waits for session.timeout.ms before reassigning partitions. This creates artificial delays.
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
running = false;
}));
try {
while (running) {
ConsumerRecords<String, Event> records = consumer.poll(...);
processRecords(records);
consumer.commitSync();
}
} finally {
consumer.close(Duration.ofSeconds(10));
producer.flush();
producer.close(Duration.ofSeconds(10));
}
| Strategy | Effect | When to Use |
|---|---|---|
| Eager rebalance | All consumers stop, partitions reassigned | Default but outdated |
| Cooperative sticky assignor | Only changed partitions reassigned | BPC with multiple instances |
| Static group membership | Consumer gets same partitions on restart | BPC with predictable load |
| Metric | What It Shows | Alert Threshold |
|---|---|---|
consumer_lag |
Lag from stream tail | > 10000 or growing in 15 min |
processing_time_p99 |
99th percentile processing time | > SLA |
error_rate |
Failed processing share | > 1% in 5 min |
dlq_rate |
Events sent to DLQ | > 0 must alert |
rebalance_count |
Rebalances per hour | > 2 per hour |
| Criterion | BPC | Kafka Streams | FaaS |
|---|---|---|---|
| State | External (Redis, DB) | Internal (RocksDB) + changelog | External (Redis, DB) |
| Event time / watermarks | No | Full support | No |
| Windowing | Manual | Tumbling, hopping, sliding, session | Manual |
| Joins | Via external state | Built-in (co-partitioned) | Via external state |
| Exactly-once | Manual idempotency | exactly_once_v2 | Manual idempotency |
| Cost at idle | Zero (scale to zero) | Exists (cluster runs) | Zero (pay-per-invocation) |
Need watermarks / event time / windows?
├── Yes → Kafka Streams or Flink
└── No ↓
Need complex joins of several streams?
├── Yes → Kafka Streams or Flink (or hybrid BPC + Flink)
└── No ↓
Need deterministic processing with exactly-once?
├── Yes → Kafka Streams (exactly_once_v2)
└── No ↓
Variable load, need to save at idle?
├── Yes → FaaS (Chapter IX)
└── No ↓
BPC on container + external state
If your business logic requires deterministic processing of events from different streams with time semantics (watermarks), or if you need complex sliding windows with precise time control, BPC cannot handle this out of the box.
| Chapter | Focus | Link to This Chapter |
|---|---|---|
| II. The Anatomy of EDA | Consumer groups, offsets, idempotency | BPC uses the same principles without a framework |
| IV. Data Liberation | Legacy integration, CDC, eventification | Sidecar is one of the data liberation tools |
| V. The Mechanics of Stream Processing | State stores, rebalance, cooperative sticky | BPC applies the same rebalance strategies |
| VII. The Memory of Microservices | Internal vs external state stores | BPC always uses external stores |
| IX. Serverless Architecture | FaaS in EDA | FaaS is the same BPC but managed |
| X. Basic Producer/Consumer Pattern | Simplicity as strategy | When BPC is enough and when a framework is needed |
group.instance.id) for predictable deploysThe Basic Producer/Consumer pattern is a working tool, not a "simplified version" for beginners. It is ideal for legacy system integration, for scenarios where event order does not matter, and for cases where the data layer does the heavy lifting. Simplicity is the highest form of sophistication.
Chapter X of the "Event-Driven Architecture" series. Sources: Apache Kafka, Confluent, production system case studies, FinCodeCow experience.