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. 05在 SQL 中执行连接并精简投影
  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),或者不返回任何内容
  • 读取查询是无状态的投影——无需跟踪
  • 写入操作侧重于命令验证,而不是之后检索数据
  • 读写可以由不同的数据库/表支持(最终一致性模式)

始终限制行数

绝不要返回无界结果集。 每个读取方法都应具有可配置的限制。

模式:限制参数
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();
}

绝不在应用程序端执行联接

联接必须在 SQL 中执行,而不是在 C# 中执行。

// 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

资源

评论

支持 Markdown