web-dev-qa-db-fra.com

Quelle est la meilleure pratique dans EF Core pour utiliser des appels asynchrones parallèles avec un DbContext injecté?

J'ai une API .NET Core 1.1 avec EF Core 1.1 et j'utilise la configuration Vanilla de Microsoft d'utiliser Dependency Injection pour fournir le DbContext à mes services. (Référence: https://docs.Microsoft.com/en-us/aspnet/core/data/ef-mvc/intro#register-the-context-with-dependency-injection )

Maintenant, je cherche à paralléliser les lectures de base de données comme une optimisation en utilisant WhenAll

Donc au lieu de:

var result1 = await _dbContext.TableModel1.FirstOrDefaultAsync(x => x.SomeId == AnId);
var result2 = await _dbContext.TableModel2.FirstOrDefaultAsync(x => x.SomeOtherProp == AProp); 

J'utilise:

var repositoryTask1 = _dbContext.TableModel1.FirstOrDefaultAsync(x => x.SomeId == AnId);     
var repositoryTask2 = _dbContext.TableModel2.FirstOrDefaultAsync(x => x.SomeOtherProp == AProp);   
(var result1, var result2) = await (repositoryTask1, repositoryTask2 ).WhenAll();

C'est très bien, jusqu'à ce que j'utilise la même stratégie en dehors de ces classes d'accès au référentiel DB et que j'appelle ces mêmes méthodes avec WhenAll dans mon contrôleur sur plusieurs services:

var serviceTask1 = _service1.GetSomethingsFromDb(Id);
var serviceTask2 = _service2.GetSomeMoreThingsFromDb(Id);
(var dataForController1, var dataForController2) = await (serviceTask1, serviceTask2).WhenAll();

Maintenant, quand j'appelle cela depuis mon contrôleur, au hasard, j'obtiendrai des erreurs de concurrence comme:

System.InvalidOperationException: ExecuteReader nécessite une connexion ouverte et disponible. L'état actuel de la connexion est fermé.

Je crois que c'est parce que parfois ces threads essaient d'accéder aux mêmes tables en même temps. Je sais que c'est par conception dans EF Core et si je le voulais, je pourrais créer un nouveau dbContext à chaque fois, mais j'essaie de voir s'il y a une solution. C'est alors que j'ai trouvé ce bon post de Mehdi El Gueddari: http://mehdi.me/ambient-dbcontext-in-ef6/

Dans lequel il reconnaît cette limitation:

un DbContext injecté vous empêche d'être en mesure d'introduire le multithread ou toute sorte de flux d'exécution parallèle dans vos services.

Et propose une solution de contournement personnalisée avec DbContextScope.

Cependant, il présente une mise en garde même avec DbContextScope dans la mesure où cela ne fonctionnera pas en parallèle (ce que j'essaie de faire ci-dessus):

si vous essayez de démarrer plusieurs tâches parallèles dans le contexte d'un DbContextScope (par exemple en créant plusieurs threads ou plusieurs tâches TPL), vous aurez de gros problèmes. Cela est dû au fait que le DbContextScope ambiant traversera tous les threads que vos tâches parallèles utilisent.

Son dernier point ici m'amène à ma question:

En général, la parallélisation de l'accès aux bases de données au sein d'une même transaction commerciale présente peu ou pas d'avantages et ne fait qu'ajouter une complexité considérable. Toute opération parallèle effectuée dans le contexte d'une transaction commerciale ne doit pas accéder à la base de données.

Ne devrais-je pas utiliser WhenAll dans ce cas dans mes contrôleurs et m'en tenir à attendre un par un? Ou est-ce que l'injection de dépendances du DbContext est le problème le plus fondamental ici, donc un nouveau devrait à la place être créé/fourni à chaque fois par une sorte d'usine?

20
starmandeluxe

Il est arrivé au point où la seule façon de répondre au débat était de faire un test de performance/charge pour obtenir des preuves statistiques comparables, empiriques afin que je puisse régler cela une fois pour toutes.

Voici ce que j'ai testé:

Test de charge cloud avec VSTS @ 200 utilisateurs max pendant 4 minutes sur une webapp Azure standard.

Test n ° 1: 1 appel API avec injection de dépendance du DbContext et async/attente pour chaque service.

Résultats du test n ° 1: enter image description here

Test # 2: 1 appel API avec nouvelle création du DbContext dans chaque appel de méthode de service et utilisation de l'exécution de thread parallèle avec WhenAll.

Résultats du test n ° 2: enter image description here

Conclusion:

Pour ceux qui doutent des résultats, j'ai effectué ces tests plusieurs fois avec des charges d'utilisateurs variables, et les moyennes étaient fondamentalement les mêmes à chaque fois.

Les gains de performances avec le traitement parallèle sont à mon avis insignifiants, et cela ne justifie pas la nécessité d'abandonner l'injection de dépendance qui créerait des frais généraux de développement/dette de maintenance, un potentiel de bogues s'il était mal géré et un écart par rapport aux recommandations officielles de Microsoft.

Une dernière chose à noter: comme vous pouvez le voir, il y a eu en fait quelques demandes ayant échoué avec la stratégie WhenAll, même si un nouveau contexte est créé à chaque fois . Je ne suis pas sûr de la raison de cela, mais je préférerais de loin pas 500 erreurs sur un gain de performances de 10 ms.

20
starmandeluxe

L'utilisation d'une méthode context.XyzAsync() n'est utile que si vous await la méthode appelée ou renvoyez le contrôle à un thread appelant qui n'a pas context dans sa portée .

Une instance DbContext n'est pas adaptée aux threads: vous ne devriez jamais l'utiliser dans des threads parallèles. Ce qui signifie, bien sûr, ne jamais l'utiliser dans plusieurs threads de toute façon, même s'ils ne fonctionnent pas en parallèle. N'essayez pas de contourner cela.

Si pour une raison quelconque, vous souhaitez exécuter des opérations de base de données parallèles (et pensez que vous pouvez éviter les blocages, les conflits de concurrence, etc. ), assurez-vous que chacun a sa propre instance DbContext. Notez cependant que la parallélisation est principalement utile pour les processus liés au processeur, et non aux processus liés aux E/S comme l'interaction avec la base de données. Peut-être que vous pouvez bénéficier d'opérations parallèles indépendantes de lecture mais je n'exécuterais certainement jamais parallèle d'écriture processus. Mis à part les blocages, etc., il est également beaucoup plus difficile d'exécuter toutes les opérations en une seule transaction.

Dans le noyau ASP.Net, vous utilisiez généralement le modèle de contexte par demande (ServiceLifetime.Scoped, voir ici ), mais même cela ne peut pas vous empêcher de transférer le contexte vers plusieurs threads. En fin de compte, seul le programmeur peut empêcher cela.

Si vous vous inquiétez des coûts de performance de la création de nouveaux contextes tout le temps: ne le soyez pas. La création d'un contexte est une opération légère, car le modèle sous-jacent (modèle de magasin, modèle conceptuel + mappages entre eux) est créé une fois, puis stocké dans le domaine d'application. De plus, un nouveau contexte ne crée pas de connexion physique à la base de données. Toutes les opérations de base de données ASP.Net s'exécutent via le pool de connexions qui gère un pool de connexions physiques.

Si tout cela implique que vous devez reconfigurer votre DI pour l'aligner sur les meilleures pratiques, qu'il en soit ainsi. Si votre configuration actuelle transmet des contextes à plusieurs threads, il y a eu une mauvaise décision de conception dans le passé. Résistez à la tentation de reporter la refactorisation inévitable par des solutions de rechange. La seule solution consiste à dé-paralléliser votre code, donc à la fin cela peut même être plus lent que si vous remodelez votre DI et votre code pour adhérer au contexte par thread.

18
Gert Arnold