日志与可观测性

系统讲解 .NET 应用的可观测性建设,覆盖 ILogger 与日志级别、结构化日志、Serilog 管道、OpenTelemetry 链路追踪与指标采集、健康检查,以及日志治理与容量管控的最佳实践。

1. ILogger 与日志级别

一句话总结: ILogger 是 ASP.NET Core 内置的日志抽象,通过日志级别与类别过滤控制输出,配合 DI 注入到任何类。

ILogger<T> 是结构化日志的入口,T(通常是当前类)作为日志类别,用于过滤与聚合。日志级别从 Trace 到 Critical 六级,生产环境通常只记录 Information 及以上。级别不只是「严重程度」,更是成本控制——Debug 日志在高并发下可能写爆磁盘。

public class OrderService(ILogger<OrderService> logger)
{
    public async Task<Order> CreateAsync(CreateOrderRequest req)
    {
        logger.LogInformation("开始创建订单 {Customer}", req.CustomerName);

        try
        {
            var order = await _repo.SaveAsync(req);
            logger.LogInformation("订单创建成功 {OrderId},耗时 {Ms}ms", order.Id, _sw.ElapsedMilliseconds);
            return order;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "订单创建失败 {Customer}", req.CustomerName);
            throw;
        }
    }
}
级别用途生产默认
Trace/Debug调试细节通常关闭
Information业务事件、状态变化开启
Warning可恢复异常、降级开启
Error异常、失败开启
Critical进程级故障开启

避坑: 别用字符串拼接组装日志消息——LogInformation($"创建 {x}") 会在不记录时也执行拼接,产生分配。用占位符形式 LogInformation("创建 {X}", x),只有真正记录时才格式化,且字段名会进结构化日志。

2. 结构化日志

一句话总结: 结构化日志把「一行文本」变成「键值字段」,让日志可被机器查询、聚合与告警,而非只能人肉 grep。

传统日志是给人看的文本;结构化日志把 {Customer}、{OrderId} 变成索引字段,让日志系统可以按字段过滤、统计、关联。ILogger 的占位符天然支持结构化,配合 JSON 格式的 provider 即可输出键值对。

// 占位符 → 结构化字段
logger.LogInformation("创建订单 Customer={Customer} Total={Total}",
    req.CustomerName, req.Total);

// 输出到控制台(JSON 格式)大致为:
// {"@t":"2026-10-01T05:00:00Z","@mt":"创建订单 Customer={Customer} Total={Total}",
//  "Customer":"张三","Total":199.0,"SourceContext":"OrderService","Level":"Information"}
字段含义
@t时间戳
@mt消息模板
Level级别
SourceContext记录者类别
自定义字段Customer/OrderId 等业务字段

避坑: 结构化日志的价值在于字段稳定。字段名要固定拼写(别一会 OrderId 一会 order_id),否则聚合查询被割裂。敏感数据(手机号、身份证)不进日志字段,需要脱敏再记。

3. Serilog 与日志管道

一句话总结: Serilog 通过「sink」把结构化日志写到控制台、文件、Elasticsearch 等目标,是 .NET 生态最流行的日志框架。

Serilog 的 LoggerConfiguration 定义日志管道:Enrich 加公共字段,WriteTo 指定输出目标,Filter 控制级别。它常与 ILogger 无缝集成——用 UseSerilog() 替换默认 provider,业务代码继续用 ILogger<T>。

// Program.cs —— Serilog 配置
var builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog((ctx, cfg) =>
    cfg.ReadFrom.Configuration(ctx.Configuration)
       .Enrich.WithMachineName()
       .Enrich.WithProperty("App", "orders-api")
       .WriteTo.Console(new RenderedCompactJsonFormatter())
       .WriteTo.File("logs/orders-.log",
            rollingInterval: RollingInterval.Day,
            outputTemplate: "{Timestamp:HH:mm:ss} [{Level}] {SourceContext} {Message}{NewLine}{Exception}"));

