pg-smart-search: Путь от 8 секунд до 40 мс — Часть 2. Масштабирование до миллиона строк и производственная архитектура

от автора

PostgreSQL vs Elasticsearch — путь от 8 секунд до 40 мс

PostgreSQL vs Elasticsearch — путь от 8 секунд до 40 мс

Привет, Хабр!

В первой части мы разобрали архитектуру pg-smart-search изнутри: параллельный Promise.race, механизм Zombie Prevention через AbortSignal, адаптерный паттерн и CLI-инструмент. Если не читали — рекомендую начать оттуда, здесь я буду отсылать к тем концепциям.

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

На объемах до 100K строк система работала именно так, как задумано: FTS побеждал в Promise.race первым, кэш давал sub-1ms на горячих запросах, пропускная способность — 90 req/sec. Картина мечты.

Потом пришли данные. Много данных. 1 000 000 строк с нормальным, реальным словарем — и всё сломалось. Время поиска улетело к 8-ми секундам.

В этой статье — честный разбор двух этапов спасения: сначала архитектурные фиксы, потом бой с «ошибками выжившего» в бенчмарках. И в конце — production-grade микрооптимизации, которые мы сделали в v1.4.1, вылизав каждую миллисекунду.


Архитектура pg-smart-search: гибридный поиск с Promise.race и Zombie Prevention

Архитектура pg-smart-search: гибридный поиск с Promise.race и Zombie Prevention

ЭТАП 1: Архитектурные грабли на 1М строк

Изначально я сделал всё «по классике»: OFFSET пагинация, триггеры на апдейт FTS-векторов и COUNT(*) OVER() на каждой странице. На тестовых 10 тысячах строк — летало. На 1 миллионе — 8 гребаных секунд.

1. Пагинация через OFFSET: скрытый убийца

LIMIT 20 OFFSET 200000 — база честно сканирует 200К строк и выбрасывает их. На миллионе это катастрофа.

Перешли на Keyset Pagination: WHERE id > $cursor. База прыгает к нужному месту по B-Tree индексу мгновенно. Глубина страницы не имеет значения.

2. COUNT(*) OVER() на каждой странице

Оконная функция для подсчета total_count проходит по всем подходящим строкам при каждом запросе. Ввели флаг skipTotalCount — считаем только на первой странице, дальше отдаём 0. Нагрузка на пагинации упала на 90%.

3. Триггеры на to_tsvector

3. Триггеры на to_tsvector

Массовый COPY миллионов строк с триггерами, пересчитывающими FTS-векторы, просто вешал базу. Решение — Generated Columns:

GENERATED ALWAYS AS (to_tsvector('config', column)) STORED

Вектор строится при вставке, один проход, в 10 раз быстрее.

Результат Этапа 1: задержка упала с 8 секунд до ~80мс. Мы праздновали. Зря.


ЭТАП 2: Ошибка выжившего и настоящий High Cardinality

Почему 19 мс было ложью

Первые бенчмарки показывали 19 мс на 1M строк. Красиво. Но скрипт-генератор тестовых данных использовал всего 40 слов в лексиконе. PostgreSQL сжимал GIN-индекс так, что он целиком влезал в кэш L3 процессора.

В реальном тексте кардинальность — сотни тысяч слов. Когда мы перегенерировали данные из 100 000 уникальных слов, тесты упали с Out-Of-Memory в Docker. Пришлось ограничить maintenance_work_mem до 512MB и делать настоящую оптимизацию.

GIN -> GiST: магия оператора <->

В первой статье мы описали, как используем pg_trgm как fallback когда FTS пустой. Там же у нас висел стандартный GIN-индекс:

CREATE INDEX idx_trigram ON my_table USING GIN (text gin_trgm_ops);

На 1M строк с real-world данными это работало так: GIN вытаскивал все строки по условию похожести, а база последовательно высчитывала word_similarity для каждой из них в памяти, чтобы найти топ-20. На миллионе это — полный скан.

Переход на GiST изменил всё:

«`

CREATE INDEX idx_trigram_gist ON my_table USING GIST (text gist_trgm_ops);-- KNN-запрос — база сразу находит 20 ближайших соседейSELECT text FROM my_table ORDER BY text <-> $1 ASC LIMIT 20;

GiST нативно поддерживает оператор дистанции <->. База не сканирует ничего лишнего — по дереву она мгновенно находит K ближайших соседей (KNN). Fallback-задержка упала до миллисекунд.

Эволюция ts_rank: честный разговор

В первой статье я показывал код с ts_rank_cd (Cover Density) как фичу взвешенного ранжирования. Тогда на 15K строк это выглядело нормально.

На миллионе строк ts_rank_cd стал узким горлышком. Алгоритм вычисляет позиционную близость лексем — для этого база спускается в каждую запись и читает позиции слов. На Bitmap Scan по 1M строк это катастрофа.

Замена на ts_rank (только частота слов, без позиций) срезала холодную задержку ещё на 30%. Качество результатов — практически идентично для большинства запросов. Для поиска по ID или SKU разницы ноль. Для академических статей — может быть заметно, но это уже другой usecase.


Зомби-запросы: до и после внедрения cancelPool

Зомби-запросы: до и после внедрения cancelPool

Про механику Zombie Prevention с AbortSignal и cancelPool подробно писал в первой части. Если коротко: при 1000 RPS на миллионной базе это разница между стабильной работой и Connection Starvation.


Итог: Честные O(log N)

После всех оптимизаций — финальный бенчмарк с High Cardinality словарем (100K уникальных слов):

Кол-во строк

Cold Latency

10 000

2.60 мс

50 000

6.40 мс

100 000

7.08 мс

500 000

10.33 мс

1 000 000

39.33 мс

График: логарифмическое масштабирование Cold Latency — O(log N)

График: логарифмическое масштабирование Cold Latency — O(log N)

Данных в 100 раз больше, время увеличилось лишь в 15 раз. Строгий O(log N). С кэшированием (Hot Cache) — ~6 мс при тысячах RPS.


ЭТАП 3: Производственная архитектура (v1.4.1)

Хорошо работать на бенчмарке — одно. Работать на продакшене при 1000 RPS — другое. В v1.4.1 мы прошлись по коду с профилировщиком и вырезали всё лишнее.

1. Кэширование стратегий — ноль аллокаций на запрос

До этого каждый вызов search() создавал новый объект стратегии:

// БЫЛО: новый объект на каждый запросresults = await new LiteStrategy(this.adapter, this.config).search(  query,  options,);

При 1000 RPS — тысячи бессмысленных аллокаций в секунду. GC в ответ начинает делать Stop-The-World паузы, которые незаметны на синтетике и очень заметны на проде.

Стратегии stateless — создаём один раз в конструкторе:

// СТАЛО: 0 аллокаций на запросconstructor(adapter, config) {    this.liteStrategy = new LiteStrategy(adapter, config);    this.ftsStrategy  = new FTSStrategy(adapter, config);    this.advancedStrategy = new AdvancedStrategy(adapter, config);    this.vectorStrategy   = new VectorStrategy(adapter, config);}

2. Ring Buffer для метрик — O(1) вместо O(n)

MetricsCollector собирал историю латентности в обычный массив. При достижении лимита — Array.shift(), который копирует весь массив. На каждой записи метрики. O(N).

// СТАЛО: Ring Buffer на Float64Arrayprivate latencyBuffer = new Float64Array(1000);private latencyIdx = 0;recordDbLatency(ms: number) {    this.latencyBuffer[this.latencyIdx % 1000] = ms;    this.latencyIdx++;    // O(1), 0 аллокаций, фиксированный размер}

3. Статический маппинг раскладки

В первой статье я показывал Smart Layout Correction — конвертацию ghbdtn -> привет. Но маппинг EN→RU пересоздавался на каждый вызов:

// БЫЛО: 130-символьный маппинг × N запросов = бессмысленная работаstatic convertLayout(query: string): string {    const en = "qwerty...".split('');    const ru = "йцукен...".split('');    const map = {};    en.forEach((c, i) => map[c] = ru[i]); // Каждый. Раз.    ...}
// СТАЛО: вычисляется одним разом при загрузке модуляprivate static readonly layoutMap = (() => {    const en = "qwerty...".split('');    const ru = "йцукен...".split('');    const m: Record<string, string> = {};    en.forEach((c, i) => m[c] = ru[i]);    return m;})();static convertLayout(query: string): string {    return query.split('').map(c => this.layoutMap[c] || c).join('');}

### 4. Memory Leak в MemoryCache — sweep timer

MemoryCacheProvider удалял записи только при обращении (lazy eviction). Если ключ больше никогда не запрашивается — он живёт в Map вечно. Классическая утечка, которую вы не увидите в краткосрочном тесте.

constructor(sweepIntervalMs = 60_000) {    this.sweepTimer = setInterval(() => {        const now = Date.now();        for (const [key, item] of this.cache) {            if (now > item.expires) this.cache.delete(key);        }    }, sweepIntervalMs);    this.sweepTimer.unref(); // Не блокирует exit Node.js}

5. AbortSignal в транзакциях — закрываем последнюю дыру

В первой статье мы описали Zombie Prevention. Но там был пробел: AbortSignal прокидывался только в обычные запросы. Внутри транзакций (BEGIN...COMMIT) сигнал терялся, и транзакция продолжала работать даже когда клиент уже ушёл.

Расширили PgClientAdapter: при получении сигнала abort он немедленно идёт в cancelPool и отправляет pg_cancel_backend(pid). Теперь зомби-запросы невозможны ни в одном сценарии.

6. Детерминированные кэш-ключи

Тонкий баг: {status: 'active', lang: 'ru'} и {lang: 'ru', status: 'active'} генерировали разные кэш-ключи через JSON.stringify. Cache miss на ровном месте.

const filterStr = JSON.stringify(  Object.keys(filters)    .sort()    .reduce((acc, k) => {      acc[k] = filters[k];      return acc;    }, {}),);

Мелочь. Но на Redis-кэше это разница между 0.5 мс и 40 мс на каждом дублирующемся запросе.


TL;DR для тех, кто листает в конец

Проблема

Решение

OFFSET пагинация на 1M строк

Keyset (Cursor) Pagination

COUNT(*) OVER() на каждой странице

skipTotalCount

Триггеры на to_tsvector при COPY

Generated Columns

GIN + ORDER BY similarity — полный скан

GiST + KNN оператор <->

ts_rank_cd — тормоз на 1M+

Заменили на ts_rank

Стратегии создаются на каждый запрос

Кэширование в конструкторе

Array.shift() в метриках — O(n)

Ring Buffer — O(1)

Маппинг раскладки пересоздаётся

Статическое поле

MemoryCache без очистки

Background sweep timer

AbortSignal теряется в транзакциях

Проброс + EventListener

JSON.stringify без сортировки ключей

Детерминированная сортировка


Если вы строите поиск на PostgreSQL и не хотите тащить внешние движки — код и документация здесь: pg-smart-search на GitHub🌟

Если сталкивались с «ошибками выжившего» в своих бенчмарках или есть другие трюки для PostgreSQL — пишите в комментариях, интересно сравнить опыт!

#PostgreSQL #NodeJS #Search #Backend #OpenSource #Performance #Database

ссылка на оригинал статьи https://habr.com/ru/articles/1048024/