SQL Server Indexed View – Wie löse ich Architekturfehler mit knappem Budget?

Wenn es einen Weg gibt, etwas besser zu machen: finde ihn. – Thomas Alva Edison

Im letzten Beitrag SQL Server Filtered Index – Wie korrigiere ich Qualitätsmerkmale einer Architektur? beschrieb ich einen Lösungsansatz der Abfrageoptimierung, welcher jedoch das Problem nicht löste. Eine Abfrage die 55 Sekunden bei einer Benutzerinteraktion benötigte und nach einer Optimierung nur doppelt so schnell, wird die Erwartungen der Benutzer nicht erfüllen. Eine Benutzerinteraktion, die länger als zwei bis drei Sekunden dauert, wird vielerorts als eine Ewigkeit wahrgenommen. Du fragst dich jetzt: «Ja, aber wie wurde es bisher gemacht?». Eine Optimierungsmöglichkeit, welche in der Legacy-Anwendung zum Einsatz kam, war der Einsatz eines Cache. Einige Daten wurden mit dem Start der Applikation im Hintergrund vorgeladen. Das ist ein möglicher Lösungsansatz. Caching bevorzuge ich jedoch nicht als erste aller Optionen, denn diese Funktionalität erhöht die Komplexität einer Anwendung bei den Charaktereigenschaften der Datenqualität. Bevor ich weitere Komplexität in Kauf nehme, versuche ich das Problem an der Wurzel zu lösen.

Häufig hört man: «Der Unternehmenswert liegt in den Daten». Hier wünsche ich mir, dass dieser Umstand auch in den entsprechenden Daten-Architekturen von Softwarelösungen mehr Würdigung findet.

Kommen wir nun nochmal zurück auf die langsame Abfrage mit folgendem Aufbau:

SELECT DISTINCT 
  RELMLO_ID
FROM TABELLE1 
WHERE ID 
  IN (SELECT RELCHA_ID 
    FROM TABELLE2 
    WHERE YEAR_NR = 2016 
    AND MONTH_NR = 10 
    AND DAY_NR = 3)

Die folgenden Statistiken dazu sind aus meiner Sicht nicht optimal für die Aufgabenstellung:

  • Table ‘Worktable’. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
  • Table ‘Workfile’. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
  • Table ‘TABELLE2’. Scan count 39460, logical reads 468658, physical reads 15787, read-ahead reads 59646, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
  • Table ‘TABELLE1’. Scan count 5, logical reads 1542, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
  • Table ‘Worktable’. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Der Ausführungsplan zur Vollständigkeit ist auch sehr kostenintensiv:

Die Abbildung zeigt den ineffizienten Abfrageplan der Abfrage in grafischer Form

Auf Konferenzen hörte ich oft: «Da ist dann halt ein Refactoring nötig». Die Realität sieht meist anders aus. Da existiert Zeit- und Kostendruck und die Refactorings werden aufgeschoben. Du kannst davon ausgehen, dass bei vielen Legacy-Anwedungen die Broken Window Theory die Ausgangslage besser beschreibt als die viel besungene Pfadfinder-Regel des sauberen Quellcode.

Nachdem wir für diese Anwendung ein besseres Verständnis bekommen hatten, machten wir uns ebenfalls eine Liste, aber dieser Umfang stand in Konflikt mit Zeit und Budget. Wir mussten in kleinen Schritten vorwärts gehen. Dem Team war es wichtig, mit den für uns zur Verfügung stehenden Mitteln schrittweise die Qualitätsmerkmale zu verbessern. Um in diesem Beispiel die Abfragegeschwindigkeit zu verbessern, legte ich zu Testzwecken eine Indexed View an.

Indexed Views sind einigen Einschränkungen unterworfen und so baute ich die Abfrage um:

SELECT
  cha. RELMLO_ID
  , tser. YEAR_NR
  , tser. MONTH_NR
  , tser. DAY_NR
  , COUNT_BIG(*) AS COUNT  
FROM dbo.TABELLE1 cha
INNER JOIN dbo.TABELLE2 tser ON cha.ID = tser.RELCHA_ID
GROUP BY
  cha.RELMLO_ID
  , tser.YEAR_NR
  , tser.MONTH_NR
  , tser.DAY_NR

Der DISTINCT-Parameter ist damit entfernt und die Eindeutigkeit wird nun mittels einem Group By Operator erzwungen. Diese Abfrage kann in einer Indexed View verwendet werden. Dazu muss eine View mit der Option Schemabinding und einem Clustered Index angelegt werden.

CREATE VIEW dbo.Test WITH SCHEMABINDING
AS
SELECT
  cha. RELMLO_ID
  , tser. YEAR_NR
  , tser. MONTH_NR
  , tser. DAY_NR
  , COUNT_BIG(*) AS COUNT  
FROM dbo.TABELLE1 cha
INNER JOIN dbo.TABELLE2 tser ON cha.ID = tser.RELCHA_ID
GROUP BY
  cha.RELMLO_ID
  , tser.YEAR_NR
  , tser.MONTH_NR
  , tser.DAY_NR
GO

-- Index
CREATE UNIQUE CLUSTERED INDEX IDX_Test   
    ON dbo.Test (RELMLO_ID, YEAR_NR, MONTH_NR, DAY_NR)
GO

Anschliessend probierte ich die Abfrage aus:

SELECT 
      RELMLO_ID
FROM dbo.Test 
WHERE YEAR_NR = 2016 
  AND MONTH_NR = 10 
  AND DAY_NR = 3

Ein Blick auf den Ausführungsplan zeigte folgendes:

Die Abbildung zeigt den Ausführungsplan, welcher nicht den Clustered Index verwendet

Der Clustered Index der View wurde gar nicht verwendet. Von der Enterprise Edition von SQL Server kannte ich dieses Verhalten sonst nicht. Ich lass in der Dokumentation und fand den Hint NoExpand. Ich führte die Abfrage nochmals mit diesem Hint aus:

SELECT 
      RELMLO_ID
FROM dbo.Test WITH(NOEXPAND)
WHERE YEAR_NR = 2016 
  AND MONTH_NR = 10 
  AND DAY_NR = 3

Und siehe da, der Ausführungsplan verwendet den Index der View:

Die Abbildung zeigt den Ausführungsplan, welcher nun den Clustered Index verwendet

Die Statistiken zeigten nun folgendes Bild:

  • Table ‘Test’. Scan count 5, logical reads 15583, physical reads 0, read-ahead reads 1, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Die Reads sind nun massiv reduziert und auch die Abfragedauer lag nun im Schnitt bei 300 ms. Die Worktable und das Workfile werden für die Abfrage auch nicht mehr benötigt. Der Ausführungsplan zeigt jedoch einen Clustered Index Scan. Ein Index mit dem eine Index Seek – Operation durchgeführt werden kann, wäre in diesem Zusammenhang besser.

Also legte ich folgenden Covered Index an und führte die Abfrage erneut aus:

CREATE NONCLUSTERED INDEX [CIDX_Test_Date]
ON [dbo].[Test] ([YEAR_NR],[MONTH_NR],[DAY_NR])
INCLUDE ([RELMLO_ID])

Der Ausführungsplan verwendete nun den Covered Index und ist dadurch in der Lage eine Index Seek – Operation auszuführen.

Die Abbildung zeigt den Ausführungsplan mit dem zuvor angelegten Index

Die Statistiken mit diesem Index zeigten folgendes:

  • Table ‘Test’. Scan count 1, logical reads 7, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Die Reads konnten mit diesem Index nochmal massiv reduziert werden. Die Abfragedauer lag nun bei 27 ms. Nun bewegen wir uns in einem Geschwindigkeitsbereich, der bedenkenlos mit einem Benutzerinterface verwendet werden kann. Im Bezug zur Ausgangslage ist die Abfrage mehr als 2’000-mal schneller.

Dies ist jedoch nur eine Zwischenlösung um die Schmerzen zu lindern, mittelfristig ist eine Schrittweise Anpassung der Datenarchitektur sinnvoller, da zu viele Indizes eine Verschlechterung der Schreibgeschwindigkeit bewirken können.