Оглавление

БигДата всегда звучит красиво — пока не нужно по ним искать и за нее платить.

Когда в системе уже не сотни тысяч, а десятки миллионов записей, каждый запрос превращается в штурм базы


Скорость — это не удобство, а свойство архитектуры.

Если система отвечает медленно, значит, архитектура построена неправильно.


Реляционная СУБД - это ядро консистентности, но не оптимальный инструмент для полнотекстового и фасетного поиска. Логично разграничить роли: Postgres отвечает за транзакции и связи, а специализированный поисковый слой - за быстрый отклик и фильтрацию по множеству полей.

Поиск — это отдельная задача, и ей нужен отдельный слой.

Мы вынесли её наружу — туда, где всё строится вокруг скорости отклика.

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

7 терабайт перестали быть проблемой: система масштабируется, а пользователи получают ответ мгновенно.

Мы сделали это не ради моды, а ради стабильности.

Архитектура должна быть честной: каждый слой делает то, для чего создан.

OpenSearch стал не надстройкой, а естественной частью системы.


1. Зачем вообще нужен этот слой

Реляционные СУБД не рассчитаны на интенсивный поиск по множеству полей и связей, особенно если в них присутствует геометрия.

Даже в PostGIS при сложных пространственных вычислениях и агрегациях возможны задержки, особенно если отсутствуют специализированные индексы (GiST/BRIN), за них надо платить, а от сюда следует что целесообразней было бы вынести часть поиска

OpenSearch решает эту задачу, превращая данные в индексированные документы, распределённые по шардам.

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

За счёт этого поиск превращается из линейного в распределённый, а отклик из секунд — в миллисекунды.


1.1. Почему не сработал векторный поиск

До OpenSearch мы долго использовали векторный поиск прямо в PostgreSQL через pgvector и собственную схему генерации эмбеддингов.

Выглядело это современно, но реальность оказалась жёстче.

Что пошло не так:

  1. Объём.

    7 ТБ данных - это миллионы объектов. Построение и хранение векторов оказалось дороже, чем сами данные.

    Каждое обновление требовало пересчёта эмбеддингов.

  2. Тип данных.

    Наши объекты — не тексты, а пространственные сущности и атрибуты.

    Векторизация таких структур даёт ложные корреляции.

  3. Недетерминизм.

    Результаты поиска “ближайшего соседа” могли отличаться при одинаковом запросе.

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

  4. Нагрузка.

    При десятках миллионов строк индекс pgvector перестал быть realtime-решением.

    Приходилось обновлять вручную и делать ребилды.

  5. Нет объяснимости.

    Нельзя понять, почему система выбрала именно этот результат.

    Он может быть “похож математически”, но не по смыслу.

После этого мы постепенно перешли на OpenSearch: не вероятностный, а детерминированный поиск.

Там, где векторы давали “похоже”, OpenSearch возвращает “точно”.

Скорость осталась, стабильность появилась.

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

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

Но практика показала: проще и надёжнее решать задачу индексами, а не вероятностями.


2. Как обеспечивается ACID-поведение

При переходе на внешнюю поисковую систему возник главный вопрос — как сохранить согласованность данных между транзакциями в Postgres и индексами в OpenSearch.

Базовое решение — триггер синхронизации, который срабатывает при изменении объектов в базе.

Он получает идентификатор изменённой записи, формирует JSON‑представление с актуальными атрибутами и передаёт его в OpenSearch API для индексации, обновления или удаления.

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

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

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

Postgres публикует изменения в очередь (через лог WAL), а отдельный потребитель обрабатывает их и обновляет индексы в OpenSearch.

Это разрывает жёсткую связанность между базой и поиском, сохраняет порядок событий и гарантирует доставку, даже если один из компонентов временно недоступен.

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


2.1. Эволюция синхронизации: от триггеров к CDC

Ранее синхронизация с поисковым индексом реализовывалась напрямую через триггерную функцию в Postgres.

Триггер срабатывал на каждое изменение в таблице объектов, формировал JSON-документ через PL/pgSQL-функцию и синхронно отправлял его в OpenSearch по HTTP-запросу.

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

{
  "id": 12345,
  "user_field1": "Smith",
  "user_field2": "John",
  "numeric_field": "75",
  "category_field": "category A",
  "timestamp_field1": "1940-05-15",
  "text_field1": "London",
  "identifier_field": "UK-123456",
  "spatial_data": {
    "area_name": "Object A",
    "primary_geometry": {
      "type": "Polygon",
      "coordinates": [[
        [-0.1276, 51.5073],
        [-0.1274, 51.5073],
        [-0.1274, 51.5075],
        [-0.1276, 51.5075],
        [-0.1276, 51.5073]
      ]]
    }
  }
}

Что пошло не так:

