Database
 sql >> Datenbank >  >> RDS >> Database

T-SQL-Bugs, Fallstricke und Best Practices – Determinismus

Wie jede Programmiersprache hat T-SQL seinen Anteil an häufigen Fehlern und Fallstricken, von denen einige zu falschen Ergebnissen und andere zu Leistungsproblemen führen. In vielen dieser Fälle gibt es Best Practices, die Ihnen helfen können, Probleme zu vermeiden. Ich habe andere MVPs von Microsoft Data Platform befragt und nach den Fehlern und Fallstricken gefragt, die sie häufig sehen oder die sie einfach besonders interessant finden, und nach den Best Practices, die sie anwenden, um diese zu vermeiden. Ich habe viele interessante Fälle.

Vielen Dank an Erland Sommarskog, Aaron Bertrand, Alejandro Mesa, Umachandar Jayachandran (UC), Fabiano Neves Amorim, Milos Radivojevic, Simon Sabin, Adam Machanic, Thomas Grohser und Chan Ming Man für das Teilen Ihres Wissens und Ihrer Erfahrung!

Dieser Artikel ist der erste einer Reihe zu diesem Thema. Jeder Artikel konzentriert sich auf ein bestimmtes Thema. Diesen Monat konzentriere ich mich auf Fehler, Fallstricke und Best Practices im Zusammenhang mit Determinismus. Eine deterministische Berechnung ist eine Berechnung, die bei gleichen Eingaben garantiert wiederholbare Ergebnisse liefert. Es gibt viele Fehler und Fallstricke, die sich aus der Verwendung nichtdeterministischer Berechnungen ergeben. In diesem Artikel behandle ich die Auswirkungen der Verwendung nicht deterministischer Reihenfolge, nicht deterministischer Funktionen, mehrfacher Verweise auf Tabellenausdrücke mit nicht deterministischen Berechnungen und die Verwendung von CASE-Ausdrücken und der NULLIF-Funktion mit nicht deterministischen Berechnungen.

In vielen Beispielen dieser Serie verwende ich die Beispieldatenbank TSQLV5.

Nichtdeterministische Ordnung

Eine häufige Quelle für Fehler in T-SQL ist die Verwendung einer nicht deterministischen Reihenfolge. Das heißt, wenn Ihre Sortierliste eine Zeile nicht eindeutig identifiziert. Es könnte eine Präsentationsbestellung, eine TOP/OFFSET-FETCH-Bestellung oder eine Fensterbestellung sein.

Nehmen wir zum Beispiel ein klassisches Paging-Szenario mit dem OFFSET-FETCH-Filter. Sie müssen die Tabelle Sales.Orders abfragen und jeweils eine Seite mit 10 Zeilen zurückgeben, sortiert nach Bestelldatum, absteigend (neueste zuerst). Der Einfachheit halber verwende ich Konstanten für die offset- und fetch-Elemente, aber normalerweise handelt es sich dabei um Ausdrücke, die auf Eingabeparametern basieren.

Die folgende Abfrage (nennen Sie sie Abfrage 1) gibt die erste Seite der 10 neuesten Bestellungen zurück:

USE TSQLV5;
 
  SELECT orderid, orderdate, custid
  FROM Sales.Orders
  ORDER BY orderdate DESC
  OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;

Der Plan für Abfrage 1 ist in Abbildung 1 dargestellt.

Abbildung 1:Plan für Abfrage 1

Die Abfrage ordnet die Zeilen absteigend nach Bestelldatum. Die Orderdate-Spalte identifiziert eine Zeile nicht eindeutig. Diese nicht deterministische Reihenfolge bedeutet, dass es konzeptionell keine Präferenz zwischen den Zeilen mit demselben Datum gibt. Was im Fall von Unentschieden bestimmt, welche Zeile SQL Server bevorzugt, sind Dinge wie Planauswahl und physisches Datenlayout – nichts, auf das Sie sich als wiederholbar verlassen können. Der Plan in Abbildung 1 scannt den Index am Bestelldatum rückwärts sortiert. Es kommt vor, dass diese Tabelle einen gruppierten Index auf orderid hat, und in einer gruppierten Tabelle wird der Schlüssel des gruppierten Index als Zeilenlokator in nicht gruppierten Indizes verwendet. Es wird tatsächlich implizit als letztes Schlüsselelement in allen Nonclustered-Indizes positioniert, obwohl SQL Server es theoretisch als eingeschlossene Spalte in den Index hätte einfügen können. Implizit ist also der nicht gruppierte Index am Bestelldatum tatsächlich am (Bestelldatum, Bestell-ID) definiert. Folglich wird in unserem geordneten Rückwärtsscan des Index zwischen gebundenen Zeilen basierend auf orderdate auf eine Zeile mit einem höheren orderid-Wert vor einer Zeile mit einem niedrigeren orderid-Wert zugegriffen. Diese Abfrage generiert die folgende Ausgabe:

  orderid     orderdate  custid
  ----------- ---------- -----------
  11077       2019-05-06 65
  11076       2019-05-06 9
  11075       2019-05-06 68
  11074       2019-05-06 73
  11073       2019-05-05 58
  11072       2019-05-05 20
  11071       2019-05-05 46
  11070       2019-05-05 44
  11069       2019-05-04 80 ***
  11068       2019-05-04 62

