PostgreSQL vs OpenSearch: гібридна пошукова конфігурація, яка справді працює

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

У вас є поле для пошуку товарів, яке «добре» до першого інциденту. Раптом клієнти не можуть знайти те, що щойно створили.
Пишуть у підтримку. Керівництво запитує, чому результати пошуку «випадкові». Інженери наполягають, що це «остаточна узгодженість».
А SRE застряг по середині, тримає пейджер і недописаний постмортем.

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

Що насправді означає «гібридний пошук» (і що — ні)

«Гібридний пошук» — термін, що часто перевантажений. У слайдах вендорів він часто означає поєднання векторного й ключового пошуку.
У продакшн‑системах під цим частіше мають на увазі: PostgreSQL залишається системою запису (source of truth), OpenSearch обслуговує запити пошуку,
а ви запускаєте конвеєр, щоб тримати їх досить синхронізованими, щоб користувачі довіряли результатам.

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

Гібридний пошук — це дві проблеми, а не одна

  • Коректність: «Чи можна повертати цей об’єкт? Чи існує він? Чи має користувач доступ?»
  • Релевантність: «Серед дозволених об’єктів — які найкраще відповідають і як їх ранжувати?»

PostgreSQL дуже добре справляється з коректністю та реляційними обмеженнями. OpenSearch — з релевантністю й пошуком по великих текстових полях та гнучким ранжуванням.
Коли команди намагаються змусити одну систему робити обидва завдання, вони платять за це латентністю, складністю або довірою. Зазвичай за всіма трьома.

Дві жарти — бо продакшн інколи потребує гумору

Жарт 1: Пошук — це просто запит до бази даних з власними думками. На жаль, ці думки часто мають вимоги до доступності.

PostgreSQL vs OpenSearch: різні контракти, різні режими відмов

Контракт PostgreSQL

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

PostgreSQL може робити повнотекстовий пошук. Для багатьох робочих навантажень цього достатньо. Але є обмеження: стемінг і ранжування менш гнучкі;
горизонтальне масштабування складніше; тонке налаштування релевантності можливе, але клопітке; і ви відчуєте біль, коли продукт почне вимагати
«пошук як у споживчому додатку» поверх реляційних схем.

Контракт OpenSearch

OpenSearch (і його спадкоємність від Elasticsearch) — розподілений пошуковий движок, спроєктований для витягання й ранжування в масштабі.
Він охоче прийме денормалізовані документи, токенізує текст, обчислить релевантні оцінки й швидко відповість на складні запити — за умови, що ви подасте стабільні mappings,
контролюватимете стратегію шардінгу й не будете випадково використовувати його як транзакційну базу даних.

OpenSearch не обіцяє транзакційну узгодженість з вашою основною базою даних. Він обіцяє near‑real‑time індексацію та стійкість на рівні кластера,
засновану на реплікації, злитті сегментів і своїх власних журналах. Якщо вам потрібна «read‑after‑write» коректність між системами — ви повинні її спроєктувати.

Цікаві факти й контекст (бо історія прогнозує аутеджі)

  1. Родовід PostgreSQL іде від проєкту POSTGRES в UC Berkeley у 1980‑х; його спрямованість на коректність і розширюваність — не випадкова.
  2. Повнотекстовий пошук у PostgreSQL став фічею першого класу у середині 2000‑х і поступово доростав; але це все ще можливість реляційної БД, а не фундаментальна сутність пошукового движка.
  3. Lucene (бібліотека під Elasticsearch і OpenSearch) почався близько 1999 року; його припущення — «документи й інвертовані індекси», а не «таблиці й join‑и».
  4. Near‑real‑time індексація — це свідомий компроміс: документи стають доступними після refresh, а не негайно після запису, якщо ви не платите за частіші refresh‑і.
  5. Видалення «type» в Elasticsearch (ера 7.x) змусило багато команд переглянути дизайн схем/mapping‑ів; OpenSearch успадкував цей урок: mappings — це контракт, і ламати його дорого.
  6. Звична практика «soft delete на рівні застосунку» (булевий прапор) може непомітно отруїти релевантність і коректність пошуку, якщо фільтри не застосовуються послідовно в обох системах.
  7. Outbox‑патрн набув популярності, коли команди обпікались на проблемах подвійного запису; тепер це стандартна відповідь на «утримувати дві системи в синхроні», коли ви не хочете втратити записи.
  8. Кластери пошуку ламаються в дивних фізичних сценаріях: водяні маркери диска переводять індекс у режим тільки для читання, злиття сегментів насичує IO, а «проста» зміна mapping‑у може спричинити масштабний реіндекс.

Якщо ви запам’ятаєте лише одне: PostgreSQL — ваш бухгалтерський реєстр. OpenSearch — ваш каталог. Не намагайтеся платити податки за допомогою каталогу.

Правила вибору: коли питати Postgres, коли — OpenSearch

Користуйтеся PostgreSQL, коли

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

