Zum Inhalt

V1 Grid-Performance-Analyse — Variable Grids bei großen Datenbeständen (OpenShift)

Zweck: Vollständige Architektur- und Hotspot-Analyse der V1-Grids (Yii 1.1, httpdocs/), damit spätere Sessions/PRs direkt auf dieser Referenz aufsetzen können, ohne den Code erneut explorieren zu müssen. Alle Aussagen sind mit Datei:Zeile belegt (Stand: 2026-07, develop).

Anlass: Auf OpenShift ist der Aufbau großer Grids (z. B. Inventar) und das Öffnen von Detailmasken extrem langsam. Referenz-Datenbestand (Kunde Heilkunde): ~50.000 Inventare, ~2 Mio. Kalibrierungen. Auf einer VDI mit Docker direkt auf schneller Hardware tritt das Problem nicht auf. Gesucht: Beschleunigung der Datenanzeige ohne Caching-Mechanismen.

Verwandte Dokumente: - analysis-v1-datatable.md — Struktur-Referenz des DataTable-Systems - v1-grid-fieldmanagement-analysis.md — Feldmanagement - GRID_OVERVIEW.md (Repo-Root) — Katalog aller ~161 Grids inkl. v1/v2-Status - OPENSHIFT_DEBUG_SUMMARY.md — OpenShift-Stabilisierung 2026-03


1. Kernaussage (TL;DR)

Die Grid-Datenquery selbst ist nicht der dominante Kostenfaktor. Die Zeit geht in viele kleine, serielle DB-Roundtrips pro Request (Übersetzungen, N+1-Lazy-Loads pro Zeile, Konfigurations-Lookups, Infrastruktur-Proben) sowie — bei Sortierung/Filterung über Kalibrierfelder — in eine ungünstige Join-Form auf die 2-Mio-Tabelle calibration.

Warum OpenShift besonders leidet: Die Query-Anzahl wird mit der Netzwerk-Latenz zur DB multipliziert. Beispielrechnung: 400 Queries/Request × 0,05 ms (Docker, DB auf demselben Host) = 20 ms — unsichtbar. Dieselben 400 Queries × 2–5 ms (OpenShift, DB als Service über das Pod-Netz) = 0,8–2 s pro Request, zzgl. File-I/O auf NFS/PVC. Der Code ist also nicht „auf OpenShift kaputt", sondern latenz-empfindlich — und große Datenbestände erhöhen zusätzlich sowohl die Query-Anzahl (Übersetzungsmodus „Version 2", siehe 4.1) als auch die Kosten der Einzelqueries (Joins auf calibration).

Die fünf größten Hebel (ohne Caching), Details in Abschnitt 6:

# Maßnahme Wirkung Aufwand
1 tt()-Übersetzungen: Bulk-Load pro Kategorie statt 1 Query pro String (Version-2-Modus) eliminiert hunderte Queries/Request auf jeder Seite klein
2 N+1 im Grid-Rendering beseitigen (Kategorie-Farbe, duedate/lastcal, last_certificate, lastUser → Batch-Load pro Seite) eliminiert ~1–4 Queries pro Zeile mittel
3 CacheHelper::flushAll() aus KTbExtendedGridView::beginCache/endCache entfernen stoppt die Selbstzerstörung von Schema-/Query-Cache bei jedem Grid-AJAX klein
4 Fällige/letzte Kalibrierung denormalisieren (Spalten auf inventory, per Save-Hook gepflegt) eliminiert Join/GROUP BY auf 2-Mio-Tabelle in Grid, Header-Countern und Dashboard; macht duedate-Sort/Filter indexfähig mittel–groß
5 Index-Lücken schließen: calibration.C2303, inventory.I4201; 2026er-Index-Migrationen auf Kundensystemen verifizieren Sort/Filter „Fällig am" ohne Filesort; Hierarchie-Joins indexiert klein

2. Architektur: Request-Fluss der variablen Grids

2.1 Beteiligte Bausteine

