关于 database-performance
面向使用 EF Core 和 Dapper 的开发者,介绍数据库访问设计与查询性能优化模式。重点包括读写模型分离、限制结果集,以及避免 N+1 查询和过量数据传输。
主要功能
- 01分离读模型与写模型
- 02为查询设置行数限制
- 03使用 AsNoTracking 进行只读查询
- 04通过批处理避免 N+1 查询
- 05在 SQL 中执行连接并精简投影
- 06按场景选择 EF Core 或 Dapper
适用场景
- 01设计数据访问层和读写存储
- 02排查慢查询与 N+1 问题
- 03优化分页和关联数据读取
- 04为复杂读取或批量操作选择工具
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ aaronontheweb/ dotnet-skills --skill database-performance安装后在 Claude Code 中输入/database-performance确认已加载。
详细介绍
数据库性能模式
何时使用此技能
在以下情况下使用此技能:
- 设计数据访问层
- 优化缓慢的数据库查询
- 在 EF Core 和 Dapper 之间进行选择
- 避免常见的性能陷阱
核心原则
- 分离读写模型 - 不要对两者使用相同的类型
- 考虑批量处理 - 避免 N+1 查询
- 只获取所需数据 - 不要使用 SELECT *
- 限制行数 - 始终设置可配置的 Take/Limit
- 在 SQL 中执行联接 - 绝不要在应用程序代码中执行
- 读取时使用 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
| 场景 | 建议 |
|---|---|
| 简单 CRUD | EF Core |
| 复杂读取查询 | Dapper |
| 带验证的写入 | EF Core |
| 批量操作 | Dapper 或原始 SQL |
| 报表/分析 | Dapper |
| 领域逻辑繁重的写入 | EF Core |
你可以在同一个项目中同时使用两者——EF Core 用于写入,Dapper 用于读取。
快速参考
| 反模式 | 解决方案 |
|---|---|
| 不限制行数 | 为每个读取方法添加 limit 参数 |
| SELECT * | 仅选择需要的列 |
| N+1 查询 | 使用 Include 或批量查询 |
| 在应用程序中联接 | 在 SQL 中执行联接 |
| 笛卡尔积爆炸 | 使用 AsSplitQuery 或投影 |
| 跟踪只读数据 | 使用 AsNoTracking |
| 通用存储库 | 使用专门的读写存储 |
| 字符串长度不受限 | 在模型中配置 MaxLength |
资源
评论
支持 Markdown