name: database-performance description: 数据库性能访问模式。分离读写模型,避免N+1查询,使用AsNoTracking,应用行限制,绝不在应用层进行连接。适用于EF Core和Dapper。 invocable: false tags: [cqrs, performance, patterns]
数据库性能模式
何时使用此技能
在以下情况下使用此技能:
- 设计数据访问层
- 优化慢速数据库查询
- 在EF Core和Dapper之间选择
- 避免常见的性能陷阱
核心原则
- 分离读写模型 - 不要为两者使用相同的类型
- 批量思考 - 避免N+1查询
- 只检索所需内容 - 不要使用SELECT *
- 应用行限制 - 始终有可配置的Take/Limit
- 在SQL中进行连接 - 绝不在应用代码中
- 读取时使用AsNoTracking - EF Core变更跟踪开销大
读写模型分离(CQRS模式)
读写模型本质不同 - 它们具有不同的形状、列和用途。 不要创建单一的“用户”实体并在各处重复使用。
- 读取模型是反规范化的,针对查询效率优化,返回多种投影类型(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、布尔标志)
- 写入存储返回最小数据(创建时仅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)
// 正确:两个查询,无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#中。
// 错误:应用层连接 - 两个查询,内存浪费
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);
// 对于真正大的内容,显式使用文本类型
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 vs Dapper
| 场景 | 推荐 |
|---|---|
| 简单CRUD | EF Core |
| 复杂读取查询 | Dapper |
| 带验证的写入 | EF Core |
| 批量操作 | Dapper或原始SQL |
| 报告/分析 | Dapper |
| 领域密集的写入 | EF Core |
您可以在同一项目中使用两者 - EF Core用于写入,Dapper用于读取。
快速参考
| 反模式 | 解决方案 |
|---|---|
| 无行限制 | 为每个读取方法添加limit参数 |
| SELECT * | 仅投影所需列 |
| N+1查询 | 使用Include或批量查询 |
| 应用层连接 | 在SQL中进行连接 |
| 笛卡尔积爆炸 | 使用AsSplitQuery或投影 |
| 跟踪只读数据 | 使用AsNoTracking |
| 通用存储库 | 目的明确的读写存储 |
| 无界字符串 | 在模型中配置MaxLength |