<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SQL · MIDDLE

Як зберігати й індексувати JSON у PostgreSQL і MySQL, і коли це помилка?

У PostgreSQL зберігайте jsonb і індексуйте його GIN для пошуку по довільних ключах або B-tree на виразі `(data->>'key')` для конкретного поля; у MySQL індекс на JSON можливий лише через згенеровану колонку, функціональний індекс (8.0.13+) або multi-valued index (8.0.17+). Помилка — тримати в JSON поля, які є в кожному рядку і за якими фільтрують чи джойнять: там потрібні звичайні колонки з типом, NOT NULL і зовнішнім ключем.

Чим json відрізняється від jsonb і що брати за замовчуванням?
Як прискорити `WHERE data->>'status' = 'paid'`, якщо в таблиці мільйон рядків?
Чому в MySQL не можна просто повісити індекс на колонку типу JSON?
Ми тримаємо всі атрибути товару в JSON-полі — що з цим не так?
JSON jsonb GIN індекси PostgreSQL MySQL

У PostgreSQL є два типи: json зберігає текст документа дослівно — з пробілами, порядком і навіть дублікатами ключів, — і парсить його наново при кожному зверненні; jsonb розбирає документ один раз на запис у бінарне подання, де ключі відсортовані й унікальні. Практично завжди потрібен jsonb: тільки він підтримує оператори @>, ?, ?|, ?& і тільки його можна проіндексувати GIN. Тип json виправданий хіба що для сирого логування, де важливо зберегти байти як прийшли. У MySQL тип один — JSON (з 5.7.8), він теж бінарний і теж нормалізує документ, а перевірка валідності відбувається на вставці.

Індексація йде двома різними шляхами, і сильна відповідь називає обидва. GIN по jsonb індексує вміст документа й відповідає на питання «чи містить документ ось цей фрагмент» (meta @> '{"source":"webhook"}') та «чи є такий ключ» (meta ? 'source'); з PG 12 туди ж потрапляють jsonpath-оператори @? і @@. Клас операторів jsonb_path_ops індексує хеші повних шляхів: індекс менший і швидший, але вміє лише containment. Якщо ж запит завжди звертається до одного відомого поля, GIN зайвий — потрібен звичайний B-tree на виразі: CREATE INDEX ON orders ((meta->>'utm_source')). Такий індекс, на відміну від GIN, дає ще й статистику по виразу після ANALYZE, тому планувальник перестає вгадувати кардинальність.

MySQL прямий індекс на JSON-колонці забороняє взагалі. Класичний шлях — згенерована колонка: ADD COLUMN utm_source VARCHAR(64) AS (meta->>'$.utm_source') STORED плюс індекс на ній; VIRTUAL теж індексується і не займає місця в рядку. З 8.0.13 те саме можна записати функціональним індексом з обовʼязковим CAST, але всередині MySQL усе одно створює приховану віртуальну колонку. Для масивів з 8.0.17 є multi-valued index — єдиний випадок, коли один рядок дає кілька записів в індексі; він працює з MEMBER OF, JSON_CONTAINS і JSON_OVERLAPS, але не годиться для сортування, унікальності й первинного ключа. Головна пастка в обох варіантах — типи й колація: вираз в індексі має збігатися з виразом у WHERE посимвольно, інакше EXPLAIN мовчки покаже ALL.

Ціна JSON платиться на записі й на читанні великих документів. У PostgreSQL будь-який UPDATE через MVCC створює нову версію рядка цілком — часткового оновлення поля всередині jsonb не існує; документ більший за пару кілобайтів їде в TOAST у стиснутому вигляді, і щоб дістати один ключ, його треба прочитати й розтиснути повністю. GIN додає помітну вартість вставки й має pending list, через який щойно записані рядки шукаються повільніше, доки не відпрацює чистка. У MySQL оновлення на місці можливе, але лише для JSON_SET, JSON_REPLACE і JSON_REMOVE і лише якщо документ не зростає; будь-яка інша зміна переписує значення повністю.

Помилка починається там, де JSON заміняє схему. Якщо поле є в кожному рядку, має тип, за ним фільтрують, сортують чи джойнять — це колонка, а не ключ у документі: інакше ви втрачаєте NOT NULL, зовнішній ключ, нормальну статистику, а помилка в назві ключа не викликає помилки взагалі, meta->>'statuss' тихо повертає NULL. Обмеження частково рятують — CHECK (jsonb_typeof(meta->'items') = 'array'), CHECK (meta ? 'version'), унікальний індекс на виразі, у MySQL CHECK на JSON-функціях з 8.0.16, — але вони не замінять зовнішній ключ. Розумна межа проста: у колонки виносимо все обовʼязкове й запитуване, у JSON лишаємо розріджені атрибути, payload зовнішніх систем, снапшоти й налаштування, форма яких змінюється швидше, ніж ви готові писати міграції.

-- PostgreSQL: тільки jsonb, json індексувати не можна
CREATE TABLE orders (
    id      bigserial PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users(id),  -- завжди є → звичайна колонка
    status  text   NOT NULL,                       -- фільтруємо → звичайна колонка
    meta    jsonb  NOT NULL DEFAULT '{}'           -- сюди тільки змінне
);