Verwenden Sie als Nächstes die folgende Abfrage (nennen Sie sie Abfrage 2), um die zweite Seite mit 10 Zeilen zu erhalten:

  SELECT orderid, orderdate, custid
  FROM Sales.Orders
  ORDER BY orderdate DESC
  OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;

Der Plan für die Abfrage ist in Abbildung 2 dargestellt.


Abbildung 2:Plan für Abfrage 2

Der Optimierer wählt einen anderen Plan – einen, der den geclusterten Index in ungeordneter Weise durchsucht und eine TopN-Sortierung verwendet, um die Anforderung des Top-Operators zu unterstützen, den Offset-Fetch-Filter zu handhaben. Der Grund für die Änderung ist, dass der Plan in Abbildung 1 einen Nonclustered Noncovering-Index verwendet und je weiter die gesuchte Seite entfernt ist, desto mehr Suchen sind erforderlich. Mit der zweiten Seitenanforderung haben Sie den Wendepunkt überschritten, der die Verwendung des nicht deckenden Index rechtfertigt.

Obwohl der Scan des Clustered-Index, der mit orderid als Schlüssel definiert ist, ein unsortierter Scan ist, verwendet die Speicher-Engine intern einen Scan der Indexreihenfolge. Dies hat mit der Größe des Indexes zu tun. Bis zu 64 Seiten bevorzugt die Speicher-Engine im Allgemeinen Scans in Indexreihenfolge gegenüber Scans in Zuordnungsreihenfolge. Selbst wenn der Index größer war, verwendet die Speicher-Engine unter der Read-Committed-Isolationsstufe und Daten, die nicht als schreibgeschützt markiert sind, einen Scan der Indexreihenfolge, um doppeltes Lesen und Überspringen von Zeilen als Ergebnis von Seitenteilungen zu vermeiden, die während der Scan. Unter den gegebenen Bedingungen greift dieser Plan in der Praxis zwischen Zeilen mit demselben Datum auf eine Zeile mit einer niedrigeren Orderid vor einer mit einer höheren Orderid zu.

Diese Abfrage generiert die folgende Ausgabe:

  orderid     orderdate  custid
  ----------- ---------- -----------
  11069       2019-05-04 80 ***
  11064       2019-05-01 71
  11065       2019-05-01 46
  11066       2019-05-01 89
  11060       2019-04-30 27
  11061       2019-04-30 32
  11062       2019-04-30 66
  11063       2019-04-30 37
  11057       2019-04-29 53
  11058       2019-04-29 6

Beachten Sie, dass Sie, obwohl sich die zugrunde liegenden Daten nicht geändert haben, sowohl auf der ersten als auch auf der zweiten Seite dieselbe Bestellung (mit der Bestell-ID 11069) erhalten haben!

Hoffentlich ist die Best Practice hier klar. Fügen Sie Ihrer Sortierliste einen Tiebreaker hinzu, um eine deterministische Reihenfolge zu erhalten. Zum Beispiel sortiere nach Bestelldatum absteigend, Bestell-ID absteigend.

Versuchen Sie erneut, nach der ersten Seite zu fragen, diesmal mit einer deterministischen Reihenfolge:

  SELECT orderid, orderdate, custid
  FROM Sales.Orders
  ORDER BY orderdate DESC, orderid DESC
  OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;

Sie erhalten garantiert folgende Ausgabe:

  orderid     orderdate  custid
  ----------- ---------- -----------
  11077       2019-05-06 65
  11076       2019-05-06 9
  11075       2019-05-06 68
  11074       2019-05-06 73
  11073       2019-05-05 58
  11072       2019-05-05 20
  11071       2019-05-05 46
  11070       2019-05-05 44
  11069       2019-05-04 80
  11068       2019-05-04 62

Fragen Sie nach der zweiten Seite:

  SELECT orderid, orderdate, custid
  FROM Sales.Orders
  ORDER BY orderdate DESC, orderid DESC
  OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;

Sie erhalten garantiert folgende Ausgabe:

  orderid     orderdate  custid
  ----------- ---------- -----------
  11067       2019-05-04 17
  11066       2019-05-01 89
  11065       2019-05-01 46
  11064       2019-05-01 71
  11063       2019-04-30 37
  11062       2019-04-30 66
  11061       2019-04-30 32
  11060       2019-04-30 27
  11059       2019-04-29 67
  11058       2019-04-29 6

Solange es keine Änderungen in den zugrunde liegenden Daten gab, erhalten Sie garantiert aufeinanderfolgende Seiten ohne Wiederholungen oder Überspringen von Zeilen zwischen den Seiten.

In ähnlicher Weise könnten Sie bei Verwendung von Fensterfunktionen wie ROW_NUMBER mit nicht deterministischer Reihenfolge je nach Planform und tatsächlicher Zugriffsreihenfolge zwischen Bindungen unterschiedliche Ergebnisse für dieselbe Abfrage erhalten. Betrachten Sie die folgende Abfrage (nennen Sie sie Abfrage 3), die die Anforderung der ersten Seite unter Verwendung von Zeilennummern implementiert (wodurch die Verwendung des Index am Bestelldatum zu Illustrationszwecken erzwungen wird):

  WITH C AS
  (
    SELECT orderid, orderdate, custid,
      ROW_NUMBER() OVER(ORDER BY orderdate DESC) AS n
    FROM Sales.Orders WITH (INDEX(idx_nc_orderdate))
  )
  SELECT orderid, orderdate, custid
  FROM C 
  WHERE n BETWEEN 1 AND 10;

Der Plan für diese Abfrage ist in Abbildung 3 dargestellt:


Abbildung 3:Plan für Abfrage 3

Sie haben hier sehr ähnliche Bedingungen wie die, die ich zuvor für Abfrage 1 mit ihrem Plan beschrieben habe, der zuvor in Abbildung 1 gezeigt wurde. Zwischen Zeilen mit Bindungen in den orderdate-Werten greift dieser Plan auf eine Zeile mit einem höheren orderid-Wert vor einer mit einem niedrigeren zu orderid-Wert. Diese Abfrage generiert die folgende Ausgabe:

  orderid     orderdate  custid
  ----------- ---------- -----------
  11077       2019-05-06 65
  11076       2019-05-06 9
  11075       2019-05-06 68
  11074       2019-05-06 73
  11073       2019-05-05 58
  11072       2019-05-05 20
  11071       2019-05-05 46
  11070       2019-05-05 44
  11069       2019-05-04 80 ***
  11068       2019-05-04 62

Führen Sie als Nächstes die Abfrage erneut aus (nennen Sie sie Abfrage 4), und fordern Sie die erste Seite an, nur erzwingen Sie diesmal die Verwendung des Clustered-Index PK_Orders:

  WITH C AS
  (
    SELECT orderid, orderdate, custid,
      ROW_NUMBER() OVER(ORDER BY orderdate DESC) AS n
    FROM Sales.Orders WITH (INDEX(PK_Orders))
  )
  SELECT orderid, orderdate, custid
  FROM C 
  WHERE n BETWEEN 1 AND 10;

Der Plan für diese Abfrage ist in Abbildung 4 dargestellt.


Abbildung 4:Plan für Abfrage 4

Dieses Mal haben Sie sehr ähnliche Bedingungen wie die, die ich zuvor für Abfrage 2 mit ihrem Plan beschrieben habe, der zuvor in Abbildung 2 gezeigt wurde. Zwischen Zeilen mit Bindungen in den orderdate-Werten greift dieser Plan auf eine Zeile mit einem niedrigeren orderid-Wert vor einer mit a zu höherer orderid-Wert. Diese Abfrage generiert die folgende Ausgabe:

  orderid     orderdate  custid
  ----------- ---------- -----------
  11074       2019-05-06 73
  11075       2019-05-06 68
  11076       2019-05-06 9
  11077       2019-05-06 65
  11070       2019-05-05 44
  11071       2019-05-05 46
  11072       2019-05-05 20
  11073       2019-05-05 58
  11067       2019-05-04 17 ***
  11068       2019-05-04 62

Beachten Sie, dass die beiden Ausführungen zu unterschiedlichen Ergebnissen führten, obwohl sich an den zugrunde liegenden Daten nichts geändert hat.

Auch hier ist die bewährte Vorgehensweise einfach:Verwenden Sie eine deterministische Reihenfolge, indem Sie einen Tiebreaker hinzufügen, etwa so:

  WITH C AS
  (
    SELECT orderid, orderdate, custid,
      ROW_NUMBER() OVER(ORDER BY orderdate DESC, orderid DESC) AS n
    FROM Sales.Orders
  )
  SELECT orderid, orderdate, custid
  FROM C 
  WHERE n BETWEEN 1 AND 10;

Diese Abfrage generiert die folgende Ausgabe:

  orderid     orderdate  custid
  ----------- ---------- -----------
  11077       2019-05-06 65
  11076       2019-05-06 9
  11075       2019-05-06 68
  11074       2019-05-06 73
  11073       2019-05-05 58
  11072       2019-05-05 20
  11071       2019-05-05 46
  11070       2019-05-05 44
  11069       2019-05-04 80
  11068       2019-05-04 62

Der zurückgegebene Satz ist unabhängig von der Planform garantiert wiederholbar.

Es ist wahrscheinlich erwähnenswert, dass es hier keine garantierte Präsentationsreihenfolge gibt, da diese Abfrage keine Präsentations-Order-by-Klausel in der äußeren Abfrage enthält. Wenn Sie eine solche Garantie benötigen, müssen Sie eine Präsentations-Order-by-Klausel hinzufügen, etwa so:

  WITH C AS
  (
    SELECT orderid, orderdate, custid,
      ROW_NUMBER() OVER(ORDER BY orderdate DESC, orderid DESC) AS n
    FROM Sales.Orders
  )
  SELECT orderid, orderdate, custid
  FROM C 
  WHERE n BETWEEN 1 AND 10
  ORDER BY n;

Nichtdeterministische Funktionen

Eine nichtdeterministische Funktion ist eine Funktion, die bei gleichen Eingaben bei unterschiedlichen Ausführungen der Funktion unterschiedliche Ergebnisse zurückgeben kann. Klassische Beispiele sind SYSDATETIME, NEWID und RAND (bei Aufruf ohne Eingabe-Seed). Das Verhalten nichtdeterministischer Funktionen in T-SQL kann für einige überraschend sein und in einigen Fällen zu Fehlern und Fallstricken führen.

Viele Leute gehen davon aus, dass beim Aufrufen einer nicht deterministischen Funktion als Teil einer Abfrage die Funktion separat pro Zeile ausgewertet wird. In der Praxis werden die meisten nichtdeterministischen Funktionen einmal pro Referenz in der Abfrage ausgewertet. Betrachten Sie die folgende Abfrage als Beispiel:

  SELECT orderid, SYSDATETIME() AS dt, RAND() AS rnd
  FROM Sales.Orders;

Da es in der Abfrage nur einen Verweis auf jede der nicht deterministischen Funktionen SYSDATETIME und RAND gibt, wird jede dieser Funktionen nur einmal ausgewertet, und ihr Ergebnis wird in allen Ergebniszeilen wiederholt. Beim Ausführen dieser Abfrage habe ich die folgende Ausgabe erhalten:

  orderid     dt                          rnd
  ----------- --------------------------- ----------------------
  11008       2019-02-04 17:03:07.9229177 0.962042872007464
  11019       2019-02-04 17:03:07.9229177 0.962042872007464
  11039       2019-02-04 17:03:07.9229177 0.962042872007464
  11040       2019-02-04 17:03:07.9229177 0.962042872007464
  11045       2019-02-04 17:03:07.9229177 0.962042872007464
  11051       2019-02-04 17:03:07.9229177 0.962042872007464
  11054       2019-02-04 17:03:07.9229177 0.962042872007464
  11058       2019-02-04 17:03:07.9229177 0.962042872007464
  11059       2019-02-04 17:03:07.9229177 0.962042872007464
  11061       2019-02-04 17:03:07.9229177 0.962042872007464
  ...

Nehmen Sie beispielsweise an, dass Sie eine Abfrage schreiben müssen, die drei zufällige Bestellungen aus der Tabelle „Sales.Orders“ zurückgibt, wenn dieses Verhalten nicht verstanden wird und zu einem Fehler führen kann. Ein üblicher Anfangsversuch besteht darin, eine TOP-Abfrage mit einer Reihenfolge basierend auf der RAND-Funktion zu verwenden, in der Annahme, dass die Funktion separat pro Zeile ausgewertet wird, etwa so:

  SELECT TOP (3) orderid
  FROM Sales.Orders
  ORDER BY  RAND();

In der Praxis wird die Funktion nur einmal für die gesamte Abfrage ausgewertet; Daher erhalten alle Zeilen das gleiche Ergebnis, und die Reihenfolge bleibt völlig unbeeinflusst. Wenn Sie den Plan für diese Abfrage überprüfen, sehen Sie tatsächlich keinen Sort-Operator. Als ich diese Abfrage mehrmals ausgeführt habe, habe ich immer das gleiche Ergebnis erhalten:

  orderid
  -----------
  11008
  11019
  11039

Die Abfrage entspricht tatsächlich einer Abfrage ohne ORDER BY-Klausel, bei der die Reihenfolge der Präsentation nicht garantiert ist. Technisch gesehen ist die Reihenfolge also nicht deterministisch, und theoretisch könnten unterschiedliche Ausführungen zu einer anderen Reihenfolge und damit zu einer anderen Auswahl der oberen 3 Reihen führen. Die Wahrscheinlichkeit dafür ist jedoch gering, und Sie können sich diese Lösung nicht so vorstellen, dass sie bei jeder Ausführung drei zufällige Zeilen erzeugt.

Eine Ausnahme von der Regel, dass eine nicht deterministische Funktion einmal pro Verweis in der Abfrage aufgerufen wird, ist die NEWID-Funktion, die einen global eindeutigen Bezeichner (GUID) zurückgibt. Wenn sie in einer Abfrage verwendet wird, ist diese Funktion separat pro Zeile aufgerufen. Die folgende Abfrage demonstriert dies:

  SELECT orderid, NEWID() AS mynewid
  FROM Sales.Orders;

Diese Abfrage generierte die folgende Ausgabe:

  orderid     mynewid
  ----------- ------------------------------------
  11008       D6417542-C78A-4A2D-9517-7BB0FCF3B932
  11019       E2E46BF1-4FA6-4EF2-8328-18B86259AD5D
  11039       2917D923-AC60-44F5-92D7-FF84E52250CC
  11040       B6287B49-DAE7-4C6C-98A8-7DB8A879581C
  11045       2E14D8F7-21E5-4039-BF7E-0A27D1A0E186
  11051       FA0B7B3E-BA41-4D80-8581-782EB88836C0
  11054       1E6146BB-FEE7-4FF4-A4A2-3243AA2CBF78
  11058       49302EA9-0243-4502-B9D2-46D751E6EFA9
  11059       F5BB7CB2-3B17-4D01-ABD2-04F3C5115FCF
  11061       09E406CA-0251-423B-8DF5-564E1257F93E
  ...

Der Wert von NEWID selbst ist ziemlich zufällig. Wenn Sie die CHECKSUM-Funktion darüber anwenden, erhalten Sie ein ganzzahliges Ergebnis mit einer noch besseren Zufallsverteilung. Eine Möglichkeit, drei zufällige Reihenfolgen zu erhalten, besteht also darin, eine TOP-Abfrage mit einer Reihenfolge basierend auf CHECKSUM(NEWID()) zu verwenden, etwa so:

  SELECT TOP (3) orderid
  FROM Sales.Orders
  ORDER BY CHECKSUM(NEWID());

