Skip to content
Go back

.NET 日志记录:从 Console.WriteLine 到生产级可观测性

Console.WriteLine 开始,到 Debug.WriteLine,再到 Microsoft.Extensions.Logging——很多 .NET 开发者走到第三步就停下了,因为日志跑通了,线上也没出问题。但当你真的需要在一百万条日志里找到某次支付失败的原因时,才发现早期的一些写法让排查变得异常困难。

这篇指南覆盖了 .NET 9 和 .NET 10 下日志记录的完整图景:ILogger 抽象、日志级别划分、结构化日志的正确写法、源生成高性能日志、Serilog/NLog 选型对比,以及 OpenTelemetry 如何把日志、追踪和指标串到一条管线里。

ILogger 抽象:所有日志的起点

.NET 日志体系建立在 ILogger 接口之上。它不是某个具体实现,而是一层抽象——应用代码面向 ILogger 编程,底层由 Console、JsonConsole、Serilog 或其他 Provider 负责输出。这意味着你随时可以替换日志后端,不用改一行业务逻辑。

using Microsoft.Extensions.Logging;

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public void ProcessOrder(int orderId, string userId)
    {
        _logger.LogInformation(
            "Processing order {OrderId} for user {UserId}",
            orderId, userId);

        // 业务逻辑...

        _logger.LogInformation(
            "Order {OrderId} processed successfully", orderId);
    }
}

为什么用 ILogger<T> 而不是 ILogger

泛型版本 ILogger<T> 有三层好处:

日志级别:不是越细越好

.NET 定义了六个日志级别,从最啰嗦到最严重依次排列:

级别数值什么时候用
Trace0最细粒度的诊断(函数入口/出口)
Debug1开发期有用,生产环境关闭
Information2正常业务里程碑
Warning3意外但不阻断执行的情况
Error4影响当前操作的失败
Critical5系统级故障,需要立即关注
None6完全关闭日志

把什么都写成 Information 是最常见的错误——框架路由日志、EF Core SQL 日志和你的业务日志搅在一起,真正有用的信息被淹没了。一个实用的经验法则:

按分类配置最低级别,在 appsettings.json 里安静地抑制框架日志:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
      "MyApp.OrderService": "Debug"
    }
  }
}

结构化日志:把日志写成可查询的数据

结构化日志的核心是把日志写成键值对,而不是拼成一段字符串。当你需要在一千万条日志里找到 OrderId = 5678 的所有记录时,结构化属性查询比全文搜索快几个数量级。

错误写法——字符串拼接,丢掉结构化属性:

_logger.LogInformation(
    $"Order {orderId} placed by user {userId} for ${amount}");

正确写法——消息模板:

_logger.LogInformation(
    "Order {OrderId} placed by {UserId} for {Amount:C}",
    orderId, userId, amount);

{OrderId}{UserId}{Amount} 这些花括号语法是消息模板,不是字符串插值。框架会把它们作为独立属性记录到日志事件中。后续在 Seq、Elasticsearch、Azure Monitor 或 Splunk 里做结构化查询时,过滤的是属性而不是文本,效率和精度都完全不一样。

高性能日志:源生成 LoggerMessage

从 .NET 6 开始,推荐的高性能写法是用 [LoggerMessage] 源生成器属性。.NET 9/10 内置的 Roslyn 分析器 CA1848 会在热路径代码中标记普通的 LogInformation() 调用,建议迁移到源生成版本。

为什么源生成的日志性能更好:

public partial class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public void ProcessOrder(int orderId, string userId)
    {
        LogProcessingOrder(_logger, orderId, userId);
    }

    [LoggerMessage(
        Level = LogLevel.Information,
        Message = "Processing order {OrderId} for user {UserId}")]
    private static partial void LogProcessingOrder(
        ILogger logger, int orderId, string userId);

    [LoggerMessage(
        Level = LogLevel.Information,
        Message = "Order {OrderId} processed successfully")]
    private static partial void LogOrderComplete(
        ILogger logger, int orderId);
}

partial 方法声明告诉源生成器在编译期生成实现代码,最终产物是一个强类型、零分配的日志调用。对大多数应用代码的性能差异可以忽略,但在高吞吐量服务中——或者只要 CA1848 触发了——就应该用这种写法。

内置 Provider 和 JSON Console

.NET 自带几个 Provider:

对云原生部署,JSON Console 输出往往是正确选择:

builder.Logging.AddJsonConsole(options =>
{
    options.JsonWriterOptions = new JsonWriterOptions { Indented = false };
    options.IncludeScopes = true;
    options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
});

JSON 输出意味着你的日志聚合器(Fluent Bit、Logstash、Azure Monitor)收到的是机器可直接解析的结构化条目,不需要额外写正则去拆字段。

第三方日志库选型

内置 Provider 满足基本需求,生产环境通常会引入第三方库。

