SQL Execution Plan Caching

On 9. 01 2010, in TSQL, by Raptor 2101

Bei modernen Datenbanken wird die Schicht der Datenabfrage von der Schicht der Datenbeschaffung getrennt. Hatte früher die Formulierung eines SQL-Befehls Einfluss auf die Art wie das DBMS die Daten beschafft, ist der gleicher Befehl heute eher als “Wunsch” welche Daten zu selektieren sind. Wie diese schlussendlich beschafft werden, bleibt dem DBMS überlassen. Der Sprung von SQL-Statement auf tatsächliche I/O-Operationen wird gemeinhin als “Execution Plan” bezeichnet.

Ein solcher ExecutionPlan wird für jede Abfrage erzeugt die an das DBMS gestellt wird. Bei der Erstellungen eines solchen Plans werden alle Informationen die dem DBMS zur Verfügung stehen (Statistiken, Datenbankstruktur, etc) für die Optimierung der Datenabfrage genutzt. Es werden Suchalgorithmen und Daten-Aggregations–Funktionen ausgewählt. Anschließend wird der ExecutionPlan ausgeführt und die Abfrage beantwortet. Um bei Wiederholung einer Abfrage Zeit zu sparen, wird ein einmal erzeugter ExecutionPlan gecached und später wiederverwendet.

Um zu sehen welche ExecutionPlans gerade im Cache vorgehalten werden, kann folgender Befehl genutzt werden.

SELECT [cp].[refcounts]
, [cp].[usecounts]
, [cp].[objtype]
, [st].[dbid]
, [st].[objectid]
, [st].[text]
, [qp].[query_plan]
FROM sys.dm_exec_cached_plans cp
CROSS APPLY sys.dm_exec_sql_text ( cp.plan_handle ) st
CROSS APPLY sys.dm_exec_query_plan ( cp.plan_handle ) qp ;

Wie bei allen Cache und Statistik-Funktionen, gibt es eine Kehrseite der Medaille. Es kann passieren, dass all die schöne Optimierung ins Lehre läuft oder ein Plan verwendet wird, der für die aktuelle Abfrage völlig ungeeignet ist. Wie schon bei dem TOP-Problem dreht sich auch hier alles darum, dass das DBMS den “falschen” ExecutionPlan benutzt.

“Falsch” bedeutet in diesem Zusammenhang, dass I/O – Operatoren benutzt werden, die für die anfallenden Datenmengen nicht optimal sind. Dafür gibt es normalerweise nur drei Gründe:

  • Man weißt das DBMS dazu an: Es gibt mehrere Möglichkeiten das DBMS auf die falsche Fährte zu schicken. Entweder zwingt man es mittels QueryHints dazu oder gibt gleiche ganze ExecutionPlans vor.
  • Die Statistik ist falsch: der unwahrscheinlichste Fall, sei aber dennoch Aufgeführt. Ändert sich bei einer Tabelle mehr als 20%+500 Datensätze (Update, Delete, Insert) wird die Statistik der Tabelle neu erzeugt. Bei 10000 Datensätzen müssten sich also 2500 Datensätze ändern, bis es zu einem Update in der Statistik gibt. Im schlimmsten Fall muss sich nur ein Datensatz ändern um bei einem Folge-Join zu einer explodierenden Datenmenge zu sorgen. In-the-Wild ist mir ein solches “Szenario” aber noch nie unter gekommen.
  • Parameter Sniffing/Spoofing + ExecutionPlan Caching: Für sich alleine genommen ist jede der beiden Technologien sinnvoll und hilfreich. In Kombination kann es jedoch zu erheblichen “Verklemmungen” kommen. “Parameter Sniffing” bezeichnet den Versuch des Query-Optimizers einen Execution Plan auf einen bestimmten Parameter-Tupel zu optimieren. Wird die Abfrage immer nur mit den gleichen Parametern ausgeführt oder nur einmal, beschleunigt das die Abfrage. Sorgt einer der Parameter z.B. dafür das eine große Tabelle auf wenige Datensätze reduziert wird, kann sich so die Lead-Tabelle ändern. Nachteilig wird das ganze wenn dieser Plan gecached wird. Bei der zweiten Ausführung der Abfrage wird der alte Execution Plan genutzt. Im schlimmsten Fall ist dieser falsch optimiert und führt zu unnötigen I/O – Operationen.

Gegen falsche Statistiken lässt sich recht einfach vorgehen. Man veranlasst einen Rebuild der entsprechenden Statistik oder erzeugt gleich alle Statistiken neu.

UPDATE STATISTICS <Tabelle> -- erneuert nur die Statistik der angegeben Tabelle
 
exec sp_updatestats -- baut alle Statistiken neu

Mit einem Statistik-Rebuild werden auch alle gecachten Execution-Pläne verworfen die von der Statistik abhängen.

Gegen Parameter-Sniffing hilft das leider nur temporär. Abfragen die mehrere “optimale” Execution Pläne haben, sollten gesondert behandelt werden. Es gibt prinzipiell fünf Möglichkeiten das Problem endgültig zu beheben.

  • der “sicherste” weg: Wenn eine Abfrage in unterschiedlichen Kontexten genutzt wird, dupliziert man die Abfrage und ändert die Signatur ein wenig. Somit werden mehrere Abfragen gestellt, die getrennt optimiert werden.
  • Kann man das Problem in eine StoredProcedure auslagern, kann man dort ein “ConditionalTree” aufbauen. Also die Übergabeparameter analysieren und entsprechende Sub-StoredProcedures aufrufen. Die wiederum auf den speziellen Fall optimiert werden.
  • Man kann auch das Parameter Sniffing unterdrücken. Dazu muss man aber ebenfalls auf Stored Procedures zurückgreifen.
    CREATE PROCEDURE SelectSomething
    		@parameter1 int,
    		@parameter2 char
    DECLARE
    	@parameter1_save int,
    	@parameter2_save char
    AS
    	SET @parameter1_save = @parameter1
    	SET @parameter2_save = @parameter2
     
    	SELECT * 
    	  FROM tabelle1 t1
    	  JOIN tabelle2 t2
    	 WHERE t1.s1 = @parameter1_save
    	   AND t2.s1 = @parameter2_save

    Die Parameterzuweisung kann vom Optimizer nicht vorhergesehen werden, deswegen wird der anschließende Select auf den allgemeinen Anwendungsfall optimiert.

  • Kann man den Anwendungsbereich eines SELECTs von vornherein einschränken oder eingrenzen, kann man QueryOptimizer anweisen auf diesen Bereich hin zu optimieren.
    SELECT * 
      FROM tabelle1 t1
      JOIN tabelle2 t2
     WHERE t1.s1 = @parameter1
       AND t2.s1 = @parameter2
    OPTION (OPTIMIZE FOR(@parameter1 = ‘somevalue’,@parameter2 = ‘somevalue’ ));
  • Es gibt fälle wo alle bisher genannten Lösungsvarianten nicht greifen, da die Abfragen nicht getrennt werden können und die einzelnen Abfrage-Ziele so unterschiedlich sind, dass eine allgemeine Optimierung keinen Vorteil bringt. In diesem Fall kann man das DBMS anweisen, den ExecutionPlan nicht zu cachen bzw keine gecachten Plane zu nutzen.

    SELECT * 
      FROM tabelle1 t1
      JOIN tabelle2 t2
     WHERE t1.s1 = @parameter1
       AND t2.s1 = @parameter2
    OPTION (RECOMPILE)

    In diesem Fall wird für diesen SELECT immer ein neuer ExecutionPlan erzeugt und nach der Ausführung sofort verworfen. So wird immer der “optimale” ExecutionPlan genutzt. Dafür geht jetzt immer Zeit für das Erzeugen des Plans drauf.

Wie man mit TOP den SQL Server lahmlegt

On 13. 12 2009, in Programmieren, TSQL, by Raptor 2101

Eine “häufig” genutzte Funktionalität des TSQL Befehlssatzes ist das TOP Statement. Es schränkt das Ergebnis Resultset auf eine vorgegebene Anzahl an Zeilen ein. Sinnvoll ist das ganze wenn man mehre gleichwertige Treffer in einer Ergebnissmenge hat, und nur einen Teil davon braucht.

Beispiel: von 1000 Läufern werden nur die selektiert, die die Strecke in einer vorgegebene Zeit zurück gelegt haben, sortiert nach der benötigten zeit aufsteigend. Aufs Treppchen kommen jedoch nur drei Läufer.

Der TSQL – Select dazu:

SELECT TOP 3 t.nachname,t.vorname,t.zeit
  FROM teilnehmer t
  JOIN rennen r on (t.rennen = r.rennen)
  JOIN strecke s on (r.strecke = s.strecke)
 WHERE t.Zeit < s.ZeitMax
   AND r.Veranstalltung = @veranstaltung
 ORDER BY t.ZEIT asc

Die landläufige “Halbwahrheit” dazu ist, dass TOP X nicht in den Execution Plan eingreift. Folglich der Select normal ausgeführt wird und nur die Ergebnis-Menge auf die ersten X Reihen beschränkt wird. Das ist gefährliches Halbwissen! Hier sind zwei Bilder die “eindrucksvoll” belegen, dass man sich mit einem TOP den ganzen Server lahmlegen kann.

Select ohne TOP

Select ohne TOP

Select Top 100

Select Top 100