Схема оказалась хрупкой:

  • база и поиск были жёстко связаны;

  • при недоступности OpenSearch транзакции в Postgres могли не выполняться;

  • HTTP-вызовы внутри триггера добавляли задержки и создавали лишнюю нагрузку при массовых изменениях.

Решение — переход на CDC (Change Data Capture).

Теперь вместо прямых вызовов используется логическая репликация:

изменения данных фиксируются в WAL, CDC-процесс читает журнал, публикует события в Kafka, а отдельный потребитель обновляет индекс в OpenSearch.

Что это дало:

  • асинхронность: поиск больше не блокирует транзакции;

  • гарантированная доставка событий даже при сбоях;

  • масштабируемость без нагрузки на основную базу;

  • отказоустойчивость за счёт накопления событий в очереди.


3. Архитектура под капотом

PostGIS отвечает за данные и связи.

Триггер обеспечивает транзакционную синхронизацию.

API служит прослойкой безопасности и формата.

OpenSearch обеспечивает мгновенный поиск.


4. Почему это работает

Индексация асинхронна.

События доставляются гарантированно через очередь.

Геоиндексы (geo_point, geo_shape)

обеспечивают быстрый отбор и фильтрацию по координатам.

Репликация

обеспечивает отказоустойчивость и равномерное распределение нагрузки.

Интервал refresh

настраивается под SLA: обычно от 5 до 30 секунд — в зависимости от частоты изменений и требований к актуальности данных.


5. Результаты

Среднее время отклика поискового запроса (по индексу) 25–40 мс при кластере из N узлов. Это время именно поиска, не включая операции синхронизации и обновления индекса.(Время зависит от того как организованны ваши данные)

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


6. Как это сделать

Практическая реализация

занимает несколько часов, если следовать логике трёх шагов.

6.1 Развёртывание OpenSearch

Устанавливаем Java и системные зависимости, создаём отдельного пользователя opensearch.

Далее скачиваем дистрибутив нужной версии с официального сайта OpenSearch и разворачиваем его в каталоге /opt/opensearch.

После распаковки:

  • даём права пользователю opensearch;

  • создаём systemd-сервис;

  • настраиваем автозапуск и логи в /var/log/opensearch;

  • проверяем работу командой

curl -X GET https://localhost:9200 -u 'admin:admin' --insecure

На этом этапе OpenSearch готов принимать запросы и создавать индексы.


6.2 Настройка OpenSearch API

API - это тонкая прослойка между внутренними сервисами и самим OpenSearch.

Она нужна для безопасности, нормализации форматов и разгрузки кластера.

Развёртывание простое:

  • создаём каталог /opt/opensearch-api;

  • распаковываем дистрибутив;

  • добавляем systemd-сервис, который запускает node index.js;

  • включаем автозапуск и проверяем статус.

Через этот слой проходят все операции добавления, обновления и удаления документов в индексе.


6.3 Перенос и синхронизация данных

После установки кластера и API нужно подключить OpenSearch к существующим данным.

Для этого:

  1. Проверяем, что Nginx проксирует запросы к opensearch и opensearch-api.

  2. Убеждаемся, что индекс создан:

PUT /vash
{
  "settings": {
    "index": {
      "number_of_shards": 2,
      "number_of_replicas": 2
    }
  },
  "mappings": {}
}
  1. Для каждой записи базы вызываем внутреннюю функцию, возвращающую объект в формате JSON.

    Полученный JSON отправляется через API в OpenSearch командой:

PUT /vash/_doc/:id
{
  ...данные объекта...
}
  1. После этого документ становится доступен в поиске.

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

Он формирует JSON-объект и синхронно передаёт его в OpenSearch API.

Таким образом сохраняется транзакционная целостность: если запись изменилась в БД, она тут же обновляется и в индексе.


6.4 Проверка

После запуска всех сервисов можно проверить:

systemctl status opensearch
systemctl status opensearch-api

и выполнить тестовый запрос:

curl http://<адрес>/opensearchvash

Если возвращается информация об индексе - связка работает.


7. Вывод

OpenSearch показал себя не как модная альтернатива, а как инструмент инженерной зрелости.

Векторный поиск был красивым экспериментом, но не выдержал реальности больших систем.

OpenSearch стал надёжным компонентом, ускоряющим поиск и фильтрацию при больших объёмах данных. Он не заменяет СУБД и не обеспечивает транзакционную целостность, но позволяет масштабировать доступ к данным без потери согласованности при грамотной событийной интеграции.

Главный принцип:

База хранит истину. OpenSearch отвечает за скорость. Триггер следит за честностью.

Так система не просто ищет быстро - она ищет правильно.


Благодарность

Отдельная благодарность @Follooower за участие в проекте и вклад в построение архитектуры и реализацию решения.

Комментарии (0)