Führen Sie diese Abfrage wiederholt aus und beachten Sie, dass Sie jedes Mal einen anderen Satz von drei zufälligen Bestellungen erhalten. Ich habe die folgende Ausgabe in einer Ausführung erhalten:

  orderid
  -----------
  11031
  10330
  10962

Und die folgende Ausgabe in einer anderen Ausführung:

  orderid
  -----------
  10308
  10885
  10444

Was ist, außer NEWID, wenn Sie eine nicht deterministische Funktion wie SYSDATETIME in einer Abfrage verwenden müssen und diese separat pro Zeile ausgewertet werden muss? Eine Möglichkeit, dies zu erreichen, besteht darin, eine benutzerdefinierte Funktion (UDF) zu verwenden, die die nicht deterministische Funktion wie folgt aufruft:

  CREATE OR ALTER FUNCTION dbo.MySysDateTime() RETURNS DATETIME2
  AS
  BEGIN
    RETURN SYSDATETIME();
  END;
  GO

Anschließend rufen Sie die UDF in der Abfrage wie folgt auf (nennen Sie sie Abfrage 5):

  SELECT orderid, dbo.MySysDateTime() AS mydt
  FROM Sales.Orders;

Die UDF wird diesmal pro Zeile ausgeführt. Sie müssen sich jedoch darüber im Klaren sein, dass mit der zeilenweisen Ausführung der UDF eine ziemlich starke Leistungseinbuße verbunden ist. Darüber hinaus ist das Aufrufen einer skalaren T-SQL-UDF ein Parallelitätsverhinderer.

Der Plan für diese Abfrage ist in Abbildung 5 dargestellt.


Abbildung 5:Plan für Abfrage 5

Beachten Sie im Plan, dass die UDF tatsächlich pro Quellzeile im Compute Scalar-Operator aufgerufen wird. Beachten Sie auch, dass SentryOne Plan Explorer Sie vor möglichen Leistungseinbußen warnt, die mit der Verwendung der UDF sowohl im Compute Scalar-Operator als auch im Stammknoten des Plans verbunden sind.

Ich habe die folgende Ausgabe von der Ausführung dieser Abfrage erhalten:

  orderid     mydt
  ----------- ---------------------------
  11008       2019-02-04 17:07:03.7221339
  11019       2019-02-04 17:07:03.7221339
  11039       2019-02-04 17:07:03.7221339
  ...
  10251       2019-02-04 17:07:03.7231315
  10255       2019-02-04 17:07:03.7231315
  10248       2019-02-04 17:07:03.7231315
  ...
  10416       2019-02-04 17:07:03.7241304
  10420       2019-02-04 17:07:03.7241304
  10421       2019-02-04 17:07:03.7241304
  ...

Beachten Sie, dass die Ausgabezeilen mehrere unterschiedliche Datums- und Zeitwerte in der mydt-Spalte haben.

Sie haben vielleicht gehört, dass SQL Server 2019 das allgemeine Leistungsproblem behebt, das durch skalare T-SQL-UDFs verursacht wird, indem solche Funktionen eingebettet werden. Die UDF muss jedoch eine Liste von Anforderungen erfüllen, um inlinefähig zu sein. Eine der Anforderungen besteht darin, dass die UDF keine nicht deterministische intrinsische Funktion wie SYSDATETIME aufruft. Der Grund für diese Anforderung ist, dass Sie die UDF möglicherweise genau erstellt haben, um eine Ausführung pro Zeile zu erhalten. Wenn die UDF inliniert wurde, würde die zugrunde liegende nichtdeterministische Funktion nur einmal für die gesamte Abfrage ausgeführt. Tatsächlich wurde der Plan in Abbildung 5 in SQL Server 2019 generiert, und Sie können deutlich sehen, dass die UDF nicht eingebettet wurde. Das liegt an der Verwendung der nichtdeterministischen Funktion SYSDATETIME. Sie können überprüfen, ob eine UDF in SQL Server 2019 inlinefähig ist, indem Sie das is_inlineable-Attribut in der sys.sql_modules-Ansicht wie folgt abfragen:

  SELECT is_inlineable
  FROM sys.sql_modules
  WHERE object_id = OBJECT_ID(N'dbo.MySysDateTime');

Dieser Code generiert die folgende Ausgabe, die Ihnen mitteilt, dass die UDF MySysDateTime nicht inlinefähig ist:

  is_inlineable
  -------------
  0

Um eine UDF zu demonstrieren, die inlinefähig ist, ist hier die Definition einer UDF namens EndOfyear, die ein Eingabedatum akzeptiert und das entsprechende Datum des Jahresendes zurückgibt:

  CREATE OR ALTER FUNCTION dbo.EndOfYear(@dt AS DATE) RETURNS DATE
  AS
  BEGIN
    RETURN DATEADD(year, DATEDIFF(year, '18991231', @dt), '18991231');
  END;
  GO

Hier werden keine nichtdeterministischen Funktionen verwendet, und der Code erfüllt auch die anderen Anforderungen für Inlining. Sie können überprüfen, ob die UDF inlinefähig ist, indem Sie den folgenden Code verwenden:

  SELECT is_inlineable
  FROM sys.sql_modules
  WHERE object_id = OBJECT_ID(N'dbo.EndOfYear');

