MySQL vs PostgreSQL: JSON-навантаження — швидкий шлях чи довгостроковий біль

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

Ви додаєте колонку JSON, бо «потрібна просто гнучкість». Потім ваші дашборди гальмують, репліки відстають,
а хтось просить «швидкий ad-hoc запит», який перетворюється на повний перегляд таблиці в мільйонах рядків. JSON — це
скотч у моделюванні даних: іноді врятує ситуацію, іноді саме через нього зникла потреба в порятунку.

MySQL і PostgreSQL обидва підтримують JSON, але вони заохочують дуже різні звички. Один дозволить швидко випустити фічу і
потроху накопичувати технічний борг. Інший дасть потужні індекси й обмеження — але також достатньо мотузки, щоб зв’язати светр з зайвого роздування й блокувань, якщо не бути обережним.

Рішення на одній сторінці: що вибрати і коли

Використовуйте PostgreSQL коли…

  • Потрібні складні запити (containment, перевірка наявності, вкладені фільтри) і ви хочете, щоб оптимізатор мав варіанти. JSONB + GIN у PostgreSQL — це набір інструментів для «дорослого» використання.
  • Вам потрібні обмеження навколо напівструктурованих даних: CHECK-обмеження, expression-індекси, згенеровані колонки та функціональні індекси — усе це перша категорія громадян.
  • Очікуєте, що JSON залишиться довше, ніж квартал. PostgreSQL зазвичай краще «старіє», коли JSON стає частиною основної схеми.
  • Ви компетентно керуєте VACUUM. PostgreSQL винагородить вас, але тільки якщо ви поважаєте роботу MVCC з прибиранням.

Використовуйте MySQL коли…

  • Використання JSON — це переважно зберігання документа й вибірка, а не важке аналітичне фільтрування. Якщо запити — «взяти за id, повернути blob», MySQL цілком підходить.
  • Ви покладаєтесь на згенеровані колонки, щоб проектувати «гарячі» JSON-шляхи в індексовані скалярні значення. Це практичний шлях MySQL до передбачуваності.
  • Ви вже стандартизувалися на MySQL операційно і JSON — невелика частина навантаження. Послідовні операції кращі за теоретичну елегантність.

Що я порадив би команді в продакшені

Якщо ваші колонки JSON — це тимчасове рішення (швидкий інгест, нормалізувати пізніше), обирайте ту СУБД, якою ваша команда вже вміє керувати. Але якщо JSON — це контракт інтерфейсу (події, конфігурації, feature flags, атрибути користувача) і ви очікуєте запитів всередині нього в масштабі, PostgreSQL зазвичай безпечніший довгостроковий вибір.

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

Цитата, яка має бути в кожному on-call runbook: «Сподівання — це не стратегія.» — широко повторюваний операційний афоризм (парафраз). З JSON сподіватися, що база «сама розбереться», — це як купити собі інцидент на вихідні.

Факти та історія: як ми дійшли до цього

Зберігання JSON у базі виглядає сучасно, але індустрія обдумувала цю ідею десятиліттями: «зберігати гнучкі дані поряд зі структурованими й запитувати їх без втрати транзакційної цілісності.» Деталі різняться, і саме через них ви читаєте це, а не спите.

8 фактів, які варто тримати в голові

  1. PostgreSQL додав JSON у 9.2 (2012), а потім представив JSONB у 9.4 (2014) для бінарного зберігання та кращої індексації.
  2. MySQL представив власний тип JSON у 5.7 (2015); до того це був TEXT з надією та регулярними виразами.
  3. JSONB нормалізує порядок ключів і видаляє дублікати ключів (перемагає останній ключ). Це чудово для індексації, але може дивувати, якщо ви хочете зберегти «рівно те, що надіслали».
  4. MySQL теж зберігає JSON у бінарному форматі і валідовує JSON при вставці, уникаючи деяких жахів з «невалідними blob».
  5. GIN-індекси в PostgreSQL спочатку були створені для повнотекстового пошуку, потім стали робочою конячкою для containment у JSONB.
  6. Згенеровані колонки в MySQL існують з 5.7, і вони — причина того, чому багато MySQL-деплойментів з JSON не розвалюються.
  7. MVCC у PostgreSQL означає, що оновлення створюють нові версії рядків; великі оновлення JSON можуть посилювати роздування, якщо VACUUM відстає.
  8. Формати реплікації важливі: row-based binlog у MySQL і logical decoding у PostgreSQL поводяться по-різному при частих оновленнях JSON і «гарячих» рядках.

Семантика JSON: що насправді зберігають рушії

MySQL: JSON — це тип, але ставтеся до нього як до документа, якщо ви не проектуєте поля

Тип JSON у MySQL — це не «TEXT з міткою». Він валідовується, зберігається в бінарному представленні й маніпулюється JSON-функціями.
Це хороша новина. Операційна реальність така, що ви рідко отримаєте стабільну продуктивність, якщо не зробите одне з двох:
(1) зберігати JSON переважно для запису й читати за первинним ключем, або (2) виносити часто запитувані шляхи в згенеровані колонки й індексувати їх.

MySQL охоче дозволить вам написати запит, що виглядає селективним, але не є індексованим. Оптимізатор зробить, що зможе, а потім просканує.
Іноді ви можете врятувати ситуацію функціональними індексами (залежить від версії) або згенерованими колонками, але потрібно діяти навмисно.

PostgreSQL: JSONB — для запитів; JSON (текст) — для збереження точного вводу

PostgreSQL дає дві філософії:
json зберігає оригінальний текст (включаючи пробіли та порядок ключів), а
jsonb зберігає декомпозований бінарний формат, оптимізований для операторів і індексації.
Якщо вам потрібна продуктивність, майже завжди варто обирати JSONB.

Оператори PostgreSQL виразні: containment (@>), перевірка наявності (?, ?|, ?&),
вилучення шляхом (->, ->>, #>, #>>), а також запити JSON path.
Ця виразність може стати пасткою: люди пишуть хитрі фільтри, які виглядають дешевими, але стають CPU-залежними під час декомпресії або зависають на індексі, що не відповідає предикату.

Жарт 1/2: JSON — як ящик для мотлоху — все вміщується, поки вам не треба знайти ножиці.

Індексація JSON: де народжується або втрачається продуктивність

Індексація в MySQL: згенеровані колонки — це дорослий хід

В MySQL індексація довільних JSON-виразів покращувалась з часом, але найнадійніша операційна схема досі:
визначити згенеровані колонки для кількох JSON-шляхів, які ви запитуєте постійно, привести їх до стабільних скалярних типів і проіндексувати.
Це дає три переваги:

  • Дає оптимізатору звичайний B-tree індекс, який він розуміє.
  • Уникає повторного вилучення JSON під час виконання.
  • Змушує вас визнати, які поля насправді є частиною «реальної схеми».

Ловушка: зміни в схемі стають повільнішими і політично більш чутливими, бо тепер JSON-blob має щупальця в DDL і міграціях.
Це не баг. Це ціна за те, що ви робите з напівструктурованих даних структуру (бо вона нею стає, щойно ви на неї опираєтесь).

Коли індексація JSON у MySQL дає збій на практиці

  • Надто динамічні предикати (різні JSON-шляхи залежно від вводу користувача) штовхають до сканувань.
  • Порівняння JSON-рядків з числами викликає неявні приведення типів і ламає використання індексів.
  • Використання функцій у WHERE без індексованого виразу змушує оптимізатор знехтувати індексом і робити все повільно.

Індексація в PostgreSQL: GIN потужний, але потрібно обирати клас оператора

Історія індексації JSONB у PostgreSQL сильніша, але це не магія. GIN-індекси можуть прискорити containment і перевірку наявності ключів,
але вони мають різні класи операторів:

  • jsonb_ops: індексує більше типів операцій, але може бути більшим за розміром.
  • jsonb_path_ops: компактніший і швидший для containment, але підтримує менше операторів.

Якщо ваше навантаження — «знайти рядки, де JSON містить ці пари», jsonb_path_ops часто є правильним вибором.
Якщо потрібна гнучка перевірка наявності й ширша підтримка операторів — jsonb_ops.
Оберіть неправильно, і ви отримаєте індекс, що існує лише для того, щоб робити VACUUM нещасним.

Expression-індекси: практичний міст між JSON і реляційним

Якщо ви часто фільтруєте по одному витягнутому полю (наприклад, payload->>'customer_id'), expression-індекс може бути компактнішим за широкий GIN
за розміром і передбачуваністю. Також ним легше оперувати, оцінюючи селективність.

Жарт 2/2: GIN-індекс — як кофеїн — неймовірний, коли влучно застосований, шкода, коли перестаратися.

Шаблони запитів, що розділяють «нормально» від «горить»

Шаблон 1: «Отримати за id і повернути JSON» (відносно безпечно)

І MySQL, і PostgreSQL добре з цим справляються. Домінуючий витрат — це I/O і розмір рядка, а не JSON-функції.
Де команди потрапляють у пастку — повільне наростання: JSON росте, рядок збільшується, ефективність кешу падає, і раптом «прості читання» стають дисковими читаннями.

Шаблон 2: «Фільтрація за ключами JSON з великою кардинальністю» (індекс або помирай)

Якщо ви фільтруєте за user_id, tenant_id, order_id всередині JSON, ви фактично фільтруєте за реляційним ключем.
Не прикидайтеся, що це гнучко. Виведіть це: згенерована колонка + індекс у MySQL, expression-індекс у Postgres або зробіть це справжньою колонкою.
Це не ідеологія. Це про уникнення повних сканів і нестабільних планів виконання.

Шаблон 3: «Ad-hoc аналітика по JSON» (бережіться повільного наростання)

JSON привабливий для аналітики, бо самопояснюваний. У продакшн OLTP-базах це пастка.
Ad-hoc аналітика зазвичай:

  • Використовує функції над багатьма рядками, викликаючи навантаження на CPU.
  • Примушує послідовні скани, бо предикати не відповідають індексам.
  • Серіалізує ваше навантаження на одній великій таблиці й одному «гарячому» дисковому підсистемі.

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

Шаблон 4: часткові оновлення JSON (гарячі рядки, важкі логи)

Обидві СУБД можуть оновлювати шляхи всередині JSON, але характеристики продуктивності різняться, а операційний вплив схожий:
часті оновлення великих JSON-документів означають більше записаних байтів, більше коливань індексів, більше роботи для реплікації і більше інвалідизації кешу.

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

Оновлення, WAL/binlog і відставання реплік

MySQL: реалії обсягу binlog і row-based реплікації

У MySQL великі оновлення JSON можуть породжувати великі binlog-події — особливо при row-based реплікації. Якщо ви оновлюєте багато рядків або великі документи, ваші репліки платять за це.
Відставання реплік рідко є «проблемою репліки». Це проблема ампліфікації записів додатком.

Також слід стежити за розміром транзакцій і частотою комітів. Навантаження, що оновлює JSON пачками, може створити неприємні сплески: тиск на fsync,
затримки flush binlog і беклог SQL-потоку репліки.

PostgreSQL: тиск WAL + churn MVCC

PostgreSQL записує WAL для змін, а MVCC означає, що оновлення створюють нові версії рядків. Часті оновлення великого поля JSONB дадуть:
більше WAL, більше «мертвих» кортежів, більше роботи для vacuum і потенційно більше роздування індексів.

Відставання реплік проявляється як backlog у WAL sender або затримка відтворення. Важливо відрізняти:
репліка не встигає застосовувати зміни (CPU/I/O при застосуванні) проти
праймар породжує забагато WAL (ампліфікація записів).

Операційні поради

  • Вимірюйте байти WAL/binlog за секунду під час піку. Це найближче до «правди» про ампліфікацію записів.
  • Розділяйте або виокремлюйте гарячі JSON-поля, якщо частота оновлень висока.
  • У PostgreSQL налаштуйте autovacuum для таблиць з інтенсивними оновленнями JSON, інакше борг vacuum проявиться як борг латентності.

Зберігання і I/O: роздування, перезапис сторінок і поведінка кешу

Розмір рядка і кеш: ваш невидимий податок

Колонки JSON збільшують рядки. Більші рядки означають менше рядків на сторінці. Менше рядків на сторінці означає більше читань сторінок для тієї самої кількості логічних рядків.
Це проявляється як:

  • Вищий churn буферного пулу в MySQL (InnoDB).
  • Більше обертання shared_buffers у PostgreSQL.
  • Більший тиск на сторінковий кеш ОС.

Більшість «таємничих регресій продуктивності» після додавання JSON насправді — «ми подвоїли розмір рядка й ніхто не відрегулював пам’ять чи шаблони доступу».

Роздування у PostgreSQL: MVCC означає, що ви винні колектору vacuum

PostgreSQL не оновлює на місці; він створює нові версії рядків. Якщо JSONB великий і часто оновлюється, накопичуються мертві кортежі, а індекси
піддаються змінам. Autovacuum може впоратися з багатьма випадками, але йому потрібні правильні пороги. Налаштування за замовчуванням призначені бути безпечними для початківців, а не оптимальними для вашого безладу.

MySQL: вторинні індекси та тиск undo/redo

InnoDB має власну ампліфікацію записів: redo logs, undo logs, doublewrite buffer, обслуговування вторинних індексів.
Великі оновлення JSON збільшують кількість затриманих байтів і можуть штовхнути вас у збій flush-логів. Ви побачите це як періодичні сплески латентності,
«раптом повільні коміти» і відставання реплік.

Практичні завдання: 14 команд, які можна запустити сьогодні

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

Завдання 1 (MySQL): підтвердити використання JSON і тиск на розмір

cr0x@server:~$ mysql -e "SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns WHERE data_type='json' ORDER BY table_schema, table_name;"
+--------------+------------+-------------+-----------+
| table_schema | table_name | column_name | data_type |
+--------------+------------+-------------+-----------+
| app          | events     | payload     | json      |
| app          | users      | attrs       | json      |
+--------------+------------+-------------+-----------+

Значення: тепер ви знаєте, які таблиці кандидати на JSON-проблеми.
Рішення: скоротіть список до топ-1–3 таблиць за кількістю рядків і частотою оновлень. Саме там індексація й вибір схеми мають значення.

Завдання 2 (MySQL): перевірити розміри таблиць і індексів

cr0x@server:~$ mysql -e "SELECT table_name, table_rows, ROUND(data_length/1024/1024,1) AS data_mb, ROUND(index_length/1024/1024,1) AS index_mb FROM information_schema.tables WHERE table_schema='app' ORDER BY data_length DESC LIMIT 10;"
+------------+------------+---------+----------+
| table_name | table_rows | data_mb | index_mb |
+------------+------------+---------+----------+
| events     |    4821031 |  8120.4 |   2104.7 |
| users      |     820114 |  1190.8 |    412.2 |
+------------+------------+---------+----------+

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

Завдання 3 (MySQL): знайти повільні JSON-предикати в slow log

cr0x@server:~$ sudo pt-query-digest /var/log/mysql/mysql-slow.log --limit 5
#  1.2s user time, 40ms system time, 27.31M rss, 190.55M vsz
# Query 1: 0.68 QPS, 0.31x concurrency, ID 0xA1B2C3D4 at byte 91234
# Time range: 2025-12-28T00:00:00 to 2025-12-28T01:00:00
# Attribute    pct   total     min     max     avg     95%  stddev  median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Exec time     62   180s    120ms     12s    540ms     3s   900ms   300ms
# Rows examine  90  1200M      10   2.5M   360k   1.1M   500k   200k
# Query: SELECT ... WHERE JSON_EXTRACT(payload,'$.customer.id') = ?

Значення: rows examined — ваш «податок на сканування». JSON_EXTRACT у WHERE без індексу — типовий підозрюваний.
Рішення: створіть згенеровану колонку для цього шляху (або функціональний індекс, якщо доречно) і перепишіть запит, щоб використовувати її.

Завдання 4 (MySQL): перевірити, чи використовує запит індекс

cr0x@server:~$ mysql -e "EXPLAIN SELECT id FROM app.events WHERE JSON_UNQUOTE(JSON_EXTRACT(payload,'$.customer.id'))='12345' LIMIT 10\G"
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: events
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 4821031
     filtered: 10.00
        Extra: Using where

Значення: type: ALL і відсутність key означають повний перегляд таблиці.
Рішення: не налаштовуйте спочатку буфери. Виправляйте схему/запит: згенерована колонка + індекс або редизайн.

Завдання 5 (MySQL): додати згенеровану колонку для гарячого JSON-шляху

cr0x@server:~$ mysql -e "ALTER TABLE app.events ADD COLUMN customer_id VARCHAR(64) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(payload,'$.customer.id'))) STORED, ADD INDEX idx_events_customer_id (customer_id);"
Query OK, 0 rows affected (2 min 41 sec)
Records: 0  Duplicates: 0  Warnings: 0

Значення: STORED-згенерована колонка матеріалізує значення, індекс стає доступним.
Рішення: перепишіть запити додатка, щоб фільтрувати за customer_id замість JSON_EXTRACT у WHERE. Потім перевірте EXPLAIN.

Завдання 6 (MySQL): перевірити, чи оптимізатор тепер використовує новий індекс

cr0x@server:~$ mysql -e "EXPLAIN SELECT id FROM app.events WHERE customer_id='12345' LIMIT 10\G"
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: events
         type: ref
possible_keys: idx_events_customer_id
          key: idx_events_customer_id
      key_len: 258
          ref: const
         rows: 120
        Extra: Using index

Значення: ви перейшли від сканування мільйонів до торкання ~120 рядків.
Рішення: запустіть у прод, але слідкуйте за латентністю запису: підтримка нового індексу підвищує вартість записів.

Завдання 7 (MySQL): перевірити відставання репліки та тиск на застосування

cr0x@server:~$ mysql -e "SHOW REPLICA STATUS\G" | egrep "Seconds_Behind_Source|Replica_SQL_Running|Replica_IO_Running|Last_SQL_Error"
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 87
Last_SQL_Error:

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

Завдання 8 (PostgreSQL): перелічити JSON/JSONB колонки та їх таблиці

cr0x@server:~$ psql -d appdb -c "SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns WHERE data_type IN ('json','jsonb') ORDER BY 1,2,3;"
 table_schema | table_name | column_name | data_type
--------------+------------+-------------+-----------
 public       | events     | payload     | jsonb
 public       | users      | attrs       | jsonb
(2 rows)

Значення: охоплення. Як і в MySQL: знайдіть кілька таблиць, що мають найбільший вплив.
Рішення: фокусуйтеся на таблицях з великою частотою оновлень і запитами, що впливають на клієнтів.

Завдання 9 (PostgreSQL): знайти найгірші JSON-запити за загальним часом

cr0x@server:~$ psql -d appdb -c "SELECT calls, total_exec_time::bigint AS total_ms, mean_exec_time::numeric(10,2) AS mean_ms, rows, query FROM pg_stat_statements WHERE query ILIKE '%jsonb%' OR query ILIKE '%->%' OR query ILIKE '%@>%' ORDER BY total_exec_time DESC LIMIT 5;"
 calls | total_ms | mean_ms | rows |                   query
-------+----------+---------+------+-------------------------------------------
 18211 |   932144 |   51.20 |    0 | SELECT ... WHERE payload @> $1
  4102 |   512030 |  124.82 |    0 | SELECT ... WHERE (payload->>'customer')= $1
(2 rows)

Значення: у вас гарячі запити, а не теоретичні проблеми.
Рішення: запустіть EXPLAIN (ANALYZE, BUFFERS) на топ-обвинувачах і побудуйте правильний індекс для форми предикату.

Завдання 10 (PostgreSQL): переглянути план запиту JSONB з інформацією про буфери

cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM events WHERE payload @> '{\"customer\":{\"id\":\"12345\"}}'::jsonb LIMIT 10;"
                                                          QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..8.44 rows=10 width=8) (actual time=0.088..0.146 rows=10 loops=1)
   Buffers: shared hit=42
   ->  Index Scan using idx_events_payload_gin on events  (cost=0.42..22134.77 rows=26235 width=8) (actual time=0.086..0.141 rows=10 loops=1)
         Index Cond: (payload @> '{"customer": {"id": "12345"}}'::jsonb)
         Buffers: shared hit=42
 Planning Time: 0.412 ms
 Execution Time: 0.182 ms
