OpenSearch проти PostgreSQL — Гібридний пошук без болю

Було корисно?

Ви не планували ставати «інженерами пошукової платформи». Ви просто хотіли, щоб поле пошуку у вашому продукті припинило вас соромити:
користувачі не знаходять те, що точно існує, затримки ростуть під час пакетних оновлень, і кожне «швидке покращення релевантності» перетворюється на вихідні.

Звичний вибір на перший погляд простий: «Чи можна залишити пошук у PostgreSQL?» чи «Нам потрібен OpenSearch?»
Реальна відповідь майже завжди: «Використовуйте обидва, але тільки якщо дисципліновано визначите, де живе істина і як працює конвеєр індексації.»

Рішення, яке вам насправді потрібно прийняти

«OpenSearch проти PostgreSQL» — хибна постановка питання. Насправді ви вирішуєте:

  • Де зберігається джерело істини? (Майже завжди в PostgreSQL.)
  • Які запити мають бути швидкими? Ключові слова, фрази, префікс, нечіткий пошук, фасети, векторна схожість, гібридний ранжування, гео, агрегати.
  • Яка відмовостійкість прийнятна? «Пошук застаріває на 5 хвилин» часто прийнятно. «Оформлення замовлення ламається» — ні.
  • Скільки операційних ручок може дозволити команда? PostgreSQL може виконувати пошук, але коли вам потрібне серйозне тонке налаштування релевантності та гібридне витягування в масштабі, OpenSearch виправдовує себе.

Ось відверта порада:

  • Лише PostgreSQL коли: набір даних невеликий, патерни запитів прості, і правильність пошуку важливіша за складну релевантність. Хочете менше рухомих частин.
  • Лише OpenSearch коли: ваш продукт може терпіти евентуальну узгодженість, і пошук — це ваш продукт. Базу ви все одно збережете десь, але не будете прикидатися, що це пошуковий механізм.
  • Гібрид (PostgreSQL + OpenSearch) коли: вам потрібне багате ранжування, агрегати або векторний пошук в масштабі, але також потрібна транзакційна істина і розумний аудит.

Одне висловлювання варте нагадування на стікері, коли просять «тільки одну систему»: «парафразована ідея» — Вернер Фогельс (CTO Amazon) давно пропагує підхід проектувати для доступності, приймаючи евентуальну узгодженість там, де вона допустима.
Пошук — одне з небагатьох місць, де такий компроміс зазвичай раціональний.

Історичні факти, що важливіші за слайди вендорів

Це не дрібниці. Вони пояснюють, чому інструменти поводяться саме так під навантаженням.

  1. Повнотекстовий пошук у PostgreSQL у складі ядра вже десятиліттями (tsvector/tsquery), і він оптимізований спочатку під транзакційні навантаження, а вже потім під пошук.
  2. Інвертована модель індексу Lucene (основа для Elasticsearch/OpenSearch) була створена для швидкого пошуку й скорингу, а не для ACID-оновлень по рядках.
  3. GIN-індекси в Postgres чудові для перевірки належності множини (наприклад, наявності токена), але можуть стати важкими в підтримці, коли текстові поля часто змінюються.
  4. Історія форку Elasticsearch має операційні наслідки: OpenSearch з’явився через зміну ліцензії; багато команд успадкували операційні звички Elasticsearch і застосували їх без змін — іноді правильно, іноді катастрофічно.
  5. Пошукові рушії трактують оновлення як «переіндексацію» внутрішньо: «оновлення документа» часто означає видалення + додавання. Через це часті малі оновлення створюють сегментний дрейф і тиск на злиття.
  6. BM25 став дефолтним механізмом релевантності для багатьох стеків; ранжувальні функції PostgreSQL корисні, але менш гнучкі у робочих процесах тонкого налаштування.
  7. Векторний пошук швидко став масовим: pgvector і kNN в OpenSearch з’явилися тому, що «пошук по ключовим словам» став недостатнім, коли ембеддинги стали дешевими.
  8. Розподілені шарди — це податок, а не безумовна перевага: масштабування OpenSearch через шарди дає пропускну здатність, але також вводить режими відмов (гарячі шарди, зсуви, хвилі релокацій), яких одиночна інстанція Postgres не породжує.

За що кожна система хороша (і погана)

Пошук в PostgreSQL: що вдається

  • Транзакційна правильність: результати пошуку відображають зафіксовані дані, якщо ви читаєте ту саму базу. Ніяких лагів синхронізації, якщо ви їх не додали.
  • Простота розгортання: один рушій, одна ланцюжок бекапу, один набір прав доступу.
  • Фільтрація з багатьма JOIN: коли «пошук» — це переважно структуровані фільтри з невеликим текстовим матчингом, Postgres часто швидший і простіший.
  • Аудит і відповідність: відновлення у певний момент часу, архівація WAL і суворий контроль доступу — звичні інструменти щоденної роботи.