Dem geübten SQL-Auge dürfte sofort (wenn man die Bilder in groß betrachtet) auffallen das ohne TOP Hash-Match-Joins genutzt werden, wohingegen bei dem TOP 100 Nested-Loop-Joins genutzt werden. Was man im Bild nur erahnen kann, ist der Umstand, das mit dieser Konstellation jeder MS-SQL Server in die Knie zu bekommen ist.

Nested-Loops skalieren ganz beschissen, nämlich linear. Was kein Problem ist, wenn die zu bearbeitende Treffermenge klein ist. Hier ist der Problem begraben. Dem TOP entnimmt der QueryOptimizer eine Information, eine Vorhersage wie viele Treffer zu erwarten sind. Der Select wird nicht ausgeführt und dann die Treffermenge beschnitten, sondern der Select wird solange ausgeführt bis die gewünschte Treffermenge erreicht ist oder der Select keine Ergebnisse mehr liefert.

Normalerweise ist dieses “Denken” auch richtig. Wenn die Treffermenge normalerweise 10k Trefferr enthält und darauf ein TOP 1 angewendet wird ist es Schwachsinnig erst die 10K Treffer zu ermitteln und dann 9999 davon weg zu schmeißen.

Umgekehrt kann man sagen, das für einen Treffer selten ein kompletter Full-Table-Scann bei allen beteiligten Tabellen benötigt wird. So wird je nach “Where”-Bedingung aus der Lead-Tabelle der erste Datensatz ermittelt und so lange an die Folgeoperationen durch gereicht, bis er in der Treffermenge landet oder Aufgrund einer Bedingung ausscheidet. Das wird solange wiederholt bis de gewünschte Trefferzahl erreicht wurde. Im Beispiel benötigte es im “Gutfall” für einen Treffer 70 “Reads” aus der Lead-Tabelle (die Tabelle ganz rechts).

Was passiert aber, wenn Aufgrund einer Sortierung die komplette Lead-Tabelle gelesen werden muss? Das bekommt der Optimizer mit und wechselt sofort auf Hash-Match-Joins. Leider erkennt der Optimizer nicht, dass es auch andere Gründe geben kann, warum die Treffermenge rapide hochgehen kann. Der ungünstigste Fall ist folgender:
Die letzte Operation vor dem “TOP”-Operator führt einen Filter aus, der alle gelieferten Datensätze ausschließt. So müssen alle vorherigen Tabellen komplett gescannt werden und alle ermittelten Datensätze durch die Nested-Loops Operatoren bearbeitet werden. Bei Tabellen mit 10k und mehr Datensätzen sprengt das jeglichen verfügbaren Speicher. Der Server geht augenblicklich in die Knie. Wenn man Glück hat, ist die Instanz in CPU und Speicherverbrauch eingeschränkt, so dass die anderen Instanzen unbeeinflusst bleiben. Wenn nicht, dürfte man seinem Kunden gegenüber in Erklärungsnot geraten…

Die Gegenmaßnahmen sind recht einfach:

  • Ausschließen das es zu solchen Extrem-Situationen kommt, wo ein Datensatz erst den ganzen ExecutionPlan durchlaufen muss um dann ausgeschlossen zu werden.
  • Keep it Simple – Selects mit Top sollten ohne große Where-Bedingung auskommen.
  • Dem QueryOptimizer Hilfestellung über query-Hints geben.
Tagged with:
 

Es kann vorkommen, dass eine SqlDependency nicht gefeuert wird obwohl sie “korrekt” initialisiert wurde und es änderungen auf der Datenbank gibt. Der Grund ist, dass auf seiten des MsSql-Servers fehler auftrahten, die nicht zum Client weiter gereicht werden. Über Sinn oder Unsinn kann man sich trefflich streiten, beheben muss man den Fehler jedoch ;)

Häuffigste Ursache eines solchen Fehlers ist das Einspielen eines (Produktiv-) Backups  in die Testumgebung. Dabei wird der DB-Owner nicht auf den User gesetzt der das Backup einspielt (es gibt dafür keine Option) und es wird auch nicht geprüft ob der DB-Owner existiert. Ist letzteres nicht der Fall, wird beim Anlegen einer Dependency versucht auf selbigen zu wechseln… mit folgendem Resultat:

“Cannot execute as the database principal because the principal “dbo” does not exist, this type of principal cannot be impersonated, or you do not have permission.”

Dieser äußerst unschöne Umstand lässt sich dann meistens im Windows-EventLog nachlesen. Leider gibt es keine anderen Hinweise. Ist der Fehler einmal erkannt, lässt er sich leicht durch folgendes Statement beheben

USE targetDB
GO
sp_changedbowner 'targetUser'

Quelle:
Support-Microsoft

Tagged with: