15.故障排查与调试

在分布式应用开发过程中,故障排查和调试是不可避免的环节。.NET Aspire 通过其强大的 Dashboard、集成的日志系统和分布式追踪功能,为开发者提供了全方位的诊断工具。本文将深入讲解如何使用这些工具高效地排查问题。

一、常见问题汇总

1.1 服务发现问题

问题现象: 应用启动后,某个服务无法连接到另一个服务,出现连接超时或找不到主机的错误。这是在使用 .NET Aspire 开发分布式应用时最常见的问题之一,通常表现为 HttpRequestExceptionSocketExceptionTaskCanceledException 等异常。

服务发现问题的根本原因在于 .NET Aspire 的服务间通信依赖于自动配置的服务名称解析机制。当你在 AppHost 中通过 WithReference() 方法建立服务依赖关系时,Aspire 会自动将被引用服务的连接信息注入到调用方的配置中。然而,如果配置不当,这个自动化过程就会失败。

最常见的问题是服务名不匹配。在 AppHost 中注册服务时,你会为每个服务指定一个名称,例如 builder.AddProject<Projects.WeatherApi>("weatherapi")。这个名称 "weatherapi" 就是其他服务用来引用它的标识符。如果在消费服务的 HTTP 客户端配置中使用了不同的名称,比如 "weather-api" 或 "WeatherAPI",服务发现就会失败。解决这个问题的关键是确保整个应用中使用统一的服务命名约定,并在配置 HTTP 客户端时严格遵循 AppHost 中定义的名称。

协议不匹配是另一个频繁出现的问题。在开发环境中,某些服务可能配置为使用 HTTPS,而另一些只支持 HTTP。如果你在 HTTP 客户端的 BaseAddress 中硬编码了 http://https://,当目标服务使用不同协议时就会失败。Aspire 提供了一个优雅的解决方案:使用 https+http:// 协议方案。这个特殊的方案告诉 Aspire 的服务发现机制首先尝试 HTTPS 连接,如果失败则自动回退到 HTTP。这种方式特别适合开发环境,既保证了生产环境的安全性,又提供了开发环境的灵活性。

Service Defaults 未正确配置是导致服务发现失败的另一个重要原因。Service Defaults 项目包含了 Aspire 应用所需的核心配置,包括服务发现、遥测、健康检查等。如果某个服务项目没有调用 AddServiceDefaults() 扩展方法,它就无法正确解析其他服务的名称。这个方法不仅配置了服务发现客户端,还设置了必要的 HTTP 客户端工厂和命名解析器。

下面通过一个完整的代码示例来说明正确的配置方式。假设我们有一个天气预报应用,其中 Web 前端需要调用 WeatherApi 服务获取数据。

首先在 AppHost 项目中正确注册这两个服务:

csharp 复制代码
// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// 注册 WeatherApi 服务,注意这里使用的名称是 "weatherapi"
var weatherApi = builder.AddProject<Projects.WeatherApi>("weatherapi")
    .WithHttpEndpoint(port: 5100, name: "http")
    .WithHttpsEndpoint(port: 5101, name: "https");

// 注册 Web 前端,并通过 WithReference 建立对 WeatherApi 的引用
var web = builder.AddProject<Projects.Web>("webfrontend")
    .WithHttpEndpoint(port: 5000)
    .WithReference(weatherApi);  // 这一行非常关键,建立了服务依赖关系

builder.Build().Run();

在这段代码中,WithReference(weatherApi) 方法的调用触发了 Aspire 的自动配置机制。它会在 Web 前端的环境变量中注入名为 services__weatherapi__http__0services__weatherapi__https__0 的配置项,这些配置项包含了 WeatherApi 服务的实际地址。

接下来在 WeatherApi 服务项目中配置基础设施:

csharp 复制代码
// WeatherApi/Program.cs
var builder = WebApplication.CreateBuilder(args);

// 添加 Service Defaults 配置,这是启用服务发现的前提
// 这个方法会配置日志、遥测、健康检查等核心功能
builder.AddServiceDefaults();

// 添加常规的 API 服务
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// 映射默认端点,包括健康检查和遥测端点
// 这些端点对于 Aspire Dashboard 的监控至关重要
app.MapDefaultEndpoints();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.MapControllers();
app.Run();

AddServiceDefaults() 方法是 Service Defaults 项目提供的扩展方法,它内部做了大量配置工作。它注册了 OpenTelemetry 的度量和追踪提供程序,配置了结构化日志,还设置了服务发现所需的命名解析器。没有这个调用,即使在 AppHost 中正确配置了服务引用,服务间通信仍然会失败。

最后在 Web 前端项目中配置 HTTP 客户端来消费 WeatherApi:

csharp 复制代码
// Web/Program.cs
var builder = WebApplication.CreateBuilder(args);

// 同样需要添加 Service Defaults
builder.AddServiceDefaults();

builder.Services.AddRazorPages();

// 配置 HTTP 客户端,注意 BaseAddress 的写法
// "https+http://weatherapi" 中的 "weatherapi" 必须与 AppHost 中的服务名完全一致
builder.Services.AddHttpClient<WeatherApiClient>(client =>
{
    // 使用特殊的协议方案,优先 HTTPS,回退 HTTP
    client.BaseAddress = new Uri("https+http://weatherapi");
    
    // 可以设置其他 HTTP 客户端选项
    client.Timeout = TimeSpan.FromSeconds(30);
    client.DefaultRequestHeaders.Add("User-Agent", "AspireWebApp/1.0");
});

var app = builder.Build();

app.MapDefaultEndpoints();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();

app.Run();

在这个配置中,https+http://weatherapi 这个 URI 看起来很特殊。它不是一个标准的 HTTP URL,而是 Aspire 服务发现机制使用的特殊格式。当 HTTP 客户端工厂解析这个地址时,它会查询配置系统中的 services__weatherapi__https__0services__weatherapi__http__0 配置项,将抽象的服务名 "weatherapi" 解析为实际的网络地址。如果 HTTPS 端点可用且证书有效,就使用 HTTPS;否则回退到 HTTP 端点。

现在创建一个客户端类来实际调用 API:

csharp 复制代码
// Web/Services/WeatherApiClient.cs
public class WeatherApiClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<WeatherApiClient> _logger;

    // HttpClient 会由依赖注入容器自动注入,已经配置好了正确的 BaseAddress
    public WeatherApiClient(HttpClient httpClient, ILogger<WeatherApiClient> logger)
    {
        _httpClient = httpClient;
        _logger = logger;
    }

    public async Task<WeatherForecast[]> GetForecastAsync()
    {
        try
        {
            // 注意这里只需要写相对路径,因为 BaseAddress 已经设置为服务地址
            // HttpClient 会自动将 "/weatherforecast" 与 "https+http://weatherapi" 组合
            // 实际请求可能是 "https://localhost:5101/weatherforecast" 或 "http://localhost:5100/weatherforecast"
            var response = await _httpClient.GetAsync("/weatherforecast");
            
            // 确保响应成功,否则抛出异常
            response.EnsureSuccessStatusCode();
            
            // 反序列化响应内容
            var forecasts = await response.Content.ReadFromJsonAsync<WeatherForecast[]>();
            
            _logger.LogInformation("Successfully retrieved {Count} weather forecasts", forecasts?.Length ?? 0);
            
            return forecasts ?? Array.Empty<WeatherForecast>();
        }
        catch (HttpRequestException ex)
        {
            // 这里捕获的异常通常表示服务发现或连接问题
            _logger.LogError(ex, "Failed to connect to WeatherApi service. Check service discovery configuration.");
            throw;
        }
        catch (TaskCanceledException ex)
        {
            // 超时异常
            _logger.LogError(ex, "Request to WeatherApi timed out");
            throw;
        }
    }
}

如果在运行时仍然遇到连接问题,可以通过以下步骤进行诊断。首先在 Aspire Dashboard 的 Projects 页面检查 WeatherApi 服务是否正常运行,状态应该显示为绿色的 "Running"。然后切换到 Environment 页面,查看 Web 前端的环境变量,确认是否存在 services__weatherapi__http__0services__weatherapi__https__0 配置项,它们的值应该是类似 http://localhost:5100https://localhost:5101 的地址。

如果环境变量缺失,说明 AppHost 中的 WithReference() 调用有问题或者服务名不匹配。如果环境变量存在但连接仍然失败,可能是防火墙或端口占用问题。在开发环境中,可以在浏览器中直接访问 http://localhost:5100/weatherforecast 来验证 WeatherApi 是否可访问。

对于容器化的服务,网络隔离可能是另一个问题来源。当服务运行在 Docker 容器中时,它们需要在同一个 Docker 网络中才能相互通信。Aspire 会自动创建和管理这些网络,但如果你手动配置了容器网络,需要确保所有相关容器都连接到同一个网络。可以使用 docker network lsdocker network inspect 命令来检查网络配置。

通过遵循这些配置原则和诊断步骤,大多数服务发现问题都可以得到解决。关键是要理解 Aspire 的服务发现机制如何工作,确保服务名称一致性,正确使用 Service Defaults,并善用 Aspire Dashboard 进行实时监控和诊断。

1.2 Dashboard 不显示服务

问题现象: AppHost 启动后,Dashboard 打开但看不到预期的服务或资源。这是一个令人困惑的问题,因为表面上看 Dashboard 已经正常运行,URL 可以访问,界面也能正常显示,但就是看不到你在 AppHost 中注册的那些项目、容器或其他资源。这个问题通常让初学者感到迷惑,因为没有明显的错误信息,一切看起来都很正常,只是资源列表是空的。

要理解这个问题,首先需要了解 Dashboard 的工作原理。Aspire Dashboard 本身是一个独立的 Web 应用程序,它通过 OTLP(OpenTelemetry Protocol)接收来自各个服务的遥测数据,包括日志、追踪和指标。当你启动 AppHost 时,它不仅会启动 Dashboard,还会启动所有注册的资源(项目、容器等),并确保这些资源能够正确地向 Dashboard 报告自己的状态。如果 Dashboard 中看不到服务,说明这个通信链路在某个环节出现了问题。

最常见的原因是 AppHost 项目本身的配置出现了问题。AppHost 是整个分布式应用的编排中心,它负责定义所有的资源、配置它们之间的依赖关系,并管理它们的生命周期。如果 AppHost 的 Program.cs 文件中的配置代码有语法错误、逻辑错误或者资源注册不完整,就会导致服务无法正常启动或无法向 Dashboard 注册。例如,你可能在调用 AddProject<T>() 时使用了错误的项目引用,或者忘记调用最后的 Build().Run() 方法。

让我们看一个完整的 AppHost 配置示例,了解正确的注册流程应该是什么样的:

csharp 复制代码
// AppHost/Program.cs
using Aspire.Hosting;

var builder = DistributedApplication.CreateBuilder(args);

// 注册一个 API 项目
// Projects.WeatherApi 是由 Aspire 工具自动生成的项目类型引用
// 这个类型定义在 AppHost.csproj 中通过 ProjectReference 生成
var weatherApi = builder.AddProject<Projects.WeatherApi>("weatherapi")
    .WithHttpEndpoint(port: 5100, name: "http")
    .WithHttpsEndpoint(port: 5101, name: "https");

// 注册 Redis 缓存容器
// Aspire 会自动拉取 Redis 镜像并启动容器
var cache = builder.AddRedis("cache")
    .WithRedisCommander();  // 可选:添加 Redis Commander 管理界面

// 注册 PostgreSQL 数据库
var postgres = builder.AddPostgres("postgres")
    .WithPgAdmin()  // 可选:添加 PgAdmin 管理界面
    .AddDatabase("weatherdb");  // 创建一个名为 weatherdb 的数据库

// 注册 Web 前端项目,并建立对其他资源的引用
var web = builder.AddProject<Projects.WebFrontend>("webfrontend")
    .WithHttpEndpoint(port: 5000, name: "http")
    .WithReference(weatherApi)    // 引用 API 服务
    .WithReference(cache)          // 引用 Redis
    .WithReference(postgres);      // 引用数据库

// 这两行代码至关重要,缺少任何一行都会导致应用无法启动
var app = builder.Build();
await app.RunAsync();

在这个配置中,每一个 AddXxx() 调用都会在 AppHost 的内部资源注册表中创建一个条目。当 Dashboard 启动时,它会查询这个注册表并显示所有注册的资源。如果你忘记调用某个 Add 方法,或者调用时出现异常(比如项目引用不存在),那么对应的资源就不会出现在 Dashboard 中。

Projects.WeatherApi 这种类型是由 MSBuild 在编译时自动生成的。要确保这个类型可用,你的 AppHost.csproj 文件必须包含正确的项目引用:

xml 复制代码
<!-- AppHost/AppHost.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <IsAspireHost>true</IsAspireHost>
  </PropertyGroup>

  <ItemGroup>
    <!-- 对 Aspire 主机库的引用 -->
    <PackageReference Include="Aspire.Hosting.AppHost" Version="8.0.0" />
  </ItemGroup>

  <ItemGroup>
    <!-- 项目引用,这些引用会生成 Projects.xxx 类型 -->
    <ProjectReference Include="..\WeatherApi\WeatherApi.csproj" />
    <ProjectReference Include="..\WebFrontend\WebFrontend.csproj" />
    <ProjectReference Include="..\ServiceDefaults\ServiceDefaults.csproj" />
  </ItemGroup>
</Project>

IsAspireHost 属性是触发代码生成的关键。当这个属性设置为 true 时,MSBuild 会扫描所有的 ProjectReference,并为每个引用的项目生成一个对应的类型定义。如果这个属性缺失或设置错误,Projects.WeatherApi 类型就不会存在,编译会失败。

另一个导致服务不显示的常见原因是被引用的服务项目本身有问题。即使 AppHost 正确注册了资源,如果服务项目无法成功启动,它也不会出现在 Dashboard 中,或者会显示为失败状态。要验证这一点,你需要检查每个服务项目的 Program.cs 文件,确保它正确配置了 Service Defaults:

csharp 复制代码
// WeatherApi/Program.cs
var builder = WebApplication.CreateBuilder(args);

// 这个调用配置了日志、遥测、健康检查等核心功能
// 如果缺少这个调用,服务虽然可能启动,但无法向 Dashboard 报告状态
builder.AddServiceDefaults();

// 添加你的服务
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// 映射默认端点,包括健康检查和遥测端点
// Dashboard 通过这些端点获取服务的运行状态
app.MapDefaultEndpoints();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.MapControllers();

// 确保使用 Run() 而不是 RunAsync() 来阻塞主线程
app.Run();

AddServiceDefaults()MapDefaultEndpoints() 这两个方法调用是服务能够被 Dashboard 发现和监控的前提。AddServiceDefaults() 内部会配置 OpenTelemetry 的各种导出器,设置日志提供程序,并注册服务发现客户端。MapDefaultEndpoints() 则会暴露 /health/alive 等健康检查端点,Dashboard 通过定期访问这些端点来判断服务是否正常运行。

有时候问题出在 NuGet 包的版本不兼容上。Aspire 是一个快速发展的框架,不同版本之间可能存在不兼容的更改。如果你的 AppHost 使用 Aspire 8.0,而某个服务项目使用的 Service Defaults 是 7.0 版本,就可能导致通信失败。要解决这个问题,确保所有项目使用相同版本的 Aspire 包:

shell 复制代码
# 在解决方案根目录执行,检查所有项目的 Aspire 包版本
dotnet list package | findstr Aspire

# 如果发现版本不一致,可以使用 Directory.Packages.props 统一管理
# 在解决方案根目录创建 Directory.Packages.props 文件
xml 复制代码
<!-- Directory.Packages.props -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  
  <ItemGroup>
    <PackageVersion Include="Aspire.Hosting.AppHost" Version="8.0.0" />
    <PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="8.0.0" />
    <PackageVersion Include="Aspire.Hosting.Redis" Version="8.0.0" />
    <PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="8.0.0" />
  </ItemGroup>
</Project>

浏览器缓存也可能导致 Dashboard 显示不正常。Dashboard 是一个单页应用(SPA),它会缓存静态资源以提高性能。如果你升级了 Aspire 版本或修改了 AppHost 配置,但浏览器仍然使用旧的缓存资源,就可能看到过时的或不完整的信息。解决这个问题的最简单方法是强制刷新浏览器:

plaintext 复制代码
在 Dashboard 页面按下:
- Chrome/Edge: Ctrl + Shift + R (Windows) 或 Cmd + Shift + R (Mac)
- Firefox: Ctrl + F5 (Windows) 或 Cmd + Shift + R (Mac)

或者在开发者工具中禁用缓存:
F12 打开开发者工具 → Network 标签页 → 勾选 "Disable cache"

如果以上方法都不能解决问题,可以尝试完全清理项目并重新构建。有时候编译产生的中间文件会损坏或过时,导致运行时行为异常:

shell 复制代码
# 停止所有正在运行的 Aspire 进程
# 在任务管理器中结束所有 dotnet.exe 进程,或使用命令
taskkill /F /IM dotnet.exe /T

# 清理解决方案中所有项目的构建输出
dotnet clean

# 删除所有 bin 和 obj 目录(可选,更彻底)
Get-ChildItem -Path . -Include bin,obj -Recurse -Directory | Remove-Item -Recurse -Force

# 恢复 NuGet 包
dotnet restore

# 重新构建整个解决方案
dotnet build

# 运行 AppHost 项目
cd AppHost
dotnet run

执行清理操作后,再次访问 Dashboard(通常是 https://localhost:17xxx),你应该能看到所有正确注册的资源。在 Projects 页面,每个服务应该显示为绿色的 Running 状态,并列出它们的端点地址。如果某个服务显示为红色或黄色警告状态,点击它可以查看详细的错误日志。

还有一种情况是端口冲突导致的问题。如果你为某个服务配置的端口已经被其他应用程序占用,该服务就无法启动,自然也不会出现在 Dashboard 中。可以使用以下命令检查端口占用情况:

shell 复制代码
# Windows 上检查端口占用
netstat -ano | findstr :5100

# 如果端口被占用,可以查找并终止占用进程
# 假设 PID 是 12345
taskkill /F /PID 12345

# Linux/Mac 上检查端口占用
lsof -i :5100

# 终止占用进程
kill -9 <PID>

为了避免端口冲突,可以在 AppHost 中使用动态端口分配,让 Aspire 自动选择可用端口:

csharp 复制代码
// 使用动态端口,而不是固定端口
var weatherApi = builder.AddProject<Projects.WeatherApi>("weatherapi")
    .WithHttpEndpoint()     // 不指定端口,自动分配
    .WithHttpsEndpoint();   // 不指定端口,自动分配

// Dashboard 会显示实际分配的端口号

如果你使用的是容器化资源(如 Redis、PostgreSQL),还需要确保 Docker Desktop 正在运行且配置正确。Aspire 依赖 Docker 来运行容器资源,如果 Docker 守护进程未启动或配置有问题,容器资源就无法启动:

shell 复制代码
# 检查 Docker 是否运行
docker ps

# 如果返回错误,尝试启动 Docker Desktop
# 然后重新运行 AppHost

# 检查 Aspire 创建的容器
docker ps --filter "label=aspire"

# 查看容器日志以诊断问题
docker logs <container-id>

最后,如果问题仍然存在,可以启用 AppHost 的详细日志来诊断问题。在 AppHost 项目的 appsettings.json 中添加日志配置:

json 复制代码
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.Hosting.Lifetime": "Information",
      "Aspire.Hosting": "Debug"
    }
  }
}

然后在命令行中运行 AppHost,仔细查看输出的日志信息。日志会显示每个资源的启动过程、端口分配情况、以及任何发生的错误。通过这些详细信息,你通常能够定位到问题的根本原因。

总结起来,Dashboard 不显示服务的问题通常源于配置错误、版本不匹配、端口冲突或环境问题。通过系统地检查 AppHost 配置、服务项目配置、NuGet 包版本、端口占用情况和 Docker 状态,结合清理重建操作和详细日志分析,绝大多数问题都能够得到解决。关键是要理解 Aspire 的工作原理,知道 AppHost、Service Defaults、Dashboard 之间是如何协作的,这样在遇到问题时才能快速定位和修复。

1.3 日志未收集

问题现象: 在 Dashboard 的日志页面看不到应用输出的日志,这是一个让许多开发者感到困惑的问题。你明明在代码中使用了 ILogger 记录了大量信息,控制台也能看到这些日志输出,但是当你打开 Aspire Dashboard 的 Logs 页面时,却发现它是空的,或者只显示了很少的系统日志。这个问题的影响范围很广,因为 Dashboard 的集中式日志视图是排查分布式应用问题的关键工具,如果无法使用它,你就失去了最重要的诊断手段之一。

这个问题的根本原因在于 .NET Aspire 的日志收集机制与传统的日志系统有所不同。Aspire 使用 OpenTelemetry 协议(OTLP)来收集和传输日志数据,而不是简单地重定向控制台输出。当你的应用启动时,它需要通过 OTLP 导出器将日志发送到 Dashboard 的后端服务,Dashboard 再从这些数据中提取并展示日志信息。如果这个数据流的任何一个环节出现问题,日志就无法正常显示。

最常见的原因是 Service Defaults 项目的配置不完整或者根本没有被引用。Service Defaults 是 Aspire 应用架构的基石,它封装了所有与观测性相关的配置,包括日志、追踪和指标。当你创建一个新的 Aspire 解决方案时,Visual Studio 或 dotnet new aspire 命令会自动生成一个 ServiceDefaults 项目,但是如果你手动创建项目或者从模板外导入项目,就可能遗漏这个关键配置。

让我们从头开始构建一个完整的日志配置流程。首先需要确保你的解决方案中包含一个 ServiceDefaults 项目。这个项目通常是一个类库项目,包含一个名为 Extensions.cs 的文件,这个文件定义了配置服务默认设置的扩展方法。如果你的解决方案中没有这个项目,需要先创建它:

shell 复制代码
# 在解决方案根目录创建 ServiceDefaults 项目
dotnet new classlib -n ServiceDefaults -f net8.0

# 添加必要的 NuGet 包
cd ServiceDefaults
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.ServiceDiscovery
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.Runtime

创建项目后,在 Extensions.cs 文件中实现核心的配置逻辑。这个文件的内容是整个日志系统能否正常工作的关键:

csharp 复制代码
// ServiceDefaults/Extensions.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

namespace Microsoft.Extensions.Hosting;

public static class Extensions
{
    /// <summary>
    /// 添加 Aspire 服务的默认配置,包括服务发现、遥测和健康检查
    /// </summary>
    public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
    {
        // 配置 OpenTelemetry,这是观测性的核心
        builder.ConfigureOpenTelemetry();

        // 添加默认健康检查
        builder.Services.AddDefaultHealthChecks();

        // 配置服务发现,使服务能够通过名称相互发现
        builder.Services.AddServiceDiscovery();

        // 为 HTTP 客户端配置服务发现和弹性策略
        builder.Services.ConfigureHttpClientDefaults(http =>
        {
            // 启用服务发现,使 HTTP 客户端能够解析服务名称
            http.AddStandardResilienceHandler();
            http.AddServiceDiscovery();
        });

        return builder;
    }

    /// <summary>
    /// 配置 OpenTelemetry,包括日志、追踪和指标
    /// </summary>
    private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
    {
        // 添加 OpenTelemetry 服务
        builder.Services.AddOpenTelemetry()
            // 配置资源属性,这些属性会附加到所有遥测数据上
            .ConfigureResource(resource =>
            {
                // 服务名称是最重要的属性,它在 Dashboard 中标识你的服务
                // 如果没有设置服务名称,日志可能无法正确归类
                resource.AddService(
                    serviceName: builder.Environment.ApplicationName,
                    serviceVersion: typeof(Extensions).Assembly.GetName().Version?.ToString() ?? "1.0.0",
                    serviceInstanceId: Environment.MachineName);
            })
            // 配置日志导出
            .WithLogging(logging =>
            {
                // 添加 OTLP 导出器,将日志发送到 Aspire Dashboard
                // 这是日志能够在 Dashboard 中显示的关键配置
                logging.AddOtlpExporter();

                // 设置日志的批处理选项,优化性能
                logging.AddProcessor(new BatchLogRecordExportProcessor(
                    new OtlpLogExporter(),
                    maxQueueSize: 2048,
                    scheduledDelayMilliseconds: 5000,
                    exporterTimeoutMilliseconds: 30000,
                    maxExportBatchSize: 512));
            })
            // 配置追踪
            .WithTracing(tracing =>
            {
                // 为 ASP.NET Core 添加自动埋点
                tracing.AddAspNetCoreInstrumentation(options =>
                {
                    // 记录详细的请求信息
                    options.RecordException = true;
                    options.EnrichWithHttpRequest = (activity, httpRequest) =>
                    {
                        activity.SetTag("http.request.header.user_agent", httpRequest.Headers.UserAgent.ToString());
                    };
                });

                // 为 HTTP 客户端添加自动埋点
                tracing.AddHttpClientInstrumentation(options =>
                {
                    options.RecordException = true;
                    options.EnrichWithHttpRequestMessage = (activity, httpRequest) =>
                    {
                        activity.SetTag("http.request.method", httpRequest.Method.ToString());
                    };
                });

                // 添加 OTLP 导出器
                tracing.AddOtlpExporter();
            })
            // 配置指标
            .WithMetrics(metrics =>
            {
                // 添加运行时指标
                metrics.AddRuntimeInstrumentation();
                
                // 添加 ASP.NET Core 指标
                metrics.AddAspNetCoreInstrumentation();
                
                // 添加 HTTP 客户端指标
                metrics.AddHttpClientInstrumentation();

                // 添加 OTLP 导出器
                metrics.AddOtlpExporter();
            });

        return builder;
    }

    /// <summary>
    /// 添加默认健康检查
    /// </summary>
    private static IServiceCollection AddDefaultHealthChecks(this IServiceCollection services)
    {
        services.AddHealthChecks()
            // 添加基本的健康检查,确保应用程序正在运行
            .AddCheck("self", () => HealthCheckResult.Healthy(), new[] { "live" });

        return services;
    }

    /// <summary>
    /// 映射默认端点,包括健康检查
    /// </summary>
    public static WebApplication MapDefaultEndpoints(this WebApplication app)
    {
        // 映射健康检查端点
        // Dashboard 通过这些端点监控服务健康状态
        app.MapHealthChecks("/health");
        app.MapHealthChecks("/alive", new HealthCheckOptions
        {
            Predicate = r => r.Tags.Contains("live")
        });

        return app;
    }
}

这个配置文件中最关键的部分是 WithLogging 方法的调用。logging.AddOtlpExporter() 这一行代码看起来简单,但它内部完成了复杂的配置工作。它会读取环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 来确定 Dashboard 的地址,然后创建一个 gRPC 客户端,定期将日志批量发送到 Dashboard。如果这个导出器没有正确配置,或者环境变量缺失,日志就无法到达 Dashboard。

配置好 ServiceDefaults 项目后,需要在每个服务项目中引用它。这不仅仅是添加项目引用那么简单,还需要在服务的启动代码中显式调用配置方法。让我们以一个 API 服务为例,展示完整的配置流程:

csharp 复制代码
// WeatherApi/Program.cs
using Microsoft.Extensions.Hosting;

var builder = WebApplication.CreateBuilder(args);

// 这是最关键的一行代码
// 它会应用 ServiceDefaults 中定义的所有配置
// 如果忘记调用这个方法,日志就不会被收集
builder.AddServiceDefaults();

// 添加你的服务
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// 如果需要额外的日志配置,可以在这里添加
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Information);

var app = builder.Build();

// 映射默认端点,这对于健康检查和日志收集都很重要
app.MapDefaultEndpoints();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

// 在应用启动时记录一条日志,验证日志系统是否工作
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("WeatherApi service starting up at {Time}", DateTime.UtcNow);

app.Run();

builder.AddServiceDefaults() 这个调用会触发前面在 ServiceDefaults 项目中定义的所有配置逻辑。它会注册 OpenTelemetry 服务,配置日志导出器,设置服务发现,并添加健康检查。如果这个调用缺失,即使你在代码中写了 logger.LogInformation(),这些日志也只会输出到控制台,而不会发送到 Dashboard。

现在让我们在一个控制器中实际使用日志功能,并理解日志是如何流经整个系统的:

csharp 复制代码
// WeatherApi/Controllers/WeatherForecastController.cs
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private readonly ILogger<WeatherForecastController> _logger;

    // 通过依赖注入获取 ILogger 实例
    // 这个 logger 已经配置好了 OTLP 导出器
    public WeatherForecastController(ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
    }

    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get()
    {
        // 记录方法调用
        // 这条日志会同时输出到控制台和发送到 Dashboard
        _logger.LogInformation("GetWeatherForecast endpoint called at {RequestTime}", DateTime.UtcNow);

        try
        {
            var forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();

            // 使用结构化日志记录详细信息
            // 注意这里使用了占位符而不是字符串插值
            // 这样 OpenTelemetry 可以提取结构化数据
            _logger.LogInformation(
                "Generated {ForecastCount} weather forecasts. Temperature range: {MinTemp}°C to {MaxTemp}°C",
                forecasts.Length,
                forecasts.Min(f => f.TemperatureC),
                forecasts.Max(f => f.TemperatureC));

            return forecasts;
        }
        catch (Exception ex)
        {
            // 记录异常
            // 异常的堆栈跟踪会自动包含在日志中
            _logger.LogError(ex, "Error generating weather forecasts");
            throw;
        }
    }
}

当你运行这段代码并调用 API 端点时,日志的流转过程是这样的:首先,ILoggerLogInformation 方法被调用,日志消息被传递给所有注册的日志提供程序。其中一个提供程序是 OpenTelemetry 的日志提供程序,它会将日志转换为 OpenTelemetry 的 LogRecord 格式。然后,LogRecord 被添加到一个内存缓冲区中,BatchLogRecordExportProcessor 定期从这个缓冲区中取出日志,批量发送到 OTLP 导出器。最后,OTLP 导出器通过 gRPC 将日志发送到 Dashboard 的后端服务,Dashboard 接收到日志后将其存储并在 UI 中展示。

如果在这个流程中,你发现日志仍然没有出现在 Dashboard 中,需要检查几个关键的配置点。首先在 AppHost 项目中确认 Dashboard 是否正确启动,并且环境变量是否正确传递给了服务:

csharp 复制代码
// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// 注册 WeatherApi 服务
var weatherApi = builder.AddProject<Projects.WeatherApi>("weatherapi")
    .WithHttpEndpoint(port: 5100);

// AppHost 会自动注入环境变量,包括 OTEL_EXPORTER_OTLP_ENDPOINT
// 但如果需要,可以手动验证这些变量
var app = builder.Build();

await app.RunAsync();

当 AppHost 启动时,它会自动为每个服务设置 OTEL_EXPORTER_OTLP_ENDPOINT 环境变量,指向 Dashboard 的 OTLP 接收端点。你可以在 Dashboard 的 Environment 页面查看每个服务的环境变量,确认这个变量是否存在且值正确。它应该类似于 http://localhost:18889http://localhost:4317,具体端口取决于 Dashboard 的配置。

如果环境变量正确但日志仍然不显示,可能是日志级别过滤导致的问题。OpenTelemetry 导出器默认只导出 Information 级别及以上的日志。如果你的日志使用了 Debug 或 Trace 级别,它们不会被发送到 Dashboard。可以通过配置来调整这个行为:

csharp 复制代码
// Program.cs 中配置最低日志级别
builder.Logging.SetMinimumLevel(LogLevel.Debug);

// 或者在 appsettings.json 中配置
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "YourNamespace": "Debug"
    },
    "OpenTelemetry": {
      "LogLevel": {
        "Default": "Information",
        "OpenTelemetry": "Warning"
      }
    }
  }
}

另一个需要注意的问题是日志的刷新延迟。由于 BatchLogRecordExportProcessor 使用批处理来优化性能,日志不是立即发送到 Dashboard 的,而是每隔几秒钟批量发送一次。默认的延迟是 5 秒,这意味着你可能需要等待几秒钟才能在 Dashboard 中看到新的日志。如果需要更快的日志刷新,可以调整批处理器的配置:

csharp 复制代码
// 在 ServiceDefaults/Extensions.cs 中调整批处理参数
.WithLogging(logging =>
{
    logging.AddOtlpExporter();
    
    // 自定义批处理器,减少延迟
    logging.AddProcessor(new BatchLogRecordExportProcessor(
        new OtlpLogExporter(),
        maxQueueSize: 2048,
        scheduledDelayMilliseconds: 1000,  // 减少到 1 秒
        exporterTimeoutMilliseconds: 30000,
        maxExportBatchSize: 512));
})

如果你使用的是 Docker 容器,还需要确保容器之间的网络连接正常。Dashboard 和服务容器必须在同一个 Docker 网络中才能通信。Aspire 通常会自动处理这个问题,但如果你手动配置了容器,需要验证网络设置:

shell 复制代码
# 检查 Aspire 创建的 Docker 网络
docker network ls | findstr aspire

# 查看网络详情
docker network inspect <network-name>

# 确认所有相关容器都连接到这个网络
docker ps --format "table {{.Names}}\t{{.Networks}}"

通过遵循这些详细的配置步骤和理解日志系统的工作原理,你应该能够解决 Dashboard 中日志不显示的问题。关键是要确保三个要素齐全:ServiceDefaults 项目正确配置、每个服务调用了 AddServiceDefaults()、以及 Dashboard 能够正常接收 OTLP 数据。一旦这些配置到位,你就能充分利用 Aspire Dashboard 的强大日志分析功能,大大提高分布式应用的可观测性和可维护性。

1.4 性能下降和内存泄漏

问题现象: 应用刚启动时一切正常,响应快速,资源使用合理。但随着时间推移,可能是几小时后,也可能是几天后,你会发现应用变得越来越慢。API 请求的响应时间从原来的几十毫秒逐渐增加到几秒钟,Web 页面加载缓慢,用户开始抱怨系统卡顿。与此同时,如果你查看任务管理器或 Dashboard 的 Metrics 页面,会发现应用的内存占用在持续增长,从最初的几百 MB 逐渐攀升到几个 GB,CPU 使用率也可能居高不下。这是典型的性能下降和内存泄漏问题,如果不及时解决,最终会导致应用崩溃或系统资源耗尽。

性能下降的原因多种多样,但在 .NET 应用中,最常见的罪魁祸首包括:未释放的资源(如数据库连接、HTTP 客户端、文件句柄)、事件订阅未取消导致的对象无法被垃圾回收、大对象堆碎片、频繁的完整 GC(Generation 2 GC)、以及不当的异步编程模式。理解这些问题的本质,并学会使用 Aspire 和 .NET 提供的诊断工具,是解决性能问题的关键。

首先我们需要建立一套监控体系来捕获性能问题的早期信号。Aspire Dashboard 的 Metrics 页面是最直接的工具,它实时展示了应用的各项性能指标。当你怀疑存在性能问题时,应该首先打开 Dashboard 并导航到 Metrics 页面,在这里你可以看到多个关键指标的时间序列图表。内存使用量(process.memory.usage)是最重要的指标之一,如果这个图表显示出持续上升且从不下降的趋势,即使经过多次 GC 也无法回收内存,那几乎可以确定存在内存泄漏。正常情况下,内存使用量应该呈现锯齿状波形,上升代表对象分配,下降代表 GC 回收,如果只有上升没有下降,就说明有对象一直被某些引用持有,无法被回收。

GC 收集次数(process.runtime.dotnet.gc.collections.count)是另一个关键指标。这个指标会分别显示 Gen0、Gen1 和 Gen2 三代 GC 的收集次数。Gen0 和 Gen1 的收集是正常且频繁的,因为它们处理的是短期存活的对象,收集速度很快,对性能影响小。但如果你看到 Gen2 收集次数快速增长,就需要警惕了。Gen2 GC 是完整的垃圾回收,它会扫描整个堆,耗时长,会导致应用暂停(Stop-the-World)。频繁的 Gen2 GC 不仅说明有大量长期存活的对象,还会严重影响应用的响应性能。

HTTP 请求指标也能揭示性能问题。http.server.request.duration 显示了服务器处理请求的耗时分布,如果你看到这个指标的 P95 或 P99 百分位数在持续增长,说明越来越多的请求变慢了。http.server.active_requests 显示当前正在处理的活跃请求数,如果这个数字持续在高位,可能是请求处理出现了阻塞,导致请求积压。线程池指标(threadpool.thread.countthreadpool.queue.length)同样重要,如果线程池队列长度不断增长,说明有太多的工作项在等待执行,这通常意味着存在同步阻塞代码或者异步操作的滥用。

让我们通过一个实际的代码示例来演示如何在应用中埋点收集自定义性能指标。假设我们有一个订单处理服务,我们想要监控订单处理的耗时、成功率以及各种资源的使用情况:

csharp 复制代码
// OrderService.cs
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;

public class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly HttpClient _httpClient;
    private readonly AppDbContext _dbContext;
    
    // 创建一个 Meter 用于发布自定义指标
    // Meter 的名称应该与你的服务名称一致,便于在 Dashboard 中识别
    private static readonly Meter s_meter = new("OrderService", "1.0.0");
    
    // 定义各种指标
    // Counter 用于累计值,如订单总数
    private static readonly Counter<long> s_orderProcessedCounter = 
        s_meter.CreateCounter<long>(
            name: "orders.processed",
            unit: "orders",
            description: "Total number of orders processed");
    
    // Histogram 用于记录值的分布,如处理耗时
    private static readonly Histogram<double> s_orderProcessingDuration = 
        s_meter.CreateHistogram<double>(
            name: "orders.processing.duration",
            unit: "ms",
            description: "Duration of order processing in milliseconds");
    
    // ObservableGauge 用于快照值,如当前队列长度
    private static readonly ObservableGauge<int> s_pendingOrdersGauge;
    
    private static int s_pendingOrders = 0;

    static OrderService()
    {
        // 注册可观察指标,这个回调会被定期调用
        s_pendingOrdersGauge = s_meter.CreateObservableGauge(
            name: "orders.pending",
            observeValue: () => s_pendingOrders,
            unit: "orders",
            description: "Number of orders pending processing");
    }

    public OrderService(
        ILogger<OrderService> logger,
        HttpClient httpClient,
        AppDbContext dbContext)
    {
        _logger = logger;
        _httpClient = httpClient;
        _dbContext = dbContext;
    }

    public async Task<OrderResult> ProcessOrderAsync(Order order)
    {
        // 使用 Stopwatch 测量处理耗时
        var sw = Stopwatch.StartNew();
        
        // 增加待处理订单计数
        Interlocked.Increment(ref s_pendingOrders);
        
        try
        {
            _logger.LogInformation(
                "Starting to process order {OrderId} for customer {CustomerId}",
                order.Id, order.CustomerId);

            // 步骤 1:验证订单
            // 为每个关键步骤创建独立的 Activity,便于在分布式追踪中分析
            using (var validateActivity = Activity.Current?.Source.StartActivity("ValidateOrder"))
            {
                validateActivity?.SetTag("order.id", order.Id);
                await ValidateOrderAsync(order);
            }

            // 步骤 2:检查库存
            using (var inventoryActivity = Activity.Current?.Source.StartActivity("CheckInventory"))
            {
                inventoryActivity?.SetTag("order.id", order.Id);
                inventoryActivity?.SetTag("order.items.count", order.Items.Count);
                
                var inventoryCheckStart = Stopwatch.GetTimestamp();
                var inventoryAvailable = await CheckInventoryAsync(order);
                var inventoryCheckDuration = Stopwatch.GetElapsedTime(inventoryCheckStart);
                
                inventoryActivity?.SetTag("inventory.check.duration.ms", inventoryCheckDuration.TotalMilliseconds);
                inventoryActivity?.SetTag("inventory.available", inventoryAvailable);

                if (!inventoryAvailable)
                {
                    _logger.LogWarning("Insufficient inventory for order {OrderId}", order.Id);
                    inventoryActivity?.SetStatus(ActivityStatusCode.Error, "Insufficient inventory");
                    return OrderResult.Failed("Insufficient inventory");
                }
            }

            // 步骤 3:处理支付
            using (var paymentActivity = Activity.Current?.Source.StartActivity("ProcessPayment"))
            {
                paymentActivity?.SetTag("order.id", order.Id);
                paymentActivity?.SetTag("payment.amount", order.TotalAmount);
                
                var paymentStart = Stopwatch.GetTimestamp();
                
                try
                {
                    var paymentResult = await ProcessPaymentAsync(order);
                    var paymentDuration = Stopwatch.GetElapsedTime(paymentStart);
                    
                    paymentActivity?.SetTag("payment.duration.ms", paymentDuration.TotalMilliseconds);
                    paymentActivity?.SetTag("payment.transaction.id", paymentResult.TransactionId);
                    
                    if (!paymentResult.Success)
                    {
                        _logger.LogError("Payment failed for order {OrderId}: {Reason}", 
                            order.Id, paymentResult.FailureReason);
                        paymentActivity?.SetStatus(ActivityStatusCode.Error, paymentResult.FailureReason);
                        return OrderResult.Failed($"Payment failed: {paymentResult.FailureReason}");
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Exception during payment processing for order {OrderId}", order.Id);
                    paymentActivity?.SetStatus(ActivityStatusCode.Error, ex.Message);
                    paymentActivity?.RecordException(ex);
                    throw;
                }
            }

            // 步骤 4:保存订单到数据库
            using (var saveActivity = Activity.Current?.Source.StartActivity("SaveOrder"))
            {
                saveActivity?.SetTag("order.id", order.Id);
                
                var saveStart = Stopwatch.GetTimestamp();
                await SaveOrderToDbAsync(order);
                var saveDuration = Stopwatch.GetElapsedTime(saveStart);
                
                saveActivity?.SetTag("db.save.duration.ms", saveDuration.TotalMilliseconds);
                
                // 如果数据库操作很慢,记录警告
                if (saveDuration.TotalMilliseconds > 1000)
                {
                    _logger.LogWarning(
                        "Slow database operation detected for order {OrderId}: {Duration}ms",
                        order.Id, saveDuration.TotalMilliseconds);
                }
            }

            // 步骤 5:发送确认通知
            using (var notifyActivity = Activity.Current?.Source.StartActivity("SendNotification"))
            {
                notifyActivity?.SetTag("order.id", order.Id);
                notifyActivity?.SetTag("customer.email", order.CustomerEmail);
                
                // 通知失败不应该导致整个订单处理失败,所以捕获异常
                try
                {
                    await SendOrderConfirmationAsync(order);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Failed to send order confirmation for {OrderId}", order.Id);
                    notifyActivity?.SetStatus(ActivityStatusCode.Error, ex.Message);
                    // 继续执行,不抛出异常
                }
            }

            sw.Stop();
            
            // 记录成功的订单处理
            s_orderProcessedCounter.Add(1, 
                new KeyValuePair<string, object?>("status", "success"),
                new KeyValuePair<string, object?>("customer.id", order.CustomerId));
            
            // 记录处理耗时
            s_orderProcessingDuration.Record(sw.Elapsed.TotalMilliseconds,
                new KeyValuePair<string, object?>("status", "success"));

            _logger.LogInformation(
                "Order {OrderId} processed successfully in {Duration}ms",
                order.Id, sw.Elapsed.TotalMilliseconds);

            return OrderResult.Success(order.Id);
        }
        catch (Exception ex)
        {
            sw.Stop();
            
            // 记录失败的订单处理
            s_orderProcessedCounter.Add(1,
                new KeyValuePair<string, object?>("status", "failed"),
                new KeyValuePair<string, object?>("error.type", ex.GetType().Name));
            
            s_orderProcessingDuration.Record(sw.Elapsed.TotalMilliseconds,
                new KeyValuePair<string, object?>("status", "failed"));

            _logger.LogError(ex, 
                "Order {OrderId} processing failed after {Duration}ms: {Error}",
                order.Id, sw.Elapsed.TotalMilliseconds, ex.Message);

            throw;
        }
        finally
        {
            // 减少待处理订单计数
            Interlocked.Decrement(ref s_pendingOrders);
        }
    }

    private async Task ValidateOrderAsync(Order order)
    {
        // 验证逻辑
        if (order.Items.Count == 0)
            throw new InvalidOperationException("Order must contain at least one item");
        
        await Task.Delay(10); // 模拟验证耗时
    }

    private async Task<bool> CheckInventoryAsync(Order order)
    {
        // 这里演示一个常见的性能陷阱:同步等待异步操作
        // 错误做法(会导致线程阻塞):
        // return _httpClient.GetAsync("/inventory/check").Result.IsSuccessStatusCode;
        
        // 正确做法:
        try
        {
            var response = await _httpClient.PostAsJsonAsync("/api/inventory/check", 
                new { OrderId = order.Id, Items = order.Items });
            response.EnsureSuccessStatusCode();
            var result = await response.Content.ReadFromJsonAsync<InventoryCheckResult>();
            return result?.AllItemsAvailable ?? false;
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "Inventory service unavailable");
            return false;
        }
    }

    private async Task<PaymentResult> ProcessPaymentAsync(Order order)
    {
        // 模拟支付处理
        await Task.Delay(200);
        return new PaymentResult 
        { 
            Success = true, 
            TransactionId = Guid.NewGuid().ToString() 
        };
    }

    private async Task SaveOrderToDbAsync(Order order)
    {
        // 这里演示数据库操作的性能优化
        // 错误做法:每次都创建新的 DbContext
        // using var db = new AppDbContext();
        // db.Orders.Add(order);
        // await db.SaveChangesAsync();
        
        // 正确做法:使用注入的 DbContext,利用连接池
        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync();
        
        // 注意:确保 DbContext 的生命周期是 Scoped
        // 不要在单例服务中持有 DbContext 的引用,这会导致内存泄漏
    }

    private async Task SendOrderConfirmationAsync(Order order)
    {
        // 发送邮件或消息
        await Task.Delay(50);
    }
}

在这个示例中,我们使用了多种技术来捕获性能数据。Meter 和相关的指标类型(Counter、Histogram、ObservableGauge)是 .NET 的标准指标 API,它们会自动与 OpenTelemetry 集成,数据会被发送到 Aspire Dashboard。Activity 是分布式追踪的核心类,每个 Activity 代表一个操作单元,可以嵌套形成调用树,在 Dashboard 的 Traces 页面可以看到完整的调用链和每个步骤的耗时。

现在让我们配置应用程序以启用这些指标的收集。在 Service Defaults 项目中,我们需要注册自定义的 Meter:

csharp 复制代码
// ServiceDefaults/Extensions.cs
public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
{
    builder.Services.AddOpenTelemetry()
        .ConfigureResource(resource =>
        {
            resource.AddService(
                serviceName: builder.Environment.ApplicationName,
                serviceVersion: "1.0.0");
        })
        .WithMetrics(metrics =>
        {
            // 添加运行时指标
            metrics.AddRuntimeInstrumentation();
            
            // 添加 ASP.NET Core 指标
            metrics.AddAspNetCoreInstrumentation();
            
            // 添加 HTTP 客户端指标
            metrics.AddHttpClientInstrumentation();
            
            // 添加我们自定义的 Meter
            // 这个名称必须与代码中 new Meter("OrderService") 的名称匹配
            metrics.AddMeter("OrderService");
            
            // 如果有多个自定义 Meter,都需要在这里注册
            metrics.AddMeter("PaymentService");
            metrics.AddMeter("InventoryService");
            
            // 配置 OTLP 导出器
            metrics.AddOtlpExporter();
        })
        .WithTracing(tracing =>
        {
            // 配置追踪的采样策略
            // 在开发环境可以使用 AlwaysOnSampler,捕获所有追踪
            // 在生产环境应该使用 TraceIdRatioBasedSampler 降低开销
            tracing.SetSampler(new AlwaysOnSampler());
            
            tracing.AddAspNetCoreInstrumentation(options =>
            {
                // 记录异常详情
                options.RecordException = true;
                
                // 为 Activity 添加自定义标签
                options.EnrichWithHttpRequest = (activity, httpRequest) =>
                {
                    activity.SetTag("http.request.header.user_agent", 
                        httpRequest.Headers.UserAgent.ToString());
                    activity.SetTag("http.request.header.host", 
                        httpRequest.Headers.Host.ToString());
                };
                
                options.EnrichWithHttpResponse = (activity, httpResponse) =>
                {
                    activity.SetTag("http.response.content_length", 
                        httpResponse.ContentLength);
                };
            });
            
            tracing.AddHttpClientInstrumentation();
            tracing.AddSqlClientInstrumentation(options =>
            {
                // 记录 SQL 命令文本,注意可能包含敏感数据
                options.SetDbStatementForText = true;
                options.RecordException = true;
            });
            
            // 为我们的自定义 Activity Source 添加追踪
            tracing.AddSource("OrderService");
            
            tracing.AddOtlpExporter();
        });

    return builder;
}

当你的应用配置好这些指标收集后,运行应用并在 Dashboard 中观察这些数据。如果你发现性能问题,比如 orders.processing.duration 的 P95 百分位数持续增长,或者 orders.pending 计数一直居高不下,就需要深入分析了。切换到 Traces 页面,按照耗时排序,找出最慢的那些请求。点击某个慢请求,你会看到一个火焰图或瀑布图,展示了整个请求的执行过程。每个矩形块代表一个 Span(Activity),块的宽度代表耗时,块之间的层级关系代表调用关系。

如果你发现某个特定的 Span 特别慢,比如 "CheckInventory" Span 耗时 2 秒,而正常情况下应该只需要几十毫秒,那就找到了性能瓶颈。点击这个 Span 可以查看它的详细属性,包括你在代码中设置的所有标签。可能你会发现 inventory.check.duration.ms 标签显示实际的 HTTP 请求只用了 100ms,但整个 Span 却用了 2 秒,这说明问题不在网络请求本身,而在其他地方,比如序列化、反序列化或者某些同步操作。

对于内存泄漏问题,Dashboard 的 Metrics 页面可以提供初步线索,但要真正找出泄漏的根源,需要使用更专业的工具。dotnet-counters 是一个命令行工具,可以实时监控 .NET 应用的性能计数器,它提供了比 Dashboard 更细粒度的数据。首先安装这个工具:

shell 复制代码
dotnet tool install --global dotnet-counters

然后使用它来监控你的应用:

shell 复制代码
# 首先找到应用的进程 ID
dotnet-counters ps

# 输出示例:
# 12345 OrderService    /path/to/OrderService.dll
# 12346 PaymentService  /path/to/PaymentService.dll

# 监控特定进程的所有计数器
dotnet-counters monitor --process-id 12345

# 或者只监控特定的计数器组
dotnet-counters monitor --process-id 12345 --counters System.Runtime,Microsoft.AspNetCore.Hosting

# 持续监控并将数据导出到文件
dotnet-counters collect --process-id 12345 --output counters.csv --format csv

dotnet-counters monitor 会显示一个实时更新的表格,其中包含大量有用的指标。关注以下几个关键指标来诊断内存泄漏:

复制代码
System.Runtime
  - GC Heap Size (MB): 当前堆大小,如果持续增长是泄漏的信号
  - Gen 0 GC Count: 零代 GC 次数,频繁是正常的
  - Gen 1 GC Count: 一代 GC 次数,适度频繁是正常的
  - Gen 2 GC Count: 二代 GC 次数,如果频繁说明有问题
  - % Time in GC: GC 占用的 CPU 时间百分比,超过 10% 就要警惕
  - Allocation Rate (MB/sec): 对象分配速率,持续高速分配会导致频繁 GC
  - LOH Size (MB): 大对象堆大小,这个堆不会被压缩,容易碎片化
  - Number of Active Timers: 活跃的定时器数量,泄漏的定时器不会被停止

如果 dotnet-counters 显示 GC Heap Size 在持续增长,且 Gen 2 GC Count 也在快速增加但内存并没有被回收,那基本可以确认存在内存泄漏。下一步是使用 dotnet-gcdumpdotnet-dump 来捕获内存快照,然后分析哪些对象占用了大量内存且无法被回收:

shell 复制代码
# 安装 dotnet-gcdump 工具
dotnet tool install --global dotnet-gcdump

# 收集 GC 堆快照(这是一个轻量级快照,只包含托管堆信息)
dotnet-gcdump collect --process-id 12345 --output heap-snapshot.gcdump

# 安装 dotnet-dump 工具
dotnet tool install --global dotnet-dump

# 收集完整的进程转储(包含托管和非托管内存,文件较大)
dotnet-dump collect --process-id 12345 --output full-dump.dmp

收集到快照后,可以使用 Visual Studio 打开 .gcdump 文件进行分析。Visual Studio 会显示一个内存分析视图,列出所有对象类型及其实例数量和占用的内存大小。按照 "Size (Bytes)" 列排序,找出占用内存最多的对象类型。常见的内存泄漏源包括:

未释放的 HttpClient 实例 。HttpClient 应该作为单例或通过 IHttpClientFactory 创建,而不是每次使用都 new 一个新实例。每个 HttpClient 实例都会创建一个底层的 HttpMessageHandler,它持有 TCP 连接池,如果不正确释放会导致连接泄漏和内存泄漏。

事件订阅未取消。如果对象 A 订阅了对象 B 的事件,B 会持有对 A 的引用,即使 A 在逻辑上已经不再使用,它也无法被 GC 回收。解决方法是在对象生命周期结束时显式取消订阅,或者使用弱事件模式。

缓存对象没有设置过期策略 。内存缓存(如 IMemoryCache)如果不设置合理的过期时间和大小限制,会无限增长。应该为每个缓存项设置 AbsoluteExpirationSlidingExpiration,并使用 MemoryCacheOptions 配置全局大小限制。

静态集合不断添加元素 。静态字段的生命周期与应用程序相同,如果静态 ListDictionary 不断添加元素而从不清理,就会导致内存泄漏。典型的例子是日志缓冲区、事件聚合器或自定义的对象池。

定时器未停止System.Threading.TimerSystem.Timers.Timer 如果创建后不调用 Dispose,它们会一直运行,并持有回调函数的引用。如果回调函数捕获了外部对象,这些对象也无法被回收。

让我们看一个修复内存泄漏的完整示例。假设我们发现一个服务类存在泄漏:

csharp 复制代码
// 有问题的代码(会导致内存泄漏)
public class LeakyNotificationService : INotificationService
{
    // 问题 1:静态集合不断增长
    private static readonly List<NotificationLog> s_notificationHistory = new();
    
    // 问题 2:每次调用都创建新的 HttpClient
    public async Task SendNotificationAsync(string message)
    {
        // 这会导致 HttpClient 和连接泄漏
        using var client = new HttpClient();
        await client.PostAsync("https://api.notifications.com/send", 
            new StringContent(message));
        
        // 这会导致静态集合无限增长
        s_notificationHistory.Add(new NotificationLog 
        { 
            Message = message, 
            Timestamp = DateTime.UtcNow 
        });
    }
    
    // 问题 3:事件订阅从未取消
    private IEventAggregator _eventAggregator;
    
    public LeakyNotificationService(IEventAggregator eventAggregator)
    {
        _eventAggregator = eventAggregator;
        // 这个订阅会导致 LeakyNotificationService 实例永远不会被 GC 回收
        _eventAggregator.Subscribe<OrderCreatedEvent>(OnOrderCreated);
    }
    
    private void OnOrderCreated(OrderCreatedEvent e)
    {
        SendNotificationAsync($"Order {e.OrderId} created").Wait();
    }
}

// 修复后的代码
public class FixedNotificationService : INotificationService, IDisposable
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IMemoryCache _cache;
    private readonly IEventAggregator _eventAggregator;
    private readonly ILogger<FixedNotificationService> _logger;
    private IDisposable? _eventSubscription;
    
    // 使用有大小限制的缓存替代静态列表
    private const int MaxHistorySize = 1000;
    
    public FixedNotificationService(
        IHttpClientFactory httpClientFactory,
        IMemoryCache cache,
        IEventAggregator eventAggregator,
        ILogger<FixedNotificationService> logger)
    {
        _httpClientFactory = httpClientFactory;
        _cache = cache;
        _eventAggregator = eventAggregator;
        _logger = logger;
        
        // 保存订阅引用,以便后续取消
        _eventSubscription = _eventAggregator.Subscribe<OrderCreatedEvent>(OnOrderCreated);
    }
    
    public async Task SendNotificationAsync(string message)
    {
        // 使用 IHttpClientFactory 创建 HttpClient
        // 这样可以复用底层的 HttpMessageHandler,避免连接泄漏
        var client = _httpClientFactory.CreateClient("NotificationApi");
        
        try
        {
            await client.PostAsync("/send", new StringContent(message));
            
            // 使用有过期时间的缓存存储历史记录
            var logKey = $"notification_log_{Guid.NewGuid()}";
            var log = new NotificationLog 
            { 
                Message = message, 
                Timestamp = DateTime.UtcNow 
            };
            
            // 设置 10 分钟的滑动过期时间
            _cache.Set(logKey, log, new MemoryCacheEntryOptions
            {
                SlidingExpiration = TimeSpan.FromMinutes(10),
                // 可以设置缓存的优先级,当内存紧张时低优先级项会被优先移除
                Priority = CacheItemPriority.Low,
                // 设置回调,当项被移除时记录日志
                PostEvictionCallbacks =
                {
                    new PostEvictionCallbackRegistration
                    {
                        EvictionCallback = (key, value, reason, state) =>
                        {
                            if (reason == EvictionReason.Capacity)
                            {
                                _logger.LogWarning("Notification log evicted due to capacity limits");
                            }
                        }
                    }
                }
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send notification");
            throw;
        }
    }
    
    private async void OnOrderCreated(OrderCreatedEvent e)
    {
        try
        {
            await SendNotificationAsync($"Order {e.OrderId} created");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send order created notification");
        }
    }
    
    // 实现 IDisposable,确保资源被正确释放
    public void Dispose()
    {
        // 取消事件订阅,这样 EventAggregator 就不再持有对此对象的引用
        _eventSubscription?.Dispose();
        _eventSubscription = null;
    }
}

// 在 Program.cs 中正确注册服务
builder.Services.AddMemoryCache(options =>
{
    // 设置缓存的大小限制(单位是任意的,需要为每个缓存项设置 Size)
    options.SizeLimit = 1000;
    
    // 设置缓存压缩的时间间隔
    options.ExpirationScanFrequency = TimeSpan.FromMinutes(5);
});

// 配置 HttpClient Factory
builder.Services.AddHttpClient("NotificationApi", client =>
{
    client.BaseAddress = new Uri("https://api.notifications.com");
    client.Timeout = TimeSpan.FromSeconds(30);
    client.DefaultRequestHeaders.Add("User-Agent", "AspireApp/1.0");
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());

// 注册通知服务为 Scoped,确保每个请求有独立的实例
// 如果注册为 Singleton,整个应用生命周期只有一个实例,可能导致状态泄漏
builder.Services.AddScoped<INotificationService, FixedNotificationService>();

通过这些详细的诊断步骤和代码示例,你应该能够有效地识别和解决性能下降和内存泄漏问题。关键是要建立全面的监控体系,使用 Aspire Dashboard、dotnet-countersdotnet-gcdump 等工具收集数据,然后根据数据找出问题的根源。记住,性能优化是一个持续的过程,需要在开发早期就建立良好的编码习惯,避免常见的陷阱,并定期审查应用的性能指标。

二、调试技巧和工具

2.1 Aspire Dashboard 的强大功能

Aspire Dashboard 是 .NET Aspire 生态系统中最强大和最直观的故障排查工具,它在你启动 AppHost 项目时会自动启动并运行。这个基于 Web 的仪表板不仅仅是一个简单的监控界面,它是一个完整的可观测性平台,将日志、追踪、指标和实时状态监控整合到一个统一的界面中。当你在开发分布式应用时遇到问题,Dashboard 往往是你的第一站,也是最有效的诊断起点。

当你启动 AppHost 项目后,控制台会输出 Dashboard 的访问地址,通常是类似 https://localhost:17360 这样的本地 HTTPS 地址。端口号是动态分配的,每次运行可能略有不同,但你总能在 AppHost 的控制台输出中找到准确的 URL。现代浏览器打开这个地址后,你会看到一个深色主题的现代化界面,顶部有导航标签页,每个标签页对应一种诊断视角。理解每个页面的用途和使用方法,是掌握 Aspire 故障排查的关键。

Projects 页面是 Dashboard 的首页,也是你最常访问的页面。这个页面以表格形式展示了 AppHost 中注册的所有资源,包括项目、容器、可执行文件等。每一行代表一个资源,显示了资源的名称、类型、状态、端点地址等关键信息。状态指示器使用直观的颜色编码:绿色表示正在运行(Running),黄色表示正在启动(Starting),红色表示失败(Failed),灰色表示已停止(Stopped)。这个简单的视觉反馈让你能够一眼看出系统的整体健康状况。

假设你正在开发一个电商应用,AppHost 中注册了 API 服务、Web 前端、Redis 缓存和 PostgreSQL 数据库。在 Projects 页面,你会看到四行数据,每一行显示相应资源的详细信息。点击某个资源的名称,会展开一个详情面板,显示更多信息,包括启动命令、工作目录、环境变量、日志输出等。如果某个服务显示为红色失败状态,点击它可以立即查看失败原因,通常是端口冲突、配置错误或依赖项缺失。端点地址列显示了服务暴露的 HTTP 和 HTTPS 地址,点击这些链接可以直接在浏览器中打开服务,这对于快速测试 API 端点或访问 Web 界面非常有用。

让我们通过一个具体的例子来说明如何使用 Projects 页面进行故障排查。假设你的 API 服务启动失败,在 Projects 页面显示为红色状态。点击 API 服务的名称展开详情,你会看到最近的日志输出。这些日志可能显示类似 "Failed to bind to address https://localhost:5001: address already in use" 的错误信息。这告诉你端口 5001 已经被其他程序占用。你可以使用 Windows 的 netstat -ano | findstr :5001 命令找到占用端口的进程,终止它,或者在 AppHost 中修改端口配置。Projects 页面的实时更新特性意味着一旦你修复了问题并重新启动服务,状态指示器会立即变为绿色,无需刷新页面。

Logs 页面提供了整个应用集群的统一日志视图,这是它最强大的功能之一。在传统的微服务架构中,每个服务都有自己的日志文件或控制台输出,排查跨服务问题需要在多个窗口之间切换,非常繁琐。Aspire Dashboard 的 Logs 页面解决了这个痛点,它将所有服务的日志按时间顺序整合在一起,并提供了丰富的过滤和搜索功能。页面顶部有一系列过滤器,包括日志级别(Trace、Debug、Information、Warning、Error、Critical)、服务名称、时间范围等。你可以组合使用这些过滤器来快速定位你关心的日志条目。

日志表格的每一行代表一条日志消息,包含时间戳、服务名称、日志级别、消息内容和相关的追踪 ID。时间戳精确到毫秒,这对于分析高并发场景下的竞争条件非常有用。服务名称列用不同的颜色标识不同的服务,让你能够快速识别日志来源。日志级别用图标和颜色表示,Error 和 Critical 级别的日志会以红色高亮显示,立即吸引你的注意力。消息内容列是最宽的列,显示了完整的日志消息。如果消息包含结构化数据(通过日志占位符记录的参数),这些数据会被提取出来显示在单独的列中,便于筛选和分析。

举个实际的例子,假设你的应用中有一个订单处理流程,涉及 Web 前端、订单 API、支付 API 和库存 API 四个服务。用户报告某个订单提交后没有响应。你打开 Logs 页面,首先在时间范围选择器中缩小到用户报告问题的时间段,比如最近 5 分钟。然后在日志级别过滤器中选择 Warning、Error 和 Critical,过滤掉常规的 Information 级别日志。现在日志列表大大缩短了,你可以看到在用户提交订单的时间点附近,支付 API 记录了一条 Error 级别的日志:"Payment gateway timeout after 30 seconds for order 12345"。点击这条日志展开详情,你会看到完整的异常堆栈跟踪和相关的上下文信息,包括订单 ID、支付金额、支付方式等。这个信息足够你初步判断问题可能出在支付网关的连接上。

Logs 页面的搜索功能非常强大,支持全文搜索和正则表达式。在搜索框中输入关键字,比如 "order 12345",Dashboard 会实时过滤出所有包含这个关键字的日志条目。这个功能在追踪特定请求或用户操作时特别有用。你还可以点击某条日志右侧的"Show in Context"按钮,Dashboard 会在新窗口中显示这条日志前后的相关日志,帮助你理解完整的执行上下文。如果日志消息中包含 Trace ID 或 Span ID(通过 OpenTelemetry 自动添加),点击这些 ID 会直接跳转到 Traces 页面的相应追踪详情,实现日志和追踪的无缝关联。

Traces 页面是分析性能问题和理解分布式调用流程的核心工具。分布式追踪(Distributed Tracing)是可观测性的三大支柱之一,它通过记录请求在各个服务之间的流转路径和每个操作的耗时,让你能够可视化地看到整个请求的生命周期。在微服务架构中,一个用户请求可能会触发十几个甚至几十个内部服务调用,没有分布式追踪,根本无法理解这个复杂的调用链。Aspire 通过集成 OpenTelemetry,自动为所有 HTTP 请求、数据库查询、消息队列操作等创建追踪 Span,你不需要写任何额外代码就能获得开箱即用的追踪能力。

Traces 页面的主视图是一个追踪列表,按时间倒序显示最近的追踪。每条追踪对应一个顶层请求,比如一个 HTTP 请求到达 Web 前端。列表显示了追踪的开始时间、持续时间、涉及的服务数量、Span 数量和状态(成功或失败)。持续时间列是最重要的性能指标,它告诉你这个请求从开始到结束花了多长时间。你可以按持续时间排序,快速找出最慢的请求。点击某个追踪进入详情页面,你会看到一个火焰图或瀑布图,这是追踪可视化的核心。

火焰图以横向的矩形条表示每个 Span,条的宽度代表 Span 的持续时间,条的纵向位置代表 Span 在调用栈中的层级。最上面的条通常是整个请求的根 Span,代表用户的原始请求。下面的条是子 Span,代表这个请求触发的各种操作,如数据库查询、HTTP 调用、业务逻辑处理等。子 Span 可以进一步有自己的子 Span,形成一个树状结构。火焰图的颜色编码表示 Span 的类型:蓝色通常表示 HTTP 请求,绿色表示数据库操作,黄色表示内部处理,红色表示错误或异常。这种视觉化表示让你能够一眼看出哪个操作最耗时,哪里出现了问题。

让我们用一个具体的场景来说明追踪分析的威力。假设你的电商应用有一个商品搜索功能,用户反馈搜索速度很慢。你打开 Traces 页面,在搜索框中输入 "GET /api/products/search" 过滤出所有搜索请求的追踪,然后按持续时间降序排列。你发现有些搜索请求耗时超过 3 秒,远超预期的几百毫秒。点击其中一个慢请求的追踪,火焰图展示了完整的调用链。你可以看到根 Span "GET /api/products/search" 持续了 3200ms,它下面有几个并行的子 Span:一个是 "Query PostgreSQL - products table" 持续 2800ms,另一个是 "Call ElasticSearch API" 持续 200ms,还有一个是 "Render response" 持续 50ms。

从这个火焰图可以清楚地看出,问题出在 PostgreSQL 查询上,它占据了总时间的 87%。点击 PostgreSQL Span 查看详细属性,你会看到执行的 SQL 语句、查询参数、返回的行数等信息。SQL 语句显示:SELECT * FROM products WHERE name ILIKE '%搜索关键字%' ORDER BY created_at DESC LIMIT 100。这是一个经典的性能问题:ILIKE '%...%' 这种模式无法利用索引,导致全表扫描。解决方案是使用全文搜索索引或切换到专门的搜索引擎(如你已经集成的 ElasticSearch)。这个例子展示了追踪如何帮助你精确定位性能瓶颈,而不是靠猜测或盲目优化。

Traces 页面还有一个强大的功能是追踪的导出和分享。你可以点击某个追踪右侧的"Export"按钮,将追踪数据导出为 JSON 格式,然后分享给团队成员或作为问题报告的附件。接收者可以在他们自己的 Dashboard 中导入这个文件,查看完全相同的追踪视图,这对于远程协作和问题复现非常有用。如果你的 Dashboard 配置了持久化存储(默认是内存存储,重启后数据会丢失),你还可以为追踪创建永久链接,这个链接即使在 Dashboard 重启后仍然有效。

Metrics 页面提供了实时的性能指标监控,这是另一个可观测性支柱。如果说日志是离散的事件记录,追踪是请求的详细分析,那么指标就是系统状态的连续快照。Metrics 页面以时间序列图表的形式展示各种性能指标,包括 CPU 使用率、内存占用、GC 收集次数、HTTP 请求速率、数据库连接数等。这些图表默认显示最近 15 分钟的数据,你可以调整时间范围来查看更长期的趋势或缩小到特定的时间段。

页面顶部是一个指标选择器,列出了所有可用的指标类型。默认情况下,Aspire 会自动收集一系列标准指标,包括 .NET 运行时指标(如 process.runtime.dotnet.gc.collections.countprocess.memory.usage)、ASP.NET Core 指标(如 http.server.request.durationhttp.server.active_requests)和 HTTP 客户端指标(如 http.client.request.duration)。如果你在代码中使用了 System.Diagnostics.Metrics API 创建了自定义指标,这些指标也会出现在列表中。每个指标都有一个描述性的名称和单位,让你清楚地知道它代表什么。

让我们看一个如何使用 Metrics 页面诊断内存泄漏的例子。假设你注意到应用运行一段时间后变得缓慢,响应时间逐渐增加。你打开 Metrics 页面,选择 process.memory.usage 指标,这个指标显示进程的总内存使用量。图表显示一条持续上升的曲线,从启动时的 200MB 逐渐增长到当前的 2GB,而且没有明显的下降趋势。正常情况下,内存使用量应该呈现锯齿状波形,上升是对象分配,下降是垃圾回收。如果只有上升没有下降,几乎可以确定存在内存泄漏。

接下来你切换到 process.runtime.dotnet.gc.collections.count 指标,这个指标分别显示 Gen0、Gen1 和 Gen2 三代垃圾回收的次数。你看到 Gen0 和 Gen1 的计数在稳定增长,这是正常的,因为它们处理短期对象。但 Gen2 的计数也在快速增长,而且 process.runtime.dotnet.gc.heap_size 指标显示堆大小在持续扩大。这些信号综合起来,表明有大量对象晋升到了 Gen2(老年代),而这些对象由于某种原因无法被回收,导致堆不断增长。这时你需要使用更专业的工具,如 dotnet-gcdump 或 Visual Studio 的 Memory Profiler,来捕获内存快照并分析哪些对象类型占用了最多内存,是什么引用阻止了它们被回收。

Metrics 页面的另一个常见用途是监控 HTTP 请求的性能。选择 http.server.request.duration 指标,Dashboard 会显示一个直方图,展示请求耗时的分布。你可以看到 P50(中位数)、P95、P99 等百分位数的值。如果 P95 或 P99 的值很高,说明有一部分请求特别慢,拖累了整体性能。你可以结合 Traces 页面,找出这些慢请求的追踪,分析它们为什么慢。http.server.active_requests 指标显示当前正在处理的活跃请求数,如果这个数字持续很高,说明请求处理存在瓶颈,可能是线程池饱和、数据库连接不足或某些同步阻塞操作。

对于自定义指标,假设你在订单服务中添加了一个计数器来跟踪订单处理的成功和失败次数:

csharp 复制代码
private static readonly Counter<long> s_orderProcessedCounter = 
    s_meter.CreateCounter<long>("orders.processed");

// 在处理订单时记录指标
s_orderProcessedCounter.Add(1, new KeyValuePair<string, object?>("status", "success"));
// 或
s_orderProcessedCounter.Add(1, new KeyValuePair<string, object?>("status", "failed"));

在 Metrics 页面,你可以选择 orders.processed 指标,Dashboard 会显示一个堆叠面积图,分别显示成功和失败的订单数量随时间的变化。如果你看到失败订单数量突然激增,可以立即切换到 Logs 页面查看相应时间段的错误日志,或者在 Traces 页面筛选失败的订单处理追踪,快速定位问题原因。这种多维度的关联分析能力,是 Aspire Dashboard 最强大的特性之一。

Console 页面显示每个服务的原始控制台输出,这是一个相对简单但在某些场景下非常有用的功能。虽然 Logs 页面提供了结构化的日志视图,但有时你需要查看完整的、未经处理的控制台输出,包括启动信息、框架日志、第三方库输出等。Console 页面为每个服务提供了一个独立的终端窗口,实时显示该服务的标准输出和标准错误流。

这个页面特别适合调试启动问题。比如,某个服务在 Projects 页面显示为失败状态,但在 Logs 页面看不到有意义的错误信息。这通常是因为服务在完成日志系统初始化之前就崩溃了,日志还没来得及写入就进程终止了。在 Console 页面,你可以看到 .NET 运行时输出的原始错误信息,如未捕获的异常、配置加载失败、依赖项缺失等。Console 输出还包括框架的启动横幅信息,显示 .NET 版本、应用程序名称、环境等,这对于验证运行环境是否正确很有帮助。

Environment 页面是验证配置的理想工具,它显示了每个服务的所有环境变量。在 Aspire 应用中,大量的配置是通过环境变量注入的,包括服务连接字符串、OTLP 端点地址、日志级别等。当服务无法连接到其他服务或数据库时,首先应该检查的就是 Environment 页面,确认相关的连接字符串环境变量是否存在且值正确。

比如,如果 Web 前端无法连接到 API 服务,你可以在 Environment 页面查找类似 services__api__http__0services__api__https__0 的环境变量。这些变量是 Aspire 的服务发现机制自动注入的,它们的值应该是 API 服务的实际地址,如 http://localhost:5100。如果这些变量不存在,说明在 AppHost 中的 WithReference() 调用有问题或者服务名不匹配。如果变量存在但值不正确,可能是端口配置错误或者网络配置问题。

Environment 页面还显示了 OpenTelemetry 相关的环境变量,如 OTEL_EXPORTER_OTLP_ENDPOINTOTEL_SERVICE_NAMEOTEL_RESOURCE_ATTRIBUTES 等。这些变量控制着遥测数据如何发送到 Dashboard。如果日志、追踪或指标没有正常显示,检查这些变量可以帮助你快速判断是配置问题还是代码问题。例如,如果 OTEL_EXPORTER_OTLP_ENDPOINT 变量缺失或指向错误的地址,服务的遥测数据就无法到达 Dashboard,导致在相应页面看不到数据。

最后值得一提的是,Dashboard 本身是一个可扩展的平台。虽然开箱即用的功能已经非常强大,但如果你有特殊需求,可以通过配置或扩展来定制 Dashboard 的行为。比如,你可以配置 Dashboard 使用持久化存储而不是默认的内存存储,这样即使 Dashboard 重启,历史数据也不会丢失。你可以配置认证和授权,保护 Dashboard 不被未授权访问。你还可以集成外部的可观测性平台,如 Jaeger、Zipkin、Prometheus、Grafana 等,将 Aspire 收集的数据导出到这些平台进行更高级的分析和告警。

通过充分利用 Dashboard 的这些页面和功能,你可以建立一套完整的故障排查工作流。当遇到问题时,首先在 Projects 页面确认所有服务都在运行;然后在 Logs 页面查找错误和警告日志,定位问题发生的时间和服务;接着在 Traces 页面分析具体的请求流程,找出性能瓶颈或调用失败的环节;最后在 Metrics 页面查看系统级的性能指标,识别资源瓶颈或趋势性问题。这种系统化的方法,结合 Dashboard 提供的丰富数据和直观可视化,能够大大提高故障排查的效率和准确性,让你在面对复杂的分布式系统问题时游刃有余。

csharp 复制代码
private static readonly Counter<long> s_orderProcessedCounter = 
    s_meter.CreateCounter<long>("orders.processed");

// 在处理订单时记录指标
s_orderProcessedCounter.Add(1, new KeyValuePair<string, object?>("status", "success"));
// 或
s_orderProcessedCounter.Add(1, new KeyValuePair<string, object?>("status", "failed"));

在 Metrics 页面,你可以选择 orders.processed 指标,Dashboard 会显示一个堆叠面积图,分别显示成功和失败的订单数量随时间的变化。如果你看到失败订单数量突然激增,可以立即切换到 Logs 页面查看相应时间段的错误日志,或者在 Traces 页面筛选失败的订单处理追踪,快速定位问题原因。这种多维度的关联分析能力,是 Aspire Dashboard 最强大的特性之一。

Console 页面显示每个服务的原始控制台输出,这是一个相对简单但在某些场景下非常有用的功能。虽然 Logs 页面提供了结构化的日志视图,但有时你需要查看完整的、未经处理的控制台输出,包括启动信息、框架日志、第三方库输出等。Console 页面为每个服务提供了一个独立的终端窗口,实时显示该服务的标准输出和标准错误流。

这个页面特别适合调试启动问题。比如,某个服务在 Projects 页面显示为失败状态,但在 Logs 页面看不到有意义的错误信息。这通常是因为服务在完成日志系统初始化之前就崩溃了,日志还没来得及写入就进程终止了。在 Console 页面,你可以看到 .NET 运行时输出的原始错误信息,如未捕获的异常、配置加载失败、依赖项缺失等。Console 输出还包括框架的启动横幅信息,显示 .NET 版本、应用程序名称、环境等,这对于验证运行环境是否正确很有帮助。

Environment 页面是验证配置的理想工具,它显示了每个服务的所有环境变量。在 Aspire 应用中,大量的配置是通过环境变量注入的,包括服务连接字符串、OTLP 端点地址、日志级别等。当服务无法连接到其他服务或数据库时,首先应该检查的就是 Environment 页面,确认相关的连接字符串环境变量是否存在且值正确。

比如,如果 Web 前端无法连接到 API 服务,你可以在 Environment 页面查找类似 services__api__http__0services__api__https__0 的环境变量。这些变量是 Aspire 的服务发现机制自动注入的,它们的值应该是 API 服务的实际地址,如 http://localhost:5100。如果这些变量不存在,说明在 AppHost 中的 WithReference() 调用有问题或者服务名不匹配。如果变量存在但值不正确,可能是端口配置错误或者网络配置问题。

Environment 页面还显示了 OpenTelemetry 相关的环境变量,如 OTEL_EXPORTER_OTLP_ENDPOINTOTEL_SERVICE_NAMEOTEL_RESOURCE_ATTRIBUTES 等。这些变量控制着遥测数据如何发送到 Dashboard。如果日志、追踪或指标没有正常显示,检查这些变量可以帮助你快速判断是配置问题还是代码问题。例如,如果 OTEL_EXPORTER_OTLP_ENDPOINT 变量缺失或指向错误的地址,服务的遥测数据就无法到达 Dashboard,导致在相应页面看不到数据。

最后值得一提的是,Dashboard 本身是一个可扩展的平台。虽然开箱即用的功能已经非常强大,但如果你有特殊需求,可以通过配置或扩展来定制 Dashboard 的行为。比如,你可以配置 Dashboard 使用持久化存储而不是默认的内存存储,这样即使 Dashboard 重启,历史数据也不会丢失。你可以配置认证和授权,保护 Dashboard 不被未授权访问。你还可以集成外部的可观测性平台,如 Jaeger、Zipkin、Prometheus、Grafana 等,将 Aspire 收集的数据导出到这些平台进行更高级的分析和告警。

通过充分利用 Dashboard 的这些页面和功能,你可以建立一套完整的故障排查工作流。当遇到问题时,首先在 Projects 页面确认所有服务都在运行;然后在 Logs 页面查找错误和警告日志,定位问题发生的时间和服务;接着在 Traces 页面分析具体的请求流程,找出性能瓶颈或调用失败的环节;最后在 Metrics 页面查看系统级的性能指标,识别资源瓶颈或趋势性问题。这种系统化的方法,结合 Dashboard 提供的丰富数据和直观可视化,能够大大提高故障排查的效率和准确性,让你在面对复杂的分布式系统问题时游刃有余。

2.2 远程调试配置

在 .NET Aspire 的开发过程中,远程调试是一个极其重要但常被忽视的技能。虽然 Visual Studio 提供了强大的本地调试功能,但在某些场景下,你需要调试运行在不同机器、容器或云环境中的服务。远程调试允许你在一个环境中编写和修改代码,而在另一个环境中执行和调试这些代码,这在处理环境相关的问题时尤为关键。理解如何正确配置和使用远程调试,能够显著提高你排查生产问题或复杂分布式场景下故障的能力。

远程调试的基本原理是在目标机器上运行一个调试器代理(通常是 vsdbg),这个代理监听特定端口并等待调试器连接。当 Visual Studio 通过网络连接到这个代理时,它可以发送调试命令,如设置断点、单步执行、查看变量等,就像调试本地应用一样。在 Aspire 应用中,这个过程稍微复杂一些,因为你需要处理多个服务、容器网络和动态端口分配等因素。但一旦正确配置,远程调试能够让你像调试单体应用一样调试分布式系统。

让我们从最简单的场景开始:在本地机器上调试 Aspire 应用中的某个特定服务。虽然这看起来不是"远程"调试,但它展示了基本的配置模式,这些模式同样适用于真正的远程场景。假设你有一个订单处理应用,包含 API 服务、Web 前端和数据库。当你使用 Visual Studio 启动 AppHost 项目时,它会自动为所有项目启用调试支持。你可以在任何服务的代码中设置断点,当请求到达时断点会被触发。但有时你只想调试某个特定的服务,而让其他服务正常运行,这就需要更精细的配置。

在 AppHost 项目中,你可以通过环境变量控制调试行为。Aspire 使用名为 DOTNET_LAUNCH_PROFILE 的环境变量来决定如何启动服务。当这个变量设置为特定的启动配置文件名时,服务会以调试模式启动并等待调试器附加。让我们看一个完整的 AppHost 配置示例,展示如何为不同的服务配置不同的调试选项:

csharp 复制代码
// AppHost/Program.cs
using Aspire.Hosting;

var builder = DistributedApplication.CreateBuilder(args);

// 配置 PostgreSQL 数据库
// 数据库容器通常不需要调试,所以没有特殊配置
var postgres = builder.AddPostgres("postgres")
    .WithPgAdmin()
    .AddDatabase("ordersdb");

// 配置 Redis 缓存
// 同样,基础设施组件通常不需要调试
var cache = builder.AddRedis("cache")
    .WithRedisCommander();

// 配置 API 服务,这是我们主要调试的目标
var apiService = builder.AddProject<Projects.OrderApi>("api")
    // 显式指定 HTTP 端口,避免动态端口带来的配置复杂性
    .WithHttpEndpoint(port: 5100, name: "http")
    .WithHttpsEndpoint(port: 5101, name: "https")
    // 添加对数据库的引用
    .WithReference(postgres)
    // 添加对缓存的引用
    .WithReference(cache)
    // 配置环境变量以启用详细的调试日志
    .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
    .WithEnvironment("Logging__LogLevel__Default", "Debug")
    // 这个环境变量告诉运行时保持控制台窗口打开,便于查看输出
    .WithEnvironment("DOTNET_LAUNCH_PROFILE", "https");

// 配置 Web 前端服务
var webService = builder.AddProject<Projects.OrderWeb>("web")
    .WithHttpEndpoint(port: 5000, name: "http")
    .WithHttpsEndpoint(port: 5001, name: "https")
    // 引用 API 服务,建立服务依赖关系
    .WithReference(apiService)
    // Web 前端可能不需要每次都调试,所以使用不同的配置
    .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development");

// 配置后台工作服务,用于处理异步任务
var workerService = builder.AddProject<Projects.OrderWorker>("worker")
    // Worker 服务通常没有 HTTP 端点,但需要连接到其他资源
    .WithReference(postgres)
    .WithReference(cache)
    .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
    // 为 Worker 服务启用远程调试端口
    // 这个配置会在服务启动时监听 4024 端口,等待调试器连接
    .WithEnvironment("DOTNET_EnableDiagnostics", "1")
    .WithEnvironment("DOTNET_DiagnosticPorts", "4024,nosuspend");

var app = builder.Build();
await app.RunAsync();

在这个配置中,我们为不同类型的服务设置了不同的调试策略。API 服务使用标准的 Visual Studio 调试,你可以直接在代码中设置断点。Worker 服务则配置了诊断端口,允许你在服务启动后手动附加调试器。DOTNET_DiagnosticPorts 环境变量的值 4024,nosuspend 表示服务会在 4024 端口监听调试连接,但不会暂停等待调试器附加(nosuspend),这样服务可以正常启动并开始处理任务。

现在让我们深入了解 Worker 服务的代码,看看如何在其中实现可调试的业务逻辑。Worker 服务通常运行长时间的后台任务,如处理消息队列、执行定时任务或同步数据。调试这类服务比调试 HTTP API 更具挑战性,因为它们没有明确的请求入口点,你需要等待特定的条件或时间触发才能命中断点。

csharp 复制代码
// OrderWorker/Program.cs
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateDefaultBuilder(args);

// 添加 Service Defaults 配置,这会自动配置日志、遥测等
builder.ConfigureServices((context, services) =>
{
    // 注册后台服务
    services.AddHostedService<OrderProcessingWorker>();
    
    // 注册业务服务
    services.AddSingleton<IOrderQueue, OrderQueue>();
    services.AddScoped<IOrderProcessor, OrderProcessor>();
});

var host = builder.Build();

// 在启动时记录一条日志,确认服务已启动
var logger = host.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Order Worker starting at {Time}", DateTime.UtcNow);

// 如果检测到调试器附加,记录一条特殊日志
if (System.Diagnostics.Debugger.IsAttached)
{
    logger.LogInformation("Debugger detected. Worker is running in debug mode.");
}

await host.RunAsync();

这个启动代码中有几个关键点需要注意。首先,我们使用 Host.CreateDefaultBuilder 而不是 WebApplication.CreateBuilder,因为 Worker 服务不需要 HTTP 管道。其次,我们检查 Debugger.IsAttached 属性来判断是否有调试器连接,这可以帮助你确认远程调试配置是否生效。最后,我们使用 AddHostedService 注册后台服务,这是 .NET 中实现长期运行任务的标准模式。

接下来实现核心的 Worker 逻辑,这是你实际设置断点和调试的地方:

csharp 复制代码
// OrderWorker/OrderProcessingWorker.cs
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Diagnostics;

public class OrderProcessingWorker : BackgroundService
{
    private readonly ILogger<OrderProcessingWorker> _logger;
    private readonly IServiceProvider _serviceProvider;
    private readonly IOrderQueue _orderQueue;
    
    // 创建一个 ActivitySource 用于分布式追踪
    // 这样即使在 Worker 中,你也能在 Dashboard 的 Traces 页面看到执行流程
    private static readonly ActivitySource s_activitySource = 
        new ActivitySource("OrderWorker");

    public OrderProcessingWorker(
        ILogger<OrderProcessingWorker> logger,
        IServiceProvider serviceProvider,
        IOrderQueue orderQueue)
    {
        _logger = logger;
        _serviceProvider = serviceProvider;
        _orderQueue = orderQueue;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Order processing worker started");

        // 主循环,持续处理订单直到应用停止
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // 在这里设置断点,每次循环都会触发
                // 这对于调试循环逻辑非常有用
                _logger.LogDebug("Checking for pending orders...");

                // 从队列中获取待处理的订单
                // 在实际应用中,这可能是从 RabbitMQ、Azure Service Bus 或其他消息队列读取
                var pendingOrders = await _orderQueue.GetPendingOrdersAsync(stoppingToken);

                if (pendingOrders.Any())
                {
                    _logger.LogInformation("Found {Count} pending orders to process", pendingOrders.Count);

                    // 并行处理订单以提高吞吐量
                    // 但要注意并发控制,避免压垮下游服务
                    var tasks = pendingOrders.Select(order => ProcessOrderAsync(order, stoppingToken));
                    await Task.WhenAll(tasks);
                }
                else
                {
                    // 没有订单时短暂休眠,避免 CPU 空转
                    // 在调试时,你可能想减少这个延迟以加快测试
                    await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
                }
            }
            catch (OperationCanceledException)
            {
                // 应用正在停止,这是预期的异常,不需要记录为错误
                _logger.LogInformation("Order processing worker stopping gracefully");
                break;
            }
            catch (Exception ex)
            {
                // 捕获并记录所有其他异常
                // 关键是不要让异常导致整个 Worker 崩溃
                _logger.LogError(ex, "Error in order processing loop");
                
                // 发生错误后等待一段时间再重试,避免快速失败循环
                await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
            }
        }

        _logger.LogInformation("Order processing worker stopped");
    }

    private async Task ProcessOrderAsync(Order order, CancellationToken cancellationToken)
    {
        // 为每个订单处理创建一个 Activity
        // 这会在 Dashboard 的 Traces 页面显示为一个独立的 Span
        using var activity = s_activitySource.StartActivity("ProcessOrder");
        activity?.SetTag("order.id", order.Id);
        activity?.SetTag("order.customer.id", order.CustomerId);

        // 在这里设置断点来调试单个订单的处理逻辑
        _logger.LogInformation("Processing order {OrderId}", order.Id);

        var stopwatch = Stopwatch.StartNew();

        try
        {
            // 创建一个作用域以获取 Scoped 服务
            // Worker 本身是单例,但某些服务(如 DbContext)需要是 Scoped
            using var scope = _serviceProvider.CreateScope();
            var orderProcessor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();

            // 执行实际的订单处理逻辑
            // 这个方法可能涉及数据库操作、HTTP 调用、消息发送等
            await orderProcessor.ProcessAsync(order, cancellationToken);

            stopwatch.Stop();
            
            // 记录成功的处理
            _logger.LogInformation(
                "Order {OrderId} processed successfully in {Duration}ms",
                order.Id,
                stopwatch.ElapsedMilliseconds);

            activity?.SetStatus(ActivityStatusCode.Ok);
        }
        catch (Exception ex)
        {
            stopwatch.Stop();

            // 记录处理失败
            _logger.LogError(
                ex,
                "Failed to process order {OrderId} after {Duration}ms: {Error}",
                order.Id,
                stopwatch.ElapsedMilliseconds,
                ex.Message);

            // 将异常信息添加到 Activity
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.RecordException(ex);

            // 决定是否需要重试
            // 某些错误(如网络临时故障)应该重试,其他错误(如数据验证失败)不应重试
            if (IsTransientError(ex))
            {
                await _orderQueue.RequeueOrderAsync(order, cancellationToken);
                _logger.LogInformation("Order {OrderId} requeued for retry", order.Id);
            }
            else
            {
                await _orderQueue.MarkOrderAsFailedAsync(order, ex.Message, cancellationToken);
                _logger.LogWarning("Order {OrderId} marked as failed", order.Id);
            }
        }
    }

    private bool IsTransientError(Exception ex)
    {
        // 判断错误是否是临时的,可以重试
        // 这个逻辑应该根据你的具体场景调整
        return ex is HttpRequestException ||
               ex is TimeoutException ||
               ex.InnerException is System.Net.Sockets.SocketException;
    }
}

这个 Worker 实现展示了几个调试友好的设计模式。首先,所有关键步骤都有详细的日志记录,即使不附加调试器,你也能通过日志了解执行流程。其次,使用了 Activity 和分布式追踪,你可以在 Dashboard 中可视化地看到订单处理的完整生命周期。第三,异常处理非常细致,区分了临时错误和永久错误,并采取了不同的补救措施。

现在让我们看看如何在 Visual Studio 中实际进行远程调试。首先确保你的 Worker 服务已经启动并配置了诊断端口。然后在 Visual Studio 中,选择"调试" → "附加到进程"(Ctrl+Alt+P),在弹出的对话框中,将"连接类型"改为"远程(无身份验证)",在"连接目标"中输入 localhost:4024(或你配置的端口号)。如果一切配置正确,你会在进程列表中看到 OrderWorker.exedotnet.exe,选择它并点击"附加"按钮。

附加调试器后,Visual Studio 会进入调试模式,你会看到所有熟悉的调试窗口:调用堆栈、局部变量、监视窗口等。现在在 ProcessOrderAsync 方法的第一行设置一个断点,然后等待队列中有新订单到来。当断点被触发时,你可以像调试本地应用一样单步执行代码、检查变量、评估表达式。这对于诊断复杂的业务逻辑问题或数据转换错误非常有效。

对于更复杂的远程调试场景,比如调试运行在 Docker 容器中的服务,配置过程略有不同。你需要在容器中安装调试器代理,并将调试端口映射到主机。让我们看一个完整的 Dockerfile 示例,展示如何为远程调试配置容器:

dockerfile 复制代码
# 使用 .NET 8 SDK 作为构建镜像
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# 复制项目文件并恢复依赖
COPY ["OrderApi/OrderApi.csproj", "OrderApi/"]
COPY ["ServiceDefaults/ServiceDefaults.csproj", "ServiceDefaults/"]
RUN dotnet restore "OrderApi/OrderApi.csproj"

# 复制所有源代码并构建
COPY . .
WORKDIR "/src/OrderApi"
RUN dotnet build "OrderApi.csproj" -c Debug -o /app/build

# 发布应用
FROM build AS publish
RUN dotnet publish "OrderApi.csproj" -c Debug -o /app/publish

# 运行时镜像
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app

# 安装 Visual Studio 远程调试器
# 这个步骤很关键,它安装了 vsdbg 工具
RUN apt-get update && apt-get install -y --no-install-recommends \
    unzip \
    && rm -rf /var/lib/apt/lists/*

# 下载并安装 vsdbg
RUN curl -sSL https://aka.ms/getvsdbgsh | \
    bash /dev/stdin -v latest -l /vsdbg

# 复制发布的应用
COPY --from=publish /app/publish .

# 暴露应用端口和调试端口
EXPOSE 80
EXPOSE 443
EXPOSE 4024

# 设置入口点
# 注意:在调试模式下,你可能想手动启动应用以控制启动时机
ENTRYPOINT ["dotnet", "OrderApi.dll"]

在 AppHost 中,你需要修改容器配置以映射调试端口:

csharp 复制代码
// 为容器化服务配置远程调试
var apiContainer = builder.AddContainer("api", "orderapi")
    .WithHttpEndpoint(port: 5100, targetPort: 80)
    .WithHttpsEndpoint(port: 5101, targetPort: 443)
    // 映射调试端口,这样你可以从主机连接到容器内的调试器
    .WithEndpoint(port: 4024, targetPort: 4024, name: "debug")
    .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
    // 配置诊断端口,容器内的应用会监听这个端口
    .WithEnvironment("DOTNET_DiagnosticPorts", "/tmp/dotnet-diagnostic-sock,nosuspend");

当容器启动后,你可以使用 docker exec 命令进入容器并验证调试器是否正在监听:

shell 复制代码
# 找到容器 ID
docker ps | findstr orderapi

# 进入容器
docker exec -it <container-id> /bin/bash

# 检查调试端口是否在监听
netstat -tuln | grep 4024

# 或者尝试从容器内连接到调试端口
curl http://localhost:4024

对于在云环境(如 Azure 或 AWS)中运行的 Aspire 应用,远程调试需要额外的网络配置。你需要确保调试端口通过防火墙规则暴露,并且有适当的身份验证机制保护调试端点。在生产环境中,远程调试应该是最后的手段,因为它会引入安全风险和性能开销。更好的做法是充分利用日志、追踪和指标来诊断问题,只有在这些方法无法解决时才考虑远程调试。

Visual Studio 还提供了快照调试(Snapshot Debugging)功能,这是一种无侵入的调试方式,特别适合生产环境。快照调试允许你在代码的特定点创建快照,捕获当时的变量状态和调用堆栈,而不会暂停应用的执行。这个功能在 Azure 上得到了很好的支持,如果你的 Aspire 应用部署到 Azure App Service 或 Azure Kubernetes Service,可以启用快照调试:

csharp 复制代码
// 在 Program.cs 中添加快照调试支持
builder.Services.AddApplicationInsightsTelemetry();
builder.Services.AddSnapshotCollector(options =>
{
    options.IsEnabledInDeveloperMode = false;
    options.IsEnabledWhenProfiling = true;
    options.ThresholdForSnapshotting = 1;
    options.MaximumSnapshotsRequired = 3;
    options.MaximumCollectionPlanSize = 50;
    options.ReconnectInterval = TimeSpan.FromMinutes(15);
    options.SnapshotInLowPriorityThread = true;
});

配置好后,你可以在 Azure Portal 的 Application Insights 中设置快照点,这类似于设置断点,但不会暂停应用。当代码执行到快照点时,系统会自动收集当时的状态信息并上传到 Azure。你可以在 Portal 中查看这些快照,分析变量值、调用堆栈和异常详情,就像在 Visual Studio 中调试一样,但完全不影响生产应用的运行。

2.3 命令行调试工具

.NET 团队提供了一套强大的命令行诊断工具集,这些工具是排查生产环境问题和进行深度性能分析的利器。与 Aspire Dashboard 提供的实时监控相比,这些命令行工具能够捕获更详细的底层数据,帮助你诊断那些通过常规监控难以发现的问题。这些工具都是跨平台的,可以在 Windows、Linux 和 macOS 上使用,并且设计为既可以本地使用,也可以在生产环境中远程使用。掌握这些工具的使用方法,能够让你在面对复杂问题时拥有更多的诊断手段和更深入的洞察力。

dotnet-trace 是一个功能强大的追踪收集工具,它能够捕获应用程序运行时的详细性能数据。与 Aspire Dashboard 的分布式追踪不同,dotnet-trace 收集的是更底层的运行时事件,包括方法调用、GC 活动、线程调度、异常抛出等。这些数据可以帮助你理解应用程序在运行时的确切行为,找出那些在高层追踪中不可见的性能问题。例如,你可能在 Dashboard 中看到某个 HTTP 请求很慢,但不知道具体是哪个代码路径导致的延迟。使用 dotnet-trace 收集详细的方法级追踪数据,就可以精确定位到有问题的代码行。

首先需要安装 dotnet-trace 工具。这是一个全局工具,安装后可以在任何目录下使用。打开命令行终端,执行安装命令。工具会从 NuGet 下载并安装到全局工具目录中,通常是用户目录下的 .dotnet/tools 文件夹。安装完成后,你可以通过 dotnet-trace --version 命令验证安装是否成功,它会显示当前安装的工具版本号。

shell 复制代码
# 安装 dotnet-trace 全局工具
dotnet tool install --global dotnet-trace

# 验证安装
dotnet-trace --version
# 输出示例:dotnet-trace 8.0.0+build.23.47101

安装完成后,第一步是找到你想要追踪的目标进程。dotnet-trace 提供了一个方便的命令来列出当前系统上所有运行中的 .NET 进程。这个命令会显示进程 ID(PID)、进程名称和完整的命令行参数,让你能够准确识别目标进程。在 Aspire 应用中,你可能同时运行着多个服务,每个服务都是一个独立的进程。通过查看命令行参数,你可以区分 AppHost、API 服务、Worker 服务等不同的进程。

shell 复制代码
# 列出所有 .NET 进程
dotnet-trace ps

# 输出示例:
# 12345  OrderApi    /path/to/OrderApi.dll
# 12346  OrderWeb    /path/to/OrderWeb.dll
# 12347  OrderWorker /path/to/OrderWorker.dll
# 12348  AppHost     /path/to/AppHost.dll

找到目标进程后,就可以开始收集追踪数据了。dotnet-trace 的核心命令是 collect,它会连接到指定的进程并开始记录运行时事件。有多种方式指定目标进程,最常用的是通过进程 ID(PID)。你还可以通过进程名称来指定,如果有多个同名进程,工具会让你选择具体的实例。收集追踪时有几个重要的参数需要理解。首先是持续时间,用 -d--duration 参数指定。如果不指定持续时间,追踪会一直进行直到你手动按 Ctrl+C 停止。对于性能分析,通常收集 30 到 60 秒的数据就足够了,时间太短可能捕获不到问题,时间太长会生成非常大的文件。

shell 复制代码
# 收集 30 秒的追踪数据
# -p 指定进程 ID
# --duration 或 -d 指定持续时间(秒)
# --output 或 -o 指定输出文件名
dotnet-trace collect --process-id 12345 --duration 30 --output api-trace.nettrace

# 或者使用简写
dotnet-trace collect -p 12345 -d 30 -o api-trace.nettrace

# 通过进程名称指定目标(如果有多个同名进程会提示选择)
dotnet-trace collect --name OrderApi --duration 30 --output api-trace.nettrace

追踪收集过程中,终端会显示实时的统计信息,包括已经收集了多少事件、文件大小、经过的时间等。这些信息可以帮助你判断追踪是否正常进行。如果你看到事件计数快速增长到数百万,说明应用程序非常活跃,生成了大量的运行时事件。如果计数增长很慢或停滞,可能是应用程序空闲或追踪配置有问题。

shell 复制代码
# 收集过程中的实时输出示例:
# Press <Enter> or <Ctrl+C> to exit...
# Recording trace 12345
# [00:00:10]  Events: 245,678  File Size: 15.2 MB
# [00:00:20]  Events: 523,456  File Size: 32.8 MB
# [00:00:30]  Events: 801,234  File Size: 50.4 MB
# Trace completed. File saved to: api-trace.nettrace

默认情况下,dotnet-trace 会收集一组标准的性能计数器和事件,这些被称为 "CPU Sampling" 配置文件。这个配置文件非常适合 CPU 密集型问题的诊断,它会定期采样每个线程的调用堆栈,让你看到哪些方法消耗了最多的 CPU 时间。但有时你需要收集更详细或更特定类型的事件,这就需要使用不同的配置文件或自定义事件提供程序。dotnet-trace 支持多种预定义的配置文件,每个配置文件针对不同的诊断场景进行了优化。

shell 复制代码
# 列出所有可用的配置文件
dotnet-trace list-profiles

# 输出示例:
# cpu-sampling          - 适合 CPU 性能分析(默认)
# gc-verbose            - 详细的 GC 事件
# gc-collect            - GC 收集和堆统计
# runtime               - 运行时事件(方法调用、异常等)

# 使用特定的配置文件
dotnet-trace collect -p 12345 --profile gc-verbose -o gc-trace.nettrace

# 自定义事件提供程序
# 这个命令收集 HTTP 客户端和 SQL 客户端的详细事件
dotnet-trace collect -p 12345 \
    --providers "Microsoft-System-Net-Http:0x1:5,Microsoft-Data-SqlClient:0xFFFFFFFF:5" \
    -o http-sql-trace.nettrace

在这个自定义提供程序的例子中,Microsoft-System-Net-Http:0x1:5 的语法需要详细解释。第一部分是事件提供程序的名称,它标识了要收集哪些事件。第二部分 0x1 是关键字过滤器,以十六进制表示。每个事件提供程序定义了多个关键字,每个关键字对应一类事件。通过组合这些关键字(使用位运算),你可以精确控制要收集哪些事件。第三部分 5 是日志级别,数字越小表示级别越高越重要(1=Critical, 2=Error, 3=Warning, 4=Informational, 5=Verbose)。Verbose 级别会收集最详细的事件,但也会生成更大的追踪文件。

让我们看一个实际的场景。假设你的 API 服务在处理某些请求时非常慢,但通过 Dashboard 的追踪看不出具体原因。你怀疑问题可能出在 CPU 计算或者某些同步阻塞操作上。这时可以使用 dotnet-trace 收集详细的 CPU 采样数据。首先找到 API 服务的进程 ID,假设是 12345。然后启动追踪收集,持续 60 秒以捕获足够的数据。

shell 复制代码
# 找到 API 服务进程
dotnet-trace ps | findstr OrderApi
# 输出:12345  OrderApi    D:\MyApp\OrderApi.dll

# 收集 60 秒的 CPU 采样追踪
dotnet-trace collect -p 12345 -d 60 -o api-cpu-trace.nettrace

# 在收集期间,从另一个终端向 API 发送请求来复现问题
# 例如使用 curl 或 Postman 调用慢接口
curl http://localhost:5100/api/orders/search?query=test

追踪完成后,你会得到一个 .nettrace 文件,这是一个二进制格式的文件,包含了收集期间的所有事件数据。直接查看这个文件是不可能的,你需要使用专门的分析工具。最强大的分析工具是 PerfView,这是微软开发的一个免费的性能分析工具,专门用于分析 .nettrace 和 .etl 追踪文件。PerfView 提供了丰富的视图和分析功能,包括调用树、火焰图、时间线等。你可以从 GitHub 下载 PerfView(https://github.com/microsoft/perfview/releases),它是一个单一的可执行文件,无需安装。

shell 复制代码
# 使用 PerfView 打开追踪文件
PerfView.exe api-cpu-trace.nettrace

# PerfView 会加载追踪文件并显示主界面
# 双击 "CPU Stacks" 可以查看 CPU 热点分析
# 双击 "Thread Time" 可以查看线程活动时间线

在 PerfView 中,CPU Stacks 视图是最常用的分析起点。这个视图以树状结构显示了所有的方法调用和它们消耗的 CPU 时间。树的根节点是线程,下面是调用栈,最底层是实际执行的方法。每个节点旁边有两个数字:独占时间(Exclusive Time)和包含时间(Inclusive Time)。独占时间是这个方法本身消耗的 CPU 时间,不包括它调用的其他方法。包含时间是这个方法及其所有子调用消耗的总时间。通过查看独占时间最高的方法,你可以找到真正的 CPU 热点。

假设在 PerfView 中你看到一个名为 OrderService.CalculateDiscount 的方法的独占时间占总 CPU 时间的 40%,这明显是一个性能瓶颈。双击这个方法可以查看它的完整调用栈和源代码位置(如果有 PDB 符号文件)。你可能发现这个方法中有一个嵌套循环,在处理大量订单项时导致了 O(n²) 的时间复杂度。这种深度的洞察是通过常规的日志和追踪无法获得的,只有通过底层的运行时追踪才能发现。

如果你更喜欢使用 Visual Studio 进行分析,可以将 .nettrace 文件转换为 Visual Studio 可以打开的格式。Visual Studio 的性能分析器提供了更友好的界面和更多的集成功能,比如可以直接跳转到源代码。dotnet-trace 提供了一个转换命令,可以将 .nettrace 文件转换为 .speedscope.json 或 .chromium.json 格式,这些格式可以被更多的工具支持。

shell 复制代码
# 转换为 Speedscope 格式(可以在 https://www.speedscope.app 在线查看)
dotnet-trace convert api-cpu-trace.nettrace --format speedscope

# 这会生成 api-cpu-trace.speedscope.json 文件
# 打开浏览器访问 https://www.speedscope.app
# 拖拽 JSON 文件到页面上即可查看火焰图

# 转换为 Chromium 格式(可以在 Chrome DevTools 中查看)
dotnet-trace convert api-cpu-trace.nettrace --format chromium

# 打开 Chrome 浏览器
# 按 F12 打开 DevTools
# 切换到 Performance 标签
# 点击 Load Profile 按钮
# 选择生成的 .json 文件

Speedscope 的火焰图是一种非常直观的性能可视化方式。X 轴代表时间,Y 轴代表调用栈的深度。每个矩形框代表一个方法调用,框的宽度表示这个方法在 CPU 上执行的时间。框的颜色是随机的,用于区分不同的方法。火焰图的名称来源于它的形状:底部的框比较宽,代表高层方法;顶部的框比较窄,代表叶子方法,整体看起来像火焰一样。通过火焰图,你可以一眼看出哪些代码路径消耗了最多的时间,点击某个框可以放大查看它的子调用。

dotnet-counters 是另一个极其有用的命令行工具,它提供了实时的性能计数器监控功能。与 Aspire Dashboard 的 Metrics 页面类似,但 dotnet-counters 可以在没有 Dashboard 的环境中使用,比如 SSH 连接到远程服务器时。这个工具特别适合快速检查应用程序的健康状态,识别资源使用趋势,或者在长时间运行期间持续监控关键指标。它的输出是实时更新的,就像 Linux 的 top 命令一样,让你能够看到指标随时间的变化。

首先需要安装 dotnet-counters 工具,安装过程与 dotnet-trace 相同。安装后,你可以使用 ps 命令列出进程,或者直接使用 monitor 命令开始监控。monitor 命令会持续运行,每秒刷新一次屏幕,显示最新的计数器值。按 Ctrl+C 可以停止监控。

shell 复制代码
# 安装 dotnet-counters
dotnet tool install --global dotnet-counters

# 列出运行中的 .NET 进程
dotnet-counters ps

# 监控特定进程的所有默认计数器
dotnet-counters monitor --process-id 12345

# 或者使用进程名称
dotnet-counters monitor --name OrderApi

默认情况下,dotnet-counters 会显示一组标准的系统计数器,包括 CPU 使用率、内存使用量、GC 统计、线程池状态等。这些计数器提供了应用程序运行状态的全面概览。输出是一个实时更新的表格,每行是一个计数器,显示当前值和变化趋势。

shell 复制代码
# monitor 命令的输出示例:
# Press p to pause, r to resume, q to quit.
# 
# Status: Running
# 
# [System.Runtime]
#     CPU Usage (%)                                     15
#     Working Set (MB)                                  256
#     GC Heap Size (MB)                                 128
#     Gen 0 GC Count                                    45
#     Gen 1 GC Count                                    12
#     Gen 2 GC Count                                    3
#     % Time in GC since last GC                        2.5
#     Allocation Rate (B/sec)                           1,234,567
#     Number of Assemblies Loaded                       78
#     Exception Count                                   5
#     ThreadPool Thread Count                           8
#     Monitor Lock Contention Count                     123
#     ThreadPool Queue Length                           2
#     ThreadPool Completed Work Item Count              1,234
# 
# [Microsoft.AspNetCore.Hosting]
#     Requests Per Second                               45.2
#     Total Requests                                    2,345
#     Current Requests                                  3
#     Failed Requests                                   2

这个输出提供了大量有价值的信息。让我们逐一解读几个关键指标。CPU Usage 显示了应用程序使用的 CPU 百分比,如果这个值持续很高(比如超过 80%),说明应用程序在进行大量计算或存在性能问题。Working Set 是进程使用的物理内存大小,如果这个值持续增长且不下降,可能存在内存泄漏。GC Heap Size 是托管堆的大小,正常情况下它应该在一个合理的范围内波动,如果持续增长说明有对象无法被回收。

Gen 0/1/2 GC Count 显示了各代垃圾回收的次数。Gen 0 和 Gen 1 的收集是快速且频繁的,这是正常的。但如果 Gen 2 GC Count 增长很快,比如每秒多次,就要警惕了。Gen 2 GC 是完整的垃圾回收,会暂停所有应用程序线程,对性能影响很大。频繁的 Gen 2 GC 通常意味着有太多长期存活的对象,或者有内存压力问题。

% Time in GC 显示了应用程序在垃圾回收上花费的时间百分比。理想情况下这个值应该小于 5%。如果这个值超过 10% 甚至 20%,说明 GC 成为了性能瓶颈,你需要优化对象分配策略或增加堆大小。Allocation Rate 显示了每秒分配的字节数,高分配速率会导致频繁的 GC,这是许多性能问题的根源。

ThreadPool Thread Count 显示了线程池中的线程数量。.NET 的线程池会根据工作负载动态调整线程数量,但增长是渐进的。如果你看到这个值一直在增长,说明有大量的工作项在等待执行,可能是因为异步操作配置不当或存在阻塞调用。ThreadPool Queue Length 显示了队列中等待执行的工作项数量,如果这个值持续很高,说明线程池处理不过来,你需要检查是否有同步阻塞代码阻塞了线程池线程。

除了默认的计数器,你还可以指定要监控的特定计数器组。dotnet-counters 支持多个内置的计数器提供程序,每个提供程序专注于不同的方面。System.Runtime 提供了运行时和 GC 相关的计数器,Microsoft.AspNetCore.Hosting 提供了 ASP.NET Core 特定的计数器,System.Net.Http 提供了 HTTP 客户端的计数器等。你可以组合使用多个提供程序来获得更全面的视图。

shell 复制代码
# 只监控 GC 相关的计数器
dotnet-counters monitor -p 12345 --counters System.Runtime[gc-heap-size,gen-0-gc-count,gen-1-gc-count,gen-2-gc-count]

# 监控 HTTP 相关的计数器
dotnet-counters monitor -p 12345 --counters System.Net.Http

# 组合多个计数器提供程序
dotnet-counters monitor -p 12345 --counters System.Runtime,Microsoft.AspNetCore.Hosting,System.Net.Http

# 列出所有可用的计数器提供程序
dotnet-counters list

list 命令会显示一个很长的列表,包含所有可用的计数器提供程序及其支持的计数器。这个列表会根据你安装的 NuGet 包和框架版本而变化。如果你的应用使用了 Entity Framework Core,你会看到 Microsoft.EntityFrameworkCore 提供程序,它提供了数据库查询性能相关的计数器。如果使用了消息队列客户端,相应的提供程序也会出现在列表中。

对于长时间的监控场景,你可能想要将计数器数据保存到文件中以便后续分析,而不是只在屏幕上实时显示。dotnet-counters 的 collect 命令可以做到这一点,它会持续收集计数器数据并写入 CSV 文件或自定义格式的文件。

shell 复制代码
# 收集 5 分钟的计数器数据到 CSV 文件
dotnet-counters collect -p 12345 --format csv --output counters.csv --duration 300

# 收集时可以指定刷新间隔(默认 1 秒)
dotnet-counters collect -p 12345 --format csv --output counters.csv --refresh-interval 5

# 使用自定义的计数器提供程序
dotnet-counters collect -p 12345 \
    --counters System.Runtime,Microsoft.AspNetCore.Hosting \
    --format csv \
    --output detailed-counters.csv

生成的 CSV 文件可以在 Excel、Python pandas 或其他数据分析工具中打开。文件的第一列是时间戳,后续的列是各个计数器的值。通过绘制时间序列图表,你可以看到指标的长期趋势,识别周期性模式或异常峰值。例如,你可能发现内存使用量每小时增长一次,这提示你检查是否有定时任务在泄漏内存。或者你可能发现在某个特定时间点 GC 暂停时间突然飙升,这可以帮助你定位问题发生的时间窗口。

dotnet-dump 是第三个重要的诊断工具,用于捕获和分析进程的内存转储。内存转储是进程在某个时刻的完整内存快照,包括所有对象、线程状态、调用栈等信息。分析内存转储可以帮助你诊断内存泄漏、找出死锁、检查对象的实际内容和状态。与 dotnet-trace 捕获的事件流不同,dump 是一个静态的快照,它记录的是特定时刻的状态而不是一段时间内的活动。

内存转储的使用场景通常是在应用程序出现问题时,比如内存使用量异常高、响应变慢或完全无响应时,你想要捕获问题发生时的状态进行事后分析。另一个常见场景是在生产环境中,当问题无法在开发或测试环境中重现时,你可以在生产环境捕获转储,然后在本地安全地分析。

shell 复制代码
# 安装 dotnet-dump
dotnet tool install --global dotnet-dump

# 列出可以转储的进程
dotnet-dump ps

# 收集完整的内存转储
dotnet-dump collect --process-id 12345 --output app.dmp

# 收集迷你转储(只包含最少的信息,文件更小)
dotnet-dump collect -p 12345 --type Mini --output app-mini.dmp

# 收集包含堆信息的转储(适合内存泄漏分析)
dotnet-dump collect -p 12345 --type Heap --output app-heap.dmp

# 收集完整转储(包含所有内存,文件很大但信息最全)
dotnet-dump collect -p 12345 --type Full --output app-full.dmp

转储文件的大小取决于进程的内存使用量和转储类型。Mini 转储只有几 MB,而 Full 转储可能有几 GB。对于内存泄漏分析,通常使用 Heap 类型的转储就足够了,它包含了托管堆的完整信息但不包含原生内存。收集转储的过程会暂停目标进程几秒钟到几十秒钟,取决于内存大小和转储类型。在生产环境中收集转储时要注意这个影响,最好在负载较低的时段进行。

收集到转储文件后,可以使用 dotnet-dump analyze 命令进行分析。这个命令会启动一个交互式的调试会话,类似于 WinDbg 或 LLDB,但专门为 .NET 应用优化。在这个会话中,你可以执行各种调试命令来检查内存、线程、对象等。

shell 复制代码
# 开始分析转储文件
dotnet-dump analyze app.dmp

# 进入交互式调试会话后,你会看到类似这样的提示符:
# >

# 查看所有可用的调试命令
> help

# 显示 CLR 和运行时信息
> clrversion

# 列出所有线程
> threads

# 显示当前线程的调用栈
> clrstack

# 切换到特定线程(例如线程 5)
> setthread 5
> clrstack

# 显示托管堆的统计信息
> dumpheap -stat

# 这会显示一个按对象类型分组的表格,包含实例数量和总大小
# 按大小排序可以快速找出占用内存最多的对象类型

dumpheap -stat 命令的输出是内存分析的关键起点。它会显示一个表格,列出所有对象类型及其统计信息。表格的列包括方法表指针(MT)、对象数量(Count)、总大小(TotalSize)和类型名称(Type)。通过按总大小排序,你可以快速看到哪些类型的对象占用了最多内存。

shell 复制代码
# dumpheap -stat 的输出示例:
# 
# Statistics:
#       MT    Count    TotalSize Class Name
# 00007ff8a1234567        1           24 System.Collections.Generic.GenericEqualityComparer
# 00007ff8a1234568      342       10,944 System.String
# 00007ff8a1234569    1,256       50,240 OrderSystem.Models.OrderItem
# 00007ff8a123456a      845      135,200 OrderSystem.Models.Order
# 00007ff8a123456b   12,345    1,234,567 System.Byte[]
# 00007ff8a123456c   45,678   12,345,678 OrderSystem.Cache.CachedData
# 
# Total 60,467 objects, 14,776,653 bytes

在这个示例输出中,CachedData 类型占用了超过 12 MB 的内存,而且有 45,678 个实例,这明显是一个可疑的数字。如果这是一个缓存类,这么多实例可能意味着缓存没有正确清理过期项。接下来可以深入检查这些对象,看看它们包含什么数据以及为什么没有被回收。

shell 复制代码
# 找出所有 CachedData 类型的对象
> dumpheap -mt 00007ff8a123456c

# 这会列出所有实例的地址,例如:
# Address               MT     Size
# 00000123456789ab 00007ff8a123456c   256
# 00000123456789cd 00007ff8a123456c   256
# ...

# 检查特定对象的内容
> dumpobj 00000123456789ab

# 这会显示对象的详细信息,包括所有字段及其值
# Name:        OrderSystem.Cache.CachedData
# MethodTable: 00007ff8a123456c
# EEClass:     00007ff8a9876543
# Size:        256(0x100) bytes
# File:        D:\MyApp\OrderSystem.dll
# Fields:
#       MT    Field   Offset                 Type VT     Attr            Value Name
# 00007ff8a1... 4000001        8        System.String  0 instance 00000123456789ef _key
# 00007ff8a1... 4000002       10        System.Object  0 instance 0000012345678a01 _value
# 00007ff8a1... 4000003       18 ...DateTime  1 instance 2023-12-01 10:30:45 _expirationTime

# 查看字符串字段的实际内容
> dumpobj 00000123456789ef

# 查看对象的引用树,了解是什么保持了这个对象的引用
> gcroot 00000123456789ab

# gcroot 的输出会显示从 GC 根到这个对象的引用链
# 例如:
# HandleTable:
#     0000012345678900 (strong handle)
#       -> 0000012345678910 OrderSystem.Cache.CacheManager
#         -> 0000012345678920 System.Collections.Generic.Dictionary`2[[System.String, ...]]
#           -> 00000123456789ab OrderSystem.Cache.CachedData

gcroot 命令的输出非常关键,它回答了"为什么这个对象没有被垃圾回收?"这个问题。在上面的例子中,我们看到 CachedData 对象被一个 Dictionary 引用,这个 Dictionary 又被 CacheManager 引用,而 CacheManager 通过一个 strong handle 成为了 GC 根。这个引用链的存在阻止了对象被回收。如果你发现大量应该过期的缓存对象仍然存在,就需要检查 CacheManager 的代码,看看它的清理逻辑是否正确执行。

对于死锁分析,可以使用 syncblk 命令来检查对象锁的状态。死锁通常发生在多个线程相互等待对方持有的锁时,导致所有涉及的线程都无法继续执行。

shell 复制代码
# 显示所有同步块(SyncBlock)的信息
> syncblk

# 输出示例:
# Index         SyncBlock MonitorHeld Recursion Owning Thread Info          SyncBlock Owner
#     1 00000123456789ab            3         1 000001234567cdef   5678   0000012345678900 OrderSystem.Models.Order
#     2 00000123456789cd            5         1 000001234567ef01   5679   0000012345678920 OrderSystem.Services.PaymentService
# Total           2
# CCW             0
# RCW             0
# ComClassFactory 0
# Free            0

# 如果 MonitorHeld 列的值很高,说明有很多线程在等待这个锁
# 检查拥有锁的线程(Owning Thread)
> setthread 5678
> clrstack

# 检查等待锁的线程
> setthread 5679
> clrstack

通过比较两个线程的调用栈,你通常能看到它们在代码的哪里互相等待。例如,线程 A 持有锁 X 并等待锁 Y,而线程 B 持有锁 Y 并等待锁 X,这就形成了一个经典的死锁。解决死锁的方法通常是重新设计锁的获取顺序,或者使用更高级的并发原语如 SemaphoreSlim 或异步锁。

dotnet-dump 还支持一些高级的内存分析命令,比如 eeheap 显示托管堆的详细布局,histoobj 显示特定地址范围内的对象分布,dumpdomain 显示所有应用程序域的信息等。这些命令在诊断复杂的内存问题时非常有用,但需要对 CLR 的内部结构有一定了解。

shell 复制代码
# 显示托管堆的详细信息
> eeheap -gc

# 输出会显示各个代的大小、段数、内存范围等
# Number of GC Heaps: 1
# generation 0 starts at 0x0000012345678000
# generation 1 starts at 0x0000012346000000
# generation 2 starts at 0x0000012348000000
# ephemeral segment allocation context: none
#          segment             begin         allocated              size
# 0000012345670000  0000012345678000  0000012345ff0000  0x978000(9928KB)
# Large object heap starts at 0x0000012350000000
#          segment             begin         allocated              size
# 0000012350000000  0000012350008000  0000012351200000  0x11f8000(18400KB)
# Total Size:              Size: 0x1b70000 (28160KB)
# GC Heap Size:            Size: 0x1b70000 (28160KB)

# 查找包含特定类型所有实例的引用
> dumpheap -type OrderSystem.Models.Order

# 显示所有字符串对象(常用于找内存泄漏)
> dumpheap -type System.String -min 1000

# 这会只显示大小超过 1000 字节的字符串,帮助找出大字符串

使用这些命令行工具需要一定的学习曲线,但一旦掌握,它们会成为你排查复杂问题的得力助手。与图形化工具相比,命令行工具的优势在于它们可以在任何环境中使用,包括没有图形界面的服务器,可以通过脚本自动化,输出可以方便地集成到 CI/CD 管道中。在实际工作中,通常的做法是先使用 Aspire Dashboard 进行高层次的问题定位,然后根据需要使用这些命令行工具进行深入分析。例如,你可能在 Dashboard 中注意到内存使用持续增长,然后使用 dotnet-counters 确认 GC 行为异常,最后使用 dotnet-dump 捕获内存转储并找出泄漏的具体对象类型和引用链。这种分层的诊断方法结合了各种工具的优势,能够高效地解决从简单到复杂的各种问题。

2.4 Visual Studio 集成调试

Visual Studio 是 .NET 开发者最熟悉和最强大的集成开发环境,当涉及到调试 .NET Aspire 应用时,它提供了无与伦比的便利性和功能深度。与命令行工具或独立的调试器相比,Visual Studio 的最大优势在于它将调试体验深度集成到了日常开发工作流中。你可以在编写代码的同一个界面中设置断点、单步执行、检查变量、修改代码并继续执行,这种无缝的体验大大提高了开发效率。对于 Aspire 这样的分布式应用框架,Visual Studio 的多项目调试能力尤为重要,它允许你同时调试 AppHost、多个服务项目和数据库连接,就像调试单体应用一样简单。

当你打开一个 Aspire 解决方案时,Visual Studio 会识别出这是一个 Aspire 应用,并自动为你配置一些调试相关的设置。解决方案资源管理器会显示所有的项目,包括 AppHost、各个服务项目、ServiceDefaults 项目等。AppHost 项目是整个应用的编排中心,它定义了所有服务之间的依赖关系和启动顺序。因此,在调试 Aspire 应用时,你通常需要将 AppHost 项目设置为启动项目。这个操作很简单,在解决方案资源管理器中找到 AppHost 项目,右键点击它,然后在上下文菜单中选择"设为启动项目"。你会注意到 AppHost 项目的名称变为粗体,这表示它现在是启动项目。当你按下 F5 或点击工具栏上的"开始调试"按钮时,Visual Studio 会首先构建 AppHost 项目及其依赖项,然后运行 AppHost,由 AppHost 负责启动所有注册的服务和资源。

AppHost 作为启动项目的工作方式与普通的控制台应用有所不同。当 AppHost 启动时,它不仅仅运行自己的代码,还会根据 Program.cs 中的配置启动其他项目。这些项目可能运行在独立的进程中,也可能运行在容器中。Visual Studio 的调试器足够智能,能够自动附加到这些子进程,让你可以在任何服务中设置断点并调试。这种多进程调试能力是通过 Visual Studio 的调试引擎和 .NET 运行时的协作实现的。当 AppHost 启动一个服务项目时,它会传递特殊的启动参数和环境变量,告诉运行时启用调试支持并等待调试器附加。Visual Studio 监控这些子进程的启动,一旦检测到新的 .NET 进程,就会自动附加调试器。

让我们通过一个完整的实例来说明整个调试流程。假设你正在开发一个天气预报应用,其中包含一个 WeatherApi 服务用于提供天气数据,一个 Web 前端用于展示数据。用户报告说某些城市的天气数据显示不正确,你需要通过调试来找出问题。首先确保你的解决方案结构是清晰的,应该包含以下项目:AppHost、WeatherApi、WebFrontend 和 ServiceDefaults。在 AppHost 的 Program.cs 中,应该有类似这样的代码来定义服务之间的关系:

csharp 复制代码
// AppHost/Program.cs
using Aspire.Hosting;

var builder = DistributedApplication.CreateBuilder(args);

// 注册 WeatherApi 服务
// 这个服务将提供天气数据的 API 端点
var weatherApi = builder.AddProject<Projects.WeatherApi>("weatherapi")
    // 配置 HTTP 端点,使用固定端口便于测试和调试
    .WithHttpEndpoint(port: 5100, name: "http")
    // 配置 HTTPS 端点,虽然在开发环境可能不强制使用,但最好配置上
    .WithHttpsEndpoint(port: 5101, name: "https")
    // 添加一个环境变量,用于配置 API 的行为
    .WithEnvironment("WeatherApi__EnableDetailedErrors", "true");

// 注册 Redis 缓存,用于缓存天气数据以提高性能
var cache = builder.AddRedis("cache")
    .WithRedisCommander();  // 添加 Redis Commander 管理界面

// 注册 Web 前端
var webFrontend = builder.AddProject<Projects.WebFrontend>("webfrontend")
    .WithHttpEndpoint(port: 5000, name: "http")
    .WithHttpsEndpoint(port: 5001, name: "https")
    // 建立对 WeatherApi 的引用,这会自动配置服务发现
    .WithReference(weatherApi)
    // 建立对 Redis 缓存的引用
    .WithReference(cache);

// 构建并运行应用
var app = builder.Build();
await app.RunAsync();

在这个配置中,我们定义了三个资源:WeatherApi 服务、Redis 缓存和 WebFrontend。WithReference() 方法的调用非常关键,它告诉 Aspire,WebFrontend 依赖于 WeatherApi 和 Redis。当 AppHost 启动时,它会先启动 WeatherApi 和 Redis,等它们就绪后再启动 WebFrontend。这种依赖关系的自动管理是 Aspire 的一个核心特性,它确保了服务按正确的顺序启动,避免了因为依赖项未就绪而导致的启动失败。

现在让我们看看 WeatherApi 服务的实现,这是我们将要调试的核心代码。这个服务提供了一个 API 端点,接受城市名称作为参数,返回该城市的天气数据。为了演示调试过程,我们在代码中故意引入了一个逻辑错误:当城市名称为空或空白时,代码没有正确处理,导致返回了不正确的数据。

csharp 复制代码
// WeatherApi/Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

// 添加 Service Defaults 配置,这会自动配置日志、遥测、健康检查等
builder.AddServiceDefaults();

// 添加 Redis 缓存支持
// Aspire 会自动从环境变量中读取 Redis 连接字符串
builder.AddRedisClient("cache");

// 注册天气服务
builder.Services.AddSingleton<WeatherService>();

// 添加 API 端点支持
builder.Services.AddEndpointsApiExplorer();

var app = builder.Build();

// 映射默认端点,包括健康检查
app.MapDefaultEndpoints();

// 定义获取天气数据的 API 端点
app.MapGet("/weather/{city}", async (string city, WeatherService weatherService, ILogger<Program> logger) =>
{
    // 记录一条日志,标记请求的开始
    // 这条日志会出现在 Aspire Dashboard 和 Visual Studio 的输出窗口中
    logger.LogInformation("Weather request received for city: {City}", city);

    try
    {
        // 这里是我们将要调试的关键调用
        // 在这个方法调用处设置断点,我们可以单步进入 GetWeatherAsync 方法
        var weather = await weatherService.GetWeatherAsync(city);
        
        // 如果一切顺利,返回天气数据
        logger.LogInformation("Weather data retrieved successfully for city: {City}", city);
        return Results.Ok(weather);
    }
    catch (ArgumentException ex)
    {
        // 捕获参数验证异常
        logger.LogWarning(ex, "Invalid city name provided: {City}", city);
        return Results.BadRequest(new { error = ex.Message });
    }
    catch (Exception ex)
    {
        // 捕获所有其他异常
        logger.LogError(ex, "Error retrieving weather for city: {City}", city);
        return Results.Problem("An error occurred while retrieving weather data");
    }
});

app.Run();

这个 Program.cs 文件定义了一个简单的 API 端点:/weather/{city}。当客户端发送请求到这个端点时,代码会调用 WeatherServiceGetWeatherAsync 方法来获取天气数据。现在让我们实现 WeatherService 类,这是包含业务逻辑和错误的地方:

csharp 复制代码
// WeatherApi/WeatherService.cs
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using System.Text.Json;

public class WeatherService
{
    private readonly ILogger<WeatherService> _logger;
    private readonly IConnectionMultiplexer _redis;
    
    // 模拟的天气数据库,实际应用中这应该是真实的数据源
    private static readonly Dictionary<string, WeatherData> s_weatherDatabase = new()
    {
        ["Beijing"] = new() { City = "Beijing", Temperature = 15, Condition = "Sunny" },
        ["Shanghai"] = new() { City = "Shanghai", Temperature = 20, Condition = "Cloudy" },
        ["Guangzhou"] = new() { City = "Guangzhou", Temperature = 28, Condition = "Rainy" },
        ["Shenzhen"] = new() { City = "Shenzhen", Temperature = 26, Condition = "Sunny" }
    };

    public WeatherService(ILogger<WeatherService> logger, IConnectionMultiplexer redis)
    {
        _logger = logger;
        _redis = redis;
    }

    public async Task<WeatherData> GetWeatherAsync(string city)
    {
        // 这是我们要调试的第一个关键点
        // 在这里设置断点,检查 city 参数的值
        _logger.LogDebug("GetWeatherAsync called with city: {City}", city);

        // 验证参数
        // 注意:这里有一个 bug!当 city 是空字符串时,IsNullOrWhiteSpace 会返回 true
        // 但代码继续执行而没有抛出异常,导致后续逻辑出错
        if (string.IsNullOrWhiteSpace(city))
        {
            _logger.LogWarning("City parameter is null or empty");
            // BUG:这里应该抛出异常,但实际上没有!
            // throw new ArgumentException("City name cannot be empty", nameof(city));
            
            // 错误地继续执行,使用空字符串作为缓存键
        }

        // 尝试从 Redis 缓存中获取数据
        // 在这里设置第二个断点,检查缓存的行为
        var cacheKey = $"weather:{city}";  // 如果 city 为空,这会是 "weather:"
        _logger.LogDebug("Checking cache with key: {CacheKey}", cacheKey);

        try
        {
            var db = _redis.GetDatabase();
            var cachedData = await db.StringGetAsync(cacheKey);
            
            if (!cachedData.IsNullOrEmpty)
            {
                _logger.LogInformation("Cache hit for city: {City}", city);
                
                // 在这里设置断点,检查反序列化的数据是否正确
                var weatherData = JsonSerializer.Deserialize<WeatherData>(cachedData!);
                
                // 使用监视窗口检查 weatherData 的各个属性
                // 如果 city 为空,weatherData 可能包含错误的或空的数据
                return weatherData!;
            }
            
            _logger.LogInformation("Cache miss for city: {City}", city);
        }
        catch (Exception ex)
        {
            // 如果 Redis 连接失败,记录错误但继续执行
            _logger.LogError(ex, "Error accessing Redis cache");
        }

        // 缓存未命中或缓存访问失败,从数据库获取数据
        // 在这里设置断点,检查字典查找的结果
        _logger.LogDebug("Fetching weather data from database for city: {City}", city);
        
        // 这里是另一个潜在的问题点
        // 如果 city 在字典中不存在,TryGetValue 会返回 false
        // 但如果 city 是空字符串,它也不会在字典中找到匹配项
        if (s_weatherDatabase.TryGetValue(city, out var data))
        {
            _logger.LogInformation("Weather data found in database for city: {City}", city);
            
            // 将数据存入缓存以供下次使用
            try
            {
                var db = _redis.GetDatabase();
                var serializedData = JsonSerializer.Serialize(data);
                
                // 在这里设置断点,验证数据是否正确序列化
                await db.StringSetAsync(cacheKey, serializedData, TimeSpan.FromMinutes(10));
                _logger.LogDebug("Weather data cached with key: {CacheKey}", cacheKey);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error caching weather data");
            }
            
            return data;
        }

        // 城市不在数据库中
        _logger.LogWarning("Weather data not found for city: {City}", city);
        
        // 这里返回一个默认值而不是抛出异常
        // 这可能不是期望的行为,应该返回 404 或抛出异常
        return new WeatherData 
        { 
            City = city, 
            Temperature = 0, 
            Condition = "Unknown" 
        };
    }
}

// 天气数据模型
public class WeatherData
{
    public string City { get; set; } = string.Empty;
    public int Temperature { get; set; }
    public string Condition { get; set; } = string.Empty;
}

这段代码中包含了几个典型的错误和可调试点。首先,参数验证逻辑不完整,当 city 为空时,代码记录了警告但没有抛出异常,导致后续逻辑使用了无效的输入。其次,缓存键的构造没有考虑空字符串的情况,可能导致缓存污染。第三,当城市不在数据库中时,代码返回了一个默认的"Unknown"天气数据,而不是明确地告诉调用者城市不存在。这些都是在实际开发中容易犯的错误,通过调试我们可以清楚地看到问题的根源。

现在让我们开始调试过程。首先在 Visual Studio 中打开解决方案,确保 AppHost 是启动项目。然后在 WeatherService.csGetWeatherAsync 方法的第一行设置一个断点。设置断点的方法很简单,将光标移动到代码行上,然后按 F9 键,或者点击代码编辑器左侧的灰色边栏。断点设置成功后,你会看到一个红色的圆点出现在代码行的左侧,这表示当程序执行到这一行时会暂停。现在按 F5 启动调试,Visual Studio 会构建解决方案,启动 AppHost,AppHost 会依次启动 Redis 容器、WeatherApi 服务和 WebFrontend。这个过程可能需要几秒到十几秒,取决于你的机器性能和项目的复杂度。

当所有服务都启动完成后,Visual Studio 会自动打开 Aspire Dashboard 的网页。你可以在 Dashboard 的 Projects 页面看到所有服务的状态,都应该显示为绿色的 Running。现在我们需要触发一个会导致问题的请求。打开另一个浏览器标签页或使用 Postman、curl 等工具,发送一个请求到天气 API,但故意使用一个空的城市名称。例如,访问 http://localhost:5100/weather/http://localhost:5100/weather/%20%20 是空格的 URL 编码)。当这个请求到达 WeatherApi 服务时,代码会执行到你设置断点的那一行,Visual Studio 会自动切换到前台并进入调试模式。

此时整个调试界面会发生变化。代码编辑器中,当前执行的代码行会以黄色背景高亮显示,这就是我们设置断点的那一行。窗口底部或右侧会出现几个调试相关的工具窗口,最重要的包括:Locals(局部变量)窗口显示当前作用域内所有变量的名称、类型和值;Autos(自动)窗口显示当前行和前几行涉及的变量;Watch(监视)窗口允许你添加自定义的表达式来监视;Call Stack(调用堆栈)窗口显示当前线程的完整函数调用链。这些窗口提供了程序运行状态的全方位视图,让你可以深入了解代码执行的每个细节。

让我们仔细检查 Locals 窗口。你应该看到几个变量:this(当前的 WeatherService 实例)、city(方法参数)、_logger(日志记录器)和 _redis(Redis 连接)。展开 city 变量,你会看到它的值可能是 ""(空字符串)或 " "(空格字符串),这取决于你发送的请求。这就是问题的根源:API 接收到了一个无效的城市名称,但代码没有正确地拒绝它。如果你展开 this 变量,可以看到 WeatherService 实例的所有字段,包括 _logger_redis,以及静态字段 s_weatherDatabase,其中包含了所有预定义的城市天气数据。

现在你可以使用调试工具栏或快捷键来控制程序的执行。按 F10 执行"单步跳过"(Step Over),这会执行当前行并移动到下一行,但不会进入任何方法调用的内部。按 F11 执行"单步进入"(Step Into),如果当前行包含方法调用,这会进入那个方法的内部,让你可以逐行调试它。按 Shift+F11 执行"单步跳出"(Step Out),这会执行完当前方法的剩余部分并返回到调用者。按 F5 继续执行直到下一个断点或程序结束。这些是调试的基本操作,熟练掌握它们能让你高效地追踪代码执行流程。

按 F10 单步执行第一行日志记录语句,然后继续按 F10 到达参数验证的 if 语句。在 Locals 窗口中,你可以看到 string.IsNullOrWhiteSpace(city) 的结果是 true,因为 city 确实是空的或只包含空格。代码进入了 if 块,记录了一条警告日志,但注意到被注释掉的 throw 语句。这就是 bug 的位置:代码检测到了无效输入,但没有采取适当的行动来阻止它继续执行。如果这行代码没有被注释,异常会被抛出,请求会以 400 Bad Request 响应结束,这是正确的行为。

继续单步执行,你会到达缓存检查的代码。在变量 cacheKey 上悬停鼠标,或在 Watch 窗口中添加 cacheKey 表达式,你可以看到它的值是 "weather:"(如果 city 是空字符串)或 "weather: "(如果 city 是空格)。这显然不是一个有效的缓存键,它可能会与其他无效请求的缓存冲突,或者在缓存系统中造成混乱。这是另一个需要修复的问题点。

当代码尝试从 Redis 获取数据时,你可以单步进入 StringGetAsync 方法来查看 Redis 客户端的内部工作,但这通常不是必要的,因为第三方库的代码通常是可靠的。相反,你可以单步跳过这个调用,然后检查返回值 cachedData。如果这是第一次请求(缓存中没有数据),cachedData.IsNullOrEmpty 应该是 true,代码会继续到数据库查找逻辑。

在字典查找的代码行设置另一个断点,或者直接单步执行到那里。当执行到 s_weatherDatabase.TryGetValue(city, out var data) 时,在 Locals 窗口检查 city 的值,然后按 F11 单步进入 TryGetValue 方法(虽然这个方法是 .NET 框架的一部分,源代码可能不可用,但你仍然可以看到它的返回值)。TryGetValue 会返回 false,因为空字符串不是字典中的任何键。在 Locals 窗口中,data 变量会显示为 null,因为 TryGetValue 在失败时不会设置 out 参数。

代码跳过 if 块,执行到最后的 return 语句,返回一个默认的 WeatherData 对象。你可以在 return 语句上设置断点,当断点触发时,在 Locals 窗口检查正在返回的对象。它的 City 属性是空字符串(或空格字符串),Temperature 是 0,Condition 是 "Unknown"。这个数据会被返回给 API 端点,然后发送给客户端。这显然不是期望的行为,客户端收到了一个看似有效的响应,但内容是无意义的。

通过这个调试过程,我们清楚地识别了问题的原因和位置。现在可以停止调试(Shift+F5),修复代码,然后重新测试。修复方案很简单:取消注释参数验证代码中的 throw 语句,确保无效输入被立即拒绝。你还可以改进字典查找失败时的行为,抛出一个自定义异常或返回一个明确的错误响应,而不是返回默认的"Unknown"数据。

修复后的代码如下:

csharp 复制代码
public async Task<WeatherData> GetWeatherAsync(string city)
{
    _logger.LogDebug("GetWeatherAsync called with city: {City}", city);

    // 修复:现在正确地验证参数并抛出异常
    if (string.IsNullOrWhiteSpace(city))
    {
        _logger.LogWarning("City parameter is null or empty");
        throw new ArgumentException("City name cannot be empty", nameof(city));
    }

    var cacheKey = $"weather:{city}";
    _logger.LogDebug("Checking cache with key: {CacheKey}", cacheKey);

    // ... 缓存逻辑保持不变 ...

    if (s_weatherDatabase.TryGetValue(city, out var data))
    {
        _logger.LogInformation("Weather data found in database for city: {City}", city);
        
        // ... 缓存存储逻辑保持不变 ...
        
        return data;
    }

    // 修复:明确地抛出异常,而不是返回默认数据
    _logger.LogWarning("Weather data not found for city: {City}", city);
    throw new KeyNotFoundException($"Weather data not found for city: {city}");
}

保存修改后,Visual Studio 支持"编辑并继续"(Edit and Continue)功能,在某些情况下你可以在调试过程中修改代码并立即应用更改,而无需重新启动应用。但对于方法签名的更改或添加新的异常类型,通常需要停止调试并重新启动。按 Shift+F5 停止调试,然后按 F5 重新启动。这次再发送相同的无效请求,代码会在参数验证处抛出 ArgumentException,API 端点的 catch 块会捕获它并返回 400 Bad Request 响应,这是正确的行为。

Visual Studio 的即时窗口(Immediate Window)是另一个强大的调试工具,它允许你在调试过程中执行任意的 C# 代码。当程序在断点处暂停时,你可以打开即时窗口(Ctrl+Alt+I 或通过菜单 Debug → Windows → Immediate),然后输入任何有效的 C# 表达式并按 Enter 执行。例如,你可以输入 city.Length 来查看字符串的长度,输入 s_weatherDatabase.Count 来查看字典中有多少个条目,甚至输入 s_weatherDatabase["Beijing"] 来检索特定城市的天气数据。即时窗口不仅可以读取数据,还可以修改变量的值,调用方法,创建新对象。这对于测试假设或验证修复方案非常有用。

监视窗口(Watch Window)提供了一种更持久的方式来跟踪表达式的值。与即时窗口的一次性执行不同,监视窗口中的表达式会在每次程序暂停时自动重新计算。你可以添加任意数量的表达式到监视窗口,Visual Studio 会在调试过程中持续显示它们的当前值。这对于跟踪复杂对象的状态变化或监视循环变量的迭代特别有用。例如,如果你在调试一个循环处理订单的代码,可以在监视窗口中添加 order.Idorder.Totalorder.Items.Count 等表达式,每次循环迭代时都能看到这些值的变化。

调用堆栈窗口(Call Stack Window)显示了当前线程从程序入口点到当前位置的完整函数调用序列。这在调试深层嵌套的方法调用或理解代码执行路径时非常有用。在调用堆栈窗口中,你可以双击任何堆栈帧来跳转到该方法的代码位置,并查看该帧的局部变量。这让你可以"向上"追溯调用链,理解当前方法是如何被调用的,传入了什么参数。在 Aspire 应用中,调用堆栈可能会跨越多个层次:从 ASP.NET Core 的 HTTP 管道,到你的控制器或端点处理器,再到业务逻辑层,最后到数据访问层。完整的调用堆栈视图让你可以看到整个请求的处理流程。

对于异步代码的调试,Visual Studio 提供了特殊的支持。当你调试包含 asyncawait 的代码时,调用堆栈窗口会显示异步调用链,帮助你理解异步操作的执行顺序。你可以看到哪些方法是同步执行的,哪些是异步等待的,以及异步操作完成后的继续点在哪里。这对于诊断异步代码中的死锁或竞态条件问题非常重要。例如,如果你的代码中有这样的模式:var result = someAsyncMethod().Result;(同步等待异步方法),Visual Studio 会在调用堆栈中明确显示这个阻塞点,帮助你识别潜在的性能问题或死锁风险。

Visual Studio 还支持条件断点和命中计数断点,这些是高级的调试技术,可以让你更精确地控制何时暂停执行。条件断点只在指定的条件为真时才会触发,这在调试循环或处理大量数据时特别有用。例如,如果你在一个处理数千个订单的循环中设置了断点,但只想在某个特定订单 ID 出现时暂停,可以右键点击断点,选择"条件",然后输入条件表达式如 order.Id == 12345。命中计数断点允许你指定断点应该在第 N 次触发时才暂停,或者每隔 N 次触发一次暂停。这对于调试间歇性问题或性能问题很有帮助,你可以让代码运行多次迭代后再暂停检查状态。

数据断点是另一个强大的功能,它允许你在特定对象的特定属性值改变时暂停执行。这对于追踪意外的状态变化非常有用。例如,如果你有一个订单对象,其状态属性在某个未知的地方被错误地修改了,你可以在该属性上设置数据断点。当任何代码尝试修改这个属性时,Visual Studio 会立即暂停,并显示是哪一行代码进行了修改。这种"反向调试"的能力在处理复杂的状态管理问题时非常宝贵。

对于 Aspire 应用的多项目调试,Visual Studio 提供了并行堆栈窗口(Parallel Stacks Window)和并行监视窗口(Parallel Watch Window),这些工具专门用于调试多线程和多进程应用。并行堆栈窗口以图形方式显示所有线程的调用堆栈,让你可以一目了然地看到不同线程正在执行什么代码。这对于诊断并发问题、死锁或竞态条件非常有用。并行监视窗口允许你同时查看多个线程中同一表达式的值,快速比较不同线程的状态。

在调试 Aspire 应用时,你还可以利用 Visual Studio 的诊断工具窗口(Diagnostic Tools Window),它在调试会话期间自动打开。这个窗口提供了 CPU 使用率、内存使用量、事件时间线等实时信息。你可以在调试过程中看到 CPU 峰值或内存分配的尖刺,点击这些峰值可以跳转到相应的代码位置。诊断工具还集成了 PerfTips,当你单步执行代码时,编辑器会在行号旁边显示每行代码的执行时间,让你立即看到哪些代码行很慢。这种即时的性能反馈可以帮助你在开发早期就识别性能问题。

最后,Visual Studio 的调试器还支持自定义调试器可视化工具(Debugger Visualizers)和类型代理(Type Proxies)。可视化工具是用于以更友好的方式显示复杂对象的工具,例如,字符串可视化工具可以以多行文本、HTML 或 XML 的形式显示长字符串。你可以编写自己的可视化工具来以特定领域的方式显示业务对象。类型代理允许你控制对象在调试器窗口中的显示方式,通过添加 [DebuggerDisplay] 特性和实现 DebuggerTypeProxy 类,你可以简化复杂对象的调试视图,只显示最重要的信息。这些高级功能需要一些额外的学习,但在处理大型复杂应用时,它们可以显著提高调试效率。

通过充分利用 Visual Studio 的这些调试功能,你可以快速定位和修复 Aspire 应用中的各种问题,从简单的逻辑错误到复杂的并发问题。关键是要熟悉各种调试窗口和工具的用途,知道在什么情况下使用哪个工具。随着经验的积累,你会发现调试不再是一个令人沮丧的试错过程,而是一个系统化的、有方法的问题解决过程。Visual Studio 的强大调试能力与 Aspire 的可观测性特性相结合,为开发高质量的分布式应用提供了坚实的基础。

2.5 VS Code 调试扩展

Visual Studio Code 作为一个轻量级但功能强大的代码编辑器,在近年来已经成为许多 .NET 开发者的首选工具,特别是在跨平台开发和容器化应用的场景中。对于 .NET Aspire 应用的开发,VS Code 提供了专门的扩展和调试支持,让你可以在这个轻量级编辑器中享受到接近 Visual Studio 的调试体验。虽然 VS Code 的调试功能在某些方面不如 Visual Studio 完整,但它的优势在于快速启动、跨平台支持以及对容器和远程开发的原生支持。理解如何在 VS Code 中配置和使用 Aspire 调试功能,可以让你在不同的开发环境和操作系统中保持一致的工作流程。

在开始配置 VS Code 调试之前,你需要确保安装了必要的扩展。首先是 C# 扩展,这是 .NET 开发的基础,它提供了语法高亮、智能感知、代码导航和基本的调试支持。你可以在 VS Code 的扩展市场中搜索 "C# Dev Kit" 或 "C# for Visual Studio Code",这个扩展由微软官方维护,是 VS Code 中 .NET 开发的核心组件。安装这个扩展后,VS Code 会自动下载和配置 OmniSharp 语言服务器,它负责提供代码分析和智能感知功能。其次是 Aspire 扩展,虽然目前 Aspire 的官方 VS Code 扩展还在早期阶段,但它提供了一些专门针对 Aspire 应用的功能,如自动检测 AppHost 项目、提供 Aspire 特定的代码片段、以及与 Aspire Dashboard 的集成。安装这些扩展后,重启 VS Code 以确保所有功能正常加载。

配置 VS Code 调试的核心是 .vscode/launch.json 文件,这个文件定义了调试会话的所有参数和行为。当你在 VS Code 中打开一个 Aspire 解决方案时,如果这个文件不存在,你可以通过几种方式创建它。最简单的方法是点击 VS Code 侧边栏的 Run and Debug 图标(通常是一个播放按钮),然后点击 "create a launch.json file" 链接。VS Code 会提示你选择环境,选择 ".NET Core" 或 ".NET 5+",它会自动生成一个基本的配置文件。但对于 Aspire 应用,我们需要手动调整这个配置以正确启动 AppHost 项目并附加调试器到所有相关的服务进程。

让我们创建一个完整的 launch.json 配置文件,详细解释每个配置项的含义和作用。假设你的 Aspire 解决方案包含一个 AppHost 项目、一个 API 服务和一个 Web 前端。首先在项目根目录创建 .vscode 文件夹(如果不存在),然后在其中创建 launch.json 文件:

json 复制代码
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Aspire AppHost",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/AppHost/bin/Debug/net8.0/AppHost.dll",
            "args": [],
            "cwd": "${workspaceFolder}/AppHost",
            "stopAtEntry": false,
            "console": "integratedTerminal",
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development",
                "DOTNET_ENVIRONMENT": "Development",
                "Logging__Console__FormatterName": "simple"
            },
            "serverReadyAction": {
                "action": "openExternally",
                "pattern": "\\bNow listening on:\\s+(https?://\\S+)",
                "uriFormat": "%s"
            },
            "sourceFileMap": {
                "/Views": "${workspaceFolder}/Views"
            },
            "justMyCode": false,
            "enableStepFiltering": true,
            "logging": {
                "moduleLoad": false,
                "engineLogging": false,
                "trace": false,
                "traceResponse": false
            }
        },
        {
            "name": "Attach to WeatherApi",
            "type": "coreclr",
            "request": "attach",
            "processName": "WeatherApi"
        },
        {
            "name": "Attach to WebFrontend",
            "type": "coreclr",
            "request": "attach",
            "processName": "WebFrontend"
        }
    ],
    "compounds": [
        {
            "name": "Aspire Debug All",
            "configurations": [
                "Aspire AppHost",
                "Attach to WeatherApi",
                "Attach to WebFrontend"
            ],
            "stopAll": true
        }
    ]
}

这个配置文件看起来很长,但每一部分都有其特定的作用。让我们逐个解析这些配置项。version 字段指定了 launch.json 文件的版本,0.2.0 是当前的标准版本。configurations 数组包含了所有的调试配置,每个配置对应一种调试场景。第一个配置 "Aspire AppHost" 是主要的启动配置,它会构建并运行 AppHost 项目。

type 字段设置为 coreclr 表示这是一个 .NET Core 运行时的调试会话。对于 .NET Framework 应用,这个值应该是 clr,但 Aspire 应用都是基于 .NET Core 的,所以始终使用 coreclrrequest 字段的值 launch 表示这个配置会启动一个新的进程,而不是附加到已有的进程。稍后我们会看到 attach 类型的配置,它们用于附加到由 AppHost 启动的子进程。

preLaunchTask 字段指定了在启动调试会话之前要执行的任务,这里设置为 build,意味着在启动前会先构建项目。这个任务需要在 .vscode/tasks.json 文件中定义,我们稍后会详细说明。如果不想每次调试前都构建,可以将这个字段设置为 null 或完全移除它,但这可能导致你调试的是旧版本的代码。

program 字段是最重要的配置之一,它指定了要启动的可执行文件的路径。这里使用了 VS Code 的变量替换语法,${workspaceFolder} 会被替换为当前工作区的根目录路径。完整的路径指向 AppHost 项目的 Debug 构建输出目录中的 DLL 文件。注意这里的 net8.0 部分需要根据你的实际目标框架版本进行调整,如果你的项目使用 .NET 9.0,这里应该改为 net9.0。路径中的正斜杠 / 在 Windows 和 Linux/macOS 上都有效,VS Code 会自动处理路径分隔符的差异。

args 数组用于传递命令行参数给应用程序。对于 AppHost 项目,通常不需要额外的参数,所以这里是空数组。但如果你想传递特定的配置参数,可以在这里添加,例如 ["--environment", "Staging"] 会将环境设置为 Staging。这些参数会作为 string[] args 传递给 AppHost 的 Main 方法或 Program.cs 中的顶层语句。

cwd 字段设置了工作目录,即应用程序启动时的当前目录。这个设置很重要,因为应用程序可能会从相对路径读取配置文件或其他资源。设置为 AppHost 项目的目录确保了相对路径的正确解析。如果不设置这个字段,工作目录默认是 ${workspaceFolder},即解决方案的根目录,这可能导致找不到配置文件。

stopAtEntry 字段决定了调试器是否在应用程序的入口点处立即暂停。设置为 false 意味着应用会正常运行,只有在遇到断点或异常时才会暂停。如果你想从程序的第一行代码开始单步调试,可以将这个值改为 true,但对于 Aspire 应用这通常不是必要的,因为你更关心的是服务启动后的业务逻辑,而不是启动过程本身。

console 字段控制了应用程序的控制台输出如何显示。integratedTerminal 是推荐的设置,它会在 VS Code 的集成终端中显示输出,你可以看到所有的日志消息、错误信息和调试输出。其他可选值包括 internalConsole(在 VS Code 的 Debug Console 窗口中显示,但不支持输入)和 externalTerminal(在独立的终端窗口中显示)。对于 Aspire 应用,integratedTerminal 是最佳选择,因为它允许你在同一个窗口中同时查看代码和输出,还可以与应用交互(如果需要的话)。

env 对象定义了启动应用时的环境变量。这些变量会覆盖系统环境变量,对于配置开发环境非常有用。ASPNETCORE_ENVIRONMENTDOTNET_ENVIRONMENT 都设置为 Development,这会激活开发模式的特性,如详细的错误页面、热重载支持等。Logging__Console__FormatterName 设置为 simple 会使控制台日志输出更简洁,去掉一些不必要的元数据,便于阅读。你可以根据需要添加其他环境变量,如数据库连接字符串、API 密钥等,但要注意不要在版本控制中提交包含敏感信息的 launch.json 文件。

serverReadyAction 是一个非常实用的功能,它可以在应用启动完成后自动执行某些操作。这里配置的 actionopenExternally,意味着当应用准备好接受请求时,VS Code 会自动在浏览器中打开应用的 URL。pattern 是一个正则表达式,用于从应用的输出中提取 URL。ASP.NET Core 应用在启动时会输出类似 "Now listening on: https://localhost:5001" 的消息,这个正则表达式会捕获这个 URL。uriFormat%s 会被替换为捕获的 URL。这个功能对于 Aspire Dashboard 特别有用,因为它会自动打开 Dashboard 页面,你不需要手动从输出中复制 URL。

sourceFileMap 用于配置源文件映射,在调试 Razor 视图或其他动态生成的代码时特别有用。这里的配置将 /Views 路径映射到工作区的 Views 文件夹,确保调试器能够找到正确的源文件。对于大多数 Aspire API 服务,这个配置不是必需的,但如果你的应用包含 Razor Pages 或 MVC 视图,这个映射可以让你在视图文件中设置断点并调试。

justMyCode 设置为 false 允许调试器进入第三方库和框架代码的内部。默认情况下,调试器会跳过非用户代码,只在你自己的代码中暂停。禁用这个选项让你可以深入到 .NET 运行时、ASP.NET Core 框架甚至 Aspire 库的内部,这对于理解底层行为或调试复杂问题非常有帮助。但要注意,这会让调试体验变得更复杂,因为你可能会在不熟悉的框架代码中迷失。如果你只想调试自己的代码,可以将这个值改为 true

enableStepFiltering 控制是否启用步骤过滤,即是否自动跳过某些类型的代码,如属性 getter/setter、编译器生成的代码等。启用这个选项可以让单步调试更顺畅,避免进入不重要的代码。这个设置与 justMyCode 是互补的,两者结合使用可以在调试细节和效率之间取得平衡。

logging 对象配置了调试器自身的日志输出。在正常的调试场景中,你不需要这些日志,所以这里都设置为 false 以减少噪音。但如果你遇到调试器本身的问题,如无法附加到进程、断点不工作等,可以将这些设置改为 true 来启用详细的调试器日志,帮助诊断问题。这些日志会出现在 Output 面板的 "C# Extension Log" 通道中。

配置文件中的第二和第三个配置是 attach 类型的,它们用于附加到已经运行的进程。当你启动 AppHost 时,它会创建多个子进程来运行各个服务。这些 attach 配置允许你在这些子进程中设置断点和单步调试。processName 字段指定了要附加的进程名称,这个名称通常与项目名称相同。VS Code 会在系统中搜索匹配这个名称的 .NET 进程,并将调试器附加上去。

但是单独使用这些 attach 配置有一个问题:你需要手动等待 AppHost 启动所有服务,然后再逐个附加调试器。这很不方便,而且容易错过你想调试的代码执行时机。这就是为什么我们定义了一个 compounds 配置。compounds 允许你组合多个调试配置,同时启动它们。"Aspire Debug All" 配置会先启动 AppHost,然后自动附加到 WeatherApi 和 WebFrontend 进程。stopAll 设置为 true 意味着当你停止调试时,所有相关的进程都会被终止,而不是只停止 AppHost。

使用这个配置的方式是,在 VS Code 的 Run and Debug 侧边栏中,从下拉列表中选择 "Aspire Debug All",然后点击绿色的播放按钮或按 F5。VS Code 会依次执行所有配置:首先构建项目,然后启动 AppHost,等待 AppHost 启动服务,最后附加调试器到各个服务进程。整个过程是自动化的,你只需要等待几秒钟,直到看到所有服务在 Aspire Dashboard 中显示为运行状态。

然而,要使 preLaunchTask 工作,我们还需要创建 tasks.json 文件来定义构建任务。在 .vscode 文件夹中创建 tasks.json 文件,内容如下:

json 复制代码
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "build",
            "command": "dotnet",
            "type": "process",
            "args": [
                "build",
                "${workspaceFolder}/AspireSolution.sln",
                "/property:GenerateFullPaths=true",
                "/consoleloggerparameters:NoSummary"
            ],
            "problemMatcher": "$msCompile",
            "group": {
                "kind": "build",
                "isDefault": true
            }
        },
        {
            "label": "publish",
            "command": "dotnet",
            "type": "process",
            "args": [
                "publish",
                "${workspaceFolder}/AspireSolution.sln",
                "--configuration",
                "Release",
                "/property:GenerateFullPaths=true",
                "/consoleloggerparameters:NoSummary"
            ],
            "problemMatcher": "$msCompile",
            "group": "build"
        },
        {
            "label": "watch",
            "command": "dotnet",
            "type": "process",
            "args": [
                "watch",
                "run",
                "--project",
                "${workspaceFolder}/AppHost/AppHost.csproj"
            ],
            "problemMatcher": "$msCompile",
            "isBackground": true,
            "presentation": {
                "reveal": "always",
                "panel": "new"
            }
        },
        {
            "label": "aspire-dashboard",
            "command": "dotnet",
            "type": "process",
            "args": [
                "run",
                "--project",
                "${workspaceFolder}/AppHost/AppHost.csproj"
            ],
            "problemMatcher": [],
            "isBackground": true,
            "presentation": {
                "reveal": "always",
                "panel": "dedicated"
            },
            "dependsOn": ["build"]
        }
    ]
}

这个 tasks.json 文件定义了几个有用的任务。第一个是 build 任务,它使用 dotnet build 命令构建整个解决方案。command 字段指定了要执行的命令,type 设置为 process 表示这是一个外部进程(而不是 shell 命令)。args 数组包含了传递给命令的参数,${workspaceFolder}/AspireSolution.sln 需要替换为你的实际解决方案文件名。/property:GenerateFullPaths=true 参数让编译器输出完整的文件路径,这样 VS Code 的问题面板可以正确地跳转到错误位置。/consoleloggerparameters:NoSummary 禁用了构建摘要,使输出更简洁。

problemMatcher 设置为 $msCompile 是一个预定义的问题匹配器,它知道如何解析 .NET 编译器的错误和警告输出,并在 VS Code 的 Problems 面板中显示它们。当你点击一个错误,VS Code 会自动打开相关的源文件并定位到错误行。group 配置将这个任务标记为构建任务,isDefault 设置为 true 意味着当你按 Ctrl+Shift+B(默认的构建快捷键)时,会执行这个任务。

publish 任务用于发布应用的 Release 版本,它添加了 --configuration Release 参数来使用发布配置。这个任务在准备部署应用时很有用,它会创建优化的二进制文件并复制所有必要的依赖项到输出目录。

watch 任务是一个特殊的任务,它使用 dotnet watch 命令启动应用并监视文件变化。当你修改代码并保存时,dotnet watch 会自动重新编译和重启应用,这对于快速迭代开发非常有用。isBackground 设置为 true 表示这个任务会持续运行,不会阻塞其他任务。presentation 配置控制了任务输出的显示方式,revealalways 确保终端面板总是可见,panelnew 会为每次运行创建一个新的终端。

aspire-dashboard 任务是一个快捷方式,直接运行 AppHost 项目并打开 Aspire Dashboard。它依赖于 build 任务,通过 dependsOn 字段指定,确保在运行前代码是最新的。presentation 中的 panel 设置为 dedicated 意味着这个任务总是使用同一个终端面板,而不是每次创建新的。

配置好这些文件后,你的 VS Code 工作区就完全具备了调试 Aspire 应用的能力。让我们通过一个完整的调试工作流来演示如何使用这些配置。假设你在 WeatherApi 服务中发现了一个 bug,需要调试来找出原因。首先在有问题的代码行设置断点,这在 VS Code 中和在 Visual Studio 中一样简单,点击代码编辑器左侧的槽位(行号左边的空白区域),会出现一个红点表示断点已设置。现在按 F5 或点击 Run and Debug 侧边栏的播放按钮,选择 "Aspire Debug All" 配置启动调试。

VS Code 会先在集成终端中运行构建任务,你会看到编译输出滚动而过。如果有编译错误,它们会立即显示在 Problems 面板中,你需要修复它们才能继续。构建成功后,AppHost 进程会启动,你会在终端中看到 Aspire 的启动日志,包括正在启动的服务列表、端口分配信息等。几秒钟后,Aspire Dashboard 的 URL 会出现在输出中,由于我们配置了 serverReadyAction,浏览器会自动打开并显示 Dashboard。

与此同时,VS Code 的状态栏会显示 "Launching...",表示调试器正在附加到各个服务进程。如果一切配置正确,你会在 Call Stack 面板中看到多个线程,分别对应 AppHost、WeatherApi 和 WebFrontend 进程。当某个服务启动完成并开始运行时,如果你在其代码中设置了断点,断点图标会从灰色变为红色,表示断点已激活并可以触发。

现在从浏览器或 Postman 向 WeatherApi 发送一个请求,触发你设置断点的代码路径。当代码执行到断点时,VS Code 会自动切换到前台,代码编辑器会高亮显示当前执行的代码行,所有调试面板(Variables、Watch、Call Stack、Debug Console)会更新显示当前的程序状态。你可以使用调试工具栏或快捷键进行单步调试:F10 单步跳过,F11 单步进入,Shift+F11 单步跳出,F5 继续执行。

VS Code 的 Variables 面板功能与 Visual Studio 的 Locals 窗口类似,显示当前作用域内的所有变量。你可以展开复杂对象查看其属性,右键点击变量选择 "Copy Value" 复制其值,或选择 "Add to Watch" 将其添加到监视列表。Watch 面板允许你输入任意的 C# 表达式,如 order.Items.Countstring.Join(", ", order.Items.Select(i => i.Name)),甚至是调用方法如 CalculateTotal()。这些表达式会在每次程序暂停时重新计算,让你可以动态地检查程序状态。

Debug Console 面板不仅显示调试器的输出,还提供了一个交互式的 REPL(Read-Eval-Print Loop)环境。你可以在这里输入 C# 代码并立即执行,查看结果。例如,输入 order.Items[0].Name 会显示第一个订单项的名称,输入 order.Items.Add(new OrderItem { Name = "Test" }) 会动态地修改订单对象。这个功能在测试假设或尝试修复方案时非常有用,你可以在不重启应用的情况下修改程序状态并观察效果。

对于 Aspire 应用的特殊需求,VS Code 还支持一些高级配置。如果你的服务运行在 Docker 容器中,你需要配置容器调试。首先确保安装了 Docker 扩展,然后修改 launch.json 以支持容器附加:

json 复制代码
{
    "name": "Attach to Container",
    "type": "coreclr",
    "request": "attach",
    "processName": "dotnet",
    "pipeTransport": {
        "pipeCwd": "${workspaceFolder}",
        "pipeProgram": "docker",
        "pipeArgs": [
            "exec",
            "-i",
            "aspire-weatherapi-1"
        ],
        "debuggerPath": "/vsdbg/vsdbg",
        "quoteArgs": false
    },
    "sourceFileMap": {
        "/app": "${workspaceFolder}/WeatherApi"
    }
}

这个配置使用了 pipeTransport 来通过 Docker 的 exec 命令连接到容器内的调试器。pipeProgram 设置为 dockerpipeArgs 指定了要连接的容器名称。debuggerPath 指向容器内安装的 vsdbg 调试器路径,这个路径需要与你的 Dockerfile 中的安装位置匹配。sourceFileMap 将容器内的 /app 路径映射到主机上的源代码目录,确保调试器能找到正确的源文件。

要使容器调试工作,你的 Dockerfile 需要包含 vsdbg 的安装步骤,这在之前的远程调试章节中已经详细说明。确保容器镜像包含了调试器,并且在运行容器时没有设置 --security-opt seccomp=unconfined 等会阻止调试器工作的安全选项。

VS Code 还支持通过命令行启动调试会话,这对于自动化脚本或 CI/CD 管道很有用。如果你安装了 Aspire CLI 工具(通过 dotnet tool install -g Microsoft.DotNet.Aspire.Cli),可以使用以下命令启动调试:

bash 复制代码
aspire run --project ./AppHost/AppHost.csproj --debug

这个命令会启动 AppHost 项目并等待调试器附加。你可以在 VS Code 中使用 "Attach to Process" 配置手动附加,或者配置一个自动附加的脚本。Aspire CLI 还提供了其他有用的命令,如 aspire list 列出所有运行中的 Aspire 应用,aspire stop 停止特定的应用,aspire logs 查看应用的日志输出。

对于团队协作,你可以将 .vscode 文件夹提交到版本控制系统,这样所有团队成员都能使用相同的调试配置。但要注意不要提交包含个人特定路径或敏感信息的配置。你可以在 launch.json 中使用环境变量或配置变量来实现可移植性,例如使用 ${env:HOME} 引用用户的主目录,或使用 ${config:aspire.dashboardUrl} 引用在 VS Code 设置中定义的配置值。

通过这些详细的配置和工作流说明,你应该能够在 VS Code 中高效地开发和调试 .NET Aspire 应用。虽然 VS Code 的调试体验与 Visual Studio 有一些差异,但它的轻量级、跨平台特性以及对容器和远程开发的原生支持,使其成为许多场景下的理想选择。特别是在 Linux 或 macOS 上开发,或者在资源受限的机器上工作时,VS Code 与 Aspire 的组合能够提供高效且流畅的开发体验。掌握这些技能后,你可以在任何环境中自如地调试和排查 Aspire 应用的问题,无论是在本地开发机器上、远程服务器上还是在 Docker 容器中。

三、日志分析技巧

3.1 日志级别和过滤

日志是应用程序运行时最重要的诊断信息来源之一,它记录了系统的运行状态、业务操作、错误信息和性能数据。在 .NET Aspire 应用中,由于涉及多个服务和组件的协同工作,日志的重要性更加凸显。然而,如果没有适当的日志级别配置和过滤机制,你可能会被海量的日志信息淹没,难以从中提取有价值的内容。理解如何正确配置日志级别和实施有效的过滤策略,是掌握 Aspire 应用调试技能的关键一步。

.NET 的日志系统基于 Microsoft.Extensions.Logging 框架,它定义了六个标准的日志级别,从低到高依次是:Trace(追踪)、Debug(调试)、Information(信息)、Warning(警告)、Error(错误)和 Critical(严重)。每个级别代表不同的重要程度和详细程度。Trace 级别是最详细的,通常用于记录每个方法的进入和退出、循环的每次迭代等非常细粒度的信息,这种级别的日志在生产环境中几乎从不启用,因为它会产生海量的日志数据,严重影响性能。Debug 级别用于开发和调试阶段,记录有助于理解程序执行流程的信息,如变量的中间值、条件判断的结果等。Information 级别是生产环境的标准级别,记录应用程序的正常运行信息,如服务启动、重要操作完成、定期任务执行等。Warning 级别表示可能存在问题但不影响系统继续运行的情况,如配置项缺失但使用了默认值、外部服务响应缓慢、资源使用率接近阈值等。Error 级别记录导致操作失败的错误,如未捕获的异常、数据验证失败、外部服务调用失败等。Critical 级别是最严重的,表示可能导致应用程序崩溃或数据损坏的灾难性错误,如数据库连接完全失败、关键资源耗尽等。

在配置日志系统时,你需要在程序的启动代码中对 ILoggingBuilder 进行配置。这通常在 Program.cs 文件中的 WebApplicationBuilder 创建之后立即进行。日志配置的核心原则是在开发环境中尽可能详细,以便快速诊断问题,而在生产环境中保持适度的日志量,既要有足够的信息用于问题追踪,又不能因为过多的日志影响性能或填满存储空间。让我们通过一个完整的示例来说明如何在 Aspire 应用中配置日志级别和过滤器。

首先看一个基础的日志配置示例。在这个示例中,我们从头开始配置日志系统,清除所有默认的日志提供程序,然后只添加我们需要的提供程序,并设置适当的日志级别。这种显式配置的方法让我们对日志系统有完全的控制,避免了默认配置可能带来的混乱或性能问题。

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

这一行代码创建了 WebApplicationBuilder 实例,它是配置 ASP.NET Core 应用的入口点。WebApplicationBuilder 内部已经配置了一套默认的日志系统,包括控制台日志提供程序、调试日志提供程序(在 Visual Studio 中输出到 Debug 窗口)以及 EventSource 日志提供程序。这些默认配置对于快速开始是很方便的,但在生产环境或需要精细控制的场景中,你可能想要清除这些默认配置并重新定制。

csharp 复制代码
builder.Logging.ClearProviders();

ClearProviders() 方法移除了所有已注册的日志提供程序。这是一个激进但有效的方式来确保你的日志配置从一个干净的状态开始。调用这个方法后,日志系统不会输出任何日志,直到你显式地添加新的提供程序。这种做法的好处是避免了日志重复输出的问题,在某些情况下,如果你添加了自定义的日志提供程序但忘记清除默认的,可能会导致同一条日志被输出多次到不同的目的地。但要注意,清除提供程序后,你需要至少添加一个新的提供程序,否则应用程序将完全没有日志输出,这会让调试变得非常困难。

csharp 复制代码
builder.Logging.AddConsole();

AddConsole() 方法添加了控制台日志提供程序,它会将日志输出到标准输出流(stdout)。在开发环境中,这意味着日志会显示在 Visual Studio 的输出窗口或 VS Code 的集成终端中。在 Docker 容器中运行时,这些日志会被 Docker 捕获,你可以通过 docker logs 命令查看它们。在 Kubernetes 环境中,控制台输出会被自动收集到集中的日志系统中。控制台日志提供程序是最常用的日志输出方式,因为它简单、快速且几乎在所有环境中都可用。它支持彩色输出(如果终端支持 ANSI 颜色代码),不同的日志级别会以不同的颜色显示,让你能够快速识别错误和警告。

csharp 复制代码
builder.Logging.SetMinimumLevel(LogLevel.Information);

SetMinimumLevel() 方法设置全局的最低日志级别为 Information。这意味着所有低于这个级别的日志(即 Trace 和 Debug 级别)都不会被输出,即使你在代码中调用了 _logger.LogTrace()_logger.LogDebug(),这些调用也会被忽略。这个设置是全局性的,它影响所有的日志类别和提供程序。设置最低日志级别是控制日志量的最直接方式,在生产环境中,你通常会设置为 Information 或更高,以减少日志的数量和对性能的影响。但在开发环境中,你可能想要设置为 Debug 甚至 Trace,以获得最详细的诊断信息。

然而,全局最低级别并不总是适用于所有情况。某些框架或第三方库可能会产生大量的低价值日志,而你自己的应用代码可能需要更详细的日志记录。这时就需要使用日志过滤器来为不同的日志类别设置不同的级别。日志类别通常是类的完全限定名称,当你使用 ILogger<T> 依赖注入时,类别会自动设置为 T 的完全限定名称。你也可以使用命名空间作为类别,这样可以为整个命名空间下的所有类设置统一的日志级别。

csharp 复制代码
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);

这行代码为所有以 "Microsoft.EntityFrameworkCore" 开头的日志类别设置最低级别为 Warning。Entity Framework Core 是一个功能强大的 ORM 框架,但它在默认配置下会产生大量的日志,特别是在 Information 级别。它会记录每一个执行的 SQL 查询、连接的打开和关闭、事务的开始和提交等。这些信息在调试数据库问题时非常有用,但在正常运行时会产生大量噪音,掩盖了真正重要的日志信息。通过将 EF Core 的日志级别提升到 Warning,你可以过滤掉这些常规的信息日志,只保留警告和错误,如长时间运行的查询警告、连接池耗尽警告、SQL 执行失败错误等。这个过滤器的匹配是前缀匹配,所以它会影响所有 EF Core 相关的日志类别,包括 Microsoft.EntityFrameworkCore.Database.Command(SQL 命令执行)、Microsoft.EntityFrameworkCore.Infrastructure(基础设施日志)等。

csharp 复制代码
builder.Logging.AddFilter("System.Net.Http", LogLevel.Debug);

这行代码的作用与前一个相反,它将 HTTP 客户端相关的日志级别降低到 Debug,允许更详细的日志输出。System.Net.Http 命名空间包含了 HttpClient 和相关的 HTTP 处理类,它们在 Debug 级别会记录每个 HTTP 请求的详细信息,包括请求 URL、HTTP 方法、请求头、响应状态码、响应头等。这些信息在调试与外部服务的集成问题时非常有价值,比如你怀疑某个 API 调用失败是因为请求头不正确,或者你想确认重试策略是否按预期工作。在开发环境中启用这个级别可以让你清楚地看到应用程序发出的每个 HTTP 请求,但在生产环境中通常应该禁用,因为它会暴露可能包含敏感信息的请求细节,而且会产生大量日志。

理解了基础配置后,让我们看一个更实际的、针对 Aspire 应用的完整日志配置示例。这个示例展示了如何在一个典型的 API 服务中配置日志,平衡详细程度和性能,并为不同的环境使用不同的配置。

csharp 复制代码
using Microsoft.Extensions.Logging.Console;
var builder = WebApplication.CreateBuilder(args);
var isDevelopment = builder.Environment.IsDevelopment();

首先检查当前环境是否为开发环境。IsDevelopment() 方法会检查 ASPNETCORE_ENVIRONMENTDOTNET_ENVIRONMENT 环境变量的值,如果是 "Development"(不区分大小写),则返回 true。这个判断很重要,因为我们会根据环境的不同应用不同的日志配置策略。在开发环境中,我们追求最大的可见性和诊断能力,不太关心性能开销。而在生产环境中,我们需要在可观测性和性能、安全性之间找到平衡。

csharp 复制代码
builder.Logging.ClearProviders();

if (isDevelopment)
{
    builder.Logging.AddConsole(options =>
    {
        options.FormatterName = ConsoleFormatterNames.Systemd;
        options.TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff ";
        options.IncludeScopes = true;
    });
    
    builder.Logging.AddDebug();
    
    builder.Logging.SetMinimumLevel(LogLevel.Debug);
}
else
{
    builder.Logging.AddConsole(options =>
    {
        options.FormatterName = ConsoleFormatterNames.Json;
        options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
        options.IncludeScopes = false;
    });
    
    builder.Logging.SetMinimumLevel(LogLevel.Information);
}

这段代码根据环境的不同配置控制台日志提供程序的行为。在开发环境中,我们使用 Systemd 格式化器,这是一种适合人类阅读的格式,每行日志包含时间戳、日志级别、类别名称和消息。时间戳格式设置为包含毫秒的格式 yyyy-MM-dd HH:mm:ss.fff,这对于分析性能问题时确定事件的精确顺序很有用。IncludeScopes 设置为 true 会在日志中包含作用域信息,作用域是一种将相关日志消息分组的机制,比如你可以为每个 HTTP 请求创建一个作用域,所有在处理这个请求期间记录的日志都会包含相同的请求 ID,这样就可以轻松地追踪单个请求的完整生命周期。同时在开发环境中添加了 Debug 日志提供程序,它会将日志输出到 Visual Studio 的 Output 窗口的 Debug 分类中,这对于在 IDE 中调试时查看日志很方便。全局日志级别设置为 Debug,允许记录详细的诊断信息。

在生产环境中,配置有所不同。我们使用 Json 格式化器,它会将每条日志输出为一个 JSON 对象,包含时间戳、级别、类别、消息和其他结构化数据。这种格式特别适合被日志聚合系统(如 ELK Stack、Splunk、Azure Monitor)解析和索引,因为它是机器可读的结构化数据。时间戳使用 ISO 8601 格式 yyyy-MM-ddTHH:mm:ss.fffZ,这是国际标准的日期时间格式,Z 表示 UTC 时区。不包含作用域信息可以减少日志的大小,因为在生产环境中日志量通常很大,每个字节都很重要。全局日志级别设置为 Information,过滤掉了 Debug 和 Trace 级别的日志,减少了日志量和性能开销。

csharp 复制代码
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.AspNetCore.Routing", LogLevel.Warning);
builder.Logging.AddFilter("Microsoft.AspNetCore.Mvc", LogLevel.Warning);

这三行代码为 ASP.NET Core 框架的核心组件设置了更高的日志级别。ASP.NET Core 在 Information 级别会记录每个 HTTP 请求的到达、路由匹配、MVC 操作的执行等信息,这些在开发时很有用,但在生产环境中会产生大量重复的日志。通过将这些类别提升到 Warning 级别,我们只保留了真正重要的信息,如路由匹配失败、模型验证错误、中间件执行异常等。这大大减少了日志量,同时仍然保留了关键的错误和警告信息。注意这些过滤器即使在开发环境中也生效,因为它们是在环境检查之后添加的,如果你想在开发环境中看到所有 ASP.NET Core 的日志,可以在这些 AddFilter 调用前加上 if (!isDevelopment) 条件。

csharp 复制代码
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", 
    isDevelopment ? LogLevel.Information : LogLevel.Warning);

这是一个条件过滤器,根据环境的不同设置 EF Core 数据库命令日志的级别。在开发环境中设置为 Information,这样你可以看到所有执行的 SQL 查询,对于调试数据访问问题非常有帮助。你可以验证生成的 SQL 是否符合预期,检查是否有 N+1 查询问题,确认查询参数是否正确等。在生产环境中设置为 Warning,只记录那些执行时间超过阈值的慢查询警告或查询执行失败的错误,这样可以在不记录所有查询的情况下监控数据库性能问题。

csharp 复制代码
builder.Logging.AddFilter("System.Net.Http.HttpClient", 
    isDevelopment ? LogLevel.Debug : LogLevel.Warning);

类似地,HTTP 客户端的日志在开发环境中设置为 Debug 级别,让你可以看到每个 HTTP 请求的详细信息,包括请求和响应的头部、重试尝试、连接池状态等。这对于调试与外部 API 的集成问题非常有用。在生产环境中设置为 Warning,只记录请求失败、超时或其他异常情况,减少日志噪音的同时保留关键的错误信息。

csharp 复制代码
builder.Logging.AddFilter((category, level) =>
{
    if (category != null && category.StartsWith("MyApp."))
    {
        return level >= LogLevel.Debug;
    }
    
    return level >= LogLevel.Information;
});

这是一个高级的过滤器,使用委托函数来决定是否记录日志。这个过滤器会为每条日志调用,接收日志的类别和级别作为参数,返回一个布尔值表示是否应该记录这条日志。在这个例子中,我们为应用程序自己的代码(假设命名空间前缀是 MyApp)允许 Debug 及以上级别的日志,而对于其他所有代码(框架、第三方库等)只允许 Information 及以上级别的日志。这种细粒度的控制让你可以保持自己代码的高可见性,同时减少来自外部代码的日志噪音。你可以在这个委托中实现任意复杂的逻辑,比如基于时间的过滤(在高峰时段提高日志级别)、基于用户的过滤(为特定用户启用详细日志)等。

csharp 复制代码
if (isDevelopment)
{
    builder.Logging.AddFilter("MyApp.Services.OrderService", LogLevel.Trace);
}

在开发环境中,你可能想要为某个特定的类启用最详细的 Trace 级别日志,以深入调试复杂的业务逻辑。这行代码为 OrderService 类启用了 Trace 级别,这意味着该类中的所有日志调用,包括 _logger.LogTrace(),都会被输出。这种针对特定类的配置在你专注于调试某个特定问题时非常有用,你可以获得极其详细的执行流程信息,而不会被其他类的日志淹没。

csharp 复制代码
builder.Logging.Configure(options =>
{
    options.ActivityTrackingOptions = ActivityTrackingOptions.SpanId
                                     | ActivityTrackingOptions.TraceId
                                     | ActivityTrackingOptions.ParentId
                                     | ActivityTrackingOptions.TraceFlags
                                     | ActivityTrackingOptions.TraceState;
});

这个配置启用了日志与分布式追踪的集成。ActivityTrackingOptions 枚举指定了应该将哪些分布式追踪信息添加到日志中。当启用这些选项后,每条日志消息都会包含 Span ID、Trace ID 等追踪标识符,这些标识符可以用来关联日志和追踪数据。例如,如果你在 Aspire Dashboard 的 Traces 页面看到一个失败的请求,你可以复制它的 Trace ID,然后在 Logs 页面搜索这个 ID,就能找到与这个请求相关的所有日志,即使这些日志来自不同的服务。这种日志和追踪的关联大大简化了分布式系统的故障排查。

配置好日志系统后,在实际的代码中使用日志时也需要遵循一些最佳实践,以确保日志既有用又不会成为性能瓶颈。让我们看一个完整的服务类示例,展示如何正确地记录日志。

csharp 复制代码
using Microsoft.Extensions.Logging;
using System.Diagnostics;

public class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;
    private readonly IEmailService _emailService;
    
    private static readonly Action<ILogger, int, Exception?> s_logOrderReceived =
        LoggerMessage.Define<int>(
            LogLevel.Information,
            new EventId(1001, "OrderReceived"),
            "Order received: OrderId={OrderId}");
    
    private static readonly Action<ILogger, int, decimal, Exception?> s_logOrderProcessed =
        LoggerMessage.Define<int, decimal>(
            LogLevel.Information,
            new EventId(1002, "OrderProcessed"),
            "Order processed successfully: OrderId={OrderId}, Amount={Amount:C}");
    
    private static readonly Action<ILogger, int, string, Exception?> s_logOrderFailed =
        LoggerMessage.Define<int, string>(
            LogLevel.Error,
            new EventId(1003, "OrderFailed"),
            "Order processing failed: OrderId={OrderId}, Reason={Reason}");

    public OrderService(
        ILogger<OrderService> logger,
        IOrderRepository repository,
        IEmailService emailService)
    {
        _logger = logger;
        _repository = repository;
        _emailService = emailService;
    }

    public async Task<bool> ProcessOrderAsync(int orderId)
    {
        using var scope = _logger.BeginScope(new Dictionary<string, object>
        {
            ["OrderId"] = orderId,
            ["OperationStartTime"] = DateTimeOffset.UtcNow
        });
        
        s_logOrderReceived(_logger, orderId, null);
        
        var sw = Stopwatch.StartNew();
        
        try
        {
            var order = await _repository.GetOrderAsync(orderId);
            if (order == null)
            {
                _logger.LogWarning("Order not found: OrderId={OrderId}", orderId);
                return false;
            }
            
            if (_logger.IsEnabled(LogLevel.Debug))
            {
                _logger.LogDebug(
                    "Order details loaded: OrderId={OrderId}, CustomerId={CustomerId}, ItemCount={ItemCount}",
                    orderId, order.CustomerId, order.Items.Count);
            }
            
            await ValidateOrderAsync(order);
            
            await _repository.SaveOrderAsync(order);
            
            await _emailService.SendOrderConfirmationAsync(order);
            
            sw.Stop();
            s_logOrderProcessed(_logger, orderId, order.TotalAmount, null);
            
            _logger.LogInformation(
                "Order processing completed in {Duration}ms",
                sw.ElapsedMilliseconds);
            
            return true;
        }
        catch (Exception ex)
        {
            sw.Stop();
            s_logOrderFailed(_logger, orderId, ex.Message, ex);
            
            _logger.LogError(
                "Order processing failed after {Duration}ms",
                sw.ElapsedMilliseconds);
            
            return false;
        }
    }
    
    private async Task ValidateOrderAsync(Order order)
    {
        _logger.LogDebug("Validating order: OrderId={OrderId}", order.Id);
        
        if (order.Items.Count == 0)
        {
            throw new InvalidOperationException("Order must contain at least one item");
        }
        
        if (order.TotalAmount <= 0)
        {
            throw new InvalidOperationException("Order total must be greater than zero");
        }
        
        _logger.LogDebug("Order validation passed: OrderId={OrderId}", order.Id);
    }
}

这个示例展示了几个重要的日志记录技巧。首先使用 LoggerMessage.Define 创建了高性能的日志委托。这是一种优化技术,可以避免每次记录日志时的字符串分配和格式化开销。LoggerMessage.Define 在编译时生成优化的代码,将格式字符串解析和参数绑定的工作提前完成,只在实际记录日志时进行最小化的工作。这对于高频率调用的日志(如每个请求都会记录的日志)特别重要,可以显著减少内存分配和 CPU 使用。

使用 BeginScope 创建日志作用域是另一个重要技巧。作用域中的数据会自动添加到该作用域内记录的所有日志中,这样你不需要在每条日志中重复传递相同的上下文信息。在这个例子中,我们将订单 ID 和操作开始时间添加到作用域,所以在 ProcessOrderAsync 方法执行期间记录的所有日志都会自动包含这些信息。作用域是可嵌套的,内层作用域的数据会与外层作用域的数据合并。using 语句确保作用域在方法结束时被正确释放。

在记录 Debug 级别日志之前检查 _logger.IsEnabled(LogLevel.Debug) 是一个性能优化技巧。如果当前配置的日志级别高于 Debug,这个检查会快速返回 false,避免了后续的字符串格式化和对象分配。虽然现代的日志框架会在内部进行类似的检查,但对于涉及复杂计算或大对象序列化的日志,显式检查仍然是值得的。

使用 Stopwatch 测量操作耗时并记录到日志中是监控性能的简单但有效的方法。你可以在日志分析工具中基于这些耗时数据创建图表和警报,识别性能下降的趋势。记录开始和结束时间,以及在异常情况下的耗时,可以帮助你理解操作是在哪个阶段失败的。

通过这些详细的配置和使用示例,你应该能够为你的 Aspire 应用建立一套有效的日志系统,它既能提供丰富的诊断信息,又不会因为日志过多而影响性能或可读性。记住,日志配置不是一次性的工作,你应该根据实际的使用情况和遇到的问题持续调整和优化,找到最适合你的应用的平衡点。

3.2 结构化日志最佳实践

结构化日志是现代应用程序可观测性的基石,它不同于传统的纯文本日志记录方式。在传统方式中,我们可能会写出类似 logger.LogInformation("Order " + order.Id + " processed successfully") 这样的代码,这种方式虽然简单,但存在严重的问题。首先,字符串拼接会产生大量的临时对象,增加垃圾回收的压力。其次,这样的日志很难被机器解析和查询,你无法轻易地在日志系统中搜索所有订单 ID 为 12345 的日志,因为 "12345" 只是日志消息中的一个普通字符。结构化日志解决了这些问题,它将日志消息分解为模板和参数,参数作为独立的键值对存储,既提高了性能,又增强了可查询性。

在 .NET 的日志框架中,结构化日志是通过占位符和参数列表实现的。当你调用 _logger.LogInformation("Processing order {OrderId}", order.Id) 时,框架会识别花括号中的 OrderId 是一个参数占位符,而 order.Id 的值会作为这个参数的值。这个信息不仅会被格式化到日志消息的文本中,还会作为一个独立的属性存储。在 Aspire Dashboard 或其他日志分析工具中,你可以直接按 OrderId 属性进行过滤和聚合,就像查询数据库一样。这种结构化的方式让日志从单纯的文本记录变成了可以进行深度分析的数据。

让我们通过一个完整的订单处理服务来深入理解结构化日志的最佳实践。这个服务展示了如何在不同的场景中记录适当的日志,如何选择合适的日志级别,如何添加丰富的上下文信息,以及如何处理异常和敏感数据。这个示例涵盖了从简单的信息记录到复杂的性能监控和错误处理的各种场景,是一个实际生产环境中可以直接使用的模式。

csharp 复制代码
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Text.Json;

public class OrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;
    private readonly IPaymentService _paymentService;
    private readonly IInventoryService _inventoryService;
    private readonly IEmailService _emailService;
    
    private static readonly Action<ILogger, int, string, decimal, Exception?> s_logOrderProcessingStarted =
        LoggerMessage.Define<int, string, decimal>(
            LogLevel.Information,
            new EventId(1001, nameof(OrderProcessingStarted)),
            "Order processing started: OrderId={OrderId}, CustomerId={CustomerId}, TotalAmount={TotalAmount:C}");
    
    private static readonly Action<ILogger, int, long, Exception?> s_logOrderProcessingCompleted =
        LoggerMessage.Define<int, long>(
            LogLevel.Information,
            new EventId(1002, nameof(OrderProcessingCompleted)),
            "Order processing completed: OrderId={OrderId}, Duration={Duration}ms");
    
    private static readonly Action<ILogger, int, string, Exception?> s_logOrderProcessingFailed =
        LoggerMessage.Define<int, string>(
            LogLevel.Error,
            new EventId(1003, nameof(OrderProcessingFailed)),
            "Order processing failed: OrderId={OrderId}, Reason={Reason}");

    public OrderService(
        ILogger<OrderService> logger,
        IOrderRepository repository,
        IPaymentService paymentService,
        IInventoryService inventoryService,
        IEmailService emailService)
    {
        _logger = logger;
        _repository = repository;
        _paymentService = paymentService;
        _inventoryService = inventoryService;
        _emailService = emailService;
    }

    public async Task<OrderResult> ProcessOrderAsync(Order order)
    {
        var orderScope = new Dictionary<string, object>
        {
            ["OrderId"] = order.Id,
            ["CustomerId"] = order.CustomerId,
            ["OrderDate"] = order.CreatedAt,
            ["ItemCount"] = order.Items.Count,
            ["CorrelationId"] = Activity.Current?.Id ?? Guid.NewGuid().ToString()
        };
        
        using var scope = _logger.BeginScope(orderScope);
        
        s_logOrderProcessingStarted(_logger, order.Id, order.CustomerId, order.TotalAmount, null);
        
        var stopwatch = Stopwatch.StartNew();
        
        try
        {
            await ValidateOrderAsync(order);
            
            var inventoryResult = await ReserveInventoryAsync(order);
            if (!inventoryResult.Success)
            {
                _logger.LogWarning(
                    "Inventory reservation failed: OrderId={OrderId}, UnavailableItems={UnavailableItems}",
                    order.Id,
                    string.Join(", ", inventoryResult.UnavailableItems));
                
                return OrderResult.Failed("Insufficient inventory", inventoryResult.UnavailableItems);
            }
            
            _logger.LogInformation(
                "Inventory reserved successfully: OrderId={OrderId}, ReservationId={ReservationId}",
                order.Id,
                inventoryResult.ReservationId);
            
            var paymentResult = await ProcessPaymentAsync(order);
            if (!paymentResult.Success)
            {
                await ReleaseInventoryAsync(inventoryResult.ReservationId);
                
                _logger.LogError(
                    "Payment processing failed: OrderId={OrderId}, PaymentError={PaymentError}, TransactionId={TransactionId}",
                    order.Id,
                    paymentResult.ErrorMessage,
                    paymentResult.TransactionId);
                
                return OrderResult.Failed($"Payment failed: {paymentResult.ErrorMessage}");
            }
            
            _logger.LogInformation(
                "Payment processed successfully: OrderId={OrderId}, TransactionId={TransactionId}, Amount={Amount:C}",
                order.Id,
                paymentResult.TransactionId,
                paymentResult.Amount);
            
            order.Status = OrderStatus.Confirmed;
            order.PaymentTransactionId = paymentResult.TransactionId;
            order.InventoryReservationId = inventoryResult.ReservationId;
            
            await _repository.SaveOrderAsync(order);
            
            _logger.LogInformation(
                "Order saved to database: OrderId={OrderId}, Status={Status}",
                order.Id,
                order.Status);
            
            await SendOrderConfirmationEmailAsync(order);
            
            stopwatch.Stop();
            s_logOrderProcessingCompleted(_logger, order.Id, stopwatch.ElapsedMilliseconds, null);
            
            if (stopwatch.ElapsedMilliseconds > 5000)
            {
                _logger.LogWarning(
                    "Slow order processing detected: OrderId={OrderId}, Duration={Duration}ms, Threshold=5000ms",
                    order.Id,
                    stopwatch.ElapsedMilliseconds);
            }
            
            return OrderResult.Success(order.Id, paymentResult.TransactionId);
        }
        catch (OrderValidationException ex)
        {
            stopwatch.Stop();
            
            _logger.LogWarning(ex,
                "Order validation failed: OrderId={OrderId}, ValidationErrors={ValidationErrors}, Duration={Duration}ms",
                order.Id,
                string.Join("; ", ex.Errors),
                stopwatch.ElapsedMilliseconds);
            
            return OrderResult.Failed("Validation failed", ex.Errors);
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            s_logOrderProcessingFailed(_logger, order.Id, ex.Message, ex);
            
            _logger.LogError(
                "Unexpected error during order processing: OrderId={OrderId}, ExceptionType={ExceptionType}, Duration={Duration}ms, StackTrace={StackTrace}",
                order.Id,
                ex.GetType().Name,
                stopwatch.ElapsedMilliseconds,
                ex.StackTrace);
            
            return OrderResult.Failed($"System error: {ex.Message}");
        }
    }

    private async Task ValidateOrderAsync(Order order)
    {
        _logger.LogDebug("Validating order: OrderId={OrderId}", order.Id);
        
        var validationStopwatch = Stopwatch.StartNew();
        var errors = new List<string>();
        
        if (order.Items == null || order.Items.Count == 0)
        {
            errors.Add("Order must contain at least one item");
        }
        
        if (order.TotalAmount <= 0)
        {
            errors.Add("Order total must be greater than zero");
        }
        
        if (string.IsNullOrWhiteSpace(order.CustomerEmail))
        {
            errors.Add("Customer email is required");
        }
        else if (!IsValidEmail(order.CustomerEmail))
        {
            errors.Add("Customer email format is invalid");
        }
        
        foreach (var item in order.Items ?? Enumerable.Empty<OrderItem>())
        {
            if (item.Quantity <= 0)
            {
                errors.Add($"Item {item.ProductId} has invalid quantity: {item.Quantity}");
            }
            
            if (item.Price < 0)
            {
                errors.Add($"Item {item.ProductId} has invalid price: {item.Price}");
            }
        }
        
        validationStopwatch.Stop();
        
        if (errors.Any())
        {
            _logger.LogWarning(
                "Order validation failed: OrderId={OrderId}, ErrorCount={ErrorCount}, Errors={Errors}, ValidationDuration={ValidationDuration}ms",
                order.Id,
                errors.Count,
                string.Join("; ", errors),
                validationStopwatch.ElapsedMilliseconds);
            
            throw new OrderValidationException("Order validation failed", errors);
        }
        
        _logger.LogDebug(
            "Order validation passed: OrderId={OrderId}, ValidationDuration={ValidationDuration}ms",
            order.Id,
            validationStopwatch.ElapsedMilliseconds);
    }

    private async Task<InventoryReservationResult> ReserveInventoryAsync(Order order)
    {
        _logger.LogDebug(
            "Reserving inventory: OrderId={OrderId}, ItemCount={ItemCount}",
            order.Id,
            order.Items.Count);
        
        var inventoryStopwatch = Stopwatch.StartNew();
        
        try
        {
            var result = await _inventoryService.ReserveItemsAsync(
                order.Items.Select(i => new InventoryReservationRequest
                {
                    ProductId = i.ProductId,
                    Quantity = i.Quantity
                }).ToList());
            
            inventoryStopwatch.Stop();
            
            if (result.Success)
            {
                _logger.LogInformation(
                    "Inventory reservation successful: OrderId={OrderId}, ReservationId={ReservationId}, Duration={Duration}ms",
                    order.Id,
                    result.ReservationId,
                    inventoryStopwatch.ElapsedMilliseconds);
            }
            else
            {
                _logger.LogWarning(
                    "Inventory reservation failed: OrderId={OrderId}, UnavailableItemCount={UnavailableItemCount}, Duration={Duration}ms",
                    order.Id,
                    result.UnavailableItems.Count,
                    inventoryStopwatch.ElapsedMilliseconds);
            }
            
            return result;
        }
        catch (Exception ex)
        {
            inventoryStopwatch.Stop();
            
            _logger.LogError(ex,
                "Error during inventory reservation: OrderId={OrderId}, Duration={Duration}ms",
                order.Id,
                inventoryStopwatch.ElapsedMilliseconds);
            
            throw;
        }
    }

    private async Task<PaymentResult> ProcessPaymentAsync(Order order)
    {
        var sanitizedCardNumber = order.PaymentInfo.CardNumber?.Length > 4
            ? "****-****-****-" + order.PaymentInfo.CardNumber[^4..]
            : "****";
        
        _logger.LogDebug(
            "Processing payment: OrderId={OrderId}, Amount={Amount:C}, PaymentMethod={PaymentMethod}, CardLast4={CardLast4}",
            order.Id,
            order.TotalAmount,
            order.PaymentInfo.PaymentMethod,
            sanitizedCardNumber);
        
        var paymentStopwatch = Stopwatch.StartNew();
        
        try
        {
            var result = await _paymentService.ProcessPaymentAsync(new PaymentRequest
            {
                OrderId = order.Id,
                Amount = order.TotalAmount,
                Currency = order.Currency,
                PaymentMethod = order.PaymentInfo.PaymentMethod,
                CardNumber = order.PaymentInfo.CardNumber,
                CardHolderName = order.PaymentInfo.CardHolderName,
                ExpiryDate = order.PaymentInfo.ExpiryDate
            });
            
            paymentStopwatch.Stop();
            
            if (result.Success)
            {
                _logger.LogInformation(
                    "Payment processed successfully: OrderId={OrderId}, TransactionId={TransactionId}, Amount={Amount:C}, Duration={Duration}ms",
                    order.Id,
                    result.TransactionId,
                    result.Amount,
                    paymentStopwatch.ElapsedMilliseconds);
            }
            else
            {
                _logger.LogError(
                    "Payment processing failed: OrderId={OrderId}, ErrorCode={ErrorCode}, ErrorMessage={ErrorMessage}, Duration={Duration}ms",
                    order.Id,
                    result.ErrorCode,
                    result.ErrorMessage,
                    paymentStopwatch.ElapsedMilliseconds);
            }
            
            return result;
        }
        catch (Exception ex)
        {
            paymentStopwatch.Stop();
            
            _logger.LogError(ex,
                "Error during payment processing: OrderId={OrderId}, Duration={Duration}ms",
                order.Id,
                paymentStopwatch.ElapsedMilliseconds);
            
            throw;
        }
    }

    private async Task ReleaseInventoryAsync(string reservationId)
    {
        _logger.LogInformation(
            "Releasing inventory reservation: ReservationId={ReservationId}",
            reservationId);
        
        try
        {
            await _inventoryService.ReleaseReservationAsync(reservationId);
            
            _logger.LogInformation(
                "Inventory reservation released: ReservationId={ReservationId}",
                reservationId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Failed to release inventory reservation: ReservationId={ReservationId}",
                reservationId);
        }
    }

    private async Task SendOrderConfirmationEmailAsync(Order order)
    {
        var emailStopwatch = Stopwatch.StartNew();
        
        try
        {
            await _emailService.SendOrderConfirmationAsync(
                order.CustomerEmail,
                order.Id,
                order.TotalAmount);
            
            emailStopwatch.Stop();
            
            _logger.LogInformation(
                "Order confirmation email sent: OrderId={OrderId}, Recipient={Recipient}, Duration={Duration}ms",
                order.Id,
                MaskEmail(order.CustomerEmail),
                emailStopwatch.ElapsedMilliseconds);
        }
        catch (Exception ex)
        {
            emailStopwatch.Stop();
            
            _logger.LogError(ex,
                "Failed to send order confirmation email: OrderId={OrderId}, Recipient={Recipient}, Duration={Duration}ms",
                order.Id,
                MaskEmail(order.CustomerEmail),
                emailStopwatch.ElapsedMilliseconds);
        }
    }

    private bool IsValidEmail(string email)
    {
        try
        {
            var addr = new System.Net.Mail.MailAddress(email);
            return addr.Address == email;
        }
        catch
        {
            return false;
        }
    }

    private string MaskEmail(string email)
    {
        if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
        {
            return "***@***";
        }
        
        var parts = email.Split('@');
        var username = parts[0];
        var domain = parts[1];
        
        var maskedUsername = username.Length <= 2
            ? "***"
            : username[0] + "***" + username[^1];
        
        return $"{maskedUsername}@{domain}";
    }
}

public class Order
{
    public int Id { get; set; }
    public string CustomerId { get; set; } = string.Empty;
    public string CustomerEmail { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public List<OrderItem> Items { get; set; } = new();
    public decimal TotalAmount { get; set; }
    public string Currency { get; set; } = "USD";
    public OrderStatus Status { get; set; }
    public PaymentInfo PaymentInfo { get; set; } = new();
    public string? PaymentTransactionId { get; set; }
    public string? InventoryReservationId { get; set; }
}

public class OrderItem
{
    public string ProductId { get; set; } = string.Empty;
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

public class PaymentInfo
{
    public string PaymentMethod { get; set; } = string.Empty;
    public string CardNumber { get; set; } = string.Empty;
    public string CardHolderName { get; set; } = string.Empty;
    public string ExpiryDate { get; set; } = string.Empty;
}

public enum OrderStatus
{
    Pending,
    Confirmed,
    Failed,
    Cancelled
}

public class OrderValidationException : Exception
{
    public List<string> Errors { get; }

    public OrderValidationException(string message, List<string> errors) : base(message)
    {
        Errors = errors;
    }
}

public class OrderResult
{
    public bool Success { get; set; }
    public int OrderId { get; set; }
    public string? TransactionId { get; set; }
    public string? ErrorMessage { get; set; }
    public List<string>? ValidationErrors { get; set; }

    public static OrderResult Success(int orderId, string transactionId) =>
        new() { Success = true, OrderId = orderId, TransactionId = transactionId };

    public static OrderResult Failed(string errorMessage, List<string>? validationErrors = null) =>
        new() { Success = false, ErrorMessage = errorMessage, ValidationErrors = validationErrors };
}

public class InventoryReservationResult
{
    public bool Success { get; set; }
    public string ReservationId { get; set; } = string.Empty;
    public List<string> UnavailableItems { get; set; } = new();
}

public class PaymentResult
{
    public bool Success { get; set; }
    public string TransactionId { get; set; } = string.Empty;
    public decimal Amount { get; set; }
    public string? ErrorCode { get; set; }
    public string? ErrorMessage { get; set; }
}

public class InventoryReservationRequest
{
    public string ProductId { get; set; } = string.Empty;
    public int Quantity { get; set; }
}

public class PaymentRequest
{
    public int OrderId { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; } = string.Empty;
    public string PaymentMethod { get; set; } = string.Empty;
    public string CardNumber { get; set; } = string.Empty;
    public string CardHolderName { get; set; } = string.Empty;
    public string ExpiryDate { get; set; } = string.Empty;
}

public interface IOrderRepository
{
    Task SaveOrderAsync(Order order);
}

public interface IPaymentService
{
    Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request);
}

public interface IInventoryService
{
    Task<InventoryReservationResult> ReserveItemsAsync(List<InventoryReservationRequest> requests);
    Task ReleaseReservationAsync(string reservationId);
}

public interface IEmailService
{
    Task SendOrderConfirmationAsync(string email, int orderId, decimal amount);
}

这个完整的示例展示了结构化日志在实际应用中的多个关键方面。首先注意到代码顶部定义的三个静态委托:s_logOrderProcessingStarteds_logOrderProcessingCompleteds_logOrderProcessingFailed。这些委托使用 LoggerMessage.Define 方法创建,这是一种高性能的日志记录技术。传统的日志记录方式,每次调用 _logger.LogInformation() 时都会进行字符串解析、占位符匹配和参数装箱等操作。而 LoggerMessage.Define 在应用程序启动时就完成了这些工作,生成优化的代码,运行时只需要填充参数值即可。这对于频繁调用的日志(如每个订单处理都会调用的日志)来说,可以显著减少 CPU 和内存开销。

EventId 参数是另一个重要的细节。new EventId(1001, nameof(OrderProcessingStarted)) 创建了一个唯一的事件标识符,包含数字 ID 和名称。这个 ID 在日志系统中作为结构化数据的一部分被记录,你可以在 Aspire Dashboard 或其他日志分析工具中按事件 ID 进行过滤。数字 ID 应该在应用程序范围内是唯一的,建议为不同的功能模块预留不同的数字范围,例如 1000-1999 用于订单处理,2000-2999 用于用户管理等。事件名称使用 nameof() 操作符可以确保类型安全,当你重命名委托变量时,事件名称会自动更新。

ProcessOrderAsync 方法的开始,我们创建了一个日志作用域并填充了丰富的上下文信息。BeginScope 接受一个字典,其中的所有键值对都会自动添加到在这个作用域内记录的所有日志中。这意味着你不需要在每条日志中重复传递订单 ID、客户 ID 等信息,它们会自动包含在日志的结构化数据中。特别注意 CorrelationId 的设置,它从当前的 Activity(如果存在)中获取 ID,Activity 是 .NET 中分布式追踪的核心概念。如果当前代码在一个 HTTP 请求的上下文中执行,Activity.Current.Id 会是一个由框架自动生成的追踪 ID,它在整个请求链路中保持不变,即使请求跨越了多个服务。将这个 ID 包含在日志作用域中,可以让你在日志系统中轻松地关联一个请求产生的所有日志,无论这些日志来自哪个服务或哪个代码层次。

使用 Stopwatch 测量操作耗时是性能监控的基础手段。在订单处理的主方法和每个子操作(验证、库存预留、支付处理)中,我们都创建了 Stopwatch 实例来记录执行时间。这些时间数据被包含在日志的结构化参数中,以 Duration 或类似的名称标识。在日志分析系统中,你可以基于这些时间数据创建性能图表、计算平均值和百分位数、设置警报阈值等。代码中还展示了一个主动的性能警报逻辑:当订单处理时间超过 5000 毫秒时,记录一条 Warning 级别的日志。这种自监控机制可以让你在性能问题影响大量用户之前就发现并处理它们。

异常处理部分展示了如何根据异常类型记录不同级别的日志。对于预期的业务异常(如 OrderValidationException),我们使用 Warning 级别而不是 Error 级别,因为验证失败是正常的业务流程的一部分,不应该被视为系统错误。日志消息包含了详细的验证错误列表,使用 string.Join() 将它们连接成可读的字符串。对于意外的系统异常,我们使用 Error 级别,并记录异常类型、消息、堆栈跟踪等详细信息。注意在调用高性能日志委托 s_logOrderProcessingFailed 时,我们将异常对象作为最后一个参数传递。.NET 的日志框架会自动提取异常的堆栈跟踪和其他属性,并将它们作为结构化数据的一部分记录。

在处理敏感数据时,数据脱敏是至关重要的。ProcessPaymentAsync 方法展示了如何安全地记录支付相关的日志。信用卡号是极其敏感的信息,绝不应该完整地出现在日志中。代码中使用 sanitizedCardNumber 变量来存储脱敏后的卡号,只保留最后四位数字,其余部分用星号替换。这样在日志中你仍然可以看到这是哪张卡,但不会泄露完整的卡号。类似地,MaskEmail 方法对电子邮件地址进行了部分遮蔽,保留了用户名的首尾字符和完整的域名,这在调试时仍然提供了足够的信息来识别用户,但减少了隐私泄露的风险。

验证逻辑部分展示了如何在日志中包含详细的上下文信息来帮助调试。当订单验证失败时,日志不仅记录了错误的数量和描述,还包含了验证操作本身的耗时。这个细节看似不重要,但在诊断性能问题时可能很关键。如果你发现验证耗时异常地高,可能是因为某个验证规则的实现有问题,或者是因为在验证过程中进行了不必要的数据库查询或网络调用。日志中的 ValidationDuration 参数可以帮助你快速识别这类问题。

库存预留和支付处理的日志记录展示了如何为外部服务调用添加完整的可观测性。每个调用都被包裹在 try-catch 块中,无论成功还是失败都记录详细的日志。成功的日志包含了关键的业务标识符(如预留 ID、交易 ID)和耗时,这些信息对于审计和问题追踪都很重要。失败的日志则包含了错误代码、错误消息和耗时,帮助你区分是超时失败还是业务规则失败。特别注意在 catch 块中,我们在记录日志后重新抛出了异常。这是因为日志的目的是记录正在发生的事情,而不是改变程序的控制流。异常应该被传播到更高层次的代码,由那里的逻辑决定如何处理(重试、补偿、返回错误响应等)。

补偿事务的日志记录(ReleaseInventoryAsync 方法)展示了在分布式事务场景中日志的重要性。当支付失败时,我们需要释放之前预留的库存。这个释放操作本身也可能失败,但我们不能让它的失败阻止整个订单处理流程的错误处理。因此,释放库存的异常被捕获并记录,但不会被重新抛出。日志是我们了解这个补偿操作是否成功的唯一途径,如果补偿失败,需要有其他机制(如后台任务)来处理遗留的库存预留。

在 Aspire Dashboard 中查看这些结构化日志时,你会看到每条日志不仅有文本消息,还有一系列的属性。点击某条日志可以展开查看所有的结构化参数,如 OrderId=12345TotalAmount=299.99Duration=523。这些参数是可搜索和可过滤的,你可以在搜索框中输入 OrderId:12345 来找出所有与这个订单相关的日志,或者输入 Duration>5000 来找出所有耗时超过 5 秒的操作。这种结构化的数据让日志从被动的文本记录变成了主动的查询和分析工具,大大提高了故障排查的效率。

结合分布式追踪使用结构化日志可以获得更强大的诊断能力。当你在 Aspire Dashboard 的 Traces 页面看到一个失败的订单处理追踪时,你可以点击 Trace ID 跳转到 Logs 页面,自动过滤出该追踪相关的所有日志。这些日志按时间顺序排列,完整地展示了请求的生命周期。你可以看到订单验证通过了,库存预留成功了,但在支付处理时失败了,具体的失败原因是 "Insufficient funds"。这种端到端的可见性在传统的日志系统中几乎不可能实现,但在 Aspire 的结构化日志和分布式追踪的组合下变得轻而易举。

在生产环境中部署这样的日志记录代码时,需要注意几个关键点。首先是日志量的控制,Debug 级别的日志虽然在开发时有用,但在生产环境中应该禁用,因为它们会产生大量的数据,增加存储成本和查询延迟。通过在 appsettings.Production.json 中设置最低日志级别为 Information,可以自动过滤掉所有 Debug 级别的日志。其次是敏感数据的处理,确保所有可能包含敏感信息的日志都经过了适当的脱敏处理。定期审查日志内容,确保没有意外泄露密码、API 密钥、个人身份信息等。第三是性能考虑,虽然结构化日志比字符串拼接更高效,但在极高频率的代码路径中(如每秒执行数千次的循环),即使是日志记录也可能成为性能瓶颈。在这种情况下,使用 _logger.IsEnabled() 检查来避免不必要的日志调用,或者考虑使用采样策略,只记录一部分事件的日志。

最后,建立统一的日志记录规范对于团队协作至关重要。定义清晰的事件 ID 范围分配、参数命名约定、日志级别使用指南等标准,可以让整个团队产生一致性和可预测性的日志。例如,约定所有表示操作开始的日志使用 "Started" 后缀,操作完成使用 "Completed" 后缀,操作失败使用 "Failed" 后缀。这样当你在日志中搜索特定模式时,可以快速定位到你关心的事件。建立日志审查流程,在代码审查时不仅检查业务逻辑的正确性,也检查日志记录是否充分、级别是否合适、敏感数据是否已脱敏。通过这些实践,结构化日志可以成为你的应用程序可观测性的坚实基础,让故障排查从猜测变成系统化的科学过程。

3.3 在 Dashboard 中分析日志

Aspire Dashboard 的 Logs 页面是一个功能强大但界面直观的日志分析工具,它提供了多种方式来过滤、搜索和分析来自分布式应用中所有服务的日志。与传统的文本日志查看器不同,Dashboard 充分利用了结构化日志的优势,让你可以像查询数据库一样查询日志。掌握 Dashboard 的日志查询技巧,可以让你在海量日志中快速定位问题,而不是在无尽的文本流中手动搜索关键词。理解这些技巧的关键在于认识到现代日志不再是简单的文本行,而是结构化的、带有丰富元数据的事件记录。

当你打开 Dashboard 并导航到 Logs 页面时,你会看到一个实时更新的日志流,默认显示最近的日志条目。页面顶部是一个搜索和过滤工具栏,这是你与日志交互的主要界面。最直观的过滤方式是按日志级别进行筛选。日志级别下拉菜单通常显示在搜索框的左侧或附近,它列出了所有标准的日志级别:Trace、Debug、Information、Warning、Error 和 Critical。默认情况下,所有级别都是选中的,这意味着你看到的是完整的日志流。但当你只关心错误或警告时,可以取消选择其他级别,只保留 Error 和 Warning 的复选框被选中。这样,Dashboard 会立即过滤掉所有 Information 级别及以下的日志,只显示潜在的问题。这种过滤是客户端进行的,非常快速,不需要重新加载数据。

例如,假设你的应用突然开始返回 500 错误,你想找出是哪里抛出了异常。打开 Logs 页面后,你首先取消选择 Trace、Debug 和 Information 级别,只保留 Warning、Error 和 Critical。日志流立即缩减到只有几十条记录,而不是数千条。你会看到每条日志行的左侧有一个彩色的标记,红色通常表示 Error,橙色表示 Warning。快速浏览这些日志,你可能会注意到有几条来自 OrderService 的 Error 级别日志,它们的时间戳与用户报告问题的时间吻合。点击其中一条日志展开详细视图,你会看到完整的日志消息、所有的结构化参数、异常堆栈跟踪(如果有的话)以及相关的追踪信息。这种快速从成千上万条日志中缩小到几条关键日志的能力,正是日志级别过滤的价值所在。

时间范围选择是另一个强大的过滤维度。Dashboard 的时间选择器通常位于页面的右上角,它提供了几种预设的时间范围,如"最近 5 分钟"、"最近 15 分钟"、"最近 1 小时"、"最近 24 小时"等,也允许你选择自定义的时间范围。当你知道问题发生的大致时间时,时间范围过滤可以显著减少需要查看的日志量。例如,如果用户在下午 2:30 报告了一个错误,你可以将时间范围设置为"2:25 PM 到 2:35 PM",这样你只需要查看这 10 分钟内的日志,而不是整个下午的日志。Dashboard 会自动从后端服务器重新加载这个时间范围内的日志,并更新显示。

时间范围过滤在分析间歇性问题时特别有用。假设你的应用在每天凌晨 3 点左右出现一个神秘的性能下降,但在其他时间运行正常。你不可能整晚守在电脑前等待问题出现,但你可以配置 Dashboard 记录所有日志到持久存储(如果支持的话),然后第二天早上打开 Dashboard,将时间范围设置为"昨天 2:50 AM 到 3:10 AM",这样你就可以回溯性地分析那个时间段的日志。你可能会发现在 3:00 AM 有一个定时任务启动,它执行了大量的数据库查询,导致数据库连接池耗尽,进而影响了正常请求的处理。这种基于时间的分析在没有时间过滤功能的情况下几乎是不可能完成的。

按服务名称过滤是处理多服务应用时的关键技能。在 Aspire 应用中,每个服务的日志都会自动标记上服务名称,这个名称通常来自 AppHost 中配置的资源名称。在 Dashboard 的 Logs 页面,你会看到一个服务名称的下拉列表或标签云,显示所有有日志输出的服务。点击某个服务名称,日志流会立即过滤为只显示来自该服务的日志。这在你已经通过其他方式(如分布式追踪或错误报告)确定问题出在某个特定服务时非常有用。你不需要在混合了所有服务日志的流中寻找特定服务的日志,而是可以直接聚焦到那个服务。

例如,在一个包含 API 服务、Web 前端、Worker 服务和数据库的典型 Aspire 应用中,如果你从 API 的健康检查端点收到了 503 Service Unavailable 响应,你首先会怀疑是 API 服务本身的问题。打开 Dashboard 的 Logs 页面,选择 api 服务的过滤器,日志流会只显示来自 API 服务的日志。如果日志中没有明显的错误,你可以进一步检查 API 依赖的其他服务。比如,API 可能依赖于 Worker 服务处理的某些后台任务,你可以切换到 worker 服务的过滤器,查看 Worker 是否有异常。这种按服务隔离日志的能力让你可以逐个排查系统的各个部分,而不会被其他无关的日志干扰。

搜索框是 Dashboard 日志功能中最强大但也最容易被低估的工具。它不仅支持简单的文本搜索,还支持结构化的属性查询。当你在搜索框中输入一个简单的字符串,如 OrderId,Dashboard 会在所有日志消息的文本内容中搜索这个字符串。这与传统的文本搜索类似,但由于 Aspire 的日志是结构化的,你还可以使用更精确的查询语法。例如,如果你想查找所有包含特定订单 ID 的日志,而不是仅仅在消息文本中搜索 "OrderId" 这个词,你可以输入 OrderId:12345。这个查询会匹配所有将 OrderId 作为结构化参数并且其值为 12345 的日志。

这种结构化查询的语法通常遵循 PropertyName:PropertyValue 的模式。属性名称是你在代码中记录日志时使用的占位符名称,如 _logger.LogInformation("Processing order {OrderId}", order.Id) 中的 OrderId。属性值可以是数字、字符串或布尔值。如果属性值是字符串并且包含空格或特殊字符,你需要用引号包裹它,如 CustomerId:"CUST-001"。Dashboard 的搜索引擎会解析这个查询,并在后端的日志存储中执行高效的过滤操作,只返回匹配的日志。这比在客户端对所有日志进行文本搜索要快得多,特别是当日志量很大时。

让我们通过一个实际的场景来说明如何组合使用这些过滤和搜索功能。假设你收到一个客户投诉,客户 ID 为 CUST-12345 的用户在今天下午 3:15 左右尝试下单时遇到了错误。你需要找出具体是什么错误以及为什么会发生。你的分析过程可能是这样的:首先打开 Dashboard 的 Logs 页面,设置时间范围为今天下午 3:10 到 3:20,这样你只需要查看这 10 分钟的日志。然后在搜索框中输入 CustomerId:CUST-12345,按回车执行搜索。Dashboard 会显示在这个时间范围内所有与这个客户相关的日志。

结果可能会显示几十条日志,包括客户浏览产品、添加商品到购物车、开始结账流程等正常操作的日志。为了进一步缩小范围,你可以添加日志级别过滤,只选择 Error 和 Warning。这时日志可能缩减到只有两三条。你看到一条 Error 级别的日志,消息是 "Payment processing failed: OrderId=67890, PaymentError=Insufficient funds"。现在你知道了问题的根本原因:客户的支付失败是因为资金不足。但你可能还想知道这个支付失败是否正确地向客户显示了,以及是否有任何补偿逻辑被触发。

为了追踪完整的请求流程,你注意到这条错误日志中包含了一个 OrderId:67890。你可以点击这个日志展开详细视图,在详细视图中,Dashboard 通常会显示一个"查看相关追踪"或"在追踪中打开"的链接。点击这个链接,Dashboard 会切换到 Traces 页面并自动加载这个订单的分布式追踪。在追踪视图中,你可以看到从用户提交订单到支付失败的整个调用链,包括每个步骤的耗时。你可能会看到在支付失败后,系统正确地调用了库存释放服务来释放之前预留的库存,并向客户发送了一封失败通知邮件。这种从日志到追踪再回到日志的跳转能力,让你可以在不同层次的可观测性数据之间无缝切换,快速建立起完整的事件图景。

Dashboard 的搜索功能还支持更高级的查询操作符。虽然具体的语法可能因 Dashboard 的版本和配置而异,但一些常见的操作符包括范围查询、通配符、逻辑操作符等。例如,如果你想查找所有处理时间超过 5 秒的订单处理日志,你可能可以使用类似 Duration:>5000 的查询,假设你在日志中记录了一个名为 Duration 的参数。通配符查询可能看起来像 OrderId:ORD-*,它会匹配所有以 "ORD-" 开头的订单 ID。逻辑操作符允许你组合多个条件,如 CustomerId:CUST-12345 AND LogLevel:Error,这会找出所有与特定客户相关的错误日志。

为了充分利用这些高级查询功能,你需要在记录日志时就考虑到可查询性。这意味着你应该将重要的业务标识符和度量值作为结构化参数记录,而不是只将它们嵌入到日志消息的文本中。例如,不要写 _logger.LogInformation($"Order {orderId} processed in {duration}ms"),这样 orderIdduration 只是文本的一部分。而应该写 _logger.LogInformation("Order processed: OrderId={OrderId}, Duration={Duration}ms", orderId, duration),这样 OrderIdDuration 就成为了可查询的结构化属性。当你在 Dashboard 中搜索时,你可以直接使用 OrderId:12345Duration:>1000 这样的查询,而不需要依赖模糊的文本匹配。

日志的实时更新是 Dashboard 的另一个重要特性。当你在 Logs 页面时,新的日志会自动追加到日志流中,你不需要手动刷新页面。这对于监控正在进行的操作或等待特定事件发生特别有用。例如,如果你刚刚修复了一个 bug 并重新部署了服务,你可以打开 Dashboard 的 Logs 页面,设置一个过滤器来只显示来自你修复的服务的日志,然后触发之前会导致 bug 的操作。你会实时地看到日志流入,如果修复成功,你应该看不到之前的错误日志;如果修复不完全,错误日志会立即出现,你可以马上看到新的错误消息和堆栈跟踪。

在某些情况下,你可能需要导出日志进行离线分析或与团队成员共享。虽然 Dashboard 的具体功能可能有所不同,但许多实现提供了导出或下载日志的选项。你可能可以选择一个时间范围和一组过滤器,然后将匹配的日志导出为 JSON、CSV 或纯文本格式。导出的日志可以在其他工具中打开,如 Excel 进行数据透视表分析,或在文本编辑器中进行正则表达式搜索。一些高级用户甚至会编写脚本来解析导出的 JSON 日志,执行自定义的分析逻辑,如计算特定操作的平均耗时、识别异常模式、生成自定义报告等。

Dashboard 与分布式追踪的集成是其最强大的功能之一。我们之前提到,当你查看一条日志的详细信息时,如果这条日志与某个分布式追踪相关联,Dashboard 会显示一个链接让你跳转到相应的追踪。这种关联是通过 Trace ID 和 Span ID 实现的。当你使用 Aspire 的 Service Defaults 配置时,日志框架会自动将当前的 Activity(.NET 中分布式追踪的核心概念)的 Trace ID 和 Span ID 添加到日志的元数据中。在 Dashboard 中,这些 ID 被用来建立日志和追踪之间的双向链接。

这种集成的实际价值在复杂的故障排查场景中体现得淋漓尽致。想象一个跨越五个微服务的用户请求,它在第三个服务中失败了。如果你只看日志,你可能会看到第三个服务中的错误日志,但很难理解这个请求之前经历了什么,以及失败对后续流程有什么影响。但如果你从日志跳转到追踪,你会看到完整的请求链路:请求从 API Gateway 开始,路由到订单服务,订单服务调用库存服务检查库存,库存服务调用数据库查询,然后订单服务调用支付服务,支付服务在这里失败了。你可以看到每个步骤的耗时,识别出支付服务的调用耗时异常地长,远超正常值。然后你可以点击支付服务的 Span,查看它的详细属性,可能会发现一个 error 属性,其值是 "Timeout connecting to payment gateway"。现在你知道了根本原因:不是支付处理本身失败,而是连接到外部支付网关时超时了。这是一个网络问题,而不是业务逻辑问题。

反过来,从追踪到日志的跳转也同样有价值。当你在 Traces 页面查看一个追踪时,你可以看到整体的时序和调用关系,但每个 Span 只包含有限的属性信息。如果你想看到更详细的执行细节,如循环的每次迭代、条件分支的选择、变量的中间值等,你需要查看日志。Dashboard 允许你点击某个 Span,然后选择"查看日志",它会自动切换到 Logs 页面并过滤出这个 Span 时间范围内产生的日志。由于日志和追踪共享相同的 Trace ID,Dashboard 可以精确地识别哪些日志属于这个追踪。你会看到一个按时间排序的日志列表,每条日志都标记着它来自哪个服务和哪个代码位置。通过阅读这些日志,你可以重建代码的执行路径,就像你在 IDE 中单步调试一样,但这是在生产环境中,事后进行的。

为了最大化 Dashboard 日志功能的价值,你需要在开发时就建立良好的日志记录习惯。首先,为每个重要的业务操作记录开始和结束日志。例如,当开始处理订单时记录一条 Information 级别的日志,包含订单 ID 和客户 ID;当订单处理完成时再记录一条日志,包含处理结果和耗时。这样在 Dashboard 中你可以很容易地通过订单 ID 找到订单的完整生命周期。其次,为所有的外部调用(如 HTTP 请求、数据库查询、消息发送)记录详细的日志,包括目标地址、请求参数、响应状态、耗时等。这些信息在诊断集成问题时至关重要。第三,使用一致的命名约定。确保相同的概念在不同的服务和代码位置使用相同的属性名称。例如,如果你在一个服务中使用 OrderId 作为订单标识符的属性名,在其他服务中也应该使用 OrderId,而不是 order_idorderId。这样在 Dashboard 中搜索时,一个查询可以匹配所有相关的日志。

最后,定期审查和优化你的日志配置。随着应用的发展,某些之前有用的日志可能变得不再需要,而新的功能可能需要新的日志点。通过在 Dashboard 中分析日志量和日志内容,你可以识别哪些日志产生得太频繁、哪些日志从未被使用、哪些关键操作缺少日志覆盖。调整日志级别、添加或移除日志调用、优化日志消息的内容,都是持续改进可观测性的一部分。Dashboard 不仅是一个故障排查工具,更是一个反馈循环,它告诉你当前的日志策略是否有效,以及如何改进它以更好地支持运维和诊断需求。

通过充分利用 Dashboard 的日志分析功能,结合结构化日志、分布式追踪和实时过滤等特性,你可以将日志从被动的记录转变为主动的诊断工具。当问题发生时,你不再需要在海量的日志文件中手动搜索,也不需要依赖猜测和运气。你可以系统地、科学地缩小问题范围,快速定位根本原因,并验证修复是否有效。这种能力不仅提高了故障排查的效率,也增强了团队对系统行为的理解,为持续改进和优化提供了坚实的数据基础。

3.4 自定义日志提供程序

虽然 .NET 内置的日志框架已经提供了丰富的功能和多个标准的日志提供程序(如控制台、调试器、EventSource),但在某些特殊场景下,你可能需要创建自定义的日志提供程序来满足特定的需求。这些场景包括将日志写入到特定格式的文件、与企业内部的日志系统集成、实现特殊的日志聚合逻辑、或者在日志写入前进行自定义的处理和过滤。创建自定义日志提供程序的过程涉及到实现几个核心接口,理解日志框架的内部工作机制,以及处理并发、性能和资源管理等实际问题。通过深入了解这个过程,你不仅可以创建满足自己需求的日志系统,还能更好地理解 .NET 日志框架的设计理念和工作原理。

让我们从一个完整的、生产就绪的文件日志提供程序开始,这个示例不仅展示了基本的实现,还包含了错误处理、性能优化、线程安全和资源清理等关键方面。这个文件日志提供程序支持日志轮转、异步写入、缓冲机制和优雅的关闭流程,是一个可以直接在实际项目中使用的实现。

csharp 复制代码
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text;

public class FileLoggerProvider : ILoggerProvider
{
    private readonly string _baseFilePath;
    private readonly long _maxFileSize;
    private readonly int _maxFileCount;
    private readonly ConcurrentDictionary<string, FileLogger> _loggers;
    private readonly BlockingCollection<LogEntry> _logQueue;
    private readonly Task _processingTask;
    private readonly CancellationTokenSource _cancellationTokenSource;
    private StreamWriter? _currentWriter;
    private long _currentFileSize;
    private int _currentFileIndex;
    private readonly object _writerLock = new();

    public FileLoggerProvider(string baseFilePath, long maxFileSize = 10 * 1024 * 1024, int maxFileCount = 10)
    {
        _baseFilePath = baseFilePath;
        _maxFileSize = maxFileSize;
        _maxFileCount = maxFileCount;
        _loggers = new ConcurrentDictionary<string, FileLogger>();
        _logQueue = new BlockingCollection<LogEntry>(boundedCapacity: 1000);
        _cancellationTokenSource = new CancellationTokenSource();
        _currentFileIndex = 0;
        
        EnsureDirectoryExists();
        InitializeWriter();
        
        _processingTask = Task.Run(ProcessLogQueue);
    }

    private void EnsureDirectoryExists()
    {
        var directory = Path.GetDirectoryName(_baseFilePath);
        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }
    }

    private void InitializeWriter()
    {
        lock (_writerLock)
        {
            var filePath = GetCurrentFilePath();
            var fileInfo = new FileInfo(filePath);
            _currentFileSize = fileInfo.Exists ? fileInfo.Length : 0;
            
            _currentWriter = new StreamWriter(
                new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read),
                Encoding.UTF8,
                bufferSize: 4096)
            {
                AutoFlush = false
            };
        }
    }

    private string GetCurrentFilePath()
    {
        var directory = Path.GetDirectoryName(_baseFilePath) ?? ".";
        var fileName = Path.GetFileNameWithoutExtension(_baseFilePath);
        var extension = Path.GetExtension(_baseFilePath);
        
        if (_currentFileIndex == 0)
        {
            return _baseFilePath;
        }
        
        return Path.Combine(directory, $"{fileName}.{_currentFileIndex}{extension}");
    }

    public ILogger CreateLogger(string categoryName)
    {
        return _loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
    }

    internal void WriteLog(LogEntry entry)
    {
        if (!_logQueue.TryAdd(entry))
        {
            Console.WriteLine($"[FileLogger] Warning: Log queue is full, dropping log entry from {entry.CategoryName}");
        }
    }

    private async Task ProcessLogQueue()
    {
        try
        {
            while (!_cancellationTokenSource.Token.IsCancellationRequested)
            {
                if (_logQueue.TryTake(out var entry, 100, _cancellationTokenSource.Token))
                {
                    await WriteLogEntryAsync(entry);
                }
            }
            
            while (_logQueue.TryTake(out var entry))
            {
                await WriteLogEntryAsync(entry);
            }
        }
        catch (OperationCanceledException)
        {
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[FileLogger] Error in log processing: {ex}");
        }
    }

    private async Task WriteLogEntryAsync(LogEntry entry)
    {
        try
        {
            var logLine = FormatLogEntry(entry);
            var logBytes = Encoding.UTF8.GetByteCount(logLine);
            
            lock (_writerLock)
            {
                if (_currentWriter == null)
                {
                    return;
                }
                
                if (_currentFileSize + logBytes > _maxFileSize)
                {
                    RotateLogFile();
                }
                
                _currentWriter.WriteLine(logLine);
                _currentFileSize += logBytes + Environment.NewLine.Length;
                
                if (_logQueue.Count == 0)
                {
                    _currentWriter.Flush();
                }
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[FileLogger] Error writing log entry: {ex}");
        }
    }

    private string FormatLogEntry(LogEntry entry)
    {
        var builder = new StringBuilder();
        
        builder.Append(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"));
        builder.Append(" [");
        builder.Append(GetLogLevelString(entry.LogLevel));
        builder.Append("] ");
        builder.Append(entry.CategoryName);
        
        if (entry.EventId.Id != 0)
        {
            builder.Append(" (");
            builder.Append(entry.EventId.Id);
            if (!string.IsNullOrEmpty(entry.EventId.Name))
            {
                builder.Append(':');
                builder.Append(entry.EventId.Name);
            }
            builder.Append(")");
        }
        
        builder.Append(": ");
        builder.Append(entry.Message);
        
        if (entry.Exception != null)
        {
            builder.AppendLine();
            builder.Append(entry.Exception.ToString());
        }
        
        if (entry.Scopes.Any())
        {
            builder.AppendLine();
            builder.Append("  => Scopes: ");
            builder.Append(string.Join(" => ", entry.Scopes));
        }
        
        return builder.ToString();
    }

    private static string GetLogLevelString(LogLevel logLevel)
    {
        return logLevel switch
        {
            LogLevel.Trace => "TRACE",
            LogLevel.Debug => "DEBUG",
            LogLevel.Information => "INFO ",
            LogLevel.Warning => "WARN ",
            LogLevel.Error => "ERROR",
            LogLevel.Critical => "CRIT ",
            _ => "NONE "
        };
    }

    private void RotateLogFile()
    {
        _currentWriter?.Dispose();
        _currentWriter = null;
        
        _currentFileIndex++;
        if (_currentFileIndex >= _maxFileCount)
        {
            DeleteOldestLogFile();
            _currentFileIndex = _maxFileCount - 1;
        }
        
        InitializeWriter();
    }

    private void DeleteOldestLogFile()
    {
        var directory = Path.GetDirectoryName(_baseFilePath) ?? ".";
        var fileName = Path.GetFileNameWithoutExtension(_baseFilePath);
        var extension = Path.GetExtension(_baseFilePath);
        
        for (int i = 1; i < _maxFileCount; i++)
        {
            var sourceFile = Path.Combine(directory, $"{fileName}.{i}{extension}");
            var destFile = Path.Combine(directory, $"{fileName}.{i - 1}{extension}");
            
            if (File.Exists(sourceFile))
            {
                File.Move(sourceFile, destFile, overwrite: true);
            }
        }
        
        var lastFile = Path.Combine(directory, $"{fileName}.{_maxFileCount - 1}{extension}");
        if (File.Exists(lastFile))
        {
            File.Delete(lastFile);
        }
    }

    public void Dispose()
    {
        _cancellationTokenSource.Cancel();
        _logQueue.CompleteAdding();
        
        try
        {
            _processingTask.Wait(TimeSpan.FromSeconds(5));
        }
        catch (AggregateException)
        {
        }
        
        lock (_writerLock)
        {
            _currentWriter?.Flush();
            _currentWriter?.Dispose();
            _currentWriter = null;
        }
        
        _logQueue.Dispose();
        _cancellationTokenSource.Dispose();
    }
}

internal class LogEntry
{
    public DateTime Timestamp { get; set; }
    public LogLevel LogLevel { get; set; }
    public EventId EventId { get; set; }
    public string CategoryName { get; set; } = string.Empty;
    public string Message { get; set; } = string.Empty;
    public Exception? Exception { get; set; }
    public List<string> Scopes { get; set; } = new();
}

这个 FileLoggerProvider 类是日志提供程序的核心实现。它实现了 ILoggerProvider 接口,这个接口只要求一个方法 CreateLogger,用于为指定的类别创建日志记录器实例。但是一个完整的日志提供程序需要处理更多的职责,包括管理日志记录器的生命周期、处理并发写入、优化性能以及确保资源的正确清理。让我们深入分析这个实现的每个关键部分。

构造函数接受三个参数:基础文件路径、最大文件大小和最大文件数量。基础文件路径是日志文件的完整路径,包括文件名和扩展名,例如 logs/app.log。最大文件大小参数控制单个日志文件的大小上限,当文件达到这个大小时会触发日志轮转。最大文件数量参数限制了保留的日志文件总数,当达到这个数量时,最旧的日志文件会被删除。这些参数的默认值分别是 10MB 和 10 个文件,这对于大多数应用来说是一个合理的起点,但可以根据实际需求进行调整。

构造函数中创建了几个关键的数据结构。ConcurrentDictionary<string, FileLogger> 用于缓存已创建的日志记录器实例,键是类别名称,值是对应的 FileLogger 实例。这个缓存的作用是避免为同一个类别重复创建日志记录器,因为 ILoggerProvider.CreateLogger 方法可能会被多次调用同一个类别名称。使用并发字典而不是普通字典是因为这个方法可能从多个线程同时调用,并发字典提供了线程安全的操作而不需要显式加锁。

BlockingCollection<LogEntry> 是一个线程安全的生产者消费者队列,它是整个异步写入机制的核心。日志记录器(生产者)将日志条目添加到这个队列,而后台任务(消费者)从队列中取出日志条目并写入文件。这种设计的好处是将日志的记录和实际的文件写入操作解耦,记录日志的线程不需要等待文件 I/O 完成,可以立即返回继续执行业务逻辑。队列的容量被设置为 1000,这是一个经过权衡的值。如果容量太小,在日志高峰期可能会导致生产者阻塞;如果容量太大,可能会在应用关闭时丢失大量未写入的日志。

后台处理任务通过 Task.Run(ProcessLogQueue) 启动,这个任务会持续运行直到应用关闭。在任务内部,一个循环不断地从队列中取出日志条目并写入文件。使用 TryTake 方法而不是 Take 方法的原因是前者允许指定超时时间,这样任务可以定期检查取消令牌,实现优雅的关闭。当取消令牌被设置时(在 Dispose 方法中),任务会退出主循环,但在退出之前,它会尽力处理队列中剩余的所有日志条目,确保在应用关闭时尽可能少地丢失日志。

InitializeWriter 方法负责创建和初始化 StreamWriter 实例,这是实际写入日志到文件的对象。方法首先检查目标文件是否已存在,如果存在则获取其当前大小,这个大小会被记录到 _currentFileSize 字段中,用于后续判断是否需要轮转日志文件。StreamWriter 被配置为使用 UTF-8 编码和 4KB 的缓冲区大小。AutoFlush 属性被设置为 false,这意味着写入的数据会先缓冲在内存中,只有在缓冲区满或显式调用 Flush 方法时才会写入磁盘。这种缓冲机制可以显著提高写入性能,减少磁盘 I/O 的频率,但代价是在应用崩溃时可能会丢失缓冲区中未写入的数据。为了平衡性能和数据安全性,代码在队列为空时会自动刷新缓冲区,这样在没有新日志到来时,已缓冲的日志会被及时写入磁盘。

文件轮转机制通过 RotateLogFile 和相关方法实现。当当前日志文件的大小超过设定的最大值时,RotateLogFile 方法会被调用。这个方法首先关闭当前的 StreamWriter,然后增加文件索引,并创建一个新的日志文件。文件命名遵循一个简单的模式:主日志文件使用原始的文件名,如 app.log,轮转后的文件在文件名后添加索引,如 app.1.logapp.2.log 等。当文件数量达到最大值时,DeleteOldestLogFile 方法会被调用,它将所有文件向前移动一个位置(app.2.log 变成 app.1.logapp.1.log 变成 app.log),然后删除最旧的文件。这种轮转策略确保了磁盘空间的使用不会无限增长,同时保留了最近的日志历史。

FormatLogEntry 方法定义了日志的输出格式。这个格式包括时间戳、日志级别、类别名称、事件 ID、日志消息、异常信息和作用域信息。时间戳使用 yyyy-MM-dd HH:mm:ss.fff 格式,包含了毫秒级的精度,这对于分析性能问题和确定事件的精确顺序很重要。日志级别被转换为固定宽度的字符串(如 INFOERROR),这样日志行在视觉上更整齐,更容易阅读。如果日志包含异常,异常的完整堆栈跟踪会被附加到消息之后的新行中。作用域信息(如果有的话)也会被附加在最后,使用 => 符号分隔多个作用域,形成一个从外到内的作用域链。

现在让我们实现 FileLogger 类,它是实际执行日志记录操作的类。这个类需要实现 ILogger 接口,这个接口定义了记录日志的核心方法 Log 以及用于创建日志作用域的方法 BeginScopeILogger 接口还包含一个 IsEnabled 方法,用于快速判断当前配置是否允许记录特定级别的日志,这个方法的存在可以避免在日志被禁用时进行不必要的字符串格式化和对象分配。

csharp 复制代码
public class FileLogger : ILogger
{
    private readonly FileLoggerProvider _provider;
    private readonly string _categoryName;
    private readonly AsyncLocal<Scope?> _currentScope = new();

    public FileLogger(FileLoggerProvider provider, string categoryName)
    {
        _provider = provider;
        _categoryName = categoryName;
    }

    public IDisposable? BeginScope<TState>(TState state) where TState : notnull
    {
        var scope = new Scope(this, state?.ToString() ?? string.Empty);
        _currentScope.Value = scope;
        return scope;
    }

    public bool IsEnabled(LogLevel logLevel)
    {
        return logLevel != LogLevel.None;
    }

    public void Log<TState>(
        LogLevel logLevel,
        EventId eventId,
        TState state,
        Exception? exception,
        Func<TState, Exception?, string> formatter)
    {
        if (!IsEnabled(logLevel))
        {
            return;
        }

        var message = formatter(state, exception);
        if (string.IsNullOrEmpty(message) && exception == null)
        {
            return;
        }

        var entry = new LogEntry
        {
            Timestamp = DateTime.UtcNow,
            LogLevel = logLevel,
            EventId = eventId,
            CategoryName = _categoryName,
            Message = message,
            Exception = exception
        };

        var currentScope = _currentScope.Value;
        while (currentScope != null)
        {
            entry.Scopes.Insert(0, currentScope.State);
            currentScope = currentScope.Parent;
        }

        _provider.WriteLog(entry);
    }

    private class Scope : IDisposable
    {
        private readonly FileLogger _logger;
        private readonly Scope? _parent;

        public string State { get; }
        public Scope? Parent => _parent;

        public Scope(FileLogger logger, string state)
        {
            _logger = logger;
            _parent = logger._currentScope.Value;
            State = state;
        }

        public void Dispose()
        {
            _logger._currentScope.Value = _parent;
        }
    }
}

FileLogger 类的实现相对简洁,因为大部分复杂的逻辑都被封装在 FileLoggerProvider 中。构造函数接受提供程序的引用和类别名称,这两个信息会被保存下来供后续使用。IsEnabled 方法在这个简单实现中总是返回 true(除了 LogLevel.None),但在更复杂的实现中,这里可以添加基于配置的级别过滤逻辑。

Log 方法是日志记录的核心,它接受五个参数:日志级别、事件 ID、状态对象、异常和格式化函数。状态对象是一个泛型参数,可以是任何类型,通常是日志框架传递的一个内部结构,包含了日志消息模板和参数。格式化函数负责将状态对象转换为最终的日志消息字符串。方法首先调用 IsEnabled 检查当前级别是否应该被记录,如果不应该,方法立即返回,避免后续的开销。然后调用格式化函数生成日志消息,如果消息为空且没有异常,也不记录日志。

创建 LogEntry 对象时,时间戳使用 DateTime.UtcNow 而不是 DateTime.Now,这是一个重要的细节。使用 UTC 时间可以避免时区和夏令时带来的混淆,特别是在分布式系统中,不同服务器可能位于不同的时区,使用 UTC 时间可以确保所有日志的时间戳都在同一个参考系中,便于关联和分析。如果需要在查看日志时显示本地时间,可以在读取日志时进行转换,而不是在记录时就使用本地时间。

作用域的实现使用了 AsyncLocal<T>,这是一个特殊的泛型类,它提供了与异步上下文相关联的存储。与 ThreadLocal<T> 不同,AsyncLocal<T> 的值会在 async/await 调用链中自动传播,这意味着在一个异步方法中设置的作用域会在该方法及其所有异步子调用中保持有效。这对于 ASP.NET Core 应用特别重要,因为 HTTP 请求的处理通常涉及多个异步操作,使用 AsyncLocal<T> 可以确保请求的上下文(如请求 ID、用户 ID)在整个处理过程中都可用。

Scope 内部类实现了一个链式结构,每个作用域保存了对父作用域的引用。当调用 BeginScope 时,一个新的 Scope 对象被创建,它的 Parent 指向当前的作用域(如果有的话),然后新的作用域成为当前作用域。当 Scope 对象被释放(通过 Dispose 方法)时,当前作用域被恢复为父作用域。这种栈式结构支持了作用域的嵌套,内层作用域可以访问外层作用域的信息。在 Log 方法中,代码遍历整个作用域链,将所有作用域的状态添加到日志条目中,这样就可以在日志中看到完整的上下文层次。

现在让我们为这个自定义日志提供程序创建扩展方法,使其可以像内置日志提供程序一样方便地集成到应用中。扩展方法应该添加到 ILoggingBuilder 接口上,这样就可以在配置日志时链式调用。同时,我们还会创建一个配置类,允许用户通过配置文件或代码来自定义日志提供程序的行为。

csharp 复制代码
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Options;

public static class FileLoggerExtensions
{
    public static ILoggingBuilder AddFileLogger(
        this ILoggingBuilder builder,
        string filePath)
    {
        return builder.AddFileLogger(options =>
        {
            options.FilePath = filePath;
        });
    }

    public static ILoggingBuilder AddFileLogger(
        this ILoggingBuilder builder,
        Action<FileLoggerOptions> configure)
    {
        builder.Services.AddSingleton<ILoggerProvider, FileLoggerProvider>(serviceProvider =>
        {
            var options = new FileLoggerOptions();
            configure(options);
            
            return new FileLoggerProvider(
                options.FilePath,
                options.MaxFileSizeBytes,
                options.MaxFileCount);
        });

        return builder;
    }

    public static ILoggingBuilder AddFileLogger(
        this ILoggingBuilder builder)
    {
        builder.Services.Configure<FileLoggerOptions>(
            builder.Services.BuildServiceProvider()
                .GetRequiredService<IConfiguration>()
                .GetSection("Logging:FileLogger"));

        builder.Services.AddSingleton<ILoggerProvider, FileLoggerProvider>(serviceProvider =>
        {
            var options = serviceProvider.GetRequiredService<IOptions<FileLoggerOptions>>().Value;
            
            return new FileLoggerProvider(
                options.FilePath,
                options.MaxFileSizeBytes,
                options.MaxFileCount);
        });

        return builder;
    }
}

public class FileLoggerOptions
{
    public string FilePath { get; set; } = "logs/app.log";
    public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024;
    public int MaxFileCount { get; set; } = 10;
    public Dictionary<string, LogLevel> LogLevels { get; set; } = new();
}

这组扩展方法提供了三种配置文件日志提供程序的方式,满足不同的使用场景。第一个重载 AddFileLogger(string filePath) 是最简单的,它只接受一个文件路径参数,其他所有设置使用默认值。这种方式适合快速启用文件日志记录,不需要复杂的配置。第二个重载 AddFileLogger(Action<FileLoggerOptions> configure) 接受一个配置委托,允许用户在代码中通过 lambda 表达式设置所有选项。这种方式提供了完全的灵活性,适合需要编程式配置的场景。第三个重载 AddFileLogger() 不接受任何参数,它从应用的配置系统(通常是 appsettings.json)中读取配置。这种方式最适合生产环境,因为它允许在不修改代码的情况下通过配置文件调整日志行为。

在 Service Defaults 项目中集成自定义日志提供程序需要修改 Extensions.cs 文件,将文件日志添加到默认的可观测性配置中。这样所有使用 Service Defaults 的服务都会自动获得文件日志记录能力,而不需要在每个服务中单独配置。

csharp 复制代码
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

namespace ServiceDefaults;

public static class Extensions
{
    public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
    {
        builder.ConfigureOpenTelemetry();
        builder.AddDefaultHealthChecks();
        builder.AddFileLogging();
        
        return builder;
    }

    public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
    {
        builder.Logging.AddOpenTelemetry(logging =>
        {
            logging.IncludeFormattedMessage = true;
            logging.IncludeScopes = true;
        });

        builder.Services.AddOpenTelemetry()
            .WithMetrics(metrics =>
            {
                metrics.AddAspNetCoreInstrumentation()
                    .AddHttpClientInstrumentation()
                    .AddRuntimeInstrumentation();
            })
            .WithTracing(tracing =>
            {
                tracing.AddAspNetCoreInstrumentation()
                    .AddHttpClientInstrumentation();
            });

        builder.AddOpenTelemetryExporters();

        return builder;
    }

    private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
    {
        var useOtlpExporter = !string.IsNullOrWhiteSpace(
            builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);

        if (useOtlpExporter)
        {
            builder.Services.AddOpenTelemetry().UseOtlpExporter();
        }

        return builder;
    }

    public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder)
    {
        builder.Services.AddHealthChecks()
            .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);

        return builder;
    }

    public static IHostApplicationBuilder AddFileLogging(this IHostApplicationBuilder builder)
    {
        var environment = builder.Environment.EnvironmentName;
        var serviceName = builder.Environment.ApplicationName;
        
        var logDirectory = builder.Configuration["Logging:FileLogger:Directory"] ?? "logs";
        var logFileName = $"{serviceName.ToLowerInvariant()}-{environment.ToLowerInvariant()}.log";
        var logFilePath = Path.Combine(logDirectory, logFileName);

        builder.Logging.AddFileLogger(options =>
        {
            options.FilePath = logFilePath;
            options.MaxFileSizeBytes = 10 * 1024 * 1024;
            options.MaxFileCount = 10;
        });

        return builder;
    }

    public static WebApplication MapDefaultEndpoints(this WebApplication app)
    {
        app.MapHealthChecks("/health");
        app.MapHealthChecks("/alive", new HealthCheckOptions
        {
            Predicate = r => r.Tags.Contains("live")
        });

        return app;
    }
}

在这个集成中,AddFileLogging 方法被添加到 AddServiceDefaults 方法中,这样所有调用 builder.AddServiceDefaults() 的服务都会自动启用文件日志。日志文件的路径根据服务名称和环境名称动态构建,例如,在开发环境中的 OrderApi 服务会将日志写入 logs/orderapi-development.log,而在生产环境中则写入 logs/orderapi-production.log。这种命名约定使得在管理多个服务和多个环境时更容易组织和查找日志文件。日志目录可以通过配置 Logging:FileLogger:Directory 键来自定义,如果没有配置,默认使用 logs 目录。

appsettings.json 中配置文件日志提供程序可以提供更大的灵活性,允许在不同环境中使用不同的配置,或者在运行时动态调整配置而无需重新编译应用。

json 复制代码
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning"
    },
    "FileLogger": {
      "Directory": "logs",
      "MaxFileSizeBytes": 10485760,
      "MaxFileCount": 10,
      "LogLevels": {
        "Default": "Information",
        "MyApp.Services": "Debug",
        "System.Net.Http": "Warning"
      }
    }
  }
}

这个配置文件展示了如何为文件日志提供程序设置各种选项。Directory 指定了日志文件存储的目录,MaxFileSizeBytes 设置单个日志文件的最大大小(这里是 10MB),MaxFileCount 限制保留的日志文件数量。LogLevels 部分允许为不同的日志类别设置不同的级别,这与全局的 LogLevel 配置分开,给予了更细粒度的控制。

在实际使用自定义日志提供程序时,需要注意几个关键点以确保系统的稳定性和性能。首先是错误处理,日志系统本身不应该成为应用失败的原因。在示例代码中,所有可能抛出异常的操作(如文件 I/O、格式化、队列操作)都被 try-catch 块包裹,异常被捕获并输出到控制台,但不会导致应用崩溃。这是一个重要的设计原则:日志系统应该是尽力而为的(best-effort),当它遇到问题时,应该静默失败或降级,而不是影响业务逻辑的执行。

其次是性能考虑,日志记录是一个高频操作,在高负载的应用中,每秒可能有成千上万次日志调用。异步队列和后台处理任务的设计确保了记录日志的调用不会阻塞业务逻辑,但队列本身也需要适当的容量限制。在示例中,队列容量被设置为 1000,当队列满时,新的日志条目会被丢弃而不是阻塞生产者。这是一个权衡决策,在极端情况下牺牲一些日志的完整性来保证应用的响应性。如果应用对日志的完整性有严格要求,可以考虑增加队列容量或使用无界队列,但要注意这可能导致内存使用增长。

第三是资源清理,Dispose 方法的实现至关重要。当应用关闭时,如果日志提供程序没有正确地刷新和关闭文件流,可能会导致日志丢失或文件损坏。示例中的 Dispose 实现首先取消后台任务,然后标记队列为完成添加状态,阻止新的日志条目进入队列。接着等待后台任务完成,给予它最多 5 秒的时间来处理队列中剩余的日志。最后强制刷新和关闭文件流。这个过程确保了在正常关闭场景下,所有已记录的日志都会被写入磁盘。

第四是线程安全,虽然大部分操作都通过异步队列自然地序列化了,但 StreamWriter 的创建和销毁仍然需要显式的锁保护。_writerLock 对象用于同步对 _currentWriter 字段的访问,确保在日志轮转期间不会有多个线程同时尝试写入或关闭文件流。使用 lock 语句而不是更复杂的并发控制机制(如读写锁或信号量)是因为这些临界区都很短,不会成为性能瓶颈,而 lock 的简单性和可靠性在这种场景下更有价值。

最后是可扩展性,虽然这个示例实现了基本的文件日志功能,但在实际项目中你可能需要添加更多特性,如日志压缩、远程上传、结构化日志格式(如 JSON)、异步刷新策略、自定义格式化器等。这些特性可以通过扩展 FileLoggerOptions 类和修改 FileLoggerProvider 的实现来添加。例如,要支持 JSON 格式的日志,可以添加一个 Format 选项,并在 FormatLogEntry 方法中根据这个选项选择不同的格式化逻辑。要支持日志压缩,可以在轮转日志文件时调用压缩库对旧文件进行压缩,然后删除原始文件。

通过创建自定义日志提供程序,你不仅获得了完全控制日志行为的能力,还深入理解了 .NET 日志框架的内部工作机制。这种理解对于调试日志相关的问题、优化日志性能以及在需要时进行更高级的定制都非常有价值。虽然在大多数情况下,内置的日志提供程序和第三方库(如 Serilog、NLog)已经足够强大和灵活,但在特定场景下,如严格的合规要求、特殊的日志格式需求或与遗留系统的集成,自定义实现可能是唯一或最佳的选择。掌握这项技能让你在面对这些特殊需求时能够从容应对,而不是受限于现有工具的能力边界。

四、性能问题诊断

4.1 识别性能瓶颈

Aspire Dashboard 的 Metrics 页面提供了一个强大的实时性能分析工具,它汇集了来自所有服务的各种性能指标,让你可以从高层次理解系统的整体健康状况,也可以深入到单个服务的细节层面。理解如何有效地使用这些指标是识别性能瓶颈的关键技能,它能让你在问题影响用户之前就发现异常模式,并指导你进行针对性的优化。与传统的基于日志的性能分析不同,指标是定量的、连续的时间序列数据,它们以固定的时间间隔采样,提供了系统行为的统计视图。这种视图特别适合发现趋势、识别周期性模式以及设置自动化的警报阈值。

当你在浏览器中打开 Aspire Dashboard 并导航到 Metrics 页面时,你会看到一个动态更新的图表集合,每个图表代表一个或一组相关的性能指标。这些指标来自 OpenTelemetry 的仪表化系统,它会自动从 ASP.NET Core 应用、HTTP 客户端、数据库客户端和 .NET 运行时本身收集数据。页面顶部通常有一个时间范围选择器,允许你查看过去几分钟、几小时或几天的数据。还有一个服务选择器,让你可以将视图聚焦到特定的服务,或者查看整个系统的聚合指标。右上角可能有一个刷新间隔设置,默认情况下指标会每隔几秒钟自动刷新,但你可以暂停自动刷新以仔细检查某个特定的时间段。

HTTP 请求指标是性能分析中最直接和最有价值的数据源之一。http.client.request.duration 指标记录了应用程序作为 HTTP 客户端发起请求时的完整耗时,从发送请求到接收完整响应。这个指标通常以直方图的形式展示,横轴是时间,纵轴是请求耗时,你可以看到随时间变化的耗时分布。图表中可能会显示几条不同颜色的线,分别代表不同的百分位数,如 P50(中位数)、P95 和 P99。P50 告诉你典型请求的性能,P95 和 P99 则揭示了最慢请求的情况,这对于理解用户体验的尾部延迟非常重要。例如,你可能看到 P50 耗时一直稳定在 50 毫秒左右,但 P99 在某些时段突然跳升到 2 秒,这表明虽然大多数请求很快,但有 1% 的请求遇到了严重的性能问题,这些可能是由于外部服务偶尔的慢响应、数据库查询超时或其他间歇性问题导致的。

在 Dashboard 中,你可以点击某个指标的图表来查看更详细的信息。对于 http.client.request.duration,详细视图可能会按请求的目标 URL 或 HTTP 方法进行分组,让你看到哪些特定的端点最慢。假设你的订单服务调用了多个外部 API:一个库存服务、一个支付网关和一个物流服务。通过按 URL 分组的视图,你可能会发现支付网关的调用耗时远高于其他服务,平均响应时间是 500 毫秒,而库存服务只需要 50 毫秒。这个信息指导你将优化重点放在支付集成上,或者考虑为这个特定的调用实施更激进的缓存策略或异步处理模式。

http.server.request.duration 指标从服务器的视角测量请求处理时间,它记录了从接收到 HTTP 请求到发送完响应的整个处理过程。这个指标与 http.client.request.duration 互补,前者告诉你调用其他服务有多慢,后者告诉你你自己的服务对调用者来说有多慢。在一个典型的微服务架构中,一个请求可能会跨越多个服务,通过比较不同服务的服务器请求耗时,你可以识别出是哪个服务成为了瓶颈。例如,如果你的前端 Web 服务的 http.server.request.duration P95 是 1 秒,而它调用的 API 服务的 P95 是 800 毫秒,那么 API 服务显然是主要的延迟来源。但如果 API 服务的 P95 只有 100 毫秒,而 Web 服务却需要 1 秒,那么问题可能出在 Web 服务的内部处理逻辑上,比如模板渲染、数据转换或不必要的同步等待。

这个指标也可以按 HTTP 状态码、请求路径和 HTTP 方法进行分组,提供更细粒度的洞察。你可能会发现 POST 请求比 GET 请求慢得多,这是合理的,因为 POST 通常涉及数据写入。但如果 GET 请求的耗时也很高,可能表明存在效率低下的查询或缺少缓存。按状态码分组可以帮助你识别错误响应是否比成功响应更慢或更快。如果错误响应明显更快,可能意味着它们是在早期验证阶段就被拒绝了,这是好的设计。但如果错误响应很慢,可能表明错误处理路径中存在性能问题,如异常处理中的复杂逻辑、过度的日志记录或尝试了多次重试。

http.server.active_requests 指标显示了在任何给定时刻正在被处理的并发请求数量。这是一个瞬时指标,不同于前两个累积的耗时指标。它的图表通常显示为一条波动的曲线,峰值表示高并发时刻,谷值表示空闲时段。监控这个指标可以帮助你理解应用的负载模式和资源利用情况。如果你看到活跃请求数持续在一个很高的水平,比如几百或几千,这可能表明应用无法足够快地处理传入的请求,导致请求积压。这种情况下,你需要增加应用的实例数量以分散负载,或者优化应用的处理逻辑以提高吞吐量。另一方面,如果活跃请求数总是很低,比如个位数,这可能表明你的应用资源被严重低估了,或者外部客户端的负载生成不够高,无法充分测试系统的能力。

在分析活跃请求指标时,还要注意突然的尖峰和异常的持续高点。突然的尖峰可能对应于特定的业务事件,如每小时整点的定时任务启动、批量数据导入或营销活动引发的流量涌入。如果这些尖峰在预期之内并且系统能够处理它们而没有明显的性能下降,那就没有问题。但如果尖峰导致了响应时间的显著增加或错误率的上升,你需要采取措施,如在高峰期前预先扩容、实施请求限流或优化热点代码路径。持续的高活跃请求数如果伴随着不断增长的响应时间,可能表明存在资源争用,如线程池耗尽、数据库连接池饱和或 CPU 接近满载。

.NET 运行时指标提供了对应用内部行为的深入洞察,这些指标直接来自 CLR(Common Language Runtime),反映了垃圾回收、线程调度、锁竞争等底层活动。process.runtime.dotnet.gc.collections.count 指标记录了垃圾回收器执行的收集次数,分为三代:Gen 0、Gen 1 和 Gen 2。在 .NET 的分代垃圾回收模型中,Gen 0 是最年轻的对象集合,收集最频繁但成本最低;Gen 2 是最老的对象集合,收集最不频繁但成本最高。监控这个指标可以帮助你理解应用的内存分配模式和 GC 的压力。

在 Dashboard 的图表中,你可能会看到 Gen 0 收集的频率远高于 Gen 1 和 Gen 2,这是正常的。但如果 Gen 2 收集的频率也很高,比如每隔几秒就发生一次,这是一个警告信号,表明你的应用在创建大量长期存活的对象,导致它们晋升到 Gen 2,增加了 GC 的负担。Gen 2 收集是昂贵的,它需要暂停所有应用线程来扫描整个托管堆,这个暂停时间可能长达几百毫秒甚至更长,在这期间应用无法处理任何请求。如果你在性能分析中发现响应时间周期性地出现尖峰,并且这些尖峰与 Gen 2 收集的时间点对齐,那么你找到了性能问题的根源。

解决高频 Gen 2 收集的方法包括减少对象分配、使用对象池、优化数据结构的生命周期管理等。例如,如果你的代码在每个请求中都创建大量的临时字符串或数组,考虑使用 StringBuilderArrayPool<T> 来重用内存。如果你在缓存中存储了大量对象但没有适当的过期策略,这些对象会一直存活并晋升到 Gen 2,定期审查和清理缓存内容可以显著减少 GC 压力。监控 GC 暂停时间的指标(如 process.runtime.dotnet.gc.pause_time)也很重要,它直接反映了 GC 对用户体验的影响。如果暂停时间经常超过几十毫秒,用户可能会感觉到应用的"卡顿"。

process.runtime.dotnet.monitor.lock_contention.count 指标记录了线程在尝试获取锁时被阻塞的次数。锁竞争是多线程应用中常见的性能杀手,当多个线程试图同时访问受保护的资源时,只有一个线程能够成功,其他线程必须等待。高锁竞争率表明应用中存在并发瓶颈,即使你有多个 CPU 核心,线程也无法并行执行,因为它们在排队等待锁。在 Dashboard 中,如果你看到锁竞争计数持续上升,并且增长率与活跃请求数成正比或更快,这表明存在热锁,即一个被频繁争用的锁。

识别具体是哪个锁导致了竞争需要更深入的分析,可能需要使用 Visual Studio 的并发可视化工具或 dotnet-dump 来检查线程堆栈。一旦识别出问题的锁,有几种常见的缓解策略。首先是减少锁的持有时间,确保在锁内部只执行必要的、快速的操作,将耗时的 I/O 或计算移到锁外。其次是使用更细粒度的锁,如果一个粗粒度的锁保护了多个独立的资源,考虑为每个资源使用单独的锁,这样不同的线程可以并行访问不同的资源。第三是使用无锁的数据结构或并发集合,如 ConcurrentDictionary<TKey, TValue>ConcurrentQueue<T>,它们通过巧妙的算法避免了显式的锁,提供了更好的并发性能。最后,对于某些场景,完全重新设计以避免共享状态可能是最佳解决方案,例如使用 Actor 模型或消息传递来隔离状态。

process.memory.usage 指标显示了应用进程的总内存使用量,包括托管堆、非托管内存、代码、线程栈等所有部分。这个指标的图表应该显示为一个相对稳定的水平,可能有一些波动,但不应该持续增长。如果你看到内存使用量像楼梯一样阶梯式上升,每次 GC 后内存稍微下降但不久又上升到新的更高水平,这是典型的内存泄漏迹象。内存泄漏在托管语言中仍然可能发生,通常是因为意外地保持了对不再需要的对象的引用,阻止了 GC 回收它们。常见的泄漏源包括:事件处理器没有正确注销、静态集合持续增长、缓存没有过期策略、闭包捕获了大对象等。

当你怀疑存在内存泄漏时,应该使用 dotnet-dump 或 Visual Studio 的内存分析工具捕获多个时间点的内存快照,然后比较这些快照来找出哪些对象类型的数量在增长。在 Dashboard 的指标视图中,你还可以将内存使用量与其他指标关联起来分析。例如,如果内存使用量在每次大型批处理任务后都会上升但不完全回落,可能表明批处理代码在创建大对象后没有正确释放它们。如果内存使用量与活跃请求数成线性关系,这可能是正常的,因为每个请求都需要分配一些内存,但如果斜率过大,表明每个请求分配的内存太多,需要优化。

对于配置了数据库连接的 Aspire 应用,数据库客户端指标提供了关于数据访问性能的关键信息。db.client.connections.usage 指标显示了当前正在使用的数据库连接数量以及连接池的状态。.NET 的数据库客户端(如 SqlConnection)使用连接池来重用连接,避免了为每个查询创建新连接的高昂开销。连接池有最大连接数的限制(默认通常是 100),如果应用同时需要的连接超过这个限制,新的数据库操作将被阻塞,直到有连接被释放回池中。在 Dashboard 的图表中,如果你看到使用的连接数经常达到或接近最大值,这表明连接池成为了瓶颈。

连接池耗尽的症状包括数据库操作的响应时间突然增加、超时错误的频率上升、以及 db.client.connections.wait_time 指标显示长时间的等待。解决连接池耗尽的方法包括增加连接池的大小、优化数据库操作以减少连接的持有时间、以及检查是否有连接泄漏(即连接被获取后没有正确关闭或释放)。增加连接池大小是最直接的方法,可以在连接字符串中设置 Max Pool Size 参数,例如 Max Pool Size=200。但要注意,数据库服务器本身也有最大连接数的限制,如果所有应用实例的连接池都很大,可能会耗尽数据库的连接容量。因此,更好的做法是优化代码,确保数据库操作尽可能短暂,使用异步方法避免阻塞线程,以及使用 using 语句或 try-finally 块确保连接在使用后被正确释放。

db.client.connections.wait_time 指标测量了线程在等待可用数据库连接时阻塞的时间。理想情况下,这个值应该接近零,表明连接池总是有可用的连接。如果等待时间开始增长,特别是在高负载期间,这表明应用对数据库的并发访问超过了连接池的容量。持续的高等待时间会直接转化为更高的请求响应时间和更差的用户体验。在分析这个指标时,要将它与其他数据库相关的指标一起看,如查询耗时、连接创建频率等。如果查询本身很快但等待连接的时间很长,问题确实出在连接池上。但如果查询耗时也很高,可能是数据库查询需要优化,如添加索引、重写低效的 SQL 或减少数据传输量。

在实际使用 Dashboard 的 Metrics 页面时,一个有效的工作流程是从高层次的指标开始,逐步深入到细节。首先查看整体的请求耗时和错误率,识别是否存在明显的性能问题或错误高峰。然后检查活跃请求数和资源使用指标(CPU、内存、GC),确定是否存在资源瓶颈。如果发现问题,使用指标的分组和过滤功能来缩小范围,例如按服务、端点或操作类型分组。最后,结合分布式追踪和日志来深入理解问题的根本原因。这种多层次、多维度的分析方法可以让你系统地排查性能问题,而不是盲目地猜测。

为了最大化 Metrics 的价值,你应该在应用代码中主动添加自定义指标,记录业务相关的度量。OpenTelemetry 提供了 Metrics API,让你可以创建计数器、直方图、计量器等不同类型的指标。例如,你可以为订单处理流程添加一个计数器,记录成功和失败的订单数量;为支付金额添加一个直方图,分析订单金额的分布;为库存水平添加一个计量器,监控库存的实时状态。这些自定义指标会自动出现在 Dashboard 中,与系统指标一起提供完整的可观测性视图。

csharp 复制代码
using System.Diagnostics.Metrics;

public class OrderService
{
    private static readonly Meter s_meter = new("OrderService", "1.0.0");
    private static readonly Counter<long> s_orderCounter = s_meter.CreateCounter<long>("orders.processed");
    private static readonly Histogram<double> s_orderAmountHistogram = s_meter.CreateHistogram<double>("orders.amount");
    
    public async Task<Order> ProcessOrderAsync(Order order)
    {
        try
        {
            await ValidateOrderAsync(order);
            await ProcessPaymentAsync(order);
            await UpdateInventoryAsync(order);
            
            s_orderCounter.Add(1, new KeyValuePair<string, object?>("status", "success"));
            s_orderAmountHistogram.Record(order.TotalAmount, new KeyValuePair<string, object?>("currency", order.Currency));
            
            return order;
        }
        catch (Exception)
        {
            s_orderCounter.Add(1, new KeyValuePair<string, object?>("status", "failed"));
            throw;
        }
    }
}

在这个代码示例中,我们使用了 System.Diagnostics.Metrics 命名空间中的类来创建自定义指标。首先创建了一个 Meter 实例,它是指标的容器,名称为 OrderService,版本为 1.0.0。这个名称和版本会在 Dashboard 中用来组织和过滤指标。然后创建了两个指标:一个计数器 orders.processed 用于记录处理的订单总数,一个直方图 orders.amount 用于记录订单金额的分布。这些指标是静态的,在应用启动时创建一次,然后在整个应用生命周期中重用。

ProcessOrderAsync 方法中,我们根据处理结果记录指标。如果订单处理成功,调用 s_orderCounter.Add(1, ...) 增加成功计数,并记录订单金额到直方图。如果处理失败(异常被捕获),增加失败计数。注意我们为计数器添加了一个标签 status,它的值是 successfailed,这样在 Dashboard 中可以分别查看成功和失败订单的数量,而不是只有一个总数。类似地,订单金额直方图使用 currency 标签来区分不同货币的订单,如果你的应用支持多币种交易,这可以帮助你分析不同市场的订单规模分布。

这些自定义指标会被 OpenTelemetry 的 Metrics 导出器自动收集并发送到 Dashboard。在 Metrics 页面,你会看到新的图表显示 orders.processed 的变化趋势,按 status 标签分组显示成功和失败的比例。orders.amount 的直方图会显示订单金额的分布,你可以看到大部分订单的金额范围、中位数、以及是否有异常的大额或小额订单。这些业务指标与系统指标结合,为你提供了从基础设施到业务层面的完整可观测性。

通过充分利用 Dashboard 的 Metrics 功能,结合系统指标、运行时指标、数据库指标和自定义业务指标,你可以建立一个全面的性能监控体系。这个体系不仅能帮助你快速响应和解决当前的性能问题,还能让你预见潜在的问题、优化资源配置、以及做出基于数据的架构和业务决策。指标是可观测性的三大支柱之一(另外两个是日志和追踪),掌握指标分析是成为高效的分布式系统工程师的必备技能。

4.2 分布式追踪性能分析

分布式追踪是现代微服务架构中最强大的性能分析工具之一,它通过记录请求在系统中的完整路径和每个环节的耗时,让你可以精确地识别性能瓶颈。在传统的单体应用中,使用断点调试和性能分析器就足以发现慢代码,但在分布式系统中,一个用户请求可能跨越多个服务、多个数据库、多个外部 API,每个环节都可能成为瓶颈。分布式追踪将这些分散的信息汇聚成一个统一的视图,让你可以一目了然地看到整个请求的生命周期。Aspire Dashboard 的 Traces 页面就是这个视图的呈现,它以可视化的方式展示了每个追踪和其中包含的所有 Span,让复杂的分布式调用链变得清晰可见。

理解分布式追踪的核心概念是有效使用它的前提。一个追踪代表了一个完整的操作,通常对应于一个用户请求或一个后台任务。每个追踪有一个唯一的 Trace ID,这个 ID 在请求的整个生命周期中保持不变,即使请求跨越了多个服务和多个进程。追踪由一个或多个 Span 组成,Span 代表操作的一个阶段或一个子操作。例如,处理订单的追踪可能包含这些 Span:接收 HTTP 请求、验证订单数据、查询库存数据库、调用支付服务、更新订单状态、发送确认邮件、返回 HTTP 响应。每个 Span 都有自己的 Span ID,以及指向其父 Span 的引用,这些引用关系形成了一个树状结构,根 Span 通常是请求的入口点,叶子 Span 是最底层的操作,如单个数据库查询或 HTTP 调用。

当你打开 Aspire Dashboard 的 Traces 页面时,你会看到一个按时间倒序排列的追踪列表,最新的追踪显示在顶部。每个列表项显示了追踪的关键信息:开始时间、总耗时、包含的 Span 数量、服务名称和追踪状态。追踪状态可以是成功、失败或未设置,失败状态通常表示追踪中至少有一个 Span 遇到了错误。列表项还使用颜色编码来快速传达状态:绿色表示成功,红色表示失败,黄色可能表示有警告或性能问题。这个列表是你分析的起点,你可以通过多种方式过滤和搜索来找到感兴趣的追踪。

按耗时排序是识别性能问题的最直接方法。在 Traces 页面的顶部,你通常会看到一个排序下拉菜单或列标题,点击耗时列可以将追踪按总耗时从高到低排序。这样,最慢的请求会立即显示在列表顶部。在正常情况下,大多数请求的耗时应该是相似的,在一个可预期的范围内,比如几十到几百毫秒。但当你看到某些追踪的耗时是正常值的十倍甚至百倍时,这些异常值就值得深入分析。点击一个慢追踪的列表项,Dashboard 会展开显示这个追踪的详细视图,这个视图是一个时序图,横轴代表时间,纵轴列出了所有 Span,每个 Span 显示为一个水平条,条的长度代表 Span 的耗时,条的位置代表 Span 的开始和结束时间。

在这个时序图中,Span 按照父子关系进行缩进显示,根 Span 完全左对齐,它的直接子 Span 会缩进一级,孙 Span 再缩进一级,以此类推。这种树状的可视化让你可以清楚地看到操作的层次结构和并发情况。如果多个 Span 的时间段有重叠,说明这些操作是并行执行的,这通常是好的,表明系统充分利用了并发性。但如果所有 Span 都是顺序排列,没有任何重叠,可能表明存在不必要的串行化,一些本可以并行的操作被强制顺序执行了。通过分析 Span 的分布和重叠,你可以识别出优化并发性的机会。

每个 Span 的条上显示了 Span 的名称和耗时,点击某个 Span 可以展开查看更详细的信息。展开的 Span 详情面板显示了多个关键信息:操作名称、开始时间、耗时、状态、相关的属性和事件。操作名称通常是一个描述性的字符串,如 HTTP GET /api/orders/123SQL SELECT from ordersRedis GET order:123,它告诉你这个 Span 代表什么操作。耗时以毫秒为单位显示,让你可以精确地知道这个操作花费了多长时间。状态字段显示操作是成功还是失败,如果失败,通常会有一个错误对象包含异常类型、消息和堆栈跟踪。

属性是 Span 的元数据,以键值对的形式存储,它们提供了关于操作的上下文信息。对于 HTTP 请求 Span,属性可能包括 http.methodhttp.urlhttp.status_codehttp.user_agent 等。对于数据库查询 Span,属性可能包括 db.system(数据库类型,如 postgresqlsqlserver)、db.statement(SQL 语句)、db.name(数据库名称)。对于自定义 Span,你可以添加任何对调试有用的属性,如业务标识符、配置值、中间计算结果等。这些属性不仅在 Dashboard 中显示,还可以被导出到其他分析系统,成为可搜索和可聚合的数据。

事件是 Span 生命周期中发生的时间点标记,每个事件有一个名称、时间戳和可选的属性。事件通常用于记录 Span 内部的重要里程碑或异常情况,如 request_startedauthentication_succeededcache_missexception_thrown。在 Span 详情面板中,事件按时间顺序列出,让你可以看到操作的时间线。例如,一个数据库查询 Span 可能有这些事件:连接打开、命令执行、结果集接收、连接关闭。通过分析事件的时间间隔,你可以识别出操作的哪个阶段最耗时。

现在让我们通过一个完整的实际案例来演示如何使用 Traces 页面分析性能问题。假设你的订单服务收到了用户的投诉,说下单过程有时会非常慢,但你无法在测试环境中稳定复现这个问题。你决定使用 Aspire Dashboard 来分析生产环境的追踪数据,找出慢请求的特征。首先打开 Dashboard,导航到 Traces 页面,设置时间范围为过去一小时,这是用户报告问题的时间段。然后按耗时排序,将最慢的追踪显示在顶部。你立即注意到列表顶部有几个追踪,它们的耗时在 5 到 10 秒之间,而大多数正常追踪只需要 200 到 500 毫秒。

点击其中一个最慢的追踪,比如一个耗时 8.5 秒的追踪。Dashboard 展开显示详细的 Span 视图,你看到这个追踪包含 15 个 Span,根 Span 是 HTTP POST /api/orders,它的耗时确实是 8.5 秒,几乎等于整个追踪的耗时,这说明没有其他并行的 Span 在外部延长了追踪时间。根 Span 有三个主要的子 Span:ValidateOrderProcessPaymentUpdateInventory,它们的耗时分别是 50 毫秒、8.2 秒和 100 毫秒。很明显,ProcessPayment Span 是瓶颈,它占用了整个请求耗时的 96%。

点击 ProcessPayment Span 展开其详情,你看到这个 Span 包含了一个子 Span HTTP POST https://payment-gateway.example.com/charge,耗时 8.1 秒。查看这个 HTTP 请求 Span 的属性,你看到 http.status_code200,表示请求成功了,但 http.response_content_length 只有几百字节,不应该需要这么长时间来传输。继续查看属性,你注意到 net.peer.namepayment-gateway.example.com,net.peer.port443。这时你想起来,支付网关最近迁移到了新的 IP 地址,但 DNS 记录可能还没有更新完全,或者存在 DNS 缓存问题。为了验证这个假设,你查看了其他慢追踪,发现它们都有相同的模式:对支付网关的 HTTP 请求耗时异常地长。

回到代码中,你检查了支付服务的配置。原来,HTTP 客户端配置了一个非常保守的连接超时时间,默认是 30 秒,并且启用了连接重用。你怀疑问题可能是这样的:当应用启动或 HTTP 连接池中的连接过期后,下一个请求需要建立新的 TCP 连接。由于 DNS 解析或网络路由的问题,连接建立过程非常慢,但一旦连接建立成功,后续使用这个连接的请求就很快。这解释了为什么只有部分请求很慢,而不是所有请求都慢。为了解决这个问题,你做了几件事:首先,验证支付网关的 DNS 记录是否正确,联系了基础设施团队确认 DNS 已经更新;其次,配置 HTTP 客户端使用更合理的超时时间和连接管理策略;第三,添加了健康检查来主动测试支付网关的连接性,如果连接失败或很慢,及时报警而不是影响用户请求。

在代码中添加自定义 Span 可以让你更细粒度地监控性能。虽然 .NET 和 ASP.NET Core 会自动为 HTTP 请求、数据库查询等常见操作创建 Span,但你的业务逻辑的内部步骤默认是不可见的。通过手动添加 Span,你可以将这些步骤也纳入追踪,获得更完整的性能视图。让我们看一个详细的例子,展示如何在订单服务中添加自定义 Span 来监控各个处理阶段。

首先需要创建一个 ActivitySource 实例,这是 .NET 中创建 Span 的工厂类。ActivitySource 应该是静态的,在应用启动时创建一次,然后在整个应用生命周期中重用。它需要一个名称,这个名称通常是服务名称或组件名称,它会在追踪中用来标识 Span 的来源。在 OpenTelemetry 的配置中,你需要明确启用这个 ActivitySource,否则它创建的 Activity(Span 在 .NET 中的对应概念)不会被记录。

csharp 复制代码
using System.Diagnostics;
using Microsoft.Extensions.Logging;

public class OrderService
{
    private static readonly ActivitySource s_activitySource = new("OrderService", "1.0.0");
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repository;
    private readonly IPaymentService _paymentService;
    private readonly IInventoryService _inventoryService;

    public OrderService(
        ILogger<OrderService> logger,
        IOrderRepository repository,
        IPaymentService paymentService,
        IInventoryService inventoryService)
    {
        _logger = logger;
        _repository = repository;
        _paymentService = paymentService;
        _inventoryService = inventoryService;
    }

    public async Task<OrderResult> ProcessOrderAsync(Order order)
    {
        using var activity = s_activitySource.StartActivity("ProcessOrder");
        if (activity is not null)
        {
            activity.SetTag("order.id", order.Id);
            activity.SetTag("order.customer_id", order.CustomerId);
            activity.SetTag("order.total_amount", order.TotalAmount);
            activity.SetTag("order.items_count", order.Items.Count);
        }

        try
        {
            await ValidateOrderAsync(order);
            await ReserveInventoryAsync(order);
            var paymentResult = await ProcessPaymentAsync(order);
            await SaveOrderAsync(order, paymentResult);
            
            activity?.SetTag("order.status", "success");
            activity?.SetStatus(ActivityStatusCode.Ok);
            
            return OrderResult.Success(order.Id, paymentResult.TransactionId);
        }
        catch (OrderValidationException ex)
        {
            activity?.SetTag("order.status", "validation_failed");
            activity?.SetTag("validation.errors", string.Join(", ", ex.Errors));
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
        catch (Exception ex)
        {
            activity?.SetTag("order.status", "failed");
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.RecordException(ex);
            throw;
        }
    }

    private async Task ValidateOrderAsync(Order order)
    {
        using var activity = s_activitySource.StartActivity("ValidateOrder");
        activity?.SetTag("order.id", order.Id);
        
        var sw = Stopwatch.StartNew();
        
        if (order.Items.Count == 0)
        {
            activity?.AddEvent(new ActivityEvent("ValidationFailed", 
                tags: new ActivityTagsCollection { { "reason", "empty_items" } }));
            throw new OrderValidationException("Order must contain at least one item");
        }

        if (order.TotalAmount <= 0)
        {
            activity?.AddEvent(new ActivityEvent("ValidationFailed",
                tags: new ActivityTagsCollection { { "reason", "invalid_amount" } }));
            throw new OrderValidationException("Order total must be greater than zero");
        }

        await Task.Delay(10);
        sw.Stop();
        
        activity?.SetTag("validation.duration_ms", sw.ElapsedMilliseconds);
        activity?.AddEvent(new ActivityEvent("ValidationSucceeded"));
    }

    private async Task ReserveInventoryAsync(Order order)
    {
        using var activity = s_activitySource.StartActivity("ReserveInventory");
        activity?.SetTag("order.id", order.Id);
        activity?.SetTag("items.count", order.Items.Count);
        
        var reservedItems = new List<string>();
        
        foreach (var item in order.Items)
        {
            using var itemActivity = s_activitySource.StartActivity("ReserveInventoryItem");
            itemActivity?.SetTag("item.product_id", item.ProductId);
            itemActivity?.SetTag("item.quantity", item.Quantity);
            
            var available = await _inventoryService.CheckAvailabilityAsync(item.ProductId, item.Quantity);
            itemActivity?.SetTag("item.available", available);
            
            if (!available)
            {
                itemActivity?.SetStatus(ActivityStatusCode.Error, "Insufficient inventory");
                itemActivity?.AddEvent(new ActivityEvent("InsufficientInventory",
                    tags: new ActivityTagsCollection
                    {
                        { "product_id", item.ProductId },
                        { "requested", item.Quantity }
                    }));
                throw new InsufficientInventoryException(item.ProductId);
            }
            
            await _inventoryService.ReserveAsync(item.ProductId, item.Quantity);
            reservedItems.Add(item.ProductId);
            
            itemActivity?.AddEvent(new ActivityEvent("InventoryReserved"));
        }
        
        activity?.SetTag("reserved_items", string.Join(",", reservedItems));
        activity?.SetTag("reservation.success", true);
    }

    private async Task<PaymentResult> ProcessPaymentAsync(Order order)
    {
        using var activity = s_activitySource.StartActivity("ProcessPayment");
        activity?.SetTag("order.id", order.Id);
        activity?.SetTag("payment.amount", order.TotalAmount);
        activity?.SetTag("payment.currency", order.Currency);
        activity?.SetTag("payment.method", order.PaymentInfo.PaymentMethod);
        
        activity?.AddEvent(new ActivityEvent("PaymentStarted"));
        
        var sw = Stopwatch.StartNew();
        PaymentResult result;
        
        try
        {
            result = await _paymentService.ChargeAsync(new PaymentRequest
            {
                OrderId = order.Id,
                Amount = order.TotalAmount,
                Currency = order.Currency,
                PaymentMethod = order.PaymentInfo.PaymentMethod,
                CardNumber = order.PaymentInfo.CardNumber
            });
            
            sw.Stop();
            
            activity?.SetTag("payment.duration_ms", sw.ElapsedMilliseconds);
            activity?.SetTag("payment.transaction_id", result.TransactionId);
            activity?.SetTag("payment.success", result.Success);
            
            if (result.Success)
            {
                activity?.AddEvent(new ActivityEvent("PaymentSucceeded",
                    tags: new ActivityTagsCollection
                    {
                        { "transaction_id", result.TransactionId },
                        { "amount", result.Amount }
                    }));
            }
            else
            {
                activity?.SetStatus(ActivityStatusCode.Error, result.ErrorMessage);
                activity?.AddEvent(new ActivityEvent("PaymentFailed",
                    tags: new ActivityTagsCollection
                    {
                        { "error_code", result.ErrorCode },
                        { "error_message", result.ErrorMessage }
                    }));
            }
            
            return result;
        }
        catch (Exception ex)
        {
            sw.Stop();
            activity?.SetTag("payment.duration_ms", sw.ElapsedMilliseconds);
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.RecordException(ex);
            throw;
        }
    }

    private async Task SaveOrderAsync(Order order, PaymentResult paymentResult)
    {
        using var activity = s_activitySource.StartActivity("SaveOrder");
        activity?.SetTag("order.id", order.Id);
        activity?.SetTag("order.status", "confirmed");
        
        order.Status = OrderStatus.Confirmed;
        order.PaymentTransactionId = paymentResult.TransactionId;
        order.ConfirmedAt = DateTime.UtcNow;
        
        await _repository.SaveAsync(order);
        
        activity?.AddEvent(new ActivityEvent("OrderSaved",
            tags: new ActivityTagsCollection
            {
                { "order_id", order.Id },
                { "transaction_id", paymentResult.TransactionId }
            }));
    }
}

这段代码展示了如何在一个订单处理服务的不同阶段创建自定义 Span。在 ProcessOrderAsync 方法的开始,我们调用 s_activitySource.StartActivity("ProcessOrder") 创建了一个名为 ProcessOrder 的 Activity。StartActivity 方法返回一个 Activity 对象,如果当前没有启用追踪(例如,这个请求没有追踪上下文,或者 ActivitySource 没有被配置为记录),返回值可能是 null。因此,在使用 activity 对象之前,代码都会检查它是否为 null,使用空条件操作符 ?. 来安全地调用方法。这个检查是必要的,因为在某些环境(如单元测试或禁用追踪的配置)中,Activity 可能不会被创建。

使用 using 语句来包裹 Activity 是一个重要的模式。Activity 实现了 IDisposable 接口,当它被释放时(在 using 块结束时),Activity 会自动结束并记录其完整的耗时。这确保了即使方法中途抛出异常,Activity 也会被正确关闭,其数据也会被发送到追踪系统。如果你忘记释放 Activity,它会一直保持活跃状态,可能导致追踪数据不完整或内存泄漏。

SetTag 方法用于向 Activity 添加键值对形式的元数据。在这个例子中,我们为 ProcessOrder Activity 添加了多个标签:订单 ID、客户 ID、总金额和商品数量。这些标签会出现在 Dashboard 的 Span 详情面板中,也可以用于搜索和过滤追踪。标签的键应该遵循 OpenTelemetry 的语义约定,使用点分隔的小写字母,如 order.idhttp.methoddb.statement。这种命名约定让标签更容易理解和跨服务一致使用。标签的值可以是字符串、数字或布尔值,复杂对象会被自动转换为字符串表示。

在方法的 try 块中,我们依次调用了几个子操作:验证订单、预留库存、处理支付和保存订单。每个子操作也创建了自己的 Activity,这些子 Activity 会自动成为父 Activity(ProcessOrder)的子节点,形成一个树状结构。这种嵌套的 Activity 完美地反映了代码的执行层次,在 Dashboard 的追踪视图中,你会看到 ValidateOrderReserveInventoryProcessPaymentSaveOrder Span 都缩进显示在 ProcessOrder Span 下方,它们的时间段包含在父 Span 的时间段内(除非存在异步并发)。

在 catch 块中,代码根据异常类型设置了不同的标签和状态。对于 OrderValidationException,我们设置 order.status 标签为 validation_failed,并将验证错误列表作为另一个标签。然后调用 SetStatus 方法将 Activity 的状态设置为 Error,并提供错误描述。这会在 Dashboard 中将这个 Span 标记为失败,用红色显示。对于其他类型的异常,我们设置状态为失败,并调用 RecordException 方法记录完整的异常信息,包括异常类型、消息和堆栈跟踪。这些异常信息会作为 Activity 的事件被记录,在 Dashboard 中可以展开查看。

ValidateOrderAsync 方法展示了如何使用 Activity 事件来标记关键的时间点。事件通过 AddEvent 方法添加,它接受一个 ActivityEvent 对象,这个对象包含事件名称、时间戳(默认是当前时间)和可选的标签集合。在验证失败时,我们添加了一个 ValidationFailed 事件,并附带了失败原因的标签,如 empty_itemsinvalid_amount。在验证成功时,添加了一个 ValidationSucceeded 事件。这些事件在 Dashboard 的 Span 详情中按时间顺序列出,让你可以看到验证过程的时间线。我们还使用了 Stopwatch 来测量验证的实际耗时,并将这个耗时作为标签添加到 Activity 中。虽然 Activity 本身会记录总耗时,但有时你想要测量不包括某些开销的"纯"处理时间,这时手动测量就很有用。

ReserveInventoryAsync 方法展示了如何为循环中的每次迭代创建独立的 Activity。在遍历订单商品列表时,为每个商品创建了一个 ReserveInventoryItem Activity。这种细粒度的追踪让你可以在 Dashboard 中看到每个商品的预留操作是如何执行的,哪些商品的预留很快,哪些很慢。在 Activity 的标签中,我们记录了商品 ID、请求数量和库存是否可用。如果库存不足,Activity 的状态被设置为错误,并添加了一个 InsufficientInventory 事件,记录了具体是哪个商品库存不足以及请求的数量。这种详细的上下文信息在调试库存相关问题时非常有价值,你可以快速识别出是哪个商品导致了订单失败。

ProcessPaymentAsync 方法中,我们展示了如何为外部服务调用添加详细的追踪信息。虽然 HTTP 客户端库会自动为 HTTP 请求创建 Span,但我们的自定义 Span 提供了业务层面的上下文,如支付金额、货币、支付方式等。我们在支付开始时添加了一个 PaymentStarted 事件,在支付完成后根据结果添加了 PaymentSucceededPaymentFailed 事件。使用 Stopwatch 测量了支付的耗时,并将这个信息作为标签记录。如果支付成功,我们还记录了交易 ID,这是一个关键的业务标识符,可以用来关联支付记录和订单。如果支付失败,我们记录了错误代码和错误消息,这些信息可以帮助你理解失败的原因是网络问题、余额不足还是卡片验证失败等。

为了让这些自定义 Activity 能够被追踪系统记录,你需要在应用的启动代码中配置 OpenTelemetry,明确启用这个 ActivitySource。在 Service Defaults 项目的 Extensions.cs 文件中,添加以下配置:

csharp 复制代码
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddSqlClientInstrumentation()
            .AddSource("OrderService");
    });

这里的 AddSource("OrderService") 调用告诉 OpenTelemetry 记录所有从名为 OrderServiceActivitySource 创建的 Activity。如果你有多个服务或组件使用自定义追踪,为每个 ActivitySource 添加相应的 AddSource 调用。需要注意的是,AddSource 的参数必须与创建 ActivitySource 时使用的名称完全匹配,包括大小写。

配置完成后,重新启动应用并触发订单处理操作。打开 Aspire Dashboard 的 Traces 页面,找到相应的追踪并展开查看。你会看到除了自动创建的 HTTP 和数据库 Span 之外,还有你添加的自定义 Span:ProcessOrderValidateOrderReserveInventoryProcessPayment。每个 Span 都显示了你设置的标签和事件,让追踪的可读性和信息量大大增加。如果某个订单处理失败了,你可以展开失败的 Span,查看是在哪个阶段失败的,失败的原因是什么,以及当时的业务上下文(如订单 ID、商品列表等)。

在 Dashboard 中分析这些自定义 Span 时,你可以发现一些有趣的性能模式。例如,你可能注意到 ReserveInventory Span 的耗时随着订单商品数量线性增长,因为它为每个商品创建了一个子 Span。如果订单有 10 个商品,你会看到 10 个 ReserveInventoryItem Span 依次执行。这时你可能会考虑优化,比如使用批量 API 一次预留所有商品,或者并行执行预留操作。通过比较优化前后的追踪,你可以量化优化的效果,看看 ReserveInventory Span 的总耗时减少了多少。

自定义追踪的另一个强大用途是识别代码中的瓶颈。假设你在 Dashboard 中看到 ProcessOrder Span 的耗时是 1.5 秒,但其中的子 Span ValidateOrderReserveInventoryProcessPaymentSaveOrder 的耗时加起来只有 1.2 秒,那么剩余的 300 毫秒去哪了?这种"未计入"的时间可能来自几个地方:Span 之间的代码执行时间、对象创建和初始化、日志记录、或者异步调度的开销。为了进一步定位这 300 毫秒的去向,你可以添加更多的 Span 来覆盖这些间隙,或者使用 CPU 性能分析器来找出热点函数。

在生产环境中使用自定义追踪时,需要注意性能开销。虽然 .NET 的 Activity 系统和 OpenTelemetry 的实现都经过了优化,但创建大量的 Activity 仍然会有一定的开销,包括内存分配、上下文切换和数据序列化。在高吞吐量的服务中,如果每个请求创建了数百个 Activity,累积的开销可能变得显著。为了减轻这个问题,你可以考虑采样策略,只追踪一部分请求。OpenTelemetry 支持多种采样器,如概率采样器(随机采样一定比例的请求)、速率限制采样器(每秒采样固定数量的请求)、基于父采样的采样器(如果父 Span 被采样,子 Span 也被采样)。在应用启动时配置采样器:

csharp 复制代码
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.SetSampler(new TraceIdRatioBasedSampler(0.1));
    });

这个配置使用概率采样器,采样率为 0.1,即 10% 的请求会被追踪,其余 90% 的请求不会产生追踪数据。这种方式可以在保留足够样本用于性能分析的同时,显著减少追踪的开销和数据量。在生产环境中,10% 的采样率通常已经足够发现大多数性能问题和异常模式,因为问题通常会在多个请求中重复出现,不需要追踪每一个请求。

最后,要充分利用自定义追踪,需要建立团队内的命名和使用约定。确保所有开发人员使用一致的 Activity 命名模式,如使用动词+名词的形式(ProcessOrderValidateOrder)。为标签键使用统一的命名空间,如所有订单相关的标签都以 order. 开头。定期审查追踪数据,识别哪些 Activity 是有用的,哪些添加了过多的噪音或重复信息。通过这种持续改进的过程,你可以构建一个既全面又高效的追踪系统,为性能分析和故障排查提供坚实的基础。

分布式追踪与性能优化形成了一个正向循环:你使用追踪来识别性能瓶颈,实施优化,然后再使用追踪来验证优化效果,发现新的改进机会。这个循环不断重复,让系统的性能持续提升。Aspire Dashboard 的 Traces 页面是这个循环的可视化界面,它让性能分析从艺术变成了科学,从猜测变成了数据驱动的决策过程。掌握这个工具和相关的技巧,你就能够自信地构建和维护高性能的分布式应用,为用户提供快速、可靠的体验。

4.3 CPU 和内存分析

Visual Studio 内置的 Performance Profiler 是一套强大的性能分析工具集,它提供了多种专门的分析器来帮助你定位不同类型的性能问题。这些工具不仅可以在开发环境中使用,还可以附加到正在运行的生产应用进行诊断,而无需修改代码或重新部署。要启动 Performance Profiler,你可以在 Visual Studio 的菜单栏中选择 Debug → Performance Profiler,或者使用快捷键 Alt+F2。这会打开一个工具选择窗口,显示所有可用的分析器以及每个分析器的简短描述。你可以同时选择多个分析器来获得更全面的性能视图,但要注意同时运行多个分析器可能会相互影响测量结果,增加应用的运行开销。

CPU Usage 分析器是性能分析中最常用的工具之一,它通过定期采样应用程序的调用堆栈来识别哪些函数消耗了最多的 CPU 时间。当你启动这个分析器并运行应用时,它会在后台收集数据,等待你停止分析会话。停止后,Visual Studio 会生成一个详细的报告,显示 CPU 时间的分布。报告的主视图是一个火焰图或热路径视图,最热的函数(消耗 CPU 时间最多的)会以更深的颜色或更大的面积显示。你可以点击任何函数来展开查看它的调用者和被调用者,追踪 CPU 时间是如何在调用链中分布的。这种可视化让你可以快速识别出应用的性能热点,即那些消耗了大量 CPU 但可能有优化空间的代码路径。

例如,假设你的订单处理服务在高负载下 CPU 使用率很高,你怀疑是某些计算密集型操作导致的。启动 CPU Usage 分析器,然后在应用中触发一批订单处理操作,让系统承受一定的负载。几分钟后停止分析,查看生成的报告。你可能会发现 CalculateShippingCost 函数出现在热路径的顶部,占用了 40% 的 CPU 时间。点击这个函数展开详情,你会看到它的内部有一个嵌套循环,为每个配送地址计算距离和费率。通过分析这个函数的源代码和调用频率,你可能会意识到可以通过缓存常用地址的计算结果或使用预计算的查找表来优化这个函数,从而显著减少 CPU 使用。

Memory Usage 分析器专门用于检测内存泄漏和分析应用的内存分配模式。在 .NET 应用中,虽然垃圾回收器会自动管理内存,但仍然可能出现内存泄漏,通常是因为代码意外地保持了对不再需要的对象的引用,阻止了 GC 回收它们。Memory Usage 分析器通过拍摄内存快照并比较不同时间点的堆状态来帮助你识别这些泄漏。快照包含了当前托管堆中所有对象的信息,包括对象类型、大小、数量以及它们之间的引用关系。通过比较两个快照,你可以看到哪些对象类型的数量在增长,哪些对象意外地存活了比预期更长的时间。

内存泄漏检测的典型工作流程是这样的:首先在 Performance Profiler 中选择 Memory Usage 分析器并启动分析会话。应用启动后,在 Diagnostic Tools 窗口的 Memory Usage 图表上方会看到一个"拍摄快照"按钮。在应用处于稳定状态时(比如刚启动完成,没有执行任何业务操作),点击按钮拍摄第一个快照,我们称之为基准快照。然后执行你怀疑导致内存泄漏的操作,比如处理一批订单、加载和卸载一个大型数据集、或者重复执行某个 API 调用多次。操作完成后,等待几秒让 GC 有机会回收短期对象,然后拍摄第二个快照。如果你想更确定地识别泄漏,可以再次执行相同的操作,拍摄第三个快照。现在点击任意两个快照之间的差异链接,Visual Studio 会打开一个比较视图,显示两个快照之间新增的对象、删除的对象和大小变化。

在比较视图中,对象类型按大小增量排序,增长最多的类型显示在顶部。如果你看到某个自定义类型(比如 OrderContextCachedData)的实例数量显著增加,并且这个增长在多次快照之间持续存在,这就是一个强烈的内存泄漏信号。点击这个类型可以展开查看所有实例的详细信息,包括每个实例的大小、保留大小(包括它引用的所有对象)以及根路径(从 GC 根到这个对象的引用链)。根路径是诊断内存泄漏最关键的信息,它告诉你为什么这个对象还没有被回收,是哪个长期存活的对象在持有它的引用。常见的泄漏根源包括静态集合、事件处理器未注销、闭包捕获、以及缓存没有过期策略。

例如,假设你在比较快照时发现 HttpClient 实例的数量从快照 1 的 10 个增长到快照 2 的 100 个,并且在快照 3 中继续增长到 200 个。这显然不正常,因为 HttpClient 应该是被重用的,而不是为每个请求创建新的实例。点击 HttpClient 类型展开详情,选择其中一个实例查看其根路径。你可能会看到这样的引用链:StaticField OrderService._httpClientsList<HttpClient>HttpClient instance。这揭示了问题的根源:OrderService 类有一个静态的 HttpClient 列表,每次调用某个方法时都会向这个列表添加新的 HttpClient 实例,但从不移除它们。这个列表是一个 GC 根(因为它是静态的),所以它引用的所有 HttpClient 实例都无法被回收。修复方法很简单:使用单例的 HttpClient 实例而不是每次创建新的,或者使用 IHttpClientFactory 来正确管理 HttpClient 的生命周期。

.NET Counters 分析器提供了应用程序运行时的实时性能计数器,这些计数器来自 .NET 运行时本身,反映了 GC 行为、线程池状态、异常频率、HTTP 请求吞吐量等关键指标。与其他需要停止后才能看到结果的分析器不同,Counters 分析器在应用运行期间持续显示数据,让你可以实时观察应用的行为。这对于监控长期运行的操作、识别周期性的性能模式或验证优化效果特别有用。在分析会话开始时,你可以选择要监控的计数器类别,如 System.Runtime(GC 和内存计数器)、Microsoft.AspNetCore.Hosting(HTTP 请求计数器)、System.Net.Http(HTTP 客户端计数器)等。

启动 Counters 分析器后,你会看到一个实时更新的图表网格,每个计数器占据一个图表,横轴是时间,纵轴是计数器的值。你可以一边观察这些图表,一边操作应用,看看不同的操作如何影响这些计数器。例如,gc-heap-size 计数器显示托管堆的总大小,你会看到它呈锯齿状波动:当应用分配对象时堆大小增长,当 GC 回收内存时堆大小下降。如果堆大小持续增长而不回落,这可能表示存在内存泄漏。threadpool-thread-count 计数器显示线程池中的线程数量,如果这个数字不断增长,可能表示应用在创建大量长期运行的任务,消耗了过多的线程池资源。http-client-requests-duration 计数器显示 HTTP 客户端请求的平均耗时,如果这个值突然跳升,可能表示你调用的外部服务变慢了,或者网络出现了问题。

Instrumentation 分析器是最精确但也最昂贵的分析工具,它通过在函数入口和出口插入探针来记录每次函数调用的精确时间。与 CPU Usage 分析器的采样方式不同,Instrumentation 不会错过任何函数调用,它能给你提供完整的调用图和精确到纳秒级的时间测量。这种精确性的代价是显著的性能开销,被仪表化的应用可能会运行得比正常慢 10 到 100 倍,因此这个分析器通常只在开发环境中使用,并且只在需要极端精确的时间测量时才启用。启动 Instrumentation 分析器时,你需要指定哪些函数应该被仪表化。默认情况下,它可能会尝试仪表化所有用户代码,但这会产生巨大的开销和海量的数据。更好的做法是缩小范围,只仪表化你感兴趣的模块或命名空间。

Instrumentation 分析器生成的报告以调用树的形式呈现,显示了函数之间的调用关系和每个函数的独占时间(函数自身执行的时间,不包括调用子函数的时间)和包含时间(包括调用子函数的总时间)。通过比较独占时间和包含时间,你可以判断一个函数是自身很慢还是因为调用了慢的子函数而显得慢。例如,如果 ProcessOrder 函数的包含时间是 1 秒,但独占时间只有 10 毫秒,这说明它的大部分时间都花在了调用子函数上,你应该继续深入到这些子函数中查找真正的瓶颈。报告还提供了一个"热路径"视图,自动高亮显示从根函数到最耗时的叶子函数的路径,这是识别性能瓶颈的快速方法。

在使用这些性能分析工具时,有几个重要的技巧可以提高分析的效率和准确性。首先,尽量在真实或接近真实的负载下进行分析。在空闲状态下的性能问题可能与高负载下的完全不同,你想要发现的是在实际使用场景中出现的瓶颈。其次,重复测量多次并比较结果。性能数据可能有很大的变异性,特别是在虚拟化环境或共享资源的系统中,单次测量的结果可能不具代表性。通过多次测量并计算平均值或中位数,你可以得到更可靠的结论。第三,在优化前后都进行性能分析,量化优化的效果。不要凭感觉判断优化是否有效,让数据说话。你可能会惊讶地发现,某些你认为会显著提升性能的优化实际上影响很小,而一些看似微不足道的改变却带来了巨大的改进。

最后,将性能分析的结果与应用的可观测性数据结合起来。Performance Profiler 告诉你代码的哪些部分慢,而 Aspire Dashboard 的日志、指标和追踪告诉你这些慢代码是在什么业务上下文中被调用的,有多频繁,影响了哪些用户。通过关联这两个层次的信息,你可以做出更明智的优化决策,优先处理那些既慢又频繁被调用、对用户体验影响最大的代码路径。这种数据驱动的优化方法比盲目地优化所有看起来慢的代码更高效,也更能产生可衡量的业务价值。

4.4 数据库查询优化

数据库查询往往是分布式应用中最常见的性能瓶颈之一,即使是一个看起来简单的查询,如果执行频率很高或数据量很大,也可能成为系统的性能杀手。在 .NET Aspire 应用中,你有多种工具和技术来识别和优化慢查询,从 Entity Framework Core 的内置日志记录到数据库服务器的查询分析工具。掌握这些技术可以让你系统地发现查询性能问题,理解问题的根本原因,并实施有效的优化措施。理解数据库查询性能的本质是理解数据访问的成本,这个成本不仅包括数据库服务器执行查询所需的 CPU 时间和磁盘 I/O,还包括网络传输的延迟、应用程序处理结果集的开销,以及在高并发场景下的资源竞争。每一个查询都是一次资源的消耗,当这些查询累积到一定规模时,它们对系统整体性能的影响就变得显著而不可忽视。

识别慢查询的第一步是启用 Entity Framework Core 的 SQL 日志记录。默认情况下,EF Core 只在 Warning 级别以上记录数据库相关的日志,这意味着你看不到执行的 SQL 语句和它们的耗时。要查看这些信息,你需要在配置文件中调整日志级别。在 appsettings.json 或针对开发环境的 appsettings.Development.json 文件中,找到 Logging 部分并添加或修改 LogLevel 配置,将 Microsoft.EntityFrameworkCore.Database.Command 类别的级别设置为 Information。这个看似简单的配置改变会带来巨大的可见性提升,它让 EF Core 在每次执行 SQL 命令时记录一条详细的日志,包含完整的 SQL 语句、传递的参数值、命令类型、超时设置以及最关键的执行耗时。

例如,在你的日志输出中,你可能会看到类似这样的条目:Executed DbCommand (523ms) [Parameters=[@p0='12345' (DbType = String)], CommandType='Text', CommandTimeout='30'] SELECT [o].[Id], [o].[CustomerId], [o].[TotalAmount], [o].[Status] FROM [Orders] AS [o] WHERE [o].[CustomerId] = @p0。这条日志清楚地告诉你几个关键信息:首先是执行耗时 523 毫秒,这是一个绝对值,你可以用它来判断这个查询是否需要优化。一般来说,超过 100 毫秒的查询就值得关注,超过 1 秒的查询则几乎肯定存在严重的性能问题。其次是执行的 SQL 语句,你可以看到 EF Core 生成了什么样的 SQL,是否使用了合适的 JOIN、WHERE 条件、ORDER BY 等。第三是传递的参数,在这个例子中,@p0 参数的值是 '12345',这对于理解查询的上下文和重现问题很重要。命令类型通常是 Text(表示普通的 SQL 文本)或 StoredProcedure(表示调用存储过程),超时时间默认是 30 秒,超过这个时间查询会被取消并抛出异常。

在开发环境中,你可以更进一步配置 EF Core 将 SQL 日志直接输出到更方便查看的位置。通过在配置 DbContext 时使用 LogTo 方法,你可以将日志写入到控制台、调试输出窗口或任何你定义的目标。这个方法接受一个委托作为参数,每次 EF Core 需要记录日志时都会调用这个委托,传递日志消息作为参数。最简单的用法是 options.LogTo(Console.WriteLine, LogLevel.Information),这会将所有 Information 级别及以上的日志写到标准输出,在 Visual Studio 中运行时,这些日志会出现在输出窗口中,在控制台应用中则直接显示在终端。

但是直接将所有日志输出到控制台可能会产生大量的噪音,因为 EF Core 不仅记录 SQL 命令的执行,还会记录连接的打开和关闭、事务的开始和提交、模型验证、迁移检查等各种内部操作。为了只关注 SQL 执行,你可以使用 LogTo 的重载版本,它接受一个过滤器委托来决定哪些日志应该被记录。过滤器接收两个参数:日志类别(一个字符串,如 Microsoft.EntityFrameworkCore.Database.Command)和日志级别(如 LogLevel.Information),并返回一个布尔值表示是否应该记录这条日志。通过检查类别字符串,你可以精确控制哪些类型的日志被输出。

csharp 复制代码
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

public static class Extensions
{
    public static IServiceCollection AddDatabaseContext(
        this IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment environment)
    {
        var connectionString = configuration.GetConnectionString("DefaultConnection")
            ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");

        services.AddDbContext<AppDbContext>(options =>
        {
            options.UseSqlServer(connectionString, sqlOptions =>
            {
                sqlOptions.EnableRetryOnFailure(
                    maxRetryCount: 5,
                    maxRetryDelay: TimeSpan.FromSeconds(30),
                    errorNumbersToAdd: null);
                
                sqlOptions.CommandTimeout(30);
                
                sqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
            });

            if (environment.IsDevelopment())
            {
                options.EnableSensitiveDataLogging();
                
                options.EnableDetailedErrors();
                
                options.LogTo(
                    Console.WriteLine,
                    new[] { DbLoggerCategory.Database.Command.Name },
                    LogLevel.Information,
                    DbContextLoggerOptions.DefaultWithUtcTime | DbContextLoggerOptions.SingleLine);
            }
        });

        return services;
    }
}

这段代码展示了如何在注册 DbContext 时配置详细的 SQL 日志记录。首先从配置中读取数据库连接字符串,如果连接字符串不存在则抛出异常,因为没有连接字符串应用无法正常工作,早期失败比运行时才发现问题要好。然后调用 AddDbContext 方法注册 AppDbContext,传递一个配置委托来设置 DbContext 的选项。在配置委托中,首先调用 UseSqlServer 指定使用 SQL Server 数据库提供程序,这个方法接受连接字符串和一个可选的 SQL Server 特定配置委托。

在 SQL Server 配置中,EnableRetryOnFailure 方法配置了自动重试策略,当遇到瞬时错误(如网络中断、连接超时、死锁)时,EF Core 会自动重试操作。maxRetryCount 参数设置为 5,表示最多重试 5 次,如果 5 次都失败则抛出异常。maxRetryDelay 设置为 30 秒,表示两次重试之间的最大延迟时间。EF Core 使用指数退避策略,第一次重试可能在几秒后,第二次在更长时间后,以此类推,但不会超过这个最大延迟。errorNumbersToAdd 参数允许你指定额外的 SQL Server 错误代码,这些错误也应该触发重试。默认情况下,EF Core 已经知道哪些错误代码代表瞬时错误,对于大多数应用这已经足够,所以这里传递 null

CommandTimeout 方法设置了数据库命令的默认超时时间为 30 秒。这个超时是指从命令发送到数据库到接收到第一个字节的响应之间的最大等待时间。如果超过这个时间查询还没有完成,EF Core 会取消查询并抛出 TimeoutException。30 秒是一个保守的值,适合大多数 OLTP 场景,但对于复杂的报表查询或数据迁移操作,你可能需要更长的超时时间。注意这个超时时间不同于 HTTP 请求的超时时间,它只控制数据库操作本身的超时,不包括网络传输或其他开销。

MigrationsAssembly 方法指定了包含数据库迁移代码的程序集。EF Core 的迁移系统用于版本控制数据库架构,通过代码来管理表结构、索引、约束等的创建和修改。迁移代码通常与 DbContext 类在同一个项目中,但在某些架构中可能分离到独立的项目。通过显式指定迁移程序集,你可以确保 EF Core 在需要应用迁移时知道去哪里查找迁移类。

接下来的 if 块检查当前环境是否为开发环境,如果是,则启用一些只适合开发阶段的选项。EnableSensitiveDataLogging 方法让 EF Core 在日志中包含参数的实际值。默认情况下,为了安全考虑,EF Core 只记录参数类型而不记录值,因为值可能包含敏感信息如用户密码、信用卡号等。在开发环境中,查看参数值对于调试查询问题非常有帮助,你可以看到传递给查询的具体数据是什么,但在生产环境中应该禁用这个选项以防止敏感数据泄漏到日志中。

EnableDetailedErrors 方法启用详细的错误消息,当数据库操作失败时,EF Core 会在异常消息中包含更多的上下文信息,如失败的实体类型、属性名称、尝试执行的操作等。这些信息对于诊断问题非常有价值,但它们也可能暴露内部实现细节,所以通常只在开发环境中启用。在生产环境中,更安全的做法是记录详细的日志而不是在异常消息中包含敏感信息。

最关键的配置是 LogTo 方法的调用,它接受四个参数。第一个参数是日志的目标,这里使用 Console.WriteLine 将日志写到控制台。你也可以使用任何其他的 Action<string> 委托,比如写到文件、发送到远程日志服务等。第二个参数是一个字符串数组,指定要记录的日志类别。DbLoggerCategory.Database.Command.Name 是 EF Core 预定义的常量,表示数据库命令执行相关的日志类别。通过只包含这个类别,我们过滤掉了其他类别的日志,如连接管理、事务、模型构建等,让输出更聚焦于 SQL 执行。第三个参数是最低日志级别,设置为 LogLevel.Information 意味着 Information、Warning 和 Error 级别的日志都会被记录,但 Debug 和 Trace 级别的不会。第四个参数是日志选项,这是一个枚举标志,可以组合多个选项。DbContextLoggerOptions.DefaultWithUtcTime 表示使用默认的日志格式但时间戳使用 UTC 而不是本地时间,这在分布式系统中很重要,因为不同服务器可能在不同时区。DbContextLoggerOptions.SingleLine 表示将每条日志压缩成单行而不是多行,这让日志更紧凑,更容易用工具处理,但可读性稍差。这两个选项使用按位或操作符 | 组合。

配置完成后,当你运行应用并执行数据库操作时,控制台会实时显示 EF Core 执行的所有 SQL 命令及其耗时。这让你可以立即看到应用的数据访问模式,识别出异常的查询。例如,如果你看到同一个查询被执行了数百次,这可能表明存在 N+1 查询问题。如果你看到某个查询总是耗时几秒,这个查询显然需要优化。通过观察这些实时日志,你可以在开发阶段就发现和修复性能问题,而不是等到生产环境才暴露。

当你在日志中识别出一个慢查询后,下一步是分析这个查询为什么慢。最有效的工具是数据库服务器的查询执行计划。执行计划是数据库引擎为执行一个查询而选择的操作序列的详细描述,它就像一个查询的"配方",告诉你数据库是如何一步步获取、过滤、连接、排序和聚合数据的。每个步骤都有一个估计的成本,成本越高表示这个步骤越昂贵。通过分析执行计划,你可以识别出查询的瓶颈,比如全表扫描、缺失的索引、低效的连接算法、不必要的排序等。

不同的数据库系统有不同的工具来查看执行计划。在 SQL Server 中,你可以使用 SQL Server Management Studio,将查询粘贴到查询窗口中,点击工具栏上的"显示估计的执行计划"按钮或按 Ctrl+L,SSMS 会生成一个图形化的执行计划,以流程图的形式展示查询的各个操作。你也可以在查询前加上 SET SHOWPLAN_XML ON 命令,然后执行查询,这会返回 XML 格式的执行计划而不是实际执行查询。XML 格式的执行计划包含了更详细的信息,可以保存下来用于后续分析或与他人共享。PostgreSQL 使用 EXPLAIN 命令来查看执行计划,如 EXPLAIN SELECT * FROM orders WHERE customer_id = 12345,这会返回一个文本格式的执行计划。如果你想看到实际执行时的统计信息而不仅仅是估计,使用 EXPLAIN ANALYZE,这会实际执行查询并收集运行时数据。MySQL 也使用 EXPLAIN 命令,语法类似。

让我们通过一个具体的例子来演示如何分析和优化查询。假设你在 EF Core 的日志中看到了这样一条记录:Executed DbCommand (1523ms) [Parameters=[@p0='2024-01-01' (DbType = DateTime)], CommandType='Text', CommandTimeout='30'] SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate], [o].[TotalAmount], [o].[Status] FROM [Orders] AS [o] INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id] WHERE [o].[OrderDate] >= @p0 AND [c].[Country] = 'US'。这个查询花费了 1.5 秒,在任何标准下都属于非常慢的查询。查询的语义是找出所有在指定日期之后、客户国家为美国的订单。你将这个 SQL 语句复制到 SQL Server Management Studio 中,在查询前加上 SET SHOWPLAN_XML ON,点击执行。

SSMS 返回的 XML 执行计划可能很复杂,但你可以在 SSMS 中点击"显示执行计划"来查看图形化的版本。在图形化执行计划中,你会看到一系列连接的方框,每个方框代表一个操作,方框之间的箭头表示数据流的方向,箭头的粗细表示数据量的大小。仔细查看这个执行计划,你可能会看到以下结构:在最底部,数据库对 Orders 表执行了一个"索引扫描"操作,使用了 IX_Orders_OrderDate 索引,这个操作的估计成本是 20%,处理了大约 50000 行数据。索引扫描意味着数据库遍历了索引的所有或大部分条目来找到满足 OrderDate >= @p0 条件的订单。然后对于每个找到的订单,数据库执行了一个"嵌套循环连接"操作,在 Customers 表中通过 CustomerId 查找对应的客户记录。这个连接的内侧是一个"键查找"操作,通过主键索引 PK_Customers 来获取客户的完整记录,这个操作对每个订单都执行一次,总成本是 30%。最后,数据库对每个连接后的行执行一个"过滤"操作,检查 Country 列是否等于 'US',这个过滤的成本是 50%,而且它过滤掉了大量的行,只有约 5000 行最终满足条件。

分析这个执行计划,你会注意到一个明显的问题:数据库首先找出了所有满足日期条件的订单(50000 行),然后为每个订单查找客户信息,最后才应用国家过滤。这意味着数据库做了大量不必要的工作,查找和连接了 45000 个不是美国客户的订单,然后再把它们丢弃。理想情况下,数据库应该先在 Customers 表中找出所有美国客户,然后只查找这些客户的订单。但为什么数据库没有选择这个更优的顺序呢?原因可能是 Customers 表的 Country 列上没有索引,数据库认为全表扫描 Customers 表比索引扫描 Orders 表更昂贵,所以选择了当前的执行计划。

优化这个查询的关键在于帮助数据库更早地应用国家过滤,减少需要处理的数据量。在 Customers 表的 Country 列上创建一个索引可以让数据库高效地找出美国客户。更好的是创建一个包含 CountryId 列的复合索引,这样数据库可以直接从索引中获取所需的客户 ID,而不需要回表查找。在 SSMS 的对象资源管理器中,展开你的数据库,找到 Customers 表,右键点击"索引"文件夹,选择"新建索引" → "非聚集索引"。在索引设计器中,将 Country 添加为索引键列,将 Id 添加为包含列。包含列不是索引键的一部分,但它们的值会被存储在索引中,这样如果查询只需要这些列,数据库可以直接从索引中读取而不需要访问表本身,这被称为"覆盖索引"。为索引命名如 IX_Customers_Country_Id,点击确定创建索引。或者你可以使用 SQL 命令创建:

sql 复制代码
CREATE NONCLUSTERED INDEX IX_Customers_Country_Id 
ON Customers(Country) 
INCLUDE (Id)

创建索引后,重新在 SSMS 中生成查询的执行计划。你会看到执行计划发生了显著的变化。现在数据库首先在 Customers 表的新索引 IX_Customers_Country_Id 上执行一个"索引查找"操作,使用 Country = 'US' 条件,这个操作非常快,只返回约 5000 个美国客户的 ID,成本只有 5%。然后数据库使用这些客户 ID 在 Orders 表中进行"索引查找",通过 IX_Orders_CustomerId 索引(假设你已经在 CustomerId 列上创建了索引,这通常是外键索引的标准做法)来找到这些客户的订单,成本是 10%。最后应用日期过滤 OrderDate >= @p0,成本是 5%。整个查询的总成本降低到了大约 20%,实际执行时间从 1.5 秒降低到了约 200 毫秒,性能提升了 7 倍。

这个例子说明了索引在查询性能中的关键作用。索引就像书籍的目录,它让数据库可以快速找到满足特定条件的行,而不需要逐行扫描整个表。但索引不是万能的,它们也有成本。每个索引都占用额外的磁盘空间,并且在数据插入、更新、删除时需要维护,这会稍微降低写操作的性能。因此,你不应该在每个列上都创建索引,而应该根据实际的查询模式有选择地创建。一个好的经验法则是:在经常出现在 WHERE 子句、JOIN 条件、ORDER BY 子句中的列上创建索引。对于外键列,几乎总是应该有索引,因为连接操作非常频繁。对于唯一约束和主键,数据库会自动创建唯一索引。定期审查数据库的索引使用情况,删除那些从未被使用的索引,它们只是在浪费空间和降低写性能。

除了缺失索引导致的慢查询,另一类常见的性能问题是 N+1 查询问题。这个问题在使用对象关系映射(ORM)工具如 Entity Framework Core 时特别容易出现,因为 ORM 的便利性可能会掩盖底层的数据库访问模式。N+1 问题的典型场景是这样的:你首先执行一个查询获取 N 条主记录(如 N 个订单),然后在处理这些记录时,对每条记录执行一个额外的查询来获取其关联数据(如每个订单的客户信息、订单项列表等),总共产生了 N+1 个数据库往返。当 N 很大时,比如 100 或 1000,这会导致灾难性的性能问题。

想象这样一个场景:你需要在管理页面显示最近的 100 个订单及其客户信息。在代码中,你可能会这样写:

csharp 复制代码
public async Task<IEnumerable<OrderViewModel>> GetRecentOrdersAsync()
{
    using var context = new AppDbContext();
    
    var orders = await context.Orders
        .OrderByDescending(o => o.OrderDate)
        .Take(100)
        .ToListAsync();
    
    var result = new List<OrderViewModel>();
    foreach (var order in orders)
    {
        result.Add(new OrderViewModel
        {
            OrderId = order.Id,
            OrderDate = order.OrderDate,
            TotalAmount = order.TotalAmount,
            CustomerName = order.Customer.Name,
            CustomerEmail = order.Customer.Email
        });
    }
    
    return result;
}

这段代码看起来很直观,首先查询最近的 100 个订单,然后遍历订单列表,对每个订单访问其 Customer 属性来获取客户名称和邮箱。但问题在于,当你第一次访问 order.Customer 时,如果你没有提前告诉 EF Core 加载客户数据,EF Core 会发现 Customer 属性是 null,然后自动执行一个查询来加载它,这被称为"延迟加载"或"懒加载"。这个额外的查询会在循环的每次迭代中执行一次,总共产生 100 个查询。加上初始的订单查询,你总共执行了 101 个数据库往返。

在 EF Core 的日志中,你会看到类似这样的模式:

shell 复制代码
Executed DbCommand (12ms) SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate], [o].[TotalAmount] FROM [Orders] AS [o] ORDER BY [o].[OrderDate] DESC OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY
Executed DbCommand (5ms) SELECT [c].[Id], [c].[Name], [c].[Email] FROM [Customers] AS [c] WHERE [c].[Id] = @p0
Executed DbCommand (4ms) SELECT [c].[Id], [c].[Name], [c].[Email] FROM [Customers] AS [c] WHERE [c].[Id] = @p0
Executed DbCommand (5ms) SELECT [c].[Id], [c].[Name], [c].[Email] FROM [Customers] AS [c] WHERE [c].[Id] = @p0
... (重复 97 次)

虽然每个单独的客户查询很快,只需要几毫秒,但 100 个查询的累积耗时可能达到几百毫秒甚至更长,特别是如果数据库服务器不在本地,网络延迟会让每个往返的成本显著增加。更糟的是,如果你的订单实体还有其他关联属性,如订单项列表,每访问一次这个属性都会触发更多的查询,形成嵌套的 N+1 问题。

解决 N+1 问题的标准方法是使用 EF Core 的显式预加载(Eager Loading)功能。通过 Include 方法,你可以告诉 EF Core 在执行主查询时就加载关联数据,而不是等到访问时才加载。修改后的代码如下:

csharp 复制代码
public async Task<IEnumerable<OrderViewModel>> GetRecentOrdersAsync()
{
    using var context = new AppDbContext();
    
    var orders = await context.Orders
        .Include(o => o.Customer)
        .OrderByDescending(o => o.OrderDate)
        .Take(100)
        .ToListAsync();
    
    var result = new List<OrderViewModel>();
    foreach (var order in orders)
    {
        result.Add(new OrderViewModel
        {
            OrderId = order.Id,
            OrderDate = order.OrderDate,
            TotalAmount = order.TotalAmount,
            CustomerName = order.Customer.Name,
            CustomerEmail = order.Customer.Email
        });
    }
    
    return result;
}

唯一的改变是添加了 .Include(o => o.Customer) 这一行。这个简单的改变会让 EF Core 生成一个包含 JOIN 的 SQL 查询,一次性获取订单和客户数据:

sql 复制代码
SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate], [o].[TotalAmount], 
       [c].[Id], [c].[Name], [c].[Email], [c].[Country], [c].[City]
FROM [Orders] AS [o]
INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
ORDER BY [o].[OrderDate] DESC
OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY

现在你只有一个数据库往返,执行时间可能只有几十毫秒,比之前的 101 个往返快了一个数量级。在日志中,你只会看到一条 Executed DbCommand 记录,而不是 101 条。当你的应用需要处理成百上千的记录时,这种性能差异会变得更加显著。

使用 Include 时需要注意几点。首先,你可以链式调用多个 Include 来预加载多个关联实体,如 .Include(o => o.Customer).Include(o => o.OrderItems)。其次,你可以使用 ThenInclude 来预加载关联的关联,形成多层的预加载,如 .Include(o => o.OrderItems).ThenInclude(i => i.Product),这会加载订单的订单项以及每个订单项的产品信息。但要小心不要过度预加载,加载太多不需要的数据会增加网络传输和内存消耗。只预加载那些你确实会在业务逻辑中访问的关联实体。如果你只需要关联实体的一两个属性,考虑使用投影(Select)而不是 Include,投影可以精确控制返回哪些列,进一步减少数据传输量。

另一个相关的技术是分割查询(Split Queries)。当你使用 Include 预加载多个集合导航属性(如订单的订单项集合和评论集合)时,EF Core 默认会生成一个包含多个 LEFT JOIN 的单一查询。如果集合很大,这个查询返回的结果集会包含大量重复的数据(因为每个订单行会与其所有订单项和评论进行笛卡尔积),导致网络传输和内存消耗急剧增加。分割查询通过将一个大查询拆分成多个小查询来解决这个问题,每个集合导航属性使用一个单独的查询。你可以通过调用 AsSplitQuery() 方法来启用分割查询:

csharp 复制代码
var orders = await context.Orders
    .Include(o => o.OrderItems)
    .Include(o => o.Reviews)
    .AsSplitQuery()
    .ToListAsync();

这会生成三个查询:一个查询订单,一个查询所有这些订单的订单项,一个查询所有这些订单的评论。虽然数据库往返增加了,但每个查询返回的数据量减少了,总的数据传输量和内存使用量可能更低。是否使用分割查询取决于具体场景,你需要测量并比较两种方式的性能。

对于更复杂的查询优化场景,你可能需要绕过 EF Core 的 LINQ 查询生成器,直接编写原始 SQL。虽然 LINQ 查询非常方便和类型安全,但对于某些高级的 SQL 特性,如窗口函数、公共表表达式(CTE)、递归查询、复杂的聚合等,LINQ 可能无法表达或生成的 SQL 不够优化。在这些情况下,手写 SQL 并使用 EF Core 的 FromSqlRawFromSqlInterpolated 方法执行可以获得更好的性能和更精确的控制。

假设你需要实现一个复杂的报表查询:计算每个客户在最近 30 天内的订单总金额和订单数量,按金额降序排列,只返回前 100 名客户。用 LINQ 表达这个查询可能很笨拙,而且生成的 SQL 可能不够高效。你可以改为编写一个优化的 SQL 查询:

csharp 复制代码
public async Task<List<CustomerReportDto>> GetTopCustomersReportAsync(DateTime startDate)
{
    using var context = new AppDbContext();
    
    var sql = @"
        SELECT TOP 100
            c.Id AS CustomerId,
            c.Name AS CustomerName,
            c.Email AS CustomerEmail,
            COUNT(o.Id) AS OrderCount,
            COALESCE(SUM(o.TotalAmount), 0) AS TotalAmount
        FROM Customers c
        LEFT JOIN Orders o ON c.Id = o.CustomerId 
            AND o.OrderDate >= @startDate
        GROUP BY c.Id, c.Name, c.Email
        HAVING COUNT(o.Id) > 0
        ORDER BY TotalAmount DESC";
    
    var result = await context.Database
        .SqlQueryRaw<CustomerReportDto>(sql, new SqlParameter("@startDate", startDate))
        .ToListAsync();
    
    return result;
}

public class CustomerReportDto
{
    public int CustomerId { get; set; }
    public string CustomerName { get; set; } = string.Empty;
    public string CustomerEmail { get; set; } = string.Empty;
    public int OrderCount { get; set; }
    public decimal TotalAmount { get; set; }
}

这段代码使用 SqlQueryRaw 方法执行原始 SQL 查询。SQL 字符串定义了查询逻辑:从 Customers 表和 Orders 表进行左连接,按客户分组,计算每个客户的订单数量和总金额,过滤掉没有订单的客户,按总金额降序排列,只返回前 100 条。查询使用了参数化的方式传递 startDate,避免 SQL 注入风险。SqlParameter 对象指定了参数名称和值,EF Core 会自动处理参数类型转换和格式化。

SqlQueryRaw 方法返回一个 IQueryable,你可以继续在其上应用 LINQ 操作如 WhereOrderByTake 等,但这些操作会在客户端(内存中)执行而不是在数据库中,所以要小心不要在已经返回大量数据的查询上进行复杂的客户端过滤。对于这个例子,所有的逻辑都在 SQL 中完成,客户端只需要调用 ToListAsync() 来物化结果。

查询结果被映射到 CustomerReportDto 类。这个类是一个简单的 DTO(数据传输对象),它的属性名称和类型与 SQL 查询返回的列匹配。EF Core 会自动根据列名和属性名的匹配来填充对象。如果列名和属性名不完全一致,你可以在 SQL 中使用别名(AS)来调整。注意 DTO 类不需要是 EF Core 的实体类型,它可以是任何普通的 C# 类,这给了你更大的灵活性来定义查询的返回结构。

使用原始 SQL 的缺点是失去了 LINQ 的类型安全和重构友好性。如果你修改了表结构或列名,编译器不会告诉你 SQL 字符串中的引用已经失效,你只会在运行时遇到错误。因此,原始 SQL 应该作为最后的手段,只在 LINQ 确实无法满足需求时才使用。为了减轻维护负担,考虑将复杂的查询封装成存储过程或数据库视图,然后通过 EF Core 调用。存储过程可以被版本控制和测试,视图可以被像表一样映射到实体类型,这提供了原始 SQL 的性能优势和 ORM 的便利性之间的平衡。

查询性能优化的另一个重要技术是缓存。如果某个查询的结果在短时间内不会改变,你可以将第一次查询的结果缓存起来,后续的请求直接从缓存中读取而不是重新查询数据库。这可以显著减少数据库负载和响应时间,特别是对于那些计算复杂但结果相对静态的查询。.NET 提供了 IMemoryCache 接口来支持内存缓存,以及 IDistributedCache 接口来支持分布式缓存如 Redis。

内存缓存适合单实例应用或不需要跨实例共享的缓存数据。它的优点是非常快,缓存命中只需要纳秒级的时间,因为数据存储在应用进程的内存中。缺点是缓存的容量受限于应用的内存大小,并且在应用重启或扩展到多个实例时缓存会丢失。分布式缓存通过将缓存数据存储在外部系统(如 Redis、Memcached)中来解决这些问题,它支持更大的容量和跨实例共享,但访问速度稍慢,因为涉及网络往返。选择哪种缓存取决于你的需求:如果缓存的数据量小(几 MB)且只在单个实例中使用,内存缓存更简单快速;如果数据量大或需要在多个实例间共享,分布式缓存更合适。

下面是一个集成查询缓存的完整示例:

csharp 复制代码
using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;

public class OrderService
{
    private readonly AppDbContext _context;
    private readonly IMemoryCache _cache;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        AppDbContext context,
        IMemoryCache cache,
        ILogger<OrderService> logger)
    {
        _context = context;
        _cache = cache;
        _logger = logger;
    }

    public async Task<List<OrderSummaryDto>> GetCustomerOrdersAsync(
        int customerId,
        DateTime? startDate = null)
    {
        var cacheKey = $"customer_orders_{customerId}_{startDate?.ToString("yyyyMMdd") ?? "all"}";
        
        if (_cache.TryGetValue(cacheKey, out List<OrderSummaryDto>? cachedOrders))
        {
            _logger.LogDebug(
                "Cache hit for customer orders: CustomerId={CustomerId}, CacheKey={CacheKey}",
                customerId, cacheKey);
            return cachedOrders!;
        }
        
        _logger.LogDebug(
            "Cache miss for customer orders: CustomerId={CustomerId}, CacheKey={CacheKey}",
            customerId, cacheKey);
        
        var query = _context.Orders
            .Where(o => o.CustomerId == customerId);
        
        if (startDate.HasValue)
        {
            query = query.Where(o => o.OrderDate >= startDate.Value);
        }
        
        var orders = await query
            .Select(o => new OrderSummaryDto
            {
                OrderId = o.Id,
                OrderDate = o.OrderDate,
                TotalAmount = o.TotalAmount,
                Status = o.Status.ToString(),
                ItemCount = o.OrderItems.Count
            })
            .OrderByDescending(o => o.OrderDate)
            .ToListAsync();
        
        var cacheOptions = new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
            SlidingExpiration = TimeSpan.FromMinutes(2),
            Size = 1
        };
        
        _cache.Set(cacheKey, orders, cacheOptions);
        
        _logger.LogInformation(
            "Cached customer orders: CustomerId={CustomerId}, CacheKey={CacheKey}, OrderCount={OrderCount}",
            customerId, cacheKey, orders.Count);
        
        return orders;
    }
}

public class OrderSummaryDto
{
    public int OrderId { get; set; }
    public DateTime OrderDate { get; set; }
    public decimal TotalAmount { get; set; }
    public string Status { get; set; } = string.Empty;
    public int ItemCount { get; set; }
}

这个服务类的 GetCustomerOrdersAsync 方法实现了一个完整的缓存包装模式。方法首先构造一个缓存键,这个键需要唯一标识这个查询的参数组合。在这个例子中,缓存键包含客户 ID 和开始日期,如果开始日期为空则使用 "all" 作为标识。缓存键的构造很重要,它必须能够区分不同的查询参数,否则会导致缓存数据错配。例如,如果你只使用客户 ID 作为缓存键,那么查询同一客户不同日期范围的订单会错误地返回缓存的结果。

构造好缓存键后,方法调用 _cache.TryGetValue 尝试从缓存中获取数据。这个方法接受缓存键和一个 out 参数,如果缓存命中(键存在),方法返回 true 并将缓存的值赋给 out 参数,否则返回 false。如果缓存命中,方法记录一条 Debug 级别的日志(在生产环境中这个级别的日志通常不会输出,但在开发和排查问题时很有用),然后直接返回缓存的数据,完全绕过了数据库查询。

如果缓存未命中,方法记录一条日志表明需要查询数据库,然后构造 EF Core 查询。查询使用 Select 方法投影到 OrderSummaryDto,只选择需要的列而不是加载完整的 Order 实体。这是一个最佳实践,特别是在查询结果会被缓存时,你不想缓存不必要的数据。投影还可以避免 EF Core 的变更追踪开销,因为投影的结果是不被追踪的普通对象。

查询执行后,方法创建一个 MemoryCacheEntryOptions 对象来配置缓存项的行为。AbsoluteExpirationRelativeToNow 设置了绝对过期时间,在这个例子中是 5 分钟,意味着无论缓存项是否被访问,5 分钟后它都会过期。SlidingExpiration 设置了滑动过期时间,在这个例子中是 2 分钟,意味着如果缓存项在 2 分钟内被访问了,过期时间会重置,但滑动过期不会超过绝对过期时间。Size 属性用于控制缓存的内存使用,每个缓存项可以被赋予一个大小值,然后你可以在配置 MemoryCache 时设置总大小限制,当达到限制时,最少使用的缓存项会被驱逐。在这个简化的例子中,所有项的大小都设置为 1,你可以根据实际的数据大小进行更精确的设置。

配置好缓存选项后,方法调用 _cache.Set 将查询结果存入缓存,关联缓存键和选项。最后记录一条 Information 级别的日志,包含缓存的订单数量,这对于监控缓存的使用情况和效果很有帮助。

使用这个缓存包装后,第一次调用 GetCustomerOrdersAsync 会查询数据库并缓存结果。后续 5 分钟内对相同参数的调用会直接从缓存返回,响应时间从几十毫秒降低到几微秒,数据库完全不会被访问。如果在 2 分钟内缓存项被再次访问,滑动过期会重置,缓存项会继续存活最多到绝对过期时间(5 分钟)。这种缓存策略适合那些读取频繁但更新不频繁的数据。

缓存失效是使用缓存时必须考虑的问题。当底层数据发生变化时,缓存需要被更新或清除,否则用户会看到过时的数据。在这个订单查询的例子中,如果客户创建了新订单或修改了现有订单,缓存的订单列表就不再准确。最简单的失效策略是依赖过期时间,等待缓存自动过期。这种策略简单但可能导致数据在一段时间内不一致。更积极的策略是在数据变化时主动使缓存失效,比如在创建订单的方法中,除了向数据库插入订单,还要调用 _cache.Remove(cacheKey) 来删除相关的缓存项。但这要求你准确知道哪些缓存项需要失效,对于复杂的缓存依赖关系,这可能很困难。

对于分布式缓存,基本的模式是类似的,但使用 IDistributedCache 接口而不是 IMemoryCache。主要的区别在于分布式缓存存储的是字节数组,你需要手动序列化和反序列化对象。通常使用 JSON 序列化,因为它简单且支持复杂的对象图。下面是使用 Redis 分布式缓存的示例:

csharp 复制代码
using Microsoft.Extensions.Caching.Distributed;
using System.Text;
using System.Text.Json;

public class OrderService
{
    private readonly AppDbContext _context;
    private readonly IDistributedCache _cache;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        AppDbContext context,
        IDistributedCache cache,
        ILogger<OrderService> logger)
    {
        _context = context;
        _cache = cache;
        _logger = logger;
    }

    public async Task<List<OrderSummaryDto>> GetCustomerOrdersAsync(
        int customerId,
        DateTime? startDate = null,
        CancellationToken cancellationToken = default)
    {
        var cacheKey = $"customer_orders_{customerId}_{startDate?.ToString("yyyyMMdd") ?? "all"}";
        
        var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken);
        if (cachedData != null)
        {
            _logger.LogDebug(
                "Distributed cache hit: CacheKey={CacheKey}",
                cacheKey);
            return JsonSerializer.Deserialize<List<OrderSummaryDto>>(cachedData)
                ?? new List<OrderSummaryDto>();
        }
        
        _logger.LogDebug(
            "Distributed cache miss: CacheKey={CacheKey}",
            cacheKey);
        
        var query = _context.Orders
            .Where(o => o.CustomerId == customerId);
        
        if (startDate.HasValue)
        {
            query = query.Where(o => o.OrderDate >= startDate.Value);
        }
        
        var orders = await query
            .Select(o => new OrderSummaryDto
            {
                OrderId = o.Id,
                OrderDate = o.OrderDate,
                TotalAmount = o.TotalAmount,
                Status = o.Status.ToString(),
                ItemCount = o.OrderItems.Count
            })
            .OrderByDescending(o => o.OrderDate)
            .ToListAsync(cancellationToken);
        
        var serializedData = JsonSerializer.Serialize(orders);
        var cacheOptions = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
            SlidingExpiration = TimeSpan.FromMinutes(3)
        };
        
        await _cache.SetStringAsync(cacheKey, serializedData, cacheOptions, cancellationToken);
        
        _logger.LogInformation(
            "Data cached in distributed cache: CacheKey={CacheKey}, OrderCount={OrderCount}",
            cacheKey, orders.Count);
        
        return orders;
    }
}

这个版本使用 IDistributedCache 接口,它的方法都是异步的并支持取消令牌。GetStringAsync 方法从缓存中读取字符串,如果键不存在返回 null。如果缓存命中,使用 JsonSerializer.Deserialize 将 JSON 字符串反序列化为对象列表。如果缓存未命中,执行数据库查询后,使用 JsonSerializer.Serialize 将结果序列化为 JSON 字符串,然后通过 SetStringAsync 存入缓存。DistributedCacheEntryOptions 的配置与 MemoryCacheEntryOptions 类似,但没有 Size 属性,因为分布式缓存的容量管理由外部系统(如 Redis)处理。

要在 Aspire 应用中启用 Redis 分布式缓存,你需要在 AppHost 中添加 Redis 资源,并在服务项目中引用它:

csharp 复制代码
// AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

var redis = builder.AddRedis("cache");

var api = builder.AddProject<Projects.Api>("api")
    .WithReference(redis);

builder.Build().Run();

然后在服务项目的配置中注册分布式缓存:

csharp 复制代码
// Api/Program.cs
builder.AddRedisClient("cache");

builder.Services.AddStackExchangeRedisCache(options =>
{
    var redisConnection = builder.Configuration.GetConnectionString("cache")
        ?? throw new InvalidOperationException("Redis connection string not found");
    options.Configuration = redisConnection;
});

通过结合查询优化、索引设计、预加载策略和缓存技术,你可以显著提升应用的数据访问性能。查询优化不是一次性的工作,而是一个持续的过程。随着数据量的增长、业务逻辑的变化和用户行为模式的演变,新的性能问题会不断出现。建立定期的性能审查流程,在每次迭代或发布前运行性能测试,监控生产环境的数据库性能指标(如慢查询日志、缓存命中率、连接池使用率),及时识别和解决性能回归。将性能视为一个关键的质量指标而不是事后的补救措施,在设计和编码阶段就考虑性能影响,这些实践可以确保你的应用长期保持高性能和良好的用户体验。通过 Aspire Dashboard 的可观测性数据、EF Core 的详细日志、数据库的执行计划分析以及系统化的优化方法,你拥有了一套完整的工具链来诊断和解决数据库性能问题,让数据访问从潜在的瓶颈变成应用的竞争优势。

五、网络问题排查

5.1 连接问题诊断

在分布式应用中,网络问题是最常见也最难以诊断的故障类型之一。不同于单体应用内部的函数调用,分布式系统中的服务通信需要跨越网络边界,涉及 DNS 解析、TCP 连接建立、TLS 握手、HTTP 协议交互等多个层次。任何一个环节出现问题都可能导致请求失败,而问题的表现形式往往是模糊的异常消息或超时错误。深入理解网络通信的各个层次以及如何系统地诊断这些问题,是构建健壮的分布式应用的关键技能。Aspire 虽然简化了服务间通信的配置,但当问题发生时,你仍然需要掌握底层的诊断技术来快速定位和解决问题。

网络连接问题的第一个常见症状是 DNS 解析失败,通常表现为 System.Net.Sockets.SocketException 异常,错误消息是 No such host is knownName or service not known。这个错误发生在 .NET 运行时尝试将服务名称转换为 IP 地址时,DNS 服务器无法找到对应的记录。在 Aspire 应用中,这个问题可能有几个原因。最常见的是服务名称拼写错误,例如你在 AppHost 中定义的服务名称是 api,但在 HTTP 客户端配置中使用了 apiservice,这个名称不存在于 Aspire 的服务发现系统中,因此无法解析。检查这类问题的第一步是对比 AppHost 中的服务定义和客户端配置中使用的服务名称,确保它们完全一致,包括大小写。

另一个可能导致 DNS 解析失败的原因是网络隔离或 DNS 配置问题。在容器化环境中,Aspire 的服务发现依赖于 Docker 网络的 DNS 功能,每个容器都有一个与其服务名称对应的 DNS 记录。如果容器不在同一个 Docker 网络中,或者网络配置不正确,DNS 解析会失败。你可以通过进入一个容器并尝试手动解析服务名来验证这一点。使用 docker exec 命令在容器中执行交互式 Shell,例如 docker exec -it aspire-api-1 /bin/bash(容器名称可以通过 docker ps 查看)。在 Shell 中,尝试使用 nslookupdig 命令解析服务名,如 nslookup api。如果解析成功,你会看到返回的 IP 地址;如果失败,错误消息会提示 DNS 服务器无法找到该名称。这个手动验证可以帮助你确定是代码中的配置问题还是基础设施层面的网络问题。

在 Aspire 的开发环境中,服务通常使用类似 http://apihttps+http://api 这样的 URL 方案。这些 URL 方案是 Aspire 特有的,它们告诉 HTTP 客户端优先尝试哪种协议。https+http://api 表示优先尝试 HTTPS 连接,如果失败则回退到 HTTP。这种灵活性在开发和测试环境中很有用,因为你可能在某些时候启用 HTTPS,而在其他时候只使用 HTTP。但在生产环境中,你应该明确指定协议,避免不必要的回退尝试。如果你的服务只支持 HTTPS,使用 https://api;如果只支持 HTTP,使用 http://api。混合使用可能会导致意外的连接失败或性能问题。

连接超时是另一类常见的网络问题,通常表现为 TaskCanceledExceptionOperationCanceledException 异常,错误消息类似于 A task was canceledThe request was canceled due to the configured HttpClient.Timeout。这个错误发生在 HTTP 请求在规定的时间内没有完成时,可能是因为远程服务响应慢、网络延迟高、或者超时配置得太短。理解 .NET HTTP 客户端的超时机制对于诊断这类问题至关重要。HttpClient 有一个 Timeout 属性,它控制整个请求的最大耗时,包括 DNS 解析、连接建立、发送请求、等待响应和读取响应体。默认的超时时间是 100 秒,这对于大多数 API 调用来说已经足够,但对于某些特殊场景(如文件上传、长轮询、流式响应)可能不够。

当你遇到超时错误时,首先要确定超时是合理的还是配置不当。如果你的服务确实需要较长的处理时间,比如一个复杂的报表生成需要 30 秒,那么客户端的超时时间应该设置得比这个时间稍长,比如 45 秒,留出一些缓冲。但如果你的 API 通常在几百毫秒内响应,却因为某些原因偶尔超时,那么问题可能出在服务端的性能或资源争用上,你应该使用前面章节介绍的性能分析工具来诊断。在配置 HTTP 客户端时,可以在 ConfigureHttpClient 方法中设置超时时间。这个方法接受一个委托,委托的参数是 HttpClient 实例,你可以在其中设置各种属性。

csharp 复制代码
builder.Services.AddHttpClient<ApiClient>(client =>
{
    client.BaseAddress = new Uri("https+http://api");
    client.Timeout = TimeSpan.FromSeconds(30);
    client.DefaultRequestHeaders.Add("User-Agent", "AspireApp/1.0");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

这段配置代码为 ApiClient 类型注册了一个类型化的 HTTP 客户端。AddHttpClient<ApiClient> 方法是一个泛型方法,它不仅注册了 HttpClient,还注册了 ApiClient 类本身,这样你就可以在构造函数中注入 ApiClient 而不是 HttpClient,获得更强的类型安全性和更清晰的依赖关系。在配置委托中,首先设置了 BaseAddress 属性,这是所有请求的基础 URL。当你调用 client.GetAsync("/orders/123") 时,实际的 URL 是 BaseAddress 加上相对路径,即 https+http://api/orders/123。使用 BaseAddress 可以避免在每个请求中重复写完整的 URL,也让代码更容易适应不同的环境。

Timeout 属性被设置为 30 秒,这个超时适用于通过这个客户端发起的所有请求。如果某个特定的请求需要不同的超时时间,你可以在调用 HTTP 方法时传递一个 CancellationToken,并使用 CancellationTokenSource 来控制超时。例如,创建一个 CancellationTokenSource,设置其 CancelAfter 时间为你想要的超时时间,然后将 Token 传递给 HTTP 方法。这种方式提供了更细粒度的超时控制,但会使代码稍微复杂一些。对于大多数场景,在客户端级别设置统一的超时时间已经足够。

DefaultRequestHeaders 集合允许你添加会自动附加到所有请求的 HTTP 头。在这个例子中,我们添加了 User-Agent 头,标识客户端应用的名称和版本,这在与第三方 API 集成时很重要,因为许多 API 提供商要求或建议设置 User-Agent 以便追踪和支持。我们还添加了 Accept 头,指示客户端期望的响应内容类型是 JSON。虽然很多现代 API 会默认返回 JSON,但显式声明期望的内容类型是一个好习惯,可以避免内容协商的歧义。需要注意的是,DefaultRequestHeaders 中的头是不可变的,一旦添加就会应用到所有请求。如果你需要为特定请求添加或修改头,应该在调用 HTTP 方法时使用 HttpRequestMessage 对象并设置其 Headers 属性。

仅仅设置超时时间还不足以构建健壮的网络通信,因为网络是不可靠的,瞬时故障(如网络抖动、服务器临时过载、DNS 缓存过期)随时可能发生。重试策略是应对这些瞬时故障的标准做法,它让客户端在请求失败时自动重试几次,而不是立即向上层抛出异常。.NET 生态系统中最流行的重试库是 Polly,它提供了丰富的策略模式,包括重试、断路器、超时、舱壁隔离等。Polly 与 HttpClient 的集成非常简洁,通过 Microsoft.Extensions.Http.Polly NuGet 包,你可以使用 AddPolicyHandler 方法将 Polly 策略附加到 HTTP 客户端。

要使用 Polly,首先需要安装 NuGet 包。在项目文件中添加 <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.0" />(版本号根据你的 .NET 版本调整)。然后在配置 HTTP 客户端时,调用 AddPolicyHandler 方法并传递一个重试策略。策略通常定义为一个静态方法,这样可以在不同的 HTTP 客户端之间重用。

csharp 复制代码
using Polly;
using Polly.Extensions.Http;

builder.Services.AddHttpClient<ApiClient>(client =>
{
    client.BaseAddress = new Uri("https+http://api");
    client.Timeout = TimeSpan.FromSeconds(30);
})
.AddPolicyHandler(GetRetryPolicy());

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(response => response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
            onRetry: (outcome, timespan, retryAttempt, context) =>
            {
                var logger = context.GetLogger();
                if (outcome.Exception != null)
                {
                    logger.LogWarning(
                        outcome.Exception,
                        "Request failed with exception, retry attempt {RetryAttempt} after {Delay}s",
                        retryAttempt,
                        timespan.TotalSeconds);
                }
                else
                {
                    logger.LogWarning(
                        "Request failed with status {StatusCode}, retry attempt {RetryAttempt} after {Delay}s",
                        outcome.Result.StatusCode,
                        retryAttempt,
                        timespan.TotalSeconds);
                }
            });
}

这段代码定义了一个完整的重试策略,让我们逐步分析它的每个部分。HttpPolicyExtensions.HandleTransientHttpError() 是 Polly 为 HTTP 场景提供的一个便利方法,它配置策略来处理瞬时 HTTP 错误。具体来说,它会捕获以下两类错误:一是网络故障,如 HttpRequestException(包括连接失败、DNS 解析失败、连接重置等);二是 HTTP 5xx 状态码(500 Internal Server Error、502 Bad Gateway、503 Service Unavailable、504 Gateway Timeout),这些状态码通常表示服务器端的临时问题,重试可能会成功。这个方法返回一个策略构建器,你可以在其上链式调用其他方法来进一步配置策略。

OrResult(response => response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) 扩展了策略,让它也处理 429 Too Many Requests 状态码。这个状态码表示客户端发送的请求超过了服务器的速率限制,通常是一个临时的限制,稍后重试可能会成功。通过添加这个条件,我们的重试策略可以自动处理速率限制场景,而不需要在应用代码中显式检查和处理 429 响应。你可以通过类似的方式添加对其他状态码的处理,比如 408 Request Timeout 或特定的自定义状态码。

WaitAndRetryAsync 方法配置了重试的核心行为。第一个参数 retryCount: 3 指定了最大重试次数,这意味着如果请求失败,Polly 会尝试重试最多 3 次,加上初始的尝试,总共会有 4 次请求机会。如果 4 次都失败,最后一次的异常或响应会被抛出或返回给调用者。选择合适的重试次数是一个权衡,太少可能无法应对持续时间稍长的瞬时故障,太多会延长失败请求的总耗时并增加服务器负载。3 次是一个常用的默认值,对于大多数场景都是合理的。

第二个参数 sleepDurationProvider 是一个委托,它接收当前的重试次数(从 1 开始)并返回重试前的等待时间。在这个例子中,我们使用指数退避策略,等待时间随重试次数呈指数增长。计算公式是 2^retryAttempt 秒,第一次重试前等待 2 秒,第二次前等待 4 秒,第三次前等待 8 秒。指数退避是处理瞬时故障的最佳实践,它的优点是在故障持续时间较短时可以快速恢复,而在故障持续时间较长时避免过度重试加剧服务器负载。相比固定延迟(如每次都等待 5 秒),指数退避在快速恢复和降低负载之间取得了更好的平衡。Math.Pow(2, retryAttempt) 计算指数值,返回的是 double 类型,TimeSpan.FromSeconds 方法接受它并转换为 TimeSpan

第三个参数 onRetry 是一个可选的回调函数,在每次重试前被调用。这个回调接收四个参数:outcome 表示失败的结果,可能是异常(outcome.Exception)或响应(outcome.Result);timespan 是即将等待的时间;retryAttempt 是当前的重试次数;context 是 Polly 的执行上下文,可以用来存储和传递额外的数据。在回调中,我们使用 context.GetLogger() 获取日志记录器(这个扩展方法需要你添加 Polly 的日志集成包),然后根据失败的类型记录不同的警告日志。如果失败是由异常引起的,记录异常的详细信息;如果是由不满意的响应引起的(如 429 或 5xx),记录响应的状态码。这些日志对于理解重试行为和诊断持续失败的原因非常有价值,你可以在 Aspire Dashboard 的 Logs 页面看到这些重试日志,追踪请求的重试历史。

将这个重试策略附加到 HTTP 客户端后,所有通过这个客户端发起的请求都会自动应用重试逻辑。当请求因网络故障、5xx 错误或 429 状态码失败时,Polly 会自动捕获错误,等待指定的时间,然后重新发起请求。这一切对于应用代码是透明的,你不需要在每个 HTTP 调用周围添加 try-catch 和重试循环,重试逻辑被优雅地封装在 HTTP 客户端管道中。如果所有重试都失败了,最后一次的异常或响应会像正常情况一样传递给调用者,你的错误处理逻辑不需要改变。

除了重试,断路器(Circuit Breaker)是另一个重要的弹性模式。断路器的作用是在检测到远程服务持续失败时"打开电路",暂时停止向该服务发送请求,给它时间恢复,避免雪崩效应。当断路器处于打开状态时,后续的请求会立即失败而不会真正发送到远程服务,这可以防止资源浪费(如线程阻塞、连接池耗尽)并加快失败响应。经过一段时间后,断路器进入半开状态,允许少量请求通过来探测服务是否已恢复。如果这些探测请求成功,断路器关闭,恢复正常;如果仍然失败,断路器重新打开。

Polly 的断路器策略可以与重试策略组合使用,形成一个多层的防御机制。配置断路器时,你需要指定触发断路器打开的失败阈值(如连续 5 次失败)、断路器保持打开的时间(如 30 秒)以及失败的定义(与重试策略类似,通常是网络异常和 5xx 状态码)。下面是一个组合了重试和断路器的完整示例:

csharp 复制代码
builder.Services.AddHttpClient<ApiClient>(client =>
{
    client.BaseAddress = new Uri("https+http://api");
    client.Timeout = TimeSpan.FromSeconds(30);
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(response => response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}

static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(
            handledEventsAllowedBeforeBreaking: 5,
            durationOfBreak: TimeSpan.FromSeconds(30),
            onBreak: (outcome, breakDelay) =>
            {
                Console.WriteLine($"Circuit breaker opened for {breakDelay.TotalSeconds}s due to {outcome.Exception?.Message ?? outcome.Result.StatusCode.ToString()}");
            },
            onReset: () =>
            {
                Console.WriteLine("Circuit breaker reset, resuming normal operation");
            },
            onHalfOpen: () =>
            {
                Console.WriteLine("Circuit breaker half-open, testing if service has recovered");
            });
}

这个配置在 HTTP 客户端上附加了两个策略:先是重试策略,然后是断路器策略。策略的执行顺序很重要,Polly 会按照附加的顺序从内到外执行。在这个配置中,当一个请求失败时,首先会被重试策略处理,尝试重试 3 次。如果重试后仍然失败,失败会传递给断路器策略。断路器会记录失败次数,当连续 5 次请求(每个请求都经过 3 次重试后仍失败)失败时,断路器打开,后续 30 秒内的所有请求会立即抛出 BrokenCircuitException 而不会真正执行。30 秒后,断路器进入半开状态,允许下一个请求通过。如果这个请求成功,断路器关闭;如果失败,断路器重新打开另外 30 秒。

CircuitBreakerAsync 方法配置了断路器的行为。handledEventsAllowedBeforeBreaking: 5 参数指定了打开断路器前允许的失败次数。这里的"失败"是指经过重试策略后仍然失败的请求,因为断路器在重试策略的外层。durationOfBreak 参数指定了断路器保持打开状态的时间,在这个例子中是 30 秒。这个时间应该足够让远程服务从故障中恢复,但也不能太长,否则会影响整体的恢复速度。onBreakonResetonHalfOpen 是回调函数,分别在断路器打开、关闭和进入半开状态时被调用。在这些回调中,你可以记录日志、发送警报、更新指标等。断路器状态的变化是重要的运维事件,应该被充分记录和监控。

组合使用重试和断路器时需要仔细调整参数以避免意外行为。例如,如果重试次数很多(如 10 次)而断路器的失败阈值很低(如 2 次),断路器可能会过早打开,因为即使是偶尔的瞬时故障也会在重试期间累积多次失败。一般来说,断路器的失败阈值应该设置为稍高于重试次数乘以一个小的系数(如 1.5 倍),这样只有在多个请求都失败时才会触发断路器。另外,断路器的打开时间应该与远程服务的预期恢复时间相匹配,如果你知道服务的重启或故障恢复通常需要 1 分钟,那么断路器应该至少保持打开 1 分钟。

SSL/TLS 错误是网络通信中另一类常见问题,通常表现为 System.Net.Http.HttpRequestException 异常,内部异常是 System.Security.Authentication.AuthenticationException,错误消息类似于 The SSL connection could not be establishedThe remote certificate is invalid according to the validation procedure。这些错误发生在 HTTPS 连接建立过程中,客户端验证服务器证书时失败。在生产环境中,这通常意味着服务器的证书有问题,如证书过期、证书不受信任、主机名不匹配等,这是严重的安全问题,应该被修复而不是绕过。但在开发和测试环境中,你可能使用自签名证书或内部 CA 签发的证书,这些证书在默认情况下不被客户端信任,导致连接失败。

Aspire 在开发环境中提供了自动的证书管理,当你使用 WithHttpsEndpoint 方法为服务启用 HTTPS 时,Aspire 会自动生成一

相关推荐
梦帮科技1 天前
第三十四篇:开源社区运营:GitHub Stars增长策略
开发语言·前端·爬虫·python·docker·架构·html
开心猴爷1 天前
Perfdog 成本变高之后,Windows 上还能怎么做 iOS APP 性能测试
后端
rannn_1111 天前
【Java项目】中北大学Java大作业|电商平台
java·git·后端·课程设计·中北大学
苏三说技术1 天前
日常工作中如何对接第三方系统?
后端
长安即是故里1 天前
保姆级docker安装教程,含国内加速镜像地址
docker·docker安装教程·国内加速
凌览1 天前
0成本、0代码、全球CDN:Vercel + Notion快速搭建个人博客
前端·后端
决战灬1 天前
Hologres高性能写入
后端
座山雕~1 天前
spring
java·后端·spring
悟空码字1 天前
刚刚,TRAE宣布,全量免费开放
后端·trae·solo