Serilog 是 .NET 生态使用最广的结构化日志库。它通过 Serilog.Extensions.Logging 桥接到 ILogger,代码继续使用 ILogger<T>,底层由 Serilog 负责输出。Serilog 4.x + Serilog.AspNetCore 10.0.0 完整支持 .NET 9/10,包括 AOT 和剪裁。关键优势:100+ 个 Sink(Seq、Elasticsearch、Splunk、Azure Table Storage 等)、丰富的 Enrichment API、以及与 Seq 的优秀本地开发体验。

NLog 是一个成熟的、支持 XML 配置的日志框架,适合需要复杂路由规则的场景——例如把 Warning 路由到数据库,Error 同时写文件和发邮件。NLog 6.x 支持 .NET 6-10。

MEL 内置:部署到 Azure 时通过 AddApplicationInsightsTelemetry() 原生集成 Application Insights。对于简单的云原生应用,JSON Console Provider 往往就够了。

快速对比:

维度MEL 内置SerilogNLog
Sink/Target 数量Console, Debug, EventLog100+ 个包60+ 个 Target
结构化日志通过 Provider原生(消息模板)原生
Enrichment仅 Scopes丰富(按属性追加)丰富
配置方式appsettings.json代码 + appsettingsXML + 代码
AOT 支持 (.NET 10)是 (4.x)部分
热路径性能好(源生成)优秀(异步 Sink)

日志作用域:一次绑定,全局生效

BeginScope 让你在一个代码块内为所有日志条目附加共享上下文,不用每一行都手动传参:

public async Task<IActionResult> PlaceOrder(OrderRequest request)
{
    using var scope = _logger.BeginScope(new Dictionary<string, object>
    {
        ["RequestId"] = HttpContext.TraceIdentifier,
        ["UserId"] = request.UserId,
        ["Operation"] = "PlaceOrder"
    });

    _logger.LogInformation(
        "Validating order for {ItemCount} items", request.Items.Count);
    // 验证逻辑...

    _logger.LogInformation("Committing order to database");
    // 持久化逻辑...

    _logger.LogInformation("Order placement complete");
    return Ok();
}

using var scope 内部所有 LogInformation 调用都自动带上 RequestIdUserIdOperation。Serilog、NLog 和 MEL 的 JSON Console Provider 都支持传播 Scope 属性。这个模式在中间件里尤为好用——一个 HTTP 请求的所有日志自动共享关联 ID。

OpenTelemetry:日志、追踪、指标合一

OpenTelemetry (OTel) 是开放的可观测性标准。在 .NET 9/10 中它深度集成,允许通过单一管线输出日志、分布式追踪和指标,并且三者之间自动关联。

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation())
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation())
    .WithLogging(logging => logging
        .AddConsoleExporter())
    .UseOtlpExporter();

.WithLogging()ILogger 桥接到 OTel 管线。如果你处在分布式追踪的 Span 中,ILogger 发出的日志条目会自动携带当前的 TraceIdSpanId。从一条错误告警拿到 Trace ID,就能拉出这次完整调用链上的所有日志——跨多个服务。

常见错误清单

记录敏感数据:永远不要把密码、Token、信用卡号或 PII 写进日志。这既是 GDPR/PCI-DSS 合规问题,也是安全风险。

// 错误——日志里出现了密码
_logger.LogInformation(
    "Login attempt: user={User} password={Password}", user, password);

// 正确
_logger.LogInformation("Login attempt for user {User}", user);

用字符串插值替代消息模板:字符串插值会立即分配字符串,同时丢失了结构化属性。

// 错误——丢失了 OrderId 的结构化属性
_logger.LogInformation($"Order {orderId} placed");

// 正确——OrderId 被捕获为独立属性
_logger.LogInformation("Order {OrderId} placed", orderId);

在紧密循环内不加级别检查就打日志

// 错误——即使 Debug 级别被关闭,每次循环仍在计算参数
foreach (var item in items)
{
    _logger.LogDebug("Processing item: " + item.Id);
}

// 正确——使用源生成日志或 IsEnabled 检查
foreach (var item in items)
{
    LogProcessingItem(_logger, item.Id);
}

[LoggerMessage(Level = LogLevel.Debug, Message = "Processing item {ItemId}")]
private static partial void LogProcessingItem(ILogger logger, int itemId);

不按分类设置最低级别:所有分类都留 Information 会把 ASP.NET Core 路由日志、EF Core SQL、HttpClient 追踪和你的业务日志搅在一起。务必在 appsettings.json 中为框架分类设置覆盖规则。

2026 年的选型建议

一个实用的决策框架:

如果你关注 .NET 开发、工具实践和可运维的软件工程,可以关注 Aide Hub。这里会继续分享能落地的教程、工具分析和工程观察。

参考


Tags


Previous

HttpClient 日志与可观测性:ILogger、DelegatingHandler 和 OpenTelemetry

Next

用 Tailscale 构建你自己的 VPN:零开放端口的私有网络