-- GIN: пошук по довільному ключу через containment @> і наявність ключа ?
CREATE INDEX orders_meta_gin ON orders USING gin (meta);
SELECT id FROM orders WHERE meta @> '{"source":"webhook"}';

-- jsonb_path_ops: менший і швидший, але лише @> (без оператора ?)
CREATE INDEX orders_meta_path ON orders USING gin (meta jsonb_path_ops);

-- B-tree на виразі: під рівність/діапазон/сортування по одному полю
-- (дає ще й статистику для планувальника після ANALYZE)
CREATE INDEX orders_meta_utm ON orders ((meta->>'utm_source'));
SELECT id FROM orders WHERE meta->>'utm_source' = 'google';

-- Число з JSON порівнюємо після приведення, не як рядок
CREATE INDEX orders_meta_amount ON orders (((meta->>'amount')::numeric));

-- MySQL 8: прямий індекс на JSON заборонений, потрібна згенерована колонка
ALTER TABLE orders
    ADD COLUMN utm_source VARCHAR(64)
        AS (meta->>'$.utm_source') STORED,
    ADD INDEX idx_utm (utm_source);

-- Або функціональний індекс (8.0.13+): CAST обовʼязковий
CREATE INDEX idx_utm_fn ON orders ((CAST(meta->>'$.utm_source' AS CHAR(64))));

-- Multi-valued index для масиву (8.0.17+): працює з MEMBER OF / JSON_CONTAINS
ALTER TABLE orders ADD INDEX idx_tags ((CAST(meta->'$.tags' AS CHAR(32) ARRAY)));
SELECT id FROM orders WHERE 'urgent' MEMBER OF(meta->'$.tags');
Різницю json і jsonb: json зберігає текст як є (пробіли, порядок і дублікати ключів), jsonb — розібране бінарне подання, ключі відсортовані й унікальні, парсинг на запис, а не на читання; індексувати можна лише jsonb.
Що GIN індексує вміст документа й обслуговує оператори `@>`, `?`, `?|`, `?&` (і `@?`/`@@` з jsonpath у PG 12+), а B-tree на виразі `(data->>'key')` — це звичайний індекс під рівність, діапазон і сортування по одному полю.
Що в MySQL індекс на колонці JSON створити не можна: потрібна STORED/VIRTUAL generated column з індексом, функціональний індекс з обовʼязковим CAST (8.0.13+) або multi-valued index для масивів під `MEMBER OF`, `JSON_CONTAINS`, `JSON_OVERLAPS` (8.0.17+).
Що JSON коштує на запис: у PostgreSQL UPDATE переписує весь рядок через MVCC, великий jsonb їде в TOAST і читання одного ключа розтискає весь документ; у MySQL часткове оновлення на місці працює лише для JSON_SET/JSON_REPLACE/JSON_REMOVE і лише якщо документ не зростає.
Критерій вибору: JSON — для розріджених, різнорідних або зовнішніх даних (payload вебхука, налаштування, снапшот); звичайні колонки — для того, що є завжди, має тип, обмеження, зовнішній ключ і бере участь у фільтрах та джойнах.
Що планувальник погано оцінює селективність по JSON: для виразу `data->>'key'` статистики немає, поки не створено індекс на цьому ж виразі (PG) або згенеровану колонку (MySQL), тому оцінка кардинальності буває на порядки хибною.
Обрати тип `json` замість `jsonb` у PostgreSQL «бо коротша назва»: по `json` не можна побудувати GIN і кожне читання ключа заново парсить текст.
У MySQL написати `CREATE INDEX ... ON t (data)` для JSON-колонки й здивуватися помилці: прямий індекс на JSON заборонений (як і на BLOB/TEXT без довжини префікса).
Створити GIN-індекс і чекати, що він прискорить `data->>'status' = 'paid'`: GIN обслуговує `@>` і `?`, а не `->>`; або запит переписують на `data @> '{"status":"paid"}'`, або будують B-tree на виразі.
У MySQL зробити функціональний індекс без CAST і без узгодженої колації: `((data->>'$.email'))` не приймається, потрібен `CAST(data->>'$.email' AS CHAR(191))` і той самий COLLATE, що й у запиті, інакше індекс мовчки не використається.
Класти в JSON `user_id`, `status`, `created_at` — поля, які є в кожному рядку: втрачаються NOT NULL, зовнішній ключ, тип і нормальна статистика, а кожен запит обростає кастами.
Порівнювати числа з JSON як рядки: `data->>'price' > '100'` — це лексикографічне порівняння, потрібен явний `(data->>'price')::numeric` у PG або CAST у MySQL.
ПОРАДА

Сформулюйте правило одним реченням: «JSON — для того, чого ми не знаємо заздалегідь; колонки — для того, за чим фільтруємо». А далі покажіть, що знаєте обидва шляхи індексації: GIN для пошуку по довільному ключу, B-tree на виразі — для конкретного, і що в MySQL це завжди generated column під капотом.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

GIN індексує вміст документа під `@>`, `?`, `?|`, `?&` (і jsonpath-оператори з PG 12), але вираз `->>` він не обслуговує — під нього створюють B-tree на тому самому виразі. Індексувати в PostgreSQL можна лише jsonb, а в MySQL прямий індекс на JSON-колонці заборонений.