Користуйтеся OpenSearch, коли

  • Потрібне ранжування за релевантністю, нечіткість, синоніми, стемінг, підсилення (boosting), «мабуть, ви мали на увазі» або багатопольовий текстовий пошук.
  • Потрібно швидко витягувати інформацію по великих текстах для багатьох користувачів одночасно.
  • Ви можете прийняти eventual consistency або спроєктувати UX/флоу з урахуванням цього.
  • Потрібні агрегації по величезним наборам результатів, де пошуковий движок показує свої переваги.

Гібридне правило, яке вас врятує

Використовуйте OpenSearch для отримання candidate IDs і оцінок; використовуйте PostgreSQL для забезпечення правди й авторизації.
Це означає, що ваш Search API часто стає двоетапним: запит до OpenSearch → список ID → вибірка з Postgres (й фільтрація). Коли важлива продуктивність, ви оптимізуєте це з’єднання ретельно.
Коли важлива коректність — ніколи не пропускайте його без серйозних підстав.

Якщо вам кортить зберігати права в OpenSearch і ніколи не перевіряти Postgres: ви будуєте інцидент безпеки з гарним UI.

Референсна архітектура, що витримує продакшн

Рухомі компоненти

  • PostgreSQL: джерело правди. Записи відбуваються тут. Транзакції тут мають значення.
  • Outbox‑таблиця: надійний запис «що треба індексувати», що пишеться в тій самій транзакції, що й бізнес‑запис.
  • Індексер‑воркер: читає outbox, отримує повний стан сутності (або проєкцію), записує в OpenSearch, позначає outbox як оброблений.
  • OpenSearch: денормалізовані пошукові документи. Налаштовані mappings. Контрольована кількість шард.
  • Search API: запитує OpenSearch за кандидатами; гідрує з Postgres; повертає результати.
  • Backfill / reindex job: масове відновлення індексу з дампів Postgres або консистентних зчитувань.
  • Набір метрик: лаги, бюджет помилок, логи повільних запитів, пропускна здатність індексації та стан кластера.

Чому outbox кращий за «просто опублікувати евент»

Outbox‑патерн — це неефектний друг, що приходить вчасно. Ви записуєте рядок у бізнес‑таблицю й рядок в outbox у тій самій транзакції бази.
Якщо транзакція комітиться — outbox‑рядок існує. Якщо відкат — його немає. Усе саме в цьому: немає втрачених оновлень через «DB commit пройшов, але публікація в Kafka впала», або навпаки.

Система CDC (логічна реплікація, Debezium тощо) теж може працювати. Але вам все одно треба управляти порядком подій, видаленнями, змінами схеми та семантикою відтворення.
Outbox простіше для багатьох команд, особливо коли індексер має обчислювати проєкцію все одно.

Як обробляти видалення чесно

Видалення — це місце, де гібридні системи тихо помирають. Ви маєте вирішити:

  • Hard delete в Postgres + видалення документу в OpenSearch.
  • Soft delete в Postgres + фільтр в обох системах + відкладена задача на остаточне очищення.

Найбезпечніша операційна позиція: завжди вважайте Postgres авторитетним. Якщо OpenSearch повертає candidate ID, який більше не існує або видалений, ваш крок гідратації відкидає його.
Це менш «ефективно», але значно більше «спокійно для сну».

Одна цитата про надійність (перефразована ідея)

Перефразована ідея — John Allspaw: надійність визначається тим, як системи поводяться під навантаженням, а не тим, як вони виглядають на діаграмах.

Моделювання даних: джерело правди, денормалізація і чому join-и не місце у пошуку

Проєктуйте документ OpenSearch як проєкцію

Ваш документ для OpenSearch має бути стабільною проєкцією сутності так, як її шукає користувач. Це зазвичай означає:
сплющені поля, дубльовані дані (денормалізація) і кілька обчислених полів для релевантності (наприклад «popularity_score»,
«last_activity_at» або «title_exact»).

Проєкція має генеруватися кодом, який ви можете версіонувати й тестувати. Якщо ваш індексер робить «SELECT * і сподіваюсь», ви відправите в продакшн
бомбу з mapping‑ів і регресії якості.

Не моделюйте реляційні join‑и в OpenSearch, якщо вам не подобаються сюрпризи з продуктивністю

OpenSearch має можливості, схожі на join (nested, parent‑child). Вони можуть бути доречними. Водночас їх легко неправильно використовувати й складно експлуатувати в масштабі.
У більшості робочих навантажень продуктового пошуку вам буде краще денормалізувати в один документ на шукану сутність і заплатити вартість індексації один раз.

Коли пов’язаний об’єкт змінюється (наприклад, назва організації), ви реіндексуєте впливові документи. Це відома ціна. Ви плануєте її й обмежуєте швидкість.
Не імітуйте, що цього не станеться.

Стабільність mapping‑ів — це турбота SRE

Ставтеся до mapping‑ів як до міграцій бази даних. Нехлюйна зміна mapping‑у може спричинити:

  • Несподівані конфлікти типів полів (індекс відкидає документи).
  • Імпліцитні динамічні поля, що роздмухують розмір індексу й тиск на heap.
  • Дорогу реіндексацію, яка конкурує з продакшн‑трафіком.

Установіть dynamic mapping свідомо. Більшість production‑команд не повинні дозволяти вільний доступ до створення полів. «Але це зручно» — це шлях до індексу,
що містить 40 версій одного й того ж поля з різним написанням.

Узгодженість, затримка та єдине SLA, що має значення

Визначте «свіжість пошуку» як вимірюваний SLO

Ваше реальне SLA: за який час після запису користувач може знайти об’єкт через пошук. Це не те саме, що p99‑латентність API.
Це SLO для конвеєра.

Ви вимірюєте це як лаг: час запису vs час індексації vs час появи у пошуку. Потім вирішуєте, що прийнятно (наприклад, 5 секунд, 30 секунд, 2 хвилини).
Якщо продукт потребує «мгновенно», реалізуйте UX, що покриває розрив: показуйте новий об’єкт безпосередньо після створення, обходячи пошук для цього флоу,
і не навчайте користувачів, що пошук — єдина істина.

Інтервали refresh: ручка, що коштує грошей

Менший інтервал refresh означає, що документи стають доступними швидше, але це збільшує churn сегментів і навантаження на IO.
Збільшення інтервалу refresh підвищує пропускну здатність індексування й зменшує навантаження, але робить пошук менш «свіжим».
Налаштовуйте його, виходячи з очікувань користувача й обсягу записів. І не міняйте його під час інциденту, якщо не розумієте наслідків.

Запобіжники коректності

  • Фільтрація авторизації: застосовуйте на етапі гідратації в Postgres або ретельно реплікуйте правила доступу й тестуйте їх.
  • Видалені/приховані елементи: фільтруйте всюди; вважайте Postgres остаточною істиною.
  • Межі мульти‑тенантності: tenant_id має бути первинним фільтром в запитах OpenSearch і ключем у вибірці з Postgres.

Жарт 2: Eventual consistency — чудова річ, поки ваш CEO не шукає те, що створив п’ять секунд тому. Тоді це перетворюється на «аутедж».

Практичні завдання (команди + виводи + рішення)

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

Завдання 1: Перевірити реплікацію/навантаження записів Postgres (чи відстаємо ще до початку індексації?)

cr0x@server:~$ psql -d appdb -c "select now(), xact_commit, xact_rollback, blks_read, blks_hit from pg_stat_database where datname='appdb';"
              now              | xact_commit | xact_rollback | blks_read | blks_hit 
-------------------------------+-------------+---------------+-----------+----------
 2025-12-30 18:41:12.482911+00 |    19403822 |         12031 |   3451201 | 98234410
(1 row)

Значення: Якщо кількість комітів росте, але cache hits падають (blks_read зростає), ви робите більше фізичних читань — часто ознака IO‑тиску.

Рішення: Якщо IO гарячий — призупиніть реіндексацію/backfill, перевірте індекси і розгляньте read‑replica для навантаження гідратації.

Завдання 2: Знайти запити Postgres, що домінують за часом (гідратація часто винна)

cr0x@server:~$ psql -d appdb -c "select query, calls, total_time, mean_time, rows from pg_stat_statements order by total_time desc limit 5;"
                       query                        | calls  | total_time | mean_time |  rows  
----------------------------------------------------+--------+------------+-----------+--------
 select * from items where id = $1 and tenant_id=$2  | 982144 |  842123.11 |     0.857 | 982144
 select id from items where tenant_id=$1 and ...     |   1022 |  221000.44 |   216.243 | 450112
 ...
(5 rows)

Значення: Гідратація за первинним ключем має бути швидкою. Якщо вона домінує за часом, ви, можливо, робите її занадто часто або бракує індексу на (tenant_id, id).

Рішення: Бачьте гідратацію пакетами (WHERE id = ANY($1)) і додавайте композитні індекси, вирівняні з межами tenant.

Завдання 3: Підтвердити існування й використання критичного індексу Postgres

cr0x@server:~$ psql -d appdb -c "explain (analyze, buffers) select * from items where tenant_id='t-123' and id=987654;"
                                                       QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
 Index Scan using items_tenant_id_id_idx on items  (cost=0.42..8.44 rows=1 width=512) (actual time=0.041..0.042 rows=1 loops=1)
   Index Cond: ((tenant_id = 't-123'::text) AND (id = 987654))
   Buffers: shared hit=5
 Planning Time: 0.228 ms
 Execution Time: 0.072 ms
(6 rows)

Значення: Index Scan + низький час виконання + buffers hit означає, що гідратація здорова по цьому шляху.

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

Завдання 4: Перевірити lag outbox (SLO свіжості в одному запиті)

cr0x@server:~$ psql -d appdb -c "select count(*) as pending, max(now()-created_at) as max_lag from search_outbox where processed_at is null;"
 pending |   max_lag   
---------+-------------
   18234 | 00:07:41.110
(1 row)

Значення: 18k в очікуванні і max lag ≈ 8 хвилин: pipeline індексації відстає. Користувачі помітять це.

Рішення: Масштабуйте воркери індексації, перевірте latency інжесту в OpenSearch і впевніться, що консумери outbox не застрягли на «poison pills».

Завдання 5: Виявити poison‑pill записи в outbox (застряглі retries)

cr0x@server:~$ psql -d appdb -c "select id, entity_id, attempts, last_error, updated_at from search_outbox where processed_at is null and attempts >= 10 order by updated_at asc limit 10;"
  id  | entity_id | attempts |           last_error            |         updated_at         
------+-----------+----------+---------------------------------+----------------------------
 9981 |  7712331  |       14 | mapping conflict on field tags  | 2025-12-30 18:20:01+00
 ...
(10 rows)

Значення: Повторювані помилки mapping conflict не «транзієнтні». Це помилки схеми.

Рішення: Карантинуйте ці записи, виправте проєкцію/mapping і переробіть їх після контрольованого фіксу.

Завдання 6: Перевірити здоров’я кластера OpenSearch (red/yellow — це не «просто колір»)

cr0x@server:~$ curl -s http://opensearch.service:9200/_cluster/health?pretty
{
  "cluster_name" : "search-prod",
  "status" : "yellow",
  "number_of_nodes" : 6,
  "active_primary_shards" : 120,
  "active_shards" : 232,
  "unassigned_shards" : 8,
  "initializing_shards" : 0,
  "relocating_shards" : 2
}

Значення: Yellow означає, що первинні шард призначені, але репліки ще не повністю розміщені. У вас зменшена надлишковість; під час відновлення продуктивність може постраждати.

Рішення: Якщо yellow тримається довго — розслідуйте розміщення шард, диск‑водяні маркери та ємність вузлів, перш ніж масштабувати трафік запитів.

Завдання 7: Виявити індекси з надміру шард (класичний податок на продуктивність)

cr0x@server:~$ curl -s http://opensearch.service:9200/_cat/indices?v
health status index               uuid                   pri rep docs.count store.size
green  open   items_v12           a1b2c3d4e5             24  1   88441211   310gb
green  open   items_v12_alias     -                      -   -   -          -
green  open   audit_v03           f6g7h8i9j0              6  1   120011223  540gb

Значення: 24 primary shards для одного індексу може бути нормально — або ознакою шард‑експлозії, залежно від кількості вузлів і шаблонів запитів.

Рішення: Якщо число шард > (nodes * 2–4) для одного «гарячого» індексу — плануйте shrink/reindex з розумним розміром шард.

Завдання 8: Перевірити тиск індексування OpenSearch (чи merges та refresh вбивають ingest?)