Dieser Code generiert die folgende Ausgabe:

  is_inlineable
  -------------
  1

Die folgende Abfrage (nennen Sie sie Abfrage 6) verwendet die UDF EndOfYear, um Bestellungen zu filtern, die an einem Jahresendedatum aufgegeben wurden:

  SELECT orderid
  FROM Sales.Orders
  WHERE orderdate = dbo.EndOfYear(orderdate);

Der Plan für diese Abfrage ist in Abbildung 6 dargestellt.


Abbildung 6:Plan für Abfrage 6

Der Plan zeigt deutlich, dass die UDF inliniert wurde.

Tabellenausdrücke, Nichtdeterminismus und Mehrfachreferenzen

Wie bereits erwähnt, werden nicht deterministische Funktionen wie SYSDATETIME einmal pro Referenz in einer Abfrage aufgerufen. Was aber, wenn Sie einmal in einer Abfrage in einem Tabellenausdruck wie einem CTE auf eine solche Funktion verweisen und dann eine äußere Abfrage mit mehreren Verweisen auf den CTE haben? Viele Leute wissen nicht, dass jeder Verweis auf den Tabellenausdruck separat erweitert wird und der eingebettete Code zu mehreren Verweisen auf die zugrunde liegende nichtdeterministische Funktion führt. Mit einer Funktion wie SYSDATETIME könnten Sie abhängig vom genauen Zeitpunkt jeder Ausführung für jede Ausführung ein anderes Ergebnis erhalten. Manche Leute finden dieses Verhalten überraschend.

Dies kann mit folgendem Code veranschaulicht werden:

  DECLARE @i AS INT = 1, @rc AS INT = NULL;
  WHILE 1 = 1
  BEGIN;
    WITH C1 AS
    (
      SELECT SYSDATETIME() AS dt
    ),
    C2 AS
    (
      SELECT dt FROM C1
      UNION
      SELECT dt FROM C1
    )
    SELECT @rc = COUNT(*) FROM C2;
 
    IF @rc > 1 BREAK;
 
    SET @i += 1;
  END;
 
  SELECT @rc AS distinctvalues, @i AS iterations;

Wenn beide Verweise auf C1 in der Abfrage in C2 dasselbe darstellten, hätte dieser Code zu einer Endlosschleife geführt. Da die beiden Referenzen jedoch separat erweitert werden, führt die Vereinigung zu zwei Zeilen, wenn das Timing so ist, dass jeder Aufruf in einem anderen 100-Nanosekunden-Intervall (der Genauigkeit des Ergebniswerts) stattfindet, und der Code sollte von der Schleife. Führen Sie diesen Code aus und überzeugen Sie sich selbst. In der Tat bricht es nach einigen Iterationen. Ich habe bei einer der Ausführungen folgendes Ergebnis erhalten:

  distinctvalues iterations
  -------------- -----------
  2              448

Die bewährte Methode besteht darin, die Verwendung von Tabellenausdrücken wie CTEs und Ansichten zu vermeiden, wenn die innere Abfrage nicht deterministische Berechnungen verwendet und die äußere Abfrage mehrmals auf den Tabellenausdruck verweist. Das gilt natürlich nur, wenn Sie die Auswirkungen verstehen und damit einverstanden sind. Alternative Optionen könnten darin bestehen, das innere Abfrageergebnis zu speichern, beispielsweise in einer temporären Tabelle, und dann die temporäre Tabelle beliebig oft abzufragen.

Angenommen, Sie müssen eine Abfrage schreiben, die zufällig Mitarbeiter aus der HR.Employees-Tabelle paart, um Beispiele zu demonstrieren, bei denen die Nichtbeachtung der Best Practices zu Problemen führen kann. Sie kommen mit der folgenden Abfrage (nennen Sie sie Abfrage 7), um die Aufgabe zu erledigen:

  WITH C AS
  (
    SELECT empid, firstname, lastname, ROW_NUMBER() OVER(ORDER BY CHECKSUM(NEWID())) AS n
    FROM HR.Employees
  )
  SELECT
    C1.empid AS empid1, C1.firstname AS firstname1, C1.lastname AS lastname1,
    C2.empid AS empid2, C2.firstname AS firstname2, C2.lastname AS lastname2
  FROM C AS C1
    INNER JOIN C AS C2
      ON C1.n = C2.n + 1;

Der Plan für diese Abfrage ist in Abbildung 7 dargestellt.


Abbildung 7:Plan für Abfrage 7

Beachten Sie, dass die beiden Verweise auf C separat erweitert werden und die Zeilennummern unabhängig für jeden Verweis berechnet werden, der durch unabhängige Aufrufe des Ausdrucks CHECKSUM(NEWID()) geordnet ist. Dies bedeutet, dass derselbe Mitarbeiter nicht garantiert dieselbe Zeilennummer in den beiden erweiterten Referenzen erhält. Wenn ein Mitarbeiter Zeilennummer x in C1 und Zeilennummer x – 1 in C2 erhält, wird die Abfrage den Mitarbeiter mit sich selbst verknüpfen. Zum Beispiel habe ich bei einer der Ausführungen folgendes Ergebnis erhalten:

  empid1      firstname1 lastname1            empid2      firstname2 lastname2
  ----------- ---------- -------------------- ----------- ---------- --------------------
  3           Judy       Lew                  6           Paul       Suurs
  9           Patricia   Doyle     ***        9           Patricia   Doyle     ***
  5           Sven       Mortensen            4           Yael       Peled
  6           Paul       Suurs                8           Maria      Cameron
  8           Maria      Cameron              5           Sven       Mortensen
  2           Don        Funk      ***        2           Don        Funk      ***
  4           Yael       Peled                3           Judy       Lew
  7           Russell    King      ***        7           Russell    King      ***

