Временные ряды и поиск: 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).
Центральная абстракция — гипертаблица. Снаружи это обычная таблица; внутри она автоматически разрезана на чанки по интервалу времени.
-- Обычная таблица 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 — это последовательный скан с посимвольным сравнением. Полнотекстовый движок делает принципиально другое: он заранее строит отображение «терм → список документов», и стоимость запроса начинает зависеть не от размера корпуса, а от длины списков найденных термов.
Два момента, которые надо усвоить твёрдо, потому что 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 не бывает дешёвых частичных обновлений, и модель «часто меняющееся поле в большом документе» — антипаттерн.
Из этой диаграммы вытекают три вещи, которые надо знать наизусть:
- Поиск near-real-time, а не real-time. Между
201 Createdи появлением документа в выдаче проходит доindex.refresh_interval. Ответ «записали → сразу читаем и не находим» — не баг. Если нужно прочитать конкретный документ немедленно, используйтеGET /idx/_doc/{id}— он читает через translog и всегда видит последнюю версию.?refresh=wait_forв запросе индексации — приемлемо;?refresh=trueв проде — почти всегда ошибка, он создаёт крошечный сегмент на каждый запрос и порождает бесконечные слияния. - Refresh — главный рычаг производительности массовой загрузки. При bulk-индексации ставьте
"refresh_interval": "30s"или-1, аnumber_of_replicas: 0на время загрузки. Разница в пропускной способности — в 2–4 раза. Возвращайте настройки и делайте_forcemergeпосле. - Долговечность и видимость — разные оси. По умолчанию
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 для перекладывания по уровням хранения.
NVMe, реплики, много CPU
rollover при 50 ГБ или 1 дне Warm: WARM — только чтение
forcemerge в 1 сегмент
реплик меньше, SSD подешевле Cold: COLD — редкий доступ
searchable snapshot в S3
локальные реплики не хранятся Frozen: FROZEN — архив
данные только в объектном хранилище
запросы секунды, а не миллисекунды Hot --> Warm: через 2 дня Warm --> Cold: через 30 дней Cold --> Frozen: через 90 дней Frozen --> [*]: delete через 365 дней Warm --> [*]: delete, если холодный уровень не нужен
Экономика этого каскада — главное, ради чего его строят: 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» она и не нужна.
Общее: как эти хранилища живут рядом с основной БД
Обе системы — производные. Значит, центральный вопрос не «как настроить», а «как наполнять и как переживать расхождение».
источник истины")] PG -->|"логическая репликация / WAL"| CDC["CDC: Debezium"] CDC --> K{{"Kafka — буфер и точка
переигрывания истории"}} APP -->|"метрики"| AGENT["Агент: OTel Collector / Telegraf"] APP -->|"логи, события"| AGENT AGENT --> K K --> IDX["Индексатор:
трансформация и обогащение"] K --> TSW["Писатель метрик"] IDX --> ES[("Elasticsearch / OpenSearch
поиск и фасеты")] TSW --> TS[("TimescaleDB / InfluxDB
временные ряды")] ES --> API["API поиска"] TS --> DASH["Дашборды и алерты"] PG -.->|"полная переиндексация
по алиасу, раз в N недель"| IDX K -.->|"переигрывание с оффсета
после инцидента"| IDX
Что здесь принципиально:
- Двойная запись из приложения — антипаттерн. «Записал в 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 почти всегда честнее, чем полноценная реплика на локальных дисках.
Типичные ошибки в проде
Список сознательно составлен из тех, что встречаются чаще всего и стоят дороже всего.
- Elasticsearch как основная база. Нет транзакций между документами, нет внешних ключей, near-real-time видимость, обновление документа — это удаление плюс вставка. Хранить здесь заказы как источник истины — гарантированная потеря данных при первом же нештатном слиянии или неудачном восстановлении.
- Тег с высокой кардинальностью в метрике.
user_id,request_id,pod_name. Проявляется как OOM, а не как замедление, и лечится только пересозданием. - Слишком много мелких шардов. «Сделаем 50 шардов, чтобы масштабировалось» на индексе в 20 ГБ — это 400 МБ на шард, кластер тратит больше времени на координацию, чем на работу. Считайте от целевых 10–50 ГБ на шард.
- Heap 64 ГБ. Потеря compressed oops плюс многосекундные паузы GC. Потолок — 31 ГБ; больше памяти на узле полезно, но она должна уходить в page cache. Если нужно больше — несколько узлов на машине.
refresh=trueна каждом документе. Тысячи микросегментов, непрерывные слияния, деградация записи и поиска одновременно.- Отсутствие ILM/ISM. Логи копятся на hot-узлах вечно, счёт растёт линейно, а потом диски заканчиваются в выходные.
- Двойная запись в приложении вместо CDC или outbox. Расхождение неизбежно и обнаруживается пользователями.
- Отсутствие алиасов. Любое изменение маппинга требует простоя, потому что тип поля в Lucene неизменяем.
DELETE FROM metrics WHERE ts < ...вместоdrop_chunks. Раздувание таблицы, отставание autovacuum, деградация всей БД, а не только таблицы метрик.- Сжатие горячего интервала в TimescaleDB. Данные ещё дописываются и обновляются, а чанк уже колоночный — записи начинают идти через staging и тормозить.
from + sizeдля глубокой пагинации. Растущая нагрузка на все шарды и упор вmax_result_window; нуженsearch_after.- Мониторинг мониторинга отсутствует. Кластер метрик, который сам себя мониторит, во время инцидента слеп именно тогда, когда нужен.
Как выбирать: короткий алгоритм
по времени или
поиск по тексту?"} KIND -->|"измерения"| CARD{"Активных серий?"} CARD -->|"< 100 тысяч"| PGTS["PostgreSQL с партиционированием
или TimescaleDB"] CARD -->|"0.1–10 млн"| MET{"Нужны джойны
с бизнес-данными и SQL?"} MET -->|"да"| TSDB["TimescaleDB"] MET -->|"нет, чистые метрики"| VM["VictoriaMetrics или Mimir
совместимые с Prometheus"] CARD -->|"> 10 млн"| RETHINK["Сначала пересмотреть метки:
это почти всегда дешевле кластера"] KIND -->|"текст"| REL{"Нужна релевантность,
фасеты, морфология?"} REL -->|"нет, только фильтры
и агрегации"| CH["ClickHouse
или колонка в основной БД"] REL -->|"да"| VOL{"Объём и требования?"} VOL -->|"< нескольких млн документов,
нет распределённости"| PGFTS["PostgreSQL FTS
или Meilisearch / Typesense"] VOL -->|"десятки млн и больше,
нужны агрегации и HA"| ESOS{"Ограничения по лицензии
или вендору?"} ESOS -->|"нужен Apache 2.0
или нейтральный стек"| OS["OpenSearch"] ESOS -->|"нужны свежие фичи Elastic"| ES2["Elasticsearch"]
Одно общее правило, которое стоит десятка бенчмарков: специализированное хранилище оправдано, когда универсальное перестало справляться, а не когда вы предполагаете, что перестанет. Стоимость лишнего кластера — это не только железо, это дежурства, обновления версий, обучение команды, ещё один способ потерять данные и ещё одна сущность в схеме, которую надо синхронизировать. Отложенное решение здесь обычно дешевле преждевременного: перевести таблицу в гипертаблицу или поднять поисковый индекс поверх существующих данных — работа на дни, а не месяцы.
Мини-итог
- Временные ряды и поиск — производные хранилища. Их можно и нужно уметь пересоздавать; отсюда более мягкие требования к долговечности и более жёсткие — к воспроизводимости пайплайна наполнения.
- В 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 и метрики близости