Exploring Scoped vs Transactional (`IDbContextFactory`) `DbContexts`
See original GitHub issueAsk a question
I am currently upgrading my framework to EfCore 6.0. In doing so, I have been taking the time to examine best practices to ensure that I am doing everything properly for my Blazor server-side application.
Currently, all my components and DbContext
instances are scoped to the user. I am concerned about the memory utilization this may incur as more and more users adopt my application (🤞), but I have not been able to definitively ascertain this is an actual concern yet. I mention this as part of me is wrestling with the notion of avoiding premature optimization in my codebase, and solving a problem that does not actually exist yet.
OK, so with that tidbit aside, I started to do some performance analysis around scoped vs. transactional operations, which I share with you below. When I say “transactional,” I primarily mean the use of IDbContextFactory
(and pooled ones at that, as we’ll see), but in my tests I use that to mean direct activation of a DbContext
. Essentially, “transactional” means something that has to be created/disposed during an operation rather than pulled from (scoped) memory.
What’s beneficial and elegant about my current design is that all IQueryable<T>
instances are defined once per DbContext
and then scoped to the user, along with the DbContext
that created them. In effect, this caches the query but also adds the memory overhead which is the concern I shared earlier.
Switching everything to be transactional/IDbContextFactory
would be very time consuming in my application, particularly all the stored IQueryable<T>
queries that I have defined that are subsequently scoped to the user.
However, upon further inspection, I could take a whack at everything that is non-queryable, that is, writable, or anything involving a DbContext.SaveChangesAsync
.
So then the thought struck me, and that leads me to my question (which I will share in a bit, I promise!):
How about using a singleton DbContext
for all queries (reads), and using IDbContextFactory
for everything else (writes)?
To do this, there are two identified issues I would need to do:
- Mark all root
DbSet<T>
queries asAsNoTracking
. - Turn off thread checking to ward off the
InvalidOperationException
that occurs when same-thread access occurs.
There may be others, but I wanted to throw the thought out there here to see if there is anything else to consider.
The Question
So the question is: is it considered OK to use a singleton-scoped DbContext
in a Blazor server-side application to handle all the queries (reads) of the application, while IDbContextFactory
handles all the modifications/operations (writes)?
Follow up: Is there anything really stopping this from happening from a design perspective? That is, are there limits to the amount of queries that one DbContext
can process, assuming the thread-checking is disabled?
Include your code
The other aspect here that is driving me towards this compromise in my application design, is that I did some benchmarking, and what I found was surprising with a very basic in-memory DbContext
. You can find the code here:
https://github.com/Mike-E-angelo/Stash/tree/master/EfCore.ScopedVsTransaction
Simply returning an empty DbSet<T>
as an array returned the following metrics:
Method | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|
Scoped | 2.489 us | 0.0358 us | 0.0334 us | baseline | 0.1945 | - | - | 2 KB | |
Pooled | 2.906 us | 0.0244 us | 0.0216 us | 1.17x slower | 0.02x | 0.2022 | - | - | 2 KB |
Transactional | 22.751 us | 0.3772 us | 0.5163 us | 9.22x slower | 0.27x | 1.7700 | 0.0305 | - | 14 KB |
Here, Scoped
refers to caching the IQueryable<T>
in memory (ala what happens when scoping to user), Pooled
is using a PooledDbContextFactory
, and Transactional
is straight-up activating a new DbContext
.
Looks like Scoped
and Pooled
are pretty much even here, and I probably would have continued to move toward a pooled IDbContextFactory
model for all of my codebase until I appended a few expressions and saw the following:
Method | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|
Scoped | 18.67 us | 0.174 us | 0.163 us | baseline | 0.6714 | - | - | 6 KB | |
Pooled | 31.57 us | 0.622 us | 0.950 us | 1.71x slower | 0.07x | 1.2207 | - | - | 10 KB |
Transactional | 61.67 us | 0.738 us | 0.690 us | 3.30x slower | 0.05x | 2.8076 | - | - | 23 KB |
It would seem that the more expressions added, the more and more Scoped
wins. And my codebase contains a lot of very complex queries containing a lot of expressions. All of which work amazing, btw. 😁 All of which is due to your amazing work over there.
So, seeing this is what got me going down this path and considering/contemplating a singleton DbContext
to handle the reads of my application, while IDbContextFactory
handles the writes.
Also, while I am at this. Keep in mind that my DbContext
above is completely empty, and the simplest of operations generate 2KB-23KB of allocations. To me, this seems a tad excessive and wanted to ensure this is a known issue and/or if I am doing something fundamentally wrong in my tests. I am using the In-Memory provider, which I would expect to be pretty lean in such a scenario, but pointing this out just in case.
To close, I would really like to express my sincere gratitude for all your efforts out there. EfCore is really great, and the team there has been really helpful in attending to my questions/issues. I am a huge fan of this project and all your efforts. What you have made here definitely deserves a unicorn as a mascot, indeed. 🦄
Thank you for any assistance/insight you can provide. 👍
Include stack traces
NA
Include verbose output
NA
Include provider and version information
EF Core version: 6.0.0-rc.1.21416.1
Database provider: Microsoft.EntityFrameworkCore.InMemory
Target framework: (e.g. .NET 5.0) net6.0 rc1
Operating system: Windows 10
IDE: Visual Studio 2022 Preview 3.1
Issue Analytics
- State:
- Created 2 years ago
- Comments:15 (7 by maintainers)
Sounds good, am happy I could help. Also always interested if you find odd perf tidbits that could be optimization opportunities.
EXCELLENT. That is indeed the missing piece here in my world. Allow me to look into this in addition to your benchmarks, and I will get back to you here when I have a better understanding.
Thank you for taking the time to provide the above valuable information and for the informative discussion, @roji. It is much appreciated. On a weekend no less. 😁