Привет, Хабр! Меня зовут Александр, я главный солюшн архитектор трайба в ОТП Банке мы с моей коллегой Екатериной, senior разработчиком трайба, в продолжение прошлой статьи расскажем вам про вызовы, с которыми мы столкнулись при реализации интеграций нашего ДБО с бэкофисом.

Зачем нужна эта статья? Начну с небольшой истории из личного опыта.
В самом начале проекта по разработке нашего ДБО передо мной стояла задача выбрать подрядчика. Ключевым критерием было совпадение в архитектурных подходах. В ходе одного из интервью с архитектором от потенциального подрядчика (далее «Андрей П.», имя вымышленное) состоялся интересный диалог:
Я: Начнем с вопроса интеграции нашего ДБО с АБС. Как вы предлагаете выстраивать обмен данными? Какие инструменты будут в основе?
Андрей П.: Без сомнений, прямое соединение с базой данных (DBLink). Это даст максимальную скорость и безупречную консистентность. Клиент всегда будет видеть актуальные данные.
Я: Допустим, что безопасность банка категорически запрещает прямые соединения с БД АБС из периметра ДБО. Что тогда?
Андрей П.: В таком случае, очевидной альтернативой является REST API. Современно, стандартно, все умеют с этим работать. Быстро и надежно.
Я: А как быть с доступностью? Если АБС "легло" на плановое обслуживание или из-за инцидента, как мы будем предоставлять сервис клиентам? ДБО должно быть всегда онлайн.
Андрей П.: Это проблема уровня АБС. Её нужно предотвращать на их стороне - обеспечивать отказоустойчивый кластер и минимальные окна простоя.
Последующие несколько минут я пытался осторожно навести его на мысль о событийной модели интеграции, но натолкнулся на контраргументы: такая модель потребует избыточной инфраструктуры для поддержания кэша, неизбежно породит проблемы с консистентностью данных и в целом снизит надежность системы по сравнению с "проверенными" методами.
Этот диалог, в той или иной форме, повторялся после не раз. Он всплывает в дискуссиях с новыми разработчиками, аналитиками и даже опытными архитекторами, которые присоединяются к нашей команде. Устоявшееся мышление, ориентированное на синхронные point-to-point взаимодействия, оказывается невероятно сильным.
Именно этот повторяющийся спор и стал одним из поводов для данной статьи. В ней я систематизирую весь наш практический опыт, полученный при построении высоконагруженного и отказоустойчивого ДБО. Мы разберем, почему классические подходы (DBLink, REST) ведут в тупик при масштабировании, и докажем на реальных кейсах и архитектурных паттернах, что событийная модель - не усложнение, а единственный путь к созданию по-настоящему надежных и масштабируемых систем в контексте современного банкинга.
Глава 1. Проблема синхронной интеграции с АБС

Первой и ключевой проблемой при проектировании высоконагруженного сервиса (дальше ДБО) стала интеграция с нашим бэкофисом и в частности с АБС системой. Из коробки поставщик АБС предлагал два варианта: прямые соединения с базой данных (DB-link) и REST API.
Прямой доступ к БД был категорически отвергнут по соображениям информационной безопасности, из-за риска создания сильной связности (tight coupling) и неподконтрольного влияния на производительность критической системы.
Оставался только REST API, который и был реализован в первой версии MVP. Тем не менее этот подход является антипаттерном и нам в долгосрочном решении не подходит. Рассмотрим, почему в нашем случае синхрон - это плохо:
1. Проблема нагрузки и масштабируемости
Главный недостаток синхронного подхода - лавинообразный рост нагрузки на АБС прямо пропорционально росту клиентской базы ДБО. Даже на первой тысяче мигрированных клиентов уже получали 100 RPS и АБС уходила в отказ от обслуживания (DoS). Проблема частично решалась использованием кэширования, но устранить ее полностью было невозможно.
2. Проблема задержек и пользовательского опыта (UX)
Синхронный вызов создает длинную цепочку зависимостей:
Клиент -> API-Gateway -> Микросервис ДБО (их тут обычно больше одного) -> API-Gateway -> адаптер АБС -> АБС
Задержки на каждом этапе (сеть, обработка) суммируются. Если АБС под нагрузкой отвечает за 2-3 секунды, а не за 200-300 мс, клиентское приложение будет ожидать ответа 5-10 секунд. При первом входе, когда данных в кэше нет или они неактуальны, необходимо подгрузить большой объем данных. Это время становится неприемлемым и ведет к потере клиентов.
Детали реализации с примерами кода
Для того, чтобы АБС справлялась с нагрузкой, сделали ограничение нагрузки. То есть, на нескольких инстансах запускается задача, которая берет только разрешенной количество соединений, при этом это ограничение должно действовать на все инстансы. Например, на 6 инстансах сервиса одновременно могут работать только 50 потоков для выкачивания данных по ресту.
Есть 2 таблички, в одной мы храним данные организаций, в другой данные счетов этих организаций. Каждая запись может быть в одном из 3 состояний WAITING_FOR_PROCESSING, PROCESSING, DONE
@Bean
public ExecutorService threadPoolExecutor() {
// ограничиваем пул
final var executor = Executors.newFixedThreadPool(50);
return ContextExecutorService.wrap(executor,
// продолжаем трейсинг, если уходим в новый тред
() -> ContextSnapshotFactory.builder().build().captureAll());
}
@SchedulerLock(name = "processTransactionEnrichmentPool")
@Scheduled(cron = "0 */2 * * * *")
public void processTransactionEnrichmentPool() {
final List<TransactionEnrichmentAccount> accounts = new ArrayList<>();
// у каждой организации может быть несколько счетов. При этом из за ограничений нагрузки у каждой в обработке может находиться все или только часть счетов.
// Поэтому сначала пытаемся найти все организации, у которых уже началась обработка, но были забраны не все счета
accounts.addAll(getReadyToProcessAccounts(PROCESSING, 0));
// а затем берем новые организации
accounts.addAll(getReadyToProcessAccounts(WAITING_FOR_PROCESSING, accounts.size()));
// запускаем загрузку
accounts.forEach(this::processAccount);
}
private List<TransactionEnrichmentAccount> getReadyToProcessAccounts(@Nonnull final TransactionEnrichmentStatus organizationStatus, final int readyToProcessAccountSize) {
final int freeCount = Math.max(accountMaxProcessingCount - IN_PROCESS_ACCOUNTS.size() - readyToProcessAccountSize, 0);
if (freeCount == 0) {
// если все слоты заняты, то не добавляем счета в обработку
return List.of();
}
// берем первые организации, которые были добавлены в очередь
final var organizations = organizationRepository.findOldestByStatus(organizationStatus, freeCount);
final var accountsByStatus = organizations
.stream()
.flatMap(organization -> organization.getTransactionEnrichmentAccounts().stream())
.collect(Collectors.groupingBy(TransactionEnrichmentAccount::getStatus));
// смотрим сколько свободных слотов, выкинув данные тез счетов, которые уже в обработке
final int freeCountWithoutOtherEnv = freeCount - accountsByStatus.getOrDefault(PROCESSING, List.of()).size();
if (freeCountWithoutOtherEnv <= 0) {
// если все слоты заняты, то не добавляем счета в обработку
return List.of();
}
// добираем данные для оставшихся свободных слотов
final var accounts = accountsByStatus.getOrDefault(WAITING_FOR_PROCESSING, List.of());
final List<TransactionEnrichmentAccount> availableAccounts = accounts.size() < freeCountWithoutOtherEnv ?
accounts : accounts.subList(0, freeCountWithoutOtherEnv);
availableAccounts.forEach(account -> {
// фиксируем, что начали обработку
account.startProcessing();
});
// если все аккаунты обработались, то ставим статус "окончено" для организации
organizations.forEach(TransactionEnrichmentOrganization::checkStatusDoneToSetStatusIsDone);
organizationRepository.saveAll(organizations);
return availableAccounts;
}
private void processAccount(final TransactionEnrichmentAccount account) {
CompletableFuture.runAsync(() -> {
Thread.currentThread().setName("Enrichment-" + account.getOrganizationId() + "-" + account.getAccountNumber());
// начинаем загружать данные из АБС
// когда загрузка закончится, надо обновить данные
account.setStatus(DONE);
accountRepository.save(account);
}, transactionEnrichmentExecutor);
}
3. Архитектурный антипаттерн: «Распределенный монолит» (Distributed Monolith)
Мы пришли к ключевой архитектурной проблеме. Внешне система выглядит как современная микросервисная архитектура (кубер кластер, куча микросервисов). Однако, все эти сервисы жестко и синхронно зависят от одной точки отказа – АБС. Это не микросервисы, распределенный монолит.
Слабая изоляция отказов: Падение АБС немедленно и гарантированно «валит» всё ДБО.
Нулевая независимость масштабирования: Мы не можем масштабировать сервисы ДБО независимо от АБС. Увеличивая количество инстансов микросервисов ДБО, мы лишь увеличиваем нагрузку на АБС, усугубляя проблему.
Сниженная надежность: Общая надежность системы (ДБО + АБС) становится равной произведению их надежностей. 0.999 0.99 = 0.989
(98.9%), что ниже целевого показателя в 99.99% для ДБО.
Теоретическая основа: почему CAP-теорема важна.

Любое решение в распределенных системах – это компромисс. CAP-теорема (Consistency, Availability, Partition Tolerance) – фундаментальный принцип, который диктует нам условия этого компромисса. Она гласит, что в распределенной системе можно гарантировать выполнение не более двух из трех свойств одновременно. При интеграции ДБО и АБС мы имеем классический распределенный кластер с сетевым взаимодействием, где сеть ненадежна по определению (Partition Tolerance - P). Следовательно, выбор стоит между консистентностью (C) и доступностью (A).
Синхронный REST-подход выбирает консистентность (C) в ущерб доступности (A). Мы блокируем клиента и ждем ответа от АБС, чтобы немедленно дать ему актуальные данные. Если АБС недоступна, мы не можем дать клиенту никаких данных – мы жертвуем доступностью.
Для ДБО правильный выбор – это доступность и устойчивость к разделению (AP). Мы должны отвечать клиенту мгновенно, даже если данные могут быть не совсем актуальными на данный момент (Eventually Consistent – конечная согласованность). Клиент может работать с системой (просматривать кэшированные данные, инициировать новые операции), а система гарантированно синхронизирует состояния в фоне, когда АБС снова станет доступна.
Этот выбор определяет переход на событийную модель (Event-Driven Architecture), которая и будет рассмотрена дальше.
Глава 2: Выбор и настройка брокера сообщений (Kafka). Первые проблемы
Первым и очевидным кандидатом на роль брокера сообщений для нашей Event-Driven архитектуры стал Apache Kafka. Ключевым требованием была необходимость развертывания кластера Kafka в строгом соответствии с зональностью сети банка.
Архитектура и ключевые настройки

Зона CDE: В этой зоне с самыми строгими т��ебованиями безопасности находится АБС - источник истинных данных.
Зона CDE-connected-to (security impacted systems): В этой зоне располагаются системы, взаимодействующие с остальной сетью банка, в частности внутренняя сеть и наше ДБО. Здесь был развернут наш кластер кафки. Больше про работу с зоной CDE можно почитать тут: PCI-DSS. Для обеспечения отказоустойчивости был развернут кластер из 3-х брокеров Kafka в каждой зоне безопасности.
Топики и партиции: Для изоляции потоков данных было создано 6 топиков в соответствии с бизнес процессами. Каждый топик был разделен на 6 партиций для обеспечения параллельной обработки. Количество партиций было выбрано исходя из планируемой пиковой нагрузки и является "верхним пределом" для количества консьюмеров в группе.
Ретеншен (Retention): retention.ms
= 1209600000
(2 недели). Этот период был взят с большим запасом, прежде всего на время инцидентов. Если консьюмерная группа отстает из-за сбоя, у нее есть 14 дней, чтобы восстановиться и дочитать данные без потери.
Фактор репликации (Replication Factor): replication.factor = 3
. Каждое сообщение хранилось на всех трех брокерах кластера, что гарантировало сохранность данных при падении любого одного-двух брокеров (в зависимости от min.insync.replicas
).
Подтверждение записи (Acks): acks = all
. Это наиболее строгая настройка, гарантирующая, что лидер подтвердит запись только после того, как все синхронные реплики (in-sync replicas) получат сообщение. Это обеспечивает сохранность данных даже при падении лидера партиции.
Мониторинг лага (Consumer Lag): Лаг консьюмера более 1 часа считался инцидентом, требующим немедленной реакции. Для мониторинга использовались Prometheus + Grafana с дашбордами, отображающими отставание каждой консьюмерной группы по всем партициям, а также log alerting, который присылает на почту детализированную информацию
Детали реализации с примерами кода
final var recordDateTime = Instant.ofEpochMilli(kafkaRecord.timestamp());
final boolean oldRecord = recordDateTime.isBefore(Instant.now().minus(1, ChronoUnit.HOURS));
if (oldRecord) {
log.error("New kafka record queue is too big {}, lag time:{}, topic: {}, partition: {}, offset: {}",
LocalDateTime.ofInstant(recordDateTime, ZoneId.systemDefault()), timeDurationFrom(recordDateTime),
kafkaRecord.topic(), kafkaRecord.partition(), kafkaRecord.offset());
}
Так же были добавлены кастомные хелсчеки на кафку
@Service
@RequiredArgsConstructor
@Log4j2
@ConditionalOnBean(name = "ibSmeKafkaAdminClient")
public class IbSmeKafkaHealthIndicator extends AbstractHealthIndicator {
private final AdminClient ibSmeKafkaAdminClient;
@Override
protected void doHealthCheck(final Health.Builder builder) {
final DescribeTopicsResult describeTopicsResult = ibSmeKafkaAdminClient.describeTopics(
List.of(topic1, topic2, topic3));
builder.up()
.withDetail("topics", describeTopicsResult.allTopicNames())
.build();
}
}
Проблема "серых" исправлений и консистентности данных
Первоначальная выгрузка данных была проведена вручную, и система работала стабильно, пока не проявилась фундаментальная проблема: не все события по транзакциям отлавливались приложением на стороне АБС, некоторые шли в обход API.
Поскольку механизм генерации событий был заточен под изменения, инициированные через API АБС, эти прямые правки не фиксировались в логе и, следовательно, не публиковались в Kafka. Это привело к расхождению между состоянием АБС и данными в кэше ДБО. Так как выписки и прочие финансовые документы формируются на основе кэша, это создало критические риски.
Варианты устранения проблемы консистенции:
1. Ручная синхронизация через REST (Reactive Approach):
Суть: Оставить старый REST-эндпоинт и предоставить команде поддержки или продукт-менеджерам возможность вручную инициировать полную перевыгрузку данных для конкретного клиента через админку.
Минусы: Реактивная, а не проактивная модель. Мы узнаем о проблеме только от разгневанного клиента. Процесс ручной, трудоемкий и не масштабируемый. Не решает проблему скрытых, необнаруженных расхождений.
2. Полная периодическая инвалидация кэша (Scheduled Invalidation):
Суть: Регулярно (раз в день/неделю) помечать все данные устаревшими и инициировать фоновую массовую пересинхронизацию через тот же REST API.
Минусы: Создает чудовищную нагрузку на АБС, аналогичную изначальной проблеме REST-подхода. Фактически мы сами организуем себе DoS-атаку в нерабочее время. Кроме того, временные окна для таких операций могут быть крайне малы, а объем данных – слишком велик. Также возможны расхождения в данных, которые изменились во время длительной синхронизации.
3. Инвалидация кэша и загрузка через ETL в нерабочее время (Compensating Transaction Pattern):
Суть: Реализовать компенсирующую транзакцию - регулярный (ежедневный) ETL-процесс (Extract, Transform, Load), запускаемый в строго отведенное низконагрузочное окно (например, с 2:00 до 5:00 ночи). Этот процесс, автоматизированный с помощью Apache Airflow, выполняет следующие шаги:
1. Extract: Выполняет выгрузку "золотого снапшота" ключевых данных (например, текущих остатков на счетах, актуальных реквизитов договоров) напрямую из реплики БД АБС, минимизируя импакт на основную базу.
2. Transform: Преобразует данные в формат, пригодный для загрузки в кэш ДБО.
3. Load: Не инвалидирует весь кэш целиком, а производит точечное обновление записей в кэше ДБО на основе выгруженного снапшота.
Преимущество перед п.2: Кардинально снижает нагрузку на АБС, так как:
- Используется реплика БД, а не рабочий инстанс.
- Выполняется один сложный SELECT
для выгрузки снапшота, а не тысячи мелких REST-запросов.
- Процесс происходит в строго регламентированное время.
Недостаток: Система не является полностью реактивной. Консистентность данных гарантируется только на момент выполнения ETL-задачи ("на начало дня"). Расхождения, возникшие в течение дня из-за прямых правок в БД, будут устранены только следующей ночью. Это trade-off между сложностью реализации полноценного CDC и рисками расхождений.
Данный подход, несмотря на свою архаичность, часто является единственным быстрым и надежным способом побороть "серые" операции. Более элегантного решение мы пока не придумали. CDC тут к сожалению ложится плохо, так как триггеры именно в коде АБС, скидывать всю таблицу в кафку возможности нет как в части требований безопасности, так и из соображений экономии инфраструктуры.
Инцидент с "лавиной событий"