(7 rows)

Значення: index scan + переважно попадання у буфери = здорово.
Рішення: зберігайте цей індекс, якщо він підтримує основні продуктові шляхи. Якщо він лише для ad-hoc запитів — не платіть за нього вартість записів.

Завдання 11 (PostgreSQL): створити цільовий GIN-індекс (обрати клас оператора)

cr0x@server:~$ psql -d appdb -c "CREATE INDEX CONCURRENTLY idx_events_payload_pathops ON events USING gin (payload jsonb_path_ops);"
CREATE INDEX

Значення: concurrent build уникає блокування записів (ціною часу і додаткової роботи).
Рішення: використовуйте jsonb_path_ops, коли домінує containment; інакше розглядайте jsonb_ops або expression-індекси.

Завдання 12 (PostgreSQL): побудувати expression-індекс для одного гарячого шляху

cr0x@server:~$ psql -d appdb -c "CREATE INDEX CONCURRENTLY idx_events_customer_id_expr ON events ((payload->'customer'->>'id'));"
CREATE INDEX

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

Завдання 13 (PostgreSQL): перевірити сигнали роздування і ефективність autovacuum

cr0x@server:~$ psql -d appdb -c "SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_vacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
 relname | n_live_tup | n_dead_tup |    last_autovacuum     |     last_vacuum
