本片文章介绍如何在 .NET Aspire 框架下集成主流数据库系统,包括 SQL Server、PostgreSQL、Redis 和 MongoDB。内容涵盖从 AppHost 资源声明、客户端连接配置,到使用 Entity Framework Core 进行数据建模与迁移管理的完整流程。此外,还介绍了连接池优化和读写分离等高级实践,帮助我们构建高性能、可扩展的数据访问层。
一、SQL Server 集成
在 .NET Aspire 中接入 SQL Server 通常包含两部分,AppHost 负责声明和启动数据库资源,业务项目使用客户端集成包获取连接字符串并配置 EF Core 或 SqlClient。首先在 AppHost 引用 Aspire.Hosting.SqlServer,通过 AddSqlServer 声明容器化的 SQL Server 并创建数据库,然后将其引用到 API 项目。代码如下:
csharp
// AppHost/Program.cs
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var sql = builder.AddSqlServer("sql")
.WithLifetime(ContainerLifetime.Persistent); // 保持容器数据不被重置
var db = sql.AddDatabase("catalogdb");
builder.AddProject<Projects.CatalogService>("catalog-api")
.WithReference(db) // 注入连接信息为环境变量与 Secret
.WaitFor(db);
builder.Build().Run();
上面的代码中,AddSqlServer 会启动一个 SQL Server 容器实例(默认使用最新的 SQL Server Linux 镜像),AddDatabase 会在该实例上创建名为 catalogdb 的数据库。WithLifetime(ContainerLifetime.Persistent) 确保容器数据在重启后依然保留,适合开发和测试场景。此外,通过 WithReference(db) 将数据库引用注入到 API 项目,Aspire 会自动将连接字符串注入到环境变量和应用配置中,供客户端项目使用。WaitFor(db) 确保应用启动前等待数据库容器就绪,避免启动竞速问题。
接着,我们在业务项目中安装 Aspire.Microsoft.Data.SqlClient 或 Aspire.Microsoft.EntityFrameworkCore.SqlServer。若使用 EF Core,可直接绑定 Aspire 注入的连接字符串,代码如下:
csharp
// CatalogService/Program.cs
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddSqlServerClient("catalogdb"); // 来自 Aspire.Microsoft.Data.SqlClient
builder.Services.AddDbContext<CatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("catalogdb"))
.EnableRetryOnFailure());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbCtx = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
dbCtx.Database.Migrate(); // 开发阶段自动迁移
}
app.MapGet("/products", async (CatalogDbContext db) => await db.Products.ToListAsync());
app.Run();
在上面代码中,AddSqlServerClient 方法会将连接字符串注入到配置中,供 EF Core 使用。通过这种方式,开发者可以轻松地管理数据库连接,而无需手动配置连接字符串。EnableRetryOnFailure 启用连接重试策略,提升稳定性,确保在临时网络问题或数据库不可用时,应用能够自动重试连接,减少因短暂故障导致的服务中断。
在应用启动时,通过作用域获取 CatalogDbContext 并调用 Database.Migrate() 可自动应用迁移。这一过程确保数据库结构与代码中的模型保持一致,避免因版本不匹配而导致的运行时错误。需要注意的是,这种自动迁移的方式仅建议在开发环境中使用,以避免在生产环境中因意外迁移而引发的潜在问题。在生产环境中,建议使用手动迁移的方式,以便更好地控制数据库的变更和版本管理。
通过上述方式,我们实现了 SQL Server 在 Aspire 框架下的集成,支持本地容器化开发和云端部署时的无缝切换。在开发阶段,容器化的 SQL Server 提供与生产环境一致的数据库体验,无需本地安装数据库软件。部署到云端时,只需修改连接字符串指向在线的 SQL Database 或其他托管服务,业务代码保持不变。另外,Aspire 自动注入的健康检查和重试策略确保连接的可靠性,WithLifetime(ContainerLifetime.Persistent) 在开发调试时保留容器数据,加快迭代速度。
二、PostgreSQL 集成
PostgreSQL 的用法与 SQL Server 类似,AppHost 引用 Aspire.Hosting.PostgreSQL,客户端项目引用 Aspire.Npgsql 或 Aspire.Npgsql.EntityFrameworkCore.PostgreSQL。下面示例使用 EF Core 方式:
csharp
// AppHost/Program.cs
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var pg = builder.AddPostgres("postgres", password: "Pass@123")
.WithLifetime(ContainerLifetime.Persistent);
var db = pg.AddDatabase("inventorydb");
builder.AddProject<Projects.InventoryService>("inventory-api")
.WithReference(db)
.WaitFor(db);
builder.Build().Run();
上面的代码启动一个 PostgreSQL 容器实例,创建名为 inventorydb 的数据库,并将其引用注入到 InventoryService 项目中。password 参数用于设置 PostgreSQL 超级用户(通常为 postgres)的密码,确保容器启动时进行身份验证。WithLifetime(ContainerLifetime.Persistent) 确保容器数据在重启后依然保留,适合开发和测试场景。通过 WithReference(db) 将数据库引用注入到 API 项目,Aspire 会自动将连接字符串、用户名和密码等信息注入到环境变量和应用配置中,供客户端项目使用。WaitFor(db) 确保应用启动前等待 PostgreSQL 容器就绪,避免启动竞速问题。这种方式使得开发者无需手动配置数据库连接信息,Aspire 会自动处理所有必要的配置细节。
接着在业务项目中安装 Aspire.Npgsql.EntityFrameworkCore.PostgreSQL 包,并使用如下代码配置 EF Core:
csharp
// InventoryService/Program.cs
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddNpgsqlDataSource("inventorydb"); // 来自 Aspire.Npgsql
builder.Services.AddDbContext<InventoryDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("inventorydb"))
.EnableRetryOnFailure());
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbCtx = scope.ServiceProvider.GetRequiredService<InventoryDbContext>();
dbCtx.Database.Migrate();
}
app.MapGet("/items", async (InventoryDbContext db) => await db.Items.ToListAsync());
app.Run();
上面代码中,AddNpgsqlDataSource 方法会将连接字符串注入到配置中,供 EF Core 使用。通过这种方式,开发者可以轻松地管理数据库连接,而无需手动配置连接字符串。EnableRetryOnFailure 启用连接重试策略,提升稳定性,确保在临时网络问题或数据库不可用时,应用能够自动重试连接,减少因短暂故障导致的服务中断。在应用启动时,通过作用域获取 InventoryDbContext 并调用 Database.Migrate() 可自动应用迁移,确保数据库结构与代码中的模型保持一致,避免因版本不匹配而导致的运行时错误。需要注意的是,这种自动迁移的方式仅建议在开发环境中使用,以避免在生产环境中因意外迁移而引发的潜在问题。在生产环境中,建议使用手动迁移的方式,以便更好地控制数据库的变更和版本管理。
通过上述方式,我们实现了 PostgreSQL 在 Aspire 框架下的集成,支持本地容器化开发和云端部署时的无缝切换。在开发阶段,容器化的 PostgreSQL 提供与生产环境一致的数据库体验,无需本地安装数据库软件。部署到云端时,只需修改连接字符串指向在线的托管 PostgreSQL 服务(如 Azure Database for PostgreSQL 或 Amazon RDS),业务代码保持不变。另外,Aspire 自动注入的健康检查和重试策略确保连接的可靠性,WithLifetime(ContainerLifetime.Persistent) 在开发调试时保留容器数据,加快迭代速度。
三、使用 Entity Framework Core
EF Core 依旧遵循模型、上下文、迁移的基本模式。在 Aspire 中,推荐在客户端项目引用 Aspire.*.EntityFrameworkCore.* 包,以便自动接收连接字符串、重试策略和健康检查。相比手动配置,Aspire 集成提供开箱即用的服务注册、连接验证和性能监控,简化了数据访问层的初始化流程。代码示例如下:
csharp
// Domain/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
// Infrastructure/CatalogDbContext.cs
using Microsoft.EntityFrameworkCore;
public class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
// Program.cs 片段
builder.Services.AddDbContext<CatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("catalogdb"))
.EnableRetryOnFailure());
当需要高性能场景,可将 AddDbContext 替换为 AddDbContextPool 或 AddPooledDbContextFactory,以复用上下文实例减少分配。AddDbContextPool 适合请求级别的上下文复用,而 AddPooledDbContextFactory 则适合在同一请求中创建多个短生命周期的上下文。代码如下:
csharp
// 方式一:DbContext 池化
builder.Services.AddDbContextPool<CatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("catalogdb"))
.EnableRetryOnFailure(),
poolSize: 128); // 池大小,默认为 128
// 方式二:工厂模式,适合后台任务或批量操作
builder.Services.AddPooledDbContextFactory<CatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("catalogdb"))
.EnableRetryOnFailure());
// 使用工厂创建上下文
app.MapPost("/batch-import", async (IDbContextFactory<CatalogDbContext> factory, IEnumerable<Product> products) =>
{
using var context = factory.CreateDbContext();
context.Products.AddRange(products);
await context.SaveChangesAsync();
return Results.Ok();
});
上面的代码展示了两种不同的上下文复用方式,帮助提升应用的性能和资源利用率。AddDbContextPool 通过内部池机制复用 DbContext 实例,减少频繁创建和销毁的开销,适合请求驱动的 Web 应用。AddPooledDbContextFactory 则提供更灵活的工厂模式,允许在同一请求或后台任务中创建多个独立的短生命周期上下文,常用于批量导入、数据同步等场景。两者均兼容 Aspire 注入的连接字符串和健康检查,只需在 opts.UseSqlServer() 或 opts.UseNpgsql() 时指定相同的连接源即可。在高并发场景下,调整 poolSize 参数使其略大于单机 CPU 核心数(通常 2~4 倍)可获得最佳性能。过小会导致竞争,过大则浪费内存。
四、数据库迁移管理
开发阶段使用 dotnet ef 命令生成和应用迁移,生产环境可用迁移 Bundle 将迁移逻辑封装成可执行文件,避免在目标环境安装 SDK。常见流程如下:
-
生成迁移 :使用以下命令创建初始迁移,命名为
InitSchema,并指定项目和启动项目:bashdotnet ef migrations add InitSchema -p CatalogService -s CatalogService -
应用迁移 :将迁移应用到数据库,确保数据库结构与代码中的模型保持一致:
bashdotnet ef database update -p CatalogService -s CatalogService -
生成迁移 Bundle :为了便于 CI/CD 或离线执行,可以生成迁移 Bundle,将迁移逻辑封装成可执行文件:
bashdotnet ef migrations bundle -p CatalogService -s CatalogService -o efbundle.exe -
执行迁移 Bundle :在目标环境中运行生成的可执行文件,连接到生产数据库:
bash./efbundle.exe --connection "Server=tcp:prod-sql;Database=catalogdb;User Id=app;Password=***"
如果希望应用启动时自动迁移,可以在 Program.cs 中创建作用域并调用 Database.Migrate(),如前文所示。需要注意的是,在生产环境中应控制自动迁移的开关,以避免在多实例或零停机发布时产生锁竞争。可以通过配置开关或仅在后台 Job 中执行迁移来实现更好的控制,确保数据库的稳定性和一致性。
五、连接池配置
SqlClient 和 Npgsql 默认开启连接池,通常无需额外代码,只需在连接字符串设置大小与超时。连接池通过复用数据库连接减少频繁的连接建立与关闭开销,显著提升应用吞吐量和响应时间。以下是 SQL Server 和 PostgreSQL 的连接池配置示例:
json
// SQL Server 连接字符串示例
"ConnectionStrings": {
"catalogdb": "Server=localhost;Database=catalogdb;User Id=sa;Password=Pass@123;Max Pool Size=200;Min Pool Size=5;Connect Timeout=15;"
}
// PostgreSQL 连接字符串示例
"ConnectionStrings": {
"inventorydb": "Host=localhost;Database=inventorydb;Username=postgres;Password=Pass@123;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=200;Connection Idle Lifetime=60;"
}
在连接字符串中,Max Pool Size 或 Maximum Pool Size 定义连接池的最大连接数,建议设为 CPU 核心数的 2~4 倍。Min Pool Size 或 Minimum Pool Size 定义最小预热连接数,可加快初始请求响应。Connect Timeout 定义连接建立超时时间(单位秒),防止长期等待。Connection Idle Lifetime 定义空闲连接的回收时间,避免僵尸连接占用资源。
在 EF Core 中可使用 AddDbContextPool 进一步减少 DbContext 分配开销。DbContext 池化在请求级别复用上下文实例,无需每次都创建新对象,提升高并发场景的性能:
csharp
// 方式一:DbContext 池化(推荐用于 Web API)
builder.Services.AddDbContextPool<CatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("catalogdb"))
.EnableRetryOnFailure(),
poolSize: 128); // 池大小通常与连接池保持一致或略小
当需要在后台任务、批量操作或高并发场景中快速获取上下文,可改用 AddPooledDbContextFactory,通过工厂模式创建短生命周期的上下文实例。这种方式适合在同一请求中需要多个独立上下文的场景:
csharp
// 方式二:DbContext 工厂模式(推荐用于后台任务或批量操作)
builder.Services.AddPooledDbContextFactory<CatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("catalogdb"))
.EnableRetryOnFailure());
// 使用示例:批量导入产品
app.MapPost("/batch-import", async (IDbContextFactory<CatalogDbContext> factory, IEnumerable<Product> products) =>
{
using var context = factory.CreateDbContext();
context.Products.AddRange(products);
await context.SaveChangesAsync();
return Results.Ok("Import completed");
});
选择合适的池化策略是性能优化的关键,AddDbContextPool 适合请求驱动的 Web 应用(如 REST API),其内部实现自动处理上下文的复用和清理。AddPooledDbContextFactory 则为工厂模式,适合需要更细粒度控制的场景,如后台任务、流式处理或需要多个独立事务的操作。在配置池大小时,建议初始值为 CPU 核心数,然后根据并发压力测试结果逐步调整,以在吞吐量和内存占用之间找到最优平衡。
六、读写分离配置
Aspire 不直接替代数据库层读写分离,但可通过多连接字符串和依赖注入分层实现灵活的读写分离架构。常见做法是为写库(主库)与只读副本分别配置连接字符串,按场景注入不同的 DbContext,确保读操作指向副本、写操作指向主库,从而均衡数据库负载。
6.1 配置多个连接字符串
首先在配置文件中为主库和只读副本分别配置连接字符串:
json
{
"ConnectionStrings": {
"CatalogWrite": "Server=primary-sql;Database=catalogdb;User Id=sa;Password=***;Max Pool Size=100;",
"CatalogRead": "Server=replica-sql;Database=catalogdb;User Id=sa;Password=***;Max Pool Size=200;"
}
}
其中 CatalogWrite 指向主库(支持读写),CatalogRead 指向只读副本(仅支持查询)。在 Aspire AppHost 中,可通过 WithConnectionString 为不同环境注入相应的连接地址:
csharp
// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);
var primaryDb = builder.AddSqlServer("sql-primary")
.WithLifetime(ContainerLifetime.Persistent)
.AddDatabase("catalogdb");
// 可选:添加只读副本或使用云端只读端点
var replicaDb = builder.AddConnectionString("sql-replica", "Server=replica-host;...");
builder.AddProject<Projects.CatalogService>("catalog-api")
.WithReference(primaryDb)
.WithConnectionString("CatalogRead", replicaDb); // 注入读库连接
builder.Build().Run();
在上面的代码中,通过 WithReference 将主库注入到 CatalogService 项目,使业务项目能够获取主库的连接信息。对于只读副本,我们使用 WithConnectionString 方法为应用注入额外的连接字符串。WithConnectionString 方法允许我们为特定的配置键(如 CatalogRead)注入自定义的连接地址,这对于实现读写分离至关重要。在本地开发环境中,可以模拟只读副本为另一个容器化的 SQL Server 实例,或者直接指向同一主库(此时读写操作都会连接到主库,仅在代码层面区分)。在生产环境部署时,只需修改配置文件中的连接字符串,使 CatalogRead 指向真实的只读副本或数据库厂商提供的只读端点,业务代码保持完全不变。
这种方式的优势在于,开发团队可以在本地通过容器化环境完全模拟生产的读写分离架构,验证代码的正确性,而无需等待真实的云端副本资源。同时,Aspire 框架会自动处理这些连接字符串的注入、环境变量的设置和健康检查的配置,我们只需在 Program.cs 中声明依赖关系,框架会负责所有的连接管理细节。当应用部署到云端时,只需在配置文件或 Aspire 清单中更新连接字符串的值,无需修改任何业务代码,即可无缝切换到真实的云端数据库副本。这样的设计实现了基础设施即代码(Infrastructure as Code)的理念,使得环境间的切换变得简洁而安全。
6.2 为读写创建不同的 DbContext
为主库创建可读写的 CatalogDbContext,为副本创建只读的 ReadOnlyCatalogDbContext:
csharp
// Infrastructure/CatalogDbContext.cs(主库,支持读写)
using Microsoft.EntityFrameworkCore;
public class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
}
// Infrastructure/ReadOnlyCatalogDbContext.cs(副本,仅支持查询)
public class ReadOnlyCatalogDbContext(DbContextOptions<ReadOnlyCatalogDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 禁用跟踪,提升查询性能
modelBuilder.HasDefaultQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
在上面的代码中,我们定义了两个不同的 DbContext 类。CatalogDbContext 用于与主库交互,支持读写操作,而 ReadOnlyCatalogDbContext 专门用于只读副本,禁用了实体跟踪以提升查询性能。通过这种方式,我们可以在业务逻辑中明确区分读写操作,确保读操作不会意外修改数据。
6.3 在 DI 中注册两个 DbContext
在 Program.cs 中分别注册两个 DbContext,使用不同的连接字符串:
csharp
// CatalogService/Program.cs
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// 注册主库(用于写入)
builder.AddSqlServerClient("CatalogWrite");
builder.Services.AddDbContext<CatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("CatalogWrite"))
.EnableRetryOnFailure());
// 注册只读副本(用于查询)
builder.AddSqlServerClient("CatalogRead");
builder.Services.AddDbContext<ReadOnlyCatalogDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("CatalogRead"))
.EnableRetryOnFailure()
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
var app = builder.Build();
// 数据库迁移仅在主库执行
using (var scope = app.Services.CreateScope())
{
var writeDb = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
writeDb.Database.Migrate();
}
app.Run();
在上面的代码中,我们分别为主库和只读副本注册了两个 DbContext。CatalogDbContext 使用 CatalogWrite 连接字符串,支持读写操作,而 ReadOnlyCatalogDbContext 使用 CatalogRead 连接字符串,仅用于查询操作,并禁用了实体跟踪以提升性能。通过这种方式,我们确保了读写操作的明确分离,避免了潜在的数据一致性问题。
6.4 在业务层按场景选择 DbContext
在 API 端点或服务层中,根据操作类型注入相应的 DbContext:
csharp
// 查询操作:使用只读副本
app.MapGet("/products", async (ReadOnlyCatalogDbContext db) =>
await db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync());
// 写入操作:使用主库
app.MapPost("/products", async (CatalogDbContext db, CreateProductRequest req) =>
{
var product = new Product { Name = req.Name, Price = req.Price };
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);
});
// 复杂操作:先写主库,再查询副本
app.MapPut("/products/{id}", async (int id, UpdateProductRequest req,
CatalogDbContext writeDb, ReadOnlyCatalogDbContext readDb) =>
{
var product = await writeDb.Products.FindAsync(id);
if (product == null) return Results.NotFound();
product.Name = req.Name;
product.Price = req.Price;
await writeDb.SaveChangesAsync();
// 为确保读取最新数据,可选择再次查询主库或加入延迟
// 若副本延迟明显,建议在业务层添加一致性检查
return Results.Ok();
});
在上面的代码中,我们根据操作类型选择了不同的 DbContext。对于查询操作,我们使用 ReadOnlyCatalogDbContext,确保所有读取操作都指向只读副本,从而减轻主库的负载。这种设计不仅提高了查询性能,还能有效避免对主库的过度访问,降低了潜在的性能瓶颈。
对于写入操作,我们使用 CatalogDbContext,确保所有数据修改都发生在主库上。这种方式保证了数据的一致性和完整性,避免了因并发写入导致的数据冲突问题。
在处理复杂操作时,例如更新后需要查询最新数据,我们可以先在主库执行写入操作,然后根据业务需求决定是否从副本读取最新数据,或者直接从主库读取以确保一致性。如果对数据一致性要求较高,建议直接从主库读取,以确保获取到最新的状态。而在对一致性要求不那么严格的场景下,可以选择从副本读取,以提高系统的整体性能和响应速度。
这种灵活的 DbContext 选择策略使得我们能够在不同的业务场景中,合理地平衡性能与一致性,确保系统在高并发情况下依然能够稳定运行。
6.5 处理副本延迟问题
由于副本可能存在复制延迟,若业务要求立即读取写入的数据,需在应用层添加一致性保障:
csharp
// 写入后立即查询主库(强一致性)
app.MapPost("/products-strong-consistent", async (CatalogDbContext db, CreateProductRequest req) =>
{
var product = new Product { Name = req.Name, Price = req.Price };
db.Products.Add(product);
await db.SaveChangesAsync();
// 写入后从主库读取,确保最新数据
return Results.Created($"/products/{product.Id}", product);
});
// 延迟重试(最终一致性)
app.MapGet("/products/{id}/retry", async (int id, ReadOnlyCatalogDbContext readDb, CatalogDbContext writeDb) =>
{
var product = await readDb.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
if (product == null)
{
// 副本可能未同步,从主库重试
await Task.Delay(100);
product = await readDb.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
}
return product ?? Results.NotFound();
});
在上面的代码中,我们展示了两种处理副本延迟的策略。第一种策略是写入后立即从主库读取数据,以确保强一致性。这种方法适用于对数据一致性要求较高的场景,例如金融交易或用户注册等关键操作。在这些情况下,确保用户能够立即看到最新的数据是至关重要的,因此我们选择从主库读取数据,以避免因副本延迟而导致的信息不一致。
第二种策略则是通过延迟重试的方式,从只读副本查询数据。如果在第一次查询时未找到所需的数据,系统会等待一段时间后再次尝试查询。这种方法适用于最终一致性的场景,例如商品库存查询或用户评论等非关键操作。在这些情况下,稍微的延迟是可以接受的,且通过这种方式可以减轻主库的负担,提高系统的整体性能。
这两种策略可以根据具体业务需求灵活选择,以平衡性能和一致性。在设计系统时,开发团队应仔细评估各个操作的业务重要性,并选择最合适的策略来满足用户的期望和系统的性能要求。
6. 云端只读端点集成
在部署到云端时,利用数据库厂商提供的只读端点更新连接字符串即可。以 Azure SQL Database 为例,Azure 原生支持读取副本,通过在连接字符串中添加 ApplicationIntent=ReadOnly 参数即可将查询路由到只读端点:
csharp
// appsettings.Production.json
{
"ConnectionStrings": {
"CatalogWrite": "Server=tcp:prod-primary.database.windows.net;Database=catalogdb;User Id=appuser;Password=***;Encrypt=true;Connection Timeout=30;",
"CatalogRead": "Server=tcp:prod-primary.database.windows.net;Database=catalogdb;User Id=appuser;Password=***;ApplicationIntent=ReadOnly;Encrypt=true;Connection Timeout=30;"
}
}
对于 Amazon RDS,可直接使用 RDS 提供的只读实例端点(Reader Endpoint):
csharp
{
"ConnectionStrings": {
"CatalogWrite": "Server=prod-primary.c9akciq32.us-east-1.rds.amazonaws.com;Database=catalogdb;User Id=admin;Password=***;",
"CatalogRead": "Server=prod-primary.c9akciq32.us-east-1.rds.amazonaws.com;Database=catalogdb;User Id=readonly;Password=***;"
}
}
对于自建或其他托管 PostgreSQL 服务,可配置专用的只读副本服务器地址:
csharp
{
"ConnectionStrings": {
"CatalogWrite": "Host=prod-primary.example.com;Database=catalogdb;Username=appuser;Password=***;",
"CatalogRead": "Host=prod-replica.example.com;Database=catalogdb;Username=readonly;Password=***;Pooling=true;Maximum Pool Size=200;"
}
}
通过 Aspire 注入的多连接字符串,无需修改业务代码即可在本地容器和云端资源间切换。应用启动时会自动根据环境配置加载对应的连接字符串,CatalogDbContext 连接主库,ReadOnlyCatalogDbContext 连接只读副本,确保读写操作的自动分离。这种方式在扩展应用吞吐量和降低数据库负载时特别有效,特别是在面对高并发查询场景时。同时,云端只读副本通常由数据库厂商负责维护和同步,无需人工干预,进一步降低了运维复杂度。
七、Redis 缓存集成
7.1 基础集成与分布式缓存
Aspire 提供托管和客户端集成,支持本地容器 Redis、Valkey 或 Azure Cache for Redis。AppHost 中添加 Redis 资源,业务项目引用 Aspire.StackExchange.Redis.DistributedCaching,即可获得分布式缓存服务:
csharp
// AppHost/Program.cs
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache")
.WithLifetime(ContainerLifetime.Persistent); // 保持缓存数据
builder.AddProject<Projects.Web>("web")
.WithReference(cache)
.WaitFor(cache);
builder.Build().Run();
在上面的代码中,AddRedis 方法启动一个 Redis 容器实例,并将其引用注入到 Web 项目中。WithLifetime(ContainerLifetime.Persistent) 确保容器数据在重启后依然保留,适合开发和测试场景。通过 WithReference(cache) 将 Redis 引用注入到 Web 项目,Aspire 会自动将连接字符串注入到环境变量和应用配置中,供客户端项目使用。WaitFor(cache) 确保应用启动前等待 Redis 容器就绪,避免启动竞速问题。这种方式使得开发者无需手动配置 Redis 连接信息,Aspire 会自动处理所有必要的配置细节。
在业务项目中安装 Aspire.StackExchange.Redis.DistributedCaching 并配置分布式缓存:
csharp
// Web/Program.cs
using Microsoft.Extensions.Caching.Distributed;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddRedisDistributedCache("cache"); // 自动注入连接字符串和 IDistributedCache
var app = builder.Build();
// 基础缓存示例
app.MapGet("/cache", async (IDistributedCache cache) =>
{
const string cacheKey = "hello";
// 先从缓存读取
var cachedValue = await cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cachedValue))
{
return Results.Ok(new { value = cachedValue, source = "cache" });
}
// 缓存未命中,从数据源获取并写入缓存
var value = "world";
await cache.SetStringAsync(cacheKey, value, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
return Results.Ok(new { value, source = "source" });
});
app.Run();
上面代码展示了如何在业务项目中使用分布式缓存。通过 AddRedisDistributedCache("cache") 方法,Aspire 会自动注入 Redis 连接字符串,并注册 IDistributedCache 服务,供应用使用。在示例中,我们尝试从缓存中读取键为 hello 的值,如果缓存命中则返回缓存值,否则从数据源获取值并将其存入缓存,设置了 5 分钟的绝对过期时间。
7.2 输出缓存集成
若需实现 HTTP 响应级别的输出缓存,可改用 Aspire.StackExchange.Redis.OutputCaching 包,在管道中调用 UseOutputCache 中间件,自动缓存整个响应:
csharp
// Web/Program.cs
using Microsoft.AspNetCore.OutputCaching;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddRedisDistributedCache("cache");
builder.Services.AddOutputCache(opts =>
{
// 缓存 GET /products 的响应,缓存时间 10 分钟
opts.AddBasePolicy(builder =>
builder.Expire(TimeSpan.FromMinutes(10))
.Tag("products"));
// 为特定路由配置不同的缓存策略
opts.AddPolicy("product-list", builder =>
builder.Expire(TimeSpan.FromMinutes(10))
.SetVaryByQueryKeys("pageSize", "pageNumber")
.Tag("products"));
});
var app = builder.Build();
app.UseOutputCache(); // 启用输出缓存中间件
// 应用输出缓存策略
app.MapGet("/products", async (ProductService service) =>
await service.GetProductsAsync())
.CacheOutput("product-list");
// 清除特定标签的缓存
app.MapPost("/products/clear-cache", async (IOutputCacheStore cacheStore) =>
{
await cacheStore.EvictByTagAsync("products", CancellationToken.None);
return Results.Ok("Cache cleared");
});
app.Run();
在代码中我们通过 AddOutputCache 方法配置了输出缓存策略。UseOutputCache 中间件启用输出缓存功能,允许我们缓存整个 HTTP 响应。通过 CacheOutput("product-list") 为 /products 路由应用了名为 product-list 的缓存策略,设置了 10 分钟的过期时间,并根据查询参数 pageSize 和 pageNumber 进行变体缓存。这样,不同的分页请求会有独立的缓存条目。
7.3 缓存策略与过期管理
在分布式缓存中使用合理的过期策略可以在命中率与内存占用间取得平衡。DistributedCacheEntryOptions 提供多种过期方式:
csharp
// 绝对过期:从写入时起固定时间后过期
var absoluteExpiry = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
};
// 滑动过期:最后一次访问后的相对时间过期
var slidingExpiry = new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10) // 10 分钟不访问则过期
};
// 绝对 + 滑动组合:在绝对过期前,支持滑动延长
var combined = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
SlidingExpiration = TimeSpan.FromMinutes(10)
};
app.MapPost("/cache-user/{userId}", async (int userId, IDistributedCache cache) =>
{
var user = new { Id = userId, Name = $"User{userId}", CreatedAt = DateTime.UtcNow };
var json = System.Text.Json.JsonSerializer.Serialize(user);
await cache.SetStringAsync($"user:{userId}", json, combined);
return Results.Ok(user);
});
在上面的代码中,我们展示了三种常用的缓存过期策略。绝对过期(AbsoluteExpirationRelativeToNow)确保缓存项在指定时间后过期,无论是否被访问。滑动过期(SlidingExpiration)则根据最后一次访问时间动态延长缓存寿命,适合热点数据。组合使用两者可以在保证数据不过期的同时,提升缓存命中率。
绝对过期策略适用于具有明确时效性的数据,例如库存快照、汇率信息或临时验证码。一旦超过设定的时间窗口,缓存自动失效,保证数据新鲜度。这种策略简单直观,易于预测内存占用,特别适合数据更新频繁或对一致性要求较高的场景。例如商品价格缓存通常采用 30 分钟绝对过期,确保用户看到的价格不会过时。
滑动过期策略则更适合频繁访问的热点数据,如用户会话、热门商品详情或推荐列表。每次访问都会重置过期计时器,只要数据持续被使用,就会一直保留在缓存中。这大幅减少了频繁重新加载的成本,提升了系统整体性能。但需要注意的是,如果缓存内存有限,长期未清理的陈旧数据可能占用宝贵的存储空间。
组合策略在实际应用中最为常见和实用。例如用户信息缓存可设置 1 小时绝对过期和 10 分钟滑动过期:如果用户活跃,每次访问都会重置滑动计时器,缓存可保留长达 1 小时。若用户长期不活跃,即使有零散访问,也会在绝对时间到期后清除。这种方式兼顾了性能和内存管理。
在实际应用中,应根据业务特性选择策略。对于用户认证信息、订单状态等重要数据,建议采用较短的绝对过期时间(如 15-30 分钟)以确保数据安全。对于商品描述、分类列表等更新不频繁的数据,可使用较长的滑动过期(如 1-2 小时),确保高并发查询时的缓存命中率。结合监控缓存命中率和 Redis 内存占用,可进一步优化过期策略。
7.4 缓存预热与主动失效
在应用启动时预热热点数据可以加快初始请求响应。同时,在数据更新时主动清除相关缓存确保数据一致性:
csharp
// 应用启动时预热缓存
app.MapPost("/products", async (CreateProductRequest req, ProductService service, IDistributedCache cache) =>
{
var product = await service.CreateProductAsync(req);
// 主动失效缓存,确保下次查询获取最新数据
await cache.RemoveAsync("products:list");
await cache.RemoveAsync($"product:{product.Id}");
return Results.Created($"/products/{product.Id}", product);
});
// 批量清除缓存
app.MapDelete("/cache/invalidate", async (IDistributedCache cache) =>
{
var keysToInvalidate = new[]
{
"products:list",
"categories:list",
"featured:products"
};
foreach (var key in keysToInvalidate)
{
await cache.RemoveAsync(key);
}
return Results.Ok("Cache invalidated");
});
// 应用启动时预热常用数据
using (var scope = app.Services.CreateScope())
{
var cache = scope.ServiceProvider.GetRequiredService<IDistributedCache>();
var service = scope.ServiceProvider.GetRequiredService<ProductService>();
var products = await service.GetFeaturedProductsAsync();
var json = System.Text.Json.JsonSerializer.Serialize(products);
await cache.SetStringAsync("featured:products", json,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
}
在上面的代码中,我们展示了如何在应用启动时预热缓存和在数据更新时主动失效缓存。通过在创建新产品后调用 cache.RemoveAsync 方法,我们确保相关的缓存项被清除,避免用户获取到过时的数据。此外,我们还提供了一个批量清除缓存的端点,允许管理员或系统在需要时手动清除特定的缓存项。
在缓存预热方面,应用启动时将热点数据提前加载到 Redis,可以显著降低初始请求的延迟。对于商品列表、分类信息等高频访问的数据,预热能够在应用上线的瞬间就提供最佳的响应性能,避免冷启动导致的大量数据库查询。预热策略应基于业务优先级和数据大小合理选择,过度预热会增加启动时间和内存占用。
在主动失效方面,数据修改后立即清除相关缓存确保最终一致性。例如创建、更新或删除产品时,不仅要修改数据源,还应清除该产品的单项缓存和相关列表缓存。为避免缓存与数据库的脏读,可使用版本号或时间戳标记缓存,在更新时递增版本或更新时间戳,客户端据此判断数据新鲜度。
在高并发场景中,可结合事件驱动架构实现缓存失效的异步处理。通过发布缓存失效事件,订阅者可在后台批量清除相关缓存,避免同步操作阻塞主流程。这种方式特别适合跨多个缓存键的复杂场景,如订单确认后需同时清除库存缓存、推荐列表缓存和用户购物车缓存等。
此外,定期的缓存刷新(例如每小时或每日)可作为主动失效的补充策略,防止因程序异常导致的缓存不一致。监控缓存命中率、未命中率和 Redis 内存占用,有助于及时发现缓存策略的问题,并根据实际情况调整预热范围和过期时间。
7.5 缓存穿透与击穿防护
缓存穿透(查询不存在的数据)和击穿(热点数据失效)会导致大量请求打到数据源。以下示例展示几种防护策略:
csharp
// 防穿透:缓存空值
app.MapGet("/products/{id}", async (int id, ProductService service, IDistributedCache cache) =>
{
const string nullMarker = "null"; // 标记不存在的数据
const string cacheKeyPrefix = "product:";
var cacheKey = $"{cacheKeyPrefix}{id}";
var cached = await cache.GetStringAsync(cacheKey);
if (cached == nullMarker) return Results.NotFound();
if (!string.IsNullOrEmpty(cached))
{
var product = System.Text.Json.JsonSerializer.Deserialize<Product>(cached);
return Results.Ok(product);
}
// 缓存未命中,查询数据源
var dbProduct = await service.GetProductAsync(id);
if (dbProduct == null)
{
// 缓存空值,防止下次继续查询数据源
await cache.SetStringAsync(cacheKey, nullMarker,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) // 空值缓存时间较短
});
return Results.NotFound();
}
// 缓存真实数据
var json = System.Text.Json.JsonSerializer.Serialize(dbProduct);
await cache.SetStringAsync(cacheKey, json,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
});
return Results.Ok(dbProduct);
});
// 防击穿:热点数据续期或使用本地锁
var lockDict = new System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim>();
app.MapGet("/hot-products", async (ProductService service, IDistributedCache cache) =>
{
const string cacheKey = "hot:products";
var cached = await cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
return System.Text.Json.JsonSerializer.Deserialize<List<Product>>(cached);
}
// 获取本地锁,防止多个请求同时回源
var semaphore = lockDict.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
if (await semaphore.WaitAsync(TimeSpan.FromSeconds(2)))
{
try
{
// 双检查,防止重复加载
cached = await cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
return System.Text.Json.JsonSerializer.Deserialize<List<Product>>(cached);
}
// 回源查询
var products = await service.GetHotProductsAsync();
var json = System.Text.Json.JsonSerializer.Serialize(products);
// 热点数据缓存时间更长,使用续期策略
await cache.SetStringAsync(cacheKey, json,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
SlidingExpiration = TimeSpan.FromMinutes(10)
});
return products;
}
finally
{
semaphore.Release();
}
}
return await service.GetHotProductsAsync(); // 锁超时,直接返回数据库结果
});
在代码中,我们展示了两种防护策略。对于缓存穿透,我们在查询不到数据时,缓存一个特殊的空值标记(如字符串 "null"),并设置较短的过期时间,防止频繁查询不存在的数据导致数据库压力过大。对于缓存击穿,我们使用本地锁(SemaphoreSlim)确保在热点数据失效时,只有一个请求能够回源查询数据,其他请求等待锁释放后再次检查缓存,避免大量请求同时打到数据源。热点数据的缓存时间较长,并结合滑动过期策略,确保频繁访问的数据能够持续保留在缓存中。
在实际生产环境中,缓存穿透和击穿是两个常见的性能陷阱,需要重点关注和防护。缓存穿透通常发生在用户查询不存在的数据时(如被删除的商品或错误的用户ID),大量这类请求会直接跳过缓存,打到数据库,造成不必要的数据库压力。通过缓存空值的方式,我们能够将这类查询的成本从数据库查询降低到一次缓存读写,显著改善系统性能。需要注意的是,空值缓存的过期时间应设置得较短(如 5-10 分钟),以避免在数据真实创建后仍无法被查询到的问题。
缓存击穿是指热点数据(如商品详情页面、热销商品等)在缓存失效的瞬间,大量并发请求同时到达数据库的现象。使用本地锁的防护策略称为"单机加锁"或"本地互斥"。当一个请求发现缓存未命中时,它会尝试获取锁。首个获得锁的请求执行回源查询,其他请求则在锁处等待。待锁释放后,其他请求再次检查缓存(通常已经被首个请求填充),直接从缓存返回结果。这样能够有效减少并发回源的请求数量,保护数据库。
对于分布式系统中的缓存击穿问题,单机锁可能不够有效。在这种情况下,可考虑使用 Redis 分布式锁(利用 SET NX EX 命令或 RedisValue 等库)或引入互斥更新策略。互斥更新是指当缓存快要过期时,后台任务提前主动更新缓存,而不是等待缓存完全失效。这种方式能够确保热点数据始终保持新鲜,避免过期导致的击穿问题。例如,可以定时扫描即将过期的热点数据缓存键,提前 1-2 分钟执行异步刷新,确保用户始终能够获取到有效的缓存数据。
缓存雪崩也是需要防护的场景------它指大量缓存在同一时间失效(如数据库故障恢复后的缓存同时清空),导致请求全部打向数据库的情况。防护方法包括:使用随机化过期时间避免集中过期。使用分层缓存策略(多级缓存)。在缓存失效时启用熔断器(Circuit Breaker)模式,临时返回降级数据而非直接查询数据库。引入缓存预热机制,应用启动时主动加载热点数据。这些策略结合使用能够构建更加健壮的缓存系统。
在监控和调优方面,应定期监测缓存命中率、未命中率、穿透/击穿事件的发生频率,以及数据库查询压力等指标。当发现命中率显著下降或数据库连接数激增时,应及时调查是否出现了穿透或击穿问题。可通过日志记录回源查询的耗时和次数,识别哪些操作最容易触发这类问题,进而优化缓存策略或调整过期时间配置。同时,与团队协作梳理业务逻辑中的常见查询场景,提前规划缓存预热和主动失效策略,是构建高效缓存系统的重要基础。
6. 云端部署与 Azure Cache for Redis
部署到 Azure 时,改用 Aspire.Hosting.Azure.Redis 即可利用云端托管的 Redis 服务,无需修改业务代码:
csharp
// AppHost/Program.cs(Azure 环境)
using Aspire.Hosting;
using Aspire.Hosting.Azure;
var builder = DistributedApplication.CreateBuilder(args);
// 本地开发用容器 Redis
// var cache = builder.AddRedis("cache");
// 云端部署用 Azure Cache for Redis
var cache = builder.AddAzureRedis("cache")
.AsAzureResource(); // 添加到 Aspire 清单
builder.AddProject<Projects.Web>("web")
.WithReference(cache)
.WaitFor(cache);
builder.Build().Run();
或在 appsettings.json 中为不同环境配置不同的连接字符串:
json
{
"ConnectionStrings": {
"cache": "localhost:6379" // 本地开发
}
}
json
// appsettings.Production.json
{
"ConnectionStrings": {
"cache": "prod-redis.redis.cache.windows.net:6380,password=***,ssl=True" // Azure Cache for Redis
}
}
上面代码中,我们展示了如何在本地开发环境使用容器化的 Redis,而在生产环境中切换到 Azure Cache for Redis。通过 AddAzureRedis 方法,Aspire 会自动处理与 Azure Redis 的集成,包括连接字符串的注入和资源管理。使用 AsAzureResource() 方法将 Redis 实例添加到 Aspire 的资源清单中,便于统一管理和监控。
在本地开发阶段,容器化的 Redis 提供即插即用的缓存服务,无需复杂的环境配置。开发者可以快速验证缓存逻辑、调试性能瓶颈,并测试各种缓存失效和更新场景。容器的轻量级特性使得团队成员能够快速搭建一致的开发环境,降低环保境差异导致的问题。
部署到 Azure 时,只需在 AppHost 中替换为 AddAzureRedis,或在 appsettings.Production.json 中更新连接字符串。Aspire 会自动处理 Azure Authentication、SSL 加密、连接池和重试策略等细节,无需修改业务代码。这种方式遵循基础设施即代码(IaC)的原则,使得环境切换变得简洁而安全。
同时,Azure Cache for Redis 提供更强大的可用性保障,包括地域冗余、自动故障转移和内置的性能监控。企业级的 SLA 保证了生产环境的稳定性。通过 Aspire 的统一接口,团队可以从本地快速迭代升级到云端高可用方案,加快产品上市速度。
通过 Aspire 的托管与客户端集成,Redis 缓存可以在本地容器、开发环境与 Azure Cache for Redis 之间无缝切换,无需修改应用代码。结合分布式缓存、输出缓存和合理的过期策略,可以有效提升系统的性能和用户体验。
八、MongoDB 集成
8.1 基础集成与资源声明
MongoDB 的托管集成由 Aspire.Hosting.MongoDB 提供,客户端集成由 Aspire.MongoDB.Driver 提供。AppHost 声明资源后,业务项目通过 AddMongoDBClient 获取已配置好的 IMongoClient。MongoDB 特别适合存储文档型数据,如订单、日志、用户行为等非结构化或半结构化数据:
csharp
// AppHost/Program.cs
using Aspire.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var mongo = builder.AddMongoDB("mongo")
.WithLifetime(ContainerLifetime.Persistent);
builder.AddProject<Projects.OrderService>("orders")
.WithReference(mongo)
.WaitFor(mongo);
builder.Build().Run();
在业务项目中安装 Aspire.MongoDB.Driver 并配置客户端:
csharp
// OrderService/Program.cs
using MongoDB.Driver;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddMongoDBClient("mongo"); // 自动注入连接字符串和 IMongoClient
var app = builder.Build();
// 基础查询示例
app.MapGet("/orders", async (IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
return await coll.Find(FilterDefinition<Order>.Empty).ToListAsync();
});
app.Run();
在上面的代码中,我们展示了如何在 AppHost 中声明 MongoDB 资源,并将其引用注入到业务项目中。通过 AddMongoDBClient("mongo") 方法,Aspire 会自动注入配置好的 IMongoClient 实例,供应用使用。这种方式省去了手动配置连接字符串、连接池和重试策略的繁琐工作,Aspire 框架会自动处理这些细节,确保开发者能够专注于业务逻辑实现。
在示例中,我们通过 IMongoClient 获取指定的数据库(orders)和集合(orders),并执行了一个简单的查询操作,返回所有订单数据。值得注意的是,MongoDB 驱动提供的异步 API(如 ToListAsync())能够有效避免线程阻塞,在高并发场景下提升应用的响应性能。
相比传统的手动配置方式,Aspire 的优势体现在:首先,自动管理 MongoDB 容器的生命周期,无需手工启动或停止。其次,自动注入连接字符串和环境变量,避免硬编码或繁琐的配置文件管理。再次,开箱即用的健康检查确保连接的可靠性,应用启动前会自动等待 MongoDB 就绪,避免启动竞速问题。最后,统一的资源声明方式使得本地开发、测试环境与云端部署(如 MongoDB Atlas)之间的切换变得简单而安全,只需修改配置或替换资源声明即可,业务代码保持不变。
在实际应用中,可进一步优化 MongoDB 使用体验。例如,将数据库获取和集合操作封装到 Repository 模式或 Service 层中,避免在每个端点中重复编写相同的访问逻辑。使用依赖注入注册这些 Repository 或 Service,使得测试和维护更加便利。利用 MongoDB 的灵活文档模式,根据业务需求动态调整数据结构,无需像关系数据库那样执行迁移。定期监控连接池状态、查询性能和索引效率,及时优化慢查询和缺失的索引。这些实践能够帮助构建高效、可维护的 MongoDB 驱动应用。
8.2 数据建模与集合管理
MongoDB 采用灵活的文档模式,可直接将 C# 类映射到文档集合。使用 BSON 属性装饰可精细控制序列化行为:
csharp
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
public class Order
{
[BsonId]
public ObjectId Id { get; set; }
[BsonElement("order_number")]
public string OrderNumber { get; set; } = string.Empty;
[BsonElement("customer_id")]
public string CustomerId { get; set; } = string.Empty;
[BsonElement("items")]
public List<OrderItem> Items { get; set; } = new();
[BsonElement("total_amount")]
public decimal TotalAmount { get; set; }
[BsonElement("status")]
public OrderStatus Status { get; set; }
[BsonElement("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[BsonElement("updated_at")]
[BsonIgnoreIfNull]
public DateTime? UpdatedAt { get; set; }
}
public class OrderItem
{
[BsonElement("product_id")]
public string ProductId { get; set; } = string.Empty;
[BsonElement("quantity")]
public int Quantity { get; set; }
[BsonElement("price")]
public decimal Price { get; set; }
}
public enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered,
Cancelled
}
在应用启动时创建必要的集合和索引,确保数据库结构就绪:
csharp
// 初始化数据库集合与索引
using (var scope = app.Services.CreateScope())
{
var client = scope.ServiceProvider.GetRequiredService<IMongoClient>();
var db = client.GetDatabase("orders");
// 创建集合(若不存在)
try
{
await db.CreateCollectionAsync("orders");
}
catch (MongoCommandException ex) when (ex.Code == 48)
{
// 集合已存在,忽略错误
}
var ordersCollection = db.GetCollection<Order>("orders");
// 创建索引以优化查询性能
var indexModel = new CreateIndexModel<Order>(
Builders<Order>.IndexKeys
.Ascending(o => o.CustomerId)
.Ascending(o => o.CreatedAt),
new CreateIndexOptions { Name = "idx_customer_created" }
);
await ordersCollection.Indexes.CreateOneAsync(indexModel);
// 创建唯一索引(订单号唯一性)
var uniqueIndexModel = new CreateIndexModel<Order>(
Builders<Order>.IndexKeys.Ascending(o => o.OrderNumber),
new CreateIndexOptions { Unique = true, Name = "idx_order_number" }
);
await ordersCollection.Indexes.CreateOneAsync(uniqueIndexModel);
}
在上面的代码中,我们展示了如何定义 MongoDB 文档模型,并在应用启动时创建集合和索引。通过使用 BsonElement 和其他 BSON 属性,我们可以精确控制 C# 类与 MongoDB 文档字段之间的映射关系。这样不仅提高了代码的可读性,还确保了数据的一致性。
BsonId 属性标记主键字段,MongoDB 会自动使用 ObjectId 类型作为文档的唯一标识符。BsonElement 定义字段在 MongoDB 文档中的键名,例如 C# 属性 OrderNumber 在数据库中存储为 order_number。这种命名转换有助于遵循不同的编码规范------C# 使用 PascalCase,而 MongoDB 文档通常采用 snake_case。BsonIgnoreIfNull 属性使得空值不会被序列化到数据库,节省存储空间并简化查询逻辑。
索引是 MongoDB 性能优化的关键。在应用启动时预先创建索引能够加速后续的查询操作。代码中创建了两个索引:一个是复合索引 idx_customer_created,用于加速按客户ID和创建时间的联合查询。另一个是唯一索引 idx_order_number,确保订单号的唯一性,并在插入重复订单号时自动抛出异常。唯一索引还能加速基于订单号的单个查询。
在实际应用中,应根据查询模式灵活创建索引。频繁在某个字段上执行过滤或排序操作时,为该字段创建索引能显著提升查询性能。复合索引适合多字段联合查询,其字段顺序应遵循查询的实际条件顺序,以最大化索引效率。定期分析查询日志,使用 MongoDB 提供的执行计划工具(如 explain())识别缺失的索引或低效的查询,是维护高效 MongoDB 应用的重要实践。
8.3 数据查询与聚合
MongoDB 驱动提供链式查询 API 和强大的聚合管道,支持复杂的数据分析:
csharp
// 基础查询
app.MapGet("/orders/{customerId}", async (string customerId, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var filter = Builders<Order>.Filter.Eq(o => o.CustomerId, customerId);
return await coll.Find(filter)
.SortByDescending(o => o.CreatedAt)
.ToListAsync();
});
// 高级查询:带分页和搜索
app.MapGet("/orders/search", async (string? status, int pageSize = 10, int pageNumber = 1, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var filterBuilder = Builders<Order>.Filter;
var filter = filterBuilder.Empty;
if (!string.IsNullOrEmpty(status) && Enum.TryParse<OrderStatus>(status, out var orderStatus))
{
filter = filter & filterBuilder.Eq(o => o.Status, orderStatus);
}
var skip = (pageNumber - 1) * pageSize;
var orders = await coll.Find(filter)
.Skip(skip)
.Limit(pageSize)
.SortByDescending(o => o.CreatedAt)
.ToListAsync();
var total = await coll.CountDocumentsAsync(filter);
return Results.Ok(new { data = orders, total, pageSize, pageNumber });
});
// 聚合管道:统计订单金额和数量
app.MapGet("/orders/analytics/summary", async (IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var pipeline = coll.Aggregate()
.Group(new BsonDocument
{
{ "_id", BsonNull.Value },
{ "total_orders", new BsonDocument("$sum", 1) },
{ "total_amount", new BsonDocument("$sum", "$total_amount") },
{ "avg_amount", new BsonDocument("$avg", "$total_amount") }
});
var result = await pipeline.FirstOrDefaultAsync();
return Results.Ok(result);
});
// 按客户聚合:计算每个客户的订单统计
app.MapGet("/orders/analytics/by-customer", async (IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var pipeline = coll.Aggregate()
.Match(Builders<Order>.Filter.Ne(o => o.CustomerId, null))
.Group(new BsonDocument
{
{ "_id", "$customer_id" },
{ "count", new BsonDocument("$sum", 1) },
{ "total_spent", new BsonDocument("$sum", "$total_amount") },
{ "last_order", new BsonDocument("$max", "$created_at") }
})
.Sort(new BsonDocument("total_spent", -1))
.Limit(10);
var results = await pipeline.ToListAsync();
return Results.Ok(results);
});
在上面的代码中,我们展示了如何使用 MongoDB 驱动的查询和聚合功能。基础查询示例展示了如何根据客户ID过滤订单,并按创建时间降序排序,以便快速获取特定客户的最新订单。通过使用 FilterDefinition,我们能够灵活地构建查询条件,确保只返回符合条件的订单数据。
高级查询示例则进一步增强了查询的灵活性,增加了分页和状态过滤功能。客户端可以通过传递状态参数来筛选特定状态的订单,例如待处理或已取消的订单。同时,分页功能允许客户端指定每页的订单数量和当前页码,从而有效管理大量数据的展示。这种设计使得用户在浏览订单时能够获得更好的体验,避免一次性加载过多数据导致的性能问题。
聚合管道的使用使得我们能够进行复杂的数据分析,例如统计订单金额和数量,或按客户聚合计算订单统计。这些功能的结合不仅提升了数据查询的效率,也为业务决策提供了有力的数据支持。通过这些示例,开发者可以更好地理解如何利用 MongoDB 的强大功能来满足业务需求。
8.4 写入与更新操作
MongoDB 支持单文档和批量写入,同时提供原子更新操作:
csharp
// 插入单个文档
app.MapPost("/orders", async (CreateOrderRequest req, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var order = new Order
{
OrderNumber = Guid.NewGuid().ToString()[..8].ToUpper(),
CustomerId = req.CustomerId,
Items = req.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
Price = i.Price
}).ToList(),
TotalAmount = req.Items.Sum(i => i.Price * i.Quantity),
Status = OrderStatus.Pending
};
await coll.InsertOneAsync(order);
return Results.Created($"/orders/{order.Id}", order);
});
// 批量插入
app.MapPost("/orders/batch", async (List<CreateOrderRequest> requests, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var orders = requests.Select(req => new Order
{
OrderNumber = Guid.NewGuid().ToString()[..8].ToUpper(),
CustomerId = req.CustomerId,
Items = req.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
Price = i.Price
}).ToList(),
TotalAmount = req.Items.Sum(i => i.Price * i.Quantity),
Status = OrderStatus.Pending
}).ToList();
await coll.InsertManyAsync(orders);
return Results.Created("/orders/batch", new { inserted = orders.Count });
});
// 更新订单状态
app.MapPut("/orders/{id}/status", async (string id, UpdateStatusRequest req, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
if (!ObjectId.TryParse(id, out var orderId))
return Results.BadRequest("Invalid order ID");
var filter = Builders<Order>.Filter.Eq(o => o.Id, orderId);
var update = Builders<Order>.Update
.Set(o => o.Status, req.Status)
.Set(o => o.UpdatedAt, DateTime.UtcNow);
var result = await coll.UpdateOneAsync(filter, update);
if (result.MatchedCount == 0)
return Results.NotFound();
return Results.Ok(new { modified = result.ModifiedCount });
});
// 原子操作:增加字段值
app.MapPut("/orders/{id}/add-item", async (string id, OrderItem newItem, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
if (!ObjectId.TryParse(id, out var orderId))
return Results.BadRequest("Invalid order ID");
var filter = Builders<Order>.Filter.Eq(o => o.Id, orderId);
var update = Builders<Order>.Update
.Push(o => o.Items, newItem)
.Inc(o => o.TotalAmount, newItem.Price * newItem.Quantity)
.Set(o => o.UpdatedAt, DateTime.UtcNow);
await coll.UpdateOneAsync(filter, update);
return Results.Ok();
});
// 条件更新:使用 $addToSet 避免重复
app.MapPut("/orders/{id}/tags", async (string id, string tag, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
if (!ObjectId.TryParse(id, out var orderId))
return Results.BadRequest("Invalid order ID");
// 假设 Order 模型中有 Tags 字段
var filter = Builders<Order>.Filter.Eq(o => o.Id, orderId);
var update = Builders<Order>.Update.AddToSet("tags", tag);
await coll.UpdateOneAsync(filter, update);
return Results.Ok();
});
在上面的代码中,我们展示了如何进行 MongoDB 的写入和更新操作。首先,单个文档插入示例展示了如何创建一个新的订单对象,并将其插入到 orders 集合中。通过使用 InsertOneAsync 方法,我们能够异步地将订单数据写入数据库,确保应用的响应性能。此外,批量插入的示例展示了如何处理多个订单请求,通过 InsertManyAsync 方法将多个订单对象一次性插入到数据库中,这样可以显著提高插入效率,减少数据库的负担。
在更新操作中,我们使用了 UpdateOneAsync 方法来更新特定订单的状态。通过构建过滤器和更新定义,我们能够精确地定位到需要更新的文档,并进行原子性更新。这种方式不仅提高了数据一致性,还确保了操作的高效性。对于需要添加新项的情况,我们使用了 Push 方法将新订单项添加到现有订单中,确保数据结构的灵活性和可扩展性。
这些操作展示了 MongoDB 在处理写入和更新时的强大能力,使得开发者能够轻松地管理订单数据,同时保持高性能和良好的用户体验。
8.5 事务与多文档操作
MongoDB 4.0+ 支持多文档事务,确保跨集合操作的原子性:
csharp
// 多文档事务:创建订单并更新库存
app.MapPost("/orders/transactional", async (CreateOrderRequest req, IMongoClient client) =>
{
using var session = await client.StartSessionAsync();
session.StartTransaction();
try
{
var db = client.GetDatabase("orders");
var ordersCollection = db.GetCollection<Order>("orders");
var inventoryCollection = db.GetCollection<InventoryItem>("inventory");
// 创建订单
var order = new Order
{
OrderNumber = Guid.NewGuid().ToString()[..8].ToUpper(),
CustomerId = req.CustomerId,
Items = req.Items,
TotalAmount = req.Items.Sum(i => i.Price * i.Quantity),
Status = OrderStatus.Pending
};
await ordersCollection.InsertOneAsync(session, order);
// 更新库存
foreach (var item in req.Items)
{
var filter = Builders<InventoryItem>.Filter.Eq(i => i.ProductId, item.ProductId);
var update = Builders<InventoryItem>.Update.Inc(i => i.Quantity, -item.Quantity);
var result = await inventoryCollection.UpdateOneAsync(session, filter, update);
if (result.MatchedCount == 0)
{
await session.AbortTransactionAsync();
return Results.BadRequest($"Product {item.ProductId} not found");
}
}
await session.CommitTransactionAsync();
return Results.Created($"/orders/{order.Id}", order);
}
catch (Exception ex)
{
await session.AbortTransactionAsync();
return Results.StatusCode(500);
}
});
public class InventoryItem
{
[BsonId]
public ObjectId Id { get; set; }
[BsonElement("product_id")]
public string ProductId { get; set; } = string.Empty;
[BsonElement("quantity")]
public int Quantity { get; set; }
}
在上面的代码中,我们展示了如何使用 MongoDB 的多文档事务功能来确保跨集合操作的原子性。通过 StartSessionAsync 方法启动一个会话,并调用 StartTransaction 方法开始一个事务,我们能够在同一个事务上下文中执行多个数据库操作。这种方式确保了所有操作要么全部成功,要么在出现错误时全部回滚,从而维护数据的一致性。
在示例中,我们首先创建了一个新的订单,并将其插入到 orders 集合中。接下来,我们遍历订单项,逐一更新相应的库存数据,确保每个商品的库存数量正确减少。为了实现这一点,我们需要在每次更新库存时检查商品是否存在于库存中。如果在更新库存的过程中发现某个商品不存在,我们会调用 AbortTransactionAsync 方法中止事务,确保订单和库存的状态保持一致,避免出现数据不一致的情况。
这种事务处理机制在处理复杂的业务逻辑时尤为重要,特别是在涉及多个集合或文档的操作时。通过使用多文档事务,开发者可以更安心地进行数据操作,确保在面对意外情况时,系统能够自动恢复到安全的状态,从而提升应用的可靠性和用户体验。
8.6 删除与清理
MongoDB 支持单个删除、条件删除和批量删除:
csharp
// 删除单个文档
app.MapDelete("/orders/{id}", async (string id, IMongoClient client) =>
{
if (!ObjectId.TryParse(id, out var orderId))
return Results.BadRequest("Invalid order ID");
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var filter = Builders<Order>.Filter.Eq(o => o.Id, orderId);
var result = await coll.DeleteOneAsync(filter);
if (result.DeletedCount == 0)
return Results.NotFound();
return Results.Ok();
});
// 删除多个文档
app.MapDelete("/orders/by-status/{status}", async (string status, IMongoClient client) =>
{
if (!Enum.TryParse<OrderStatus>(status, out var orderStatus))
return Results.BadRequest("Invalid status");
var db = client.GetDatabase("orders");
var coll = db.GetCollection<Order>("orders");
var filter = Builders<Order>.Filter.Eq(o => o.Status, orderStatus);
var result = await coll.DeleteManyAsync(filter);
return Results.Ok(new { deleted = result.DeletedCount });
});
// 设置 TTL 索引,自动删除过期文档
app.MapPost("/logs", async (LogEntry log, IMongoClient client) =>
{
var db = client.GetDatabase("orders");
var coll = db.GetCollection<LogEntry>("logs");
// 创建 TTL 索引(自动删除 24 小时后的日志)
try
{
var indexModel = new CreateIndexModel<LogEntry>(
Builders<LogEntry>.IndexKeys.Ascending(l => l.CreatedAt),
new CreateIndexOptions { ExpireAfter = TimeSpan.FromHours(24) }
);
await coll.Indexes.CreateOneAsync(indexModel);
}
catch { /* 索引可能已存在 */ }
await coll.InsertOneAsync(log);
return Results.Ok();
});
public class LogEntry
{
[BsonId]
public ObjectId Id { get; set; }
[BsonElement("message")]
public string Message { get; set; } = string.Empty;
[BsonElement("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
在上面的代码中,我们展示了如何进行 MongoDB 的删除和清理操作。首先,单个文档删除示例展示了如何根据订单ID删除特定的订单。通过构建过滤器并调用 DeleteOneAsync 方法,我们能够异步地删除指定的订单文档。如果指定的订单不存在,系统会返回 404 状态码,确保客户端能够正确处理删除请求的结果。
我们还提供了条件删除和批量删除的示例。条件删除允许开发者根据特定条件(如订单状态)删除多个文档,这在处理过期或不再需要的订单时非常有用。批量删除操作通过 DeleteManyAsync 方法实现,能够有效地清理数据库中的冗余数据,保持数据的整洁性。
为了进一步优化数据管理,我们还可以设置 TTL(Time-To-Live)索引,自动删除过期的文档。例如,在日志记录中,我们可以创建一个 TTL 索引,使得日志在存储 24 小时后自动删除。这种机制不仅减少了手动清理的工作量,还能有效控制数据库的存储空间,确保系统的高效运行。
通过这些删除和清理操作,开发者能够灵活地管理 MongoDB 中的数据,确保数据的准确性和及时性,同时提升应用的性能和用户体验。
8.7 云端部署与 MongoDB Atlas 集成
部署到云端时,改用 Aspire.Hosting.Azure.CosmosDB 或直接在配置文件中指向 MongoDB Atlas 连接字符串:
json
// appsettings.Development.json(本地容器)
{
"ConnectionStrings": {
"mongo": "mongodb://localhost:27017"
}
}
// appsettings.Production.json(MongoDB Atlas)
{
"ConnectionStrings": {
"mongo": "mongodb+srv://user:password@cluster.mongodb.net/?retryWrites=true&w=majority"
}
}
或在 Azure 环境中使用 Cosmos DB:
csharp
// AppHost/Program.cs(Azure Cosmos DB)
using Aspire.Hosting.Azure;
var cosmos = builder.AddAzureCosmosDB("mongo")
.AddDatabase("orders")
.AsAzureResource();
builder.AddProject<Projects.OrderService>("orders")
.WithReference(cosmos);
MongoDB 驱动默认支持连接池与异步 API,Aspire 注入的连接字符串同样可在生产环境指向 Atlas 或自建集群。通过统一的 IMongoClient 接口,开发者无需修改业务代码即可在本地容器、开发环境与云端集群间无缝切换。
九、总结
通过 Aspire 的托管与客户端集成,可以用同一套代码在本地容器、测试环境与云端资源之间切换,而无需手工改动连接字符串或健康检查配置。SQL Server 与 PostgreSQL 适合结构化数据,EF Core 统一建模与迁移。Redis 用于热点数据与输出缓存。MongoDB 适合文档型场景。借助连接池与读写分离实践,可以进一步提升吞吐与稳定性。上述示例均可直接嵌入 Aspire 解决方案,按需替换资源端点即可。