Der effizienteste Weg, um eine nach oberster Tabelle gruppierte Unterabfrage COUNT abzurufen?

7

Gegeben das folgende Schema

CREATE TABLE categories
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    name NVARCHAR(50)
);

CREATE TABLE [group]
(
    id UNIQUEIDENTIFIER PRIMARY KEY
);

CREATE TABLE logger
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    group_id UNIQUEIDENTIFIER,
    uuid CHAR(17)
);

CREATE TABLE data
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    logger_uuid CHAR(17),
    category_name NVARCHAR(50),
    recorded_on DATETIME
);

Und die folgenden Regeln

  1. Jeder dataDatensatz verweist auf a loggerund acategory
  2. Jeder loggerwird immer eine habengroup
  3. Jeder groupkann mehrere loggers haben
  4. Ich möchte nur die zuletzt aufgezeichneten Daten zählen

category_nameist nicht eindeutig pro Zeile, es ist nur eine Möglichkeit, einen bestimmten Datensatz einer Kategorie zuzuordnen, es idist wirklich nur ein Ersatzschlüssel.

Was wäre der optimale Weg, um eine Ergebnismenge wie zu erreichen

category_id | logger_group_count
--------------------------------
12345          4
67890          2
.....          ...

dh zähle die Nr. von Gruppen für jede Kategorie, in der ein Logger Daten aufgezeichnet hat?

Als ersten Stich kam ich auf:

SELECT g.id, COUNT(DISTINCT(a.id)) AS logger_group_count 
FROM categories g
  LEFT OUTER JOIN data d ON d.category_name = g.name
  INNER JOIN logger s ON s.uuid = d.logger_uuid
  INNER JOIN group a ON a.id = s.group_id
GROUP BY g.id

Ist aber extrem langsam (~ 45s), datahat 400k + Datensätze - hier ist der Abfrageplan und hier ist eine Geige zum Spielen.

Ich möchte sicherstellen, dass ich das Beste aus der Abfrage heraushole, bevor ich mich mit anderen Dingen wie der Hardwareauslastung usw. befasse. Die Azure SQL-Kosten können erheblich steigen (obwohl Sie möglicherweise nur ein wenig mehr Saft von Ihrer aktuellen Stufe benötigen). .

James
quelle
2
Kommentare sind nicht für eine ausführliche Diskussion gedacht. Dieses Gespräch wurde in den Chat verschoben . Verwenden Sie diesen Chatraum zur weiteren Fehlerbehebung und halten Sie den Fragentext auf dem neuesten Stand.
Paul White 9

Antworten:

8

Sie verwenden eine neuere Version von SQL Server, sodass der aktuelle Plan viele Informationen enthält. Siehe das Warnschild am SELECTBediener? Dies bedeutet, dass SQL Server eine Warnung generiert hat, die die Abfrageleistung beeinträchtigen kann. Sie sollten sich immer diese ansehen:

<Warnings>
<PlanAffectingConvert ConvertIssue="Seek Plan" Expression="[s].[logger_uuid]=CONVERT_IMPLICIT(nchar(17),[d].[uuid],0)" />
<PlanAffectingConvert ConvertIssue="Seek Plan" Expression="CONVERT_IMPLICIT(nvarchar(100),[d].[name],0)=[g].[name]" />
</Warnings>

Es gibt zwei Datentypkonvertierungen, die durch Ihr Schema verursacht werden. Aufgrund der Warnungen vermute ich, dass der Name tatsächlich ein NVARCHAR(100)und ein logger_uuidist NCHAR(17). Das in der Frage angegebene Tabellenschema ist möglicherweise nicht korrekt. Sie sollten die Hauptursache für diese Konvertierungen verstehen und beheben. Einige Arten von Datentypkonvertierungen verhindern Indexsuchen, führen zu Problemen bei der Kardinalitätsschätzung und verursachen andere Probleme.

Eine weitere wichtige Sache, die überprüft werden muss, sind Wartestatistiken. Sie können diese auch in den Details des SELECTBedieners sehen. Hier ist das XML für Ihre Wartestatistiken und die von der Abfrage aufgewendete Zeit:

<WaitStats>
<Wait WaitType="RESOURCE_GOVERNOR_IDLE" WaitTimeMs="49515" WaitCount="3773" />
<Wait WaitType="SOS_SCHEDULER_YIELD" WaitTimeMs="57164" WaitCount="2466" />
</WaitStats>
<QueryTimeStats ElapsedTime="67135" CpuTime="10007" />

Ich bin kein Cloud-Typ, aber es sieht so aus, als ob Ihre Abfrage eine CPU nicht vollständig aktivieren kann . Dies hängt wahrscheinlich mit Ihrer aktuellen Azure-Ebene zusammen. Die Abfrage benötigte bei der Ausführung nur etwa 10 Sekunden CPU, dauerte jedoch 67 Sekunden. Ich glaube, dass 50 Sekunden dieser Zeit damit verbracht wurden, gedrosselt zu werden, und 7 Sekunden dieser Zeit wurden Ihnen gegeben, aber für andere Abfragen verwendet, die gleichzeitig ausgeführt wurden. Die schlechte Nachricht ist, dass die Abfrage langsamer ist, als es aufgrund Ihrer Stufe sein könnte. Die gute Nachricht ist, dass eine Reduzierung der CPU zu einer 5-fachen Reduzierung der Laufzeit führen kann. Mit anderen Worten, wenn Sie die Abfrage dazu bringen können, 1 Sekunde CPU zu verwenden, wird möglicherweise eine Laufzeit von etwa 5 Sekunden angezeigt.

Als Nächstes können Sie die Eigenschaft Aktuelle Zeitstatistik in Ihren Bedienerdetails überprüfen, um festzustellen, wo die CPU-Zeit verbracht wurde. Ihr Plan verwendet den Zeilenmodus, sodass die CPU-Zeit für einen Operator die Summe der Zeit ist, die dieser Operator sowie seine untergeordneten Elemente verbringen. Dies ist ein relativ einfacher Plan, sodass es nicht lange dauert, festzustellen, dass der Clustered-Index-Scan logger_data6527 ms CPU-Zeit benötigt. Der Loop-Join, der ihn aufruft, benötigt 10006 ms CPU-Zeit, sodass die gesamte CPU Ihrer Abfrage in diesem Schritt verbraucht wird. Ein weiterer Hinweis darauf, dass bei diesem Schritt etwas schief geht, finden Sie in der Dicke der relativen Pfeile:

dicke Pfeile

Von diesem Operator werden viele Zeilen zurückgegeben, daher lohnt es sich, sich die Details anzusehen. Wenn Sie sich die tatsächliche Anzahl der Zeilen für den Clustered-Index-Scan ansehen, sehen Sie, dass 14088885 Zeilen zurückgegeben und 14100798 Zeilen gelesen wurden. Die Kardinalität der Tabelle beträgt jedoch nur 484803 Zeilen. Intuitiv scheint das ziemlich ineffizient zu sein, oder? Der Clustered-Index-Scan gibt weit mehr als die Anzahl der Zeilen in der Tabelle zurück. Ein anderer Plan mit einem anderen Join-Typ oder einer anderen Zugriffsmethode in der Tabelle ist wahrscheinlich effizienter.

Warum hat SQL Server so viele Zeilen gelesen und zurückgegeben? Der Clustered-Index befindet sich auf der Innenseite einer verschachtelten Schleife. Es gibt 38 Zeilen, die von der Außenseite der Schleife zurückgegeben werden (der Scan in der loggerTabelle), sodass der Scan bei logger_data38 Mal ausgeführt wird. 484803 * 38 = 18422514, was ziemlich nahe an der Anzahl der gelesenen Zeilen liegt. Warum hat SQL Server einen solchen Plan gewählt, der sich so ineffizient anfühlt? Es wird sogar geschätzt, dass 57 Scans der Tabelle durchgeführt werden. Der Plan, den Sie erhalten haben, war also wahrscheinlich effizienter als vermutet.

Sie haben sich vielleicht gefragt, warum TOPIhr Plan einen Operator enthält. SQL Server eingeführt , um eine Reihe Ziel , wenn eine Abfrage - Plan für Ihre Abfrage zu erstellen. Dies ist möglicherweise detaillierter als gewünscht. In der Kurzversion muss SQL Server jedoch nicht immer alle Zeilen eines Clustered-Index-Scans zurückgeben. Manchmal kann es vorzeitig beendet werden, wenn nur eine feste Anzahl von Zeilen benötigt wird und diese Zeilen gefunden werden, bevor das Ende des Scans erreicht ist. Ein Scan ist nicht so teuer, wenn er vorzeitig beendet werden kann, sodass die Bedienerkosten durch eine Formel abgezinst werden, wenn ein Zeilenziel vorliegt. Mit anderen Worten, SQL Server erwartet, den Clustered-Index 57 Mal zu scannen, geht jedoch davon aus, dass die benötigte einzelne Zeile sehr schnell gefunden wird. Aufgrund des Vorhandenseins von benötigt es nur eine einzige Zeile von jedem ScanTOP Operator.

Sie können Ihre Abfrage beschleunigen, indem Sie das Abfrageoptimierungsprogramm dazu ermutigen, einen Plan auszuwählen, der die logger_dataTabelle nicht 38 Mal scannt . Dies kann so einfach sein wie das Eliminieren der Datentypkonvertierungen. Dadurch könnte SQL Server eine Indexsuche anstelle eines Scans durchführen. Wenn nicht, korrigieren Sie die Conversions und erstellen Sie einen Deckungsindex für logger_data:

CREATE INDEX IX ON logger_data (category_name, logger_uuid);

Das Abfrageoptimierungsprogramm wählt einen Plan basierend auf den Kosten aus. Durch Hinzufügen dieses Index ist es unwahrscheinlich, dass der langsame Plan erstellt wird, der viele Scans für logger_data ausführt, da der Zugriff auf die Tabelle über eine Indexsuche anstelle eines Clustered-Index-Scans billiger ist.

Wenn Sie den Index nicht hinzufügen können, können Sie einen Abfragehinweis hinzufügen, um die Einführung von Zeilenzielen zu deaktivieren : USE HINT('DISABLE_OPTIMIZER_ROWGOAL')). Sie sollten dies nur tun, wenn Sie sich mit dem Konzept der Reihenziele wohl fühlen und diese verstehen. Das Hinzufügen dieses Hinweises sollte zu einem anderen Plan führen, aber ich kann nicht sagen, wie effizient er sein wird.

Joe Obbish
quelle
4

Stellen Sie zunächst sicher, dass in jeder Tabelle alle Kandidatenschlüssel deklariert und Fremdschlüssel erzwungen sind:

CREATE TABLE dbo.categories
(
    id uniqueidentifier NOT NULL
        CONSTRAINT [UQ dbo.categories id]
        UNIQUE NONCLUSTERED,
    [name] nvarchar(50) NOT NULL 
        CONSTRAINT [PK dbo.categories name]
        PRIMARY KEY CLUSTERED
);

-- Choose a better name for this table
CREATE TABLE dbo.[group]
(
    id uniqueidentifier NOT NULL
        CONSTRAINT [PK dbo.group id]
        PRIMARY KEY CLUSTERED
);

CREATE TABLE dbo.logger
(
    id uniqueidentifier 
        CONSTRAINT [UQ dbo.logger id]
        UNIQUE NONCLUSTERED,
    group_id uniqueidentifier NOT NULL
        CONSTRAINT [FK dbo.group id]
        FOREIGN KEY (group_id)
        REFERENCES [dbo].[group] (id),
    uuid char(17) NOT NULL
        CONSTRAINT [PK dbo.logger uuid]
        PRIMARY KEY CLUSTERED
);

CREATE TABLE dbo.logger_data
(
    id uniqueidentifier 
        CONSTRAINT [PK dbo.logger_data id]
        PRIMARY KEY NONCLUSTERED,
    logger_uuid char(17) NOT NULL
        CONSTRAINT [FK dbo.logger_data uuid]
        FOREIGN KEY (logger_uuid)
        REFERENCES dbo.logger (uuid),
    category_name nvarchar(50) NOT NULL
        CONSTRAINT [dbo.logger_data name]
        FOREIGN KEY (category_name)
        REFERENCES dbo.categories ([name]),
    recorded_on datetime NOT NULL,

    INDEX [dbo.logger_data logger_uuid recorded_on] 
        CLUSTERED (logger_uuid, recorded_on)
);

Ich habe auch einen nicht eindeutigen Clustered-Index zu logger_dataon hinzugefügt logger_uuid, recorded_on.

Beachten Sie dann, dass die größte Aufgabe in Ihrem Ausführungsplan das Scannen der 484.836 Zeilen in der Datentabelle ist. Da Sie nur an der neuesten Lesung für einen bestimmten Logger interessiert sind und derzeit nur 48 Logger vorhanden sind, ist es effizienter, diesen vollständigen Scan durch 48 Singleton-Suchvorgänge zu ersetzen:

SELECT 
    category_id = C.id, 
    logger_group_count = COUNT_BIG(DISTINCT L.group_id)
FROM dbo.logger AS L
CROSS APPLY 
(
    -- Latest reading per logger
    SELECT TOP (1) 
        LD.recorded_on,
        LD.category_name
    FROM  dbo.logger_data AS LD
    WHERE LD.logger_uuid = L.uuid
    ORDER BY 
        LD.recorded_on DESC
) AS LDT1
JOIN dbo.categories AS C
    ON C.[name] = LDT1.category_name
GROUP BY
    C.id
ORDER BY
    C.id;

Der Ausführungsplan lautet:

Geschätzter Plan

dbfiddle

Sie sollten Ihre Instanz auch von 2017 RTM auf das neueste kumulative Update patchen.

Paul White 9
quelle
0

Warum brauchen Sie den Join on Group?

Warum ist Kategorien g?

SELECT c.id, COUNT(DISTINCT(s.group_id)) AS logger_group_count 
FROM categories c
JOIN data d 
  ON d.category_name = c.name
JOIN logger s 
  ON s.uuid = d.logger_uuid
GROUP BY c.id  

Ich hoffe, dass Sie im wirklichen Leben die Fremdschlüssel deklarieren.

Sie sollten einen Index für jede dieser Verknüpfungsspalten haben.

Paparazzo
quelle
0

Problembereiche sind:

  1. Improper data type: Wenn Datentyp ist INTweniger Datenseite das bedeutet und nicht index fragmentation, wenn es sich um NewSequentialIDdas bedeutet , more data pageund no index fragmentationmit UNIQUEIDENTIFIERIhnen beide problem.So INT Daten erhalten Typ ist die ideale Wahl.
  2. Data type and length of both column should be same in relationship column: a.category_name = g.NAME Logger_data Clustered-Index-Scan im Plan schlägt beispielsweise vor, dass beide Spaltenlängen 50 oder 100 betragen sollten, damit Optimizer keine Zeit damit verbringen muss. Convert_Implicit Noch besser ist, dass die Beziehung mit dem Datentyp int wie CategoryID int` definiert wird.
  3. Wenn diese Abfrage sehr wichtig ist und häufig verwendet wird, können Sie sich vorstellen Denormalization, in Ihrem Beispiel kann ich nicht sagen, wie?

Versuchen Sie unten Abfrage,

    SELECT g.id
    ,sum(CASE 
            WHEN rn = 1
                THEN 1
            ELSE 0
            END)
FROM categories g
INNER JOIN (
    SELECT d.category_name
        ,ROW_NUMBER() OVER (
            PARTITION BY d.category_name
            ,s.group_id ORDER BY s.group_id
            ) rn
    FROM data d
    INNER JOIN logger s ON s.uuid = d.logger_uuid
        --INNER JOIN [group] a ON a.id = s.group_id
    ) a ON a.category_name = g.NAME
GROUP BY g.id

Ich mag @PaparazziIdee, also habe ich sie aufgenommen.

Ich denke, Plan ist besser als dein. Mit der obigen Korrektur und Indexabstimmung wird es noch besser abschneiden.

Sie müssen hier korrigieren,

ROW_NUMBER()over(partition by d.category_name,a.id order by s.group_id )rn 

order by s.group_idSollte es sein , order by DateOrIDcolumn descdie neuesten record.with Ihre Probe gibt ich nicht in der Lage bin , wie man aus aktueller Platte zu finden.

Beachten Sie auch, dass partition by d.category_namedies hätte sein sollen partition by d.CatgoryID.

KumarHarsh
quelle
0

Dank einer großartigen Antwort von @JoeObbish konnte ich den Abfrageplan besser verstehen und herausfinden, wo es Probleme gab und welche Indizes ich verwenden konnte, um ihn zu verbessern. Dazwischen haben sich die Torpfosten ein wenig geändert, da ich vergessen habe zu erwähnen, dass dies nur für die neuesten Messwerte von jedem Logger gelten muss, z. B. wenn logger_aDaten unter aufgezeichnet wurden category_x @ 11:50und category_y @ 11:51ich dies nur als zählen möchte category_y.

Hier ist das resultierende SQL

;WITH logger_data AS (
  SELECT 
    category_name,
    logger_uuid,
    recorded_on,
    RN = ROW_NUMBER() OVER (PARTITION BY logger_uuid ORDER BY recorded_on DESC)
  FROM data
)
SELECT c.id, count(DISTINCT l.group_id) FROM categories c
INNER JOIN logger_data d on d.category_name = c.name
INNER JOIN logger l ON l.uuid = d.logger_uuid
WHERE RN = 1
GROUP BY c.id

Dies ist jedoch immer noch eine teure Abfrage, da die folgenden Indizes angewendet werden

CREATE CLUSTERED INDEX ix_latest ON "dbo"."data"
(
    logger_uuid,
    recorded_on DESC
)
GO
CREATE CLUSTERED INDEX ix_groups ON "dbo"."logger"
(
    group_id
)

Geht von ~ 25s bis ~ 3s und für eine Tabelle mit ~ 500k Zeilen. Ich bin ziemlich zufrieden damit und denke, dass es wahrscheinlich mehr Raum für Verbesserungen gibt, aber so wie es aussieht, ist dies gut genug.

Hier ist der endgültige Plan , weitere Vorschläge / Verbesserungen sind willkommen.

James
quelle