Site icon Blog ARC Optimizer

Conseils pour des requêtes de données efficaces dans ASP.NET Core

Conseils pour des requêtes de données efficaces dans ASP.NET Core


Des requêtes efficaces sont essentielles aux performances et à l’évolutivité des API qui gèrent de grands volumes de données. Dans cet article, nous explorerons les meilleures pratiques et stratégies pour optimiser les requêtes backend à l’aide d’Asp.Net Core.

Dans les applications backend qui consomment des bases de données relationnelles, la façon dont les requêtes sont structurées peuvent être cruciales pour les performances des applications. Les requêtes inefficaces, le chargement excessif de données et le manque de filtres appropriés sont quelques-uns des pièges qui peuvent compromettre les performances, en particulier dans les API exposées à des volumes d’accès élevé.

Dans cet article, nous couvrirons les techniques pour mettre en œuvre des requêtes efficaces, telles que la conception uniquement des propriétés nécessaires, éliminant les requêtes en boucle, en utilisant le paginage basé sur le curseur, les filtres dynamiques et autres.

🔍 L’importance d’optimiser les recherches de données dans les API Web

Le traitement efficace des données est l’un des principaux défis auxquels sont confrontés les développeurs backend aujourd’hui. Avec la demande croissante d’applications basées sur les données, il est devenu courant de traiter les demandes impliquant de grands volumes d’informations, nécessitant des solutions qui équilibrent les performances, l’évolutivité et la convivialité.

Des scénarios tels que la génération de rapports analytiques, la construction de tableaux de bord en temps réel et la gestion de grands catalogues de produits sur les marchés ne sont que quelques exemples où les performances du backend peuvent devenir un goulot d’étranglement si elles ne sont pas correctement adressées.

Dans cet article, nous explorerons certaines ressources et stratégies d’amélioration des performances que nous pouvons adopter lors de la création d’API Web qui traitera de grands volumes de données.

Tous les exemples de code couverts dans cet article sont disponibles dans ce référentiel GitHub: Code source de Turbonote.

🎯 1. Projeter uniquement les propriétés nécessaires

L’utilisation du noyau de Framework Entity (EF Core) pour interroger les instances d’entité est très utile car il résume la complexité des interactions de base de données, permettant l’utilisation d’objets fortement typés au lieu de commandes SQL brutes. Cependant, il est nécessaire de considérer quelles données doivent être extraites dans les requêtes pour les optimiser.

Considérez l’exemple ci-dessous. Bien que ce code ne demande que la propriété Slug de chaque message, l’entité entière est récupérée. Par conséquent, les colonnes inutiles sont transférées de la base de données pendant la requête.

await foreach (var post in db.Posts.AsAsyncEnumerable())
{
    post.Slug.Validate();
}
SELECT [p].[PostId], [p].[AuthorId], [p].[CategoryId], [p].[Content], [p].[CreatedAt], [p].[PublishedAt], [p].[Slug], [p].[Title], [p].[UpdatedAt]
FROM [Posts] AS [p]

Il est possible d’optimiser cette requête avec la méthode d’extension de sélection en disant à EF quelles colonnes projeter:

await foreach (var postSlug in db.Posts.Select(b => b.Slug).AsAsyncEnumerable())
{
    postSlug.Validate();
}

Maintenant, le SQL résultant récupère uniquement la colonne souhaitée:

SELECT [p].[Slug]
FROM [Posts] AS [p]

2. 🔄️ Élimination des requêtes en boucle

Le problème connu sous le nom de requêtes N + 1 est courant lors de l’utilisation de EF et itérant sur une liste, ce qui entraîne de nouvelles requêtes pour chaque élément.

Vous trouverez ci-dessous un exemple qui utilise plusieurs requêtes:

var posts = await db.Posts.ToListAsync();

foreach (var post in posts)
{
    var comments = await db.Comments
        .Where(c => c.PostId == post.PostId)
        .ToListAsync();

    post.Comments = comments;
}
SELECT [c].[CommentId], [c].[AuthorId], [c].[Content], [c].[CreatedAt], [c].[PostId]
FROM [Comments] AS [c]
WHERE [c].[PostId] = @__post_PostId_0

SELECT [c].[CommentId], [c].[AuthorId], [c].[Content], [c].[CreatedAt], [c].[PostId]
FROM [Comments] AS [c]
WHERE [c].[PostId] = @__post_PostId_0

(...successive queries)

S’il y a 100 messages, le code fera 1 + 100 requêtes (1 pour les messages et 1 pour chaque groupe de commentaires).

Au lieu de cela, nous pouvons faire ce qui suit:

var comments = await db.Posts.Include(p => p.Comments).ToListAsync();

Ce qui entraînerait le SQL suivant:

SELECT [p].[PostId], [p].[AuthorId], [p].[CategoryId], [p].[Content], [p].[CreatedAt], [p].[PublishedAt], [p].[Slug], [p].[Title], [p].[UpdatedAt], [c].[CommentId], [c].[AuthorId], [c].[Content], [c].[CreatedAt], [c].[PostId]
FROM [Posts] AS [p]
LEFT JOIN [Comments] AS [c] ON [p].[PostId] = [c].[PostId]
ORDER BY [p].[PostId]

Notez que lorsque vous utilisez .Include(p => p.Comments)EF apporte déjà les commentaires liés à chaque message dans une seule requête avec LEFT JOINen évitant le problème N + 1.

La pagination basée sur le curseur est une technique de pagination où les résultats sont récupérés à partir d’un marqueur (le curseur), généralement représenté par la clé unique d’un élément, comme un ID ou une date de création.

Il fonctionne comme suit: Considérons une seule valeur de référence, appelée curseur, qui sera utilisée pour récupérer les prochains enregistrements à partir de ce point.

Au lieu d’utiliser un numéro de page ou un décalage comme dans la méthode de décalage, le client envoie ce curseur dans la demande (?after=105par exemple), et le serveur renvoie les enregistrements qui viennent après cette valeur, limitant le nombre avec LIMIT ou Take. Cette méthode est plus efficace et fiable, en particulier dans les grandes bases de données ou celles qui subissent des changements fréquents, car il évite les problèmes d’enregistrements en double ou perdus qui peuvent se produire avec une pagination basée sur le décalage.

Un point à noter est que, bien que plus efficace que le OFFSET Méthode, la pagination basée sur le curseur ne permet pas les sauts directs vers une page spécifique; Il nécessite une navigation séquentielle.

Le code ci-dessous montre comment implémenter une pagination basée sur le curseur:

app.MapGet("/posts/pagination", async (
    [FromQuery] int? after,
    [FromQuery] int pageSize,
    BlogDbContext db) =>
{
    const int MaxPageSize = 100;
    pageSize = pageSize > MaxPageSize ? MaxPageSize : pageSize;

    var query = db.Posts
        .AsNoTracking()
        .OrderBy(p => p.PostId)
        .Where(p => !after.HasValue || p.PostId > after.Value)
        .Take(pageSize + 1); 

    var posts = await query.ToListAsync();

    var hasNextPage = posts.Count > pageSize;
    var results = posts.Take(pageSize).ToList();

    var nextCursor = hasNextPage ? results.Last().PostId : (int?)null;

    return Results.Ok(new
    {
        Data = results.Select(p => new
        {
            p.PostId,
            p.Title,
            p.Slug,
            p.PublishedAt
        }),
        NextCursor = nextCursor
    });
});

Si nous exécutons le point de terminaison Get /posts/pagination/?pageSize=10 after=50EF générera le SQL suivant:

SELECT TOP(11) [p].[PostId], [p].[AuthorId], [p].[CategoryId], [p].[Content], [p].[CreatedAt], [p].[PublishedAt], [p].[Slug], [p].[Title], [p].[UpdatedAt]
FROM [Posts] AS [p]
WHERE [p].[PostId] > 50
ORDER BY [p].[PostId];

Notez que nous définissons ici le paramètre After PostId.

Notez également un article de plus que le pageSize est envoyé (Take(pageSize + 1))ce qui permet au serveur de savoir s’il y a plus de pages à charger sans s’appuyer sur des décomptes supplémentaires. Cet article supplémentaire n’est pas renvoyé au client, mais il sert à définir si le NextCursor le champ sera rempli de PostId du dernier élément visible, qui facilite la navigation séquentielle.

Enfin, nous utilisons AsNoTracking() Pour améliorer les performances en empêchant EF Core de suivre les objets retournés, quelque chose d’inutile dans les scénarios en lecture seule.

4. 🏓 Utilisation de filtres dynamiques avec iQueryable

Une erreur courante dans les requêtes de base de données consiste à charger tous les enregistrements en mémoire et à appliquer des filtres. Cette approche est inefficace, car elle consomme des ressources inutiles et a un impact sur les performances des applications.

Au lieu de cela, il est recommandé d’appliquer des filtres directement à la requête. Une façon de le faire est à travers le IQueryable Interface, qui permet au filtre d’être traduit en SQL et exécuté dans la base de données, renvoyant uniquement les données nécessaires.

Notez l’exemple ci-dessous:

   
    var allPosts = await db.Posts.ToListAsync();

    
    var filteredPosts = allPosts;

    if (published == false)
    {
        filteredPosts = filteredPosts.Where(p => p.PublishedAt == null).ToList();
    }

    if (categoryId.HasValue)
    {
        filteredPosts = filteredPosts.Where(p => p.CategoryId == categoryId.Value).ToList();
    }

    filteredPosts = filteredPosts
        .OrderByDescending(p => p.PublishedAt)
        .ToList();

Ici, nous chargeons toutes les données en mémoire, puis les filtrons plus tard.

Une alternative serait à utiliser IQueryableassembler dynamiquement le filtre et enfin l’application de la requête à la base de données:

IQueryable<Post> query = db.Posts;


if (published == false)
{
    query = query.Where(p => p.PublishedAt == null);
}

if (categoryId.HasValue)
{
    query = query.Where(p => p.CategoryId == categoryId.Value);
}


query = query.OrderByDescending(p => p.PublishedAt);


var posts = await query.ToListAsync();

Supposant categoryId = 3 et published = falsele SQL suivant sera généré:

SELECT [p].[PostId], [p].[AuthorId], [p].[CategoryId], [p].[Content], 
       [p].[CreatedAt], [p].[PublishedAt], [p].[Slug], [p].[Title]
FROM [Posts] AS [p]
WHERE [p].[PublishedAt] IS NULL AND [p].[CategoryId] = 3
ORDER BY [p].[PublishedAt] DESC

De cette façon, une requête plus efficace est exécutée que le premier exemple, car il filtre les enregistrements dont nous avons besoin directement dans SQL, sans avoir besoin de travailler avec les données en mémoire.

5. 🙅 Ne transférez pas de fonctions inutiles à la requête

Parfois, nous devons effectuer des conversions sur les données manipulées. Un exemple courant consiste à convertir du texte en majuscules ou en minuscules, qui en C # peut être effectué en utilisant le ToLower() et ToUpper() Méthodes. Cependant, si nous ne faisons pas attention, ces fonctions peuvent être envoyées à la requête et aux performances de compromis. Notez l’exemple ci-dessous:

var user = db.Users.Where(u => u.UserName.ToUpper() == userName.ToUpper());

Ce code générera la requête SQL suivante:

SELECT [u].[UserId], [u].[CreatedAt], [u].[Email], [u].[UserName]
FROM [Users] AS [u]
WHERE UPPER([u].[UserName]) = @__ToUpper_0

Le problème ici est que le UPPER La clause a été transférée à la requête, ce qui peut compromettre les performances en fonction du scénario. Après tout, cette fonction sera exécutée sur toutes les lignes résultant de la requête.

Une alternative dans ce cas est de se fier uniquement à la collation du UserName colonne. Si la colonne utilise une collation insensible à la casse (comme SQL_Latin1_General_CP1_CI_AS dans SQL Server), alors aucune transformation n’est nécessaire:

var user = db.Users.Where(u => u.UserName == userName);

Le SQL généré sera:

SELECT [u].[UserId], [u].[CreatedAt], [u].[Email], [u].[UserName]
FROM [Users] AS [u]
WHERE [u].[UserName] = @__userName_0

Par conséquent, il n’est pas nécessaire d’utiliser ToUpper(); SQL Server lui-même effectuera la conversion à l’aide du SQL_Latin1_General_CP1_CI_AS collation. Dans ce cas, le nom «John» sera le même que «John» et «John».

Pour vérifier si la colonne a le SQL_Latin1_General_CP1_CI_AS Collation, exécutez simplement la requête:

SELECT COLUMN_NAME, COLLATION_NAME 
FROM INFORMATION_SCHEMA.COLUMNS 
WHERE TABLE_NAME = 'Users' AND COLUMN_NAME = 'UserName';

S’il a le collation, il reviendra:

Colonnel_Name Collation_name
Nom d’utilisateur Sql_latin1_general_cp1_ci_as

6. 📦 Réduisez le nombre de requêtes avec la mise en cache

Imaginez un point final qui renvoie les catégories de publication. Ils changent rarement mais sont accessibles des milliers de fois par jour. Dans ce cas, l’interrogation de la base de données à chaque fois est un gaspillage de ressources. Pour y remédier, nous pouvons utiliser des stratégies de mise en cache, comme l’utilisation du cache de mémoire local (ImemoryCache).

Notez l’exemple ci-dessous:

app.MapGet("/posts/categories", async (
    [FromServices] IMemoryCache cache,
    [FromServices] BlogDbContext db) =>
{
    const string cacheKey = "categories_list";

    if (!cache.TryGetValue(cacheKey, out List<Category> categories))
    {
        categories = await db.Categories
            .OrderBy(c => c.Name)
            .ToListAsync();

        var cacheOptions = new MemoryCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromMinutes(10))
            .SetAbsoluteExpiration(TimeSpan.FromHours(1));

        cache.Set(cacheKey, categories, cacheOptions);
    }

    return Results.Ok(categories);
});

Au lieu d’accéder à la base de données avec chaque demande, la base de données vérifie si les données sont présentes dans le cache. S’il n’y a pas déjà de données, la requête est exécutée, puis les données sont temporairement enregistrées dans le cache en mémoire. Ainsi, les prochaines demandes utiliseront les données directement à partir du cache.

L’utilisation d’une stratégie de mise en cache réduit les accès de base de données inutiles, ce qui entraîne une amélioration des performances et une évolutivité.

7. ✨ Utilisation de SQL brut optimisé

Analysons une situation où la rédaction d’une requête SQL manuelle (SQL RAW) est plus avantageuse que d’utiliser LINQ seul.

Imaginez un point de terminaison qui affiche un résumé de blog via un tableau de bord avec des statistiques agrégées. Ce tableau de bord doit s’afficher: les publications totales, les publications totales publiées, les commentaires totaux et la dernière date publiée.

En utilisant l’approche LINQ et la manipulation en mémoire, nous pourrions faire ce qui suit:

var totalPosts = await db.Posts.CountAsync();

var publishedPosts = await db.Posts.CountAsync(p => p.PublishedAt != null);

var totalComments = await db.Comments.CountAsync();

var lastPublished = await db.Posts
     .Where(p => p.PublishedAt != null)
     .MaxAsync(p => p.PublishedAt);

 return Results.Ok(new
 {
     totalPosts,
     publishedPosts,
     totalComments,
     lastPublished
 });

Qui générera le SQL suivant:

SELECT COUNT(*)
FROM [Posts] AS [p]

SELECT COUNT(*)
FROM [Posts] AS [p]
WHERE [p].[PublishedAt] IS NOT NULL

SELECT COUNT(*)
FROM [Comments] AS [c]

SELECT MAX([p].[PublishedAt])
FROM [Posts] AS [p]
WHERE [p].[PublishedAt] IS NOT NULL

Le problème avec cette approche est que quatre requêtes distinctes sont exécutées, ce qui peut provoquer des frais généraux dans des environnements à haute charge. De plus, il est plus difficile d’optimiser les requêtes en utilisant uniquement des index SQL.

Une approche plus performante serait de créer une seule requête en utilisant SQL RAW:

var result = await db
     .DashboardStats
     .FromSqlRaw(@"
         SELECT
             (SELECT COUNT(*) FROM Posts) AS TotalPosts,
             (SELECT COUNT(*) FROM Posts WHERE PublishedAt IS NOT NULL) AS PublishedPosts,
             (SELECT COUNT(*) FROM Comments) AS TotalComments,
             (SELECT MAX(PublishedAt) FROM Posts WHERE PublishedAt IS NOT NULL) AS LastPublished
     ")
     .AsNoTracking()
     .FirstAsync();

 return Results.Ok(result);

Notez que dans cette approche, nous utilisons SQL RAW pour ajouter manuellement une requête personnalisée qui renvoie tous les résultats en une seule requête. De cette façon, SQL Server peut évaluer toutes les sous-requêtes en parallèle et optimiser les lectures de la page mise en cache.

Le SQL généré ressemblera à ceci:

SELECT TOP(1) [t].[LastPublished], [t].[PublishedPosts], [t].[TotalComments], [t].[TotalPosts]
FROM (
	    SELECT
		    (SELECT COUNT(*) FROM Posts) AS TotalPosts,
		    (SELECT COUNT(*) FROM Posts WHERE PublishedAt IS NOT NULL) AS PublishedPosts,
		    (SELECT COUNT(*) FROM Comments) AS TotalComments,
		    (SELECT MAX(PublishedAt) FROM Posts WHERE PublishedAt IS NOT NULL) AS LastPublished
) AS [t]

🌱 Conclusion

Une condition préalable à la création d’une bonne API permet de bonnes performances. Pour y parvenir, il est essentiel d’adopter des pratiques qui optimisent l’accès aux données et l’interrogation. Tout au long de cet article, nous avons exploré sept conseils sur la façon de tirer le meilleur parti des fonctionnalités d’Asp.net Core et de la base de données, en réduisant les goulots d’étranglement et en améliorant l’efficacité des applications backend.

J’espère que ces conseils vous aideront à développer des API encore plus rapides et plus évolutives capables de gérer efficacement de grands volumes de données. Rendez-vous la prochaine fois!




Source link
Quitter la version mobile