Пошук в PostgreSQL: де боляче

  • Налаштування релевантності незручно: можна робити ранжування, ваги, словники, синоніми, але робочий процес менш ергономічний, ніж у спеціалізованих інструментах.
  • Фасети та агрегати в масштабі: так, SQL може агрігувати; ні, вам може не сподобатися латентність при високій кардинальності й багатьох одночасних запитах.
  • Гібридне витягування (ключові слова + вектори + повторне ранжування): можливо, але плани запитів перетворюються на машину Руба Голдберга.
  • Письмова ампліфікація: обслуговування GIN плюс часті оновлення та реалії vacuum можуть стати вашим щільним місцем продуктивності.

OpenSearch: що вдається

  • Швидке повнотекстове витягування: інвертовані індекси створені для цього.
  • Фасети/агрегати: terms-агрегації, діапазонні бакети та запити в стилі аналітики — першокласні можливості.
  • Інструменти релевантності: аналізатори, синоніми, бустинг, function_score та explain — для цього і створено продукт.
  • Горизонтальне масштабування: можна додавати вузли й перерозподіляти шарди. Це не безкоштовно, але можливо.
  • Векторні та гібридні патерни: kNN-пошук і гібридні шаблони тепер звичні в експлуатації.

OpenSearch: де боляче

  • Операційна складність: тюнінг JVM, розмір шардів, злиття сегментів, circuit breakers, тиск на heap, координація кластера.
  • Евентуальна узгодженість: інтервали refresh, пайплайни інжесту та ретраї означають, що «тут і зараз» — поняття розмите.
  • Переіндексація — стиль життя: змінюються mappings, аналізатори, синоніми. Ви будете переіндексувати. Плануйте це.

Жарт №1: Кластер пошуку схожий на хатнього кроля — тихий, милий, а потім раптом у вас 400 кроликів і ніхто не пам’ятає як з ними справлятися.

Реальність движка збереження

PostgreSQL зберігає рядки. OpenSearch зберігає документи. Звучить як маркетинг, але це важливо:

  • Часткові оновлення дешево в Postgres і часто дорого в OpenSearch (оновлення = переіндексація документа).
  • Великі текстові поля можуть роздувати TOAST у Postgres і підвищувати IO; в OpenSearch вони збільшують розмір сегментів і витрати на злиття.
  • Стиснення і поведінка злиття в OpenSearch — постійний фоновий процес. У Postgres — vacuum і autovacuum. Жоден з них не опційний.

Архітектури гібридного пошуку, які не карають вас пізніше

Патерн A: PostgreSQL — істина, OpenSearch — оптимізований під читання індекс

Класика. Пишете в Postgres. Стрімите зміни в OpenSearch. Додаток запитує OpenSearch за ID і ранжуванням, потім підтягує канонічні рядки з Postgres.
Це нудно. Нудьга масштабується.

  • Плюси: правильність в одному місці; відмови пошуку деградують плавно («застарілі результати» або «обмежений пошук»).
  • Мінуси: треба експлуатувати pipeline синхронізації та обробляти backfill.

Патерн B: Подвійне читання з фолбеком через feature-flag

Під час міграції з Postgres FTS в OpenSearch запускаєте обидва паралельно. Порівнюєте результати й латентність.
Роллаут контролюєте по орендарях, фічах або типам запитів.

  • Плюси: безпечніші міграції; дає A/B-тест релевантності.
  • Мінуси: подвійна вартість запитів; потребує уважного кешування та обмеження швидкості.

Патерн C: Postgres для фільтрів + OpenSearch для добірки, потім повторне ранжування

Коли структурні обмеження складні (права доступу, нарахування прав, часові вікна), Postgres може порахувати кандидатний набір або обмеження.
OpenSearch витягує кандидатів; reranker (можливо ML) переупорядковує.

  • Плюси: сильна правильність контролю доступу; «хто що бачить» залишається в SQL, де аудитори звикли дивитися.
  • Мінуси: може стати дорогим при великих наборах кандидатів; потрібно уважно визначати семантику пагінації.

Стратегії синхронізації: оберіть одну і зробіть її нудною

Більшість болю походить від «ми просто будемо їх синхронізувати» без деталей. Виберіть реальний механізм:

  • Транзакційний outbox: пишете рядок в таблицю outbox в тій же транзакції; воркер публікує в OpenSearch. Висока правильність, простий replay.
  • Логічна реплікація / CDC: транслюйте WAL-зміни й трансформуйте в документи. Потужно, але ви тепер експлуатуєте дата-пайплайн.
  • Пакетне відновлення щонічно: прийнятно для «каталогу» у деяких бізнесах; неприйнятно для безпеки/алертів/часочутливого пошуку.

Одне правило: проектуйте так, щоб можна було перемотати. Якщо ви не можете відтворювано перемотати й перестроїти індекс OpenSearch, у вас не система — у вас вайб.

Операційні реалії: SLO, режими відмов і нічні дзвінки

Визначте SLO, що відповідають архітектурі

