Der Buffer Pool ist kalt: SQL Server nach Restore, Import und Neustart gezielt vorwärmen
Ausgangslage
Eine große Datenbank ist nach dem nächtlichen Restore morgens extrem langsam. Bei mir wird jede Nacht eine produktive Datenbank auf einen zweiten SQL Server zurückgespielt, damit man mit dem Stand von gestern arbeiten und testen kann. Die ersten Abfragen am Morgen dauern ewig, und erst nach einiger Zeit wird es erträglich.
Der Grund ist selten ein Fehler, sondern ein kalter Cache. Dagegen kannst du etwas tun, ohne den Server zu tunen: Du liest die Daten, die gleich gebraucht werden, vorher ein.
Wann der Buffer Pool kalt wird
Der Buffer Pool ist nach einem Restore leer, aber das ist nicht der einzige Fall. Er verliert seine heißen Seiten in mehreren Situationen:
- Restore (
WITH REPLACE): Alle Seiten der Datenbank sind verworfen. Jede erste Abfrage liest von der Platte, und die Pläne der Datenbank müssen neu kompiliert werden. - Neustart von Instanz oder Server: Nach einem Neustart oder einem Failover der Instanz auf einen anderen Knoten beginnt der Buffer Pool bei null.
- Große Importe und ETL-Läufe: Ein Bulk-Import oder ein Ladejob zieht viele Seiten durch den Buffer Pool und kann die heißen Seiten des Tagesgeschäfts verdrängen.
- Wartung: Index-Rebuilds, Statistik-Updates mit FULLSCAN und
DBCC CHECKDBlesen große Datenmengen. Auch sie schieben Seiten heraus. - Große Berichte und Scans: Eine einzige Abfrage, die eine große Tabelle komplett liest, reicht dafür ebenfalls.
In allen Fällen ist die Datenbank in Ordnung, nur langsam, bis sich der Cache wieder gefüllt hat. Das Ziel ist, den Cache nicht erst von den Benutzern füllen zu lassen.
Schritt 1: Hot-Liste erfassen
Statt blind alles zu lesen, lasse ich SQL Server verraten, was tatsächlich benutzt wird. Eine Prozedur wertet sys.dm_os_buffer_descriptors aus und notiert pro Tabelle und Index, wie viele MB gerade im Buffer Pool liegen. Vereinfacht sieht die Abfrage so aus:
SELECT s.name, o.name, i.index_id,
SUM(b.Pages) * 8 / 1024.0 AS CachedMB
FROM (SELECT allocation_unit_id, COUNT_BIG(*) AS Pages
FROM sys.dm_os_buffer_descriptors
WHERE database_id = DB_ID()
AND page_type IN ('DATA_PAGE', 'INDEX_PAGE')
GROUP BY allocation_unit_id) AS b
JOIN sys.allocation_units au ON au.allocation_unit_id = b.allocation_unit_id AND au.type = 1
JOIN sys.partitions p ON p.hobt_id = au.container_id
JOIN sys.indexes i ON i.object_id = p.object_id AND i.index_id = p.index_id
JOIN sys.objects o ON o.object_id = p.object_id
JOIN sys.schemas s ON s.schema_id = o.schema_id
GROUP BY s.name, o.name, i.index_id
ORDER BY CachedMB DESC;
Das Ergebnis landet in einer kleinen Tabelle in einer Hilfsdatenbank (bei mir DBAUtil), nicht in der Datenbank, die zurückgespielt oder gewartet wird. Objekte mit weniger als 8 MB im Cache lasse ich als Rauschen weg.
Die Prozedur läuft als Agent-Job zu einer Zeit, in der der Buffer Pool das Tagesgeschäft abbildet, zum Beispiel am späten Nachmittag. Wichtig ist, dass sie läuft, bevor ein Import, eine Wartung oder ein Restore den Cache verändert.
Schritt 2: Vorlesen
Eine zweite Prozedur liest die Liste und führt für jeden Eintrag einen Scan aus:
SELECT COUNT_BIG(*) FROM [MeineDatenbank].[dbo].[Tabelle] WITH (INDEX(2)) OPTION (MAXDOP 4);
Ein paar Details, auf die es ankommt:
- Der
INDEX(n)-Hint ist nötig. Ohne ihn nimmt der Optimizer fürCOUNT(*)den schmalsten Nonclustered Index und nicht den, den ich vorwärmen will. Ich nutze die numerische Index-ID, das erspart Ärger mit Namen. - Indexed Views werden mit
NOEXPAND, INDEX(n)gelesen, sonst greift der Hint nicht. - Die Reihenfolge ist absteigend nach Cache-Anteil. Abgebrochen wird bei einem MB-Budget (Standard: 80 % des Buffer-Pool-Targets) oder einem Zeitlimit (Standard: 30 Minuten).
- Fehlt ein Objekt in der Zieldatenbank, wird es übersprungen und geloggt.
- Die Liste kann lokal liegen oder per Linked Server von einem anderen Server gelesen werden. Das ist nützlich, wenn die Datenbank täglich auf einen zweiten Server zurückgespielt wird.
- Ist die Liste leer oder der Linked Server nicht erreichbar, läuft ein Fallback auf die größten Indizes der Datenbank. Das ist nur Größe statt Nutzung, also schlechter, aber besser als nichts.
Die Aufrufe je Szenario
Dieselbe Prozedur deckt alle Szenarien ab. Es ändert sich nur, woher die Liste kommt und wann sie läuft.
Nach dem Restore auf einen zweiten Server. Die Liste wird auf dem Produktivserver erfasst und per Linked Server gelesen:
EXEC DBAUtil.dbo.usp_Warmup_RestoredDb
@TargetDb = N'MeineDatenbank',
@SourceDatabase = N'MeineDatenbank',
@ListLinkedServer = N'ProduktivServer';
Nach einem Import oder ETL-Lauf auf demselben Server. Die Liste liegt lokal und stammt von vor dem Import. Der Aufruf kommt als letzter Schritt in den Ladejob:
EXEC DBAUtil.dbo.usp_Warmup_RestoredDb
@TargetDb = N'MeineDatenbank',
@SourceDatabase = N'MeineDatenbank',
@MaxMinutes = 15;
Nach dem Neustart des Servers. Ein Agent-Job, dessen Zeitplan "Starten, wenn der SQL-Server-Agent gestartet wird" lautet, ruft dieselbe Prozedur auf. Die Tabelle mit der Liste liegt in einer Datenbank und überlebt den Neustart.
Alle drei Fälle setzen voraus, dass die Liste regelmäßig erfasst wird. Eine veraltete Liste liest veraltete Objekte. Die Spalte CapturedAt in der Tabelle zeigt, wie alt sie ist.
Stolpersteine und Grenzen
Das Warm-up hat Grenzen, und ein paar davon solltest du kennen, bevor du es einsetzt:
- Nicht abgedeckt: LOB- und Row-Overflow-Seiten sowie Columnstore-Indizes lassen sich per Scan nicht sinnvoll vorwärmen.
- Reihenfolge: Rebuilds und Importe verfälschen die Hot-Liste. Der Collector muss davor laufen, nicht danach. Am einfachsten rufst du ihn als ersten Schritt des Wartungsjobs auf.
- Arbeitsspeicher: Ist
max server memorykleiner als der heiße Datenbestand, bringt Vorlesen wenig. - Last: Das Vorlesen erzeugt I/O. Lass es außerhalb der Spitzenzeiten laufen und begrenze es mit Budget, Zeitlimit und MAXDOP.
- Aufwand und Nutzen: Dauert das Warm-up länger als die kalte Anlaufphase davor, ist nichts gewonnen.
- Neustart: Läuft der Job, bevor die Datenbank online ist, werden alle Objekte übersprungen und geloggt.
- Linked Server: Fällt er aus, läuft nur der Fallback. Das siehst du in der Meldung des Laufs.
Und ein Hinweis in eigener Sache: Das sind meine Werkzeuge für meine Umgebung. Wer sie übernimmt, testet sie zuerst auf einer Kopie.
Messen statt glauben
Ob sich das lohnt, zeigt nur eine Messung. Eine einfache Kontrollabfrage zeigt, wie viel jeder Datenbank gerade im Buffer Pool liegt:
SELECT DB_NAME(database_id) AS DatabaseName,
COUNT_BIG(*) * 8 / 1024 AS CachedMB
FROM sys.dm_os_buffer_descriptors
GROUP BY database_id
ORDER BY CachedMB DESC;
Führe sie direkt nach dem Restore, nach dem Warm-up und am Ende des Arbeitstags aus. Zusätzlich kannst du bei einer typischen Abfrage mit SET STATISTICS IO ON die physischen Lesezugriffe (physical reads) vergleichen.
|
Messung |
Ohne Warm-up |
Mit Warm-up |
|---|---|---|
|
Buffer-Pool-Belegung der Datenbank direkt nach dem Start (MB) |
[Messwert] |
[Messwert] |
|
Physical Reads einer typischen Abfrage beim ersten Lauf |
[Messwert] |
[Messwert] |
|
Dauer dieser Abfrage beim ersten Lauf (s) |
[Messwert] |
[Messwert] |
|
Dauer des Warm-ups (s) |
nicht zutreffend |
[Messwert] |
Die Prozedur gibt nach dem Lauf die Zahl der vorgelesenen Indizes, die MB und die Sekunden zurück.
Fazit
Ein kalter Buffer Pool ist kein Defekt, sondern der Normalzustand nach Restore, Neustart und größeren Jobs. Zwei kleine Prozeduren reichen: eine Liste dessen, was wirklich benutzt wird, und ein Lauf, der es danach wieder einliest.