Baustein Datei Rolle
Controller-Action httpdocs/protected/modules/frontend/controllers/FrontendInventoryController.php:531 (actionAdmin) Filter-/Sort-Parameter → Model, wählt View (v1 admin / v2 admin_v2 per Flag features/grid/use_legacy_inventory, Z. 754–765, 784–795)
Haupt-View .../views/frontendInventory/admin_v2.php Seite: Tabs, Action-Menü, Modals; inkludiert _columns_v2.php + _admin_v2.php
Spalten-Aufbau .../views/frontendInventory/_columns_v2.php Action-Buttons + FrontendProperty::getGridViewShownColumns() (Z. 202) → nur sichtbare Spalten; der volle Spaltenkatalog lädt lazy per loadColumns-AJAX (Z. 175–195)
Spalten-Fabrik httpdocs/protected/modules/frontend/components/GridColumn.php:382 (getColumn, 10.862 Zeilen, 558 cases) erzeugt pro sichtbarer Spalte die Widget-Config inkl. value-Expressions, Filter, Editable-Setup
Feldkonfiguration field_configuration-Tabelle via InventoryHelper::getTableFields/getFieldsFaster (httpdocs/protected/helpers/InventoryHelper.php:445–731) definiert verfügbare/sichtbare/editierbare Felder je Tabelle; nur per-Request statisch memoisiert
Sichtbare Spalten FrontendProperty::getVisibleColumns (FrontendProperty.php:1131) → FrontendTableViews::getStandardView (FrontendTableViews.php:135) Benutzer-/Standard-Tabellenansicht (1–2 Queries, läuft doppelt: in search() und in _columns)
DataProvider FrontendInventory::search() (FrontendInventory.php:2912–3873) baut KCDbCriteria (Filter aus ~70 compare()-Aufrufen), Relationen, Sort-Attribute; separater Count
Provider-Klasse httpdocs/protected/components/KCActiveDataProvider.php pageSize-Handling ('all'PHP_INT_MAX, Z. 83–85!), Query-Cache-Hook (cache_duration = 3600, Z. 47)
Grid-Widget httpdocs/protected/components/KTbExtendedGridView.php (+ KGridViewTrait.php, GridViewHelper.php) HTML-Rendering, Fragment-Cache, Bulk-Actions, Summary/Pager
Zellen KTbDataColumn, KTbEditableColumn/KTbEditableField, TButtonColumn, KCCheckBoxColumn (httpdocs/protected/components/) pro Zelle evaluateExpression; Editable = volles Widget pro Zelle
Übersetzung tt() (helpers/helperFunctions.php:822) → AdminMessage::tt (modules/adminpanel/models/AdminMessage.php:66–326) DB-basiert, ohne Cross-Request-Cache (Kernbefund 4.1)

2.2 Ablauf

Erstaufruf (Nicht-AJAX): Layout (httpdocs/themes/calhelp/views/layouts/main.php) mit ~175 tt()-Aufrufen und 2 Fällig-Zählern (FrontendInventory::getTotalNotification('due'/'overdue'), Layout Z. 170–171 → FrontendInventory.php:5486) → admin_v2.php_columns_v2.php (Spalten) → _admin_v2.php mit $dataProvider = $model->search() (Z. 99) → Widget rendert Filter-Zeile, Datenzeilen, Summary, Pager. Das Grid wird beim Erstaufruf mit Daten gerendert (kein Deferred-Load; anders als das Kalibrier-Grid, s. 2.3).

AJAX-Update (jede Filter-Eingabe, jeder Sort-Klick, jeder Seitenwechsel via $.fn.yiiGridView.update): gleiche Action mit $_GET['ajax'] = 'inventory-grid'renderPartial('admin_v2') (Controller Z. 754–765) → kompletter Neuaufbau von Spalten, search(), Count und HTML. Es gibt kein JSON-/Delta-Protokoll — jede Interaktion ist ein voller Server-Render des Grid-Fragments.

Fragment-Cache: KTbExtendedGridView::run() (Z. 115–138) umschließt die Ausgabe mit COutputCache — Key User_<id>_Language_<lang>_<controller>_<gridId>, varyByExpression = http_build_query($_GET) (Z. 79–80), Dauer Controller::getCacheDuration() = Property settings/cache/duration, Default 120 s (Controller.php:18,693–708). Da der Key über alle GET-Parameter variiert, trifft er bei interaktiver Filterei praktisch nie — und wird durch den flushAll-Bug (4.3) ohnehin sofort wieder gelöscht.

2.3 Detailmasken

  • actionView (FrontendInventoryController.php:843): lädt das Inventar ohne with() (Z. 863); view.php (1.689 Zeilen) rendert ~11 Tab-Grids (Kalibrierungen, Repair, Location, Notepad, Orders, Child-Inventory, DMS, Tickets, Audit-Historie, Mailer-Actions, Specifications). Relationen (customer, lastUser, lastRental, type) werden lazy im View nachgeladen. Workaround im Code: @set_time_limit(300); @ini_set('memory_limit','512M') (Z. 854–855) — der Kommentar (Z. 845–853) beschreibt das eigentliche Problem: pro Kalibrier-Zeile wird ein FrontendCalibration-AR inkl. inventory/customer/type/standards/ results/lastRental/DMS-Relationen hydriert.
  • Das Kalibrier-Grid im Detail lädt deferred: FrontendCalibration::search() liefert bei Nicht-AJAX einen leeren Provider (FrontendCalibration.php:3287–3292); die Daten kommen per Grid-AJAX nach. Sortierung C2301 DESC, C2333 DESC, C2339 DESC (Z. 3794–3796) + Filter t.MTAG = <id> (Z. 3379) ist seit Jan-2026 durch index_MTAG_C2301_C2333_C2339 gedeckt.
  • Teure Per-Zeilen-Pfade im Kalibrier-Grid: Dokument-/Zertifikat-Spalten → getDocumentUrl()Model::loadMergedRelations() (Model.php:327–341) = 1 Query pro Zeile, wenn die cert-Relationen nicht vorgeladen sind.

3. Query-Ebene: Anatomie von FrontendInventory::search()

FrontendInventory.php:2912–3873. Positiv: SELECT enthält nur sichtbare Felder (Z. 2986), der Count läuft mit vereinfachtem Criteria (Bedingungen ja, Anzeige-Joins nein, Z. 3810–3828), totalItemCount wird genau einmal berechnet und via setTotalItemCount() gesetzt (Z. 3855–3858).

Relation (FrontendInventory.php:342–347):

'activeCalibrationGrid' => array(
    self::HAS_ONE, 'FrontendCalibration', 'MTAG',
    'on' => '(activeCalibrationGrid.C2339 = 1 OR activeCalibrationGrid.C2339 IS NULL)',
    'joinType' => 'LEFT JOIN',
    'together' => true
),

Aktiviert bei: duedate-Filter (Z. 3259–3308, u. a. Dashboard-Links ?notification=due|overdue), lastcal (Z. 3310–3346), last_order_number (Z. 3348–3353), Sortierung auf diese Spalten (Z. 3682–3713), Advanced-Filter mit Kalibrier-Variablen (Z. 3178–3181). Resultierende Form:

SELECT t.<felder> FROM inventory t
LEFT JOIN calibration acg ON acg.MTAG = t.MTAG AND (acg.C2339 = 1 OR acg.C2339 IS NULL)
[JOIN map_user / status / customers / types ...]
WHERE ... AND acg.C2303 < :heute ...
GROUP BY t.MTAG                -- Dedup, s. 3.2
ORDER BY acg.C2303 ASC         -- kein Index auf C2303!
LIMIT 10;
-- plus identisch gefilterte COUNT-Query (Z. 3855)

Probleme: 1. OR C2339 IS NULL verhindert einen sauberen Index-Zugriff auf (MTAG, C2339) und lässt bei Altdaten (NULL-Flag) alle Kalibrierungen eines Geräts in den Join laufen (Fan-out Richtung 2 Mio. Zeilen). 2. GROUP BY t.MTAG über das Join-Ergebnis + ORDER BY auf der Join-Spalte C2303 → volle Materialisierung + Filesort bei jedem Request; LIMIT 10 hilft nicht. 3. C2303 (Fällig-Datum) hat in keiner Migration einen Index (geprüft: alle add_missing_indexes*-Migrationen). 4. Die COUNT-Query wiederholt denselben Join (relationsCount, Z. 3816–3823).

3.2 Dauerhaftes GROUP BY t.MTAG

shouldDeduplicateInventoryGrid() (Z. 2798–2810) ist per Default an (abschaltbar nur via ENV ENABLE_INVENTORY_GRID_DEDUP=0) → die Datenquery hat immer GROUP BY t.MTAG (Z. 2989–2994), auch ohne jeden Join. Nötig ist das nur, wenn HAS_MANY-Relationen (mapusers, Dokument-Relationen) together gejoint werden und Zeilen duplizieren.

3.3 Sichtbarkeits-Join für Nicht-Global-User

Ohne inventory_global-Recht wird mapusers (HAS_MANY!) together gejoint + auf mapusers.user_uID = <user> gefiltert (Z. 3076–3081) → Zeilen-Fan-out (daher das GROUP BY) statt eines EXISTS-Subselects. map_user selbst ist gut indiziert (m130220_194523_install.php:320–327).

