Лента операций под нагрузкой: архитектура на elasticsearch, kafka и redis

Лента операций под нагрузкой: архитектура, которая действительно выдерживает масштаб
------------------------------------------------------------------

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

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

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

Обновлённая лента операций должна была удовлетворять сразу нескольким жёстким условиям:

- выдерживать большие объёмы данных, вплоть до многолетней истории по миллионам клиентов;
- оставаться быстрой: приемлемое время ответа — десятки миллисекунд, а не секунды;
- поддерживать гибкий поиск: по тексту назначения, сумме, типу операции, контрагенту, комментариям, в том числе с морфологией и неточными запросами;
- легко масштабироваться как по глубине истории (от пары дней до нескольких лет), так и по числу клиентов и запросов;
- устойчиво работать в условиях постоянных догрузок и корректировок операций в течение дня.

При этом архитектура не должна превращаться в громоздкого «монстра», который сложно поддерживать, настраивать и разворачивать в новых средах.

Почему классическая реляционная база с кешем не подошла

На этапе выбора технологий рассматривался наиболее привычный вариант: реляционная СУБД (например, PostgreSQL) в связке с кешем.

Плюсы были очевидны:

- понятная и проверенная временем технология;
- хорошая производительность на коротком отрезке истории;
- зрелые механизмы транзакционности и целостности данных.

Но в реальности этот подход упёрся в серьёзные ограничения:

- данные за несколько лет по активным клиентам занимают значительный объём;
- держать в кеше многолетнюю историю слишком дорого по памяти;
- сложные фильтры и полнотекстовый поиск по объёмным таблицам начинают «тормозить», если не городить агрессивный и достаточно сложный шардированный кеш;
- распределённый кеш (например, Redis-кластер) усложнял бы архитектуру и сопровождение;
- понадобились бы механизмы sticky sessions, а от такой привязки к конкретному узлу было решено отказаться.

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

Почему отказались от ClickHouse

Второй кандидат — ClickHouse. На тестах он показал отличные результаты для аналитических запросов: агрегации, отчёты, выборки по большим объёмам данных выполнялись очень быстро. Но для ленты операций в режиме реального времени обнаружились критические минусы:

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

Для клиентской ленты это недопустимо: операции могут меняться в течение дня (коррекции, уточнения, смена статуса), и массовая перезапись записей под высокой нагрузкой грозила фрагментацией и деградацией скорости.

В результате ClickHouse остался в арсенале как мощный аналитический инструмент, но не как основа для онлайн-ленты.

Почему выбор пал на Elasticsearch

Для новой ленты операций команда остановилась на Elasticsearch. На этапе оценки он удовлетворял всем ключевым требованиям:

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

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

Первая версия архитектуры: индекс на каждую организацию

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

- запросы обрабатывались за десятки миллисекунд;
- операции по конкретному клиенту были изолированы в своём индексе;
- масштабирование выглядело линейным — добавился новый клиент, появился новый индекс.

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

Ограничения по количеству индексов и шардов

Каждый индекс в Elasticsearch делится на шарды. Шарды распределяются по узлам кластера. На практике для одного узла не рекомендуется превышать порог в районе 1000 шардов. После этого:

- возрастает нагрузка на координирующие узлы;
- существенно растут накладные расходы на обслуживание шардов;
- система теряет предсказуемость: время ответов начинает «гулять», периодически возникают пики задержек.

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

Модель «индекс на клиента» очень быстро приводила к росту числа шардов до критических значений. Стало ясно: такая схема не годится для долгосрочной перспективы.

Переход к фиксированному числу индексов

Следующим шагом стал отказ от индивидуального индекса на каждую организацию. Вместо этого:

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

Такая схема дала несколько важных преимуществ:

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

Однако вместе с решением одной проблемы проявилась другая — управление обновлениями документов под высокой конкурирующей нагрузкой.

Источники операций и сложность потока данных

Клиентские операции в банковской системе могут рождаться в самых разных сценариях:

- посещение офиса и работа с операционистом;
- старые и новые системы дистанционного банковского обслуживания;
- внутренние регламентные и межсистемные операции;
- автоматические списания, начисления, переносы.

Каждое такое событие проходит через цепочку сервисов, обогащается данными, может изменять свой статус и атрибуты. В итоге оно должно отразиться в основной банковской системе и в ленте операций в ДБО.

В этой схеме неизбежно возникают ситуации, когда:

- одна и та же операция может быть обновлена несколькими источниками почти одновременно;
- информация поступает частями, асинхронно, из разных систем;
- порядок прихода событий не всегда соответствует их «логическому» порядку.

Наивная попытка «просто обновлять» документ в Elasticsearch при каждом изменении приводила к гонкам, потерям данных и неконсистентному состоянию.

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

Классический пример:

1. Операция создаётся в статусе «В обработке».
2. Через некоторое время приходит уточнение суммы.
3. Затем подтягивается детальная информация о контрагенте.
4. Чуть позже — финальный статус «Проведена».

Если эти изменения обрабатываются разными сервисами параллельно, а записи в Elasticsearch перезаписываются без контроля версий, возможна ситуация:

- сервис со статусом «Проведена» пишет обновлённый документ;
- вслед за ним сервис с уточнением контрагента записывает более старую версию статуса, но с новыми данными по контрагенту;
- в результате клиент видит операцию в промежуточном статусе, хотя в реальности она уже завершена.

Нужно было решение, которое:

- не даёт перезаписывать новые данные старыми;
- при этом не блокирует весь поток запросов и не превращается в «бутылочное горлышко».

Этап 1. Оптимистическая блокировка (Optimistic Concurrency Control)

Первым шагом стала реализация оптимистического контроля версий на уровне Elasticsearch. Его суть:

- каждый документ в индексе имеет версию;
- при обновлении указывается ожидаемая версия;
- если кто-то уже обновил документ и версия изменилась, операция отклоняется.

Такой подход позволил:

- избежать перезаписи новых данных старыми;
- защититься от части гонок при параллельных апдейтах;
- сохранить высокую степень параллелизма — не нужно держать тяжёлые блокировки.

Но оптимистический контроль не решал всё:

- оставались конфликты, которые приходилось разруливать на уровне приложений;
- при высоком уровне конкуренции число конфликтов и повторных попыток обновления росло;
- логика «кто правее» при конфликте версий оказывалась размазанной по сервисам.

Стало понятно, что нужен более централизованный и упорядоченный подход.

Этап 2. Сервис-агрегатор и Redis

Следующим шагом стало введение отдельного сервис-агрегатора, который взял на себя роль «единой точки правды» по обновлениям операций.

Архитектурно это выглядело так:

- все события об изменениях операций перестали писать напрямую в Elasticsearch;
- они сначала попадали в сервис-агрегатор;
- агрегатор собирал все изменения по конкретной операции;
- промежуточное состояние операции хранилось в Redis как в быстром in-memory хранилище;
- только после консолидации данных агрегатор формировал итоговое представление операции и отправлял его в Elasticsearch.

Это дало несколько выгод:

- обновления по одной операции стекались в одном месте, и порядок их применения стал контролируемым;
- уменьшилось количество конфликтов версий в индексе;
- Redis позволял быстро хранить и обновлять состояние без тяжёлых транзакций в основной БД.

Однако появилась и другая сторона медали:

- архитектура усложнилась — появился дополнительный критичный сервис и ещё одно хранилище;
- выросли требования к отказоустойчивости Redis и агрегатора;
- пришлось внедрять собственную логику «схлопывания» событий (event collapsing), чтобы не перегружать Elasticsearch мелкими апдейтами.

На определённом этапе стало ясно, что пора выстраивать весь поток событий более системно.

Этап 3. Kafka и единый формат событий

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

Ключевые решения:

- все изменения операций начали описываться в едином формате события;
- источники не пишут напрямую в индекс, а публикуют события в Kafka;
- сервис-агрегатор стал консьюмером соответствующих топиков Kafka;
- порядок обработки событий по одной операции стал определяться ключом партиционирования (например, уникальный идентификатор операции);
- агрегатор восстанавливает и поддерживает актуальное состояние операции по потоку событий, затем пишет в Elasticsearch уже «собранный» документ.

Преимущества такого подхода:

- строго упорядоченный поток событий по каждой операции — упрощается логика консолидации;
- возможность повторного проигрывания событий при изменении логики обработки или восстановлении после сбоев;
- гибкая масштабируемость за счёт разделения по топикам и партициям;
- возможность подключать новые сервисы, которые тоже читают события и строят свои проекции (например, для аналитики или мониторинга).

В этой конфигурации Elasticsearch стал, по сути, проекцией состояния ленты — быстрым хранилищем для поиска и отображения, а не единственным источником правды.

Минимальная архитектура, которая показала стабильность

В результате последовательных итераций была выстроена архитектура, которую можно назвать минимально необходимой для устойчивой работы ленты под высокой нагрузкой:

1. Единый формат событий об операциях
Все системы-источники «говорят» на одном языке. Это уменьшает количество конвертаций и недопониманий между сервисами.

2. Kafka как шина событий
Обеспечивает:

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

3. Сервис-агрегатор
Отвечает за:

- консолидацию событий по операции;
- применение бизнес-правил;
- формирование итогового документа операции.

4. Redis как оперативное хранилище состояния
Используется для:

- хранения промежуточного состояния операций;
- быстрых апдейтов без тяжёлых транзакций в БД.

5. Elasticsearch как проекция для поиска и отображения
Хранит:

- итоговое состояние операции в виде JSON-документа;
- оптимизированную структуру для поиска и фильтрации по нескольким параметрам.

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

Что оказалось критично для производительности на практике

За время эксплуатации обновлённой архитектуры стало понятно, какие практики действительно влияют на скорость и стабильность:

- Контроль числа шардов и индексов. Крупные, но не гигантские индексы с разумным числом шардов работают значительно лучше множества мелких индексов.
- Продуманная схема полей. Сокращение избыточных полей, аккуратная работа с nested-структурами и keyword-полями сильно влияет на размер индекса и скорость запросов.
- Ограничение глубины истории «по умолчанию». По умолчанию лента может показывать, например, последние 3–6 месяцев, а более старые данные — только по запросу. Это снижает нагрузку на поиск.
- Асинхронные обновления. Не все изменения нужно немедленно «проталкивать» в индекс; часть можно агрегировать и отправлять пакетами.
- Набор преднастроенных фильтров. Чаще всего пользователи ищут типовые вещи (последние переводы, платежи, списания по карте). Оптимизация под эти кейсы даёт заметный выигрыш.

Типичные ошибки, которых удалось избежать (или исправить по ходу)

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

2. Отсутствие централизованного агрегационного слоя.
Обновлять документы из множества сервисов напрямую — прямой путь к гонкам и неконсистентности.

3. Попытка использовать аналитическую СУБД как онлайн-хранилище.
Инструменты вроде ClickHouse отлично подходят для отчётов, но плохо переносят постоянные точечные апдейты в режиме реального времени.

4. Преждевременная оптимизация без метрик.
Локальные «улучшения» без замеров иногда ухудшают ситуацию. Наблюдаемость и метрики по каждому узлу и индексу — обязательное условие.

Что это дало клиенту

С точки зрения пользователя все эти архитектурные решения превращаются в простые и понятные эффекты:

- лента операций открывается быстро, даже если в истории — несколько лет активных транзакций;
- поиск по назначению платежа, контрагенту или сумме работает почти мгновенно;
- статусы операций обновляются корректно, без странных «откатов» и исчезающих платежей;
- система не «сыплется» при пиковых нагрузках — например, в дни зарплат, акций или массовых платежей.

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

Итог

Выбор Elasticsearch стал не финальной точкой, а отправной. Ключ к стабильной работе ленты операций под нагрузкой — не только в конкретной технологии поиска, но и в:

- правильном управлении индексами и шардами;
- событийной архитектуре с Kafka;
- наличии сервиса-агрегатора и оперативного хранилища состояния;
- жёстком контроле параллельных обновлений и версий документов.

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

3
2
Прокрутить вверх