-- Crearea tabelei cu coloană JSONB
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
sku VARCHAR(64) NOT NULL UNIQUE,
price NUMERIC(10, 2) NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- Index GIN optimizat pentru operatorul @> (containment)
CREATE INDEX idx_products_attributes_path
ON products USING gin (attributes jsonb_path_ops);
-- Query rapid care folosește indexul GIN
SELECT id, sku, price
FROM products
WHERE attributes @> '{"brand": "Sony", "specs": {"4k": true}}';Am văzut prea multe echipe care folosesc JSONB doar ca să scape de migrații și de scris scheme relaționale. JSONB e genial când ai atribute dinamice sau payload-uri externe, dar dacă îl tratezi ca pe un coș de gunoi nestructurat, ajungi rapid să regreți decizia.
Când are sens și când îți tragi un glonț în picior
Acum vreo trei ani, la un catalog de produse cu vreo 400k SKU-uri, aveam clasica problemă: fiecare categorie avea zeci de specificații tehnice diferite (pantofi cu mărimi și culori, televizoare cu diagonală și rezoluție). Să faci tabele EAV (Entity-Attribute-Value) însemna join-uri imposibile și interogări lente. Să adaugi 200 de coloane nullable în tabela de produse era o altă mizerie.
Aici JSONB a strălucit. Am păstrat datele tranzacționale de bază (id, titlu, preț, stoc, sku, status) în coloane normale, iar specificațiile tehnice le-am băgat într-o coloană metadata de tip JSONB.
Trade-off-ul e simplu: JSONB merge excelent pentru date semi-structurate pe care le citești des și le filtrezi rar sau specific. În schimb, e nasol dacă ai nevoie de constrângeri de cheie străină (Foreign Key), dacă modifici frecvent proprietăți individuale din JSON sau dacă ai nevoie de agregări matematice la nivel de coloană. În Postgres, un update pe o cheie dintr-un JSONB rescrie tot rândul (sau bucata din TOAST), ceea ce generează un bloat masiv la scrieri frecvente.
Indexarea cu GIN: diferența de la 500ms la 2ms
Fără index, un query cu filtrare pe o cheie JSONB face sequential scan pe toată tabela. La câteva mii de rânduri nu simți, dar pe la 200k rânduri timpul de răspuns sare lejer de 400-500ms.
Soluția este un index GIN (Generalized Inverted Index). Însă multă lume scrie simplu CREATE INDEX ON table USING gin (data); fără să știe că există două moduri de operare:
jsonb_ops(default): indexează fiecare cheie și valoare separat. Permite căutări pe chei, valori sau existența unor căi, dar indexul rezultat este masiv pe disc.jsonb_path_ops: indexează doar combinațiile întregi de tip path-hash. Suportă doar operatorul de incluziune (@>), dar indexul este de 3-4 ori mai mic și căutările sunt considerabil mai rapide.
În exemplul de cod de mai jos am folosit jsonb_path_ops pentru un query de tip containment (@>). La proiectul cu produsele, trecerea la indexul ăsta pe atribute ne-a dus un filtru complex de pe interfață de la 420ms la 3ms.
Costul ascuns: scrierile și spațiul pe disc
Indexul GIN nu vine pe gratis. Dacă ai o tabelă cu 100 de insert-uri pe secundă, un index GIN pe un JSON stufos va îngreuna vizibil scrierile, pentru că Postgres trebuie să spargă obiectul în atomi și să actualizeze arborele inversat la fiecare insert.
Regula mea de bază după atâția ani e clară: dacă știi dinainte structura și vrei să filtrezi pe ea în 90% din cazuri, pune coloană normală cu tip strict. Dacă structura e volatilă sau depinde de integrări third-party, folosește JSONB indexat cu GIN, dar nu uita să monitorizezi dimensiunea indexului.
Voi cum gestionați proprietățile dinamice în producție: JSONB dedicat sau mergeți încă pe tabele clasice EAV?