Привет, Хабр!
В первой части мы разобрали архитектуру 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, вылизав каждую миллисекунду.
ЭТАП 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.
Про механику 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 мс |
Данных в 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 для тех, кто листает в конец
|
Проблема |
Решение |
|
|
Keyset (Cursor) Pagination |
|
|
skipTotalCount |
|
Триггеры на |
Generated Columns |
|
GIN + |
GiST + KNN оператор |
|
|
Заменили на |
|
Стратегии создаются на каждый запрос |
Кэширование в конструкторе |
|
|
Ring Buffer — O(1) |
|
Маппинг раскладки пересоздаётся |
Статическое поле |
|
MemoryCache без очистки |
Background sweep timer |
|
AbortSignal теряется в транзакциях |
Проброс + EventListener |
|
|
Детерминированная сортировка |
Если вы строите поиск на PostgreSQL и не хотите тащить внешние движки — код и документация здесь: pg-smart-search на GitHub🌟
Если сталкивались с «ошибками выжившего» в своих бенчмарках или есть другие трюки для PostgreSQL — пишите в комментариях, интересно сравнить опыт!
#PostgreSQL #NodeJS #Search #Backend #OpenSource #Performance #Database
ссылка на оригинал статьи https://habr.com/ru/articles/1048024/