mcpField
D
database-performance

database-performance

github.com/aarononthewebデータ・データベース1.2k677 回インストール今日更新
リポジトリ

database-performance について

EF Core と Dapper を使う開発者向けに、データアクセス設計とデータベースクエリの性能改善パターンを解説するスキルです。読み取りと書き込みのモデル分離、取得件数の制限、N+1 クエリや過剰なデータ転送の回避を扱います。

主な機能

  1. 01読み取りモデルと書き込みモデルの分離
  2. 02クエリへの行数制限の設定
  3. 03読み取り専用クエリでの AsNoTracking
  4. 04バッチ処理による N+1 クエリの回避
  5. 05SQL での結合と取得列の絞り込み
  6. 06状況に応じた EF Core と Dapper の選択

活用シーン

  1. 01データアクセス層と読み書きストアの設計
  2. 02遅いクエリや N+1 問題の調査
  3. 03ページングと関連データ取得の最適化
  4. 04複雑な読み取りや一括処理に使うツールの選定

インストール

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

$npx skills add https://github.com/aaronontheweb/dotnet-skills --skill database-performance

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

詳細

データベースのパフォーマンスパターン

このスキルを使う場面

次のような場合にこのスキルを使ってください:

  • データアクセス層を設計するとき
  • 遅いデータベースクエリを最適化するとき
  • EF Core と Dapper のどちらを選ぶか検討するとき
  • よくあるパフォーマンス上の落とし穴を避けるとき

基本原則

  1. 読み取りモデルと書き込みモデルを分ける - 両方に同じ型を使わないでください
  2. バッチで考える - N+1 クエリを避けてください
  3. 必要なものだけを取得する - SELECT * は使わないでください
  4. 行数に上限を設ける - 設定可能な Take/Limit を必ず指定してください
  5. SQL で結合する - アプリケーションコードで結合しないでください
  6. 読み取りには AsNoTracking を使う - EF Core の変更追跡はコストがかかります

読み取り/書き込みモデルの分離(CQRS パターン)

読み取りモデルと書き込みモデルは根本的に異なり、形状、列、目的も異なります。 単一の「User」エンティティを作り、あらゆる場面で再利用しないでください。

  • 読み取りモデルは非正規化され、クエリの効率性を高めるよう最適化されており、複数のプロジェクション型(UserProfile、UserSummary、UserDetailForAdmin)を返します
  • 書き込みモデルは正規化され、検証を重視しており、強く型付けされたコマンド(CreateUserCommand、UpdateUserCommand)を受け取ります
アーキテクチャ
src/
  MyApp.Data/
    Users/
      # Read side - multiple optimized projections
      IUserReadStore.cs
      PostgresUserReadStore.cs

      # Write side - command handlers
      IUserWriteStore.cs
      PostgresUserWriteStore.cs

      # Read DTOs - lightweight, denormalized
      UserProfile.cs
      UserSummary.cs

      # Write commands - validation-focused
      CreateUserCommand.cs
      UpdateUserCommand.cs
    Orders/
      IOrderReadStore.cs
      IOrderWriteStore.cs
      (similar structure...)
読み取りストアインターフェース
// Read models: Multiple specialized projections optimized for different use cases
public interface IUserReadStore
{
    // Returns detailed profile for single-user view
    Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default);

    // Returns lightweight info for lookups
    Task<UserProfile?> GetByEmailAsync(EmailAddress email, CancellationToken ct = default);

    // Returns paginated summaries - only what the list view needs
    Task<IReadOnlyList<UserSummary>> GetAllAsync(int limit, UserId? cursor = null, CancellationToken ct = default);

    // Boolean query - no entity needed
    Task<bool> EmailExistsAsync(EmailAddress email, CancellationToken ct = default);
}
書き込みストアインターフェース
// Write model: Accepts strongly-typed commands, minimal return values
public interface IUserWriteStore
{
    // Returns only the created ID - caller doesn't need the full entity
    Task<UserId> CreateAsync(CreateUserCommand command, CancellationToken ct = default);

    // Update validates command, returns void (success or throws)
    Task UpdateAsync(UserId id, UpdateUserCommand command, CancellationToken ct = default);

    // Delete is simple and explicit
    Task DeleteAsync(UserId id, CancellationToken ct = default);
}

主な構造上の違い:

  • 読み取りストアは複数の異なる DTO(UserProfile、UserSummary、bool フラグ)を返します
  • 書き込みストアは最小限のデータ(作成時は UserId のみ)または void を返します
  • 読み取りクエリはステートレスなプロジェクションであるため、追跡は不要です
  • 書き込み操作では、その後のデータ取得ではなく、コマンドの検証に重点を置きます
  • 読み取りと書き込みで異なるデータベース/テーブルを使用できます(結果整合性パターン)

常に行数に上限を設ける

上限のない結果セットを返さないでください。 すべての読み取りメソッドに設定可能な上限を設けてください。

パターン:Limit パラメーター
public interface IOrderReadStore
{
    // Limit is required, not optional
    Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
        CustomerId customerId,
        int limit,
        OrderId? cursor = null,
        CancellationToken ct = default);
}

// Implementation
public async Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
    CustomerId customerId,
    int limit,
    OrderId? cursor = null,
    CancellationToken ct = default)
{
    await using var connection = await _dataSource.OpenConnectionAsync(ct);

    const string sql = """
        SELECT id, customer_id, total, status, created_at
        FROM orders
        WHERE customer_id = @CustomerId
        AND (@Cursor IS NULL OR created_at < (SELECT created_at FROM orders WHERE id = @Cursor))
        ORDER BY created_at DESC
        LIMIT @Limit
        """;

    var rows = await connection.QueryAsync<OrderRow>(sql, new
    {
        CustomerId = customerId.Value,
        Cursor = cursor?.Value,
        Limit = limit
    });

    return rows.Select(r => r.ToOrderSummary()).ToList();
}
ページネーションを使った EF Core
public async Task<PaginatedList<OrderSummary>> GetOrdersAsync(
    CustomerId customerId,
    Paginator paginator,
    CancellationToken ct = default)
{
    var query = _context.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == customerId.Value)
        .OrderByDescending(o => o.CreatedAt);

    var totalCount = await query.CountAsync(ct);

    var orders = await query
        .Skip((paginator.PageNumber - 1) * paginator.PageSize)
        .Take(paginator.PageSize)  // Always limit!
        .Select(o => new OrderSummary(
            new OrderId(o.Id),
            o.Total,
            o.Status,
            o.CreatedAt))
        .ToListAsync(ct);

    return new PaginatedList<OrderSummary>(
        orders,
        totalCount,
        paginator.PageSize,
        paginator.PageNumber);
}

読み取りクエリでは AsNoTracking を使う

EF Core の変更追跡はコストがかかります。読み取り専用のクエリでは無効にしてください。

// DO: Disable tracking for reads
var users = await _context.Users
    .AsNoTracking()
    .Where(u => u.IsActive)
    .ToListAsync();

// DON'T: Track entities you won't modify
var users = await _context.Users
    .Where(u => u.IsActive)
    .ToListAsync();  // Change tracking enabled - wasteful
デフォルトの動作を設定する
// For read-heavy applications, consider this in DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}

その後、必要に応じて追跡を明示的に有効にします:

var user = await _context.Users
    .AsTracking()  // Explicit - we intend to modify
    .FirstOrDefaultAsync(u => u.Id == userId);

N+1 クエリを避ける

N+1 問題とは、リストを取得した後、各項目の関連データを個別にクエリすることです。

問題点
// BAD: N+1 queries
var orders = await _context.Orders.ToListAsync();

foreach (var order in orders)
{
    // Each iteration hits the database!
    var items = await _context.OrderItems
        .Where(i => i.OrderId == order.Id)
        .ToListAsync();
}
解決策 1:Include(EF Core)
// GOOD: Single query with join
var orders = await _context.Orders
    .AsNoTracking()
    .Include(o => o.Items)
    .ToListAsync();
解決策 2:バッチクエリ(Dapper)
// GOOD: Two queries, no N+1
const string sql = """
    SELECT id, customer_id, total FROM orders WHERE customer_id = @CustomerId;
    SELECT oi.* FROM order_items oi
    INNER JOIN orders o ON oi.order_id = o.id
    WHERE o.customer_id = @CustomerId;
    """;

using var multi = await connection.QueryMultipleAsync(sql, new { CustomerId = customerId });
var orders = (await multi.ReadAsync<OrderRow>()).ToList();
var items = (await multi.ReadAsync<OrderItemRow>()).ToList();

// Join in memory (acceptable - data already fetched)
foreach (var order in orders)
{
    order.Items = items.Where(i => i.OrderId == order.Id).ToList();
}

アプリケーション側で結合しない

結合は C# ではなく、SQL で行う必要があります。

// BAD: Application join - two queries, memory waste
var customers = await _context.Customers.ToListAsync();
var orders = await _context.Orders.ToListAsync();

var result = customers.Select(c => new
{
    Customer = c,
    Orders = orders.Where(o => o.CustomerId == c.Id).ToList()  // O(n*m) in memory!
});

// GOOD: SQL join - single query
var result = await _context.Customers
    .AsNoTracking()
    .Include(c => c.Orders)
    .ToListAsync();

// GOOD: Explicit join (Dapper)
const string sql = """
    SELECT c.id, c.name, COUNT(o.id) as order_count
    FROM customers c
    LEFT JOIN orders o ON c.id = o.customer_id
    GROUP BY c.id, c.name
    """;

カルテシアン爆発を避ける

複数の Include 呼び出しは、カルテシアン積を引き起こすことがあります。

// DANGEROUS: Can explode into millions of rows
var product = await _context.Products
    .Include(p => p.Reviews)      // 100 reviews
    .Include(p => p.Images)       // 20 images
    .Include(p => p.Categories)   // 5 categories
    .FirstOrDefaultAsync(p => p.Id == id);
// Result: 100 * 20 * 5 = 10,000 rows transferred!
解決策:分割クエリ
// GOOD: Multiple queries, no Cartesian explosion
var product = await _context.Products
    .AsSplitQuery()
    .Include(p => p.Reviews)
    .Include(p => p.Images)
    .Include(p => p.Categories)
    .FirstOrDefaultAsync(p => p.Id == id);
// Result: 4 separate queries, ~125 rows total
解決策:明示的なプロジェクション
// BEST: Only fetch what you need
var product = await _context.Products
    .AsNoTracking()
    .Where(p => p.Id == id)
    .Select(p => new ProductDetail(
        p.Id,
        p.Name,
        p.Description,
        p.Reviews.OrderByDescending(r => r.CreatedAt).Take(10).ToList(),
        p.Images.Take(5).ToList(),
        p.Categories.Select(c => c.Name).ToList()))
    .FirstOrDefaultAsync();

列のサイズを制限する

EF Core モデルで最大長を定義し、過大なデータを防いでください。

public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.Property(u => u.Email)
            .HasMaxLength(254)  // RFC 5321 limit
            .IsRequired();

        builder.Property(u => u.Name)
            .HasMaxLength(100)
            .IsRequired();

        builder.Property(u => u.Bio)
            .HasMaxLength(500);

        // For truly large content, use text type explicitly
        builder.Property(u => u.Notes)
            .HasColumnType("text");
    }
}

汎用リポジトリを作らない

汎用リポジトリはクエリの複雑さを隠し、最適化を難しくします。

// BAD: Generic repository
public interface IRepository<T>
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();  // No limit!
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);  // Can't optimize
}

// GOOD: Purpose-built read stores
public interface IOrderReadStore
{
    Task<OrderDetail?> GetByIdAsync(OrderId id, CancellationToken ct = default);
    Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(CustomerId id, int limit, CancellationToken ct = default);
    Task<IReadOnlyList<OrderSummary>> GetPendingAsync(int limit, CancellationToken ct = default);
}

汎用リポジトリの問題点:

  • 個別のクエリを最適化できません
  • 上限を強制する方法がありません
  • N+1 問題が隠れてしまいます
  • 過剰なデータを取得しやすくなります
  • データアクセスについて安易に考えるようになります

読み取り中心のワークロードには Dapper を使う

複雑な読み取りクエリでは、明示的な SQL を使う Dapper のほうが、すっきりして高速なことがよくあります。

public sealed class PostgresUserReadStore : IUserReadStore
{
    private readonly NpgsqlDataSource _dataSource;

    public PostgresUserReadStore(NpgsqlDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public async Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default)
    {
        await using var connection = await _dataSource.OpenConnectionAsync(ct);

        const string sql = """
            SELECT id, email, name, bio, created_at
            FROM users
            WHERE id = @Id
            """;

        var row = await connection.QuerySingleOrDefaultAsync<UserRow>(
            sql, new { Id = id.Value });

        return row?.ToUserProfile();
    }

    // Internal row type for Dapper mapping
    private sealed class UserRow
    {
        public Guid id { get; set; }
        public string email { get; set; } = null!;
        public string name { get; set; } = null!;
        public string? bio { get; set; }
        public DateTime created_at { get; set; }

        public UserProfile ToUserProfile() => new(
            Id: new UserId(id),
            Email: new EmailAddress(email),
            Name: new PersonName(name),
            Bio: bio,
            CreatedAt: new DateTimeOffset(created_at, TimeSpan.Zero));
    }
}

EF Core と Dapper の使い分け

シナリオ推奨
単純な CRUDEF Core
複雑な読み取りクエリDapper
検証を伴う書き込みEF Core
一括操作Dapper または生の SQL
レポート/分析Dapper
ドメイン中心の書き込みEF Core

同じプロジェクトで両方を使うこともできます。書き込みには EF Core、読み取りには Dapper を使います。


クイックリファレンス

アンチパターン解決策
行数の上限がないすべての読み取りメソッドに limit パラメーターを追加する
SELECT *必要な列だけを選択する
N+1 クエリInclude またはバッチクエリを使用する
アプリケーションでの結合SQL で結合する
カルテシアン爆発AsSplitQuery または射影を使用する
読み取り専用データの追跡AsNoTracking を使用する
汎用リポジトリ用途に特化した読み取り/書き込みストア
上限のない文字列モデルで MaxLength を設定する

リソース

コメント