Der MySQL Query Cache: warum es ihn nicht mehr gibt
AI generated
InnoDB
SQL
MySQL · Caching · Performance · Architektur
Der MySQL Query Cache
warum MySQL ihn abgeschafft hat

Der MySQL Query Cache versprach schnellere Leseanfragen durch einfaches Wiederverwenden von Ergebnissen, scheiterte aber an einem einzigen globalen Lock, der bei jedem Schreibzugriff die gesamte Instanz ausbremste. Dieser Artikel erklärt die Architektur, die konkreten Bottlenecks, die Abschaffung in MySQL 8.0 und wie Redis- und ORM-basiertes Caching den Query Cache heute sauberer ersetzt.

18 Min. Lesezeit query_cache_type · Invalidierung · Redis · Second-Level-Cache MySQL 5.7 · MySQL 8.0 · InnoDB

1. Was der MySQL Query Cache war

Der MySQL Query Cache war ein Instanz-weiter Speicherbereich, der das komplette Ergebnis eines SELECT-Statements zusammen mit dem exakten Text der Anfrage ablegte. Kam eine identische Anfrage erneut herein, byte-genau übereinstimmend inklusive Groß-/Kleinschreibung und Whitespace, lieferte der MySQL Query Cache das gespeicherte Ergebnis zurück, ohne den Optimizer, den Storage Engine Layer oder den Buffer Pool erneut zu bemühen. Das klang nach einer eleganten Idee für read-lastige Anwendungen mit vielen wiederholten Anfragen, etwa Produktlisten oder Kategorieseiten in einem Onlineshop.

Aktiviert wurde der Query Cache über die Variable query_cache_type, die Größe des reservierten Speichers über query_cache_size, und query_cache_limit begrenzte die maximale Ergebnisgröße pro gecachter Anfrage. Auf den ersten Blick wirkte die Konfiguration simpel: einen Wert setzen, Server neu starten, fertig. In der Praxis stellte sich schnell heraus, dass der MySQL Query Cache nur unter sehr spezifischen Bedingungen einen Vorteil brachte, nämlich bei nahezu statischen Tabellen mit hoher Leselast und kaum Schreibzugriffen.

Schon die exakte Textübereinstimmung war ein praktisches Problem. Zwei semantisch identische Anfragen wie SELECT * FROM products WHERE id = 5 und select * from products where id=5 galten dem Query Cache als komplett unterschiedliche Einträge. Selbst ein zusätzliches Leerzeichen oder ein Kommentar im Statement verhinderte einen Cache-Hit. Für dynamisch generierte Anfragen aus ORMs, bei denen sich Formatierung und Parameter-Reihenfolge leicht unterscheiden konnten, sank die Trefferquote dadurch drastisch unter das theoretische Potenzial.


-- These two statements are semantically identical
-- but the query cache treats them as separate cache entries
SELECT * FROM products WHERE category_id = 12 AND status = 1;
SELECT   *   FROM products WHERE category_id = 12 AND status = 1;

-- Checking cache status on a MySQL 5.7 instance
SHOW VARIABLES LIKE 'query_cache%';
-- +------------------------------+---------+
-- | Variable_name                | Value   |
-- +------------------------------+---------+
-- | query_cache_limit            | 1048576 |
-- | query_cache_min_res_unit     | 4096    |
-- | query_cache_size             | 0       |
-- | query_cache_type             | OFF     |
-- | query_cache_wlock_invalidate | OFF     |
-- +------------------------------+---------+

2. Die Architektur: ein globaler Lock für alle Anfragen

Der eigentliche Konstruktionsfehler des MySQL Query Cache lag nicht in der Idee des Ergebnis-Cachings selbst, sondern in der Implementierung des Zugriffsschutzes. Jede einzelne Interaktion mit dem Cache, egal ob ein Lookup bei einer neuen Anfrage, das Einfügen eines frisch berechneten Ergebnisses oder eine Invalidierung nach einem Write, musste einen einzigen globalen Mutex erwerben. Dieser Mutex war instanzweit, nicht pro Tabelle oder pro Datenbank, sondern ein einziger Lock für den kompletten MySQL Query Cache.

Auf einem Single-Core-Server oder bei niedriger Nebenläufigkeit fiel das kaum auf. Sobald aber mehrere CPU-Kerne parallel Anfragen bearbeiten sollten, wurde der globale Lock zur harten Serialisierungsgrenze. Selbst reine SELECT-Anfragen, die inhaltlich überhaupt nichts miteinander zu tun hatten, mussten sich denselben Mutex teilen. Ein Connection-Pool mit 32 gleichzeitigen Threads auf einem 16-Core-Server konnte dadurch effektiv nur einen Bruchteil der theoretischen Parallelität ausschöpfen, weil jeder Thread kurz vor dem eigentlichen Datenzugriff auf den Query-Cache-Mutex wartete.

Benchmark-Daten aus der MySQL-Community und aus Perconas eigenen Messungen zeigten den Effekt deutlich: Mit steigender Anzahl gleichzeitiger Verbindungen sank der Durchsatz bei aktiviertem Query Cache ab einem bestimmten Punkt sogar unter den Durchsatz mit komplett deaktiviertem Cache. Der Grund war einfach, die Zeit, die Threads mit Warten auf den Mutex verbrachten, überstieg den Zeitgewinn durch vermiedene Query-Ausführungen. Genau dieses Verhalten machte den MySQL Query Cache auf modernen Multi-Core-Systemen zu einem Anti-Pattern statt zu einer Optimierung.

3. Warum Writes den Query Cache ausbremsten

Noch gravierender als der Lock bei Lesezugriffen war das Verhalten bei Schreiboperationen. Jedes INSERT, UPDATE oder DELETE auf einer Tabelle löste eine vollständige Invalidierung aller gecachten Ergebnisse aus, die diese Tabelle referenzierten, unabhängig davon, ob die Änderung die betroffenen Zeilen einer konkreten gecachten Anfrage überhaupt berührte. Änderte sich eine einzige Zeile in einer Millionen-Zeilen-Tabelle, verwarf der MySQL Query Cache sämtliche Cache-Einträge für diese Tabelle, auch wenn neunundneunzig Prozent davon inhaltlich unverändert geblieben wären.

Diese grobkörnige Invalidierung auf Tabellenebene bedeutete, dass der Query Cache bei jeder Kombination aus häufigen Writes und häufigen Reads auf derselben Tabelle in eine Negativspirale geriet. Jeder Write erzwang das Verwerfen des Caches, jeder darauffolgende Read musste die Anfrage komplett neu berechnen und anschließend wieder in den Cache schreiben, was erneut den globalen Lock erforderte. Für eine typische E-Commerce-Datenbank mit Warenkorb-Updates, Lagerbestandsänderungen und gleichzeitigen Produktabfragen bedeutete das in der Praxis, dass der Cache im Sekundentakt geleert und neu befüllt wurde, ohne jemals stabil genutzt zu werden.


-- A single UPDATE invalidates every cached result for this table,
-- even results that never touched the affected row
UPDATE products SET stock = stock - 1 WHERE id = 4821;

-- All of these previously cached SELECTs are now discarded,
-- regardless of whether row 4821 appeared in their result set
SELECT * FROM products WHERE category_id = 3;
SELECT * FROM products WHERE price > 50;
SELECT COUNT(*) FROM products WHERE status = 1;

4. Invalidierungsstürme in der Praxis

In produktiven Systemen mit hoher Schreiblast entstand ein Phänomen, das Betriebsteams als Invalidierungssturm bezeichneten. Sobald genügend gleichzeitige Writes und Reads auf denselben stark frequentierten Tabellen zusammentrafen, geriet der Mutex des MySQL Query Cache zum eigentlichen Flaschenhals der gesamten Instanz, sichtbar in SHOW STATUS als hohe Wartezeiten auf Query_cache_lock und ein auffällig niedriges Verhältnis von Qcache_hits zu Qcache_inserts. Statt Performance zu gewinnen, verlor die Anwendung Performance an ein Feature, das eigentlich helfen sollte.

Der Status-Indikator Qcache_lowmem_prunes zeigte ein weiteres Symptom, wie oft der Cache Einträge wegen Speichermangel entfernen musste, bevor sie überhaupt einmal genutzt wurden. Bei fragmentiertem Cache-Speicher, verursacht durch die konstante Invalidierung großer und kleiner Ergebnismengen, stieg dieser Wert oft parallel zur Last, statt mit ihr zu skalieren. Erfahrene DBAs erkannten das Muster meist erst nach einem Lasttest oder einem echten Peak-Event, etwa einem Sale mit vielen gleichzeitigen Bestellungen, bei dem die Antwortzeiten trotz aktivem Query Cache explodierten statt zu sinken.

5. Die Abschaffung in MySQL 8.0

Oracle markierte den MySQL Query Cache bereits mit MySQL 5.7.20 offiziell als deprecated und entfernte ihn vollständig mit dem Release von MySQL 8.0.3 im Jahr 2018. Die Begründung im offiziellen Änderungsprotokoll war unmissverständlich: Der Query Cache skaliere schlecht auf Systemen mit hoher Nebenläufigkeit, und moderne Alternativen außerhalb des Datenbank-Kerns würden dieselbe Aufgabe zuverlässiger und flexibler lösen. Alle zugehörigen Variablen, darunter query_cache_type und query_cache_size, verschwanden komplett aus dem Server.

Wer heute versucht, auf einer MySQL-8-Instanz SET GLOBAL query_cache_type = 1 auszuführen, erhält einen klaren Fehler, weil die Variable schlicht nicht mehr existiert. Für Migrationen von MySQL 5.7 auf 8.0 bedeutet das, dass jede my.cnf, die noch Query-Cache-Parameter enthält, vor dem Upgrade bereinigt werden muss, sonst startet der Server gar nicht erst. Dieser radikale Schnitt war eine bewusste Entscheidung des MySQL-Teams, die Verantwortung für Caching-Strategien konsequent aus dem Datenbank-Server heraus in die Anwendungsschicht zu verlagern, wo sie flexibler und ohne globalen Lock gelöst werden kann.


# my.cnf: MySQL 5.7, query cache still available (but not recommended)
[mysqld]
query_cache_type = 1
query_cache_size = 64M
query_cache_limit = 2M

# my.cnf: MySQL 8.0, these lines cause a startup failure:
# ERROR: unknown variable 'query_cache_type=1'
# Remove all query_cache_* entries before upgrading to 8.0

6. Was den Query Cache heute ersetzt

Statt eines instanzweiten Ergebnis-Caches im Datenbank-Server setzt die moderne MySQL-Architektur auf mehrere spezialisierte Caching-Schichten außerhalb des Servers. An erster Stelle steht der InnoDB Buffer Pool, der Datenseiten und Indexseiten im Arbeitsspeicher hält und damit wiederholte physische Lesezugriffe von der Platte vermeidet, allerdings auf Seitenebene arbeitet und keine fertigen Query-Ergebnisse cacht. Für das eigentliche Ergebnis-Caching übernehmen heute externe Systeme wie Redis oder Memcached die Rolle, die früher der MySQL Query Cache spielte, jedoch ohne dessen globalen Lock.

Der entscheidende architektonische Unterschied: Ein externer Cache wie Redis lebt außerhalb des Datenbankprozesses und blockiert daher niemals den MySQL-Server selbst. Invalidierung erfolgt gezielt über Anwendungslogik, Events oder Time-to-Live-Werte, statt pauschal jede Tabelle bei jedem Write komplett zu invalidieren. Anwendungen können selbst entscheiden, welche Datensätze wie lange und mit welcher Granularität gecacht werden, was eine deutlich feinere Kontrolle erlaubt als der alte, alles-oder-nichts-artige MySQL Query Cache.

Zusätzlich haben sich Proxy-Lösungen wie ProxySQL etabliert, die selektives Query-Caching auf Verbindungsebene anbieten, ohne den eigentlichen Datenbankprozess zu belasten. Solche Proxies erlauben es, gezielt einzelne Query-Muster zu cachen und andere bewusst auszuschließen, was für gemischte Workloads mit sowohl stark gelesenen als auch stark geschriebenen Tabellen praktikabler ist als ein pauschaler Cache im Server selbst.

7. Application-Level-Caching mit Redis aufbauen

Ein typisches Redis-basiertes Ersatzmuster für den MySQL Query Cache folgt dem Cache-Aside-Pattern: Die Anwendung prüft zuerst Redis auf einen passenden Schlüssel, führt bei einem Miss die MySQL-Anfrage aus und schreibt das Ergebnis anschließend mit einer TTL zurück in Redis. Der entscheidende Vorteil gegenüber dem alten Query Cache liegt in der gezielten Invalidierung, statt der gesamten Tabelle wird nur der konkret betroffene Cache-Schlüssel gelöscht, sobald sich die zugrunde liegenden Daten ändern.

Für Produktkataloge bewährt sich ein Schlüsselschema, das die relevanten Filterparameter direkt im Cache-Key kodiert, etwa products:category:12:page:1. Ändert sich ein einzelnes Produkt, muss nur der betroffene Schlüssel oder eine begrenzte Menge verwandter Schlüssel invalidiert werden, nicht der gesamte Produktkatalog. Diese gezielte Invalidierung ist genau der Mechanismus, der dem MySQL Query Cache strukturell fehlte, weil er nur auf Tabellenebene, nicht auf Zeilen- oder Query-Ebene arbeiten konnte.


-- Key design for a Redis-backed replacement: encode the filter
-- parameters directly in the cache key so invalidation stays targeted
-- products:category:{category_id}:page:{page}
-- products:detail:{product_id}
-- Only the affected keys need to be dropped on write, not a whole table
SELECT id, name, price, stock FROM products
WHERE category_id = 12
ORDER BY name
LIMIT 20 OFFSET 0;

# Cache-aside pattern against Redis, replacing the old MySQL query cache
# 1. Look up the cache key first
redis-cli GET "products:category:12:page:1"

# 2. On a cache miss, run the MySQL query directly
mysql -e "SELECT id, name, price FROM products WHERE category_id = 12 LIMIT 20 OFFSET 0;"

# 3. Store the serialized result with a bounded TTL (e.g. 300 seconds)
redis-cli SETEX "products:category:12:page:1" 300 "<serialized-json-result>"

# 4. On a write to the affected product, invalidate only the related keys
redis-cli DEL "products:category:12:page:1" "products:category:12:page:2"

8. Second-Level-Cache in ORMs richtig konfigurieren

Viele ORM-Frameworks bringen inzwischen einen eigenen Second-Level-Cache mit, der konzeptionell näher am ursprünglichen MySQL Query Cache liegt als ein manuell gepflegter Redis-Cache, aber ohne dessen globalen Lock arbeitet. Doctrine für PHP, Hibernate für Java und ähnliche Frameworks cachen dabei nicht rohe SQL-Ergebnisse, sondern hydratisierte Entitäten oder Query-Resultate auf Anwendungsebene, meist rückseitig gestützt durch Redis oder Memcached als Storage-Backend.

Der entscheidende Konfigurationspunkt ist die Invalidierungsstrategie, entweder TTL-basiert mit einer festen Ablaufzeit, oder ereignisbasiert, bei dem das ORM beim Speichern einer Entität automatisch die zugehörigen Cache-Einträge entfernt. Für Daten mit hoher Änderungsfrequenz, etwa Lagerbestände, empfiehlt sich eine kurze TTL von wenigen Sekunden kombiniert mit expliziter Invalidierung bei kritischen Schreiboperationen. Für nahezu statische Referenzdaten wie Länderlisten oder Kategoriebäume sind TTLs von mehreren Stunden bis Tagen üblich, weil das Risiko veralteter Daten dort gering ist.

9. Migration: query_cache_type abschalten und Alternativen messen

Beim Umstieg von MySQL 5.7 auf 8.0 ist der erste Schritt, den Query Cache bereits vor dem eigentlichen Upgrade produktiv zu deaktivieren und die Auswirkung zu messen. In der Praxis empfiehlt es sich, query_cache_type = 0 und query_cache_size = 0 für einige Tage im Produktivbetrieb laufen zu lassen und dabei die Antwortzeiten sowie die CPU-Auslastung mit dem vorherigen Zustand zu vergleichen. In den meisten Fällen mit mehr als wenigen gleichzeitigen Verbindungen sinkt die durchschnittliche Latenz spürbar, weil der globale Lock entfällt.

Anschließend folgt der Aufbau der Ersatzlösung, meist Redis für dynamische Query-Ergebnisse und ein aktivierter Second-Level-Cache im ORM für Entitäts-Lookups. Wichtig bei der Messung ist, nicht nur die Cache-Hit-Rate isoliert zu betrachten, sondern die Gesamtlatenz unter realer Last, denn ein Redis-Cache mit niedriger Trefferquote, aber schneller Fallback-Query kann in Summe besser performen als ein hochprozentiger MySQL Query Cache, der durch seinen Lock die parallele Verarbeitung ausbremst.

Kriterium MySQL Query Cache (bis 5.7) Redis / Application Cache Auswirkung
Lock-Verhalten Ein globaler Mutex pro Instanz Kein Lock im MySQL-Prozess Kein Blockieren paralleler Reads
Invalidierung Ganze Tabelle bei jedem Write Gezielt pro Cache-Key Weniger unnötiges Neuberechnen
Match-Kriterium Exakter SQL-Text (byte-genau) Frei definierbarer Key Höhere Trefferquote
Skalierung bei Nebenläufigkeit Fällt bei vielen Cores ab Horizontal skalierbar Stabiler Durchsatz unter Last
Verfügbarkeit ab MySQL 8.0 Entfernt seit 8.0.3 Unabhängig vom MySQL-Release Zukunftssicher

Mironsoft

MySQL-Performance-Audits und Caching-Architektur

Noch alte Query-Cache-Reste in eurer my.cnf?

Wir analysieren eure MySQL-Konfiguration, entfernen veraltete Query-Cache-Parameter und bauen eine moderne Caching-Architektur mit Redis und ORM-Second-Level-Cache, passend zu eurem Workload.

Konfigurations-Audit

my.cnf auf veraltete und MySQL-8-inkompatible Parameter prüfen

Redis-Caching-Layer

Cache-Aside-Pattern mit gezielter Invalidierung aufbauen

Migration auf MySQL 8

Sichere Upgrade-Pfade ohne Query-Cache-Altlasten

10. Zusammenfassung

Der MySQL Query Cache war eine gut gemeinte Idee mit einer strukturellen Schwäche, die sich mit steigender Nebenläufigkeit immer schmerzhafter zeigte: ein einziger globaler Mutex für die gesamte Instanz. Jede Anfrage, ob Lookup, Insert oder Invalidierung, musste sich diesen Lock teilen, was auf Multi-Core-Servern zum echten Bottleneck wurde. Schreibzugriffe verschärften das Problem zusätzlich, weil jede Änderung an einer Tabelle sämtliche gecachten Ergebnisse für diese Tabelle verwarf, unabhängig davon, ob die betroffenen Zeilen überhaupt Teil des Cache-Eintrags waren.

Mit MySQL 8.0.3 wurde der Query Cache konsequent entfernt, und die Verantwortung für Ergebnis-Caching wanderte dorthin, wo sie besser aufgehoben ist: in die Anwendungsschicht. Redis als externer Cache mit gezielter Invalidierung, kombiniert mit ORM-eigenem Second-Level-Cache und einem gut dimensionierten InnoDB Buffer Pool, ersetzt den alten MySQL Query Cache heute zuverlässiger, feingranularer und ohne dessen Lock-Problematik. Wer noch Query-Cache-Parameter in einer produktiven my.cnf findet, sollte sie vor dem nächsten Upgrade entfernen und durch eine moderne Caching-Strategie ersetzen.

MySQL Query Cache: Das Wichtigste auf einen Blick

Der globale Lock

Ein einziger Mutex pro Instanz für alle Cache-Zugriffe, wurde auf Multi-Core-Systemen zum Bottleneck.

Grobe Invalidierung

Jeder Write verwarf alle Cache-Einträge der betroffenen Tabelle, unabhängig von der tatsächlichen Relevanz.

Entfernt seit MySQL 8.0.3

Deprecated ab 5.7.20, vollständig entfernt seit 2018. query_cache_type existiert nicht mehr.

Moderner Ersatz

Redis mit Cache-Aside-Pattern und ORM-Second-Level-Cache für gezielte, lockfreie Invalidierung.

11. FAQ: Der MySQL Query Cache

1Was war der MySQL Query Cache?
Ein instanzweiter Speicher für SELECT-Ergebnisse, gekoppelt an den exakten Anfragetext. Bei identischer Folgeanfrage lieferte er das gespeicherte Ergebnis direkt zurück.
2Warum wurde er entfernt?
Ein globaler Mutex pro Instanz wurde bei hoher Nebenläufigkeit zum Bottleneck, verschärft durch pauschale Invalidierung ganzer Tabellen bei jedem Write.
3Seit wann gibt es ihn nicht mehr?
Deprecated seit 5.7.20, vollständig entfernt mit MySQL 8.0.3 im Jahr 2018.
4Alte my.cnf mit query_cache_type?
MySQL 8.0 startet dann nicht. Alle query_cache_* Parameter müssen vor dem Upgrade entfernt werden.
5Was ist der beste Ersatz?
Redis mit Cache-Aside-Pattern plus ORM-Second-Level-Cache, beide ohne globalen Lock und mit gezielter Invalidierung.
6Ist der Buffer Pool ein Ersatz?
Nur teilweise, er cacht Seiten, keine fertigen Query-Ergebnisse.
7Warum galt Formatierung als Miss?
Der Vergleich war byte-genau. Schon ein Leerzeichen oder Kommentar erzeugte einen separaten Cache-Eintrag.
8Wie erkennt man Invalidierungsstürme?
Hohe Wartezeit auf Query_cache_lock, niedriges Verhältnis Qcache_hits zu Qcache_inserts, steigende Qcache_lowmem_prunes.
9Skaliert ProxySQL besser?
Ja, weil es außerhalb des MySQL-Prozesses läuft und keinen globalen Server-Lock benötigt.
10Welche TTL für Redis wählen?
Kurz bei volatilen Daten wie Lagerbeständen, lang bei fast statischen Referenzdaten wie Kategoriebäumen.