mcpField
C
caching-strategies

caching-strategies

github.com/wshaddix開発9179 回インストール37 週間前に更新
リポジトリ

caching-strategies について

ASP.NET CoreのRazor Pagesアプリを扱うアーキテクト向けのキャッシュ指針です。キャッシュ方式の選択や、古いデータ、キャッシュスタンピード、実装の複雑化を避ける方法を説明します。

主な機能

  1. 01出力キャッシュとレスポンスキャッシュのパターン
  2. 02メモリ、分散、HybridCacheの指針
  3. 03キャッシュ無効化の方法
  4. 04Razor Pagesとユーザー別キャッシュ
  5. 05アンチパターンとキャッシュキーの統一

活用シーン

  1. 01アプリに適したキャッシュ方式の選択
  2. 02負荷の高いデータベースクエリのキャッシュ
  3. 03複数サーバーインスタンス間でのキャッシュ共有
  4. 04更新後のページやレコードのキャッシュ無効化

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill caching-strategies

インストール後、Claude Code で/caching-strategiesを入力して読み込まれたか確認します。

詳細

Razor Pages アプリケーションのキャッシュ戦略を専門とする、シニア ASP.NET Core アーキテクトです。Razor Pages アプリケーションにキャッシュを実装する際は、正確性を維持しながらパフォーマンスを最大化できるよう、以下のパターンを適用してください。対象は .NET 8 以降で、最新の機能と nullable 参照型が有効になっている環境です。

根拠

キャッシュはアプリケーションのパフォーマンスを改善する最も効果的な方法の 1 つですが、実装が不適切だと、古いデータ、キャッシュスタンピード、複雑さにつながります。ここで紹介するパターンでは、シンプルなものから分散型のものまで、キャッシュソリューションを段階的に示し、それぞれをいつ使用するべきか明確に説明します。

キャッシュの階層

戦略範囲用途レイテンシ
出力キャッシュサーバー全体ページ全体のレスポンス低
レスポンスキャッシュクライアント + プロキシ静的ページ、アセット低
メモリキャッシュ単一インスタンス短時間有効な高コストのデータ非常に低
分散キャッシュ複数インスタンスサーバー間で共有するデータ低〜中
HybridCache(.NET 9 以降)複数インスタンスメモリキャッシュと分散キャッシュの利点を組み合わせる非常に低

パターン 1:出力キャッシュ(ページ全体)

頻繁には変更されず、ユーザー固有のデータを含まないページに使用します。

構成
// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder =>
        builder.Expire(TimeSpan.FromSeconds(10)));
    options.AddPolicy("LongCache", builder =>
        builder.Expire(TimeSpan.FromMinutes(5)));
    options.AddPolicy("AuthenticatedCache", builder =>
        builder.Expire(TimeSpan.FromMinutes(1))
               .Tag("user-specific"));
});

// Add middleware (order matters!)
var app = builder.Build();
app.UseOutputCache(); // After UseRouting, before endpoints
ページ単位での使用
// Cache entire page for 60 seconds
[OutputCache(Duration = 60)]
public class IndexModel : PageModel { }

// Named policy with tags for invalidation
[OutputCache(PolicyName = "LongCache")]
public class PrivacyModel : PageModel { }

// Vary by query string parameter
[OutputCache(Duration = 300, VaryByQueryKeys = new[] { "page", "category" })]
public class BlogListModel : PageModel { }

// Vary by header (e.g., for mobile vs desktop)
[OutputCache(Duration = 300, VaryByHeaderNames = new[] { "User-Agent" })]
public class ProductListModel : PageModel { }

// Different cache for authenticated users
[OutputCache(PolicyName = "AuthenticatedCache")]
[Authorize]
public class DashboardModel : PageModel { }
キャッシュの無効化
// Tag-based invalidation
public class BlogAdminModel(IOutputCacheStore cache) : PageModel
{
    public async Task<IActionResult> OnPostPublishAsync()
    {
        // Invalidate all pages tagged with "blog"
        await cache.EvictByTagAsync("blog", CancellationToken.None);
        
        return RedirectToPage("/Blog/List");
    }
}

パターン 2:レスポンスキャッシュ(クライアント側)

ブラウザーや CDN にキャッシュできる静的アセットやページに使用します。

// Program.cs
builder.Services.AddResponseCaching();

var app = builder.Build();
app.UseResponseCaching(); // Before UseOutputCache
// Page-level cache control
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
public class StaticContentModel : PageModel { }

// No caching (for error pages, authenticated content)
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public class ErrorModel : PageModel { }

// Private caching (client only, no CDN)
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client)]
public class UserProfileModel : PageModel { }

パターン 3:メモリキャッシュ

単一のサーバーインスタンス内で、高コストな計算やデータベースクエリに使用します。

構成
// Program.cs
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 100_000_000; // 100MB total cache size
    options.CompactionPercentage = 0.25; // Remove 25% when limit reached
    options.ExpirationScanFrequency = TimeSpan.FromMinutes(5);
});
ハンドラー/PageModel での使用
public class ProductService(IMemoryCache cache, AppDbContext db)
{
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
    
    public async Task<Product?> GetProductAsync(Guid id)
    {
        var cacheKey = $"product:{id}";
        
        if (cache.TryGetValue(cacheKey, out Product? product))
        {
            return product;
        }
        
        product = await db.Products.FindAsync(id);
        
        if (product != null)
        {
            var cacheOptions = new MemoryCacheEntryOptions()
                .SetAbsoluteExpiration(CacheDuration)
                .SetSize(1) // For size-limited cache
                .RegisterPostEvictionCallback((key, value, reason, state) =>
                {
                    // Log cache eviction
                });
                
            cache.Set(cacheKey, product, cacheOptions);
        }
        
        return product;
    }
    
    public void InvalidateProduct(Guid id)
    {
        cache.Remove($"product:{id}");
    }
}
GetOrCreateAsync を使ったキャッシュアサイドパターン
public async Task<List<Category>> GetCategoriesAsync()
{
    return await cache.GetOrCreateAsync(
        "categories:all",
        async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
            entry.SetSize(1);
            
            return await db.Categories
                .AsNoTracking()
                .ToListAsync();
        });
}

パターン 4:分散キャッシュ(Redis)

キャッシュを共有する必要がある複数インスタンスのデプロイで使用します。

構成
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "MyApp:"; // Prefix for all keys
});

// Or using Aspire
builder.AddRedis("cache");
使用方法
public class DistributedProductService(IDistributedCache cache, AppDbContext db)
{
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
    
    public async Task<Product?> GetProductAsync(Guid id)
    {
        var cacheKey = $"product:{id}";
        
        // Try to get from distributed cache
        var cached = await cache.GetStringAsync(cacheKey);
        if (cached != null)
        {
            return JsonSerializer.Deserialize<Product>(cached);
        }
        
        // Fetch from database
        var product = await db.Products.FindAsync(id);
        
        if (product != null)
        {
            // Serialize and store
            var serialized = JsonSerializer.Serialize(product);
            var options = new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = CacheDuration
            };
            
            await cache.SetStringAsync(cacheKey, serialized, options);
        }
        
        return product;
    }
}
スライディング有効期限パターン
public async Task<UserSession?> GetSessionAsync(string sessionId)
{
    var options = new DistributedCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromMinutes(20), // Extend on access
        AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(8) // Max lifetime
    };
    
    var session = await cache.GetStringAsync($"session:{sessionId}");
    if (session == null) return null;
    
    // Touch the cache to extend sliding expiration
    await cache.RefreshAsync($"session:{sessionId}");
    
    return JsonSerializer.Deserialize<UserSession>(session);
}

パターン 5:HybridCache(.NET 9 以降)

.NET 9 以降で推奨:ローカルのメモリキャッシュ(高速)と分散キャッシュ(共有)の両方を提供し、自動的に同期します。

構成
// Program.cs
builder.Services.AddHybridCache(options =>
{
    options.DefaultLocalCacheExpiration = TimeSpan.FromMinutes(5);
    options.DefaultExpiration = TimeSpan.FromMinutes(30);
    options.LocalCacheMaximumSizeBytes = 50_000_000; // 50MB
});
使用方法
public class HybridProductService(IHybridCache cache, AppDbContext db)
{
    public async Task<Product?> GetProductAsync(Guid id, CancellationToken ct = default)
    {
        return await cache.GetOrCreateAsync(
            $"product:{id}",
            async cancel => await db.Products.FindAsync(new object[] { id }, cancel),
            new HybridCacheEntryOptions
            {
                LocalCacheExpiration = TimeSpan.FromMinutes(5),
                Expiration = TimeSpan.FromMinutes(30)
            },
            tags: new[] { "products" },
            cancellationToken: ct);
    }
    
    public async Task RemoveProductAsync(Guid id)
    {
        await cache.RemoveByTagAsync("products");
    }
}

キャッシュの無効化戦略

1. タグベースの無効化
// Add tags during cache entry creation
await cache.SetAsync(key, data, options, tags: new[] { "users", $"user:{userId}" });

// Invalidate by tag
await cache.RemoveByTagAsync("users"); // Removes all user entries
2. イベント駆動型の無効化
public class ProductUpdatedHandler(IDistributedCache cache) : INotificationHandler<ProductUpdated>
{
    public async Task Handle(ProductUpdated notification, CancellationToken ct)
    {
        await cache.RemoveAsync($"product:{notification.ProductId}");
        await cache.RemoveByTagAsync("products:list");
    }
}
3. 時間ベースの無効化
// Different expiration strategies for different data freshness requirements
public class CachePolicies
{
    public static readonly TimeSpan UserData = TimeSpan.FromMinutes(5);
    public static readonly TimeSpan ProductData = TimeSpan.FromHours(1);
    public static readonly TimeSpan ReferenceData = TimeSpan.FromDays(1);
}

アンチパターン

キャッシュスタンピード
// ❌ BAD: Multiple requests hit database simultaneously when cache expires
public async Task<Product> GetProduct(Guid id)
{
    if (!cache.TryGetValue(id, out var product))
    {
        product = await db.Products.FindAsync(id); // All requests hit here
        cache.Set(id, product);
    }
    return product!;
}

// ✅ GOOD: Use locking to prevent stampede
public async Task<Product?> GetProductAsync(Guid id)
{
    return await cache.GetOrCreateAsync(
        $"product:{id}",
        async _ => await db.Products.FindAsync(id));
}
大きなオブジェクトの保存
// ❌ BAD: Storing entire collections
var allProducts = await db.Products.ToListAsync();
cache.Set("products:all", allProducts);

// ✅ GOOD: Store individual items, paginate
var products = await db.Products
    .Skip(offset)
    .Take(50)
    .ToListAsync();
一貫性のないキャッシュキー
// ❌ BAD: Inconsistent key generation
var key1 = $"user-{userId}";
var key2 = $"user:{userId}";
var key3 = $"User:{userId}";

// ✅ GOOD: Centralized key helpers
public static class CacheKeys
{
    public static string User(Guid id) => $"user:{id}";
    public static string UserList(string? filter = null) => 
        filter == null ? "users:all" : $"users:filter:{filter}";
}

Razor Pages 固有のパターン

ページの一部のキャッシュ
// Cache partial view output
public class ProductCardViewComponent(IDistributedCache cache) : ViewComponent
{
    public async Task<IViewComponentResult> InvokeAsync(Guid productId)
    {
        var cacheKey = $"product-card:{productId}";
        
        var html = await cache.GetStringAsync(cacheKey);
        if (html != null)
        {
            return Content(html);
        }
        
        var product = await GetProductAsync(productId);
        var result = View(product);
        
        // Render and cache the HTML
        using var writer = new StringWriter();
        await result.RenderViewComponentAsync(writer);
        html = writer.ToString();
        
        await cache.SetStringAsync(cacheKey, html, 
            new DistributedCacheEntryOptions 
            { 
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) 
            });
        
        return Content(html);
    }
}
ユーザーごとのキャッシュ
[OutputCache(Duration = 60, VaryByCookie = new[] { ".AspNetCore.Identity.Application" })]
public class UserDashboardModel : PageModel { }

// Or vary by custom header
[OutputCache(Duration = 60, VaryByHeaderNames = new[] { "X-User-Tier" })]
public class PricingModel : PageModel { }

参考資料

コメント