Time-series и поисковые хранилища
Time-series хранилища оптимизированы под запись (LSM, даунсэмплинг, retention) для append-only метрик; поисковые движки инвертируют текст в индекс терминов, где SQL LIKE рушится. Специализированное хранилище несёт двойную запись — добавляй, лишь когда primary не тянет.
Продакт-менеджер попросил две «маленькие» фичи: дашборд с CPU каждого сервера за последние 90 дней и поисковую строку, что находит товары по любому слову в названии или описании. Обе легли на существующий primary Postgres, потому что «это просто ещё таблицы». Таблица метрик росла на 50 миллионов строк в день, и её индексы молотили диск; поиск гнал WHERE description LIKE '%word%', под который база не может использовать индекс, так что каждый поиск становился полным сканом таблицы, виснувшим под нагрузкой. Ни одна фича не была сложной — их просто просили у движка, построенного под другую форму работы. Реляционный primary — блестящее транзакционное хранилище, и плохой пожарный шланг метрик, и плохой поисковый движок, и никакой объём индексов это не меняет.
Time-series: другая форма записи
Через десять минут ты поймёшь, почему обе фичи из вступления были дорогими, какое свойство хранения каждая нарушала — и как выглядит правильное решение, когда те же требования появятся снова.
Time-series данные — поток измерений с метками времени, только на добавление: метрики серверов, показания IoT-сенсоров, события приложения, биржевые тики. Их форма необычна: записи многократно превосходят чтения, почти ничто никогда не обновляется и не удаляется поштучно, а запросы почти всегда — «агрегируй эту метрику по этому окну времени». Time-series база (Prometheus, InfluxDB, TimescaleDB, AWS Timestream) построена вокруг этой формы, и три свойства её определяют:
- Хранение, оптимизированное под запись (LSM-деревья). Вместо обновления данных на месте (что значит случайный seek диска на запись — медленно) log-structured merge tree буферизует записи в памяти и сбрасывает их на диск как отсортированные неизменяемые файлы, сливая их в фоне. В результате записи — последовательные append, единственное, что и шпиндельные, и твердотельные диски делают быстрее всего, поэтому LSM-хранилище впитывает куда большую пропускную способность записи, чем традиционное обновление на месте (B-дерево). Обмен — больше работы при чтении и во время фоновой компакции, и это нормально, потому что time-series тяжело на запись.
- Даунсэмплинг. Тебе не нужно посекундное разрешение для данных за прошлый год — оно нужно за последний час. Даунсэмплинг предагрегирует старые данные в более грубые корзины (посекундно → поминутно → почасово), сжимая их на порядки, сохраняя тренд.
- Retention (срок хранения). Старые сырые данные автоматически отбрасываются после окна (держи 7 дней сырыми, 90 дней по 1 минуте, 2 года по 1 часу). Retention — первоклассная декларативная функция: хранилище само удаляет устаревшие данные, так что хранение остаётся ограниченным вместо вечного роста.
Вместе эти три свойства означают, что time-series хранилище принимает данные на полной скорости, пока объём остаётся ограниченным и запросы по любому окну времени работают быстро — без малейшего давления на транзакционный primary. Без retention таблица растёт бесконечно; без даунсэмплинга старые данные в посекундном разрешении жрут место и тормозят диапазонные запросы; без LSM-записи диск становится узким местом ещё до того, как данные успели лечь.
Реляционный primary может хранить метрики, но это не та форма: B-tree индексы деградируют, когда миллиарды строк churn-ятся, нет встроенного даунсэмплинга или retention, а пожарный шланг записи конкурирует с транзакционным трафиком за тот же буфер-кеш и WAL — ровно то отравление, что ты видел с блобами в 02-blob-and-object.
Поиск: почему LIKE — это не поиск
Поисковая строка, что находит документы по любому слову, выглядит как запрос к базе, но это принципиально другой паттерн доступа. SQL-ный WHERE description LIKE '%word%' вынуждает полный скан: ведущий wildcard означает, что никакой B-tree индекс не поможет, так что движок читает и подстрочно сравнивает каждую строку. Он также не даёт ранжирования по релевантности, нет стемминга («running» матчит «run»), нет терпимости к опечаткам, нет многополевого скоринга. Работает на тысяче строк и рушится на миллионе.
Поисковый движок (Elasticsearch/OpenSearch, Lucene, собственный полнотекст Postgres через tsvector) решает это, строя инвертированный индекс. Где обычный индекс отображает строка → значения, инвертированный — наоборот: для каждого термина (слова) он хранит список документов, которые его содержат. Так «найди документы, содержащие payment и failed» становится двумя дешёвыми lookup-ами списков, пересечёнными — без скана. Поверх инвертированного индекса движок наслаивает то, чего LIKE никогда не сможет: токенизацию и стемминг (так «payments» находит «payment»), скоринг релевантности (какой документ — лучший матч, а не просто какой-то), нечёткий матч под опечатки и агрегации (фасеты/фильтры). Поэтому ты тянешься к поисковому движку в момент, когда «поиск» значит больше, чем «точный префиксный матч по одной колонке».
▸Почему это работает
Почему инвертированный индекс быстр для текста там, где B-дерево безнадёжно? B-дерево индексирует значение колонки слева-направо, так что оно может найти строки, где колонка начинается с «pay», за логарифмическое время — но у '%word%' wildcard слева, так что нет префикса для seek и дерево бесполезно; ты сканируешь всё. Инвертированный индекс обходит это, индексируя содержимое, а не значения: при записи движок токенизирует каждый документ в термины и для каждого термина дописывает id документа в его posting-список. Запрос «payment failed» затем тянет два готовых posting-списка и пересекает их — работа пропорциональна тому, сколько документов содержат эти слова, а не размеру всего корпуса. Цена платится при записи (каждый документ надо проанализировать и обновить индекс) и в хранении (индекс может соперничать с данными по размеру), и именно поэтому поиск живёт в отдельном, амортизированном по записи движке, а не как фича, прикрученная к транзакционному primary.
Senior-решение: специализировать или перегрузить primary?
Каждое специализированное хранилище — реальная цена: ещё одна система запускать, мониторить, бэкапить, защищать и (трудная часть) держать в синхроне. Так что вопрос никогда не «лучше ли time-series база на метриках?» (очевидно да), а «мой primary правда не тянет эту нагрузку, и стоит ли провал второй системы?» Честная прогрессия:
- Начни на primary. У Postgres удивительно способный полнотекстовый поиск (
tsvector) и time-series расширения (TimescaleDB). Для умеренного масштаба одно хранилище куда дешевле головной боли согласованности двух. - Специализируй, когда бьёт измеренный предел — пожарный шланг метрик вытесняет транзакционный трафик, или
LIKE-поиски сканируют миллионы строк и отваливаются по таймауту. Предел, а не модное слово, оправдывает второе хранилище. - Считай специализированное хранилище производным индексом, а не источником истины. Реляционный primary остаётся системой записи (source of truth); поисковый движок и time-series хранилище — оптимизированные под чтение проекции его. Это ключевая рамка: если поисковый индекс потерян, ты пересобираешь его из primary — ты не теряешь данные, лишь доступность этого пути запроса.
Проблема двойной записи
В момент, когда запись должна лечь в два хранилища — строка в Postgres и документ в Elasticsearch — у тебя проблема двойной записи (dual-write), и она сложнее, чем кажется. Наивный код пишет в оба последовательно: db.save(order); search.index(order). Но это две системы без общей транзакции. Если процесс падает между двумя вызовами, или запись в поиск провалилась, а запись в БД прошла, хранилища расходятся — заказ существует, но не ищется, тихо, навсегда. Слепой ретрай может задвоить запись; обернуть их в «транзакцию» невозможно через две системы (распределённой транзакции тут нет). Надёжные паттерны делают primary единственным источником истины и выводят второе хранилище из его журнала записи:
- Change Data Capture (CDC) — хвостуй журнал репликации базы (её WAL/binlog) и стримь каждое закоммиченное изменение в поисковое/time-series хранилище. Коммит БД — единственное атомарное событие; обновление индекса — нижестоящий потребитель, который может ретраить и переигрывать.
- Transactional outbox (транзакционный исходящий ящик) — в той же транзакции БД, что пишет заказ, пиши ещё и событие «проиндексируй этот заказ» в таблицу
outbox; отдельный релей читает outbox и толкает в поисковый движок. Поскольку строка заказа и строка outbox коммитятся атомарно, событие нельзя потерять.
Оба разделяют один принцип: никогда не доверяй двум независимым записям остаться согласованными — сделай одно хранилище авторитетным и распространяй из его коммита. Принять, что производное хранилище в итоге согласовано (новый заказ ищется через секунду), обычно нормально; притворяться, что двойная запись атомарна, — баг.
▸Частая ошибка
Классический провал двойной записи — «best-effort» обновление индекса, зарытое в обработчике запроса: пиши строку, потом выстрели вызов индексации поиска и проигнорируй (или просто залогируй) его провал, чтобы «держать запрос быстрым». Работает в каждом тесте и демо, потому что тесты не падают посреди обработчика, а сеть не рвётся в CI. В проде это дрейфует медленно — пара документов не индексируется при каждом деплое, икота очереди тут, таймаут там — и через три месяца «поиск теряет часть результатов» — неотлаживаемая загадка, потому что ничто не записало, какие записи потеряны. Починка — не больше ретраев в обработчике; это убрать двойную запись целиком, выведя индекс из журнала коммитов БД (CDC или outbox), так что сам акт коммита данных есть акт, гарантирующий их индексацию.
Твоя таблица метрик на реляционном primary берёт 50 млн append-only строк/день, и её B-tree индексы теперь молотят диск, конкурируя с транзакционным трафиком. Почему time-series хранилище — правильный ход, механически?
Ты пишешь каждый новый заказ в Postgres, потом вызываешь индекс поиска в том же обработчике и логируешь-и-игнорируешь любой провал, «чтобы держать запрос быстрым». Месяцы спустя поиск непредсказуемо теряет часть заказов. В чём корневая причина и починка?
Поисковый движок находит документы по любому слову без скана корпуса, потому что строит _______ индекс: для каждого термина он хранит список содержащих его документов, так что запрос пересекает готовые списки вместо подстрочного сравнения каждой строки, как обязан SQL LIKE.
- 01Какие три свойства подгоняют time-series хранилище под метрики и почему реляционный primary подходит плохо?
- 02Почему SQL LIKE — не поиск и как инвертированный индекс это чинит?
- 03Сформулируй проблему двойной записи и два надёжных паттерна, что её решают.
Реляционный primary — блестящее транзакционное хранилище и плохой пожарный шланг метрик и плохой поисковый движок. Time-series хранилища (Prometheus, InfluxDB, Timescale, Timestream) совпадают с append-only формой метрик тремя свойствами: LSM-дерево, превращающее записи в быстрые последовательные append, даунсэмплинг, сжимающий старые данные в грубые корзины, и retention, ограничивающий хранение авто-отбросом устаревших данных. Поисковые движки (Elasticsearch/OpenSearch, Lucene, Postgres tsvector) заменяют вынуждающий скан SQL-ный LIKE инвертированным индексом — термин → posting-список документов — так что полнотекстовые запросы пересекают готовые списки и получают стемминг, скоринг релевантности и нечёткий матч, платя цену при записи. Senior-решение не «лучше ли специализированное хранилище» (лучше), а «мой primary измеримо не тянет эту нагрузку?» — начни на primary, специализируй на реальном пределе и считай новое хранилище производным индексом с primary как источником истины. Эта рамка вскрывает реальную опасность: проблему двойной записи. Две независимые записи без общей транзакции тихо расходятся при любом падении или провале, так что ты никогда не делаешь вторую запись best-effort — ты выводишь её из журнала коммитов БД через CDC или транзакционный outbox, делая сам акт коммита данных актом, гарантирующим их индексацию. Теперь, когда встретишь обработчик, который пишет в БД, затем стреляет в индекс поиска и логирует провал — ты знаешь, в чём баг, почему он всплывёт в проде через месяцы, и какие два паттерна убирают его на уровне архитектуры.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.