В завершение главы стоит рассказать о показательном инциденте, который наглядно демонстрирует важность правильной настройки потребителей Kafka. В один из рабочих дней операционист принял решение закрыть проводки, зависшие еще 10 лет назад. Казалось бы, рутинная операция. Однако она спровоцировала лавинообразный выброс событий в Kafka: закрытие одной проводки за 2015 год вызывало каскадный пересчет всей связанной ленты операций за десятилетний период.
Технически это выразилось в следующем:
- Размер отдельных (менее 1 процента) сообщений в топике достигал максимально разрешенного лимита в 1 МБ.
- В одно сообщение упаковывалось до 10 000 проводок.
Каждое такое сообщение инициировало более 5 различных внутренних интеграций:
- Обмен данными между микросервисами ДБО.
- Запись в Elasticsearch для полнотекстового поиска.
- Обновление кэшей клиентских приложений.
Первые признаки проблемы проявились в виде участившихся ребалансов потребительской группы. Оффсет практически не двигался, хотя потребители были активны. Причина оказалась в том, что обработка одного сообщения занимала больше времени, чем разрешено параметром max.poll.interval.ms
. Консьюмер исключался из группы, происходил ребаланс, и сообщение переходило к следующему потребителю – который также не успевал его обработать.
Наши попытки решить проблему включали:
1. Увеличение количества инстансов потребителей (запускали на двух ЦОДах до 6 под, на каждую поду по 2 консумера).
2. Эксперименты с настройками были разными, доводили цифры до абсурдных значений, но в итоге остановились на этом:
max.poll.interval.ms=900000 # 15 минут
session.timeout.ms=45000 # 45 секунд
heartbeat.interval.ms=15000 # 15 секунд
request.timeout.ms=30000 # 30 секунд
private static ConcurrentKafkaListenerContainerFactory<String, String> buildKafkaListenerContainerFactory(@NonNull final ConsumerFactory<String, String> kafkaConsumerFactory, @NonNull final KafkaProperties kafkaProperties) {
final var factory = new ConcurrentKafkaListenerContainerFactory<String, String>();
factory.setConsumerFactory(kafkaConsumerFactory);
factory.setBatchListener(false);
factory.getContainerProperties().setDeliveryAttemptHeader(true); // получаем данные о номере попытки обработки
factory.getContainerProperties().setObservationEnabled(true); // включаем трейсинг (traceId), чтобы мы могли найти проблему при межсервисном взаимодействии по одному идентификатору
// То есть, если сервис отправляет в кафку сообщение, то потребитель получает traceId и мы можем видеть всю обработку
factory.setCommonErrorHandler(buildErrorHandler(kafkaProperties.getMaxOnErrorAttempts())); // конфигурируем обработку ошибок на кафке, разрешаем сервису сделать ограниченной количество ретраев перед тем, как потерять сообщение
return factory;
}
private static DefaultErrorHandler buildErrorHandler(int maxOnErrorAttempts) {
final var fixedBackOff = new FixedBackOff();
fixedBackOff.setMaxAttempts(maxOnErrorAttempts == 0 ? 5 : maxOnErrorAttempts);
final var errorHandler = new DefaultErrorHandler((consumerRecord, e) ->
// logic to execute when all the retry attempts are exhausted
log.error("Last process attempt for kafka message {}", consumerRecord, e), fixedBackOff);
// после исчерпания количества попыток отправляется ак на сообщение, чтобы не зависала очередь, при этом информация о потерянном сообщении будет отправлена на почту для детального разбора
errorHandler.setCommitRecovered(true);
errorHandler.setAckAfterHandle(true);
return errorHandler;
}
Однако все эти попытки давали лишь временное улучшение. Кардинальное решение пришло только после анализа бизнес-логики: было принято решение игнорировать события старше 6 месяцев через внедрение фильтрации на уровне потребителя. Это позволило:
- Снизить нагрузку на все компоненты системы.
- Избежать ребалансов потребительской группы.
- Удерживать обработку сообщений в рамках допустимых таймаутов.
private void processUpdate(final KafkaMessage message) {
// дата операции
final var date = DateUtils.formatDateWithSlashSeparator(message.getDate());
// OldestHistoryDate = текущая дата - 6 месяцев
if (date.isBefore(getOldestHistoryDate())) {
log.info("Skip too old turnover with date {}", date);
return;
}
// обработка сообщения
}
Этот инцидент наглядно показал, что даже правильно спроектированная событийная архитектура требует глубокого понимания предметной области и тщательной настройки всех компонентов. Важно не только уметь обрабатывать сообщения, но и понимать, какие из них действительно несут бизнес-ценность в текущем контексте.
Глава 3: Событийная интеграция через MQ. Вызовы гарантированной доставки
Несмотря на успешное внедрение Kafka в ядре нашей архитектуры, оказалось, что этот инструмент применим не везде. Ограничения были не только техническими, но и, что часто встречается в крупных организациях, бюрократическими и историческими.
Ключевые системы, такие как CRM (управление взаимоотношениями с клиентами) и MDM (Master Data Management – управление мастер-данными), исторически использовали для интеграции REST API. Несмотря на наличие Kafka в стеке, политика информационной безопасности и устоявшиеся практики этих команд диктовали использование MQ либо REST в качестве канала для передачи данных. Все аргументы против синхронного REST-подхода, описанные ранее оставались в силе, но повлиять на выбор технологий партнерских команд не представилось возможным. Это привело к усложнению архитектуры.
Характер данных и требования к консистентности
Данные, поступающие из этих систем, являются критически важными для бизнеса и требуют максимального уровня консистентности:
Из CRM: Наиболее чувствительные данные - полномочия и права доступа клиентов (например, кто может подписывать платежные документы, кто имеет доступ к тем или иным счетам). Расхождение в этих данных между источником и нашим кэшем может привести к серьезным операционным рискам и финансовым потерям.
Из MDM: Актуальные персональные данные и реквизиты клиентов (как юридических, так и физических лиц). Неверные реквизиты при формировании платежных документов или неактуальный юридический адрес при выводе справок – прямые риски для репутации банка и качества обслуживания.
Таким образом, мы не могл�� позволить себе потерю или "проседание" даже одного сообщения из этих потоков.
Анализ возможных вариантов обеспечения консистентности:
1. Гибридный подход с фоновой синхронизацией (Временное решение):

Суть: Асинхронно читаем очередь MQ и обновляем кэш. При этом, при каждом входе клиента в ДБО, в фоновом режиме инициируется синхронный REST-вызов к системе-источнику для проверки актуальности ключевых данных (полномочий, реквизитов).
Недостатки: Фактически, мы не избавляемся от синхронной зависимости. Мы лишь маскируем ее, перенося на "момент входа". Падение CRM/MDM в этот момент приведет к задержкам или невозможности входа для клиента. Данные старше суток считаются неактуальными, что создает постоянный фоновый стресс для системы и клиента.
2. Использование встроенных механизмов MQ:

Суть: Использование механизма подтверждений (квитанций) о доставке сообщений на стороне MQ.
Недостатки: системы-источники имеют множество подписчиков. Обработка квитанций от всех потребителей создает значительную нагрузку и сложность логики на стороне отправителя. Это может легко привести к исчерпанию ресурсов и эффекту DoS. Кроме того, этот механизм не защищает от потери сообщения до его попадания в очередь (сбой на стороне отправителя) или от логических ошибок обработки на стороне потребителя.
3. Сквозная нумерация сообщений (Sequence ID):

Суть: Каждое сообщение, отправляемое источником, содержит порядковый номер (Sequence ID). Потребитель (наша система) ведет учет этих номеров и детектирует пропуски.
Преимущества: Простота реализации на стороне потребителя. Позволяет четко идентифицировать факт потери сообщения.
Недостатки: При обнаружении пропуска необходимо запросить повторную отправку именно этого сообщения. Это требует создания отдельного механизма рестарта потока (retransmission request) на стороне источника, что часто отсутствует в legacy-системах. Также не решает проблему пропуска сообщения на источнике.
4. Ежедневная полная синхронизация через ETL:

Суть: MQ используется для оперативных уведомлений об изменениях, но раз в сутки (ночью) запускается ETL-задача (например, на Apache Airflow), которая выгружает полный слепок актуальных данных из источника и полностью перезаписывает соответствующий сегмент кэша.
Преимущества: Обеспечивает высокий уровень консистентности "на начало дня". Снимает нагрузку с MQ и источника, так как не требует сложных механизмов повтора.
Недостатки: Данные в течение дня могут расходиться. Архитектурно это костыль. Требует поддержания двух параллельных механизмов обновления (MQ + ETL). Так как со стороны информационной безопасности вариант приемлем, он остается как запасной.
5. Комбинированная стратегия "Точечный ретрай + Валидация":

Осознавая недостатки всех предыдущих вариантов, мы разработали гибридный подход, который лег в основу нашей интеграции:
a) Idempotent Consumer с проверкой последовательностей:
Наш консьюмер MQ был спроектирован как идемпотентный. Он хранит в своем локальном хранилище максимальный Sequence ID, успешно обработанный для каждого клиента. При получении нового сообщения проверяется его номер.
- Если номер N+1
- данные применяются к кэшу, номер сохраняется.
- Если номер <= N
- сообщение игнорируется (дубликат).
- Если номер > N+1
- фиксируется пропуск.
b) Точечный запрос при обнаружении пропуска:
При детектировании пропуска (например, получили сообщение N+5
вместо N+1
), система не инициирует массовую перевыгрузку. Вместо этого она отправляет точечный REST-запрос к API источника (CRM/MDM) для получения актуального состояния той сущности, по которой обнаружен пропуск. Это может быть запрос по ID клиента или договора.
c) Фоновая валидация по расписанию:
Для подстраховки раз в несколько часов запускается фоновая задача, которая выборочно (для 1% рандомных клиентов) сверяет Sequence ID в нашем кэше с эталонным логом на стороне источника.
Почему этот подход оказался оптимальным:
- Минимальная нагрузка на источник: Точечные REST-запросы при пропуске одного сообщения создают на несколько порядков меньшую нагрузку, чем постоянные проверки или полные выгрузки.
- Высокая гарантия доставки: Мы не пропускаем пропуски и активно их исправляем.
- Отказ от синхронности: Основной трафик идет асинхронно через MQ. Синхронные вызовы происходят редко и только по необходимости, не блокируя работу системы в целом.
- Приемлемый уровень риска: Комбинация идемпотентности, детекции пропусков и фоновой валидации снижает риск расхождений до приемлемого минимума.
Это решение позволит нам соблюсти бюрократические требования к использованию MQ, обеспечив при этом тот уровень надежности и консистентности, который необходим для работы с критичными бизнес-данными.
Заключение
Было реализовано в нашем MVP, где скорость была важнее качества:

К чему стремимся, где на первый план выдвигается доступность без проблем в безопасности:

