データベースパフォーマンスパターン
このスキルを使うタイミング
以下のような場合にこのスキルを活用してください:
- データアクセスレイヤーを設計するとき
- 遅いデータベースクエリを最適化するとき
- EF CoreとDapperのどちらを選ぶか検討するとき
- 一般的なパフォーマンスの落とし穴を避けたいとき
核となる原則
- 読み取りモデルと書き込みモデルを分離する - 両方に同じ型を使わない
- バッチで考える - N+1クエリを避ける
- 必要なものだけを取得する - SELECT * は禁止
- 行制限を適用する - 常に設定可能なTake/Limitを設ける
- 結合はSQLで行う - アプリケーションコードでは絶対に行わない
- 読み取りにはAsNoTrackingを使用する - EF Coreの変更追跡は高コスト
読み取り/書き込みモデルの分離(CQRSパターン)
読み取りモデルと書き込みモデルは根本的に異なります。形状、カラム、目的が異なるため、単一の「User」エンティティを作成してあらゆる場所で再利用してはいけません。
- 読み取りモデルは非正規化され、クエリ効率に最適化され、複数の射影型(UserProfile、UserSummary、UserDetailForAdminなど)を返します。
- 書き込みモデルは正規化され、バリデーションに重点を置き、強い型付けされたコマンド(CreateUserCommand、UpdateUserCommand)を受け取ります。
アーキテクチャ
src/
MyApp.Data/
Users/
# 読み取り側 - 複数の最適化された射影
IUserReadStore.cs
PostgresUserReadStore.cs
# 書き込み側 - コマンドハンドラ
IUserWriteStore.cs
PostgresUserWriteStore.cs
# 読み取りDTO - 軽量、非正規化
UserProfile.cs
UserSummary.cs
# 書き込みコマンド - バリデーション重視
CreateUserCommand.cs
UpdateUserCommand.cs
Orders/
IOrderReadStore.cs
IOrderWriteStore.cs
(同様の構造...)
読み取りストアインターフェース
// 読み取りモデル:異なるユースケースに最適化された複数の特殊な射影
public interface IUserReadStore
{
// 単一ユーザー表示用の詳細プロフィールを返す
Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default);
// ルックアップ用の軽量情報を返す
Task<UserProfile?> GetByEmailAsync(EmailAddress email, CancellationToken ct = default);
// ページネーションされたサマリーを返す - リストビューに必要なものだけ
Task<IReadOnlyList<UserSummary>> GetAllAsync(int limit, UserId? cursor = null, CancellationToken ct = default);
// ブールクエリ - エンティティは不要
Task<bool> EmailExistsAsync(EmailAddress email, CancellationToken ct = default);
}
書き込みストアインターフェース
// 書き込みモデル:強い型付けされたコマンドを受け取り、最小限の戻り値
public interface IUserWriteStore
{
// 作成されたIDのみを返す - 呼び出し側は完全なエンティティを必要としない
Task<UserId> CreateAsync(CreateUserCommand command, CancellationToken ct = default);
// 更新はコマンドを検証し、voidを返す(成功するか例外をスローする)
Task UpdateAsync(UserId id, UpdateUserCommand command, CancellationToken ct = default);
// 削除はシンプルで明示的
Task DeleteAsync(UserId id, CancellationToken ct = default);
}
構造上の重要な違いの説明:
- 読み取りストアは複数の異なるDTO(UserProfile、UserSummary、boolフラグなど)を返す
- 書き込みストアは最小限のデータ(作成時はUserIdのみ)またはvoidを返す
- 読み取りクエリはステートレスな射影であり、追跡は不要
- 書き込み操作はコマンドのバリデーションに焦点を当て、その後のデータ取得は行わない
- 異なるデータベース/テーブルが読み取りと書き込みをバックアップできる(結果整合性パターン)
常に行制限を適用する
無制限の結果セットを決して返してはいけません。 すべての読み取りメソッドには設定可能な制限が必要です。
パターン:制限パラメータ
public interface IOrderReadStore
{
// 制限は必須であり、オプションではない
Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
CustomerId customerId,
int limit,
OrderId? cursor = null,
CancellationToken ct = default);
}
// 実装
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) // 常に制限をかける!
.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の変更追跡は高コストです。読み取り専用クエリでは無効にしましょう。
// 推奨:読み取りでは追跡を無効にする
var users = await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();
// 非推奨:変更しないエンティティを追跡する
var users = await _context.Users
.Where(u => u.IsActive)
.ToListAsync(); // 変更追跡が有効 - 無駄が多い
デフォルト動作の設定
// 読み取りが多いアプリケーションでは、DbContextで以下を検討する
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
必要なときに明示的に追跡を有効にします:
var user = await _context.Users
.AsTracking() // 明示的 - 変更する意図がある
.FirstOrDefaultAsync(u => u.Id == userId);
N+1クエリを避ける
N+1問題:リストを取得した後、各アイテムの関連データを個別にクエリすること。
問題
// 悪い例:N+1クエリ
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// 各イテレーションでデータベースにアクセス!
var items = await _context.OrderItems
.Where(i => i.OrderId == order.Id)
.ToListAsync();
}
解決策1:Include(EF Core)
// 良い例:結合を使用した単一クエリ
var orders = await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.ToListAsync();
解決策2:バッチクエリ(Dapper)
// 良い例:2つのクエリ、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();
// メモリ内で結合(許容範囲 - データは既に取得済み)
foreach (var order in orders)
{
order.Items = items.Where(i => i.OrderId == order.Id).ToList();
}
アプリケーションサイドでの結合は絶対に行わない
結合はSQLで行い、C#では行わないでください。
// 悪い例:アプリケーション結合 - 2回のクエリ、メモリの無駄
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)!
});
// 良い例:SQL結合 - 単一クエリ
var result = await _context.Customers
.AsNoTracking()
.Include(c => c.Orders)
.ToListAsync();
// 良い例:明示的な結合(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呼び出しは、デカルト積を引き起こす可能性があります。
// 危険:数百万行に爆発する可能性
var product = await _context.Products
.Include(p => p.Reviews) // 100件のレビュー
.Include(p => p.Images) // 20枚の画像
.Include(p => p.Categories) // 5つのカテゴリ
.FirstOrDefaultAsync(p => p.Id == id);
// 結果:100 * 20 * 5 = 10,000行が転送される!
解決策:分割クエリ
// 良い例:複数のクエリ、カーテシアン爆発なし
var product = await _context.Products
.AsSplitQuery()
.Include(p => p.Reviews)
.Include(p => p.Images)
.Include(p => p.Categories)
.FirstOrDefaultAsync(p => p.Id == id);
// 結果:4つの個別クエリ、合計約125行
解決策:明示的な射影
// 最良の例:必要なものだけを取得する
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制限
.IsRequired();
builder.Property(u => u.Name)
.HasMaxLength(100)
.IsRequired();
builder.Property(u => u.Bio)
.HasMaxLength(500);
// 本当に大きなコンテンツの場合は、明示的にtext型を使用
builder.Property(u => u.Notes)
.HasColumnType("text");
}
}
汎用リポジトリは構築しない
汎用リポジトリはクエリの複雑さを隠蔽し、最適化を困難にします。
// 悪い例:汎用リポジトリ
public interface IRepository<T>
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync(); // 制限なし!
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate); // 最適化不可
}
// 良い例:目的特化型の読み取りストア
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();
}
// Dapperマッピング用の内部行型
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の使い分け
| シナリオ | 推奨 |
|---|---|
| シンプルなCRUD | EF Core |
| 複雑な読み取りクエリ | Dapper |
| バリデーションを含む書き込み | EF Core |
| 一括操作 | Dapperまたは生SQL |
| レポート/分析 | Dapper |
| ドメイン中心の書き込み | EF Core |
同じプロジェクトで両方を使用できます。EF Coreを書き込みに、Dapperを読み取りに使用します。
クイックリファレンス
| アンチパターン | 解決策 |
|---|---|
| 行制限なし | すべての読み取りメソッドにlimitパラメータを追加 |
| SELECT * | 必要なカラムのみを射影 |
| N+1クエリ | Includeまたはバッチクエリを使用 |
| アプリケーション結合 | SQLで結合を行う |
| カーテシアン爆発 | AsSplitQueryまたは射影を使用 |
| 読み取り専用データの追跡 | AsNoTrackingを使用 |
| 汎用リポジトリ | 目的特化型の読み取り/書き込みストア |
| 無制限の文字列 | モデルにMaxLengthを設定 |
リソース
- EF Coreパフォーマンス: https://learn.microsoft.com/en-us/ef/core/performance/
- Dapper: https://github.com/DapperLib/Dapper
- AsSplitQuery: https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries