跳到正文
DEV Community· Anton Martyniuk·· 2 小時前AI 評分53

深入解析 .NET 各類快取及適用時機(第一部分)

All Types of Caching in .NET: When to Use Each (Deep Dive, Part 1)

AI 導讀

文章梳理 .NET 六種快取方案的適用情境,第一部分聚焦 IMemoryCache,説明其用法、優點與限制。文中區分程序內的 L1 快取與多個應用程式實例共享的 L2 快取,並指出 IMemoryCache 不會跨實例同步,也不會防止同一快取鍵同時未命中時的請求一併查詢資料庫。作者建議先修正資料庫查詢,再加入快取以提升吞吐量。

正文

Your API is slow and Claude recommends you to add caching.

It adds IMemoryCache to the shipment tracking API, and the p95 latency drops from 120 ms to 5 ms.

Everyone is happy.

Then they scale to two instances.

A customer updates the delivery address, refreshes the page, and sees the old one.
Refreshes again and sees the new one. One more refresh, and the old address is back.

Nothing is broken. Each instance has its own memory, and only one of them knows that the address has changed.

Caching is the cheapest performance win in .NET, and one of the easiest things to get wrong.

The first and most important rule is: do not add caching to hide your slow database queries.
It's like fixing symptoms.

First, fix your database queries (the root cause), and only then add caching to increase your throughput and make your API even faster.

In .NET, you have 6 ways to cache: IMemoryCache, IDistributedCache, Redis, HybridCache, FusionCache, and Output Cache.

In this post, we will explore each caching option with pros, cons, pitfalls, and when to use it:

  • Where Each Cache Sits in a .NET App
  • In-Memory Cache (IMemoryCache)
  • Distributed Cache (IDistributedCache) with Redis
  • HybridCache
  • FusionCache (Part 2)
  • Output Cache (Part 2)
  • Which Cache Should You Choose (Part 2)

Let's dive in.

It's a very long newsletter (no one will read such a long article), so I will release PART 2 next week.


👉 Read original article on my newsletter: https://antondevtips.com/blog/all-types-of-caching-in-dotnet-and-when-to-use-each-part1

Where Each Cache Sits in a .NET App

Every cache in .NET sits somewhere on the path between an HTTP request and your database.

Some sit in front of your code and cache whole HTTP responses.
Others sit inside your handlers and cache .NET objects, either in the memory of one process or in an external store shared by every instance.

Here is the full picture:

HTTP request
   │
   ▼
Output Cache (whole HTTP response) ──miss──┐
                                           ▼
Endpoint + Handler
   │
   ▼
L1: in-memory (IMemoryCache / HybridCache / FusionCache) ──miss──┐
                                                                 ▼
L2: distributed (IDistributedCache over Redis) ──miss──┐
                                                       ▼
Database

You need to know these three terms:

  • L1 cache is the memory of the current process. It is the fastest cache you can have because there is no network request and no deserialization.
  • L2 cache is an external store like Redis, shared by all instances of your app. Slower than L1 (but much faster than a database) and every instance sees the same data.
  • Cache stampede happens when many requests miss the cache at the same moment and all of them hit the database for the same key.

In this article, we will explore the shipments domain and we will cache shipments.

Here is the Minimal API endpoint that calls a handler, and the handler queries EF Core directly:

app.MapGet("/api/shipments/{shipmentNumber}", async (
    string shipmentNumber,
    GetShipmentByNumberHandler handler,
    CancellationToken cancellationToken) =>
{
    var shipment = await handler.HandleAsync(shipmentNumber, cancellationToken);
    return shipment is null ? Results.NotFound() : Results.Ok(shipment);
});

internal sealed class GetShipmentByNumberHandler(ShipmentsDbContext dbContext)
{
    public async Task<ShipmentResponse?> HandleAsync(
        string shipmentNumber,
        CancellationToken cancellationToken)
    {
        return await dbContext.Shipments
            .Where(s => s.Number == shipmentNumber)
            .Select(s => s.MapToResponse())
            .FirstOrDefaultAsync(cancellationToken);
    }
}

This query is already fast. We have indexed on the Shipment.Number field and we also select only the necessary fields.

Let's add a cache to this query.

In-Memory Cache (IMemoryCache)

IMemoryCache is a key-value store that lives in the memory of your application process.

You store any .NET object in it, and you get the same object reference back.
When you get items from an IMemoryCache, they don't require serialization and you don't need any network requests.

To use IMemoryCache in your application, add it into the DI container:

builder.Services.AddMemoryCache();

In the handler, we will use the cache-aside strategy on top of the database query:

internal sealed class GetShipmentByNumberHandler(
    ShipmentsDbContext dbContext,
    IMemoryCache cache)
{
    public async Task<ShipmentResponse?> HandleAsync(
        string shipmentNumber,
        CancellationToken cancellationToken)
    {
        return await cache.GetOrCreateAsync($"shipment:{shipmentNumber}", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SetSize(1);

            return await dbContext.Shipments
                .Where(s => s.Number == shipmentNumber)
                .Select(s => s.MapToResponse())
                .FirstOrDefaultAsync(cancellationToken);
        });
    }
}

GetOrCreateAsync first checks the cache, calls the database if the item is not found in the cache, and stores the result returned by the factory method.
The entry expires 10 minutes after it was written.

This is the fastest cache you can have in .NET.
A hit is a dictionary lookup that returns a reference. There is nothing to deserialize.

IMemoryCache has the following advantages:

  • Zero infrastructure: no Redis, no connection strings, nothing to deploy.
  • Fastest possible reads: a hit costs nanoseconds and allocates nothing.
  • Any object: you can cache types that are not serializable, including compiled regexes or large immutable lookup tables.

Here are the drawbacks of IMemoryCache:

  • Per-process only: two instances of your API have two separate caches that never talk to each other.
  • Lost on restart: every deployment starts with a cold cache.
  • Shares memory with your app: a cache that grows without a limit can cause memory issues.

IMemoryCache Pitfalls

There is no size limit by default.
SetSize(1) in the code above does nothing until you set a SizeLimit on the cache itself:

builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 10_000;
});

The size unit is whatever you decide. Here, each shipment counts as 1, so the cache can hold at most 10,000 shipments.
Once you set SizeLimit, every entry must call SetSize, or the cache throws when you add it.

The cache hands out the same reference every time.
If a caller mutates the object it got from the cache, every later caller sees the mutation.
This is a classic source of bugs that only appear under load:

var response = await cache.GetOrCreateAsync(key, ...);
response.Items.Clear(); // every next request now gets a shipment with no items.

Cache immutable records, or return a copy.

There is no way to remove entries by prefix or tag.
IMemoryCache has Remove(key) and nothing else.
If you need to drop all cached shipments for an order, you need to store all the keys to be able to delete them.

GetOrCreateAsync has no stampede protection.
If 100 requests miss the same key at the same time, 100 factories run and 100 database queries execute.
IMemoryCache does not lock per key. You have to add a SemaphoreSlim yourself (bad!) or use a cache that does it for you.

When you scale to several instances, you need a store they all share.


👉 Read original article on my newsletter: https://antondevtips.com/blog/all-types-of-caching-in-dotnet-and-when-to-use-each-part1

來源:DEV Community · dev.to