---------+------------+------------+------------------------+------------------------
 events  |    4809123 |     912044 | 2025-12-28 00:41:12+00 | 2025-12-22 03:11:02+00
 users   |     820104 |      12033 | 2025-12-28 00:38:01+00 | 2025-12-23 02:08:40+00
(2 rows)

Значення: мертві кортежі в events великі; autovacuum працює, але може бути недопровідним для рівня churn.
Рішення: налаштуйте autovacuum для конкретної таблиці, зменшіть частоту оновлень великого JSONB або виокреміть гарячі змінні поля.

Завдання 14 (Системний рівень): визначити, чи ви обмежені I/O або CPU

cr0x@server:~$ iostat -x 1 5
Linux 6.1.0 (db01) 	12/29/2025 	_x86_64_	(16 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          22.11    0.00    6.34   18.90    0.00   52.65

Device            r/s     rkB/s   rrqm/s  %rrqm  r_await rareq-sz     w/s     wkB/s   w_await wareq-sz  aqu-sz  %util
nvme0n1         320.0  18240.0     0.0   0.00    4.20    57.00   410.0  24576.0    9.80    59.95   6.10   92.0

Значення: високий %util і значний iowait вказують на насичення сховища. JSON-навантаження часто збільшують I/O через більші рядки і churn індексів.
Рішення: спочатку виправте шаблони запитів/індексів; якщо все ще насичено — масштабувати IOPS (кращі диски) або знизити ампліфікацію записів (зміни в схемі/дизайні).

Швидкий план діагностики

Коли JSON-запити повільні, люди витрачають години на суперечки про «вибір СУБД» замість того, щоб знайти реальний горловий вузол.
Цей план — порядок дій під час інциденту — бо він швидко сходиться до висновку.

Перше: доведіть, чи це скан, пропущений індекс або сирий I/O

  • MySQL: запустіть EXPLAIN для повільного запиту. Якщо type: ALL, зупиніться і виправте предикат/індекс.
  • PostgreSQL: запустіть EXPLAIN (ANALYZE, BUFFERS). Якщо бачите sequential scans на великих таблицях — потрібен відповідний індекс або перепис запиту.
  • Система: перевірте iostat -x. Якщо сховище завантажене, скани і роздування будуть головними підозрюваними.

Друге: кількісно оцініть ампліфікацію записів і тиск на реплікацію

  • MySQL: перевіряйте відставання реплік і патерни росту binlog; великі оновлення JSON часто корелюють із стрибками відставання.
  • PostgreSQL: перевіряйте генерацію WAL і мертві кортежі; інтенсивні оновлення JSON можуть перетворити vacuum на постійну фонову кризу.

Третє: перевірте ефективність кешу і наростання розміру рядка

  • Чи ваш гарячий робочий набір все ще в пам’яті, або ріст JSON його витіснив?
  • Чи ви додали широкий GIN-індекс, який подвоїв вартість записів?
  • Чи хтось почав робити ad-hoc фільтри по неіндексованих ключах JSON?

Четверте: виправте найменшу річ, що змінює криву

  • Підніміть гарячі ключі в реальні колонки (найкраще) або згенеровані/expression колонки (наступний крок).
  • Додайте правильний індекс під форму предикату, потім валідуйте з EXPLAIN.
  • Якщо оновлення — проблема, виокреміть змінні поля з JSON blob.

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

Помилка 1: «Запит виглядає селективним, але повільний»

Симптоми: латентність росте з розміром таблиці; EXPLAIN показує повний скан; CPU піки під час навантаження.

Корінь: вилучення JSON у WHERE без індексованого виразу (MySQL), або невідповідність між оператором і індексом (PostgreSQL).

Виправлення: MySQL: STORED згенерована колонка + B-tree індекс; PostgreSQL: expression-індекс або правильний клас оператора GIN; перепишіть предикат, щоб він відповідав індексу.

Помилка 2: «Додали GIN-індекс і записи стали повільніші»

Симптоми: зростає латентність вставок/оновлень; стрибає швидкість WAL/binlog; відставання реплік погіршується після створення індексу.

Корінь: широкий GIN-індекс на великому JSONB з частими оновленнями; висока вартість обслуговування індексу.

Виправлення: замінити на вузькі expression-індекси; використовувати jsonb_path_ops, якщо тільки containment; виокремити змінні поля; пересвідчитися, що цей запит потрібен в OLTP.

Помилка 3: «Postgres повільнішає з часом; vacuum не встигає»

Симптоми: ростуть розміри таблиць і індексів; запити гальмують; autovacuum працює постійно; багато мертвих кортежів.

Корінь: часті оновлення великих полів JSONB створюють багато мертвих кортежів; пороги autovacuum не налаштовані під рівень churn таблиці.

Виправлення: налаштувати autovacuum під таблицю; зменшити частоту/розмір оновлень; перемістити змінні дані в окрему таблицю; розглянути партиціювання для таблиць подій.

Помилка 4: «MySQL відстає після додавання JSON-фіч»

Симптоми: Seconds_Behind_Source зростає під час сплесків; репліки відновлюються повільно; коміти стрибкоподібні.

Корінь: великі row-based binlog-події від оновлень JSON; надто великі транзакції; забагато вторинних індексів на проєктованих полях JSON.

Виправлення: зменшити обсяг оновлень JSON; змінити батчинг; обмежити індексовані проекції лише дійсно гарячими шляхами; перевірити налаштування binlog/redo log і патерни комітів.

Помилка 5: «Ми зберегли все в JSON і тепер нам потрібні обмеження»

Симптоми: неконсистентні значення в JSON; валідація на рівні додатка дрейфує; запити мають обробляти відсутні ключі і неправильні типи.

Корінь: схема віддана на виконання коду додатка; відсутні інструменти примусових обмежень; міграції відкладені занадто довго.

Виправлення: підніміть ключові поля в колонки; додайте CHECK-обмеження (Postgres) або забезпечте через згенеровані колонки + NOT NULL (MySQL); введіть версіонування payload-ів.

Три корпоративні історії з полі бою JSON

1) Інцидент через хибне припущення: «JSON фактично безкоштовний для запитів»

Середня SaaS-компанія випустила «activity feed», підкріплений таблицею подій. Кожна подія мала JSON payload.
Продуктова команда хотіла фільтрацію: «показати тільки події, де payload.actor.role = ‘admin’». Легко, подумали вони.
Backend використовував MySQL, і перша реалізація використовувала JSON_EXTRACT у WHERE.

У стейджингу все було нормально. У проді це стало повільною катастрофою: таблиця events була великою, і фільтр був популярним.
Запит виглядав селективним, але робив повний скан, торкаючись мільйонів рядків на запит у піковий час.
CPU на максимумі, I/O насичене, і весь кластер розвинув симптом «усе повільне», що змушує керівництво заходити в канал інциденту.

Хибне припущення було не в тому, що «MySQL не може робити JSON». Воно полягало в тому, що «якщо предикат вузький, база оптимізує його».
Бази оптимізують те, що ви індексуєте, а не те, на що ви сподіваєтесь. Вилучення JSON без індексованого виразу — не вузька операція; це дорога операція, повторювана по багатьох рядках.

Виправлення було болісно простим: додати STORED згенеровану колонку для actor_role, індексувати її і змінити запит.
Постмортем додав правило: будь-який ключ JSON, який використовується в гарячому WHERE, має бути спроєктований і проіндексований або переміщений в реальну колонку.
Гнучка схема лишалася, але тільки там, де вона не на критичному шляху.

2) Оптимізація, що обернулася проти: «Просто додайте великий GIN-індекс»

Інша компанія працювала на PostgreSQL і мала одну величезну таблицю events з JSONB payload-ами.
Вони хотіли швидкий ad-hoc пошук для клієнтської підтримки, тому хтось додав широкий GIN-індекс на весь payload з використанням класу оператора за замовчуванням.
Швидкість запитів покращилася миттєво. Усі аплодували і перейшли далі.

Через два тижні латентність записів почала повільно повзти вгору. Активність autovacuum стала постійною. З’явилося відставання реплік під час піку.
GIN-індекс був дорогим у підтримці, бо payload-и були великі й часто оновлювалися полями enrichment.
Індекс також швидко ріс, збільшуючи навантаження на чекпойнти і I/O. Перемога в пошуку підтримки перетворилася на проблему «кожен API-ендпоінт став повільнішим».

Фіаско полягало не в тому, що GIN поганий. Воно полягало в тому, що вони проіндексували все для робочого навантаження, яке фактично не було критичним.
Індекс перетворив базу на пошукову систему. PostgreSQL це може робити, але ви платите в ампліфікації записів і роздуванні.

Остаточне вирішення: прибрати широкий індекс, додати два expression-індекси для кількох ключів, які використовувала служба підтримки,
і перенести повнотекстовий пошук з OLTP-шляху. Підтримка отримала свій робочий процес, а продакшн перестав платити податок на кожен запис.