cr0x@server:~$ curl -s http://opensearch.service:9200/_nodes/stats/indices?pretty | head -n 30
{
  "cluster_name" : "search-prod",
  "nodes" : {
    "n1" : {
      "name" : "search-n1",
      "indices" : {
        "refresh" : { "total" : 992112, "total_time_in_millis" : 31212344 },
        "merges" : { "current" : 14, "current_docs" : 8812333, "total_time_in_millis" : 92233111 }
      }
    }

Значення: Висока конкуренція merge‑ів і велика current_docs означають, що кластер витрачає багато часу на злиття сегментів — часто це IO‑bound.

Рішення: Обмежте bulk‑індексацію, тимчасово збільшіть інтервал refresh (якщо прийнятно) і перевірте пропускну здатність диска/глибинy черги на data‑вузлах.

Завдання 9: Виміряти латентність пошуку на рівні движка (де вузьке місце — OpenSearch чи ваш додаток?)

cr0x@server:~$ curl -s -H 'Content-Type: application/json' http://opensearch.service:9200/items_v12/_search -d '{
  "profile": true,
  "size": 10,
  "query": { "bool": { "filter": [ { "term": { "tenant_id": "t-123" } } ], "must": [ { "match": { "title": "graph api" } } ] } }
}' | head -n 25
{
  "took" : 38,
  "timed_out" : false,
  "hits" : {
    "total" : { "value" : 129, "relation" : "eq" },
    "max_score" : 7.1123,
    "hits" : [
      { "_id" : "987654", "_score" : 7.1123, "_source" : { "title" : "Graph API Gateway" } }
    ]
  }

Значення: «took: 38» — час движка в мілісекундах. Якщо p99 API ≈ 800ms, вузьке місце, ймовірно, у гідратації, мережі, серіалізації або зовнішніх викликах.

Рішення: Використовуйте це, щоб припинити взаємні звинувачення. Оптимізуйте справді повільний шар.

Завдання 10: Перевірити mapping для ризикового поля (щоб уникнути прихованих mismatches keyword/text)

cr0x@server:~$ curl -s http://opensearch.service:9200/items_v12/_mapping?pretty | grep -n "title" -n | head
132:         "title" : {
133:           "type" : "text",
134:           "fields" : {
135:             "keyword" : { "type" : "keyword", "ignore_above" : 256 }
136:           }
137:         },

Значення: title — text із підполем keyword. Це стандарт: використовувати title для повнотекстового пошуку, title.keyword — для точних збігів/сортування (у межах розумного).

Рішення: Якщо ви сортуєте по title (text) — виправте запити; сортуйте по title.keyword або нормалізованому полі для сортування.

Завдання 11: Перевірити диск‑watermark (кластери переходять у режим тільки для читання за браком місця)

cr0x@server:~$ curl -s http://opensearch.service:9200/_cluster/settings?include_defaults=true | grep -n "watermark" | head -n 20
412:         "cluster.routing.allocation.disk.watermark.low" : "85%",
413:         "cluster.routing.allocation.disk.watermark.high" : "90%",
414:         "cluster.routing.allocation.disk.watermark.flood_stage" : "95%",

Значення: На flood_stage індекси можуть бути позначені як read‑only для захисту кластера. Індексація почне падати.

Рішення: Якщо ви близькі до flood_stage — збільшіть місце, видаліть старі індекси або зменшіть ретеншн. Не «просто повторюйте» запити.

Завдання 12: Підтвердити, що alias вказує на потрібний індекс (помилки при реіндексації виглядають як баги релевантності)

cr0x@server:~$ curl -s http://opensearch.service:9200/_cat/aliases?v | grep items
alias         index     filter routing.index routing.search is_write_index
items_current items_v12 -      -            -              true

Значення: Write alias і read alias мають бути свідомими. Якщо ваш додаток читає items_v11, а індексери пишуть в items_v12 — ви «зникнете» деякі документи в пошуку.

Рішення: Виправте хореографію alias: пишіть у новий індекс, зробіть backfill, потім атомарно переключіть read alias.

Завдання 13: Проінспектувати thread pools OpenSearch (чи не насичуєте ви пошук або write threads?)

cr0x@server:~$ curl -s http://opensearch.service:9200/_cat/thread_pool/search?v
node_name name   active queue rejected completed
search-n1 search      18   120     3421  91822311
search-n2 search      17   110     3302  91011210

Значення: Висока черга й зростання відхилених означають перевантаження; OpenSearch відмовляє у виконанні роботи.

Рішення: Зменшіть вартість запитів (фільтри, менше шард), додайте вузли або реалізуйте клієнтське обмеження швидкості й фолбеки.

Завдання 14: Перевірити поведінку bulk‑індексації (чи не надсилаєте занадто великі батчі?)

cr0x@server:~$ curl -s -H 'Content-Type: application/json' http://opensearch.service:9200/_cat/nodes?v
ip         heap.percent ram.percent cpu load_1m load_5m load_15m node.role master name
10.0.2.11           92          78  86   12.11   10.42     9.88 dimr      -      search-n1
10.0.2.12           89          76  80   11.22    9.90     9.31 dimr      -      search-n2

Значення: Heap > ~85–90% на тривалий час — тривожний сигнал. Bulk‑інгест може викликати GC‑thrash і сплески латентності.

Рішення: Зменшіть розмір bulk‑запитів, обмежте паралельність і впевніться, що кількість шард не примушує надто велике навантаження на кожен вузол.

Плейбук швидкої діагностики

Коли «пошук зламався», вам потрібен шлях, що уникає нескінченних дебатів. Ось порядок, який швидко знаходить вузьке місце.
Він оптимізований для гібридних налаштувань, де Postgres — правда, а OpenSearch — витяг.

По‑перше: воно застаріле, неправильне чи повільне?

  • Застаріле: нові/оновлені об’єкти не з’являються.
  • Неправильне: відображаються неавторизовані/видалені об’єкти, або відсутні предмети, що мають відповідати.
  • Повільне: сплески латентності або тайм‑аути.

Це різні класи відмов. Не виправляйте тюнінг релевантності, коли ваш outbox відстає на 20 хвилин.

По‑друге: встановіть, який рівень повільний

  1. Виміряйте час движка (OpenSearch «took») для того самого запиту.
  2. Виміряйте час API для запиту.
  3. Виміряйте час гідратації в Postgres (EXPLAIN ANALYZE).

Якщо OpenSearch швидкий, але API повільний — винна, як правило, гідратація (N+1), перевірки прав або серіалізація.
Якщо OpenSearch повільний, а Postgres в порядку — ви в зоні шард/merge/heap.

По‑третє: перевірити лаг конвеєра та рівні помилок

  1. Кількість pending в outbox і max lag.
  2. Логи індексера (mapping conflict, 429 rejections, timeouts).
  3. Стан кластера OpenSearch, диск‑watermark, відхилення в thread pool.

По‑четверте: вирішіть безпечну міру пом’якшення

  • Застаріле: масштабувати індексери, обмежити backfill, виправити poison‑pills, уникати ручного spam‑refresh.
  • Неправильне: застосовувати Postgres як істину при гідратації; звузити фільтри; перевірити alias; підтвердити поширення видалень.
  • Повільне: зменшити вартість запитів, зменшити кількість шард у вибірці (routing/filters), обмежити паралелізм, додати вузли як крайній засіб.

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

1) «Я створив, але пошук не знаходить»

Симптом: Нові елементи відсутні кілька хвилин; пряма ссылка працює.

Корінь: Лаг індексації (backlog outbox), довгий інтервал refresh або індексер уповільнений через відхилення від OpenSearch.

Виправлення: Виміряйте lag outbox; масштабнуйте консумерів; зменшіть розмір bulk; переконайтеся, що OpenSearch не в flood_stage; встановіть інтервал refresh свідомо і повідомте SLO свіжості.

2) «Пошук показує елементи інших tenant‑ів»

Симптом: Результати протікають між tenant‑ами — зазвичай викриває розлючений клієнт.

Корінь: Відсутній tenant_id фільтр у запиті OpenSearch або вибірці гідратації; alias вказує на змішаний індекс; кеш не ключується по tenant.

Виправлення: Вимагайте tenant_id у кожному запиті (контракт API); додайте тести запитів; перевірте стратегію розподілу індексів; виправте кеши, щоб включати ключ tenant.

3) «OpenSearch швидкий, але API повільний»

Симптом: OpenSearch took низький (<50ms), але API p99 високий.

Корінь: N+1 гідратація, широкі SELECT *, перевірки прав для кожного рядка або повільна серіалізація великих payload‑ів.

Виправлення: Пакетна гідратація WHERE id = ANY($1); повертайте мінімальні стовпці; попередньо обчислюйте флаги прав; поставте жорсткі ліміти на розмір результатів і повернутих полів.

4) «Індексація падає випадково»

Симптом: Деякі документи ніколи не індексуються; retries зациклені; помилки виглядають непослідовними.

Корінь: Mapping‑конфлікти через динамічні поля або невідповідні типи (рядок vs масив, integer vs keyword).

Виправлення: Закріпіть mapping‑и; валідовуйте вихід проєкції; помістіть poison‑pills в карантин; реіндексуйте з виправленим mapping‑ом і суворою валідацією вхідних даних.

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

Симптом: Якість результатів погіршилась після додавання boosting/synonyms; скарги на «нелогічні» результати.

Корінь: Зміна analyzer‑ів без реіндексації, підсилення шумних полів, надто широкі синоніми або змішування фільтрів з scoring‑логікою.

Виправлення: Розглядайте зміни analyzer‑ів як подію реіндексації; валідовуйте з офлайн‑judgment‑сетами; відділяйте фільтри (bool.filter) від scoring (bool.must/should).

6) «Кластер став read‑only і індексація померла»

Симптом: Bulk‑запити починають падати; логи згадують блоки read‑only.

Корінь: Спрацював диск‑flood_stage watermark.

Виправлення: Звільніть диск (видаліть старі індекси), додайте ємність, зменшіть ретеншн; потім зніміть блоки read‑only після вирішення проблеми з місцем — не раніше.

7) «Реіндексація знищила пошук»

Симптом: Під час backfill‑у сплески латентності й тайм‑аути; CPU/IO кластера під навантаженням.

Корінь: Реіндексація конкурує з живим трафіком; занадто велика паралельність; інтервал refresh надто малий; надмірна кількість шард.

Виправлення: Обмежте bulk‑інгест; збільшіть інтервал refresh під час backfill; плануйте на непік; ізолюйте індексуючі вузли, якщо можливо; дотримуйтеся розумного розміру шард.

Три міні‑історії з практики корпоративного світу

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

Середній B2B‑проект випустив «миттєвий пошук» для щойно створених записів. Команда припустила, що пошуковий движок поведеться як база: як тільки виклик індексації поверне 200, документ стає доступним.
Це припущення жило у коментарях, потім у обіцянці клієнтам, потім у демо для продажів.

Вчора в понеділок підтримка повідомила, що користувачі не знаходять щойно створені записи. Інженери перевірили логи API: виклики індексації були успішні. OpenSearch здавався зеленим. Команда звинуватила «кешування клієнта» і випустила патч для лікування кешу, який нічого не дав.

Насправді причина була в поведінці refresh у поєднанні з навантаженням. Під важким індексуванням refresh‑и затримувались, merges були дорогими. Пайплайн індексації був коректним, але «пошуковість» не була миттєвою.
Користувачі натискали пошук через кілька секунд після створення, і система не мала UX‑шляху, щоб показати щойно створені записи поза пошуком.

Виправлення не було героїчним. Вони визначили freshness SLO і реалізували флоу після створення, що показує створений запис безпосередньо, плюс банер, що пошук може зайняти трохи часу.
Також скоригували інтервал refresh і ввели метрику «time to searchable». Інцидент закінчився, коли всі погодились з фактичним контрактом: пошук — near‑real‑time, а не транзакційний.

Міні‑історія 2: Оптимізація, що обернулася проти команди

Інша компанія спробувала знизити латентність, пропустивши гідратацію з Postgres. Логіка: «OpenSearch уже має всі потрібні поля. Навіщо звертатись до Postgres? Це додатковий мережевий виклик і навантаження.» На папері виглядало добре. Вони навіть святкували зниження QPS на базі.

Потім з’явилися тонкі баги. Зміна прав в Postgres довго доходила до OpenSearch, тому користувачі ненадовго бачили те, що не повинні. Soft‑deleted записи залишались. Прапорець «прихований» застосовувався не послідовно. Продукт отримував повідомлення про «примарні» елементи й «приватні» елементи в пошуку.

Найгірше: система стала важкою для розуміння. Коректність тепер залежала від ідеальної синхронізації OpenSearch і бездоганної проєкції. Коли цього не було — режим відмови став витоком даних, а не просто застарілими результатами.

Вони відкотились до гідратації для захищених сутностей і лишили обмежений режим «тільки OpenSearch» для публічного контенту. Латентність трохи зросла. Ризик інциденту впав значно. Оптимізація не вдалася, бо оптимізувала не той метрик: p95 замість довіри.

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

Інша організація проводила квартальні drill‑и реіндексації. Не тому, що це весело — а тому, що це не весело. Вони ставились до цього як до тренування: створювали нову версію індексу, робили backfill з Postgres, dual‑write, валідували вибірки, потім переключали read‑alias.

Одного разу зміна mapping‑у вийшла з поганим типом поля. Індексація почала падати для частини документів, але пайплайн не зруйнувався, бо помилки були ізольовані конкретними записами outbox.
Спрацювали алерти по «poison pill rate» та «indexing error rate». Oncall‑інженер бачив точно, що і де зламалося.

Вони випустили виправлену версію індексу і відтворили outbox з відомого офсету. Оскільки alias‑фліп та backfill були відрепетировані, команда уникла довгого аутеджу й ручного лікування даних. Клієнти майже нічого не помітили.

Це не була гламурна інженерія. Це був чекліст, ру‑нбук і дисципліна тестувати страшні речі до того, як вони стануть терміновими. Саме така практика не отримує овацій — поки не рятує вихідні.

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

Покроково: впровадьте гібридний пошук, який вас не підведе

  1. Визначте контракт: freshness SLO («від запису до доступності в пошуку»), правила коректності (авторизація, видалення, межі tenant),
    і прийнятну застарілість для кожної фічі.
  2. Робіть Postgres джерелом правди: всі записи відбуваються там; жодних виключень «для продуктивності» без письмового аналізу ризиків.
  3. Створіть outbox‑таблицю: бізнес‑транзакція також пише запис в outbox з entity_id, entity_type, operation і timestamрами.
  4. Збудуйте індексер‑воркер з ідемпотентністю: безпечні повтори, ключі дедуплікації, обмежена паралельність і карантин для poison‑pills.
  5. Проєктуйте документ OpenSearch: детермінований, версіонований, протестований; уникайте динамічних mapping‑сюрпризів.
  6. Використовуйте alias з першого дня: read alias + write alias, щоб реіндексувати без змін у коді застосунку.
  7. Реалізуйте патерн запитів: OpenSearch повертає candidate IDs; Postgres гідрує авторитетні записи й фільтрує.
  8. Побудуйте backfill job: масове індексування з Postgres, з обмеженням швидкості, відновлюване, з видимістю метрик. Плануйте це; воно вам знадобиться.
  9. Інструментуйте лаг конвеєра: pending outbox, throughput індексації, рівні помилок; пейджіть по тривалих порушеннях freshness SLO.
  10. Тестуйте навантаження реалістично: включайте індексацію, merges, refresh і продакшн‑подібні мікси запитів — особливо tenant‑фільтри й агрегації.
  11. Репетируйте реіндекс‑дрилі: принаймні щоквартально і після будь‑яких змін analyzer/mapping, що вимагають реіндексації.
  12. Напишіть «режим аварійного доступу»: якщо OpenSearch деградований, фолбек на Postgres FTS для обмеженої функціональності або повернення часткових результатів з чітким UX.

Чекліст: перед тим як звинувачувати OpenSearch у повільному пошуку

  • Порівняйте OpenSearch «took» з латентністю API.
  • Перевірте патерни запитів гідратації в Postgres (N+1?) та індекси.
  • Переконайтеся, що ви не вибираєте величезні payload‑и без потреби.
  • Підтвердьте, що tenant‑фільтри й логіка прав не роблять виклики на рядок.

Чекліст: перед тим як запускати реіндекс у продакшн

  • Чи розмір шардів розумний для нового індексу?
  • Чи маєте ви достатній резерв дискового простору для паралельних індексів?
  • Чи налаштований інтервал refresh для backfill?
  • Чи є у вас ліміти швидкості і зворотний тиск від відхилень OpenSearch?
  • Чи є план валідації (вибірки, підрахунки, spot‑перевірки)?
  • Чи атомарний і відрепетируваний alias‑flip?

Питання та відповіді (FAQ)

1) Чи може PostgreSQL повністю замінити OpenSearch для повнотекстового пошуку?

Іноді так. Якщо ваш пошук переважно структуровані фільтри з помірним текстовим сумісністю, Postgres FTS простіший і за замовчуванням коректніший.
Якщо потрібна складна релевантність, нечіткість, багатопольове підсилення або дуже висока конкурентність на великих корпусах — OpenSearch виправдовує свою вартість.

2) Чому не зберігати все в OpenSearch і не читати з Postgres?

Тому що зрештою ви заново відкриєте транзакції, обмеження й аудит складно. OpenSearch не призначений бути бухгалтерським реєстром.
Якщо ви пропускаєте перевірки Postgres для авторизації й видалень — ви ставите на карту свою безпеку, покладаючись на eventual consistency і бездоганну проєкцію.

3) Outbox чи CDC: що краще?

Outbox простіший для розуміння й тестування в коді застосунку. CDC потужніший, коли треба захоплювати багато таблиць і сервісів.
Для індексації пошуку outbox часто виграє, бо вам зазвичай потрібна проєкція, і ви хочете явно контролювати, що і коли індексується.

4) Чи потрібно гідрувати результати з Postgres кожного разу?

Для захищених або мінливих сутностей — так, принаймні як перевірка коректності. Для публічного, низькоризикового контенту можна повертати джерела OpenSearch напряму, але потрібна сувора дисципліна щодо видалень, приховувань і меж tenant.

5) Як обробляти «пошук одразу після створення» в UX?

Не обіцяйте миттєвої доступності в пошуку, якщо ви не готові її оплачувати (а навіть тоді розподілені системи мають крайні випадки).
Після створення об’єкта переводьте користувача на сторінку об’єкта, показуйте його в «недавно створених» або закріпіть у UI до появи в пошуку.

6) Яка найпоширеніша причина помилок індексації?

Mapping‑конфлікти через невідповідні типи полів, зазвичай спричинені динамічними mapping‑ами і недбалою проєкцією. Друга причина: диск‑watermark, що переводить індекси в режим read‑only. Третє: розмір bulk‑запитів і паралельність, що викликають відхилення.

7) Як уникнути проблем з шард‑менеджментом?

Тримайте кількість шардів відповідною до числа вузлів і очікуваного розміру даних. Уникайте дрібних шард (накладні витрати) та гігантських шард (важке відновлення).
Використовуйте alias і реіндексуйте, коли починаєте перевищувати початкові припущення. Шарди не безкоштовні; вони коштують heap‑пам’яті й складності операцій.

8) Чи потрібні окремі кластери OpenSearch для індексації й запитів?

Не завжди, але розділення допомагає при важких backfill‑ах, частій реіндексації або різких піках трафіку. Як мінімум — контролюйте throughput індексації з зворотним тиском, щоб merges не з’їдали латентність запитів.
Якщо ваш бізнес критично залежить від пошуку — розгляньте архітектуру, що обмежує blast radius.

9) Як тестувати релевантність, не ламаючи продакшн?

Побудуйте офлайн‑judgment‑сет (запити + очікувані хороші результати). Запускайте A/B‑тести на знімках. Деплойте зміни релевантності за фіче‑флагами або версіями індексів.
Більшість «багів» релевантності — це зміни без базових порівнянь.

10) Який найкращий фолбек, коли OpenSearch деградує?

Обмежений Postgres‑орієнтований пошук для ключових робочих потоків (точний збіг, префікс або звужений FTS) часто достатній.
Головне — визначити, що означає «режим деградації» і тримати його в межах можливостей бази.

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

Якщо у вас уже є Postgres і OpenSearch у продакшні, ваша мета — не «ідеальний пошук». Вона — передбачуваний пошук.
Ось практичні кроки з високим ROI:

  1. Додайте метрики свіжості: lag outbox, throughput індексера, «time to searchable» і рівні помилок індексації.
  2. Аудит фільтрів tenant/auth: застосуйте їх в одному місці (краще — на етапі гідратації) і тестуйте як контроли безпеки.
  3. Знайдіть і усуньте N+1 гідратацію: пакетна вибірка по ID і вибір лише потрібних колонок.
  4. Закріпіть mappings: зупиніть випадкові динамічні поля, що роздмухують індекс і викликають конфлікти.
  5. Репетируйте alias‑flip: відрепетируйте реіндексацію, навіть якщо вона не потрібна сьогодні. Вам вона знадобиться.
  6. Напишіть односторінковий контракт: що свіжо, що коректно і що відбувається під час деградації.

Гібридний пошук працює, коли ви перестаєте змушувати одну систему бути двома. Дайте Postgres право бути коректним.
Дайте OpenSearch — релевантним. Потім побудуйте конвеєр і API, які визнають реальність і все одно добре обслуговують користувачів.

← Попередня
Docker «Too Many Requests» при завантаженні образів: виправте обмеження реєстру як слід
Наступна →
Виклики в документації, які вас не підведуть: CSS-змінні, темний режим і операційна дисципліна

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