Zoals elke programmeertaal heeft T-SQL een aantal veelvoorkomende bugs en valkuilen, waarvan sommige onjuiste resultaten veroorzaken en andere prestatieproblemen veroorzaken. In veel van die gevallen zijn er best practices die u kunnen helpen voorkomen dat u in de problemen komt. Ik heb mede-Microsoft Data Platform MVP's ondervraagd en gevraagd naar de bugs en valkuilen die ze vaak zien of die ze gewoon bijzonder interessant vinden, en de best practices die ze gebruiken om die te vermijden. Ik heb veel interessante gevallen.
Veel dank aan Erland Sommarskog, Aaron Bertrand, Alejandro Mesa, Umachandar Jayachandran (UC), Fabiano Neves Amorim, Milos Radivojevic, Simon Sabin, Adam Machanic, Thomas Grohser en Chan Ming Man voor het delen van uw kennis en ervaring!
Dit artikel is het eerste in een reeks over dit onderwerp. Elk artikel richt zich op een bepaald thema. Deze maand focus ik op bugs, valkuilen en best practices die te maken hebben met determinisme. Een deterministische berekening is een berekening die gegarandeerd herhaalbare resultaten oplevert bij dezelfde invoer. Er zijn veel bugs en valkuilen die het gevolg zijn van het gebruik van niet-deterministische berekeningen. In dit artikel behandel ik de implicaties van het gebruik van niet-deterministische volgorde, niet-deterministische functies, meerdere verwijzingen naar tabeluitdrukkingen met niet-deterministische berekeningen, en het gebruik van CASE-expressies en de NULLIF-functie met niet-deterministische berekeningen.
Ik gebruik de voorbeelddatabase TSQLV5 in veel van de voorbeelden in deze serie.
Niet-deterministische volgorde
Een veelvoorkomende bron voor bugs in T-SQL is het gebruik van niet-deterministische volgorde. Dat wil zeggen, wanneer uw volgorde op lijst een rij niet uniek identificeert. Het kan een presentatiebestelling, TOP/OFFSET-FETCH-bestelling of raambestelling zijn.
Neem bijvoorbeeld een klassiek pagingscenario met het OFFSET-FETCH-filter. U moet de tabel Sales.Orders doorzoeken en één pagina van 10 rijen tegelijk retourneren, gerangschikt op besteldatum, aflopend (meest recente eerst). Ik zal voor de eenvoud constanten gebruiken voor de offset- en fetch-elementen, maar meestal zijn het uitdrukkingen die zijn gebaseerd op invoerparameters.
De volgende zoekopdracht (noem het Query 1) retourneert de eerste pagina van 10 meest recente bestellingen:
USE TSQLV5; SELECT orderid, orderdate, custid FROM Sales.Orders ORDER BY orderdate DESC OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;
Het plan voor Query 1 wordt getoond in figuur 1.
Figuur 1:Plan voor query 1
De query ordent de rijen op orderdatum, aflopend. De kolom orderdatum identificeert een rij niet uniek. Deze niet-deterministische volgorde betekent dat er conceptueel geen voorkeur is tussen de rijen met dezelfde datum. In het geval van gelijken, bepaalt welke rij SQL Server de voorkeur geeft, zaken als plankeuzes en fysieke gegevenslay-out - niet iets waarop u kunt vertrouwen als herhaalbaar. Het plan in figuur 1 scant de index op besteldatum achteruit geordend. Het gebeurt zo dat deze tabel een geclusterde index heeft op orderid, en in een geclusterde tabel wordt de geclusterde indexsleutel gebruikt als rij-locator in niet-geclusterde indexen. Het wordt eigenlijk impliciet gepositioneerd als het laatste sleutelelement in alle niet-geclusterde indexen, ook al zou SQL Server het in theorie als een opgenomen kolom in de index hebben kunnen plaatsen. Dus impliciet is de niet-geclusterde index op orderdatum feitelijk gedefinieerd op (orderdate, orderid). Bijgevolg wordt in onze geordende achterwaartse scan van de index, tussen gekoppelde rijen op basis van orderdatum, een rij met een hogere orderid-waarde geopend vóór een rij met een lagere orderid-waarde. Deze query genereert de volgende uitvoer:
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
Gebruik vervolgens de volgende query (noem het Query 2) om de tweede pagina van 10 rijen te krijgen:
SELECT orderid, orderdate, custid FROM Sales.Orders ORDER BY orderdate DESC OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;
Het plan voor Query wordt getoond in figuur 2.
Figuur 2:Plan voor query 2
De optimizer kiest een ander plan:het ene scant de geclusterde index op een ongeordende manier en gebruikt een TopN Sort om het verzoek van de Top-operator te ondersteunen om het offset-ophaalfilter af te handelen. De reden voor de wijziging is dat het plan in figuur 1 een niet-geclusterde niet-dekkende index gebruikt, en hoe verder de pagina die u zoekt, hoe meer zoekacties nodig zijn. Met het tweede paginaverzoek overschreed je het omslagpunt dat het gebruik van de niet-dekkende index rechtvaardigt.
Hoewel de scan van de geclusterde index, die is gedefinieerd met orderid als sleutel, ongeordend is, gebruikt de opslagengine intern een indexorderscan. Dit heeft te maken met de grootte van de index. Tot 64 pagina's geeft de opslagengine over het algemeen de voorkeur aan scans van indexvolgorden boven scans van toewijzingsvolgorden. Zelfs als de index groter was, onder het read-commit-isolatieniveau en gegevens die niet zijn gemarkeerd als alleen-lezen, gebruikt de opslagengine een indexvolgordescan om dubbel lezen en het overslaan van rijen te voorkomen als gevolg van paginasplitsingen die optreden tijdens de scannen. Onder de gegeven omstandigheden, in de praktijk, tussen rijen met dezelfde datum, heeft dit plan toegang tot een rij met een lagere orderid voor een rij met een hogere orderid.
Deze query genereert de volgende uitvoer:
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
Merk op dat, hoewel de onderliggende gegevens niet zijn gewijzigd, u uiteindelijk dezelfde bestelling (met bestel-ID 11069) terugkrijgt op zowel de eerste als de tweede pagina!
Hopelijk is de beste praktijk hier duidelijk. Voeg een tiebreaker toe aan je lijst met volgordes om een deterministische volgorde te krijgen. Sorteer bijvoorbeeld op orderdatum aflopend, orderid aflopend.
Probeer opnieuw om de eerste pagina te vragen, dit keer met een deterministische volgorde:
SELECT orderid, orderdate, custid FROM Sales.Orders ORDER BY orderdate DESC, orderid DESC OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;
U krijgt gegarandeerd de volgende output:
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
Vraag naar de tweede pagina:
SELECT orderid, orderdate, custid FROM Sales.Orders ORDER BY orderdate DESC, orderid DESC OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;
U krijgt gegarandeerd de volgende output:
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
Zolang er geen wijzigingen zijn in de onderliggende gegevens, krijgt u gegarandeerd opeenvolgende pagina's zonder herhalingen of overslaan van rijen tussen de pagina's.
Op een vergelijkbare manier kunt u, door vensterfuncties zoals ROW_NUMBER met niet-deterministische volgorde te gebruiken, verschillende resultaten krijgen voor dezelfde query, afhankelijk van de vorm van het plan en de daadwerkelijke toegangsvolgorde tussen banden. Overweeg de volgende query (noem het Query 3), waarbij het verzoek om de eerste pagina wordt geïmplementeerd met behulp van rijnummers (waardoor het gebruik van de index op besteldatum voor illustratiedoeleinden wordt geforceerd):
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;
Het plan voor deze zoekopdracht wordt weergegeven in Afbeelding 3:
Figuur 3:Plan voor query 3
U heeft hier zeer vergelijkbare voorwaarden als die ik eerder heb beschreven voor Query 1 met zijn plan dat eerder in figuur 1 werd getoond. Tussen rijen met banden in de orderdatumwaarden, heeft dit plan toegang tot een rij met een hogere orderid-waarde vóór een met een lagere orderid waarde. Deze query genereert de volgende uitvoer:
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
Voer vervolgens de query opnieuw uit (noem het Query 4) en vraag de eerste pagina op, maar forceer deze keer het gebruik van de geclusterde 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;
Het plan voor deze zoekopdracht wordt getoond in figuur 4.
Figuur 4:Plan voor query 4
Deze keer heb je zeer vergelijkbare voorwaarden als degene die ik eerder heb beschreven voor Query 2 met zijn plan dat eerder in figuur 2 werd getoond. Tussen rijen met banden in de orderdatumwaarden, heeft dit plan toegang tot een rij met een lagere orderid-waarde vóór een met een hogere orderid waarde. Deze query genereert de volgende uitvoer:
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
Merk op dat de twee uitvoeringen verschillende resultaten opleverden, ook al veranderde er niets in de onderliggende gegevens.
Nogmaals, de beste werkwijze hier is eenvoudig:gebruik deterministische volgorde door een tiebreak toe te voegen, zoals:
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;
Deze query genereert de volgende uitvoer:
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
De geretourneerde set is gegarandeerd herhaalbaar, ongeacht de vorm van het plan.
Het is waarschijnlijk de moeite waard om te vermelden dat, aangezien deze query geen presentatievolgorde per clausule in de buitenste query heeft, er hier geen gegarandeerde presentatievolgorde is. Als je zo'n garantie nodig hebt, moet je een aanbiedingsvolgorde per clausule toevoegen, zoals:
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;
Niet-deterministische functies
Een niet-deterministische functie is een functie die, gegeven dezelfde invoer, verschillende resultaten kan retourneren in verschillende uitvoeringen van de functie. Klassieke voorbeelden zijn SYSDATETIME, NEWID en RAND (indien aangeroepen zonder invoer seed). Het gedrag van niet-deterministische functies in T-SQL kan voor sommigen verrassend zijn en kan in sommige gevallen leiden tot bugs en valkuilen.
Veel mensen gaan ervan uit dat wanneer u een niet-deterministische functie aanroept als onderdeel van een query, de functie afzonderlijk per rij wordt geëvalueerd. In de praktijk worden de meeste niet-deterministische functies eenmaal per referentie in de query geëvalueerd. Beschouw de volgende vraag als voorbeeld:
SELECT orderid, SYSDATETIME() AS dt, RAND() AS rnd FROM Sales.Orders;
Aangezien er slechts één verwijzing is naar elk van de niet-deterministische functies SYSDATETIME en RAND in de query, wordt elk van deze functies slechts één keer geëvalueerd en wordt het resultaat herhaald over alle resultaatrijen. Ik kreeg de volgende uitvoer bij het uitvoeren van deze query:
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 ...
Als voorbeeld waarbij het niet begrijpen van dit gedrag kan resulteren in een bug, stel dat u een query moet schrijven die drie willekeurige bestellingen uit de tabel Sales.Orders retourneert. Een gebruikelijke eerste poging is om een TOP-query te gebruiken met een volgorde op basis van de RAND-functie, in de veronderstelling dat de functie afzonderlijk per rij zou worden geëvalueerd, zoals:
SELECT TOP (3) orderid FROM Sales.Orders ORDER BY RAND();
In de praktijk wordt de functie slechts één keer geëvalueerd voor de hele query; daarom krijgen alle rijen hetzelfde resultaat en wordt de volgorde niet beïnvloed. Als u het plan voor deze zoekopdracht controleert, ziet u zelfs geen sorteeroperator. Toen ik deze zoekopdracht meerdere keren uitvoerde, kreeg ik steeds hetzelfde resultaat:
orderid ----------- 11008 11019 11039
De query is eigenlijk gelijk aan een query zonder een ORDER BY-clausule, waarbij de volgorde van de presentatie niet is gegarandeerd. Dus technisch gezien is de volgorde niet-deterministisch, en theoretisch verschillende uitvoeringen kunnen resulteren in een andere volgorde, en dus in een andere selectie van de bovenste 3 rijen. De kans hierop is echter klein en u kunt deze oplossing niet beschouwen als het produceren van drie willekeurige rijen in elke uitvoering.
Een uitzondering op de regel dat een niet-deterministische functie eenmaal per verwijzing in de query wordt aangeroepen, is de functie NEWID, die een GUID (Globally Unique Identifier) retourneert. Bij gebruik in een zoekopdracht is deze functie is afzonderlijk per rij aangeroepen. De volgende zoekopdracht toont dit aan:
SELECT orderid, NEWID() AS mynewid FROM Sales.Orders;
Deze query genereerde de volgende uitvoer:
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 ...
De waarde van NEWID zelf is vrij willekeurig. Als je de functie CHECKSUM er bovenop toepast, krijg je een geheel getal met een nog betere willekeurige verdeling. Dus een manier om drie willekeurige bestellingen te krijgen, is door een TOP-query te gebruiken met volgorde op basis van CHECKSUM(NEWID()), zoals:
SELECT TOP (3) orderid FROM Sales.Orders ORDER BY CHECKSUM(NEWID());
Voer deze query herhaaldelijk uit en merk op dat u elke keer een andere set van drie willekeurige bestellingen krijgt. Ik kreeg de volgende uitvoer in één uitvoering:
orderid ----------- 11031 10330 10962
En de volgende uitvoer in een andere uitvoering:
orderid ----------- 10308 10885 10444
Behalve NEWID, wat als u een niet-deterministische functie zoals SYSDATETIME in een query moet gebruiken, en u wilt dat deze afzonderlijk per rij wordt geëvalueerd? Een manier om dit te bereiken is door een door de gebruiker gedefinieerde functie (UDF) te gebruiken die de niet-deterministische functie aanroept, zoals:
CREATE OR ALTER FUNCTION dbo.MySysDateTime() RETURNS DATETIME2 AS BEGIN RETURN SYSDATETIME(); END; GO
Je roept dan de UDF aan in de query als volgt (noem het Query 5):
SELECT orderid, dbo.MySysDateTime() AS mydt FROM Sales.Orders;
De UDF wordt deze keer wel per rij uitgevoerd. U moet er echter rekening mee houden dat er een behoorlijk scherpe prestatiestraf is verbonden aan de uitvoering per rij van de UDF. Bovendien is het aanroepen van een scalaire T-SQL UDF een parallellisme-remmer.
Het plan voor deze zoekopdracht wordt getoond in figuur 5.
Figuur 5:Plan voor query 5
Merk in het plan op dat de UDF inderdaad wordt aangeroepen per bronrij in de Compute Scalar-operator. Merk ook op dat SentryOne Plan Explorer u waarschuwt voor de mogelijke prestatievermindering die gepaard gaat met het gebruik van de UDF, zowel in de Compute Scalar-operator als in het hoofdknooppunt van het plan.
Ik kreeg de volgende uitvoer van de uitvoering van deze query:
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 ...
Merk op dat de uitvoerrijen meerdere verschillende datum- en tijdwaarden hebben in de mydt-kolom.
U hebt misschien gehoord dat SQL Server 2019 het veelvoorkomende prestatieprobleem verhelpt dat wordt veroorzaakt door scalaire T-SQL UDF's door dergelijke functies in te lijnen. De UDF moet echter aan een lijst met vereisten voldoen om inlineable te zijn. Een van de vereisten is dat de UDF geen niet-deterministische intrinsieke functie zoals SYSDATETIME aanroept. De redenering voor deze vereiste is dat u de UDF misschien precies hebt gemaakt om een uitvoering per rij te krijgen. Als de UDF inline zou worden, zou de onderliggende niet-deterministische functie slechts één keer worden uitgevoerd voor de hele query. In feite is het plan in afbeelding 5 gegenereerd in SQL Server 2019 en je kunt duidelijk zien dat de UDF niet inline is geworden. Dat komt door het gebruik van de niet-deterministische functie SYSDATETIME. U kunt controleren of een UDF inlineable is in SQL Server 2019 door het kenmerk is_inlineable in de weergave sys.sql_modules op te vragen, zoals:
SELECT is_inlineable FROM sys.sql_modules WHERE object_id = OBJECT_ID(N'dbo.MySysDateTime');
Deze code genereert de volgende uitvoer die u vertelt dat de UDF MySysDateTime niet inlineeerbaar is:
is_inlineable ------------- 0
Om een UDF te demonstreren die inlineable is, volgt hier de definitie van een UDF genaamd EndOfyear die een invoerdatum accepteert en de respectieve eindejaarsdatum retourneert:
CREATE OR ALTER FUNCTION dbo.EndOfYear(@dt AS DATE) RETURNS DATE AS BEGIN RETURN DATEADD(year, DATEDIFF(year, '18991231', @dt), '18991231'); END; GO
Er wordt hier geen gebruik gemaakt van niet-deterministische functies en de code voldoet ook aan de andere vereisten voor inlining. U kunt controleren of de UDF inlineable is door de volgende code te gebruiken:
SELECT is_inlineable FROM sys.sql_modules WHERE object_id = OBJECT_ID(N'dbo.EndOfYear');
Deze code genereert de volgende uitvoer:
is_inlineable ------------- 1
De volgende query (noem het Query 6) gebruikt de UDF EndOfYear om bestellingen te filteren die op een eindejaarsdatum zijn geplaatst:
SELECT orderid FROM Sales.Orders WHERE orderdate = dbo.EndOfYear(orderdate);
Het plan voor deze query wordt getoond in figuur 6.
Figuur 6:Plan voor query 6
Het plan laat duidelijk zien dat de UDF inline is geraakt.
Tabeluitdrukkingen, niet-determinisme en meerdere verwijzingen
Zoals vermeld, worden niet-deterministische functies zoals SYSDATETIME eenmaal per referentie in een query aangeroepen. Maar wat als u een keer naar een dergelijke functie verwijst in een query in een tabelexpressie zoals een CTE, en vervolgens een buitenste query hebt met meerdere verwijzingen naar de CTE? Veel mensen realiseren zich niet dat elke verwijzing naar de tabeluitdrukking afzonderlijk wordt uitgebreid en dat de inline-code resulteert in meerdere verwijzingen naar de onderliggende niet-deterministische functie. Met een functie als SYSDATETIME, afhankelijk van de exacte timing van elk van de uitvoeringen, zou je uiteindelijk voor elk een ander resultaat kunnen krijgen. Sommige mensen vinden dit gedrag verrassend.
Dit kan worden geïllustreerd met de volgende code:
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;
Als beide verwijzingen naar C1 in de query in C2 hetzelfde voorstelden, zou deze code in een oneindige lus hebben geresulteerd. Omdat de twee verwijzingen echter afzonderlijk worden uitgebreid, wanneer de timing zodanig is dat elke aanroep plaatsvindt in een ander interval van 100 nanoseconden (de precisie van de resultaatwaarde), resulteert de unie in twee rijen en moet de code breken met de lus. Voer deze code uit en ontdek het zelf. Inderdaad, na enkele iteraties breekt het. Ik kreeg het volgende resultaat in een van de executies:
distinctvalues iterations -------------- ----------- 2 448
Het beste is om het gebruik van tabelexpressies zoals CTE's en views te vermijden, wanneer de inner query niet-deterministische berekeningen gebruikt en de outer query meerdere keren naar de tabelexpressie verwijst. Dat is natuurlijk tenzij je de implicaties begrijpt en je het goed vindt. Alternatieve opties kunnen zijn om het resultaat van de innerlijke query te behouden, bijvoorbeeld in een tijdelijke tabel, en vervolgens de tijdelijke tabel zo vaak als nodig is te doorzoeken.
Om voorbeelden te demonstreren waarbij het niet volgen van de beste werkwijze u in de problemen kan brengen, stelt u zich voor dat u een query moet schrijven die werknemers willekeurig uit de tabel HR.Employees koppelt. Je bedenkt de volgende vraag (noem het vraag 7) om de taak uit te voeren:
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;
Het plan voor deze query wordt getoond in figuur 7.
Figuur 7:Plan voor Query 7
Merk op dat de twee verwijzingen naar C afzonderlijk worden uitgebreid en dat de rijnummers onafhankelijk worden berekend voor elke verwijzing, geordend door onafhankelijke aanroepen van de expressie CHECKSUM(NEWID()). Dit betekent dat dezelfde werknemer niet gegarandeerd hetzelfde rijnummer krijgt in de twee uitgebreide verwijzingen. Als een medewerker rijnummer x in C1 en rijnummer x – 1 in C2 krijgt, zal de zoekopdracht de medewerker aan zichzelf koppelen. Ik kreeg bijvoorbeeld het volgende resultaat in een van de executies:
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 ***
Merk op dat er hier drie gevallen van zelfparen zijn. Dit is gemakkelijker te zien door een filter toe te voegen aan de buitenste zoekopdracht die specifiek naar zelfparen zoekt, zoals:
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;
Mogelijk moet u deze query een aantal keren uitvoeren om het probleem te zien. Hier is een voorbeeld van het resultaat dat ik kreeg in een van de executies:
empid1 firstname1 lastname1 empid2 firstname2 lastname2 ----------- ---------- -------------------- ----------- ---------- -------------------- 5 Sven Mortensen 5 Sven Mortensen 2 Don Funk 2 Don Funk
Volgens de best practice is een manier om dit probleem op te lossen, het resultaat van de interne query in een tijdelijke tabel te bewaren en vervolgens indien nodig meerdere exemplaren van de tijdelijke tabel op te vragen.
Een ander voorbeeld illustreert bugs die het gevolg kunnen zijn van het gebruik van niet-deterministische volgorde en meerdere verwijzingen naar een tabeluitdrukking. Stel dat u de tabel Sales.Orders moet doorzoeken en om trendanalyses uit te voeren, wilt u elke bestelling koppelen aan de volgende op basis van de besteldatum. Uw oplossing moet compatibel zijn met pre-SQL Server 2012-systemen, wat betekent dat u de voor de hand liggende LAG/LEAD-functies niet kunt gebruiken. U besluit een CTE te gebruiken die rijnummers berekent om rijen te positioneren op basis van volgorde van orderdatum, en vervolgens twee instanties van de CTE samen te voegen, waarbij orders worden gekoppeld op basis van een offset van 1 tussen de rijnummers, zoals zo (noem deze Query 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;
Het plan voor deze zoekopdracht wordt getoond in figuur 8.
Figuur 8:Plan voor Query 8
De volgorde van het rijnummer is niet bepalend, aangezien de besteldatum niet uniek is. Merk op dat de twee verwijzingen naar de CTE afzonderlijk worden uitgebreid. Vreemd genoeg, aangezien de query zoekt naar een andere subset van kolommen van elk van de instanties, besluit de optimizer om in elk geval een andere index te gebruiken. In één geval gebruikt het een geordende achterwaartse scan van de index op orderdatum, waardoor effectief rijen met dezelfde datum worden gescand op basis van orderid aflopende volgorde. In het andere geval scant het de geclusterde index, geordend op onwaar en sorteert het, maar effectief tussen rijen met dezelfde datum, benadert het de rijen in oplopende volgorde. Dat komt door een soortgelijke redenering die ik eerder in het gedeelte over niet-deterministische volgorde heb gegeven. Dit kan ertoe leiden dat dezelfde rij rijnummer x krijgt in het ene geval en rijnummer x – 1 in het andere geval. In zo'n geval zal de join uiteindelijk een bestelling met zichzelf matchen in plaats van met de volgende zoals het hoort.
Ik kreeg het volgende resultaat bij het uitvoeren van deze query:
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 *** ...
Let op de zelf-matches in het resultaat. Nogmaals, het probleem kan gemakkelijker worden geïdentificeerd door een filter toe te voegen dat op zoek is naar zelf-overeenkomsten, zoals:
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;
Ik kreeg de volgende output van deze vraag:
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 ...
De beste werkwijze hier is om ervoor te zorgen dat u een unieke volgorde gebruikt om determinisme te garanderen door een tiebreaker zoals orderid toe te voegen aan de venstervolgorde-clausule. Dus ook al heb je meerdere verwijzingen naar dezelfde CTE, de rijnummers zullen in beide hetzelfde zijn. Als u herhaling van de berekeningen wilt vermijden, kunt u ook overwegen om het resultaat van de interne zoekopdracht te behouden, maar dan moet u wel rekening houden met de extra kosten van dergelijk werk.
CASE/NULLIF en niet-deterministische functies
Wanneer u meerdere verwijzingen naar een niet-deterministische functie in een query hebt, wordt elke verwijzing afzonderlijk geëvalueerd. Wat verrassend kan zijn en zelfs tot bugs kan leiden, is dat je soms één referentie schrijft, maar impliciet wordt deze omgezet in meerdere referenties. Such is the situation with some uses of the CASE expression and IIF function.
Consider the following example:
SELECT CASE ABS(CHECKSUM(NEWID())) % 2 WHEN 0 THEN 'Even' WHEN 1 THEN 'Odd' END;
Here the outcome of the tested expression is a nonnegative integer value, so clearly it has to be either even or odd. It cannot be neither even nor odd. 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.
Conclusie
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!