Every Query Counts: Why Database Performance Is Your Store's Hidden Bottleneck

Die meisten langsamen PrestaShop-Shops, die wir retten mussten, hatten dieselbe Geschichte: Homepage mit 3-5 Sekunden Time to First Byte, "Add to cart" hängt zwei Sekunden, Kategorieseite nagelt einen MySQL-Core auf 100%, während der Rest des Servers untätig ist. Fast immer kam der Gewinn aus der Datenbank — nicht aus CDN, nicht aus noch einem Image Compressor, nicht aus dem sechsten Cache-Modul obendrauf.

Server-Performance-Monitoring für PrestaShop-Datenbank-Query-Optimierung

Eine normale PrestaShop-Seite feuert etwa 80 bis 300 SQL Queries. In einem Shop mit 10.000 Produkten und Layered Navigation kann eine Kategorieseite über 500 kommen. Die meisten brauchen 1-3 ms. Das Problem sind zwei oder drei, die 150-400 ms fressen und TTFB sprengen. Googles Research beziffert den Conversion-Schaden auf grob 7% pro zusätzlichen 100 ms Ladezeit. Wir sehen das in Client Analytics jedes Mal, wenn eine Slow Query in Produktion landet.

Das ist kein allgemeiner "PrestaShop schneller machen" Überblick. Das ist das Datenbank-Kapitel — Slow Query Logs, EXPLAIN, InnoDB Tuning, Indexes und Datenballast, den das Back Office nie zeigt. Für den größeren Kontext starten Sie mit unserem general performance guide.

Step 1: Enable the Slow Query Log — Your Single Best Diagnostic Tool

Bevor Sie eine Einstellung ändern, finden Sie heraus, was wirklich langsam ist. Das Slow Query Log protokolliert jede Query über einem Schwellwert. Viele Shopbetreiber springen direkt zu "wir nehmen Redis" — Schmerzmittel ohne Diagnose. Das versteckt das echte Problem, bis der Katalog doppelt so groß ist und dieselbe Query statt 400 ms plötzlich 1,2 s braucht.

Enabling the Slow Query Log

In die MySQL- oder MariaDB-Konfiguration eintragen (meist /etc/mysql/mysql.conf.d/mysqld.cnf oder /etc/mysql/mariadb.conf.d/50-server.cnf):

# Slow-Query-Logging aktivieren
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow-query.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
min_examined_row_count = 100

Wichtige Settings:

  • long_query_time = 0.5 — alles über 500 ms. Der Default von 10 Sekunden ist für E-Commerce nutzlos.
  • log_queries_not_using_indexes = 1 — findet Full Table Scans, die heute noch schnell sind, weil die Tabelle klein ist.
  • min_examined_row_count = 100 — hält das Log auf Queries, die wirklich Arbeit machen.

MySQL neu starten, mindestens 24 Stunden laufen lassen, dann zusammenfassen:

# Die schlimmsten Verursacher zusammenfassen
mysqldumpslow -s t -t 20 /var/log/mysql/slow-query.log

# Oder pt-query-digest aus dem Percona Toolkit für tiefere Analyse nutzen
pt-query-digest /var/log/mysql/slow-query.log > /tmp/query-report.txt

pt-query-digest aus dem Percona Toolkit ist unser Standard. Es gruppiert ähnliche Queries, sortiert nach Gesamtzeit und zeigt in Minuten, welche fünf Queries 80% der Datenbanklast erzeugen.

Step 2: Reading EXPLAIN Plans — The Skill That Separates Guessing from Knowing

Wenn das Log die Täter nennt, fragt EXPLAIN nach dem Warum: welchen Index der Optimizer nutzt, wie viele Zeilen er lesen will, ob er auf Disk sortiert.

EXPLAIN SELECT p.id_product, pl.name, p.price
FROM ps_product p
LEFT JOIN ps_product_lang pl ON p.id_product = pl.id_product AND pl.id_lang = 1
LEFT JOIN ps_category_product cp ON p.id_product = cp.id_product
WHERE cp.id_category = 42 AND p.active = 1
ORDER BY p.date_add DESC;

What to Look For in the Output

ColumnRed FlagWhat It Means
typeALLFull Table Scan. Auf 200 Zeilen okay, auf ps_product fatal.
typeindexFull Index Scan — besser als ALL, aber läuft den ganzen Index.
possible_keysNULLKein nutzbarer Index.
keyNULLIndexes existieren, keiner gewählt. Oft stale statistics — ANALYZE TABLE.
rowsHohe ZahlGeschätzte gelesene Zeilen. 50.000 lesen für 12 Treffer heißt Index fehlt.
ExtraUsing temporaryTemporäre Tabelle, oft Disk Spill auf Kategorieseiten.
ExtraUsing filesortSortierung außerhalb des Index.

Gut sind type: ref oder type: eq_ref, ein benannter Index in key und kleine rows. type: ALL plus Using temporary; Using filesort heißt: maximale Arbeit für minimales Ergebnis.

A Real-World PrestaShop Example

Am häufigsten sehen wir Specific-price Lookup. Quantity Discounts, Group Prices und zeitgebundene Promotions leben in ps_specific_price und wachsen nach Jahren Flash Sales stark:

EXPLAIN SELECT * FROM ps_specific_price
WHERE id_product = 1542
AND id_shop IN (0, 1)
AND id_currency IN (0, 1)
AND id_country IN (0, 8)
AND id_group IN (0, 1, 3)
AND id_customer = 0
AND from_quantity >= 1
AND (from = '0000-00-00 00:00:00' OR from <= NOW())
AND (to = '0000-00-00 00:00:00' OR to >= NOW());

In einem Shop mit ca. 200.000 Zeilen in ps_specific_price las diese Query fast die ganze Tabelle für 3 Zeilen. Ein Composite Index löste es:

ALTER TABLE ps_specific_price
ADD INDEX idx_product_shop_currency
(id_product, id_shop, id_currency, id_country);

Die Query fiel von hunderten Millisekunden auf unter eine Millisekunde. Mal 36 Produkte auf einer Kategorieseite ist das der Unterschied zwischen nutzbar und Beschwerde.

Step 3: PrestaShop's Worst Offender Tables — And How to Fix Them

Nach Jahren Slow Query Logs tauchen dieselben Tabellen immer wieder auf. Keine davon drängt sich im Back Office auf.

ps_connections and ps_connections_page

Jede Visitor Connection und jeder Page View, für immer geloggt. Bei 5.000 Besuchern pro Tag und 4 Seiten sind das 7,3 Millionen Zeilen pro Jahr. Wir haben Shops mit 50 Millionen Zeilen in ps_connections_page gesehen. Das Statistikmodul ist selten den Preis wert.

-- Schaden prüfen
SELECT table_name, table_rows,
  ROUND(data_length/1024/1024, 2) AS data_mb,
  ROUND(index_length/1024/1024, 2) AS index_mb
FROM information_schema.tables
WHERE table_schema = 'prestashop'
AND table_name IN ('ps_connections', 'ps_connections_page',
  'ps_log', 'ps_mail', 'ps_guest', 'ps_pagenotfound');

-- Alte Connection-Daten bereinigen (90 Tage behalten)
DELETE FROM ps_connections_page
WHERE id_connections IN (
  SELECT id_connections FROM ps_connections
  WHERE date_add < DATE_SUB(NOW(), INTERVAL 90 DAY)
);
DELETE FROM ps_connections WHERE date_add < DATE_SUB(NOW(), INTERVAL 90 DAY);

-- Alte Logs bereinigen (30 Tage behalten)
DELETE FROM ps_log WHERE date_add < DATE_SUB(NOW(), INTERVAL 30 DAY);

-- Gesendete E-Mail-Logs bereinigen (60 Tage behalten)
DELETE FROM ps_mail WHERE date_add < DATE_SUB(NOW(), INTERVAL 60 DAY);

-- 404-Tracking bereinigen (30 Tage behalten)
DELETE FROM ps_pagenotfound WHERE date_add < DATE_SUB(NOW(), INTERVAL 30 DAY);

Was oft übersehen wird: ein großes DELETE gibt Speicher nicht ans OS zurück, sondern nur intern frei. Reclaim:

OPTIMIZE TABLE ps_connections, ps_connections_page, ps_log, ps_mail, ps_guest;

ps_cart and ps_cart_product

Abandoned Carts sammeln sich endlos. Alles älter als sechs Monate ohne Bestellung ist Ballast:

-- Verwaiste Warenkörbe identifizieren (keine Bestellung)
DELETE cp FROM ps_cart_product cp
INNER JOIN ps_cart c ON cp.id_cart = c.id_cart
LEFT JOIN ps_orders o ON c.id_cart = o.id_cart
WHERE o.id_cart IS NULL
AND c.date_add < DATE_SUB(NOW(), INTERVAL 180 DAY);

DELETE c FROM ps_cart c
LEFT JOIN ps_orders o ON c.id_cart = o.id_cart
WHERE o.id_cart IS NULL
AND c.date_add < DATE_SUB(NOW(), INTERVAL 180 DAY);

ps_search_index and ps_search_word

Der native Search Index bläht sich bei großen Katalogen auf, besonders nach Imports. Wenn Suche träge wird, neu bauen:

-- Nukleare Option: leeren und neu aufbauen
TRUNCATE TABLE ps_search_index;
TRUNCATE TABLE ps_search_word;

-- Danach Full Reindex via CLI auslösen:
php bin/console prestashop:search:reindex

Step 4: Index Strategy for PrestaShop Module Tables

Core-Tabellen haben brauchbare Indexes. Modultabellen fast nie. Wir haben hunderte Drittmodule gesehen; sauber indizierte Custom Tables sind selten, und Ihr Shop zahlt bei jedem Page Load.

Principles for Effective Indexing

  1. Spalten in WHERE Clauses indexieren. WHERE id_product = X AND id_shop = Y heißt Composite Index (id_product, id_shop).
  2. Reihenfolge in Composite Indexes zählt. Selektivste Spalte zuerst.
  3. ORDER BY abdecken. Wer immer nach date_add DESC sortiert, nimmt date_add in den Index.
  4. Nicht überindizieren. Jeder Index verlangsamt INSERT und UPDATE.
-- Beispiel: Modul-Review-Tabelle mit üblichen Query-Mustern
CREATE TABLE ps_mymodule_reviews (
  id_review INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  id_product INT UNSIGNED NOT NULL,
  id_customer INT UNSIGNED NOT NULL,
  id_shop INT UNSIGNED NOT NULL DEFAULT 1,
  rating TINYINT UNSIGNED NOT NULL,
  status TINYINT NOT NULL DEFAULT 0,
  date_add DATETIME NOT NULL,

  -- Composite Index für "genehmigte Reviews für Produkt X anzeigen"
  INDEX idx_product_status (id_product, status, date_add),

  -- Index für "alle Reviews eines Kunden anzeigen"
  INDEX idx_customer (id_customer),

  -- Index für Admin-Liste mit Shop-Filter
  INDEX idx_shop_status (id_shop, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Finding Missing Indexes on Existing Tables

-- Alle Modultabellen ohne nicht-primäre Indexes auflisten
SELECT t.table_name, t.table_rows
FROM information_schema.tables t
LEFT JOIN information_schema.statistics s
  ON t.table_name = s.table_name
  AND t.table_schema = s.table_schema
  AND s.index_name != 'PRIMARY'
WHERE t.table_schema = 'prestashop'
AND t.table_name LIKE 'ps_%'
AND t.table_rows > 1000
AND s.index_name IS NULL
ORDER BY t.table_rows DESC;

Jede Tabelle über 1.000 Zeilen ohne Secondary Index ist Kandidat. Mit Slow Query Log abgeglichen finden Sie die Hälfte der schlimmsten Queries in einer Sitzung.

Step 5: InnoDB Configuration — The Settings That Actually Matter

InnoDB-Defaults sind für "irgendeine Datenbank auf irgendeiner Hardware" geschrieben. Auf einem dedizierten E-Commerce-Server sind sie oft lächerlich konservativ.

Datenbankoptimierung und Full Page Cache Konfiguration für schnelleres PrestaShop

The Critical Settings

[mysqld]
# === Buffer Pool: Die wichtigste Einzel-Einstellung ===
# Auf dediziertem DB-Server 70-80% des verfügbaren RAM.
# Bei Shared Hosting: 50% RAM, mindestens 1GB.
innodb_buffer_pool_size = 4G

# Buffer Pool in Instanzen teilen (1 pro GB)
innodb_buffer_pool_instances = 4

# === Log Files: Größer = weniger Disk Writes ===
# Default 48M ist zu klein. 25% des Buffer Pool, max. 2G je Datei.
innodb_log_file_size = 1G
innodb_log_buffer_size = 64M

# === Flush Behavior ===
# 1 = Full ACID (sicherste Variante, langsamer)
# 2 = Flush in OS Buffer pro Commit, Disk Write einmal/Sekunde (guter Kompromiss)
# 0 = Flush einmal/Sekunde (schnellste Variante, riskiert 1 Sekunde Daten bei Crash)
innodb_flush_log_at_trx_commit = 2

# O_DIRECT nutzen, um Double Buffering mit OS Page Cache zu vermeiden
innodb_flush_method = O_DIRECT

# === I/O Capacity ===
# SSD: 2000-4000. HDD: 200-400. Cloud SSD: 1000-2000.
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000

# === Thread Concurrency ===
innodb_read_io_threads = 4
innodb_write_io_threads = 4
innodb_purge_threads = 4

# === Per-Table Tablespace (Default in MySQL 5.7+, prüfen ob aktiv) ===
innodb_file_per_table = 1

# === Temp Tables ===
tmp_table_size = 64M
max_heap_table_size = 64M

# === Sort and Join Buffers (pro Connection, nicht überdimensionieren) ===
sort_buffer_size = 2M
join_buffer_size = 4M
read_buffer_size = 2M
read_rnd_buffer_size = 1M

# === Connection Pool ===
max_connections = 200
thread_cache_size = 100

# === Table Cache ===
table_open_cache = 4000
table_definition_cache = 2000

# === Performance Schema in Produktion deaktivieren (spart ~400MB RAM) ===
performance_schema = OFF

Understanding innodb_buffer_pool_size

Das ist die wichtigste MySQL-Einstellung. Der Buffer Pool hält InnoDB Data Pages und Indexes im RAM. Was dort liegt, kommt aus RAM; was fehlt, geht auf Disk — auf Produktseiten der Unterschied zwischen 50 ms und 500 ms.

So dimensionieren wir:

-- Gesamte Datenbankgröße prüfen
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024 / 1024, 2) AS total_gb
FROM information_schema.tables
WHERE table_schema = 'prestashop';

-- Buffer-Pool-Hit-Ratio prüfen (sollte > 99% sein)
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';

-- Berechnung: hit_ratio = 1 - (reads / read_requests) * 100
-- Wenn unter 99%, buffer_pool_size erhöhen

Ein Benchmark von Releem auf MariaDB 10.5 zeigte: ordentliches InnoDB Tuning senkte Response Time von 610 ms auf 370 ms. Das passt zu unserer Erfahrung, aber der Gewinn hängt davon ab, wie unterdimensioniert der Buffer Pool vorher war.

Query Cache: MariaDB vs MySQL 8

MySQL 8.0 hat den Query Cache entfernt. MariaDB behält ihn, und auf read-heavy PrestaShop-Shops kann er noch helfen.

For MariaDB (10.5+):

query_cache_type = 1
query_cache_size = 64M
query_cache_limit = 2M

For MySQL 8.0+: nicht aktivieren — Variablen existieren nicht. Investieren Sie in Buffer Pool und application-level caching with Redis.

Wenn Sie nicht an MySQL 8 hängen, läuft MariaDB 10.11 mit PrestaShop sehr gut; viele Hosts stellen auf Anfrage um.

Step 6: PrestaShop-Specific Query Patterns to Watch

Neben generischem MySQL Tuning hat PrestaShop einige Query-Formen, die bei Skalierung immer wieder Probleme machen.

The Product Count Problem

Layered Navigation feuert ein COUNT(DISTINCT) pro Filter Facet. Bei 15 Attributgruppen und 200 Werten sind das 200+ Count Queries pro Page Load:

SELECT COUNT(DISTINCT p.id_product)
FROM ps_product p
INNER JOIN ps_product_attribute_combination pac ...
WHERE ... AND pac.id_attribute = 47;

200 Queries zu je 10 ms sind zwei Sekunden Datenbankzeit, bevor PrestaShop eine Produktkarte rendert. Wirkliche Lösungen: vorab berechnete Facet Count Table (wie unsere SEO and performance modules sie erzeugen) oder sichtbare Filter reduzieren.

The N+1 Query Problem in Module Hooks

Ein Modul auf displayProductListReviews, das pro Produkt eine Query ausführt, erzeugt N+1. 36 Produkte, 36 Extra Queries; drei Module und Sie haben 100+ Queries zusätzlich:

// Schlecht: Query pro Produkt in einem Listen-Hook
public function hookDisplayProductListReviews($params) {
    $id_product = (int)$params['product']['id_product'];
    $result = Db::getInstance()->getRow(
        'SELECT AVG(rating) as avg_rating
         FROM ps_mymodule_reviews
         WHERE id_product = ' . $id_product
    );
    // ...
}

// Gut: alle Produkte auf einmal abfragen, Ergebnis cachen
public function hookActionProductSearchAfter($params) {
    $products = $params['result']->getProducts();
    $ids = array_column($products, 'id_product');

    $ratings = Db::getInstance()->executeS(
        'SELECT id_product, AVG(rating) as avg_rating
         FROM ps_mymodule_reviews
         WHERE id_product IN (' . implode(',', array_map('intval', $ids)) . ')
         GROUP BY id_product'
    );
    // In statischem Cache für Display Hook speichern
}

Cart Rule Evaluation

Jede Cart Rule wird gegen jeden Warenkorb evaluiert, bei jedem Page Load, der den Cart berührt. Hunderte aktive Regeln machen cart und checkout zu Join-Festen. Bei mehr als 50 aktiven Regeln:

  • Alles Abgelaufene archivieren (active = 0 plus date_to in der Vergangenheit — beides, sonst wird weiter evaluiert).
  • Überlappende Regeln zusammenführen, wo Business-Regeln es erlauben.
  • ps_cart_rule auf (active, date_from, date_to) indexieren, falls nicht vorhanden.

Step 7: Monitoring in Production — Don't Set and Forget

Nichts davon ist einmalig. Kataloge wachsen, Traffic ändert sich, ein Modulupdate kann über Nacht eine neue Slow Query einführen.

Essential Metrics to Track

-- Buffer-Pool-Effizienz (wöchentlich prüfen)
SELECT
  FORMAT(VARIABLE_VALUE, 0) AS buffer_pool_read_requests
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests';

-- Slow Queries pro Stunde (sollte sinken)
SHOW GLOBAL STATUS LIKE 'Slow_queries';

-- Table Lock Waits (bei InnoDB nahe Null)
SHOW GLOBAL STATUS LIKE 'Table_locks_waited';

-- Temporäre Tabellen auf Disk (hoch = tmp_table_size erhöhen)
SHOW GLOBAL STATUS LIKE 'Created_tmp_disk_tables';
SHOW GLOBAL STATUS LIKE 'Created_tmp_tables';

-- Thread Connection Usage
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Max_used_connections';

Automated Monitoring

Für Shops, bei denen Downtime echten Umsatz kostet, Monitoring vor dem Problem aufsetzen:

  • Percona Monitoring and Management (PMM) — kostenlos, self-hosted, mit MySQL/MariaDB Dashboards und Query Analytics. Das nutzen wir selbst.
  • Releem — automatisches Tuning nach realer Workload.
  • MySQLTuner — ein Perl Script für schnelle Empfehlungen: perl mysqltuner.pl --host 127.0.0.1. Kein Ersatz, aber ein 30-Sekunden-Sanity-Check.

Step 8: When to Add Redis — And What It Actually Solves

Redis ersetzt keine Query-Fixes. Es ist eine Schicht darüber. Wenn Queries langsam sind, versteckt Redis das Problem für Cached Requests und macht es schlimmer, wenn der Cache abläuft und ein Thundering Herd MySQL trifft.

Nach Indexes, Pruning und InnoDB Tuning lohnt Redis für:

  • Session storage — beseitigt Filesystem Locking bei parallelen checkouts.
  • Smarty cache — verhindert tausende Template-Dateien auf Disk nach Cache Clear.
  • Symfony cache — Doctrine Metadata, Routing und Service Container aus RAM statt PHP Files.
  • Module-level caching — alles Teure, das nicht pro Request neu sein muss.

Auf einem Shop mit erledigter Datenbankarbeit nimmt Redis meist noch einmal spürbar TTFB weg. Die Konfiguration steht im performance guide.

Step 9: Full Page Cache — The Final Layer

Wenn Datenbank schlank und Application Cache in Redis ist, ist der letzte Gang: PHP für anonymen Traffic überspringen. Varnish oder nginx FastCGI Cache liefern gerenderte Seiten aus Memory in einstelligen Millisekunden.

Der harte Teil in PrestaShop ist Invalidation. Preisänderungen, Bestand, Cart Rules, neue Orders — alles muss richtige Pages evicten. Drei Ansätze:

  • Tag-based invalidation — Varnish mit xkey. Seiten nach Produkt, Kategorie, CMS taggen und selektiv purgen. Sauberste, aufwendigste Option.
  • TTL-based — 5-15 Minuten cachen und kurze Staleness akzeptieren.
  • Hybrid with ESI / JS holes — Page Shell cachen, Löcher für Cart Widget, Login Status und Live-Teile. So arbeitet unser Performance Revolution Modul auf mypresta.rocks.

Mit tuned Database, Redis und FPC ist einstelliger TTFB auf dem Cached Path realistisch.

The Priority Order: Maximum Impact, Minimum Risk

Die Reihenfolge ist der wichtigste Teil:

  1. Slow Query Log aktivieren, Top 10 fixen. Kostenlos, risikoarm, größter Sofortgewinn.
  2. Aufgeblähte Tabellen beschneiden. ps_connections, ps_log, ps_mail, tote Carts.
  3. InnoDB Buffer Pool tunen. Eine Config-Änderung, Restart, fertig.
  4. Fehlende Indexes auf Modultabellen ergänzen. Besonders Reviews, Points, Custom Fields, Join Tables.
  5. Redis für Sessions und Cache ergänzen. Nimmt Filesystem I/O aus dem Hot Path.
  6. Full Page Cache. Zuletzt, und erst nach dem Rest.

Jeder Schritt baut auf dem vorherigen auf. Direkt zu Schritt sechs auf wackeliger Datenbank führt zu Cache-Invalidation-Problemen, die mit Schritt eins nie entstanden wären.

Wenn Sie nicht wissen, wo Sie anfangen sollen: Slow Query Log. 24 Stunden echte Daten sagen mehr als jedes Benchmark-Modul. Wenn das Log etwas zeigt, das Sie nicht einordnen können, get in touch — Datenbankanalyse machen wir jede Woche.

Diesen Beitrag teilen:
David Miller

David Miller

Über ein Jahrzehnt praktische PrestaShop-Expertise. David entwickelt leistungsstarke E-Commerce-Module mit Fokus auf SEO, Checkout-Optimierung und Shop-Management. Leidenschaft für sauberen Code und messbare Ergebnisse.

Hat Ihnen dieser Artikel gefallen?

Erhalten Sie unsere neuesten Tipps, Anleitungen und Modul-Updates direkt in Ihr Postfach.

Kommentare (3)

P
Piotr Nowak 14.02.2026
Good article but I think you should mention the opcache preloading feature for PHP 8.1+. It made a big difference for us combined with the query optimizations you described.
Antworten
L
Laura Bianchi 14.02.2026
Implemented the Redis full page cache approach you described here. Our TTFB went from 800ms to 120ms. Incredible difference for our catalog of 15k products.
Antworten
D
David Miller 14.02.2026
Amazing results Laura! Redis FPC is a game changer especially for large catalogs. If you want to squeeze even more out of it, try combining it with Varnish as a reverse proxy.
Lade ...
Nach oben