Beachten Sie, dass es hier drei Fälle von Selbstpaaren gibt. Dies lässt sich leichter erkennen, indem der äußeren Abfrage ein Filter hinzugefügt wird, der speziell nach Selbstpaaren sucht, etwa so:

  WITH C AS
  (
    SELECT empid, firstname, lastname, ROW_NUMBER() OVER(ORDER BY CHECKSUM(NEWID())) AS n
    FROM HR.Employees
  )
  SELECT
    C1.empid AS empid1, C1.firstname AS firstname1, C1.lastname AS lastname1,
    C2.empid AS empid2, C2.firstname AS firstname2, C2.lastname AS lastname2
  FROM C AS C1
    INNER JOIN C AS C2
      ON C1.n = C2.n + 1
  WHERE C1.empid = C2.empid;

Möglicherweise müssen Sie diese Abfrage mehrmals ausführen, um das Problem zu sehen. Hier ist ein Beispiel für das Ergebnis, das ich bei einer der Hinrichtungen erhalten habe:

  empid1      firstname1 lastname1            empid2      firstname2 lastname2
  ----------- ---------- -------------------- ----------- ---------- --------------------
  5           Sven       Mortensen            5           Sven       Mortensen
  2           Don        Funk                 2           Don        Funk

Gemäß der bewährten Methode besteht eine Möglichkeit zur Lösung dieses Problems darin, das innere Abfrageergebnis in einer temporären Tabelle zu speichern und dann nach Bedarf mehrere Instanzen der temporären Tabelle abzufragen.

Ein weiteres Beispiel veranschaulicht Fehler, die sich aus der Verwendung einer nicht deterministischen Reihenfolge und mehrfachen Verweisen auf einen Tabellenausdruck ergeben können. Angenommen, Sie müssen die Tabelle „Sales.Orders“ abfragen und möchten, um eine Trendanalyse durchzuführen, jede Bestellung basierend auf der Bestellung nach Bestelldatum mit der nächsten koppeln. Ihre Lösung muss mit Systemen vor SQL Server 2012 kompatibel sein, was bedeutet, dass Sie die offensichtlichen LAG/LEAD-Funktionen nicht verwenden können. Sie beschließen, einen CTE zu verwenden, der Zeilennummern berechnet, um Zeilen basierend auf der Reihenfolge des Bestelldatums zu positionieren, und verbinden dann zwei Instanzen des CTE, indem Sie Bestellungen basierend auf einem Offset von 1 zwischen den Zeilennummern wie folgt paaren (nennen Sie diese Abfrage 8):

  WITH C AS
  (
    SELECT *, ROW_NUMBER() OVER(ORDER BY orderdate DESC) AS n
    FROM Sales.Orders
  )
  SELECT
    C1.orderid AS orderid1, C1.orderdate AS orderdate1, C1.custid AS custid1,
    C2.orderid AS orderid2, C2.orderdate AS orderdate2
  FROM C AS C1
    LEFT OUTER JOIN C AS C2
      ON C1.n = C2.n + 1;

Der Plan für diese Abfrage ist in Abbildung 8 dargestellt.

Abbildung 8:Plan für Abfrage 8

Die Reihenfolge der Zeilennummern ist nicht deterministisch, da das Bestelldatum nicht eindeutig ist. Beachten Sie, dass die beiden Verweise auf den CTE separat erweitert werden. Merkwürdigerweise entscheidet sich der Optimierer, in jedem Fall einen anderen Index zu verwenden, da die Abfrage nach einer anderen Teilmenge von Spalten aus jeder der Instanzen sucht. In einem Fall verwendet es einen geordneten Rückwärtsscan des Index am Bestelldatum, wodurch effektiv Zeilen mit demselben Datum basierend auf der Bestell-ID in absteigender Reihenfolge gescannt werden. Im anderen Fall scannt es den gruppierten Index, sortiert falsch und sortiert dann, aber effektiv unter Zeilen mit demselben Datum greift es auf die Zeilen in aufsteigender Reihenfolge zu. Das liegt an einer ähnlichen Argumentation, die ich zuvor im Abschnitt über nichtdeterministische Ordnung angeführt habe. Dies kann dazu führen, dass dieselbe Zeile in einem Fall die Zeilennummer x und in dem anderen Fall die Zeilennummer x – 1 erhält. In einem solchen Fall wird der Join am Ende eine Bestellung mit sich selbst abgleichen, anstatt mit der nächsten, wie es sollte.

Beim Ausführen dieser Abfrage habe ich folgendes Ergebnis erhalten:

  orderid1    orderdate1 custid1     orderid2    orderdate2
  ----------- ---------- ----------- ----------- ----------
  11074       2019-05-06 73          NULL        NULL
  11075       2019-05-06 68          11077       2019-05-06
  11076       2019-05-06 9           11076       2019-05-06 ***
  11077       2019-05-06 65          11075       2019-05-06
  11070       2019-05-05 44          11074       2019-05-06
  11071       2019-05-05 46          11073       2019-05-05
  11072       2019-05-05 20          11072       2019-05-05 ***
  ...

Beachten Sie die Selbstübereinstimmungen im Ergebnis. Auch hier kann das Problem leichter identifiziert werden, indem ein Filter hinzugefügt wird, der nach Selbstübereinstimmungen sucht, etwa so:

  WITH C AS
  (
    SELECT *, ROW_NUMBER() OVER(ORDER BY orderdate DESC) AS n
    FROM Sales.Orders
  )
  SELECT
    C1.orderid AS orderid1, C1.orderdate AS orderdate1, C1.custid AS custid1,
    C2.orderid AS orderid2, C2.orderdate AS orderdate2
  FROM C AS C1
    LEFT OUTER JOIN C AS C2
      ON C1.n = C2.n + 1
  WHERE C1.orderid = C2.orderid;

Ich habe die folgende Ausgabe von dieser Abfrage:

  orderid1    orderdate1 custid1     orderid2    orderdate2
  ----------- ---------- ----------- ----------- ----------
  11076       2019-05-06 9           11076       2019-05-06
  11072       2019-05-05 20          11072       2019-05-05
  11062       2019-04-30 66          11062       2019-04-30
  11052       2019-04-27 34          11052       2019-04-27
  11042       2019-04-22 15          11042       2019-04-22
  ...

Die beste Vorgehensweise hier ist sicherzustellen, dass Sie eine eindeutige Reihenfolge verwenden, um den Determinismus zu gewährleisten, indem Sie der Fensterreihenfolgeklausel einen Tiebreaker wie orderid hinzufügen. Obwohl Sie also mehrere Verweise auf denselben CTE haben, werden die Zeilennummern in beiden gleich sein. Wenn Sie die Wiederholung der Berechnungen vermeiden möchten, können Sie auch in Betracht ziehen, das Ergebnis der inneren Abfrage beizubehalten, aber dann müssen Sie die zusätzlichen Kosten einer solchen Arbeit berücksichtigen.

CASE/NULLIF und nichtdeterministische Funktionen

Wenn Sie in einer Abfrage mehrere Verweise auf eine nicht deterministische Funktion haben, wird jeder Verweis separat ausgewertet. Was überraschend sein und sogar zu Fehlern führen kann, ist, dass Sie manchmal eine Referenz schreiben, die jedoch implizit in mehrere Referenzen umgewandelt wird. Dies ist bei einigen Verwendungen des CASE-Ausdrucks und der IIF-Funktion der Fall.

Betrachten Sie das folgende Beispiel:

SELECT CASE ABS(CHECKSUM(NEWID())) % 2 WHEN 0 THEN 'Even' WHEN 1 THEN 'Odd' END;

Hier ist das Ergebnis des getesteten Ausdrucks ein nicht negativer ganzzahliger Wert, also muss es eindeutig entweder gerade oder ungerade sein. Es kann weder gerade noch ungerade sein. However, if you run this code enough times, you will sometimes get a NULL indicating that the implied ELSE NULL clause of the CASE expression was activated. The reason for this is that the above expression translates to the following:

  SELECT 
    CASE 
      WHEN ABS(CHECKSUM(NEWID())) % 2 = 0 THEN 'Even' 
      WHEN ABS(CHECKSUM(NEWID())) % 2 = 1 THEN 'Odd'
      ELSE NULL
    END;

In the converted expression there are two separate references to the tested expression that generates a random nonnegative value, and each gets evaluated separately. One possible path is that the first evaluation produces an odd number, the second produces an even number, and then the ELSE NULL clause is activated.

Here’s a very similar situation with the NULLIF function:

  SELECT NULLIF(ABS(CHECKSUM(NEWID())) % 2, 0);

This expression generates a random nonnegative value, and is supposed to return 1 when it’s odd, and NULL otherwise. It’s never supposed to return 0 since in such a case the 0 is supposed to be replaced with a NULL. Run it a few times and you will see that in some cases you get a 0. The reason for this is that the above expression internally translates to the following one:

  SELECT
    CASE
      WHEN ABS(CHECKSUM(NEWID())) % 2 = 0 THEN NULL
      ELSE ABS(CHECKSUM(NEWID())) % 2
    END;

A possible path is that the first WHEN clause generates a random odd value, so the ELSE clause is activated, and the ELSE clause generates a random even value so the % 2 calculation results in a 0.

In both cases this behavior is standard, so the bug is more in the eyes of the beholder based on your expectations and your choice of how to write the code. The best practice in both cases is to persist the result of the original calculation and then interact with the persisted result. If it’s a single value, store the result in a variable first. If you’re querying tables, first persist the result of the nondeterministic calculation in a column in a temporary table, and then apply the CASE/IIF logic in the query against the temporary table.

Schlussfolgerung

This article is the first in a series about T-SQL bugs, pitfalls and best practices, and is the result of discussions with fellow Microsoft Data Platform MVPs who shared their experiences. This time I focused on bugs and pitfalls that resulted from using nondeterministic order and nondeterministic calculations. In future articles I’ll continue with other themes. If you have bugs and pitfalls that you often stumble into, or that you find as particularly interesting, please do share!