von EXPLAIN-Plänen bis zum produktionsreifen Query-Caching
Langsame Kataloge entstehen fast nie durch zu wenig Hardware, sondern durch Queries ohne passenden Index, unnötig breite SELECT-Statements und fehlendes Caching. Dieser Artikel zeigt, wie man EXPLAIN-Pläne liest, fehlende Indizes diagnostiziert, Connection-Overhead reduziert und MySQL/MariaDB gezielt für katalogschwere PHP-Anwendungen konfiguriert.
Inhaltsverzeichnis
- 1. Warum Query-Optimierung im Katalog-Maßstab entscheidet
- 2. EXPLAIN-Pläne lesen: type, key, rows und Extra
- 3. Indexnutzung und fehlende Indizes diagnostizieren
- 4. SELECT * vermeiden und Spalten gezielt abfragen
- 5. Query-Result-Caching: Application-Cache und Redis
- 6. Connection-Overhead und Connection-Pooling
- 7. EAV-Muster und katalogspezifische N+1-Probleme
- 8. Slow Query Log, EXPLAIN ANALYZE und Percona Toolkit
- 9. MySQL/MariaDB-Konfiguration für katalogschwere Workloads
- 10. Zusammenfassung
- 11. FAQ
1. Warum Query-Optimierung im Katalog-Maßstab entscheidet
Ein Magento-Katalog mit einigen hunderttausend SKUs, mehreren Store-Views und komplexen Attributsets stellt völlig andere Anforderungen an die Datenbank als eine kleine CRUD-Anwendung. Jede Kategorieseite, jede Produktliste und jede Suchanfrage löst mehrere Joins über EAV-Tabellen, Preisindizes und Lagerbestandstabellen aus. Bleibt eine einzelne dieser Queries ohne passenden Index oder mit unnötig breitem Spaltenumfang, multipliziert sich der Effekt mit jeder gleichzeitigen Anfrage, bis der Datenbankserver zum Flaschenhals wird, lange bevor PHP selbst an seine Grenzen stößt.
Der Unterschied zwischen einer Query mit 2 Millisekunden und einer mit 800 Millisekunden liegt in der Praxis fast immer an denselben drei Ursachen: ein fehlender oder falsch sortierter Index, ein Full Table Scan über eine Tabelle mit Millionen Zeilen oder eine temporäre Tabelle mit Filesort für eine unindizierte Sortierung. Diese Ursachen sind mit den richtigen Werkzeugen in wenigen Minuten diagnostizierbar, werden aber in der Praxis selten systematisch gesucht, weil Anwendungscode und Datenbankschicht oft von unterschiedlichen Personen verantwortet werden.
Dieser Artikel behandelt Query-Optimierung als Ingenieursdisziplin: EXPLAIN-Pläne lesen, Indizes gezielt einsetzen, Spaltenumfang reduzieren, Ergebnisse cachen, Verbindungsaufbau minimieren und die Datenbankkonfiguration an die tatsächliche Workload anpassen. Kein SEO-Thema, sondern reine Mechanik zwischen PHP-Anwendung und relationaler Datenbank.
2. EXPLAIN-Pläne lesen: type, key, rows und Extra
EXPLAIN vor eine SELECT-Query gestellt zeigt, wie der MySQL/MariaDB-Optimizer die Anfrage tatsächlich ausführen wird, ohne sie auszuführen. Die wichtigste Spalte ist type: const und eq_ref sind exzellent, ref ist gut, range ist akzeptabel, index bedeutet einen vollständigen Index-Scan und ALL bedeutet einen Full Table Scan, der bei großen Katalogtabellen fast immer ein Problem ist. Jede Query mit type: ALL auf einer Tabelle mit mehr als einigen tausend Zeilen sollte als Kandidat für einen neuen Index betrachtet werden.
Die Spalte key zeigt, welcher Index tatsächlich verwendet wurde, und key_len verrät, wie viele Bytes des Index tatsächlich genutzt wurden, was bei zusammengesetzten Indizes entscheidend ist. Die Spalte rows ist eine Schätzung, wie viele Zeilen der Optimizer durchsuchen muss, um das Ergebnis zu liefern, nicht die Anzahl der zurückgegebenen Zeilen. Eine Query, die 10 Ergebniszeilen liefert, aber laut rows 500.000 Zeilen durchsucht, ist ein klares Warnsignal.
Die Spalte Extra liefert die konkretesten Hinweise: Using filesort bedeutet, dass MySQL eine zusätzliche Sortierung außerhalb des Index durchführen musste, Using temporary bedeutet, dass eine temporäre Tabelle für GROUP BY oder DISTINCT angelegt wurde, und Using index bedeutet einen positiven Covering-Index-Treffer, bei dem alle benötigten Spalten aus dem Index selbst gelesen werden, ohne die eigentliche Tabelle anzufassen. In MySQL 8 und MariaDB 10.4+ liefert EXPLAIN ANALYZE zusätzlich tatsächliche Ausführungszeiten pro Planschritt, nicht nur Schätzungen.
-- Diagnose a slow catalog product listing query
EXPLAIN SELECT e.entity_id, e.sku, e.type_id
FROM catalog_product_entity e
INNER JOIN catalog_product_entity_int i
ON i.entity_id = e.entity_id AND i.attribute_id = 96
WHERE i.store_id = 1 AND i.value = 4
ORDER BY e.entity_id DESC
LIMIT 20;
-- Typical unoptimized output (before adding a composite index):
-- +----+-------------+-------+------+---------------+------+---------+------+--------+----------------------------------+
-- | id | table | type | key | key_len | ref | rows | Extra |
-- +----+-------------+-------+------+---------------+------+---------+------------------------------------+
-- | 1 | e | ALL | NULL | NULL | NULL | 480213 | Using where; Using filesort |
-- | 1 | i | ALL | NULL | NULL | NULL | 1920852 | Using where; Using join buffer |
-- +----+-------------+-------+------+---------------+------+---------+------------------------------------+
-- Two full table scans, no index used at all: this is the query to fix first.
-- After CREATE INDEX idx_attr_store_value (attribute_id, store_id, value):
-- +----+-------------+-------+---------------------+---------+------+------+-------------+
-- | id | table | type | key | key_len | ref | rows | Extra |
-- +----+-------------+-------+----------------------+---------+------+------+-------------+
-- | 1 | i | ref | idx_attr_store_value | 10 | const,const | 42 | Using where |
-- | 1 | e | eq_ref| PRIMARY | 4 | i.entity_id | 1 | NULL |
-- +----+-------------+-------+----------------------+---------+------+------+-------------+
-- rows dropped from ~2.4M combined to 43, this is what a fixed query looks like.
3. Indexnutzung und fehlende Indizes diagnostizieren
Ein Index beschleunigt Lesezugriffe, weil die Datenbank statt eines linearen Scans eine sortierte Baumstruktur (B-Tree bei InnoDB) durchsuchen kann, was den Zugriff von O(n) auf O(log n) reduziert. Der Preis dafür sind zusätzlicher Speicherplatz und langsamere Schreibvorgänge, weil jeder INSERT und UPDATE den Index mitpflegen muss. Bei katalogschweren Anwendungen mit deutlich mehr Lese- als Schreiblast ist dieser Tausch fast immer sinnvoll, besonders auf EAV-Wertetabellen, die per Definition sehr viele Zeilen pro Attribut enthalten.
Zusammengesetzte Indizes müssen in der richtigen Spaltenreihenfolge angelegt werden: Spalten mit Gleichheitsvergleichen (WHERE store_id = 1) gehören vor Spalten mit Bereichsvergleichen (WHERE created_at > ...), und die am häufigsten selektiv gefilterte Spalte sollte möglichst weit vorne stehen. Ein Index über (store_id, attribute_id, value) deckt Queries ab, die alle drei Spalten oder nur die ersten ein oder zwei Spalten von links nach rechts filtern, deckt aber keine Query ab, die nur nach value filtert, ohne store_id einzuschränken.
Fehlende Indizes findet man systematisch mit dem Slow Query Log kombiniert mit pt-index-usage aus dem Percona Toolkit oder mit sys.schema_unused_indexes als Gegenstück, das ungenutzte Indizes aufdeckt, die nur Schreiblast verursachen, ohne je gelesen zu werden. Die Selektivität eines Index, also der Anteil eindeutiger Werte an der Gesamtzeilenzahl, entscheidet über den Nutzen: Ein Index auf einer Boolean-Spalte mit nur zwei möglichen Werten bringt selten etwas, ein Index auf sku oder entity_id dagegen fast immer.
-- Composite index covering the most common product listing filter pattern
-- Column order: equality filters first, range/sort filters last
CREATE INDEX idx_attr_store_value
ON catalog_product_entity_int (attribute_id, store_id, value);
-- Covering index: query can be answered entirely from the index,
-- without touching the underlying table row (Extra: Using index)
CREATE INDEX idx_covering_listing
ON catalog_product_entity_int (attribute_id, store_id, value, entity_id);
-- Find indexes that exist but are never used by the optimizer
-- (Percona Toolkit / sys schema, MySQL 8 and MariaDB 10.6+)
SELECT object_schema, object_name, index_name
FROM sys.schema_unused_indexes
WHERE object_schema = 'magento';
-- Check selectivity before adding an index: closer to 1.0 is more selective
SELECT COUNT(DISTINCT sku) / COUNT(*) AS selectivity
FROM catalog_product_entity;
4. SELECT * vermeiden und Spalten gezielt abfragen
SELECT * ist bequem, aber in jeder Hinsicht teurer als eine explizite Spaltenliste. Erstens überträgt die Datenbank Daten, die die Anwendung nie verwendet, was Netzwerk-I/O und Deserialisierungsaufwand in PHP unnötig erhöht, besonders bei TEXT- oder BLOB-Spalten wie Produktbeschreibungen. Zweitens verhindert SELECT * systematisch Covering-Index-Treffer, weil der Optimizer, sobald auch nur eine nicht indizierte Spalte angefragt wird, zusätzlich zum Index-Scan noch die eigentliche Tabellenzeile nachladen muss (Extra: NULL statt Using index).
Drittens bricht SELECT * stillschweigend, wenn sich das Tabellenschema ändert. Fällt eine Spalte weg oder wird umbenannt, merkt man das oft erst zur Laufzeit statt beim Deployment, weil PHP-Code, der auf Array-Keys über Spaltennamen zugreift, ohne Fehler weiterläuft, bis eine bestimmte Spalte tatsächlich gebraucht wird. Eine explizite Spaltenliste macht den Datenvertrag zwischen Query und Anwendungscode sichtbar und reviewbar, was in Code-Reviews sofort auffällt, wenn sich Anforderungen ändern.
In der Praxis lohnt sich eine feste Regel: Jede Query bekommt exakt die Spalten, die der aufrufende Code tatsächlich liest, keine mehr. Bei PDO mit vorbereiteten Statements ist der Mehraufwand für eine explizite Spaltenliste minimal und zahlt sich durch kleinere Result Sets, bessere Index-Nutzung und robusteren Code mehrfach aus.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Repository;
use PDO;
/**
* Fetches product listing rows with an explicit, minimal column set.
* Avoids SELECT * to keep the result set small and index-covered.
*/
final class ProductListingRepository
{
public function __construct(
private readonly PDO $connection,
) {
}
/**
* Returns lightweight listing rows for a category page.
*
* @param int $categoryId
* @param int $storeId
* @param int $limit
* @return array<int, array{entity_id: int, sku: string, name: string, price: float}>
*/
public function fetchListingRows(int $categoryId, int $storeId, int $limit = 20): array
{
// Explicit column list, only what the listing template renders,
// never SELECT * over an EAV-joined result.
$statement = $this->connection->prepare(
'SELECT e.entity_id, e.sku, n.value AS name, p.value AS price
FROM catalog_category_product_index_store1 idx
INNER JOIN catalog_product_entity e ON e.entity_id = idx.product_id
INNER JOIN catalog_product_entity_varchar n
ON n.entity_id = e.entity_id AND n.attribute_id = :name_attr AND n.store_id = :store_id
INNER JOIN catalog_product_index_price p
ON p.entity_id = e.entity_id AND p.customer_group_id = 0 AND p.website_id = :website_id
WHERE idx.category_id = :category_id
ORDER BY idx.position ASC
LIMIT :limit'
);
$statement->bindValue(':category_id', $categoryId, PDO::PARAM_INT);
$statement->bindValue(':store_id', $storeId, PDO::PARAM_INT);
$statement->bindValue(':website_id', $storeId, PDO::PARAM_INT);
$statement->bindValue(':name_attr', 73, PDO::PARAM_INT);
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
$statement->execute();
/** @var array<int, array{entity_id: int, sku: string, name: string, price: float}> $rows */
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return $rows;
}
}
5. Query-Result-Caching: Application-Cache und Redis
Der schnellste Datenbankzugriff ist der, der gar nicht stattfindet. Query-Result-Caching auf Anwendungsebene speichert das Ergebnis teurer, aber selten wechselnder Queries, wie etwa Kategoriebäume, Attributsets oder Preisregeln, in einem schnellen Key-Value-Store wie Redis, statt sie bei jedem Request neu gegen die Datenbank auszuführen. Der eingebaute MySQL Query Cache wurde in MySQL 8.0 vollständig entfernt, weil er bei jedem Schreibzugriff auf eine Tabelle den gesamten Cache für diese Tabelle invalidierte und damit unter realer Schreiblast mehr Overhead als Nutzen erzeugte. Application-Level-Caching mit gezielter Invalidierung hat dieses Problem nicht.
Ein Cache-Wrapper um teure Repository-Methoden sollte drei Dinge leisten: einen deterministischen Cache-Key aus den Query-Parametern bilden, eine sinnvolle TTL setzen, die zur Änderungsfrequenz der Daten passt, und eine explizite Invalidierung bei relevanten Schreibvorgängen anbieten, statt sich ausschließlich auf TTL-Ablauf zu verlassen. Für Magento-Kataloge eignen sich kurze TTLs von 60 bis 300 Sekunden für Preis- und Lagerdaten und deutlich längere TTLs von mehreren Stunden für Kategoriebäume und statische Attributdefinitionen, die sich selten ändern.
Wichtig ist, Cache-Stampede zu vermeiden: Läuft eine stark frequentierte Cache-Zeile gleichzeitig für viele parallele Requests ab, können alle gleichzeitig dieselbe teure Query gegen die Datenbank auslösen. Ein Lock-basiertes Muster, bei dem nur ein Request die Query tatsächlich ausführt und die anderen kurz warten oder einen leicht abgelaufenen Wert weiterverwenden, verhindert diese Lastspitzen zuverlässig.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Cache;
use Redis;
/**
* Wraps expensive read queries with a Redis-backed result cache,
* including basic cache-stampede protection via a short-lived lock.
*/
final class QueryResultCache
{
public function __construct(
private readonly Redis $redis,
private readonly int $defaultTtl = 300,
) {
}
/**
* Returns a cached value or computes and stores it, guarded against
* cache stampede by a short-lived lock key.
*
* @param string $key Deterministic cache key derived from query parameters.
* @param callable(): mixed $resolver Executes the expensive query on cache miss.
* @param int|null $ttl Time to live in seconds; falls back to defaultTtl.
* @return mixed
*/
public function remember(string $key, callable $resolver, ?int $ttl = null): mixed
{
$cached = $this->redis->get($key);
if ($cached !== false) {
return unserialize($cached, ['allowed_classes' => false]);
}
$lockKey = $key . ':lock';
$acquired = $this->redis->set($lockKey, '1', ['NX', 'EX' => 5]);
if (!$acquired) {
// Another process is already recomputing this key.
// Wait briefly instead of hammering the database in parallel.
usleep(50000);
$cached = $this->redis->get($key);
if ($cached !== false) {
return unserialize($cached, ['allowed_classes' => false]);
}
}
$value = $resolver();
$this->redis->setex($key, $ttl ?? $this->defaultTtl, serialize($value));
$this->redis->del($lockKey);
return $value;
}
}
6. Connection-Overhead und Connection-Pooling
Jede neue Datenbankverbindung kostet einen TCP-Handshake, eine Authentifizierung und in vielen Setups einen TLS-Handshake, was zusammen mehrere Millisekunden dauern kann, selbst bevor die erste Query ausgeführt wird. Bei klassischem PHP-FPM mit kurzlebigen Prozessen und einer neuen Verbindung pro Request summiert sich dieser Overhead unter Last erheblich. Persistente Verbindungen (PDO::ATTR_PERSISTENT) reduzieren diesen Aufwand, indem PHP-FPM-Worker Verbindungen zwischen Requests wiederverwenden, statt sie jedes Mal neu aufzubauen.
Persistente Verbindungen haben allerdings eine Falle: Sitzungsvariablen, temporäre Tabellen oder nicht zurückgesetzte Transaktionszustände können zwischen Requests durchsickern, wenn der vorherige Request nicht sauber aufgeräumt hat. In der Praxis ist ein externer Connection Pooler wie ProxySQL oder MySQL Router oft die robustere Lösung, weil er Verbindungen auf Infrastrukturebene poolt, ohne dass Anwendungscode auf PHP-Ebene Zustand zwischen Requests teilen muss.
Für katalogschwere Anwendungen mit vielen parallelen PHP-FPM-Workern begrenzt max_connections auf der Datenbankseite die Gesamtzahl gleichzeitiger Verbindungen hart. Übersteigt die Summe aus PHP-FPM-Workern mal maximaler Verbindungen pro Worker diesen Wert, kommt es zu Too many connections-Fehlern unter Lastspitzen. Ein Connection Pooler zwischen Anwendung und Datenbank glättet diese Spitzen, indem er eine kleinere, stabile Anzahl echter Datenbankverbindungen gegen eine größere Anzahl logischer Anwendungsverbindungen multiplext.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Connection;
use PDO;
/**
* Builds a PDO connection with persistent connections and sane defaults
* for a catalog-heavy PHP-FPM workload behind a connection pooler.
*/
final class ConnectionFactory
{
/**
* Creates a configured PDO instance.
*
* @param string $dsn DSN string, typically pointing at a ProxySQL/Router endpoint.
* @param string $user Database user.
* @param string $password Database password.
* @return PDO
*/
public function create(string $dsn, string $user, string $password): PDO
{
return new PDO($dsn, $user, $password, [
// Reuse connections across requests within the same PHP-FPM worker.
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Always use real prepared statements, not client-side emulation.
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_TIMEOUT => 3,
]);
}
}
7. EAV-Muster und katalogspezifische N+1-Probleme
Das Entity-Attribute-Value-Modell hinter Magento-Produkten löst das Problem beliebig erweiterbarer Attributsets, erkauft das aber mit einem strukturellen N+1-Risiko: Statt einer flachen Produkttabelle mit allen Spalten liegen Attribute verteilt über catalog_product_entity_varchar, _int, _decimal, _text und _datetime. Wer für eine Liste von 50 Produkten pro Produkt einzeln jedes Attribut nachlädt, erzeugt schnell hunderte Einzelqueries statt einer einzigen JOIN-Query, ein Muster, das strukturell dem klassischen N+1-Problem aus ORM-Kontexten entspricht, auch ohne dass ein ORM im Spiel ist.
Die korrekte Lösung ist Batch-Loading: Alle benötigten Attributwerte für alle Produkte einer Liste in einer einzigen Query pro Attributtyp nachladen, mit WHERE entity_id IN (...) statt einer Schleife über einzelne WHERE entity_id = ?-Aufrufe. Magentos eigene Indextabellen (catalog_product_index_price, catalog_category_product_index_store*) existieren genau deshalb: Sie denormalisieren EAV-Daten zur Lesezeit vor, damit Listing-Queries nicht mehr live über mehrere EAV-Tabellen joinen müssen.
Bei eigenen Erweiterungen, die zusätzliche Attribute in Listings anzeigen, lohnt sich derselbe Denormalisierungsansatz: eine eigene Indextabelle, die bei relevanten Schreibvorgängen per Indexer oder Event-Observer aktualisiert wird, statt bei jedem Read live über EAV zu joinen. Das verschiebt Kosten von der Lesezeit, die häufig und latenzkritisch ist, zur Schreibzeit, die seltener und weniger zeitkritisch ist.
-- ANTI-PATTERN: N+1 style attribute loading, one query per product per attribute
-- (50 products x 3 attributes = 150 round trips)
-- SELECT value FROM catalog_product_entity_varchar WHERE entity_id = 101 AND attribute_id = 73;
-- SELECT value FROM catalog_product_entity_varchar WHERE entity_id = 102 AND attribute_id = 73;
-- ... repeated for every entity_id and every attribute_id ...
-- CORRECT: batch-load all values for all entities in a single query per attribute type
SELECT entity_id, attribute_id, value
FROM catalog_product_entity_varchar
WHERE store_id = 1
AND attribute_id IN (73, 121, 154)
AND entity_id IN (101, 102, 103, 104, 105 /* ... up to 50 ids ... */);
-- Even better for listings: read from the pre-built flat price index
-- instead of joining raw EAV tables at request time
SELECT entity_id, min_price, max_price
FROM catalog_product_index_price
WHERE website_id = 1 AND customer_group_id = 0
AND entity_id IN (101, 102, 103, 104, 105);
8. Slow Query Log, EXPLAIN ANALYZE und Percona Toolkit
Der Slow Query Log ist das erste Werkzeug für systematisches Profiling, weil er jede Query protokolliert, die eine konfigurierbare Schwelle überschreitet. long_query_time = 0.5 und log_slow_verbosity = full liefern in Kombination mit log_queries_not_using_indexes = ON ein vollständiges Bild aller Queries, die entweder zu langsam sind oder keinen Index nutzen, auch wenn sie zufällig noch unter der Zeitschwelle bleiben. In Produktion sollte der Slow Query Log dauerhaft aktiv sein, mit Log-Rotation, nicht nur punktuell während einer Debugging-Session.
Der Slow Query Log allein zeigt nur, welche Queries langsam sind, nicht welche am häufigsten vorkommen oder in Summe die meiste Zeit verbrauchen. pt-query-digest aus dem Percona Toolkit aggregiert Slow-Query-Logs zu einer priorisierten Liste nach Gesamtzeit, nicht nach Einzelzeit, was oft überraschende Ergebnisse liefert: Eine Query mit 5 Millisekunden Einzelzeit, die 50.000-mal pro Stunde ausgeführt wird, verbraucht in Summe mehr Datenbankzeit als eine seltene Query mit 2 Sekunden Einzelzeit.
EXPLAIN ANALYZE geht über die Schätzung von einfachem EXPLAIN hinaus und führt die Query tatsächlich aus, während es reale Zeiten pro Ausführungsschritt misst. Das deckt Fälle auf, in denen der Optimizer eine falsche Kardinalitätsschätzung trifft und einen suboptimalen Plan wählt, obwohl theoretisch ein besserer Index vorhanden wäre. Regelmäßiges ANALYZE TABLE auf stark schreibenden Tabellen hält die intern gespeicherten Statistiken aktuell, auf denen der Optimizer seine Entscheidungen trifft.
#!/usr/bin/env bash
# Enable comprehensive slow query logging on MySQL/MariaDB
mysql -e "SET GLOBAL slow_query_log = 'ON';"
mysql -e "SET GLOBAL long_query_time = 0.5;"
mysql -e "SET GLOBAL log_queries_not_using_indexes = 'ON';"
mysql -e "SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-query.log';"
# Aggregate the slow query log into a ranked report, sorted by total time,
# not by individual query duration, this surfaces the real cost drivers.
pt-query-digest /var/log/mysql/slow-query.log > /tmp/query-digest-report.txt
# Refresh optimizer statistics on frequently written catalog tables
mysql magento -e "ANALYZE TABLE catalog_product_entity, catalog_product_index_price;"
# Run EXPLAIN ANALYZE for real per-step execution timings (MySQL 8 / MariaDB 10.4+)
mysql magento -e "EXPLAIN ANALYZE SELECT entity_id FROM catalog_product_entity WHERE sku = 'ABC-123';"
9. MySQL/MariaDB-Konfiguration für katalogschwere Workloads
Der InnoDB Buffer Pool ist der wichtigste einzelne Konfigurationswert für katalogschwere Workloads, weil er Tabellen- und Indexdaten im RAM hält und Festplattenzugriffe vermeidet. innodb_buffer_pool_size sollte auf einem dedizierten Datenbankserver 60 bis 75 Prozent des verfügbaren RAMs betragen, groß genug, um den aktiven Arbeitsdatensatz des Katalogs vollständig im Speicher zu halten. Ist der Buffer Pool zu klein, verdrängt jede neue Query ältere, noch benötigte Seiten, was zu wiederholten Festplattenzugriffen für dieselben Daten führt.
sort_buffer_size und join_buffer_size steuern, wie viel Speicher pro Verbindung für Sortiervorgänge und Join-Operationen ohne Index zur Verfügung steht. Zu klein eingestellt, muss MySQL für Sortierungen auf Festplatte ausweichen (sichtbar als Using filesort mit hoher Laufzeit im EXPLAIN-Plan), zu groß eingestellt, verbraucht jede gleichzeitige Verbindung unnötig viel Speicher, was bei hoher Verbindungszahl den Server insgesamt destabilisieren kann. Diese Werte sollten pro Verbindung, nicht global, moderat gehalten und nur für einzelne bekannt teure Reporting-Queries per Session gezielt erhöht werden.
innodb_flush_log_at_trx_commit und innodb_io_capacity beeinflussen das Verhältnis zwischen Schreibsicherheit und Durchsatz. Für katalogschwere Anwendungen mit überwiegend Lesezugriffen und selten kritischen Einzeltransaktionen ist oft ein Kompromiss sinnvoll, der volle ACID-Garantien für Bestellungen behält, aber Massenimport-Prozesse wie Produktfeeds über separate, batch-optimierte Verbindungseinstellungen laufen lässt, statt die globale Konfiguration für den Normalbetrieb zu verschlechtern.
| Dimension | Unoptimiert | Optimiert | Effekt |
|---|---|---|---|
| Spaltenauswahl | SELECT * |
Explizite Spaltenliste | Kleineres Result Set, Covering-Index möglich |
| Indexnutzung | type: ALL (Full Scan) |
type: ref / eq_ref |
Millionen statt zehn Zeilen gelesen |
| Ergebnisbegrenzung | Kein LIMIT |
LIMIT + Keyset-Pagination |
Konstante statt wachsende Antwortzeit |
| Attribut-Ladepattern | N+1 Einzelqueries pro Produkt | Batch-Load mit IN (...) |
Hunderte Roundtrips zu wenigen Queries |
| Wiederholte Reads | Kein Caching | Redis Query-Result-Cache | DB-Last unabhängig von Traffic-Spitzen |
In der Summe zeigt die Tabelle, dass keine einzelne Maßnahme allein katalogschwere Workloads stabil macht. Fehlende Indizes und N+1-Muster verursachen die größten absoluten Zeitverluste, während Caching und Connection-Pooling die Lastspitzen abfedern, die entstehen, wenn viele Requests gleichzeitig dieselben teuren Queries auslösen. Erst das Zusammenspiel aus EXPLAIN-getriebener Indexpflege, minimalem Spaltenumfang, Batch-Loading und gezieltem Caching liefert konstant niedrige Antwortzeiten unter realer Produktionslast.
Mironsoft
Datenbankoptimierung, Query-Profiling und Performance-Infrastruktur
Langsame Datenbank-Queries im Katalog?
Wir analysieren eure Slow-Query-Logs, lesen EXPLAIN-Pläne, ergänzen fehlende Indizes und bauen Query-Caching sowie Connection-Pooling für katalogschwere Magento- und PHP-Anwendungen auf, die auch unter Lastspitzen stabil bleiben.
Query-Audit
Slow Query Log, EXPLAIN-Analyse und pt-query-digest gegen euren Katalog
Index-Design
Zusammengesetzte und Covering-Indizes für EAV- und Preisqueries
Caching & Pooling
Redis-Query-Cache und Connection-Pooling für stabile Antwortzeiten
10. Zusammenfassung
Datenbank-Query-Optimierung für PHP-Anwendungen folgt einem wiederkehrenden Muster: EXPLAIN zeigt, ob eine Query einen Index nutzt, wie viele Zeilen sie durchsucht und ob eine teure Sortierung oder temporäre Tabelle entsteht. Zusammengesetzte Indizes in der richtigen Spaltenreihenfolge und Covering-Indizes reduzieren Zeilenzugriffe von Millionen auf wenige Dutzend. Explizite Spaltenlisten statt SELECT * verkleinern Result Sets und ermöglichen Index-only-Zugriffe. Batch-Loading statt N+1-Mustern über EAV-Tabellen ersetzt hunderte Einzelqueries durch wenige gebündelte Aufrufe.
Query-Result-Caching mit Redis und kurzen, datenabhängigen TTLs nimmt wiederkehrende teure Queries komplett von der Datenbank, während Connection-Pooling den Overhead pro Request reduziert und Lastspitzen abfedert. Slow Query Log, pt-query-digest und EXPLAIN ANALYZE liefern die Datengrundlage, um Optimierungsaufwand priorisiert dort einzusetzen, wo die größte Gesamtzeit verbraucht wird, statt subjektiv zu raten, welche Query als nächstes optimiert werden sollte.
Datenbank-Query-Optimierung für PHP-Anwendungen, das Wichtigste auf einen Blick
EXPLAIN lesen
type, key, rows und Extra zeigen sofort, ob ein Full Table Scan, ein fehlender Index oder eine teure Sortierung vorliegt.
Indizes gezielt setzen
Zusammengesetzte Indizes mit Gleichheitsspalten zuerst, Covering-Indizes für reine Leselisten, Selektivität vor dem Anlegen prüfen.
Spaltenumfang und Batch-Loading
Nie SELECT *. EAV-Attribute mit IN (...) pro Attributtyp bündeln statt pro Produkt einzeln nachzuladen.
Caching & Verbindungen
Redis-Query-Cache mit Stampede-Schutz, persistente Verbindungen oder Connection Pooler wie ProxySQL für stabilen Durchsatz.