-- Index optimizat strict pentru operatorul de containment (@>)
CREATE INDEX idx_products_specs ON products USING gin (specs jsonb_path_ops);
-- Query care consumă eficient indexul
EXPLAIN ANALYZE
SELECT id, title, price
FROM products
WHERE specs @> '{"color": "black", "ram": "16GB"}';Nu arunca totul într-un JSONB doar pentru că îți e lene să scrii o migrație. Am văzut echipe care au transformat Postgres într-un MongoDB prost gândit, iar după un an plângeau când trebuiau să facă un simplu join sau un raport agregat.
JSONB este o unealtă excelentă dacă înțelegi exact pentru ce a fost creat: date semistructurate, atribute dinamice și payload-uri de la integrări terțe.
Când are sens JSONB și când e o greșeală
Regula mea de aur după zece ani de lovit cu capul de pragul de sus e simplă: dacă o cheie din JSON dictează logica de business, dacă o filtrezi în 90% din query-uri sau dacă are o relație de tip foreign key cu alt tabel, pune-o într-o coloană normală. Nu vrei user_id, status sau total_amount îngropate într-un JSON.
La un marketplace B2B cu vreo 1.4 milioane de produse, aveam categorii extrem de diverse: de la șuruburi industriale cu zece dimensiuni ciudate până la echipamente medicale cu specificații stufoase. Să faci tabele EAV (Entity-Attribute-Value) pentru 200 de atribute dinamice e un coșmar de mentenanță și omoară performanța cu zeci de join-uri. Acolo JSONB a strălucit. Am păstrat coloanele clasice pentru preț, stoc, SKU și titlu, iar restul specificațiilor le-am împachetat într-o coloană specs de tip JSONB.
Diferența critică între GIN standard și jsonb_path_ops
Mulți devopsi sau backend devs pun reflex CREATE INDEX USING gin (specs); și consideră treaba terminată. Problema e că indexul GIN standard indexează fiecare cheie, fiecare valoare și fiecare sub-element separat. Dacă documentul tău e mare, indexul devine masiv — ocupă pe disc aproape cât tabelul în sine.
În proiectul respectiv, căutările din catalog erau de tip filtrare: utilizatorul bifa „culoare: negru” și „RAM: 16GB”. Pentru operatorul de containment (@>), nu ai nevoie de indexul GIN complet. Poți folosi operator class-ul jsonb_path_ops.
Ce am câștigat când am trecut la jsonb_path_ops? Dimensiunea indexului a scăzut cu aproape 60%, iar query-ul de căutare a coborât de la 920ms (fără index, seq scan curat) la 4ms. În plus, costul de inserție pe fiecare rând nou a scăzut simțitor pentru că arborele indexului e mult mai compact.
Trade-off-uri pe care nu ți le spune nimeni
JSONB vine la pachet cu o factură ascunsă: update-urile. Postgres folosește MVCC. Asta înseamnă că dacă tu modifici o singură proprietate adâncă dintr-un payload JSONB de 4KB (folosind jsonb_set), Postgres scrie un rând complet nou pe disc și îl marchează pe cel vechi ca mort.
Dacă ai un rând pe care îl actualizezi de 50 de ori pe secundă, JSONB îți va genera un volum uriaș de „dead tuples”, discul se va umple, iar autovacuum va munci din greu să țină pasul. Pentru date cu rată mare de mutație, coloanele native rămân mult superioare.
Al doilea compromis: pierzi validarea la nivel de schemă. Dacă cineva trimite din greșeală "ram": "16" ca string în loc de integer sau greșește un casing (ram vs RAM), Postgres va înghiți payload-ul fără să clipească, iar query-urile tale ulterioare vor rata rezultatele silențios.
JSONB câștigă detașat la atribute dinamice, setări de utilizator și stocare de webhook-uri, dar pierde urât la date cu modificări dese sau la entități care cer integritate referențială strictă. Voi unde ați simțit cel mai tare limitele lui JSONB în producție?