Emulieren Sie benutzerdefinierte Skalarfunktionen auf eine Weise, die Parallelität nicht verhindert

12

Ich versuche zu sehen, ob es eine Möglichkeit gibt, SQL Server dazu zu bringen, einen bestimmten Plan für die Abfrage zu verwenden.

1. Umwelt

Stellen Sie sich vor, Sie haben einige Daten, die von verschiedenen Prozessen gemeinsam genutzt werden. Nehmen wir also an, wir haben einige Versuchsergebnisse, die viel Platz beanspruchen. Dann wissen wir für jeden Prozess, welches Jahr / Monat des Versuchsergebnisses wir verwenden möchten.

if object_id('dbo.SharedData') is not null
    drop table SharedData

create table dbo.SharedData (
    experiment_year int,
    experiment_month int,
    rn int,
    calculated_number int,
    primary key (experiment_year, experiment_month, rn)
)
go

Jetzt haben wir für jeden Prozess Parameter in der Tabelle gespeichert

if object_id('dbo.Params') is not null
    drop table dbo.Params

create table dbo.Params (
    session_id int,
    experiment_year int,
    experiment_month int,
    primary key (session_id)
)
go

2. Testdaten

Fügen wir einige Testdaten hinzu:

insert into dbo.Params (session_id, experiment_year, experiment_month)
select 1, 2014, 3 union all
select 2, 2014, 4 
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 3, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 4, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

3. Ergebnisse abrufen

Nun ist es sehr einfach, Versuchsergebnisse zu erhalten, indem Sie @experiment_year/@experiment_month:

create or alter function dbo.f_GetSharedData(@experiment_year int, @experiment_month int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.SharedData as d
    where
        d.experiment_year = @experiment_year and
        d.experiment_month = @experiment_month
)
go

Der Plan ist schön und parallel:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number

Abfrage 0 Plan

Bildbeschreibung hier eingeben

4. Problem

Aber um die Nutzung der Daten etwas allgemeiner zu gestalten, möchte ich eine andere Funktion haben - dbo.f_GetSharedDataBySession(@session_id int). Ein einfacher Weg wäre also, skalare Funktionen zu erstellen, die übersetzen @session_id-> @experiment_year/@experiment_month:

create or alter function dbo.fn_GetExperimentYear(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_year
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

create or alter function dbo.fn_GetExperimentMonth(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_month
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

Und jetzt können wir unsere Funktion erstellen:

create or alter function dbo.f_GetSharedDataBySession1(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
        dbo.fn_GetExperimentYear(@session_id),
        dbo.fn_GetExperimentMonth(@session_id)
    ) as d
)
go

Abfrage 1 Plan

Bildbeschreibung hier eingeben

Der Plan ist derselbe, außer dass er natürlich nicht parallel ist, da skalare Funktionen, die den Datenzugriff ausführen, den gesamten Plan seriell machen .

Daher habe ich verschiedene Ansätze ausprobiert, z. B. Unterabfragen anstelle von Skalarfunktionen zu verwenden:

create or alter function dbo.f_GetSharedDataBySession2(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
       (select p.experiment_year from dbo.Params as p where p.session_id = @session_id),
       (select p.experiment_month from dbo.Params as p where p.session_id = @session_id)
    ) as d
)
go

Abfrage 2 Plan

Bildbeschreibung hier eingeben

Oder mit cross apply

create or alter function dbo.f_GetSharedDataBySession3(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.Params as p
        cross apply dbo.f_GetSharedData(
            p.experiment_year,
            p.experiment_month
        ) as d
    where
        p.session_id = @session_id
)
go

Abfrage 3 Plan

Bildbeschreibung hier eingeben

Aber ich kann keinen Weg finden, diese Abfrage so gut zu schreiben wie die, die Skalarfunktionen verwendet.

Ein paar Gedanken:

  1. Grundsätzlich möchte ich, dass ich SQL Server irgendwie anweisen kann, bestimmte Werte vorab zu berechnen und sie dann als Konstanten weiterzugeben.
  2. Was hilfreich sein könnte, wäre, wenn wir einen Zwischen-Materialisierungs- Hinweis hätten. Ich habe einige Varianten geprüft (TVF mit mehreren Anweisungen oder cte mit top), aber bisher ist kein Plan so gut wie der mit skalaren Funktionen
  3. Ich weiß, dass die Verbesserung von SQL Server 2017 - Froid: Optimierung von Imperativen Programmen in einer relationalen Datenbank kommen wird. Ich bin mir jedoch nicht sicher, ob es helfen wird. Es wäre allerdings schön gewesen, hier das Gegenteil zu beweisen.

Zusätzliche Information

Ich benutze eine Funktion (anstatt Daten direkt aus den Tabellen auszuwählen), weil es viel einfacher ist, sie in vielen verschiedenen Abfragen zu verwenden, die normalerweise @session_idals Parameter dienen.

Ich wurde gebeten, die tatsächlichen Ausführungszeiten zu vergleichen. In diesem speziellen Fall

  • Abfrage 0 läuft für ~ 500ms
  • Abfrage 1 läuft für ~ 1500ms
  • Abfrage 2 läuft für ~ 1500ms
  • Abfrage 3 läuft für ~ 2000ms.

Plan Nr. 2 hat einen Index-Scan anstelle eines Suchlaufs, der dann nach Prädikaten in verschachtelten Schleifen gefiltert wird. Plan Nr. 3 ist nicht so schlimm, leistet aber noch mehr Arbeit und arbeitet langsamer als Plan Nr. 0.

Nehmen wir an, dass dies dbo.Paramsnur selten geändert wird und in der Regel 1-200 Zeilen, also nicht mehr als 2000, erwartet werden. Es sind jetzt ungefähr 10 Spalten und ich erwarte nicht, dass die Spalte zu oft hinzugefügt wird.

Die Anzahl der Zeilen in Params ist nicht festgelegt, sodass für jede @session_idZeile eine Zeile vorhanden ist. Die Anzahl der Spalten ist nicht festgelegt. dbo.f_GetSharedData(@experiment_year int, @experiment_month int)Dies ist einer der Gründe, warum ich nicht von überall aus anrufen möchte. Daher kann ich dieser Abfrage intern eine neue Spalte hinzufügen. Ich würde mich über Meinungen / Vorschläge zu diesem Thema freuen, auch wenn es einige Einschränkungen gibt.

Roman Pekar
quelle
Der Abfrageplan mit Froid ähnelt dem von Abfrage2 oben. Ja, Sie gelangen also nicht zu der Lösung, die Sie in diesem Fall erzielen möchten.
Karthik

Antworten:

13

Sie können nicht wirklich sicher erreichen, was Sie heute in SQL Server wollen, dh in einer einzigen Anweisung und bei paralleler Ausführung, innerhalb der in der Frage festgelegten Einschränkungen (wie ich sie wahrnehme).

Meine einfache Antwort lautet also nein . Der Rest dieser Antwort ist meist eine Diskussion darüber, warum das so ist, falls es von Interesse ist.

Es ist möglich, einen parallelen Plan zu erstellen, wie in der Frage angegeben. Es gibt jedoch zwei Hauptsorten, von denen keine für Ihre Anforderungen geeignet ist:

  1. Eine korrelierte verschachtelte Schleife wird mit einem Round-Robin-Verteilungsstream auf der obersten Ebene verbunden. Da garantiert ist, dass eine einzelne Zeile Paramsfür einen bestimmten session_idWert stammt, wird die Innenseite auf einem einzelnen Thread ausgeführt, obwohl sie mit dem Parallelitätssymbol gekennzeichnet ist. Dies ist der Grund, warum der scheinbar parallele Plan 3 nicht so gut funktioniert. es ist in der Tat seriell.

  2. Die andere Alternative ist die unabhängige Parallelität auf der Innenseite der verschachtelten Schleifenverbindung. Unabhängig bedeutet hier, dass Threads auf der Innenseite gestartet werden und nicht nur die Threads, die auf der Außenseite der verschachtelten Schleifen ausgeführt werden. SQL Server unterstützt die Parallelität unabhängiger innerer geschachtelter Schleifen nur, wenn eine äußerliche Zeile garantiert ist und keine korrelierten Verknüpfungsparameter vorhanden sind ( Plan 2 ).

Wir haben also die Wahl zwischen einem seriellen Parallelplan (aufgrund eines Threads) mit den gewünschten korrelierten Werten. oder ein innerer paralleler Plan, der gescannt werden muss, weil er keine Parameter hat, mit denen gesucht werden kann. (Abgesehen davon: Es sollte eigentlich erlaubt sein, die innere Parallelität mit genau einem Satz korrelierter Parameter zu steuern, aber es wurde wahrscheinlich aus gutem Grund nie implementiert.)

Eine natürliche Frage lautet dann: Warum brauchen wir überhaupt korrelierte Parameter? Warum kann SQL Server nicht einfach direkt nach den Skalarwerten suchen, die z. B. von einer Unterabfrage bereitgestellt werden?

Nun, SQL Server kann nur mit einfachen Skalarreferenzen, z. B. einer Konstanten-, Variablen-, Spalten- oder Ausdrucksreferenz, nach Indizes suchen (daher kann sich auch ein Skalarfunktionsergebnis qualifizieren). Eine Unterabfrage (oder eine ähnliche Konstruktion) ist einfach zu komplex (und möglicherweise unsicher), um sie als Ganzes in die Speicher-Engine zu verschieben. Daher sind separate Abfrageplanoperatoren erforderlich. Dies erfordert wiederum Korrelation, was bedeutet, dass keine Parallelität der gewünschten Art vorliegt.

Alles in allem gibt es derzeit wirklich keine bessere Lösung als Methoden wie das Zuweisen der Nachschlagewerte zu Variablen und die anschließende Verwendung der Werte in den Funktionsparametern in einer separaten Anweisung.

Möglicherweise haben Sie jetzt bestimmte lokale Überlegungen, sodass sich das Zwischenspeichern der aktuellen Werte für Jahr und Monat SESSION_CONTEXTlohnt, z. B .:

SELECT FGSD.calculated_number, COUNT_BIG(*)
FROM dbo.f_GetSharedData
(
    CONVERT(integer, SESSION_CONTEXT(N'experiment_year')), 
    CONVERT(integer, SESSION_CONTEXT(N'experiment_month'))
) AS FGSD
GROUP BY FGSD.calculated_number;

Dies fällt jedoch in die Kategorie der Problemumgehung.

Wenn andererseits die Aggregationsleistung von größter Bedeutung ist, können Sie die Inline-Funktionen beibehalten und einen Columnstore-Index (primär oder sekundär) für die Tabelle erstellen. Möglicherweise bieten die Vorteile des Speichers im Spaltenspeicher, der Stapelverarbeitung und des aggregierten Pushdown größere Vorteile als eine parallele Suche im Zeilenmodus.

Achten Sie jedoch auf skalare T-SQL-Funktionen, insbesondere beim Speichern von Spaltenspeichern, da die Funktion leicht zeilenweise in einem separaten Filter im Zeilenmodus ausgewertet wird. Es ist im Allgemeinen recht schwierig zu gewährleisten, wie oft SQL Server Skalare auswertet, und es besser nicht zu versuchen.

Paul White 9
quelle
Danke, Paul, tolle Antwort! Ich dachte darüber nach, session_contextaber ich bin der Meinung , dass es eine etwas zu verrückte Idee für mich ist und ich bin mir nicht sicher, wie sie zu meiner aktuellen Architektur passen wird. Was jedoch nützlich wäre, könnte ein Hinweis sein, den ich verwenden könnte, um dem Optimierer mitzuteilen, dass das Ergebnis der Unterabfrage wie eine einfache Skalarreferenz behandelt werden soll.
Roman Pekar
8

Soweit ich weiß, ist die gewünschte Planform nicht nur mit T-SQL möglich. Anscheinend möchten Sie die ursprüngliche Planform (Abfrage 0-Plan), wobei die Unterabfragen Ihrer Funktionen als Filter direkt auf den Clustered-Index-Scan angewendet werden. Sie werden niemals einen solchen Abfrageplan erhalten, wenn Sie keine lokalen Variablen verwenden, um die Rückgabewerte der Skalarfunktionen zu speichern. Die Filterung wird stattdessen als verschachtelter Loop-Join implementiert. Es gibt drei verschiedene Möglichkeiten (aus der Sicht der Parallelität), wie der Loop-Join implementiert werden kann:

  1. Der gesamte Plan ist seriell. Dies ist für Sie nicht akzeptabel. Dies ist der Plan, den Sie für Abfrage 1 erhalten.
  2. Der Loop-Join wird seriell ausgeführt. Ich glaube, in diesem Fall kann die Innenseite parallel verlaufen, aber es ist nicht möglich, Prädikate an sie weiterzugeben. Die meiste Arbeit wird also parallel erledigt, aber Sie scannen die gesamte Tabelle und das Teilaggregat ist viel teurer als zuvor. Dies ist der Plan, den Sie für Abfrage 2 erhalten.
  3. Der Loop-Join wird parallel ausgeführt. Bei parallelen Joins mit verschachtelten Schleifen wird die Innenseite der Schleife seriell ausgeführt, es können jedoch bis zu DOP-Threads gleichzeitig auf der Innenseite ausgeführt werden. Ihre äußere Ergebnismenge enthält nur eine einzelne Zeile, sodass Ihr paralleler Plan effektiv seriell ist. Dies ist der Plan, den Sie für Abfrage 3 erhalten.

Dies sind die einzigen möglichen Planformen, die mir bekannt sind. Wenn Sie eine temporäre Tabelle verwenden, können Sie einige andere abrufen, aber keine davon löst Ihr grundlegendes Problem, wenn die Abfrageleistung genauso gut sein soll wie für Abfrage 0.

Sie können eine äquivalente Abfrageleistung erzielen, indem Sie die skalaren UDFs verwenden, um lokalen Variablen Rückgabewerte zuzuweisen, und diese lokalen Variablen in Ihrer Abfrage verwenden. Sie können diesen Code in eine gespeicherte Prozedur oder eine UDF mit mehreren Anweisungen einschließen, um Wartbarkeitsprobleme zu vermeiden. Beispielsweise:

DECLARE @experiment_year int = dbo.fn_GetExperimentYear(@session_id);
DECLARE @experiment_month int = dbo.fn_GetExperimentMonth(@session_id);

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(@experiment_year, @experiment_month)
group by
    calculated_number;

Die skalaren UDFs wurden außerhalb der Abfrage verschoben, die Sie für die Parallelisierung zulassen möchten. Der Abfrageplan, den ich erhalte, scheint der gewünschte zu sein:

Plan für parallele Abfragen

Beide Ansätze haben Nachteile, wenn Sie diese Ergebnismenge in anderen Abfragen verwenden müssen. Sie können einer gespeicherten Prozedur nicht direkt beitreten. Sie müssten die Ergebnisse in einer temporären Tabelle speichern, die ihre eigenen Probleme hat. Sie können einer MS-TVF beitreten, in SQL Server 2016 können jedoch Probleme mit der Schätzung der Kardinalität auftreten. SQL Server 2017 bietet Interleaved-Ausführung für MS-TVF, wodurch das Problem vollständig gelöst werden könnte.

Nur um ein paar Dinge zu klären: T-SQL Scalar UDFs verbieten immer Parallelität und Microsoft hat nicht gesagt, dass FROID in SQL Server 2017 verfügbar sein wird.

Joe Obbish
quelle
in Bezug auf Froid in SQL 2017 - nicht sicher, warum ich dachte, dass es da ist. Es wurde bestätigt, dass es in vNext ist - brentozar.com/archive/2018/01/…
Roman Pekar
4

Dies kann höchstwahrscheinlich mit SQLCLR erfolgen. Ein Vorteil von skalaren SQLCLR-UDFs besteht darin, dass sie keine Parallelität verhindern, wenn sie dies nicht tun alle Daten Zugriff tun (und manchmal müssen auch als „deterministisch “ gekennzeichnet werden). Wie können Sie also etwas nutzen, das keinen Datenzugriff erfordert, wenn der Vorgang selbst Datenzugriff erfordert?

Nun, denn es dbo.Paramswird erwartet , dass der Tisch:

  1. im Allgemeinen nie mehr als 2000 Zeilen enthalten,
  2. Struktur selten ändern,
  3. Nur (derzeit) müssen zwei INTSpalten haben

Es ist möglich, die drei Spalten - session_id, experiment_year int, experiment_month- in einer statischen Sammlung (z. B. einem Dictionary) zwischenzuspeichern, die außerhalb des Prozesses erstellt und von den Scalar-UDFs gelesen wird, die die Werte experiment_year intund experiment_montherhalten. Was ich mit "Out-of-Process" meine, ist: Sie können eine vollständig separate SQLCLR Scalar-UDF oder Stored Procedure verwenden, die den Datenzugriff und das Lesen aus der dbo.ParamsTabelle zum Auffüllen der statischen Auflistung ermöglicht. Diese UDF oder gespeicherte Prozedur wird ausgeführt, bevor die UDFs verwendet werden, die die Werte "Jahr" und "Monat" erhalten. Auf diese Weise führen die UDFs, die die Werte "Jahr" und "Monat" erhalten, keinen DB-Datenzugriff aus.

Die UDF oder gespeicherte Prozedur, die die Daten liest, kann zuerst überprüfen, ob die Auflistung 0 Einträge enthält. Wenn dies der Fall ist, füllen Sie sie aus, andernfalls überspringen Sie sie. Sie können sogar den Zeitpunkt verfolgen, zu dem die Auflistung erstellt wurde. Wenn der Zeitraum länger als X Minuten (oder so ähnlich) war, können Sie die Auflistung auch dann löschen und neu auffüllen, wenn Einträge in der Auflistung vorhanden sind. Das Überspringen der Grundgesamtheit ist jedoch hilfreich, da sie häufig ausgeführt werden muss, um sicherzustellen, dass die beiden Haupt-UDFs immer mit Daten gefüllt sind, von denen die Werte abgerufen werden.

Die Hauptsorge ist, wenn SQL Server die App Domain aus irgendeinem Grund entlädt (oder wenn sie durch etwas ausgelöst wird, das verwendet) DBCC FREESYSTEMCACHE('ALL'); ). Sie möchten nicht riskieren, dass die Sammlung zwischen der Ausführung der UDF oder gespeicherten Prozedur "Auffüllen" und den UDFs gelöscht wird, um die Werte "Jahr" und "Monat" zu erhalten. In diesem Fall können Sie ganz am Anfang dieser beiden UDFs prüfen, ob eine Ausnahme ausgelöst wird, wenn die Auflistung leer ist, da es besser ist, einen Fehler zu machen, als falsche Ergebnisse zu liefern.

Das oben erwähnte Anliegen geht natürlich davon aus, dass die Versammlung als "" gekennzeichnet werden soll SAFE. Wenn die Assembly als markiert werden kann, kann EXTERNAL_ACCESSein statischer Konstruktor die Methode ausführen, die die Daten liest und die Auflistung auffüllt, sodass Sie diese Methode immer nur manuell ausführen müssen, um die Zeilen zu aktualisieren. Sie werden jedoch immer aufgefüllt (weil der statische Klassenkonstruktor immer ausgeführt wird, wenn die Klasse geladen wird, was immer dann der Fall ist, wenn eine Methode in dieser Klasse nach einem Neustart ausgeführt wird oder die App-Domäne entladen wird). Dies erfordert die Verwendung einer regulären Verbindung und nicht der In-Process-Kontextverbindung (die statischen Konstruktoren nicht zur Verfügung steht, daher ist dies erforderlich EXTERNAL_ACCESS).

Bitte beachten Sie: Um die Assembly nicht als kennzeichnen zu müssen UNSAFE, müssen Sie alle statischen Klassenvariablen als kennzeichnen readonly. Dies bedeutet zumindest die Sammlung. Dies ist kein Problem, da schreibgeschützte Sammlungen Elemente hinzufügen oder daraus entfernen können. Sie können nur außerhalb des Konstruktors oder beim erstmaligen Laden nicht initialisiert werden. Das Verfolgen der Zeit, zu der die Auflistung geladen wurde, um sie nach X Minuten ablaufen zu lassen, ist schwieriger, da eine static readonly DateTimeKlassenvariable nicht außerhalb des Konstruktors oder des ursprünglichen Ladevorgangs geändert werden kann. Um diese Einschränkung zu umgehen, müssen Sie eine statische schreibgeschützte Auflistung verwenden, die ein einzelnes Element enthält, das der DateTimeWert ist, damit es bei einer Aktualisierung entfernt und erneut hinzugefügt werden kann.

Solomon Rutzky
quelle
Ich weiß nicht, warum jemand dies abgelehnt hat. Obwohl es nicht sehr allgemein ist, denke ich, dass es in meinem aktuellen Fall anwendbar sein könnte. Ich würde eine reine SQL-Lösung vorziehen, aber ich werde mir das auf jeden Fall genauer ansehen und versuchen, herauszufinden, ob es funktioniert
Roman Pekar
@RomanPekar Ich bin mir nicht sicher, aber es gibt viele Leute, die gegen SQLCLR sind. Und vielleicht ein paar, die gegen mich sind ;-). Wie auch immer, ich kann mir nicht vorstellen, warum diese Lösung nicht funktionieren würde. Ich verstehe die Vorliebe für reines T-SQL, aber ich weiß nicht, wie ich das erreichen soll, und wenn es keine konkurrierende Antwort gibt, tut es vielleicht auch niemand anderes. Ich weiß nicht, ob speicheroptimierte Tabellen und nativ kompilierte UDFs hier besser abschneiden würden. Außerdem habe ich gerade einen Absatz mit einigen Implementierungshinweisen hinzugefügt, um dies zu berücksichtigen.
Solomon Rutzky
1
Ich war noch nie davon überzeugt, dass die Verwendung readonly staticsin der SQLCLR sicher oder sinnvoll ist. Viel weniger bin ich davon überzeugt, das System dann zum Narren zu halten, indem ich es zu readonlyeinem Referenztyp mache , den Sie dann ändern . Gibt mir die absoluten Willen tbh.
Paul White 9
@PaulWhite Verstanden, und ich erinnere mich, dass dies vor Jahren in einem privaten Gespräch auftauchte. In Anbetracht der gemeinsamen Natur von App-Domänen (und damit staticObjekten) in SQL Server besteht ein Risiko für die Race-Bedingungen. Aus diesem Grund habe ich aus dem OP heraus festgestellt, dass diese Daten minimal und stabil sind, und diesen Ansatz als "selten änderungsbedürftig" eingestuft und bei Bedarf ein Mittel zur Aktualisierung angegeben. In diesem Anwendungsfall sehe ich nicht viel, wenn überhaupt ein Risiko. Ich habe vor Jahren einen Beitrag über die Fähigkeit gefunden, schreibgeschützte Sammlungen als beabsichtigt zu aktualisieren (in C # keine Diskussion zu: SQLCLR). Ich werde versuchen, es zu finden.
Solomon Rutzky
2
Keine Notwendigkeit, es gibt keine Möglichkeit, mich damit vertraut zu machen, abgesehen von der offiziellen SQL Server-Dokumentation, die sagt, dass es in Ordnung ist, was ich ziemlich sicher nicht existiere.
Paul White 9