// appsettings.json 里的 Serilog 级别覆盖
// "Serilog": { "MinimumLevel": "Information",
//   "Override": { "Microsoft.AspNetCore": "Warning" } }
组件作用
Enrich附加机器名、App 名等公共字段
WriteTo控制台/文件/ES 等 sink
Filter按级别/字段过滤
RollingInterval文件按天滚动
RenderedCompactJsonFormatter紧凑 JSON,适合摄取

避坑: 日志文件要设滚动与保留策略(RetainedFileCountLimit),否则磁盘被日志写满。结构化格式优先 JSON(CompactJsonFormatter),文本模板只适合本地调试。Serilog 的 ReadFrom.Configuration 能热改级别,但生产一般走环境变量。

4. OpenTelemetry 链路追踪

一句话总结: 分布式追踪用 traceId 贯穿跨服务调用,OpenTelemetry 是采集与导出的事实标准,配合 ASP.NET Core 自动埋点即可打通链路。

单机日志只能看到「一个服务里发生了什么」,跨服务调用需要链路追踪:请求生成全局 traceId,每个服务记 spanId 与父 span,汇聚成完整调用链。OpenTelemetry .NET 通过 AddOpenTelemetry + AddAspNetCoreInstrumentation 自动为 HTTP 请求创建 span。

// Program.cs —— 启用 OpenTelemetry
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.AddAspNetCoreInstrumentation();
        tracing.AddHttpClientInstrumentation();
        tracing.AddSource("MyApp.*");
        tracing.AddOtlpExporter(opt =>
            opt.Endpoint = new Uri("http://otel-collector:4317"));
    })
    .WithMetrics(metrics =>
    {
        metrics.AddAspNetCoreInstrumentation();
        metrics.AddMeter("MyApp.Meters");
        metrics.AddOtlpExporter();
    });

// 业务代码创建自定义 span
using var activity = ActivitySource.StartActivity("order.create");
activity?.SetTag("order.id", order.Id);
概念说明
traceId整条链路唯一 ID
span一个服务的某段工作
parentId父 span,串起调用链
Instrumentation自动埋点库
ExporterOTLP 导出到 Collector

避坑: 链路追踪要打通日志关联:日志里带上 traceId,出问题时从日志反查链路。ASP.NET Core 的 Activity 与日志的关联通常由 ILogger 的 scopes 或 Serilog 的 TraceId enrichment 完成,别让日志与追踪「两张皮」。

5. 指标采集

一句话总结: 指标是「数字化的健康状态」,通过计数器、仪表与直方图反映吞吐、错误率与延迟,供监控系统聚合告警。

日志回答「发生了什么」,指标回答「有多严重」。OpenTelemetry 指标模型:Counter 累加计数(请求数)、UpDownCounter 可增可减(在线连接数)、Histogram 分布(耗时)。ASP.NET Core 自动提供 HTTP 指标(http.server.request.duration),业务指标用 Meter 自定义。

// 业务指标
public static class OrderMetrics
{
    public static readonly Meter Meter = new("MyApp.Meters", "1.0.0");
    public static readonly Counter<long> OrdersCreated =
        Meter.CreateCounter<long>("orders.created", "orders");
    public static readonly Histogram<double> OrderDuration =
        Meter.CreateHistogram<double>("order.duration", "ms");
}

public class OrderService(ILogger<OrderService> logger)
{
    public async Task<Order> CreateAsync(CreateOrderRequest req)
    {
        var sw = Stopwatch.StartNew();
        var order = await _repo.SaveAsync(req);
        sw.Stop();

        OrderMetrics.OrdersCreated.Add(1);
        OrderMetrics.OrderDuration.Record(sw.ElapsedMilliseconds);
        return order;
    }
}
指标类型用途示例
Counter单调递增请求总数
UpDownCounter可增可减在线连接数
Histogram分布延迟、包大小
Gauge当前值队列深度
// Prometheus 侧聚合查询示例
// 请求速率(1 分钟窗口)
rate(http_server_request_duration_seconds_count[1m])
// 99 分位延迟
histogram_quantile(0.99,
  sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le))
// 按路由聚合错误率
sum(rate(http_server_requests_total{status=~"5.."}[5m])) by (route)
  / sum(rate(http_server_requests_total[5m])) by (route)

避坑: 指标命名要遵循约定(点分结构 orders.created),带维度(如 method、route)才有聚合价值,但维度爆炸(cardinality 过高)会拖垮存储。别把用户 ID 之类高基数值当指标维度。

6. 健康检查与探活

一句话总结: 健康检查让编排平台知道实例是否可服务,区分「进程活着」与「依赖可用」,是故障摘除的前提。

ASP.NET Core 内置健康检查:AddHealthChecks 注册检查项,MapHealthChecks 暴露端点。可以分三类:Liveness(进程活)、Readiness(依赖可用、可接流量)、Startup(初始化完成)。Kubernetes 的 startupProbe/livenessProbe/readinessProbe 分别对应。

builder.Services.AddHealthChecks()
    .AddDbContextCheck<OrderDbContext>()                    // 数据库
    .AddRedis("redis:6379")                                 // 缓存
    .AddUrlGroup(new Uri("https://payments.internal"), "payments"); // 下游

var app = builder.Build();

// 存活探针:进程活着
app.MapHealthChecks("/healthz", new HealthCheckOptions
{
    Predicate = _ => false // 不执行具体检查,只回 200
});

// 就绪探针:依赖可用才接流量
app.MapHealthChecks("/readyz", new HealthCheckOptions
{
    Predicate = _ => true
});
探针端点失败后果
Liveness/healthz重启容器
Readiness/readyz从负载均衡摘除
Startup/startupz启动阶段探活

避坑: 健康检查别去检查「不关键」的依赖——比如检查一个不重要的报表库,挂了就把整个实例摘了,放大故障。健康端点本身要轻量(低超时),别做重查询。

7. 日志治理与容量管控

一句话总结: 日志治理是「质量 + 成本」工程:控制级别、过滤噪音、脱敏敏感数据、设定保留周期,让日志系统可持续运转。

日志量失控会从「可观测性」变成「成本黑洞」。治理手段:按环境设默认级别、过滤健康检查与静态资源噪音、敏感字段脱敏、分级保留(热数据短、冷数据长)。日志是「写给未来的自己排查用的」,垃圾日志与缺失日志一样有害。

// 过滤噪音:健康检查与静态资源不进日志
builder.Services.AddLogging(logging =>
{
    logging.AddFilter("Microsoft.AspNetCore.HealthChecks", LogLevel.Warning);
    logging.AddFilter("Microsoft.AspNetCore.StaticFiles", LogLevel.Warning);
});

// 脱敏:敏感字段在写入前打码
public static string MaskPhone(string phone)
    => phone.Length >= 7 ? $"{phone[..3]}****{phone[^4..]}" : "****";

logger.LogInformation("用户注册 Phone={Phone}", MaskPhone(user.Phone));
治理项手段
级别基线生产 Information,Debug 只对特定类
噪音过滤AddFilter 按类别压制
脱敏写入前 Mask 或结构化忽略
保留策略按日滚动 + 容量限制
告警只对 Error/Critical 与关键指标告警

避坑: 别把所有 Exception 都打 Error——可预期、已优雅降级的异常应该打 Warning,否则告警风暴让团队麻木。日志字段要克制,每行日志的价值密度要高,配合 traceId 关联,出问题能 3 分钟定位。

8. 总结

主题要点
ILogger占位符结构化、按级别与类别过滤
结构化日志字段稳定、可查询、可聚合
Serilogsink 多样化,Enrich + JSON 输出
链路追踪OTel 自动埋点,traceId 贯穿调用链
指标Counter/Histogram 反映吞吐与延迟
健康检查Liveness/Readiness 分层探活
日志治理级别基线、噪音过滤、脱敏、保留策略

可观测性的三角是日志、指标、追踪:日志回答「发生了什么」,指标回答「有多严重」,追踪回答「发生在哪条链路上」。ASP.NET Core + OpenTelemetry 让三者的采集成本大幅降低——自动埋点、标准导出、traceId 关联。真正考验工程能力的是治理:控制日志量、稳定字段约定、脱敏敏感数据、让告警精准。当系统抖动时,靠这三角能在几分钟内定位到具体服务与代码路径,这就是可观测性的回报。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排