Базы данных Временные ряды и поиск: TimescaleDB, InfluxDB, Elasticsearch, OpenSearch
0%

Временные ряды и поиск: TimescaleDB, InfluxDB, Elasticsearch, OpenSearch

Временные ряды и поиск: TimescaleDB, InfluxDB, Elasticsearch, OpenSearch

Есть соблазн считать, что временные ряды и полнотекстовый поиск — две несвязанные темы, случайно оказавшиеся в одной статье. Это не так, и общее у них важнее различий.

И то и другое — производные хранилища (derived data systems, в терминологии Клеппмана). Они почти никогда не бывают источником истины. Метрики и логи порождаются приложением, которое живёт на PostgreSQL; поисковый индекс товаров — это проекция каталога, лежащего в той же PostgreSQL. Отсюда следует общее свойство, ради которого стоит держать эти две темы вместе: производное хранилище можно потерять целиком и построить заново. Это меняет всё — требования к долговечности, к консистентности, к бэкапам, к цене одного узла. Вы никогда не примете асинхронную репликацию без кворума для биллинга, но для поискового индекса это разумный дефолт, потому что худший исход — переиндексация за четыре часа, а не пропавшие деньги.

Второе общее свойство: оба класса отказываются от универсальности ради одного паттерна доступа. Time-series БД знает, что время — это главное измерение, и строит всё вокруг него. Поисковый движок знает, что запрос — это набор термов, и строит инвертированный индекс. За это они платят тем, что вне своего паттерна ведут себя откровенно плохо: попробуйте сделать в Elasticsearch транзакционное списание средств или в InfluxDB — джойн трёх таблиц.

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

Часть I. Временные ряды

Что делает временной ряд особенным

Временной ряд — это последовательность измерений (идентификатор серии, момент времени, значение). Формально это обычная таблица, и первая реакция инженера — «положу в PostgreSQL». Реакция правильная: до определённого масштаба это лучшее решение. Но у нагрузки на временные ряды есть пять свойств, которые ломают обычную СУБД, когда объём растёт.

1. Запись почти всегда append-only и монотонна по времени. Новые точки приходят с растущим timestamp. В B-tree это означает, что все вставки бьют в самый правый лист. Это, вопреки интуиции, хорошо: страница горячая и лежит в буферном кэше. Плохо становится, когда индексов много и каждый из них — не по времени: тогда вставка одной строки трогает случайные страницы в пяти разных деревьях, и вы упираетесь в случайный ввод-вывод. Механику B-tree и цену дополнительных индексов подробно разбирает статья про индексы и планы выполнения.

2. Данные почти никогда не обновляются, но постоянно удаляются пачками. «Храним 90 дней» — это DELETE FROM metrics WHERE ts < now() - interval '90 days' каждую ночь. В PostgreSQL такой DELETE создаёт десятки миллионов мёртвых версий строк, autovacuum захлёбывается, таблица раздувается, а место операционной системе не возвращается. Это классическая причина, по которой «просто PostgreSQL» умирает на метриках не от вставок, а от ретеншена.

3. Запросы почти всегда агрегирующие и по диапазону. «Средний CPU по кластеру за последний час, поминутно» — это не выборка строк, это свёртка. Читаются миллионы точек, возвращаются шестьдесят. Строчное хранение здесь проигрывает колоночному в разы: вы поднимаете с диска всю строку ради одного числа. Ту же логику в чистом виде эксплуатируют аналитические СУБД — см. ClickHouse и колоночные аналитические БД.

4. Данные отлично сжимаются. Соседние точки одной серии почти одинаковы. Метки времени идут с почти постоянным шагом — их можно хранить дельтой от дельты (delta-of-delta) в считанные биты. Значения — числа с плавающей точкой, у которых старшие биты совпадают, — их сжимают XOR-кодированием. Оба приёма пришли из статьи Facebook про Gorilla (VLDB 2015) и сегодня стоят под капотом практически у всех: Prometheus, InfluxDB, TimescaleDB, VictoriaMetrics. Коэффициент сжатия 10–20× — норма, а не рекорд.

5. Кардинальность — главный ресурс. Серия определяется набором меток. Число серий равно произведению числа различных значений всех меток. Это единственная величина, которая убивает time-series БД, и про неё — отдельный раздел ниже.

TimescaleDB: расширение PostgreSQL, а не отдельная БД

TimescaleDB — это расширение PostgreSQL. Здесь два следствия. Первое: вы получаете весь PostgreSQL — полноценный SQL, джойны, оконные функции, внешние ключи, транзакции, драйверы на любом языке, pg_dump, реплики, весь мир расширений. Всё, что описано в статье про PostgreSQL, продолжает работать. Второе: вы получаете и все его ограничения — MVCC с его вакуумом, процесс на соединение, отсутствие честного распределённого шардинга (multi-node deprecated с версии 2.14 и удалён в 2.19).