Гібридна система має принаймні три групи латентності:

  • Лаг інжесту: час від коміту в Postgres до того, як документ стає доступним в OpenSearch.
  • Латентність запиту: P50/P95/P99 для пошукових запитів, плюс час на підживлення результатів з Postgres.
  • Правильність свіжості: як часто користувачі бачать застарілі або відсутні результати для щойно записаних даних.

Режими відмов OpenSearch, які слід очікувати

  • Тиск на heap веде до GC-штормів, які виглядають як випадкові сплески латентності, поки кластер не стане жовтим/червоним.
  • Гарячі шарди від перекосів рутінгу спричиняють «плавлення» одного вузла, поки інші бездіють.
  • Тиск злиттів під час великих швидкостей оновлень викликає IO-сплески і збільшує латентність запитів.
  • Вибухи мапінгів (dynamic fields) роздувають стан кластера і ламають усе демократичним способом.

Режими відмов PostgreSQL, які слід очікувати

  • Заборгованість autovacuum приводить до роздутих таблиць/індексів, повільних запитів і ризику wraparound ID транзакції.
  • Роздування GIN особливо для часто оновлюваних документів; індекс росте швидше за ваше терпіння.
  • Погані плани запитів через застарілі статистики або проблеми, схожі на parameter sniffing (generic vs custom plans).
  • Несподівані блокування від змін схеми або інтенсивних одночасних оновлень.

Жарт №2: Найшвидший спосіб покращити латентність пошуку — перейменувати дашборд з «P95» на «P50» і піти на обід.

Чого уникати як виклику на пейджер о 3:00

  • Не дозволяйте OpenSearch стати системою запису для всього, що цікавить комплаєнс.
  • Динамічні мапінги в продакшні, якщо вам не подобається відкривати «рядок чи число» конфлікти серед інциденту.
  • Переіндексація без вільної ємності. Переіндексація — це не хобі у вільний час; це стрес-тест, який ви самі запускаєте собі.
  • Фільтрація прав тільки в OpenSearch, якщо ваша модель авторизації нетривіальна. Тримайте права в Postgres і ставте пошук як движок підказок.

План швидкої діагностики

Ваш пошук повільний або неправильний. Не сперечайтеся про архітектуру в Slack. Перевірте вузьке місце за кілька хвилин.

Перше: це лаг інжесту чи проблема під час запиту?

  • Симптом: користувачі не знаходять нові елементи, але старі знаходяться → ймовірно лаг індексації.
  • Симптом: все знаходиться, але повільно/ненадійно → ймовірно проблема запитної стадії (OpenSearch/PG) або вузьке місце підживлення.

Друге: ізолюйте, який саме етап відмовляє

  1. App → OpenSearch: виміряйте сиру латентність пошукового виклику та відсоток помилок.
  2. Внутрішній стан OpenSearch: CPU, heap, GC, пул потоків, відхилення в чергах, злиття сегментів.
  3. Hydration → Postgres: повільні SQL, насичення пула підключень, промахи індексів, блокування, IO.
  4. Sync pipeline: відставання споживачів, черги dead-letter, ретраї, помилки bulk-запитів.

Третє: оберіть найшвидше безпечне пом’якшення

  • Лаг інжесту: призупиніть неважливі оновлення; обережно збільшіть розмір пакетів; додайте споживачів пайплайну; тимчасово підвищте інтервал refresh.
  • Перевантаження OpenSearch під час запитів: зменшіть складність запитів (менше агрегатів), додайте кешування, коригуйте кількість шардів тільки з планом або тимчасово маршрутизовуйте важких орендарів в інші кластери.
  • Перевантаження Postgres: обмежте фан-аут підживлення; робіть батчеві вибірки ID; додайте відсутні індекси; збільшуйте пул підключень тільки якщо БД це витримає.

Практичні завдання: команди, виводи та рішення

Це реальні завдання, які можна виконати під час налаштування або інциденту. Кожне включає, що означає вивід і яке рішення прийняти далі.

1) Перевірте здоров’я кластера OpenSearch і що насправді означає «yellow»

cr0x@server:~$ curl -s localhost:9200/_cluster/health?pretty
{
  "cluster_name" : "search-prod",
  "status" : "yellow",
  "number_of_nodes" : 3,
  "active_primary_shards" : 48,
  "active_shards" : 48,
  "unassigned_shards" : 48
}

Значення: первинні шарди розподілені, репліки — ні. Запити можуть працювати, але у вас немає резерву й є ризик під час ребалансу.
Рішення: не починайте реіндексацію; спочатку виправте розміщення (доступність вузлів, пороги диска, ліміти шардів) або тимчасово зменшіть кількість реплік.

2) Знайдіть причини не призначених шардів, перш ніж гадати

cr0x@server:~$ curl -s localhost:9200/_cat/shards?v
index             shard prirep state      docs store ip         node
products_v12      0     p      STARTED   9812  42mb  10.0.0.12  os-1
products_v12      0     r      UNASSIGNED

Значення: репліка не може бути розміщена.
Рішення: перегляньте explain для allocation; якщо це поріг диска — звільніть диск або додайте вузли; якщо ліміт шардів — зменшіть кількість шардів або свідомо збільште ліміти.