3.4 Filter-Semantik

  • Text-Filter → LIKE '%wert%' (prepareSearchValue, helperFunctions.php:8483–8501) — Contains-Suche, kein Index nutzbar (bei 50k Inventarzeilen allein akzeptabel, in Kombination mit Joins teuer).
  • „Exakt"-Modus → BINARY spalte = wert (KCDbCriteria.php:186) — der BINARY-Cast auf der Spalte verhindert ebenfalls Indexnutzung.
  • Status-/Zustands-Startfilter joinen status (LEFT JOIN + OR-Kette, addSomeFilters, Z. 7853–7861) — unkritisch klein, aber Teil jeder Query.

3.5 Indizes (Migrationsstand im Repo)

Tabelle Vorhanden (Auszug) Fehlt
calibration PK CTAG; UNIQUE (C2301,C2333,MTAG) (m161114...); (C2339,MTAG) (m210407...:8); (MTAG,C2301↓,C2333↓,C2339↓) + (C2301↓,C2333↓,C2339↓) (m260117_074317_add_missing_indexes_v4.php:9,15); MTAG solo (m260528_000001_add_mtag_index_to_calibration.php:16) C2303 (due date) — nirgends
inventory PK MTAG; ktag (+FK); I4217 (m190304...:7); path (m210811...:8); I4299, (I4299,MTAG) (m260117...:21,27) I4201 (Asset-Nr.; Join-Ziel von childItems, barcode, get_path())
location (MTAG,L2815,customer_KTAG) + (MTAG,L2815,delivery_customer_KTAG) (m260117...:53,58)
messages (language_code,category_id,message_from) (m260117...:33) Nutzen durch BINARY-Vergleich beschnitten (4.1)

⚠️ Auf Kundensystemen verifizieren, ob m260117_074317 und m260528_000001 bereits gelaufen sind (SHOW INDEX FROM calibration; bzw. tbl_migration). Vor diesen Migrationen gab es keinen führenden MTAG-Index auf calibration — exakt das Szenario „Detailmaske öffnet sich minutenlang" bei 2 Mio. Kalibrierungen.


4. Query-Anzahl pro Request (der OpenShift-Killer)

4.1 Übersetzungssystem tt() — größter Einzelposten

AdminMessage::tt (AdminMessage.php:66–326), nutzt ausschließlich statische Per-Request-Maps, keinen Yii-Cache:

  • Erster tt()-Aufruf je Request: BaseAdminMessageCategory::model()->findAll() (Z. 92–97).
  • translationVersion wird bei > 10.000 Zeilen in messages automatisch auf 2 gestellt (Z. 132–147). Genau das ist bei großen/alten Installationen (Heilkunde) der Fall.
  • Version 2 (Z. 177–207): pro einzelnem String ein findByAttributes mit BINARY message_from = :message_from (Z. 186) — der BINARY-Cast neutralisiert den message_from-Teil des Index. Eine Grid-Seite (Layout ~175 tt() + Spalten-Header + Buttons + Modals) erzeugt so 200–500 serielle Einzelqueries pro Request.
  • Fallback-Pfad (Z. 234–285): weitere Query + ggf. addNewMessage() = INSERT während des Renderns (Z. 269–279) — Schreiblast auf dem Lesepfad.
  • Version 1 (≤ 10.000 Messages) lädt dagegen eine Query pro Kategorie (Z. 149–176) — der performante Modus ist paradoxerweise der für kleine Systeme.

→ Latenz-Mathematik siehe TL;DR. Dieser Pfad erklärt, warum alle Masken (nicht nur Grids) auf OpenShift zäh sind, und warum es mit wachsendem Datenbestand schlimmer wurde (10.000er-Schwelle kippt den Modus).

4.2 N+1 im Grid-Rendering (pro Zeile)

