Ein GIN, der die Listenquery nie sah
WHERE title->>'ru' ILIKE scannte fünfzigtausend JSONB-Zeilen. Der GIN war für Tags. Der Katalog zahlte den Extract.
Der Katalog lag auf PostgreSQL, weil JSONB und GIN wie ein kostenloser Suchindex wirkten. Das öffentliche Raster filterte publizierte SKUs und suchte den russischen Titel mit title->>'ru' ILIKE. EXPLAIN sagte Seq Scan, rows 50000. CPU saß auf Extract. Der GIN auf payload kam nicht in den Plan.
GIN trifft Containment. Die Liste wollte Text. Vue SSR bekam trotzdem das volle JSONB und wählte die Locale in PHP. Der Buffer Cache hielt Dokumente, die die Seite wegwarf.
Das Problem war, GIN als Title-Index zu behandeln. Besucher zahlten einen Seq Scan über fünfzigtausend JSONB-Zeilen. Ich brauchte eine gespeicherte Textspalte, einen Btree auf dem Raster und Extract vom Hot Path weg.
Die generierte Spalte
title_ru ist eine stored generated Spalte. Das Listen-SELECT ist id, slug, price, title_ru. Ein Btree auf (is_published, sort_order, id) deckt das Raster. Titelsuche, wenn es sie gibt, nutzt diese Textspalte, keinen Extract im WHERE. Tags behalten GIN und @>.
Was PHP ließ
json_decode verließ die Liste. Der Worker schickt einen Locale-String an Vue SSR. Admin editiert weiter JSONB. Der öffentliche Pfad sieht aus wie der MySQL-Shop auf dieser Site: kompakte Zeilen, Covering-Index, JSON vom Hot Path weg.
ILIKE auf einer generated Textspalte kann einen Trigram-Index nutzen, wenn Suche bleibt. Der Listenfilter tut das nicht. Published plus sort_order reicht für das Raster. Suche ist eine zweite Query, kein Anhang an jeder Seite.
Was ich daraus mitnehme
GIN ist Containment. title->> ILIKE ist Seq Scan, bis der Text in einer eigenen Spalte lebt.
JSONB ist ein Schreibformat. Das öffentliche Raster sind typisierte Spalten. Der MySQL-Filesort-Fall auf dieser Site ist dieselbe Lektion in einer anderen Engine.
EXPLAIN ist billiger als Cache. Der GIN war gesund und ungenutzt. Die Query fragte nie nach ihm.