3) Поясніть розміщення шарду словами кластера

cr0x@server:~$ curl -s -H 'Content-Type: application/json' localhost:9200/_cluster/allocation/explain?pretty -d '{"index":"products_v12","shard":0,"primary":false}'
{
  "index" : "products_v12",
  "shard" : 0,
  "primary" : false,
  "current_state" : "unassigned",
  "can_allocate" : "no",
  "allocate_explanation" : "cannot allocate because allocation is not permitted to any of the nodes",
  "node_allocation_decisions" : [
    {
      "node_name" : "os-2",
      "node_decision" : "no",
      "deciders" : [
        {
          "decider" : "disk_threshold",
          "decision" : "NO",
          "explanation" : "the node is above the high watermark"
        }
      ]
    }
  ]
}

Значення: пороги диска блокують розміщення реплік.
Рішення: звільніть місце, додайте сховище або перемістіть шарди; не «вимикайте водяні знаки» просто так, якщо вам не подобається раптовий режим read-only індексів.

4) Перевірте навантаження ресурсів OpenSearch ззовні (Linux)

cr0x@server:~$ ps -o pid,cmd,%cpu,%mem --sort=-%mem | head
  PID CMD                         %CPU %MEM
 2178 /usr/share/opensearch/jdk   312.0 48.6
 1042 /usr/bin/node app-server     64.2  3.1

Значення: JVM OpenSearch споживає значну пам’ять і CPU.
Рішення: перевірте heap vs RSS; якщо RSS велике, можливо йде свопінг або сторінка кешу під тиском. Наступний крок: перевірити GC і використання heap.

5) Інспектуйте використання heap JVM через node stats

cr0x@server:~$ curl -s localhost:9200/_nodes/stats/jvm?pretty | head -n 25
{
  "nodes" : {
    "n1" : {
      "jvm" : {
        "mem" : {
          "heap_used_in_bytes" : 15234567890,
          "heap_max_in_bytes" : 17179869184
        },
        "gc" : {
          "collectors" : {
            "old" : {
              "collection_count" : 92,
              "collection_time_in_millis" : 184322
            }
          }
        }
      }
    }
  }
}

Значення: heap зайнятий приблизно на 88% з великим часом старого GC.
Рішення: зменшіть навантаження запитів/агрегацій, перевірте використання fielddata і подумайте про тюнінг heap лише після перевірки, що це не проблема мапінгу/агрегацій.

6) Виявлення відхилень в пулі потоків (тихий вбивця)

cr0x@server:~$ curl -s localhost:9200/_nodes/stats/thread_pool?pretty | grep -A3 rejected | head
          "search" : {
            "threads" : 13,
            "queue" : 1000,
            "rejected" : 421

Значення: пошукові запити відхиляються, бо черга заповнена.
Рішення: уповільніть клієнтів (circuit breaker), зменшіть фан-аут, зменшіть дорогі агрегати або додайте потужності. Не просто збільшуйте розмір черги і називайте це фіксом.

7) Перевірте тиск сегментів/злиттів (чому оновлення відчуваються як шліфування)

cr0x@server:~$ curl -s localhost:9200/_nodes/stats/indices/segments,merge?pretty | head -n 35
{
  "nodes" : {
    "n1" : {
      "indices" : {
        "segments" : {
          "count" : 2345,
          "memory_in_bytes" : 987654321
        },
        "merge" : {
          "current" : 12,
          "current_docs" : 1900000,
          "total_throttled_time_in_millis" : 842112
        }
      }
    }
  }
}

Значення: багато сегментів і злиттів, з накопиченим часом тротлінгу.
Рішення: коригуйте refresh_interval, стратегію bulk-ingest і шаблони оновлень. Розгляньте винесення полів, що часто змінюються, з документа або уважніше використовуйте денормалізацію.

8) Перевірте налаштування індексу, що впливають на свіжість та вартість інжесту

cr0x@server:~$ curl -s localhost:9200/products_v12/_settings?pretty | head -n 40
{
  "products_v12" : {
    "settings" : {
      "index" : {
        "refresh_interval" : "1s",
        "number_of_shards" : "12",
        "number_of_replicas" : "1"
      }
    }
  }
}

Значення: refresh_interval 1s чудовий для свіжості, але дорогий для інжесту.
Рішення: якщо інжест відстає, тимчасово підвищте refresh_interval (наприклад, до 10s–30s) і комунікуйте очікування щодо свіжості; поверніть назад після очищення беку.

9) Швидко перевірте блоут і стан vacuum у PostgreSQL

cr0x@server:~$ psql -d app -c "SELECT relname,n_live_tup,n_dead_tup,round(100.0*n_dead_tup/nullif(n_live_tup+n_dead_tup,0),2) AS dead_pct FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
  relname   | n_live_tup | n_dead_tup | dead_pct
------------+------------+------------+---------
products    |   12400123 |    3120099 |   20.11
events      |    9021123 |    2019921 |   18.29

Значення: кількість мертвих кортежів висока; vacuum може відставати.
Рішення: перевірте налаштування autovacuum, довгі транзакції та IO. Розгляньте ручний VACUUM (не FULL) у тихий вікно, якщо ситуація критична.

10) Підтвердіть, чи Postgres не втрачає індекс, який ви вважаєте наявним

cr0x@server:~$ psql -d app -c "\d+ products"
                                                  Table "public.products"
   Column   |           Type           | Collation | Nullable | Default | Storage  | Stats target | Description
------------+--------------------------+-----------+----------+---------+----------+--------------+-------------
 id         | bigint                   |           | not null |         | plain    |              |
 title      | text                     |           | not null |         | extended |              |
 body       | text                     |           |          |         | extended |              |
 search_tsv | tsvector                 |           |          |         | extended |              |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

Значення: немає GIN-індексу на search_tsv.
Рішення: додайте правильний індекс перед тим, як звинувачувати залізо. Також перевірте, як підтримується search_tsv (тригер, згенерований стовпець чи батч).

11) Правильно поясніть повільний запит пошуку в Postgres

cr0x@server:~$ psql -d app -c "EXPLAIN (ANALYZE,BUFFERS) SELECT id FROM products WHERE search_tsv @@ plainto_tsquery('english','wireless headphones') ORDER BY ts_rank(search_tsv, plainto_tsquery('english','wireless headphones')) DESC LIMIT 20;"
                                                         QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..152.33 rows=20 width=8) (actual time=812.221..812.260 rows=20 loops=1)
   Buffers: shared hit=120 read=9812
   ->  Index Scan using products_search_tsv_gin on products  (cost=0.43..9821.33 rows=1290 width=8) (actual time=812.219..812.248 rows=20 loops=1)
         Index Cond: (search_tsv @@ plainto_tsquery('english'::regconfig, 'wireless headphones'::text))
 Planning Time: 2.012 ms
 Execution Time: 812.312 ms

Значення: значні дискові читання (read=9812) домінують; індекс є, але дані не в кеші.
Рішення: розгляньте прогрів кешу для «гарячих» запитів, додайте RAM, зменшіть робочий набір або перенесіть це навантаження в OpenSearch, якщо конкуруючих запитів стане більше.

12) Перевіряйте повільні запити Postgres в реальному часі

cr0x@server:~$ psql -d app -c "SELECT pid,now()-query_start AS age,wait_event_type,wait_event,substr(query,1,80) AS q FROM pg_stat_activity WHERE state='active' ORDER BY age DESC LIMIT 8;"
 pid  |   age   | wait_event_type |  wait_event   |                                        q
------+---------+-----------------+---------------+--------------------------------------------------------------------------------
 8123 | 00:01:12| IO              | DataFileRead  | SELECT id FROM products WHERE search_tsv @@ plainto_tsquery('english','wirel...
 8344 | 00:00:44| Lock            | tuple         | UPDATE products SET body = $1 WHERE id = $2

Значення: присутні як IO-очікування, так і блокування рядків.
Рішення: розділіть «читання/запис» гарячі точки; розгляньте винос важких записів із таблиць, що підтримують пошук, або від’єднання індексації від OLTP-шляхів.

13) Перевірте лаг реплікації (якщо підживлення читає репліку)

cr0x@server:~$ psql -d app -c "SELECT application_name,state,write_lag,flush_lag,replay_lag FROM pg_stat_replication;"
 application_name |   state   | write_lag | flush_lag | replay_lag
------------------+-----------+-----------+-----------+-----------
 app-read-replica  | streaming | 00:00:00  | 00:00:01  | 00:00:08

Значення: репліка відстає в відтворенні на 8 секунд.
Рішення: якщо користувачі скаржаться на «відсутні предмети після створення», перевірте чи підживлення читає з репліки. Маршрутуйте критичні читання свіжості на primary або явно прийміть застарілість.

14) Перевірте backlog вашого синхронізаційного пайплайну (таблиця outbox)

cr0x@server:~$ psql -d app -c "SELECT count(*) AS pending, min(created_at) AS oldest FROM search_outbox WHERE processed_at IS NULL;"
 pending |         oldest
---------+------------------------
  184221 | 2026-02-04 08:11:03+00

Значення: у вас великий backlog і він старий.
Рішення: призупиніть неважливих продюсерів, масштабируйте споживачів і перевірте помилки bulk в OpenSearch. Якщо backlog росте під нормальним навантаженням — ви спроектували пайплайн оптимістично, а не реалістично.

15) Перевірте помилки bulk-ingest в OpenSearch (не довіряйте «200 OK»)

cr0x@server:~$ curl -s -H 'Content-Type: application/json' localhost:9200/_bulk -d $'{"index":{"_index":"products_v12","_id":"42"}}\n{"title":"x","price":"not-a-number"}\n' | head
{"took":7,"errors":true,"items":[{"index":{"_index":"products_v12","_id":"42","status":400,"error":{"type":"mapper_parsing_exception","reason":"failed to parse field [price] of type [float]"}}}]}

Значення: bulk API прийняв запит, але один елемент упав через конфлікт мапінгу.
Рішення: надсилайте невдалі елементи в dead-letter-потік; виправте мапінг і переіндексуйте. Якщо ігнорувати це, індекс стане мовчазно неповним.

16) Підтвердіть мапінги полів OpenSearch, щоб уникнути «сюрпризів динамічного мапінгу»

cr0x@server:~$ curl -s localhost:9200/products_v12/_mapping?pretty | head -n 40
{
  "products_v12" : {
    "mappings" : {
      "properties" : {
        "title" : { "type" : "text" },
        "price" : { "type" : "float" }
      }
    }
  }
}

Значення: price — float; інжест має відповідати цьому.
Рішення: закріпіть мапінги і перевіряйте дані на боці продюсера. «Нехай OpenSearch сам розбереться» працює, поки не перестане — і тоді він ніколи не зламається ввічливо.

Міні-історії з корпоративного світу (анонімізовані)

Міні-історія 1: Інцидент через неправильне припущення

Середня B2B SaaS-компанія перенесла «глобальний пошук» з PostgreSQL FTS в OpenSearch, щоб підтримати фасети та швидші префіксні запити.
Вони робили міграцію як зазвичай: новий сервіс, новий індекс, CDC-пайплайн і тріумфальний дашборд з упавшою латентністю запитів.

Потім з’явилися тікети підтримки: «Я створив запис і пошук його не знаходить». Менеджер інженерів припустив, що то користувач помилився або кеш.
Команда вдвічі збільшила частоту refresh до 500ms і заспокоїлася. Тікети продовжилися.

Неправильне припущення було тонким: вони думали, що CDC-пайплайн «майже в реальному часі», бо обробляв повідомлення швидко, коли черга була пуста.
Під час піку споживання споживач outbox відставав на хвилини. Інтервали refresh не виправляють відсутніх документів, які взагалі не були індексовані.

Фікс був не хитромудрим. Додали моніторинг лагу, масштабували споживачів і зробили інтерфейс чесним: «Нові елементи можуть з’являтися до N хвилин».
Для кількох робочих потоків, де важлива була свіжість, додаток робив запити напряму в PostgreSQL у короткому «щойно створеному» вікні.

Урок: інтервал refresh не гарантія узгодженості. Це контроль видимості після інжесту. Ваш пайплайн тепер є межею узгодженості.

Міні-історія 2: Оптимізація, що відкотилася

Інша організація мала мульти-орендний маркетплейс. Пошук «був нормальний», поки не додали семантичний пошук з ембеддингами.
Хтось запропонував оптимізацію: покласти все в один індекс OpenSearch з агресивним dynamic mapping і маршрутизувати за tenant ID для локальності.
Це прекрасно працювало в стадії. Стадія — магічне місце, де дані ніколи не сперечаються.

В продакшні поведінка орендарів розійшлася. Один орендар почав завантажувати документи з об’єктом «metadata», що містив сотні ключів, які різнилися в кожному записі.
Динамічний мапінг створював поля як за оплату за поле. Стан кластера виріс, використання heap підскочило, і rolling restarts почали падати, бо вузли довго приєднувалися.

Вони намагалися «оптимізувати» далі збільшенням heap. Це дало час, потім збільшило паузи GC.
Тим часом налагодження релевантності стало неможливим, бо потреби аналізаторів різних орендарів конфліктували в одному мапінгу.

Остаточний фікс: перейти на контрольовану схему. Флаттенізувати metadata в одне keyword-поле при потребі і явно білити ключі, які індексуються.
Деякі високонавантажені орендарі отримали власні індекси з окремими lifecycle-політиками. Система стала трохи менш елегантною, але значно стабільнішою.

Урок: динамічний мапінг + мультиорендна варіативність — це баг масштабування, а не фіча. Гнучкість коштує стабільності кластера.

Міні-історія 3: Нудна практика, що врятувала день

Компанія, близька до платіжної сфери, тримала PostgreSQL як істину і OpenSearch для пошуку. У них була одна звичка, що виглядала як надмірність: кожна версія індексу була незмінною.
products_v10, products_v11, products_v12. Додаток запитував alias products_current.

Розробнику потрібно було додати синоніми й змінити аналізатори для кращої релевантності. Це вимагало реіндексації.
Замість «оновлення індексу на місці» вони побудували products_v13 паралельно, перевірили кількість документів, запустили порівняння запитів і атомарно переключили alias.

Під час розгортання виявили баг інжесту: поле price іноді приходило як «N/A». Новий індекс відкидав ці документи. Старий індекс досі обслуговував трафік.
Вони виправили продюсера, переписали невдалі записи з outbox, перебудували v13 і знову переключили alias. Користувачі майже нічого не помітили, окрім поступового покращення релевантності.

Урок: незмінні версії індексів + alias cutover не гламурні, але перетворюють «реіндексацію» з кризи на рутину.

Типові помилки: симптом → корінь → виправлення

1) «Пошук повільнішає тільки коли додаємо агрегати»

  • Симптоми: P95 злітає для фасетних запитів; CPU підскакує; відхилення в пулі потоків OpenSearch.
  • Корінь: агрегати з високою кардинальністю по неаналізованих полях, занадто багато шардів або недостатній heap для структур агрегування.
  • Виправлення: зменшити кардинальність агрегатів, використовувати keyword-поля, додати filter context, попередньо обчислювати фасети або розділити індекси за орендарями/категоріями. Перевірити статистики пулу потоків і heap.

2) «Нові записи не з’являються, але OpenSearch зелений»

  • Симптоми: відсутні свіжі дані; стан кластера ок; помилок в запитах немає.
  • Корінь: лаг інжесту, помилки bulk, накопичення dead-letter, або занадто великий refresh_interval для очікувань.
  • Виправлення: моніторити відставання outbox/CDC, інспектувати bulk-відповіді на помилки, реалізувати replay. Встановити й комунікувати SLO свіжості.

3) «Postgres FTS був швидкий, потім повільно з часом»

  • Симптоми: зростання латентності; високі IO-читання; таблиці й GIN-індекси ростуть непропорційно.
  • Корінь: блоут від оновлень/видалень + борг vacuum; зростання списку GIN pending; довгі транзакції, що блокують vacuum.
  • Виправлення: налаштувати autovacuum, розбивати довгі транзакції, розглянути REINDEX або перебудову GIN під час технічного вікна, і робити підтримку search_tsv ефективною.

4) «Рестарти вузлів OpenSearch викликають каскад таймаутів»

  • Симптоми: rolling restart викликає yellow/red; запити таймаутять; релокації шардів насичують мережу/диск.
  • Корінь: надто багато шардів, недостатній запас диска, неправильно налаштовані throttle релокацій або недоступні репліки.
  • Виправлення: зменшити кількість шардів (більші шарди, менше їх), забезпечити запас диска, планувати rolling restart поетапно і тримати репліки здоровими.

5) «Ми налаштували релевантність і тепер результати неправильні»

  • Симптоми: точні співпадіння заховані; бренд-терміни ігноруються; непередбачувані зміни ранжування.
  • Корінь: зміни аналізатора без реіндексації, синоніми застосовані некоректно під час запиту, невідповідності multi-field mapping.
  • Виправлення: версіонувати аналізатори й індекси, тестувати з explain, підтримувати «золоті» запити і розгортати через alias flips та порівняння результатів.

6) «Наш гібридний запит дублює або втрачає результати при пагінації»

  • Симптоми: користувач бачить повтори на 2-й сторінці; відсутні елементи; непослідовне впорядкування.
  • Корінь: нестабільні ключі сорту між OpenSearch і підживленням з Postgres, використання пагінації з offset під час конкурентних записів.
  • Виправлення: використовуйте search_after або курсорну пагінацію в OpenSearch; підживляйте за стабільними ID; тримайте детермінований вторинний сортувальний ключ (наприклад _score + id).

Чеклісти / покроковий план

Покроково: як обрати Postgres-only vs OpenSearch vs hybrid

  1. Запишіть типи запитів: ключове слово, фраза, префікс, нечіткий пошук, фасети, гео, вектор, гібридне ранжування.
  2. Запишіть вимоги до свіжості: «секунди», «хвилини», «погодинно». Будьте конкретними.
  3. Визначте поведінку при відмові: що трапиться, якщо пошук впаде або застаріє?
  4. Оцініть churn даних: чи документи часто оновлюються або переважно додаються?
  5. Прототипуйте найскладніший запит в обох системах і виміряйте P95 під конкуренцією, а не лише одною сесією.
  6. Виберіть найпростішу архітектуру, яка відповідає SLO. Не ту, що найкрутіша.

Покроково: побудова безпечного гібридного пайплайну

  1. Тримайте Postgres як істину: не допускайте, щоб OpenSearch став єдиною копією критичних полів.
  2. Реалізуйте таблицю outbox з надійним курсором і підтримкою replay.
  3. Зробіть індексацію ідемпотентною: ID документа походить від первинного ключа; оновлення перезаписують.
  4. Використовуйте bulk-indexing з backoff і механізмом dead-letter.
  5. Версіонуйте індекси і використовуйте alias для cutover.
  6. Моніторьте лаг: вік backlog outbox, rate помилок bulk, відхилення пулу потоків OpenSearch, лаг реплікації Postgres якщо релевантно.
  7. Навантажте тестом інжест + запит разом: кластер, що індексує добре, може погано відповідати на запити, коли починаються злиття.

Покроково: реіндексація без драми

  1. Створіть нову версію індексу з явними мапінгами й налаштуваннями.
  2. Бекфіл з Postgres контрольованими батчами.
  3. Подвійно пишіть нові зміни (або реплейте outbox) в обидва індекси.
  4. Валідуйте кількість документів, вибіркові запити й логи bulk-помилок.
  5. Атомарно переключіть alias на новий індекс.
  6. Тримайте старий індекс для відкату поки впевненість не висока; потім видаліть.