3) Нудна, але правильна практика, що врятувала день: «Зробіть JSON контрактом, версіонуйте й тестуйте»

Фінтех-подібна команда зберігала метадані верифікації клієнта в JSONB у PostgreSQL. Там були вкладені поля, опціональні ключі й блоки від вендорів.
Вони знали, що ці дані будуть еволюціонувати, і також знали, що потрібно надійно запитувати кілька полів для звітів з комплаєнсу.
Тому вони зробили те, що здається нецікавим: додали колонку schema_version типу integer і писали явні міграції для змін форми payload.

Також вони підняли кілька критичних полів до реальних колонок: customer_id, verification_status і vendor_name.
Усе інше жило в JSONB. На додачу були CHECK-обмеження, що гарантували відповідність status відомому набору,
і тести додатка, які перевіряли сумісність схем JSON за версіями.

Через місяці вендор трохи змінив формат payload (поле перемістилося глибше).
Команди, що зберігають сирий JSON без контракту, зазвичай дізнаються про це тоді, коли звіти ламаються о 2-й ночі.
Ця команда виявила проблему в CI, бо тест валідації схеми впав і інструмент міграції змусив явне перетворення.

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

Контрольні списки / покроковий план

Якщо ви починаєте нову фічу з великою кількістю JSON

  1. Запишіть топ-5 шаблонів запитів, які ви очікуєте протягом наступних шести місяців (не лише тиждень запуску).
  2. Класифікуйте поля: незмінні vs змінні; часто фільтруються vs рідко фільтруються; велика кардинальність vs мала.
  3. Підніміть «часто фільтровані, з великою кардинальністю» поля в реальні колонки (переважно) або згенеровані/expression колонки.
  4. Оберіть стратегію індексування, специфічну для СУБД:
    • MySQL: STORED згенеровані колонки + B-tree індекси; уникайте JSON_EXTRACT у гарячих WHERE.
    • PostgreSQL: expression-індекси для гарячих шляхів; GIN для containment/перевірки наявності; вибирайте клас оператора свідомо.
  5. Встановіть бюджети розміру payload (м’які й жорсткі обмеження). Ріст JSON безшумний, поки не стане критичним.
  6. Плануйте еволюцію: додайте schema_version, документуйте трансформації і зробіть міграції рутиною.

Якщо ви вже випустили і все повільно

  1. Знайдіть топ-3 запити за сумарним часом (slow log / pg_stat_statements).
  2. Запустіть EXPLAIN з реальністю (MySQL EXPLAIN, Postgres EXPLAIN ANALYZE BUFFERS). Не гадати.
  3. Додайте найменший індекс, що відповідає предикату (індекс на згенеровану колонку або expression-індекс) і перевірте зміну плану.
  4. Виміряйте вартість на стороні запису після індексації (латентність комітів, швидкість WAL/binlog, відставання реплік).
  5. Якщо оновлень багато, виокреміть змінні поля з JSON у окрему таблицю з правильним ключем.
  6. Введіть обмеження для ad-hoc запитів (таймаути, read replicas або виділений шлях для звітності).