Quelle Ort Kosten
Zeilen-CSS Kategorie-Farbe: rowCssClassExpression => '$data->getClassNameForRow($row)' _admin_v2.php:141FrontendInventory.php:1991–2018, Query Z. 2010–2012 FrontendCategory ... find() pro Zeile, unbedingt, unmemoisiert — 50 Zeilen = 50 Queries, auch ohne aktivierte Kategorie-Farben
duedate / lastcal / last_order_number (Anzeige) GridColumn.php:7402,7412,7422getValueFromCalibrationField (FrontendInventory.php:1617–1628) parametrisierter Lazy-Call $this->activeCalibrationGrid([...]) = 1 Query pro Zeile (per MTAG memoisiert, Spalten teilen sie sich)
last_certificate GridColumn.php:7596getLastCertificate (FrontendInventory.php:2371–2379) lastCalibration(['with'=>['certificate1','certificate2']]) pro Zeile, unmemoisiert
last_location_customer / _delivery / L2801/L2802 GridColumn.php:7431–7458 lastUser/lastDeliveryUser/lastRental lazy = 1 Query pro Zeile (per MTAG memoisiert)
category_name-Spalte GridColumn.php:7505 Kategorie-Lookup pro Zeile
Kalibrier-Grid: Dokument-Spalten GridColumn.php:1208–1255Model.php:327–341 findByPk mit DMS-Relationen pro Zeile

Entschärft (Memoisierung pro Fremdschlüssel, nicht pro Zeile): status (pro Status-Code), type (pro Typ-ID), customer (Bulk getMyCustomers).

Wichtig: Die Anzeige-Relationen sind im DataProvider nicht eager geladen$criteria->with wird nur für Filter/Sort-Relationen gesetzt (Z. 3793–3808). Und selbst Eager-Loading würde die parametrisierten Relationsaufrufe ($this->activeCalibrationGrid(['select'=>…])) nicht bedienen — die erzwingen immer eine frische Query.

4.3 Cache-Selbstzerstörung bei jedem Grid-AJAX

KTbExtendedGridView::beginCache()/endCache() (Z. 70–108): bei jedem Request mit ajax-Parameter wird CacheHelper::flushAll(['Columns:*']) aufgerufen — zweimal pro Request. CacheHelper::flushAll (CacheHelper.php:122–162) macht $cache->flush() auf den gesamten Anwendungs-Cache (nur Columns:* + 3 Except-Keys werden gerettet). Damit werden bei jeder Filter-Eingabe u. a. gelöscht:

  • der DB-Schema-Cache (schemaCachingDuration = 3600, config/components.php:9) → der nächste Request re-introspektiert Tabellen-Schemata (SHOW FULL COLUMNS je AR-Tabelle),
  • der Query-Cache (u. a. KCActiveDataProvider cache_duration = 3600),
  • alle COutputCache-Fragmente und CacheHelper::remember-Einträge (z. B. user_model).