Центральная абстракция — гипертаблица. Снаружи это обычная таблица; внутри она автоматически разрезана на чанки по интервалу времени.

Гипертаблица TimescaleDB: чанки, chunk exclusion и колоночное сжатие

-- Обычная таблица PostgreSQL
CREATE TABLE metrics (
    ts          timestamptz      NOT NULL,
    device_id   int              NOT NULL,
    metric      text             NOT NULL,
    value       double precision NOT NULL,
    tags        jsonb
);

-- Превращаем в гипертаблицу. chunk_time_interval подбирают так,
-- чтобы активный чанк ВМЕСТЕ СО ВСЕМИ ИНДЕКСАМИ занимал ~25% RAM.
SELECT create_hypertable(
    'metrics', by_range('ts', INTERVAL '1 day')
);

-- Индекс под типовой запрос: сначала селективная метка, потом время DESC.
-- Порядок колонок критичен: (device_id, ts DESC) обслуживает
-- «последние точки конкретного устройства», (ts, device_id) — нет.
CREATE INDEX ON metrics (device_id, ts DESC);

Что реально даёт разрезание на чанки:

  • Chunk exclusion. Планировщик исключает чанки по константным и по параметризованным во время выполнения условиям на время. Запрос за последний час не видит трёхлетнюю историю вообще — это отсечение происходит до чтения хоть одной страницы данных.
  • Удаление ретеншеном стоит ноль. drop_chunks() — это DROP TABLE. Никаких мёртвых версий, никакой работы для autovacuum, место возвращается ОС мгновенно. Это, по совести, главная причина брать TimescaleDB, а вовсе не скорость вставки.
  • Индексы локальны для чанка. Дерево на 100 миллионов строк не строится никогда; вместо него — тридцать деревьев по 3 миллиона. B-tree глубиной 3 вместо 5, и все горячие узлы в кэше.
-- Ретеншен и сжатие как декларативные политики фонового планировщика
SELECT add_retention_policy('metrics', INTERVAL '90 days');

ALTER TABLE metrics SET (
    timescaledb.compress,
    -- segmentby: по какому ключу группировать строки в колоночные батчи.
    -- Ставьте сюда то, по чему фильтруете; иначе сжатые чанки будут
    -- читаться целиком и «быстрое» сжатие обернётся медленными запросами.
    timescaledb.compress_segmentby = 'device_id, metric',
    -- orderby: порядок внутри батча. По времени — чтобы работали
    -- delta-of-delta по ts и XOR по значениям.
    timescaledb.compress_orderby   = 'ts DESC'
);
SELECT add_compression_policy('metrics', INTERVAL '7 days');

Сжатие в TimescaleDB — это не «gzip поверх строк». Чанк старше порога переписывается в колоночную форму: строки группируются по segmentby пачками до 1000 штук, каждая колонка внутри пачки хранится отдельным массивом со своим кодеком. Отсюда честные 10–20× по объёму и одновременно ускорение агрегатов — читается одна колонка вместо всей строки. С версии 2.18 гибридный движок называется Hypercore и умеет держать в одном чанке и строчную, и колоночную часть.

Плата за сжатие абсолютно реальна и её надо знать заранее: точечный UPDATE/DELETE внутри сжатого чанка требует распаковки соответствующего батча, вставка «в прошлое» в сжатый чанк работает, но через staging-область, а индекс по колонке, не входящей в segmentby, в сжатом чанке не помогает. Сжимайте только тот горизонт, который у вас реально иммутабелен.

Непрерывные агрегаты (continuous aggregates) — вторая половина ценности. Это материализованное представление, которое досчитывается инкрементально: пересчитываются только те временные интервалы, куда пришли новые данные.

CREATE MATERIALIZED VIEW metrics_1h
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', ts) AS bucket,
    device_id,
    metric,
    avg(value)   AS avg_value,
    max(value)   AS max_value,
    count(*)     AS points
FROM metrics
GROUP BY bucket, device_id, metric
WITH NO DATA;

SELECT add_continuous_aggregate_policy('metrics_1h',
    start_offset      => INTERVAL '3 days',   -- насколько глубоко пересматриваем
    end_offset        => INTERVAL '1 hour',   -- не трогаем «горячий» край
    schedule_interval => INTERVAL '30 minutes');

Ключевая деталь, которую пропускают: по умолчанию включён real-time aggregation — запрос к metrics_1h возвращает объединение материализованной части и «догоняющих» сырых данных за последний незакрытый интервал. Это удобно, но означает, что запрос читает и сырую гипертаблицу тоже. Если вам нужна предсказуемая латентность дашборда, отключайте: ALTER MATERIALIZED VIEW metrics_1h SET (timescaledb.materialized_only = true).

Как выглядит выигрыш в плане выполнения — стоит посмотреть глазами:

EXPLAIN (ANALYZE, BUFFERS)
SELECT time_bucket('5 minutes', ts) AS b, avg(value)
FROM metrics
WHERE device_id = 42
  AND ts > now() - INTERVAL '6 hours'
GROUP BY b ORDER BY b;
GroupAggregate  (actual time=2.104..18.377 rows=72)
  Group Key: (time_bucket('00:05:00'::interval, m.ts))
  ->  Custom Scan (ChunkAppend) on metrics m  (actual rows=21600)
        Chunks excluded during startup: 88        <-- вот ради этого всё
        ->  Index Scan using _hyper_3_91_chunk_metrics_device_id_ts_idx
              Index Cond: (device_id = 42 AND ts > (now() - '06:00:00'::interval))
              Buffers: shared hit=214
Planning Time: 1.882 ms
Execution Time: 18.9 ms

Chunks excluded during startup: 88 — это 88 таблиц, которые планировщик выбросил, ни разу не прочитав. На обычной секционированной таблице PostgreSQL похожего эффекта можно добиться и без Timescale (PARTITION BY RANGE + pg_partman), и это честная альтернатива: если вам нужны только партиционирование и ретеншен, а сжатие с непрерывными агрегатами не нужны, ванильный PostgreSQL закрывает задачу бесплатно и без вендорской лицензии.

Про лицензию стоит сказать прямо, потому что это регулярный источник неприятных открытий. TimescaleDB разделён на две части: Apache-2 ядро (гипертаблицы, chunk exclusion) и Timescale License (TSL) — source-available, не OSI-одобренная, покрывающая как раз сжатие, непрерывные агрегаты и политики. TSL разрешает свободное использование, но запрещает предоставлять TimescaleDB как управляемый сервис третьим лицам. Для 99% компаний это не проблема; для облачного провайдера — блокер. В 2025 году компания Timescale Inc. переименовалась в TigerData, продукт остался.

InfluxDB: специализированная БД со своей моделью и бурной историей

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

# Line protocol: measurement,теги поля timestamp
cpu,host=web-01,region=eu-central usage_user=23.5,usage_sys=4.1 1721304000000000000

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

Теги (tags) Поля (fields)
Тип всегда строка float, int, bool, string
Индексируются да нет
Фильтрация WHERE быстрая полный скан значений
Участвуют в ключе серии да нет
Влияют на кардинальность да, мультипликативно нет

Правило: всё, по чему фильтруете и группируете, — тег; всё, что измеряете, — поле. И ни в коем случае не тег с высокой кардинальностью.

История версий важна, потому что «InfluxDB» в 2026 году — это три разных продукта:

Версия 3 (IOx) — это архитектурно уже другая система: разделение хранения и вычисления, данные лежат в Parquet в объектном хранилище (см. объектные хранилища), запросы исполняет DataFusion. По сути InfluxDB 3 стал колоночной аналитической БД, заточенной под время. Ограничение бесплатного Core на 72 часа глубины запросов — принципиальный момент: это не «медленно на старых данных», а прямой отказ. Историю хранит только Enterprise.

Разрыв совместимости между 1.x → 2.x → 3.x — самая частая претензия к продукту, и она обоснована: два раза за десятилетие пользователей просили переписать все запросы. Это не техническая, а риск-характеристика вендора, и её надо взвешивать наравне с бенчмарками.

Кардинальность: единственная метрика, за которой надо следить

Число серий — это произведение мощностей множеств значений всех меток:

серий = measurements × Π (уникальных значений тега_i) × полей

cpu, теги: host(5000) × region(6) × env(3)          =  90 000 серий  — нормально
+ добавили тег pod_name (эфемерные поды, 40 000)    =  3.6 млрд серий — система мертва
+ добавили тег request_id                           =  бесконечность

Каждая серия — это отдельный индексный вход, отдельный набор буферов в памяти, отдельный файл или блок на диске. Взрыв кардинальности проявляется не как «стало медленнее», а как OOM-kill процесса и невозможность стартовать заново. Классические источники взрыва, все реальные:

  • ID запроса, трассировки, сессии в тегах. Это не метрика — это лог. Место таких данных в поисковом или лог-хранилище.
  • Эфемерные имена: pod_name, container_id в Kubernetes. Каждый передеплой создаёт новый набор серий, старые остаются в индексе до истечения ретеншена.
  • URL с параметрами в метке path. Нужен /api/users/{id}, а не /api/users/8347. Нормализацию делают на стороне приложения, до отправки метрики.
  • Сообщения об ошибках в метках. Текст исключения — не измерение.

Диагностика: в InfluxDB 1.x — SHOW SERIES CARDINALITY; в Prometheus — topk(10, count by (__name__)({__name__=~".+"})) и страница /status/tsdb; в TimescaleDB кардинальность бьёт мягче (это просто много различных значений в колонке), но раздувает индексы и убивает эффективность segmentby.

Практический ориентир: до ~100 тысяч активных серий работает всё что угодно, включая PostgreSQL. От 1 до 10 миллионов — нужен специализированный движок и внимательная настройка. Свыше 10 миллионов активных серий — это уже проект, а не установка: VictoriaMetrics, Mimir или Thanos с продуманным разбиением, либо пересмотр самой схемы меток, что почти всегда дешевле.

Сравнение хранилищ временных рядов

TimescaleDB InfluxDB 3 Prometheus VictoriaMetrics ClickHouse
Модель данных реляционная, произвольные типы measurement + теги/поля метрика + метки, только float64 совместим с Prometheus реляционная, колоночная
Язык полный SQL SQL + InfluxQL PromQL PromQL + MetricsQL SQL
Джойны с бизнес-данными нативные нет нет нет есть, но дорогие
Приём данных INSERT/COPY, 100k–1M строк/с на узел line protocol, очень высокий pull-модель, скрейпинг push + pull до миллионов строк/с
Консистентность ACID PostgreSQL eventual, без транзакций нет транзакций нет транзакций eventual внутри реплики
Масштабирование записи вертикально + партиционирование горизонтально в Enterprise один узел горизонтальный кластер горизонтальный шардинг
Долговечность WAL PostgreSQL, полноценная WAL, окно потерь WAL + блоки по 2 часа WAL асинхронные части
Эксплуатация знакома всем, кто знает PG своя, менялась трижды простейшая, один бинарник простая, один бинарник требует экспертизы
Ретеншен drop_chunks, политика политика на бакет глобальный флаг по времени и по объёму TTL таблицы
Когда НЕ брать >10 млн активных серий, нужен write-шардинг нужны джойны, SQL-экосистема, стабильность API нужна долгая история, точность биллинга нужен не-метрический сценарий, события нужны точечные UPDATE и высокая конкурентность коротких запросов

Отдельно проговорю распространённую ошибку выбора: метрики и события — разные нагрузки. Метрика — регулярная числовая величина известной серии. Событие — разовая запись с произвольным набором атрибутов, которую надо потом искать по этим атрибутам. Prometheus и InfluxDB построены под первое. Логи, аудит, трассировки, клики — это второе, и им место в поисковом или колоночном хранилище. Попытка складывать события в метрическую БД — это как раз тот путь, который заканчивается взрывом кардинальности.

Часть II. Поиск

Инвертированный индекс: почему поиск вообще возможен

LIKE '%база данных%' в SQL — это последовательный скан с посимвольным сравнением. Полнотекстовый движок делает принципиально другое: он заранее строит отображение «терм → список документов», и стоимость запроса начинает зависеть не от размера корпуса, а от длины списков найденных термов.

Инвертированный индекс: анализ, postings-списки и скоринг BM25

Два момента, которые надо усвоить твёрдо, потому что 80% багов поиска — именно тут.

Анализатор применяется дважды и должен совпадать. При индексации текст проходит через цепочку «char filter → tokenizer → token filters» и превращается в набор термов. При поиске запрос проходит через ту же цепочку. Если поле индексировалось русским анализатором со стеммингом, а запрос анализируется стандартным — «базы» и «баз» не совпадут, и вы получите пустую выдачу при явно присутствующем документе. Отладка: GET /idx/_analyze и GET /idx/_validate/query?explain=true.

Скоринг — это BM25, а не «сколько раз встретилось». С Elasticsearch 5.0 умолчание — Okapi BM25: редкий терм весит больше частого (IDF), десятое вхождение слова добавляет почти ничего (насыщение по tf), а совпадение в коротком поле ценнее, чем в длинном (нормализация по длине). Понимание этих трёх свойств объясняет большую часть «странной» релевантности. Инструмент разбора — "explain": true в теле запроса: он возвращает полное дерево вычисления скора по каждому подзапросу.

Elasticsearch: сегменты, шарды и почему документ не находится сразу

Elasticsearch и OpenSearch — распределённые обёртки вокруг Apache Lucene. Понимание Lucene объясняет почти всё поведение кластера.

Lucene хранит данные в сегментах, и сегмент иммутабелен. Новый документ не меняет существующие файлы — он попадает в буфер, из которого рождается новый сегмент. Удаление — это отметка в битовом векторе; физически документ исчезнет только при слиянии сегментов. Обновление — это удаление плюс вставка. Отсюда прямое следствие: в Elasticsearch не бывает дешёвых частичных обновлений, и модель «часто меняющееся поле в большом документе» — антипаттерн.

Из этой диаграммы вытекают три вещи, которые надо знать наизусть:

  1. Поиск near-real-time, а не real-time. Между 201 Created и появлением документа в выдаче проходит до index.refresh_interval. Ответ «записали → сразу читаем и не находим» — не баг. Если нужно прочитать конкретный документ немедленно, используйте GET /idx/_doc/{id} — он читает через translog и всегда видит последнюю версию. ?refresh=wait_for в запросе индексации — приемлемо; ?refresh=true в проде — почти всегда ошибка, он создаёт крошечный сегмент на каждый запрос и порождает бесконечные слияния.
  2. Refresh — главный рычаг производительности массовой загрузки. При bulk-индексации ставьте "refresh_interval": "30s" или -1, а number_of_replicas: 0 на время загрузки. Разница в пропускной способности — в 2–4 раза. Возвращайте настройки и делайте _forcemerge после.
  3. Долговечность и видимость — разные оси. По умолчанию index.translog.durability: request — fsync на каждый запрос, данные переживут падение узла. Переключение в async даёт ощутимый прирост записи ценой окна потерь sync_interval (5 секунд по умолчанию). Для логов это часто оправданный размен, для каталога товаров — нет.

Шард — это один Lucene-индекс. Отсюда правила сайзинга, за которые платят кровью:

  • Целевой размер шарда: 10–50 ГБ. Меньше 10 ГБ — накладные расходы на метаданные и оверхед на запрос перевешивают; больше 50 ГБ — восстановление и релокация шарда занимают часы, а merge требует свободного места, равного размеру шарда.
  • Жёсткий предел — 2^31−1 ≈ 2.1 млрд документов на шард (ограничение Lucene). Достигается редко, но достигается.
  • Не больше ~20 шардов на 1 ГБ heap. Каждый шард держит структуры в памяти постоянно, независимо от того, обращаются к нему или нет.
  • Heap ≤ 31 ГБ. Выше JVM теряет сжатые указатели (compressed oops), и 32 ГБ heap вмещает меньше объектов, чем 31 ГБ. И не более половины RAM — вторая половина нужна page cache, из которого Lucene читает mmap-нутые сегменты. Именно поэтому машина 64 ГБ с 31 ГБ heap — канонический узел.
  • Число первичных шардов нельзя изменить после создания индекса (только _split/_shrink с ограничениями и остановкой записи). Отсюда — паттерн rollover: пишем в алиас, движок сам создаёт следующий индекс по достижении порога.

Типичная схема логов: индексы по дням или по объёму, алиас для записи, ILM для перекладывания по уровням хранения.

Экономика этого каскада — главное, ради чего его строят: hot-узлы на NVMe стоят в 5–10 раз дороже за терабайт, чем те же данные в объектном хранилище. Перекладывание 95% редко читаемых логов вниз обычно сокращает счёт за кластер на 60–80%. В OpenSearch тот же механизм называется ISM (Index State Management) и настраивается похожими политиками.

Маппинг: место, где решается судьба кластера

PUT /orders
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "refresh_interval": "5s",
    "analysis": {
      "analyzer": {
        "ru_text": {
          "tokenizer": "standard",
          "filter": ["lowercase", "russian_stop", "russian_stemmer"]
        }
      },
      "filter": {
        "russian_stop":    { "type": "stop",            "stopwords": "_russian_" },
        "russian_stemmer": { "type": "stemmer",         "language": "russian"    }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "title":    { "type": "text",    "analyzer": "ru_text",
                    "fields": { "raw": { "type": "keyword" } } },
      "status":   { "type": "keyword" },
      "price":    { "type": "scaled_float", "scaling_factor": 100 },
      "created":  { "type": "date" },
      "customer": { "type": "keyword", "doc_values": true, "index": false },
      "notes":    { "type": "text", "index": false }
    }
  }
}

Разбор решений, каждое из которых типовое:

  • dynamic: strict. Без этого первое же поле с опечаткой создаёт новое поле в маппинге. Логи с произвольными атрибутами так порождают «взрыв маппинга» на десятки тысяч полей, и кластер начинает падать на обновлении метаданных. Альтернатива для по-настоящему динамических данных — тип flattened, который кладёт весь объект в одно поле.
  • text против keyword. text анализируется и годится для полнотекстового поиска, но по нему нельзя сортировать и агрегировать. keyword хранится целиком и годится для фильтров, сортировки, агрегаций и точного совпадения. Паттерн title + title.raw (multi-field) даёт и то и другое ценой дублирования.
  • doc_values. Колоночное представление на диске, которое обслуживает агрегации и сортировку. Включено по умолчанию для всех типов, кроме text. Если поле только фильтруется, но не ищется — "index": false с сохранёнными doc_values экономит место.
  • fielddata: true на text-поле. Единственно верный совет: никогда. Это построение обратного отображения в heap на лету — классическая причина OOM всего узла.
  • scaled_float для денег. Хранится как long, делится на scaling_factor. Компактнее и точнее double.

Запрос, показывающий разделение на скоринг и фильтрацию — базовый навык:

GET /orders/_search
{
  "query": {
    "bool": {
      "must":   [ { "match": { "title": { "query": "красные кроссовки",
                                          "operator": "and" } } } ],
      "should": [ { "match_phrase": { "title": { "query": "красные кроссовки",
                                                 "boost": 3 } } } ],
      "filter": [ { "term":  { "status": "in_stock" } },
                  { "range": { "price": { "lte": 15000 } } } ]
    }
  },
  "aggs": {
    "by_brand":   { "terms": { "field": "brand", "size": 20 } },
    "price_hist": { "histogram": { "field": "price", "interval": 5000 } }
  },
  "track_total_hits": 1000,
  "size": 20
}

Здесь важны три вещи. Условия в filter не участвуют в скоринге и кэшируются в node query cache — всё, что является бинарным «да/нет», обязано быть в filter, а не в must. should внутри bool с непустым must работает как бустер релевантности, а не как обязательное условие. track_total_hits: 1000 отключает точный подсчёт общего числа совпадений сверх порога — на больших индексах это заметно ускоряет запрос, а пользователю всё равно достаточно «более 1000 результатов».

Отдельно про пагинацию: from + size в Elasticsearch линейно дорожает — чтобы отдать страницу 500, каждый шард должен собрать и отсортировать 500 × 20 документов и отправить координатору. Отсюда жёсткий лимит index.max_result_window в 10 000. Для глубокой навигации — search_after с точкой отсчёта по последнему отсортированному значению, для выгрузки всего индекса — _pit (point in time) плюс search_after. scroll устарел и держит ресурсы кластера.

Elasticsearch против OpenSearch: что действительно отличается

Форк родился из смены лицензии, поэтому начнём с неё.

Elasticsearch OpenSearch
Лицензия AGPLv3 / ELv2 / SSPL на выбор Apache 2.0
Управление Elastic N.V. OpenSearch Software Foundation (Linux Foundation)
Основа Lucene, свежие версии первыми Lucene, обычно с лагом
Жизненный цикл индексов ILM ISM
Безопасность, RBAC, алертинг часть под ELv2, в бесплатном тайре базово всё в Apache 2.0 из коробки
Клиенты официальные, с 8.x проверяют версию сервера и не работают с OpenSearch свои форки клиентов
Векторный поиск HNSW в Lucene, ELSER, ES|QL k-NN плагин: Lucene, nmslib, FAISS
Управляемый сервис Elastic Cloud, а также в GCP/Azure/AWS Amazon OpenSearch Service, Aiven и другие
Когда НЕ брать лицензия несовместима с вашей моделью распространения; нужен вендор-нейтральный стек нужны свежайшие фичи Elastic, ES|QL, ELSER, платная поддержка Elastic

Практическая сторона: для типовых задач (логи, поиск по каталогу, агрегации) системы взаимозаменяемы — базовый API совпадает, приложение переносится почти без изменений. Расхождения начинаются на новых возможностях и, что важнее в бытовом смысле, на клиентских библиотеках: официальный клиент Elasticsearch 8.x намеренно отказывается работать с OpenSearch, так что «просто поменять URL» не выйдет — меняется зависимость. Проектируя интеграцию, спрячьте поисковый движок за собственным тонким слоем — это единственная страховка от такой развилки.

Когда поисковый движок вообще не нужен

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

PostgreSQL FTS. Встроенный полнотекстовый поиск на tsvector + GIN закрывает больше задач, чем принято думать: морфология для русского и английского, префиксный поиск, ранжирование, подсветка, pg_trgm для опечаток и похожести.

ALTER TABLE products ADD COLUMN search_vec tsvector
  GENERATED ALWAYS AS (
      setweight(to_tsvector('russian', coalesce(title, '')),       'A') ||
      setweight(to_tsvector('russian', coalesce(description, '')), 'B')
  ) STORED;

CREATE INDEX ON products USING GIN (search_vec);

SELECT id, title, ts_rank(search_vec, q) AS rank
FROM products, websearch_to_tsquery('russian', 'красные кроссовки') q
WHERE search_vec @@ q AND status = 'in_stock'
ORDER BY rank DESC LIMIT 20;

Плюсы огромные: никакой синхронизации — индекс обновляется в той же транзакции, что и данные; фильтры по бизнес-полям и джойны нативны; ноль новых сервисов. Минусы честные: ранжирование ts_rank — не BM25 и заметно грубее; нет фасетных агрегаций сравнимого качества; нет распределённого поиска; и главное — GIN-индекс на десятках миллионов документов с высокой частотой обновлений начинает страдать от роста pending-списка. Практический рубеж — единицы миллионов документов и умеренная нагрузка на поиск. Если нужен BM25 внутри PostgreSQL, существует расширение ParadeDB pg_search на базе Tantivy.

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

ClickHouse для логов. Если поиск по логам сводится к фильтрам по полям, временным диапазонам и агрегациям, а не к настоящей релевантности, ClickHouse обычно дешевле Elasticsearch в 3–10 раз по железу при сопоставимых объёмах. Полнотекстовость там слабее, но для «покажи ошибки сервиса X за час с кодом 500» она и не нужна.

Общее: как эти хранилища живут рядом с основной БД

Обе системы — производные. Значит, центральный вопрос не «как настроить», а «как наполнять и как переживать расхождение».

Что здесь принципиально:

  • Двойная запись из приложения — антипаттерн. «Записал в PostgreSQL и тут же в Elasticsearch» ломается при любом сбое между двумя вызовами, и расхождение накапливается молча. Правильный путь — либо CDC из журнала БД (Debezium читает WAL и гарантирует, что событие соответствует зафиксированной транзакции), либо transactional outbox: строка в таблицу outbox в той же транзакции, отдельный процесс её вычитывает. Подробнее эта механика разбирается в треке инженерии данных.
  • Индексатор обязан быть идемпотентным. Доставка «хотя бы один раз» — норма. Используйте _id документа, равный первичному ключу, и внешнее версионирование: PUT /idx/_doc/{id}?version={updated_at_epoch}&version_type=external. Тогда повторная доставка старого события просто отклоняется движком с 409, а не откатывает документ назад.
  • Переиндексация должна быть рутиной, а не подвигом. Приложение всегда ходит через алиас orders, никогда — через физический индекс. Новая версия маппинга — это новый индекс orders_v7, наполнение, проверка, атомарное переключение алиаса одним вызовом _aliases. Если этот путь не отработан заранее, любое изменение маппинга становится инцидентом, потому что тип поля в Lucene изменить нельзя.
  • Расхождение надо измерять. Регулярное сравнение count(*) и контрольных сумм по окнам между источником и производным хранилищем — дешёвая проверка, которая ловит тихую потерю событий раньше пользователей.

Стоимость владения: где на самом деле уходят деньги

Порядки величин для ориентира (реальные цифры зависят от площадки, но соотношения устойчивы).

Сценарий Профиль нагрузки Разумная конфигурация Что доминирует в счёте
Метрики приложения, 50k серий 5k точек/с, ретеншен 1 год PostgreSQL + TimescaleDB, один узел 8 vCPU / 32 ГБ / 500 ГБ SSD почти ничего; сжатие даёт 15× и год умещается в сотни ГБ
Метрики инфраструктуры, 5 млн серий 500k точек/с VictoriaMetrics кластер, 3 узла, или Mimir поверх S3 оперативная память под индекс серий
Логи, 500 ГБ/сутки, 30 дней пиковая запись, редкое чтение старого Elasticsearch: 3 hot по 64 ГБ RAM + warm + cold в S3 hot-узлы и NVMe; без ILM счёт растёт втрое
Поиск по каталогу, 5 млн товаров 500 запросов/с, обновления пачками 3 узла по 32 ГБ, 3 шарда × 1 реплика CPU на скоринг и агрегации, не диск
Поиск по каталогу, 500k товаров 50 запросов/с PostgreSQL FTS на существующем узле ноль дополнительных затрат

Три эмпирических правила по деньгам. Первое: в поисковом кластере платят за оперативную память — Lucene живёт page cache, и как только рабочее множество перестаёт помещаться, латентность растёт скачком, а не плавно. Второе: в метриках платят за кардинальность, а не за объём точек — миллиард точек в 50 тысячах серий дешевле, чем сто миллионов точек в пяти миллионах серий. Третье: реплики удваивают стоимость хранения — для логов старше нескольких дней одна копия в объектном хранилище через searchable snapshot почти всегда честнее, чем полноценная реплика на локальных дисках.

Типичные ошибки в проде