Наш путь от синхронной REST-интеграции к гибридной событийной модели позволил сформулировать несколько ключевых принципов, которые выходят далеко за рамки выбора между Kafka и MQ. Это скорее уроки о принятии сложности в обмен на надежность.
1. Сложность как плата за гибкость
Да, событийная модель сложнее прямых вызовов. Но эта сложность – осознанная плата за гибкость и масштабируемость. Мы получили систему, которую можно развивать, не опасаясь создать точку отказа.
2. Глубже топиков: консистентность требует усилий
Событийная модель – не просто брокер и топики. Для критичных потоков данных приходится вводить дополнительные механизмы и усложнять архитектуру для соблюдения консистетности.
3. Отказоустойчивость - главный приоритет
Все это служит одной цели – сделать систему по настоящему отказоустойчивой. Мы сознательно выбрали статус AP-системы, где доступность клиента важнее сиюминутной консистентности всех данных. Клиент всегда может работать с сервисом, пока фоном идет синхронизация.
Интеграция систем в большом банке похожа на реконструкцию самолета во время полета. Вы не можете просто остановить все процессы и переписать их с нуля. Событийная архитектура в этом смысле становится системой автоматического пилотирования – она позволяет постепенно, эволюционно отключать прямые синхронные связи, заменяя их гибкими, асинхронными потоками данных.
Это не просто смена технологий – это смена парадигмы мышления: от создания статичных, хрупких связей к построению живой, пульсирующей сети данных, которая способна адаптироваться к сбоям, выдерживать нагрузку и расти вместе с бизнесом. Начните с анализа ваших самых болезненных синхронных интеграций – и вы найдете точку входа для внедрения событийного подхода. Мы построили не просто новую систему – мы заложили основу для цифровой экосистемы банка будущего, где данные становятся стратегическим активом, а не побочным продуктом работы разрозненных систем.
Комментарии (6)
Fardeadok
04.09.2025 17:57Абс отвечала медленно и вы добавили кеш. Получили проблему с рассогласованием. Начали ее решать по всякому. Кафка тут работает переносчиком проблем. Поскольку из текста неизвестно какие по скорости операции входят в ДБО то непонятно какие уровни согласованности требуются и непонятно что использовать для решения. В следующий раз нарисуйте схему потоков со временем и все
michael_v89
04.09.2025 17:57я пытался навести его на мысль о событийной модели интеграции, но натолкнулся на контраргументы: такая модель потребует избыточной инфраструктуры для поддержания кэша, неизбежно породит проблемы с консистентностью данных
Проблема "серых" исправлений и консистентности данных
Инцидент с "лавиной событий"
Осознавая недостатки всех предыдущих вариантов, мы разработали гибридный подход, который лег в основу нашей интеграции:Ну то есть он был прав?
А как быть с доступностью? Если АБС "легло" на плановое обслуживание или из-за инцидента, как мы будем предоставлять сервис клиентам?
Как быть с доступностью, если ДБО "легло" на плановое обслуживание или из-за инцидента?
osvirt
04.09.2025 17:57Всегда интересно читать людей, которые знают много умных слов и технологий и уверенно применяют это, не задав пару вопросов при составлении ИЗ. Например: а наши клиенты точно хотят быстро поднять неактуальную информацию или готовы увидеть сообщение в духе "чтобы не обманывать ваши ожидания о состоянии лицевого счёта, мы вынуждены немного дольше обрабатывать запрос из-за проблем с (придумайте сами)"?
*пишу, как пользователь банковских приложений и по совместительству "немного" разработчик
EgorovDenis
04.09.2025 17:57Что мешало решить (читать как сильно уменьшить) проблему надёжной доставки сообщений через паттерны Outbox/Inbox?
funca
Судя по сюжету, проектировщики системы больше следовали некоемому итеративному стохастическому процессу, нежели определенной методологии. Вероятно они столкнулись с чем-то для себя новым, неизвестным. Возможно, что в команде просто не было нормальных инженеров, одни лишь программисты да руководители.
Вы же понимаете, что сама постановка вопроса о замене REST на ивенты или кафку является бессмыслицей - это термины из разных областей.
Актуальность это бизнесовое свойство информации с точки зрения конкретного потребителя, REST - архитектурный стиль взаимодействия в архитектуре клиент-сервер, сообщения - элемент целого семейства паттернов интеграци, ивенты - логический тип сообщений, а кафка - просто софт. Гарантии доставки сообщений это ещё одно свойство. Ни какой софт или элемент напрямую не решает ни проблем бизнеса, ни архитектуры, ни данных.
В описанном случае, скорее - плата за пробелы в образовании команды и просчёты, допущенные ими при построении системы, а так же барак, который они в итоге развели. Вместо того, чтобы заняться анализом, проектированием и расчётами, ребята собрали какие-то новые для себя кубики и стали в них увлечённо играть.
svaur Автор
Благодарю за комментарий. Технологическая поправка, где вы указываете, что REST - это архитектурный стиль, абсолютно корректна. В статье мы сознательно использовали упрощение для лучшей читаемости, чтобы не перегружать и без того сложный материал формулировками вроде «синхронное взаимодействие, реализованное через REST API».
Вы затронули важный вопрос о методологии. В идеальном мире мы бы, конечно, начали с глубокого анализа и безупречного проектирования. Но банковская среда - это мир жёстких ограничений: legacy-системы, требования регуляторов, информационная безопасность, ограниченные сроки и бюджеты. В нашем случае идеальное решение было недостижимо. Посмотрите предыдущую статью, ссылка в начале.
Мы действовали итеративно в рамках Agile: начали с того, что было уже в банке, собрали на синхронных интеграциях MVP решение, а уже дальше, постепенно с миграцией клиентов эволюционировали к событийной архитектуре.
Прочитав ваш комментарий вспомнилась история о двух инженерах (из книги «Мифический Человеко-месяц», если не ошибаюсь) - идеалисте и прагматике. Идеалист 2 года пишет идеальное ядро, а прагматик выливает «полурабочее» решение на рынок, быстро набирает клиентов и в итоге нанимает идеалиста к себе на зарплату. Идеалистом быть в нашем неидеальном мире очень тяжело)