Auf OpenShift läuft der Cache zudem als CFileCache auf dem NFS-PVC (s. 5) — flush() iteriert dort ein Verzeichnis über NFS. Der Effekt: das System verhält sich, als gäbe es keinen Cache, bezahlt aber zusätzlich die Flush-Kosten. (Das erklärt die Beobachtung „Caching bringt nichts".)

4.4 Sonstiger Per-Request-Overhead

Posten Ort Kosten/Request
Header-Zähler fällig/überfällig themes/calhelp/views/layouts/main.php:170–171getTotalNotification (FrontendInventory.php:5486–5518) + fn_due (Z. 4855–4907) 2 COUNT-Queries inventory JOIN calibration (C2339=1) JOIN customers mit Filter auf unindiziertem C2303 — auf jeder Vollseite
SchemaCheck (Pre-Bootstrap) httpdocs/index.phpSchemaCheck.php:45–95 eigene PDO-Verbindung + 5–6 INFORMATION_SCHEMA-Queries, ungecacht, jeder Request
DB-Log-Route config/components.php:153–159 (KCDbLogRoute, autoCreateLogTable=true, Levels error, warning, info) zusätzliche dbLog-Verbindung + Tabellen-Probe je Request; INSERT je Log-Eintrag (inkl. info)
DB-Verbindungen local.php:34–64 — keine persistenten Verbindungen bis zu 3 Connect-Handshakes/Request (db, dbLog, SchemaCheck-PDO) + SET NAMES
Konfig-Queries Grid getGridViewShownColumns/getGridViewVisibleColumns/getStandardView/property()->get ~5–15 Queries; getStandardView läuft doppelt (in search() Z. 2926 und in _columns)
Editierbare Zellen KTbExtendedGridView::renderTableRow Z. 521–542 pro Zelle $data->checkAccess('edit') (für inventory früh true, FrontendInventory.php:1356–1358) + volles KTbEditableField-Widget pro Zelle (KTbEditableColumn.php:59–155) — CPU, kein DB
pageSize all KCActiveDataProvider.php:83–85 (PHP_INT_MAX) rendert den kompletten Bestand serverseitig (50k Zeilen × N+1!) — OOM-/Timeout-Risiko

5. Infrastruktur-Faktoren OpenShift vs. VDI/Docker

Aus local.php, config/components.php, configs/php.ini, k8s/:

Thema Befund Wirkung auf OpenShift
Cache-Backend local.php:80–109: KCMemCache nur wenn MEMCACHE_HOST gesetzt + Extension da; sonst CFileCache. k8s/values-oc.yml:51–52: memcached.enabled: false Schema-/Query-/App-Cache = Dateien in protected/runtime/cache/ auf dem PVC/NFS (statefulset.yaml:254–256) → jeder Cache-Hit ist NFS-I/O; flushAll (4.3) iteriert NFS-Verzeichnisse
PVC-Mounts statefulset.yaml:236–286: runtime, assets, filemanager, uploads, Logs per subPath auf einem RWO-PVC Log-Schreiben, Asset-stat()s, Cache-I/O über NFS; PHP-Quellcode bleibt im Image (Overlay, schnell)
OPcache configs/php.ini:1765–1891komplett auskommentiert, kein Tuning (validate_timestamps, memory_consumption, max_accelerated_files) ggf. ungetunter Distro-Default; große Codebase (Yii + vendor)
realpath-Cache php.ini:341,347 auskommentiert → 16 KB Default viele stat()-Syscalls pro Request
DB-Verbindung CDbConnection (kein class-Override in local.phpKCDbConnection/KCMysqlSchema sind inaktiv), kein PDO::ATTR_PERSISTENT Verbindungsaufbau pro Request × 2–3
Sessions CHttpSession files, /var/lib/php/sessions (Container-FS, nicht PVC), GC aus (php.ini:1413) ok; aber PHP-File-Sessions serialisieren parallele AJAX-Requests desselben Users (Session-Lock) — mehrere Grids/Notification-Calls laden nacheinander
Query-Cache-Konfig components.php:10–11: queryCachingCount = 1, queryCachingDuration = 5000 global wird nur die erste Query je Request gecacht — praktisch wirkungslos
Ressourcen values-oc.yml:36–40: requests 500Mi/300m, keine Limits CPU-Throttling möglich; PHP-Rendering (Editable-Widgets, AR-Hydration) reagiert direkt darauf

Abgrenzung: Auf der VDI (Docker, DB im selben Compose-Netz auf lokaler Hardware) sind RTT ≈ 0,05 ms und File-I/O lokal — dieselbe Query-/IO-Anzahl kostet dort fast nichts. Der Unterschied ist also erklärbar, ohne dass OpenShift „defekt" ist.


6. Maßnahmenkatalog (ohne Caching), priorisiert

P0 — Quick Wins (Tage, geringes Risiko)

  1. tt() Version-2-Modus bulk-laden (AdminMessage.php:177–207): beim ersten Miss einer Kategorie die ganze Kategorie laden (wie Version 1, Z. 149–176), statt pro String zu queryen; BINARY-Vergleich entfernen (Kollation utf8_unicode_ci reicht praktisch; sonst Spalte binär kollationieren statt casten). addNewMessage()-INSERTs (Z. 126, 278) hinter einen expliziten „Capture"-Schalter legen. Erwartung: −200…−500 Queries pro Seite, wirkt auf alle Masken.
  2. CacheHelper::flushAll aus dem Grid-Widget entfernen (KTbExtendedGridView.php:72–77, 102–107): gezielt nur die betroffenen Columns:*-/Fragment-Keys invalidieren (flushExcept-Logik umkehren bzw. delete der konkreten Keys). Schema-/Query-Cache bleiben dann überhaupt erst wirksam — auch mit CFileCache.
  3. N+1 Zeilen-CSS: getClassNameForRow (FrontendInventory.php:2010) — Kategorie-Farben einmal pro Request als Map (item_uID → color) für die Seiten-MTAGs laden (eine IN (...)-Query) oder ganz überspringen, wenn show_category_color aus ist (_admin_v2.php:102–105 kennt das Flag bereits). −1 Query pro Zeile.
  4. Header-Zähler entkoppeln (main.php:170–171): die beiden getTotalNotification-COUNTs per AJAX nachladen (Endpoints existieren: frontendInventory/getNotification) oder nur auf der Dashboard-Seite berechnen. −2 schwere COUNTs auf jeder Vollseite.
  5. Indizes: (a) auf Kundensystemen prüfen, ob m260117_074317 + m260528_000001 angewendet sind; (b) neue Yii-Migration: calibration (C2303) — oder besser (MTAG, C2339, C2303) — und inventory (I4201). Macht duedate-Filter/Sort und Hierarchie-/Barcode-Joins indexfähig. Muss als Yii-Migration kommen (V1-Kundensysteme!).
  6. SchemaCheck drosseln (index.phpSchemaCheck.php): Ergebnis in lokaler Datei (z. B. runtime/schema-check.ok, Container-FS) merken und nur bei Fehlschlag/Alter erneut prüfen. −1 Verbindung, −6 Queries pro Request.
  7. KCDbLogRoute zähmen (components.php:153–159): autoCreateLogTable => false (Tabelle existiert), Level auf error, warning reduzieren. −1 Verbindung/Probe-Query, weniger INSERTs.

P1 — Query-Form (der strukturelle Fix, 1–2 Wochen)

  1. Letzte/aktive Kalibrierung denormalisieren: Spalten auf inventory (z. B. last_cal_date, next_due_date, last_order_number, active_CTAG), gepflegt in den Save-/Delete-Pfaden von FrontendCalibration (dort existiert bereits die C2339-Aktiv-Logik) + einmaliger Backfill-Command. Danach:
  2. Grid-Filter/Sort duedate/lastcal auf lokale, indizierte Spalten (search() Z. 3259–3346, 3682–3713 vereinfachen; Join activeCalibrationGrid entfällt),
  3. Header-Zähler (4.4) und Dashboard-Widgets werden zu Index-Scans auf inventory,
  4. Anzeige-N+1 (4.2, duedate/lastcal-Spalten) entfällt komplett. ⚠️ Schema-Änderung an einer V1-Tabelle → Yii-Migration Pflicht; V2/Laravel-Migration nur für V2-Installationen nachziehen. Kein thermo_-Präfix.
  5. Übergangsvariante ohne Schema-Änderung (falls 8 zu groß): Join-Bedingung auf C2339 = 1 verengen (Altdaten-Backfill: UPDATE calibration SET C2339 = 0 WHERE C2339 IS NULL für Nicht-Aktive), GROUP BY nur setzen, wenn tatsächlich eine HAS_MANY-Relation gejoint ist (shouldDeduplicateInventoryGrid datengetrieben statt Default-an), mapusers als EXISTS (SELECT 1 FROM map_user ...) statt Join (Z. 3076–3081).
  6. Anzeige-Relationen batchen statt lazy: nach fetchData() für die Seiten-MTAGs eine Query je Relation (calibration, location/lastUser, dms-Zertifikate) und die statischen Memo-Arrays (self::$calibrationList, self::$locationCustomerList, …) vorbefüllen — die value-Expressions greifen dann ohne Einzelqueries. −1…4 Queries pro Zeile bei unveränderten Spalten-Definitionen.
  7. COUNT entkoppeln: bei unveränderten Filtern (Hash der Filter-GET-Parameter) den letzten totalItemCount aus dem Request mitgeben (Hidden-Field <gridId>_item_total existiert schon) statt bei jedem Seitenwechsel neu zu zählen.

P2 — Rendering (parallel machbar)

  1. Editable-ACL pro Grid statt pro Zelle (KTbExtendedGridView.php:521–542): Ergebnis von $data->checkAccess('edit') pro Request memoisieren (für Tabellen ≠ inventory real).
  2. pageSize=all kappen (KCActiveDataProvider.php:83–85): Obergrenze (z. B. 1.000) statt PHP_INT_MAX; Export-Wege existieren separat.
  3. Optional: debug_backtrace() aus WebUser::checkAccess (Z. 202–208) entfernen — wird hunderte Male pro Request aufgerufen.

P3 — Betrieb/OpenShift (kein Code, große Wirkung)

  1. runtime/cache vom NFS-PVC auf emptyDir/Container-FS legen (Cache-Dateien müssen nicht persistent sein) — macht CFileCache brauchbar, solange kein Memcache läuft.
  2. Memcached im OC-Values aktivieren (values-oc.yml:51) — streng genommen Caching, aber nur Wiederherstellung des vorgesehenen Betriebszustands.
  3. OPcache explizit konfigurieren (opcache.enable=1, memory_consumption≥256, max_accelerated_files≥50000, validate_timestamps=0 bei Image-Deploys) + realpath_cache_size=4096K, realpath_cache_ttl=600 (configs/php.ini).
  4. DB-Nähe prüfen (gleicher Node/AZ, kein DNS pro Connect), ggf. persistente Verbindungen (PDO::ATTR_PERSISTENT) testen; queryCachingCount=1 (components.php:10) entfernen oder sinnvoll setzen.

7. Mess- und Verifikationsplan

Vorhandene Instrumente (alle ohne Code-Änderung nutzbar):

  • INVENTORY_SEARCH_PROFILE=1 (+ optional INVENTORY_SEARCH_PROFILE_THRESHOLD=<sek>, Default 5.0): loggt Count-/Search-Dauer inkl. Kontext (Joins, Dedup, Total) als WARNING, Kategorie inventory.search (FrontendInventory.php:2817–2882).
  • ENABLE_INVENTORY_GRID_DEDUP=0: A/B-Test GROUP-BY-Kosten (Z. 2798–2810).
  • ?_grid_version=v1|v2 (nur YII_DEBUG): View-Vergleich (Controller Z. 755–758) — beide Views teilen search()/Widget, unterscheiden sich also kaum in der Datenpfad-Performance.
  • MySQL-Seite: SET GLOBAL slow_query_log, performance_schema.events_statements_summary_by_digest (Query-Anzahl pro Request-Typ!), EXPLAIN der 3.1-Query mit/ohne C2303-Index.
  • Vorher/Nachher-Kennzahlen je Maßnahme: (a) Queries pro Request (general log zählen), (b) TTFB actionAdmin ohne AJAX, (c) TTFB Grid-AJAX mit Filter, (d) TTFB actionView.

Erwartungswerte für den Referenzbestand (50k/2M) nach P0+P1: Grid-AJAX von mehreren Sekunden auf < 500 ms (Queries: von 300–600 auf < 30), Detailmaske ohne 512M-Memory-Workaround.


8. Schlüsselstellen-Index (für spätere Sessions)

Thema Datei:Zeile
Grid-Einstieg Inventar FrontendInventoryController.php:531 (actionAdmin), 754–765 (v1/v2-Switch)
search() Inventar FrontendInventory.php:2912 (Criteria), 3810 (Count), 3848 (Provider)
activeCalibrationGrid-Relation FrontendInventory.php:342–347
Dedup-GROUP-BY FrontendInventory.php:2798,2989–2994
Sichtbarkeits-Join mapusers FrontendInventory.php:3076–3081
N+1 Zeilen-CSS FrontendInventory.php:1991–2018 (Query: 2010)
N+1 Kalibrier-Anzeige FrontendInventory.php:1617–1628, GridColumn.php:7402–7422
N+1 last_certificate FrontendInventory.php:2371–2379, GridColumn.php:7596
Header-Zähler themes/calhelp/views/layouts/main.php:170–171, FrontendInventory.php:5486, fn_due 4855
tt()/Übersetzung helperFunctions.php:822, AdminMessage.php:66–326 (V2-Einzelqueries: 177–207)
flushAll-Bug KTbExtendedGridView.php:70–108, CacheHelper.php:122–162
Fragment-Cache KTbExtendedGridView.php:115–138, Controller.php:18,693
Provider/pageSize KCActiveDataProvider.php:81–121 ('all'PHP_INT_MAX: 83–85)
Spalten-Fabrik GridColumn.php:382 (Inventar-Block: 6987–7622)
Feld-Konfig-Cache (nur per Request) InventoryHelper.php:445–545 (getFieldsFaster), 557–731
Editable pro Zelle KTbExtendedGridView.php:521–542, KTbEditableColumn.php:59–155
Detail-Ansicht FrontendInventoryController.php:843 (actionView, Memory-Workaround 854), view.php (~11 Grids)
Kalibrier-Grid Detail FrontendCalibration.php:3285–3804 (leerer Provider bei Nicht-AJAX: 3287)
Index-Migrationen m260117_074317_add_missing_indexes_v4.php, m260528_000001_add_mtag_index_to_calibration.php, m210407_024347_add_missing_indexes_v3.php
Cache-/DB-Konfig config/components.php:8–11,129,145–189, config/local.php:34–109
OpenShift-Deployment k8s/values-oc.yml:36–52, k8s/charts/calserver/templates/statefulset.yaml:236–286, configs/php.ini:341,1322,1765–1891
Profiling-Schalter FrontendInventory.php:2817–2882 (ENV INVENTORY_SEARCH_PROFILE), DevProfiler (nur YII_DEBUG)