Лента операций под нагрузкой: архитектура, которая работает

В ОТП Банке при разработке сервисов мы в первую очередь ориентируемся на удобство клиента. Одним из внедрённых решений стал ElasticSearch, который помог ускорить поиск и повысить точность выдачи.

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


Как появилась новая лента операций

Команда ОТП Банка работала над обновлённой лентой операций, которая должна была отвечать сразу нескольким требованиям. Решение должно было оставаться быстрым даже при больших объёмах данных, поддерживать гибкий и точный поиск, а также легко масштабироваться в зависимости от глубины истории транзакций, от нескольких дней до нескольких лет.

В процессе обсуждения рассматривались разные варианты. В том числе классическая реляционная база с кешем и ClickHouse. Первый подход был устойчивым и хорошо справлялся с данными за короткий период, но при попытке работать с глубокой историей возникали ограничения по памяти. Поддерживать кеш на многолетний объём информации оказалось слишком ресурсоёмко, особенно при масштабировании. Использование распределённого кеша вроде Redis усложняло бы архитектуру, а механизмы sticky sessions противоречили нашим планам по отказу от такой логики.

Реляционная база с кешем

ClickHouse на этапе оценки показал хорошие результаты для аналитики, но для нашей системы в реальном времени он не подошёл. Сложность поддержки, высокая чувствительность к настройке, а также отсутствие полноценной поддержки точечных обновлений стали критичными факторами. Поскольку клиентские операции могут часто меняться, в том числе в течение одного дня, замена целой записи вместо простого апдейта снижала бы производительность и со временем привела бы к фрагментации.

ClickHouse

Почему мы выбрали Elasticsearch и с чем столкнулись

Для обновлённой ленты операций команда ОТП остановилась на Elasticsearch — это решение показалось наиболее сбалансированным. Оно хорошо работает с большими объёмами разнородных данных, поддерживает гибкий поиск, включая морфологию и нечеткие запросы, масштабируется за счёт шардирования и репликации и позволяет хранить данные в виде JSON-документов. Всё это делает его удобным инструментом, когда речь идёт о миллиардах записей и запросах, чувствительных к скорости.

Elasticsearch

Elasticsearch полностью отвечал нашим требованиям — производительность, гибкий поиск, масштабируемость. Но по мере масштабирования стало очевидно, что система не лишена уязвимых мест.

Проблема с количеством индексов и шардов

Мы начали с простой схемы. Для каждой организации создавался отдельный индекс. При небольшом количестве клиентов всё работало стабильно и быстро. Но по мере масштабирования система начала терять производительность. Время отклика выросло до 200 миллисекунд, а для нашей системы это было уже выше допустимого.

Причина была в структуре Elasticsearch. Индексы делятся на шарды, а для одного узла не рекомендуется использовать больше 1000 шардов. При превышении этого значения система становится нестабильной и предсказуемость снижается. Даже при увеличении лимита стабильной работы никто не гарантирует. Чтобы понять, сколько индексов мы можем использовать без потери производительности, мы рассчитали:

  • Один индекс содержит 8 шардов для масштабирования и 4 реплики для отказоустойчивости. Всего 32 шарда на индекс;
  • В кластере на 4 узлах общий лимит составляет примерно 4000 шардов;
  • Мы используем только треть лимита, оставляя запас на миграции и техническое обслуживание. Это около 1300 шардов;
  • Делим 1300 на 32 и получаем максимум 40 индексов.

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

Какое решение сработало

Мы распределили все организации по фиксированному числу индексов. Такой подход позволил контролировать общее количество индексов, сохранить предсказуемость системы и упростить масштабирование.

Какие варианты мы рассматривали и почему отказались

  • Объединение небольших клиентов в один индекс.Этот подход оказался ненадежным. Клиент с небольшой нагрузкой может быстро вырасти, и в одном индексе появится перегрузка;
  • Разделение по времени. Хранение данных по месяцам сделало бы архитектуру сложной. Поиск по истории требовал бы многократных обращений к разным индексам, что ухудшило бы скорость и стабильность;
  • Разделение по регионам. Деление оказалось неравномерным. Такая схема не решала проблему распределения нагрузки и не улучшала масштабируемость.

Проблема параллельных обновлений

В нашей системе клиентские операции поступают из разных источников. Это может быть обращение в офис, новое и старое ДБО (дистанционное банковское обслуживание) или внутренние операции. После создания событие проходит через цепочку сервисов, пока не попадает в основную банковскую систему.

Попытка обновлять документы напрямую привела к потере данных. Проблема в том, что Elasticsearch не обеспечивает строгой консистентности (immediate consistency) на уровне отдельных документов. Даже если использовать withRefreshPolicy(RefreshPolicy.IMMEDIATE) для принудительной индексации, это не защищает от race condition — ситуации, когда параллельные запросы приходят одновременно и работают с устаревшими версиями.

Кроме того, в Elasticsearch нет блокировки на уровне строки (row-level locking) в рамках транзакции. Это значит, что конкурентные операции update могут перезаписать друг друга, затирая последние изменения. В результате данные становились неполными или некорректными, а обычные подходы к синхронизации в нашем случае оказались неприменимы. Тогда команда ОТП Банка начала с самого очевидного решения, доступного в рамках самого Elasticsearch.

Первый этап — оптимистическая блокировка (Optimistic Concurrency Control)

Мы попробовали использовать внутренний механизм контроля версий документа (_version). Но в условиях высокой нагрузки это дало обратный эффект. Пока обновлялась версия, приходили новые события, из-за чего появлялись ошибки конфликта, и значительная часть обновлений просто не применялась.

Второй этап — сервис-агрегатор и Redis

Следующим этапом стало внедрение централизованного механизма синхронизации за пределами Elasticsearch. Для этого команда OTP разработала отдельный сервис-агрегатор, который принимал события из всех источников, приводил их к единому формату, расставлял приоритеты обработки и вводил искусственные задержки, чтобы выровнять нагрузку. Такой подход позволил преобразовать разнородные события в последовательный поток с единым идентификатором.

Для координации параллельных обращений к одной операции и при этом сохранения stateless-архитектуры агрегатора команда использовала Redis как распределенный кэш и легковесный координатор. В нем по каждому идентификатору операции сохранялась временная метка последнего события. Это решение эффективно справлялось с параллельными обновлениями и стало временным, но рабочим способом стабилизировать систему.

Третий этап — Kafka и единый формат

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

  • Ключи сообщений: Теперь каждое сообщение в Kafka содержит идентификатор операции как ключ. Это гарантирует, что все события с одним и тем же ключом будут обрабатываться в правильном порядке внутри одной партиции.
  • Унифицированный формат: Мы договорились об одном стандарте для всех событий. Независимо от источника, структура сообщения стала единой, что упростило обработку.
  • Один топик вместо многих:Вместо отдельных каналов для каждого сервиса мы создали общий топик для всех событий. Это позволило избавиться от форматов, заданных отдельно для каждого источника.

В результате нам удалось существенно упростить и укрепить всю цепочку обработки. Kafka обеспечила гарантированный порядок событий, а единый формат позволил сократить количество преобразований и ошибок. Отказ от Redis и промежуточного сервиса снизил нагрузку на инфраструктуру и упростил сопровождение. Архитектура стала чище, стабильнее и легче в поддержке.

Минимальная архитектура для стабильной работы

В завершение команда ОТП Банка зафиксировала минимальную конфигурацию Elasticsearch, оптимальную для инфраструктуры с двумя основными ЦОДами, где развернуты кластеры Kubernetes и микросервисы, а также одним дополнительным ЦОДом, предназначенным исключительно для обеспечения кворума и обладающим ограниченными вычислительными ресурсами.

Схематичное изображение финальной архитектуры

Каждая нода в кластере выполняет строго определённую роль. Мастер-ноды управляют метаданными, формируют кворум и предотвращают split-brain-сценарии. Координирующие ноды принимают запросы от микросервисов, распределяют нагрузку и собирают результаты. Data-ноды хранят данные и выполняют операции поиска и агрегации.

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

Такой подход позволил команде выстроить отказоустойчивую систему, которая отвечает требованиям к высокой доступности и производительности. При необходимости эта конфигурация легко масштабируется, а ее устойчивость к сбоям подтверждена в продакшене.