Якщо ви вирішуєте між MySQL і PostgreSQL для JSON сьогодні

  • Вибирайте PostgreSQL, якщо запити по JSON — це продуктова фіча, а не реалізаційна деталь.
  • Вибирайте MySQL, якщо JSON переважно для зберігання і ви готові проектувати гарячі ключі в індексовані згенеровані колонки.
  • Оберіть СУБД, якою ваша команда вміє керувати в умовах інциденту. Теоретично краща функціональність не підніме вашого on-call о 3-й ранку.

FAQ

1) Чи завжди PostgreSQL кращий для JSON, ніж MySQL?

Ні. PostgreSQL зазвичай кращий для складних запитів і гнучкої індексації. MySQL може бути відмінним, якщо ви тримаєте використання JSON простим
або проектуєте гарячі шляхи в згенеровані колонки. «Завжди» — це шлях до аутейджів.

2) Чи варто зберігати JSON як TEXT/VARCHAR натомість?

Зазвичай ні. Ви втрачаєте валідацію і багато JSON-операторів. Якщо ви справді ніколи не запитуєте внутрішнє вміст JSON і лише зберігаєте/отримуєте його,
TEXT може працювати — але ви берете на себе ризик гігієни даних. Нативні типи JSON безпечніші для коректності.

3) Коли ключ JSON має стати реальною колонкою?

Якщо він використовується в join-ах, у гарячих WHERE, для сортування або потрібен для обмежень — це колонка. Якщо часто оновлюється,
скоріше за все, це колонка або окрема таблиця. JSON — для змінності, але не для основної ідентичності.

4) Чи вирішують GIN-індекси проблеми продуктивності JSONB у PostgreSQL?

Вони вирішують деякі проблеми. Але можуть створити інші (вартість записів, роздування, обслуговування).
Використовуйте GIN, коли ваші предикати відповідають containment/перевірці наявності і дані стабільні настільки, щоб виправдати write tax.

5) Який еквівалент GIN-індексу JSONB у MySQL?

Прямого еквіваленту немає. У MySQL зазвичай створюють згенеровані колонки, що витягують скалярні значення, і індексують їх.
Це інша філософія: ви вирішуєте заздалегідь, що важливо.

6) Як запобігти «випадковим ключам скрізь» у JSON?

Ставтеся до JSON як до контракту: версіонуйте його, валідовуйте і документуйте дозволені форми.
Примусьте критичні інваріанти базою даних (Postgres) або через згенеровані колонки + NOT NULL/касти типів (MySQL).

7) Чому часткові оновлення JSON досі дорогі?

Бо «часткове оновлення» на рівні SQL може все одно означати значне перезаписування й churn індексів на рівні зберігання,
плюс обсяг WAL/binlog. Великі документи, що часто оновлюються, будуть дорогими незалежно від того, як гарно виглядає SQL.

8) Чи можна використовувати JSON для мульти-тенантних даних і просто фільтрувати по tenant_id всередині JSON?

Можна, але не варто. Ізоляція орендарів належить реальній колонці з індексом.
Покладання tenant_id у JSON полегшує випадкове сканування по орендарях і ускладнює примусові обмеження й межі продуктивності.

9) Який найбезпечніший «гібридний» шаблон?

Зберігайте ключові поля як колонки (id, status, timestamps, foreign keys), зберігайте опційні/вендор-специфічні поля в JSON/JSONB,
і індексуйте лише невелику підмножину JSON-шляхів, які ви фактично запитуєте. Решта лишається гнучкою без росту витрат на основні запити.

Висновок: наступні кроки, що не принизять вас пізніше

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

Практичні наступні кроки:

  1. Визначте топ-3 JSON-запити за загальним часом і запустіть EXPLAIN з реальними статистиками виконання.
  2. Підніміть топ-3 ключі JSON, які використовуються для фільтрації/джоїнів, у колонки або згенеровані/expression колонки й індексуйте їх.
  3. Виміряйте ампліфікацію записів (WAL/binlog rate) до і після індексації; слідкуйте за відставанням реплік.
  4. Введіть бюджет розміру payload і застосовуйте його на етапі інгесту.
  5. Версіонуйте ваші JSON-пейлоади. Інакше майбутній ви витратить вихідні на розшифровку «чому цей ключ іноді існує».

Обирайте СУБД, що відповідає операційним сильним сторонам вашої команди, а потім проектуйте використання JSON так, ніби воно стане постійним — бо так зазвичай і буває.

← Попередня
ZFS SMB: Виправлення проблеми «Копіювання у Windows повільне»
Наступна →
Кеш CPU (L1/L2/L3) простими словами: чому перемагає пам’ять

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