Питання й відповіді

1) Чи може PostgreSQL забезпечити «достатньо хороший» пошук для каталогу продуктів?

Так, якщо ваші потреби переважно точні або близькі до точних пошуків ключових слів плюс фільтри, і ваш масштаб/чейн помірний.
Коли потрібне важке фасетування, нечіткий матчинг, синоніми в масштабі або гібридне векторне + ключове ранжування, OpenSearch стає здоровим вибором.

2) Якщо я додам pgvector, чи потрібен мені ще OpenSearch?

Іноді ні. pgvector чудовий, коли ви хочете ембеддинги поряд із транзакційними даними і можете терпіти вартість запиту.
Але OpenSearch зазвичай перемагає, коли потрібне комбіноване ранжування, агрегати і продуктивність витягування під високою конкуренцією.

3) Чому не зберегти все в OpenSearch і не обходитися Postgres?

Тому що ви відтворите транзакційні гарантії підручними засобами. OpenSearch — не OLTP-база.
Тримайте істину в Postgres (або іншій БД), якщо домен не допускає евентуальної узгодженості для всього, що вам важливо.

4) Яка найбільша операційна пастка з OpenSearch?

Розростання шардів і неконтрольовані мапінги. Надто багато шардів створює накладні витрати координації; динамічні поля роздувають стан кластера і пам’ять.
Обидві речі «гниють повільно, а потім одразу ламаються».

5) Яка найбільша операційна пастка з пошуком тільки в Postgres?

Управління vacuum і блоутом, особливо для GIN-індексів на часто оновлюваних текстах.
Postgres справиться, але вам потрібно ставитися до vacuum як до першокласного продакшн-завдання, а не як до фонової казки.

6) Як зробити гібридний пошук достатньо консистентним для користувачів?

Визначте SLO свіжості, вимірюйте лаг пайплайну і забезпечте плавні фолбеки.
Для робочих процесів, що потребують негайної видимості, робіть прямі запити в Postgres для щойно створених записів або показуйте статус «індексація в процесі».

7) Як пагінувати у гібридній системі?

Віддавайте перевагу курсорній пагінації OpenSearch (search_after) зі стабільним сортуванням, потім підтягуйте ці ID у Postgres одним батчевим запитом.
Уникайте offset-пагінації на змінних наборах даних, якщо вам подобаються дублікати.

8) Чи потрібен окремий індекс OpenSearch для кожного орендаря?

Не завжди. Окремі індекси на орендаря можуть роздмухати кількість шардів. Спільний індекс з routing може працювати, але тільки при строгому контролі схеми.
Високонавантажені або з великою варіативністю орендарі часто виправдовують окремі індекси.

9) Як зрозуміти, чи треба підвищити refresh_interval в OpenSearch?

Якщо інжест відстає, злиття тротлінгуються і користувачі готові терпіти трохи застарілості, підвищуйте тимчасово.
Якщо ваш продукт обіцяє «між миттєвим пошуком», підвищення просто перемістить скарги з ops в support.

10) Чи потрібно підживлювати результати з Postgres щоразу?

Часто так для правильності й контролю доступу, але не завжди для продуктивності. Багато систем зберігають достатньо денормалізованих полів в OpenSearch для показу.
Правило: якщо поле має бути правильним і актуальним — підживлюйте або перевіряйте його в Postgres.

Наступні кроки, які можна зробити цього тижня

  • Визначте джерело істини: запишіть це. Якщо це не Postgres, будьте явними щодо того, що ви ризикуєте.
  • Додайте метрики лагу для вашого пайплайну індексації (вік backlog outbox, пропускна здатність споживачів, rate помилок bulk).
  • Програйте план швидкої діагностики раз у спокійний день, а не під час інциденту. Зафіксуйте базові показники.
  • Версіонуйте ваш індекс і реалізуйте alias cutover, якщо ще не зробили. Це різниця між «зміною» і «інцидентом».
  • Виберіть три золоті запити і відслідковуйте їх у CI або запланованій задачі з латентністю й перевіркою топ-результатів.
  • Зменшіть несподіванки: вимкніть dynamic mapping де можливо, явно мапуйте поля і перевіряйте типи даних на боці продюсера.

Якщо ви вагаєтеся: почніть з Postgres-only, якщо сьогодні ви можете досягти цілей релевантності і латентності. Перейдіть до гібриду, коли зможете назвати конкретні патерни запитів, що цього вимагають.
Не приймайте OpenSearch лише тому, що хтось хоче синоніми. Приймайте його тому, що ваша система потребує пошукового рушія, а не ще один кластер для догляду.

← Попередня
Зміцнення Windows для домашніх лабораторних серверів: мінімальні зміни, максимальний ефект
Наступна →
Найкращі материнські плати для чистих IOMMU груп: на що звернути увагу перед покупкою

Залишити коментар