Список сознательно составлен из тех, что встречаются чаще всего и стоят дороже всего.

  1. Elasticsearch как основная база. Нет транзакций между документами, нет внешних ключей, near-real-time видимость, обновление документа — это удаление плюс вставка. Хранить здесь заказы как источник истины — гарантированная потеря данных при первом же нештатном слиянии или неудачном восстановлении.
  2. Тег с высокой кардинальностью в метрике. user_id, request_id, pod_name. Проявляется как OOM, а не как замедление, и лечится только пересозданием.
  3. Слишком много мелких шардов. «Сделаем 50 шардов, чтобы масштабировалось» на индексе в 20 ГБ — это 400 МБ на шард, кластер тратит больше времени на координацию, чем на работу. Считайте от целевых 10–50 ГБ на шард.
  4. Heap 64 ГБ. Потеря compressed oops плюс многосекундные паузы GC. Потолок — 31 ГБ; больше памяти на узле полезно, но она должна уходить в page cache. Если нужно больше — несколько узлов на машине.
  5. refresh=true на каждом документе. Тысячи микросегментов, непрерывные слияния, деградация записи и поиска одновременно.
  6. Отсутствие ILM/ISM. Логи копятся на hot-узлах вечно, счёт растёт линейно, а потом диски заканчиваются в выходные.
  7. Двойная запись в приложении вместо CDC или outbox. Расхождение неизбежно и обнаруживается пользователями.
  8. Отсутствие алиасов. Любое изменение маппинга требует простоя, потому что тип поля в Lucene неизменяем.
  9. DELETE FROM metrics WHERE ts < ... вместо drop_chunks. Раздувание таблицы, отставание autovacuum, деградация всей БД, а не только таблицы метрик.
  10. Сжатие горячего интервала в TimescaleDB. Данные ещё дописываются и обновляются, а чанк уже колоночный — записи начинают идти через staging и тормозить.
  11. from + size для глубокой пагинации. Растущая нагрузка на все шарды и упор в max_result_window; нужен search_after.
  12. Мониторинг мониторинга отсутствует. Кластер метрик, который сам себя мониторит, во время инцидента слеп именно тогда, когда нужен.

Как выбирать: короткий алгоритм

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

Мини-итог

  • Временные ряды и поиск — производные хранилища. Их можно и нужно уметь пересоздавать; отсюда более мягкие требования к долговечности и более жёсткие — к воспроизводимости пайплайна наполнения.
  • В time-series главный ресурс — кардинальность, а не объём. Она задаётся схемой меток, то есть решением разработчика, а не размером железа.
  • TimescaleDB выигрывает, когда нужны SQL, джойны с бизнес-данными и знакомая эксплуатация; главная его ценность — не скорость вставки, а бесплатный ретеншен через drop_chunks и колоночное сжатие с непрерывными агрегатами.
  • InfluxDB — специализированный движок с высокой скоростью приёма и тремя несовместимыми поколениями за десять лет; разделение на теги и поля определяет всё остальное.
  • В поиске всё вытекает из иммутабельности сегментов Lucene: near-real-time видимость, дорогие обновления, неизменяемый маппинг, сайзинг шардов, потолок heap в 31 ГБ.
  • Elasticsearch и OpenSearch технически взаимозаменяемы на типовых задачах; выбор — про лицензию, вендора и клиентские библиотеки. Спрячьте движок за своим слоем абстракции.
  • Перед тем как заводить отдельный кластер, проверьте PostgreSQL: партиционирование для рядов и tsvector + GIN для поиска закрывают удивительно большую долю реальных задач.

Источники

  • Martin Kleppmann. Designing Data-Intensive Applications, глава 3 (storage) и глава 11 (derived data) — dataintensive.net
  • Facebook. Gorilla: A Fast, Scalable, In-Memory Time Series Database, VLDB 2015 — vldb.org
  • TimescaleDB / TigerData — документация по гипертаблицам, сжатию и непрерывным агрегатам: docs.tigerdata.com
  • InfluxDB 3 — архитектура IOx, line protocol, ограничения Core: docs.influxdata.com
  • Prometheus — модель данных и предупреждения о кардинальности: prometheus.io/docs/practices/naming
  • VictoriaMetrics — разбор проблемы кардинальности: docs.victoriametrics.com
  • Elasticsearch Guide — сайзинг шардов, ILM, тюнинг индексации: elastic.co/guide
  • OpenSearch — документация и ISM: opensearch.org/docs
  • Apache Lucene — устройство сегментов и кодеков: lucene.apache.org
  • PostgreSQL — полнотекстовый поиск: postgresql.org/docs/current/textsearch.html
  • Debezium — CDC из журнала БД: debezium.io

Что дальше

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

Векторные базы: pgvector, Qdrant, Milvus, Weaviate, HNSW и метрики близости

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов