Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server

LINQ wurde als neue leistungsstarke Sprache zur Datenmanipulation in .NET eingeführt. LINQ to SQL als Teil davon ermöglicht eine recht bequeme Kommunikation mit einer Datenbank, beispielsweise über Entity Framework. Allerdings vergessen Entwickler häufig, darauf zu achten, welche SQL-Abfrage der queryable provider in Ihrem Fall — Entity Framework — generieren wird.

Betrachten wir zwei grundlegende Punkte anhand eines Beispiels.
Dazu erstellen wir in SQL Server die Datenbank Test und erzeugen darin mit der folgenden Abfrage zwei Tabellen:

Tabellen erstellen

USE [TEST]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Ref](
	[ID] [int] NOT NULL,
	[ID2] [int] NOT NULL,
	[Name] [nvarchar](255) NOT NULL,
	[InsertUTCDate] [datetime] NOT NULL,
 CONSTRAINT [PK_Ref] PRIMARY KEY CLUSTERED 
(
	[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO

ALTER TABLE [dbo].[Ref] ADD  CONSTRAINT [DF_Ref_InsertUTCDate]  DEFAULT (getutcdate()) FOR [InsertUTCDate]
GO

USE [TEST]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Customer](
	[ID] [int] NOT NULL,
	[Name] [nvarchar](255) NOT NULL,
	[Ref_ID] [int] NOT NULL,
	[InsertUTCDate] [datetime] NOT NULL,
	[Ref_ID2] [int] NOT NULL,
 CONSTRAINT [PK_Customer] PRIMARY KEY CLUSTERED 
(
	[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO

ALTER TABLE [dbo].[Customer] ADD  CONSTRAINT [DF_Customer_Ref_ID]  DEFAULT ((0)) FOR [Ref_ID]
GO

ALTER TABLE [dbo].[Customer] ADD  CONSTRAINT [DF_Customer_InsertUTCDate]  DEFAULT (getutcdate()) FOR [InsertUTCDate]
GO

Nun füllen wir die Tabelle Ref mit dem folgenden Skript:

Befüllung der Tabelle Ref

USE [TEST]
GO

DECLARE @ind INT=1;

WHILE(@ind<1200000)
BEGIN
	INSERT INTO [dbo].[Ref]
           ([ID]
           ,[ID2]
           ,[Name])
    SELECT
           @ind
           ,@ind
           ,CAST(@ind AS NVARCHAR(255));

	SET @ind=@ind+1;
END 
GO

Analog fügen wir die Tabelle Customer mit dem folgenden Skript hinzu:

Befüllung der Tabelle Customer

VERWENDEN [TEST]
GO

ERKLÄREN @ind INT=1;
ERKLÄREN @ind_ref INT=1;

WÄHREND(@ind<=12000000)
BEGINN
	WENN(@ind%3=0) SET @ind_ref=1;
	SONST WENN (@ind%5=0) SET @ind_ref=2;
	SONST WENN (@ind%7=0) SET @ind_ref=3;
	SONST WENN (@ind=0) SET @ind_ref=4;
	SONST WENN (@ind=0) SET @ind_ref=5;
	SONST WENN (@ind=0) SET @ind_ref=6;
	SONST WENN (@ind=0) SET @ind_ref=7;
	SONST WENN (@ind=0) SET @ind_ref=8;
	SONST WENN (@ind=0) SET @ind_ref=9;
	SONST WENN (@ind=0) SET @ind_ref=10;
	SONST WENN (@ind=0) SET @ind_ref=11;
	SONST SET @ind_ref=@ind90000;
	
	EINFÜGEN IN [dbo].[Customer]
	 ([ID]
	 ,[Name]
	 ,[Ref_ID]
	 ,[Ref_ID2])
	 AUSWÄHLEN
	 @ind,
	 CAST(@ind AS NVARCHAR(255)),
	 @ind_ref,
	 @ind_ref;


	SET @ind=@ind+1;
END
GO

Somit haben wir zwei Tabellen erhalten, von denen eine über 1 Million Datensätze und die andere mehr als 10 Millionen Datensätze enthält.

Jetzt müssen wir in Visual Studio ein Testprojekt Visual C# Console App (.NET Framework) erstellen:

Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server

Um mit der Datenbank zu interagieren, müssen wir eine Bibliothek für Entity Framework hinzufügen.
Um dies hinzuzufügen, klicken wir mit der rechten Maustaste auf das Projekt und wählen im Kontextmenü "Manage NuGet Packages":

Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server

Dann geben wir im erscheinenden Fenster zur Verwaltung von NuGet-Paketen im Suchfeld den Begriff "Entity Framework" ein, wählen das Paket Entity Framework aus und installieren es:

Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server

Anschließend müssen wir in der Datei App.config nach dem schließenden Element configSections den folgenden Block hinzufügen:


In der connectionString muss die Verbindungszeichenfolge eingetragen werden.

Jetzt erstellen wir in separaten Dateien 3 Schnittstellen:

  1. Implementierung der Schnittstelle IBaseEntityID
    namespace TestLINQ
    {
        public interface IBaseEntityID
        {
            int ID { get; set; }
        }
    }
    

  2. Implementierung der Schnittstelle IBaseEntityName
    namespace TestLINQ
    {
        public interface IBaseEntityName
        {
            string Name { get; set; }
        }
    }
    

  3. Implementierung der Schnittstelle IBaseNameInsertUTCDate
    namespace TestLINQ
    {
        public interface IBaseNameInsertUTCDate
        {
            DateTime InsertUTCDate { get; set; }
        }
    }
    

Und in einer separaten Datei erstellen wir die Basisklasse BaseEntity für unsere beiden Entitäten, die gemeinsame Felder enthalten wird:

Implementierung der Basisklasse BaseEntity

namespace TestLINQ
{
    public class BaseEntity : IBaseEntityID, IBaseEntityName, IBaseNameInsertUTCDate
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public DateTime InsertUTCDate { get; set; }
    }
}

Dann erstellen wir in separaten Dateien unsere beiden Entitäten:

  1. Implementierung der Klasse Ref
    using System.ComponentModel.DataAnnotations.Schema;
    
    namespace TestLINQ
    {
        [Table("Ref")]
        public class Ref : BaseEntity
        {
            public int ID2 { get; set; }
        }
    }
    

  2. Implementierung der Klasse Customer
    using System.ComponentModel.DataAnnotations.Schema;
    
    namespace TestLINQ
    {
        [Table("Customer")]
        public class Customer: BaseEntity
        {
            public int Ref_ID { get; set; }
            public int Ref_ID2 { get; set; }
        }
    }
    

Jetzt erstellen wir in einer separaten Datei den Kontext UserContext:

Implementierung der Klasse UserContext

using System.Data.Entity;

namespace TestLINQ
{
    public class UserContext : DbContext
    {
        public UserContext()
            : base("DbConnection")
        {
            Database.SetInitializer(null);
        }

        public DbSet Customer { get; set; }
        public DbSet Ref { get; set; }
    }
}

Wir haben eine einsatzbereite Lösung für Tests zur Optimierung mit LINQ to SQL über EF für MS SQL Server erhalten:

Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server

Jetzt fügen wir in die Datei Program.cs den folgenden Code ein:

Datei Program.cs

using System;
using System.Collections.Generic;
using System.Linq;

namespace TestLINQ
{
    class Program
    {
        static void Main(string[] args)
        {
            using (UserContext db = new UserContext())
            {
                var dblog = new List();
                db.Database.Log = dblog.Add;

                var query = from e1 in db.Customer
                            from e2 in db.Ref
                            where (e1.Ref_ID == e2.ID)
                                 && (e1.Ref_ID2 == e2.ID2)
                            select new { Data1 = e1.Name, Data2 = e2.Name };

                var result = query.Take(1000).ToList();

                Console.WriteLine(dblog[1]);

                Console.ReadKey();
            }
        }
    }
}

Fahren wir fort mit unserem Projekt.

Am Ende der Ausführung wird in der Konsole ausgegeben:

Der generierte SQL-Abfrage

SELECT TOP (1000) 
    [Extent1].[Ref_ID] AS [Ref_ID], 
    [Extent1].[Name] AS [Name], 
    [Extent2].[Name] AS [Name1]
    FROM  [dbo].[Customer] AS [Extent1]
    INNER JOIN [dbo].[Ref] AS [Extent2] ON ([Extent1].[Ref_ID] = [Extent2].[ID]) AND ([Extent1].[Ref_ID2] = [Extent2].[ID2])

Das heißt, insgesamt hat die LINQ-Abfrage eine SQL-Abfrage an die MS SQL Server-Datenbank generiert, die durchaus ansprechend ist.

Jetzt ändern wir die Bedingung von UND auf ODER in der LINQ-Abfrage:

LINQ-Abfrage

var query = from e1 in db.Customer
                            from e2 in db.Ref
                            where (e1.Ref_ID == e2.ID)
                                || (e1.Ref_ID2 == e2.ID2)
                            select new { Data1 = e1.Name, Data2 = e2.Name };

Und erneut starten wir unsere Anwendung.

Die Ausführung wird mit einem Fehler abgebrochen, der mit der Überschreitung der Ausführungszeit von 30 Sekunden zusammenhängt:

Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server

Wenn wir uns anschauen, welche Abfrage dabei von LINQ generiert wurde:

Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server
, kann man feststellen, dass die Auswahl über das kartesische Produkt von zwei Mengen (Tabellen) erfolgt:

Der generierte SQL-Abfrage

SELECT TOP (1000) 
    [Extent1].[Ref_ID] AS [Ref_ID], 
    [Extent1].[Name] AS [Name], 
    [Extent2].[Name] AS [Name1]
    FROM  [dbo].[Customer] AS [Extent1]
    CROSS JOIN [dbo].[Ref] AS [Extent2]
    WHERE [Extent1].[Ref_ID] = [Extent2].[ID] OR [Extent1].[Ref_ID2] = [Extent2].[ID2]

Lassen Sie uns die LINQ-Abfrage wie folgt umschreiben:

Optimierte LINQ-Abfrage

var query = (from e1 in db.Customer
                   join e2 in db.Ref
                   on e1.Ref_ID equals e2.ID
                   select new { Data1 = e1.Name, Data2 = e2.Name }).Union(
                        from e1 in db.Customer
                        join e2 in db.Ref
                        on e1.Ref_ID2 equals e2.ID2
                        select new { Data1 = e1.Name, Data2 = e2.Name });

Dann erhalten wir die folgende SQL-Abfrage:

SQL-Abfrage

SELECT 
    [Limit1].[C1] AS [C1], 
    [Limit1].[C2] AS [C2], 
    [Limit1].[C3] AS [C3]
    FROM ( SELECT DISTINCT TOP (1000) 
        [UnionAll1].[C1] AS [C1], 
        [UnionAll1].[Name] AS [C2], 
        [UnionAll1].[Name1] AS [C3]
        FROM  (SELECT 
            1 AS [C1], 
            [Extent1].[Name] AS [Name], 
            [Extent2].[Name] AS [Name1]
            FROM  [dbo].[Customer] AS [Extent1]
            INNER JOIN [dbo].[Ref] AS [Extent2] ON [Extent1].[Ref_ID] = [Extent2].[ID]
        UNION ALL
            SELECT 
            1 AS [C1], 
            [Extent3].[Name] AS [Name], 
            [Extent4].[Name] AS [Name1]
            FROM  [dbo].[Customer] AS [Extent3]
            INNER JOIN [dbo].[Ref] AS [Extent4] ON [Extent3].[Ref_ID2] = [Extent4].[ID2]) AS [UnionAll1]
    )  AS [Limit1]

Leider kann in LINQ-Abfragen nur eine Bedingung für den Join angegeben werden, daher ist es möglich, eine äquivalente Abfrage durch zwei separate Abfragen für jede Bedingung zu erstellen und diese dann über Union zu kombinieren, um Duplikate der Zeilen zu entfernen.
Ja, die Abfragen werden im Allgemeinen nicht äquivalent sein, da vollständige Duplikate von Zeilen zurückgegeben werden können. In der Praxis sind vollständige Duplikate jedoch nicht erforderlich und man versucht, sie zu vermeiden.

Vergleichen wir jetzt die Ausführungspläne dieser beiden Abfragen:

  1. Für CROSS JOIN beträgt die durchschnittliche Ausführungszeit 195 Sekunden:
    Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server
  2. Für INNER JOIN-UNION beträgt die durchschnittliche Ausführungszeit weniger als 24 Sekunden:
    Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server

Wie aus den Ergebnissen ersichtlich, arbeitet die optimierte LINQ-Abfrage für zwei Tabellen mit Millionen von Einträgen um ein Vielfaches schneller als die nicht optimierte.

Für den Fall mit AND in den Bedingungen, sieht die LINQ-Abfrage wie folgt aus:

LINQ-Abfrage

var query = from e1 in db.Customer
                            from e2 in db.Ref
                            where (e1.Ref_ID == e2.ID)
                                 && (e1.Ref_ID2 == e2.ID2)
                            select new { Data1 = e1.Name, Data2 = e2.Name };

Fast immer wird eine korrekte SQL-Abfrage generiert, die im Durchschnitt etwa 1 Sekunde benötigt:

Einige Aspekte der Optimierung von LINQ-Abfragen in C#.NET für MS SQL Server
Auch für Manipulationen mit LINQ to Objects anstelle einer Abfrage wie folgt:

LINQ-Abfrage (1. Variante)

var query = from e1 in seq1
                            from e2 in seq2
                            where (e1.Key1==e2.Key1)
                               && (e1.Key2==e2.Key2)
                            select new { Data1 = e1.Data, Data2 = e2.Data };

kann eine Abfrage wie folgt verwendet werden:

LINQ-Abfrage (2. Variante)

var query = from e1 in seq1
                            join e2 in seq2
                            on new { e1.Key1, e1.Key2 } equals new { e2.Key1, e2.Key2 }
                            select new { Data1 = e1.Data, Data2 = e2.Data };

{REPOSITORY_ABSOLUTE_PATH}

Definition von zwei Arrays

Para[] seq1 = new[] { new Para { Key1 = 1, Key2 = 2, Data = "777" }, new Para { Key1 = 2, Key2 = 3, Data = "888" }, new Para { Key1 = 3, Key2 = 4, Data = "999" } };
Para[] seq2 = new[] { new Para { Key1 = 1, Key2 = 2, Data = "777" }, new Para { Key1 = 2, Key2 = 3, Data = "888" }, new Para { Key1 = 3, Key2 = 5, Data = "999" } };

, und der Typ Para wird wie folgt definiert:

Definition des Typs Para

class Para
{
        public int Key1, Key2;
        public string Data;
}

Damit haben wir einige Aspekte der Optimierung von LINQ-Abfragen für MS SQL Server betrachtet.

Leider vergessen selbst erfahrene und führende .NET-Entwickler oft, dass sie verstehen müssen, was die Anweisungen im Hintergrund bewirken, die sie verwenden. Andernfalls werden sie zu Konfiguratoren und können eine Zeitbombe für die Zukunft legen, sowohl beim Skalieren der Softwarelösung als auch bei geringfügigen Änderungen der äußeren Umgebungsbedingungen.

Es wurde auch eine kurze Übersicht durchgeführt und hier.

Die Quellcodes für den Test - das Projekt selbst, die Erstellung von Tabellen in der TEST-Datenbank sowie die Befüllung dieser Tabellen befinden sich hier.
In diesem Repository befinden sich auch im Ordner Plans die Pläne zur Durchführung von Abfragen mit den Bedingungen ODER.

Quelle: habr.com

60GB SSD 8Gb DDR4