Dit artikel is de vierde aflevering in een serie over T-SQL bugs, valkuilen, en best practices. Eerder behandelde ik determinisme, subqueries en joins. De focus van het artikel van deze maand is bugs, valkuilen en best practices met betrekking tot window functies. Bedankt Erland Sommarskog, Aaron Bertrand, Alejandro Mesa, Umachandar Jayachandran (UC), Fabiano Neves Amorim, Milos Radivojevic, Simon Sabin, Adam Machanic, Thomas Grohser, Chan Ming Man en Paul White voor het aandragen van jullie ideeen!
In mijn voorbeelden gebruik ik een voorbeeld database genaamd TSQLV5. Je kunt het script dat deze database aanmaakt en opvult hier vinden, en het ER diagram hier.
Er zijn twee veel voorkomende valkuilen met betrekking tot window functies, die beide het resultaat zijn van tegenintuïtieve impliciete defaults die worden opgelegd door de SQL standaard. De ene valkuil heeft te maken met berekeningen van doorlopende totalen waarbij je een window frame krijgt met de impliciete RANGE optie. Een andere valkuil is enigszins verwant, maar heeft ernstiger gevolgen, en betreft een impliciete frame definitie voor de FIRST_VALUE en LAST_VALUE functies.
Venster frame met impliciete RANGE optie
Onze eerste valkuil betreft de berekening van doorlopende totalen met behulp van een aggregate window functie, waarbij je wel expliciet de window order clausule specificeert, maar niet expliciet de window frame eenheid (ROWS of RANGE) en de bijbehorende window frame omvang, bijv, RIJEN ONBEGRENSD VOORAFGAAND. De impliciete standaard is contra-intuïtief en de gevolgen kunnen verrassend en pijnlijk zijn.
Om deze valkuil te demonstreren, zal ik een tabel Transacties gebruiken die twee miljoen bankrekeningtransacties bevat met crediteringen (positieve waarden) en debiteringen (negatieve waarden). Voer de volgende code uit om de tabel Transacties te maken en met voorbeeldgegevens te vullen:
SET NOCOUNT ON; USE TSQLV5; -- http://tsql.solidq.com/SampleDatabases/TSQLV5.zip DROP TABLE IF EXISTS dbo.Transactions; CREATE TABLE dbo.Transactions ( actid INT NOT NULL, tranid INT NOT NULL, val MONEY NOT NULL, CONSTRAINT PK_Transactions PRIMARY KEY(actid, tranid) -- creates POC index ); DECLARE @num_partitions AS INT = 100, @rows_per_partition AS INT = 20000; INSERT INTO dbo.Transactions WITH (TABLOCK) (actid, tranid, val) SELECT NP.n, RPP.n, (ABS(CHECKSUM(NEWID())%2)*2-1) * (1 + ABS(CHECKSUM(NEWID())%5)) FROM dbo.GetNums(1, @num_partitions) AS NP CROSS JOIN dbo.GetNums(1, @rows_per_partition) AS RPP;
De valkuil heeft zowel een logische kant met een mogelijke logische fout, als een prestatiekant met een prestatieverlies. De prestatieboete is alleen relevant wanneer de vensterfunctie wordt geoptimaliseerd met rijmodusverwerkingsoperatoren. SQL Server 2016 introduceert de batch-mode Window Aggregate operator, die het performance penalty deel van de valkuil wegneemt, maar vóór SQL Server 2019 wordt deze operator alleen gebruikt als je een columnstore index aanwezig hebt op de data. SQL Server 2019 introduceert batch mode op rowstore ondersteuning, zodat u batch-mode verwerking kunt krijgen, zelfs als er geen columnstore indexen aanwezig zijn op de data. Om het prestatieverlies te demonstreren met de row-mode verwerking, als u de codevoorbeelden in dit artikel uitvoert op SQL Server 2019 of later, of op Azure SQL Database, gebruikt u de volgende code om het databasecompatibiliteitsniveau in te stellen op 140, zodat de batch-mode op rowstore nog niet is ingeschakeld:
ALTER DATABASE TSQLV5 SET COMPATIBILITY_LEVEL = 140;
Gebruik de volgende code om tijd- en I/O-statistieken in de sessie in te schakelen:
SET STATISTICS TIME, IO ON;
Om te voorkomen dat u in SSMS moet wachten totdat twee miljoen rijen zijn afgedrukt, raad ik u aan de codevoorbeelden in dit gedeelte uit te voeren met de optie Resultaten na uitvoering weggooien ingeschakeld (ga naar Query-opties, Resultaten, Raster, en vink Resultaten na uitvoering weggooien aan).
Voordat we naar de valkuil gaan, beschouwen we de volgende query (noem deze Query 1) die het saldo van de bankrekening na elke transactie berekent door een lopend totaal toe te passen met behulp van een vensteraggregatiefunctie met een expliciete framespecificatie:
SELECT actid, tranid, val, SUM(val) OVER( PARTITION BY actid ORDER BY tranid ROWS UNBOUNDED PRECEDING ) AS balance FROM dbo.Transactions;
Het plan voor deze query, waarbij gebruik wordt gemaakt van rijmodusverwerking, wordt weergegeven in Figuur 1.
Figuur 1: Plan voor query 1, verwerking in rijmodus
Het plan haalt de gegevens voorgeordend op uit de geclusterde index van de tabel. Dan gebruikt het de Segment en Sequence Project operators om rijnummers te berekenen om uit te zoeken welke rijen behoren tot het frame van de huidige rij. Vervolgens worden de Segment, Window Spool en Stream Aggregate operatoren gebruikt om de window aggregate functie te berekenen. De Window Spool operator wordt gebruikt om de frame rijen te spoolen die vervolgens moeten worden geaggregeerd. Zonder speciale optimalisatie zou het plan per rij alle van toepassing zijnde frame rijen naar de spool hebben moeten schrijven, en ze dan hebben moeten aggregeren. Dit zou hebben geresulteerd in kwadratische, of N2, complexiteit. Het goede nieuws is dat wanneer het frame begint met UNBOUNDED PRECEDING, SQL Server de case identificeert als een fast track case, waarin het eenvoudigweg het lopende totaal van de vorige rij neemt en de waarde van de huidige rij toevoegt om het lopende totaal van de huidige rij te berekenen, wat resulteert in lineaire schaalvergroting. In deze fast track mode, schrijft het plan slechts twee rijen naar de spool per input rij-één met het aggregaat, en één met het detail.
De Window Spool kan fysiek worden geïmplementeerd op een van de twee manieren. Ofwel als een snelle in-memory spool die speciaal is ontworpen voor window functies, of als een langzame on-disk spool, die in wezen een tijdelijke tabel in tempdb is. Als het aantal rijen dat naar de spool moet worden geschreven per onderliggende rij groter kan zijn dan 10.000, of als SQL Server het aantal niet kan voorspellen, zal het de langzamere on-disk spool gebruiken. In ons query plan worden precies twee rijen per onderliggende rij naar de spool geschreven, dus SQL Server gebruikt de in-memory spool. Helaas is er geen manier om aan het plan te zien wat voor soort spool je krijgt. Er zijn twee manieren om dit uit te zoeken. Een is om een uitgebreid event genaamd window_spool_ondisk_warning te gebruiken. Een andere optie is om STATISTICS IO aan te zetten en het aantal gerapporteerde logische lezingen voor een tabel genaamd Worktable te controleren. Een getal groter dan nul betekent dat je de on-disk spool hebt. Nul betekent dat je de in-memory spool hebt. Hier zijn de I/O statistieken voor onze query:
Zoals je kunt zien, hebben we de in-memory spool gebruikt. Dat is meestal het geval wanneer je de ROWS window frame unit gebruikt met UNBOUNDED PRECEDING als eerste delimiter.
Hier zijn de tijd statistieken voor onze query:
Het kostte deze query ongeveer 4,5 seconden te voltooien op mijn machine met de resultaten weggegooid.
Nu het addertje onder het gras. Als u de RANGE optie gebruikt in plaats van ROWS, met dezelfde scheidingstekens, kan er een subtiel verschil in betekenis zijn, maar een groot verschil in prestatie in rijmodus. Het verschil in betekenis is alleen relevant als je geen totale ordening hebt, d.w.z. als je rangschikt op iets dat niet uniek is. De ROWS UNBOUNDED PRECEDING optie stopt bij de huidige rij, dus in het geval van gelijk eindigen is de berekening nondeterministisch. Omgekeerd kijkt de ROWS UNBOUNDED PRECEDING optie verder dan de huidige rij, en houdt rekening met eventuele ties. Het gebruikt een gelijkaardige logica als de TOP WITH TIES optie. Wanneer je een totale ordening hebt, d.w.z. je ordent op iets unieks, zijn er geen bindingen om mee te rekenen, en daarom worden ROWS en RANGE logisch equivalent in zo’n geval. Het probleem is dat wanneer je RANGE gebruikt, SQL Server altijd gebruik maakt van de on-disk spool onder row-mode processing omdat bij het verwerken van een gegeven rij niet kan worden voorspeld hoeveel rijen er nog zullen worden opgenomen. Dit kan een ernstige prestatievermindering tot gevolg hebben.
Overweeg de volgende query (noem het Query 2), die hetzelfde is als Query 1, alleen met behulp van de RANGE-optie in plaats van ROWS:
SELECT actid, tranid, val, SUM(val) OVER( PARTITION BY actid ORDER BY tranid RANGE UNBOUNDED PRECEDING ) AS balance FROM dbo.Transactions;
Het plan voor deze query wordt getoond in Figuur 2.
Figuur 2: Plan voor query 2, verwerking in rijmodus
Query 2 is logisch equivalent aan query 1 omdat we een totale volgorde hebben; omdat deze query echter RANGE gebruikt, wordt deze geoptimaliseerd met de on-disk spool. Merk op dat in het plan voor Query 2 de Window Spool er hetzelfde uitziet als in het plan voor Query 1, en de geschatte kosten zijn hetzelfde.
Hier zijn de tijd en I/O statistieken voor de uitvoering van Query 2:
Tabel ‘Worktable’ logische leest: 12044701. Tabel ‘Transacties’ logische lezingen: 6208.
Merk het grote aantal logische lezingen tegen Worktable op, wat aangeeft dat je de on-disk spool hebt gekregen. De run time is meer dan vier keer zo lang als voor Query 1.
Als je denkt dat als dat het geval is, je gewoon zult vermijden de RANGE optie te gebruiken, tenzij je echt nodig hebt om banden op te nemen, dat is goed denken. Het probleem is dat als je een window functie gebruikt die een frame ondersteunt (aggregaten, FIRST_VALUE, LAST_VALUE) met een expliciete window order clausule, maar geen melding maakt van de window frame unit en de bijbehorende extent, je standaard RANGE UNBOUNDED PRECEDING krijgt. Deze standaard is bepaald door de SQL standaard, en de standaard heeft deze gekozen omdat het in het algemeen de voorkeur geeft aan meer deterministische opties als standaard. De volgende query (noem Query 3) is een voorbeeld dat in deze val loopt:
SELECT actid, tranid, val, SUM(val) OVER( PARTITION BY actid ORDER BY tranid ) AS balance FROM dbo.Transactions;
Vaak schrijven mensen op deze manier in de veronderstelling dat ze standaard ROWS UNBOUNDED PRECEDING krijgen, zich niet realiserend dat ze eigenlijk RANGE UNBOUNDED PRECEDING krijgen. Omdat de functie een totale volgorde gebruikt, krijg je hetzelfde resultaat als met ROWS, dus aan het resultaat kun je niet zien dat er een probleem is. Maar de prestatiecijfers die je krijgt zijn zoals voor Query 2. Ik zie mensen de hele tijd in deze val lopen.
De beste praktijk om dit probleem te voorkomen is in gevallen waarin je een window functie met een frame gebruikt, expliciet te zijn over de window frame eenheid en de omvang, en in het algemeen de voorkeur te geven aan ROWS. Gebruik RANGE alleen in gevallen waarin de volgorde niet uniek is en u verbanden moet opnemen.
Beschouw de volgende query ter illustratie van een geval waarin er een conceptueel verschil is tussen ROWS en RANGE:
SELECT orderdate, orderid, val, SUM(val) OVER( ORDER BY orderdate ROWS UNBOUNDED PRECEDING ) AS sumrows, SUM(val) OVER( ORDER BY orderdate RANGE UNBOUNDED PRECEDING ) AS sumrange FROM Sales.OrderValues ORDER BY orderdate;
Deze query levert de volgende uitvoer op:
orderdate orderid val sumrows sumrange ---------- -------- -------- -------- --------- 2017-07-04 10248 440.00 440.00 440.00 2017-07-05 10249 1863.40 2303.40 2303.40 2017-07-08 10250 1552.60 3856.00 4510.06 2017-07-08 10251 654.06 4510.06 4510.06 2017-07-09 10252 3597.90 8107.96 8107.96 ...
Observeer het verschil in de resultaten voor de rijen waar dezelfde besteldatum meer dan eens voorkomt, zoals het geval is voor 8 juli 2017. Merk op hoe de ROWS-optie geen ties omvat en dus niet deterministisch is, en hoe de RANGE-optie wel ties omvat, en dus altijd deterministisch is.
Het is echter de vraag of je in de praktijk gevallen hebt waarin je bestelt op iets dat niet uniek is, en je echt ties moet opnemen om de berekening deterministisch te maken. Wat in de praktijk waarschijnlijk veel gebruikelijker is, is om een van de twee dingen te doen. Het ene is het breken van ties door iets toe te voegen aan de venster volgorde om het uniek te maken en op deze manier te resulteren in een deterministische berekening, zoals dit:
SELECT orderdate, orderid, val, SUM(val) OVER( ORDER BY orderdate, orderid ROWS UNBOUNDED PRECEDING ) AS runningsum FROM Sales.OrderValues ORDER BY orderdate;
Deze query genereert de volgende output:
orderdate orderid val runningsum ---------- -------- --------- ----------- 2017-07-04 10248 440.00 440.00 2017-07-05 10249 1863.40 2303.40 2017-07-08 10250 1552.60 3856.00 2017-07-08 10251 654.06 4510.06 2017-07-09 10252 3597.90 8107.96 ...
Een andere optie is om een voorlopige groepering toe te passen, in ons geval, op orderdatum, als volgt:
SELECT orderdate, SUM(val) AS daytotal, SUM(SUM(val)) OVER( ORDER BY orderdate ROWS UNBOUNDED PRECEDING ) AS runningsum FROM Sales.OrderValues GROUP BY orderdate ORDER BY orderdate;
Deze query genereert de volgende uitvoer waarbij elke besteldatum slechts eenmaal voorkomt:
orderdate daytotal runningsum ---------- --------- ----------- 2017-07-04 440.00 440.00 2017-07-05 1863.40 2303.40 2017-07-08 2206.66 4510.06 2017-07-09 3597.90 8107.96 ...
Houd in ieder geval de beste werkwijze hier in gedachten!
Het goede nieuws is dat als je draait op SQL Server 2016 of later en een columnstore index aanwezig is op de data (zelfs als het een nep gefilterde columnstore index is), of als je draait op SQL Server 2019 of later, of op Azure SQL Database, ongeacht de aanwezigheid van columnstore indexen, alle drie de bovengenoemde query’s geoptimaliseerd worden met de batch-mode Window Aggregate operator. Met deze operator worden veel van de inefficiënties van de rijmodus geëlimineerd. Deze operator gebruikt helemaal geen spool, dus er is geen kwestie van in-memory versus on-disk spool. Hij gebruikt een meer verfijnde verwerking waarbij hij meerdere parallelle passes kan toepassen over het venster met rijen in het geheugen voor zowel ROWS als RANGE.
Om het gebruik van de batchmodus-optimalisatie te demonstreren, moet u ervoor zorgen dat het compatibiliteitsniveau van uw database is ingesteld op 150 of hoger:
ALTER DATABASE TSQLV5 SET COMPATIBILITY_LEVEL = 150;
Voer Query 1 opnieuw uit:
SELECT actid, tranid, val, SUM(val) OVER( PARTITION BY actid ORDER BY tranid ROWS UNBOUNDED PRECEDING ) AS balance FROM dbo.Transactions;
Het plan voor deze query is te zien in figuur 3.
Figuur 3: Plan voor query 1, verwerking in batchmodus
Hier volgen de prestatiestatistieken die ik voor deze query heb gekregen:
Tabel ‘Transactions’ logische leest: 6208.
De looptijd is gedaald tot 1 seconde!
Run Query 2 met de expliciete RANGE optie opnieuw:
SELECT actid, tranid, val, SUM(val) OVER( PARTITION BY actid ORDER BY tranid RANGE UNBOUNDED PRECEDING ) AS balance FROM dbo.Transactions;
Het plan voor deze query is te zien in figuur 4.
Figuur 2: Plan voor query 2, verwerking in batchmodus
Hier zijn de prestatiestatistieken die ik voor deze query heb gekregen:
Tabel ‘Transactions’ logische leest: 6208.
De prestaties zijn dezelfde als voor Query 1.
Run Query 3 opnieuw, met de impliciete RANGE optie:
SELECT actid, tranid, val, SUM(val) OVER( PARTITION BY actid ORDER BY tranid ) AS balance FROM dbo.Transactions;
Het plan en de prestatiecijfers zijn uiteraard dezelfde als voor Query 2.
Wanneer u klaar bent, voert u de volgende code uit om de prestatiestatistieken uit te schakelen:
SET STATISTICS TIME, IO OFF;
Vergeet ook niet de optie Discard results after execution in SSMS uit te schakelen.
Impliciet frame met FIRST_VALUE en LAST_VALUE
De FIRST_VALUE en LAST_VALUE functies zijn offset window functies die een expressie retourneren van respectievelijk de eerste of de laatste rij in het window frame. Het lastige van deze functies is dat mensen die ze voor het eerst gebruiken, zich vaak niet realiseren dat ze een frame ondersteunen, maar eerder denken dat ze op de hele partitie van toepassing zijn.
Bedenk de volgende poging om bestelinformatie terug te geven, plus de waarden van de eerste en laatste bestelling van de klant:
SELECT custid, orderdate, orderid, val, FIRST_VALUE(val) OVER( PARTITION BY custid ORDER BY orderdate, orderid ) AS firstval, LAST_VALUE(val) OVER( PARTITION BY custid ORDER BY orderdate, orderid ) AS lastval FROM Sales.OrderValues ORDER BY custid, orderdate, orderid;
Als u ten onrechte denkt dat deze functies op de gehele venster-partitie van toepassing zijn, hetgeen de overtuiging is van veel mensen die deze functies voor het eerst gebruiken, verwacht u natuurlijk dat FIRST_VALUE de orderwaarde van de eerste bestelling van de klant teruggeeft, en dat LAST_VALUE de orderwaarde van de laatste bestelling van de klant teruggeeft. In de praktijk ondersteunen deze functies echter een kader. Ter herinnering, met functies die een frame ondersteunen, krijg je standaard RANGE UNBOUNDED PRECEDING als je de window order clause specificeert, maar niet de window frame unit en de bijbehorende extent. Met de FIRST_VALUE functie krijgt u het verwachte resultaat, maar als uw query wordt geoptimaliseerd met rij-mode operatoren, betaalt u de straf van het gebruik van de on-disk spool. Met de LAST_VALUE functie is het nog erger. Niet alleen betaal je de straf van de on-disk spool, maar in plaats van de waarde van de laatste rij in de partitie, krijg je de waarde van de huidige rij!
Hier is de uitvoer van de bovenstaande query:
custid orderdate orderid val firstval lastval ------- ---------- -------- ---------- ---------- ---------- 1 2018-08-25 10643 814.50 814.50 814.50 1 2018-10-03 10692 878.00 814.50 878.00 1 2018-10-13 10702 330.00 814.50 330.00 1 2019-01-15 10835 845.80 814.50 845.80 1 2019-03-16 10952 471.20 814.50 471.20 1 2019-04-09 11011 933.50 814.50 933.50 2 2017-09-18 10308 88.80 88.80 88.80 2 2018-08-08 10625 479.75 88.80 479.75 2 2018-11-28 10759 320.00 88.80 320.00 2 2019-03-04 10926 514.40 88.80 514.40 3 2017-11-27 10365 403.20 403.20 403.20 3 2018-04-15 10507 749.06 403.20 749.06 3 2018-05-13 10535 1940.85 403.20 1940.85 3 2018-06-19 10573 2082.00 403.20 2082.00 3 2018-09-22 10677 813.37 403.20 813.37 3 2018-09-25 10682 375.50 403.20 375.50 3 2019-01-28 10856 660.00 403.20 660.00 ...
Vaak als mensen zo’n uitvoer voor de eerste keer zien, denken ze dat SQL Server een bug heeft. Maar dat is natuurlijk niet zo; het is gewoon de standaard SQL-standaard. Er zit een fout in de query. Als je je realiseert dat er een frame bij betrokken is, wil je expliciet zijn over de frame specificatie, en het minimale frame gebruiken dat de rij vangt die je zoekt. Zorg er ook voor dat je de ROWS eenheid gebruikt. Dus, om de eerste rij in de partitie te krijgen, gebruik je de FIRST_VALUE functie met het frame ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Om de laatste rij in de partitie te krijgen, gebruikt u de LAST_VALUE functie met het frame ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING.
Hier is onze herziene query met de bug hersteld:
SELECT custid, orderdate, orderid, val, FIRST_VALUE(val) OVER( PARTITION BY custid ORDER BY orderdate, orderid ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS firstval, LAST_VALUE(val) OVER( PARTITION BY custid ORDER BY orderdate, orderid ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING ) AS lastval FROM Sales.OrderValues ORDER BY custid, orderdate, orderid;
Deze keer krijgt u het juiste resultaat:
custid orderdate orderid val firstval lastval ------- ---------- -------- ---------- ---------- ---------- 1 2018-08-25 10643 814.50 814.50 933.50 1 2018-10-03 10692 878.00 814.50 933.50 1 2018-10-13 10702 330.00 814.50 933.50 1 2019-01-15 10835 845.80 814.50 933.50 1 2019-03-16 10952 471.20 814.50 933.50 1 2019-04-09 11011 933.50 814.50 933.50 2 2017-09-18 10308 88.80 88.80 514.40 2 2018-08-08 10625 479.75 88.80 514.40 2 2018-11-28 10759 320.00 88.80 514.40 2 2019-03-04 10926 514.40 88.80 514.40 3 2017-11-27 10365 403.20 403.20 660.00 3 2018-04-15 10507 749.06 403.20 660.00 3 2018-05-13 10535 1940.85 403.20 660.00 3 2018-06-19 10573 2082.00 403.20 660.00 3 2018-09-22 10677 813.37 403.20 660.00 3 2018-09-25 10682 375.50 403.20 660.00 3 2019-01-28 10856 660.00 403.20 660.00 ...
U vraagt zich af wat de motivatie voor de standaard is geweest om zelfs maar een frame met deze functies te ondersteunen. Als je erover nadenkt, zul je ze meestal gebruiken om iets te krijgen van de eerste of de laatste rij in de partitie. Als je de waarde nodig hebt van, laten we zeggen, twee rijen voor de huidige, in plaats van FIRST_VALUE te gebruiken met een frame dat begint met 2 PRECEDING, is het dan niet veel eenvoudiger om LAG te gebruiken met een expliciete offset van 2, zoals dit:
SELECT custid, orderdate, orderid, val, LAG(val, 2) OVER( PARTITION BY custid ORDER BY orderdate, orderid ) AS prevtwoval FROM Sales.OrderValues ORDER BY custid, orderdate, orderid;
Deze query genereert de volgende uitvoer:
custid orderdate orderid val prevtwoval ------- ---------- -------- ---------- ----------- 1 2018-08-25 10643 814.50 NULL 1 2018-10-03 10692 878.00 NULL 1 2018-10-13 10702 330.00 814.50 1 2019-01-15 10835 845.80 878.00 1 2019-03-16 10952 471.20 330.00 1 2019-04-09 11011 933.50 845.80 2 2017-09-18 10308 88.80 NULL 2 2018-08-08 10625 479.75 NULL 2 2018-11-28 10759 320.00 88.80 2 2019-03-04 10926 514.40 479.75 3 2017-11-27 10365 403.20 NULL 3 2018-04-15 10507 749.06 NULL 3 2018-05-13 10535 1940.85 403.20 3 2018-06-19 10573 2082.00 749.06 3 2018-09-22 10677 813.37 1940.85 3 2018-09-25 10682 375.50 2082.00 3 2019-01-28 10856 660.00 813.37 ...
Het is duidelijk dat er een semantisch verschil is tussen het bovenstaande gebruik van de LAG functie en FIRST_VALUE met een frame dat begint met 2 PRECEDING. Met de eerste, als een rij niet bestaat in de gewenste offset, krijg je standaard een NULL. Met de laatste krijg je nog steeds de waarde van de eerste rij die aanwezig is, d.w.z. de waarde van de eerste rij in de partitie. Beschouw de volgende query:
SELECT custid, orderdate, orderid, val, FIRST_VALUE(val) OVER( PARTITION BY custid ORDER BY orderdate, orderid ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS prevtwoval FROM Sales.OrderValues ORDER BY custid, orderdate, orderid;
Deze query genereert de volgende uitvoer:
custid orderdate orderid val prevtwoval ------- ---------- -------- ---------- ----------- 1 2018-08-25 10643 814.50 814.50 1 2018-10-03 10692 878.00 814.50 1 2018-10-13 10702 330.00 814.50 1 2019-01-15 10835 845.80 878.00 1 2019-03-16 10952 471.20 330.00 1 2019-04-09 11011 933.50 845.80 2 2017-09-18 10308 88.80 88.80 2 2018-08-08 10625 479.75 88.80 2 2018-11-28 10759 320.00 88.80 2 2019-03-04 10926 514.40 479.75 3 2017-11-27 10365 403.20 403.20 3 2018-04-15 10507 749.06 403.20 3 2018-05-13 10535 1940.85 403.20 3 2018-06-19 10573 2082.00 749.06 3 2018-09-22 10677 813.37 1940.85 3 2018-09-25 10682 375.50 2082.00 3 2019-01-28 10856 660.00 813.37 ...
Opgemerkt moet worden dat er deze keer geen NULL’s in de uitvoer staan. Er is dus enige waarde in het ondersteunen van een frame met FIRST_VALUE en LAST_VALUE. Zorg er alleen voor dat je de best practice onthoudt om altijd expliciet te zijn over de frame specificatie met deze functies, en om de ROWS optie te gebruiken met het minimale frame dat de rij bevat die je zoekt.
Conclusie
Dit artikel richtte zich op bugs, valkuilen en best practices met betrekking tot window functies. Vergeet niet dat zowel de window aggregate functies als de FIRST_VALUE en LAST_VALUE window offset functies een frame ondersteunen, en dat als je de window order clause opgeeft, maar niet de window frame unit en de bijbehorende extent opgeeft, je standaard RANGE UNBOUNDED PRECEDING krijgt. Dit gaat ten koste van de prestaties als de query wordt geoptimaliseerd met rij-mode operatoren. Met de LAST_VALUE functie resulteert dit in het verkrijgen van de waarden van de huidige rij in plaats van de laatste rij in de partitie. Vergeet niet om expliciet te zijn over het kader en om in het algemeen de voorkeur te geven aan de ROWS optie boven RANGE. Het is geweldig om de prestatieverbeteringen te zien met de batch-mode Window Aggregate operator. Als het van toepassing is, is in ieder geval de performance valkuil geëlimineerd.