Serilog:从结构化日志认知到 .NET 工程落地

问题背景

很多项目不缺日志,缺的是有用的日志。

平时接口跑得顺,大家都觉得日志够用。真到线上出问题,日志的短板会一下子暴露出来。

比如订单接口偶发超时,日志里只剩这么一句:

text 复制代码
Create order failed for customer 1024, cost 3800ms, trace abc123

这种日志平时看着还行,真到线上排障时基本帮不上太多忙:

  1. 你没法直接按 CustomerId、TraceId、ElapsedMs 做过滤和聚合,只能全文搜索
  2. 同一个字段今天写 customerId,明天写 userId,后天又换成 client_id,查询条件根本沉淀不下来
  3. 想统计某类错误的数量、平均耗时、失败占比,往往还得写额外脚本二次清洗
  4. 日志平台能做的分析能力很弱,因为它拿到的只是一段文本,不是一条可分析的数据

问题往往不在于日志打得不够多,而在于日志从一开始就没按可检索、可聚合、可关联的方式去设计。

传统文本日志更像写给人看的备注,结构化日志才像写给系统消费的数据。业务一旦进入多人协作、线上排障、统一观测这些阶段,日志就不再只是打出来看一眼,而是排障、审计、告警、指标补充、链路追踪的一部分。走到这一步,结构化日志就不是锦上添花,而是该补的基础课。

原理解析

什么是结构化日志

很多人第一次接触结构化日志,会下意识把重点放在 JSON 输出上。其实 JSON 只是表现形式,核心不在日志长什么样,而在日志里的字段有没有明确语义,后续能不能被系统稳定识别。

先看两种写法的差异。

普通文本日志:

csharp 复制代码
logger.LogInformation(
    $"Order {order.Id} created for customer {order.CustomerId}, amount {order.Amount}, cost {elapsedMs}ms"
);

这行代码最终只会产出一段字符串。人类看没问题,但日志平台拿到以后,并不知道哪一段是订单号,哪一段是金额,哪一段是耗时。

结构化日志:

csharp 复制代码
logger.LogInformation(
    "Order {OrderId} created for customer {CustomerId}, amount {Amount}, cost {ElapsedMs}ms",
    order.Id,
    order.CustomerId,
    order.Amount,
    elapsedMs
);

换成这种写法以后,日志框架记录的就不再是一整段普通字符串,而是一个日志事件:

  • 模板是 Order {OrderId} created for customer {CustomerId}, amount {Amount}, cost {ElapsedMs}ms
  • 属性是 OrderId、CustomerId、Amount、ElapsedMs
  • 元数据还包括时间、级别、异常、来源、线程、TraceId 这些上下文

输出到控制台时,它可以渲染成人能直接看的句子;送到 Elasticsearch、Seq、Loki 或 OpenTelemetry 后端时,它又能以字段化数据的形式被检索和聚合。

所以,结构化日志本质上是事件加字段,不是把日志换个更漂亮的格式。

为什么需要结构化日志

结构化日志真正解决的,是文本日志进入工程化阶段以后暴露出来的几个硬伤。

1. 检索成本高

文本日志适合肉眼翻看,不适合机器分析。字段埋在句子里,检索通常依赖模糊匹配或者正则,成本高,也不稳定。

一旦日志天然带字段,排查动作会直接从翻文本变成查数据:

  • 按 TraceId 找一条请求的全链路日志
  • 按 OrderId 找某一笔订单的状态变化
  • 按 StatusCode = 500 统计最近 10 分钟的异常峰值
  • 按 ElapsedMs > 3000 过滤所有慢请求

这些动作放在文本日志里都挺别扭,放在结构化日志里反而是最基础的用法。

2. 字段不稳定,团队协作成本高

同一个业务含义,如果今天有人写 customerId,明天有人写 userId,后天又有人写 client_id,日志系统就很难形成稳定查询。

结构化日志还有一个很现实的价值,它会逼着团队把字段契约慢慢收敛下来,比如:

  • TraceId 表示链路标识
  • OrderId 表示订单标识
  • UserId 表示当前登录用户
  • ElapsedMs 表示耗时,统一按毫秒记录

一旦字段稳定下来,排障脚本、监控规则、仪表盘、告警条件都能复用。

3. 无法自然接入可观测体系

现在的日志通常不会单独存在,而是要和指标、链路追踪一起工作。

如果你的日志里没有稳定字段,没有 TraceId、SpanId、RequestPath、StatusCode 这些上下文,日志和 tracing 就连不起来。最后经常会看到这种尴尬场景:

  • 链路平台里能看到一个慢请求
  • 日志平台里也有一条错误日志
  • 但两边关联不上,只能靠时间戳人工猜

这就是结构化日志真正值钱的地方。它把日志从输出一段文本,抬到了沉淀一条可观测数据的层次。

为什么在 .NET 里很多团队选择 Serilog

.NET 本身提供的是 Microsoft.Extensions.Logging 这一层日志抽象,它解决的是统一接口问题,但不直接决定你的结构化日志方案怎么落地。

真到工程落地这一步,很多团队会选 Serilog,原因大致有这几个:

  1. 它从设计上就是围绕结构化日志展开的,不是后来补上的能力
  2. Message Template 语义成熟,字段模型清晰
  3. Sink 生态完整,控制台、文件、Seq、Elasticsearch、OpenTelemetry 都有成熟支持
  4. Enricher、Destructuring、过滤、分级控制这些能力适合长期跑在生产环境里

说得更严谨一点,Serilog 不是 .NET 里的唯一选项,但它确实是最常见、最成熟的结构化日志方案之一。

Serilog 到底做了什么

Serilog 的核心链路可以简化成下面这样:

text 复制代码
应用代码
  -> Message Template
  -> LogEvent
  -> Enricher 补充上下文
  -> Sink 输出到 Console、File、Seq、OTLP 等目标

这里面最关键的是 LogEvent。它不是普通字符串,而是一个日志事件对象,里面至少会带上这些信息:

  • Timestamp
  • Level
  • MessageTemplate
  • Properties
  • Exception

也就是说,你写下来的不再只是一句话,而是一份带上下文字段的事件数据。

再看最常见的这一行:

csharp 复制代码
Log.Information(
    "Payment {PaymentId} completed for order {OrderId}",
    paymentId,
    orderId
);

Serilog 会把它拆成:

  • 模板:Payment {PaymentId} completed for order
  • 属性:PaymentId、OrderId
  • 级别:Information
  • 时间戳:当前时间

如果输出到 JSON Sink,日志后端看到的就是字段化结果,而不是一整段句子。

示例代码

从文本日志切到结构化日志

先看一种项目里很常见,但后面基本都会返工的写法:

csharp 复制代码
app.MapPost("/orders", async (
    CreateOrderRequest request,
    OrderApplicationService orderService,
    ILogger<Program> logger) =>
{
    var startedAt = Stopwatch.GetTimestamp();

    try
    {
        var orderId = await orderService.CreateAsync(request);
        var elapsedMs = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;

        logger.LogInformation(
            $"Create order success, orderId={orderId}, customerId={request.CustomerId}, elapsed={elapsedMs}ms"
        );

        return Results.Ok(new { OrderId = orderId });
    }
    catch (Exception ex)
    {
        logger.LogError(
            ex,
            $"Create order failed, customerId={request.CustomerId}, totalAmount={request.TotalAmount}"
        );

        return Results.Problem();
    }
});

这段代码乍一看没什么问题,但后面会很难用,主要卡在两个点:

  1. 成功日志还是字符串拼接,字段无法稳定提取
  2. 同一类事件的字段命名没有固定模板,后续很难做查询和统计

换成结构化写法后,事情会简单很多:

csharp 复制代码
app.MapPost("/orders", async (
    CreateOrderRequest request,
    OrderApplicationService orderService,
    ILogger<Program> logger) =>
{
    var startedAt = Stopwatch.GetTimestamp();

    try
    {
        var orderId = await orderService.CreateAsync(request);
        var elapsedMs = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;

        logger.LogInformation(
            "Create order succeeded. OrderId: {OrderId}, CustomerId: {CustomerId}, TotalAmount: {TotalAmount}, ElapsedMs: {ElapsedMs}",
            orderId,
            request.CustomerId,
            request.TotalAmount,
            elapsedMs
        );

        return Results.Ok(new { OrderId = orderId });
    }
    catch (Exception ex)
    {
        logger.LogError(
            ex,
            "Create order failed. CustomerId: {CustomerId}, TotalAmount: {TotalAmount}",
            request.CustomerId,
            request.TotalAmount
        );

        return Results.Problem();
    }
});

public sealed record CreateOrderRequest(int CustomerId, decimal TotalAmount, List<int> ItemIds);

这里最关键的变化,不是把插值字符串替换成了占位符,而是字段终于稳定下来了。后面你在日志平台里按 CustomerId、OrderId、ElapsedMs 去查,就不会再陷入全文搜索。

ASP.NET Core 里接入 Serilog

下面给一个最常见的接入方式,示意代码基于 .NET 8。

先安装常用包:

  • Serilog.AspNetCore
  • Serilog.Sinks.Console
  • Serilog.Sinks.File
  • Serilog.Enrichers.Environment

Program.cs 可以这样写:

csharp 复制代码
using Serilog;

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
    loggerConfiguration
        .ReadFrom.Configuration(context.Configuration)
        .Enrich.FromLogContext()
        .Enrich.WithMachineName()
        .Enrich.WithEnvironmentName()
        .Enrich.WithProperty("Application", "OrderService")
        .WriteTo.Console(
            outputTemplate:
                "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
        )
        .WriteTo.File(
            path: "logs/order-service-.log",
            rollingInterval: RollingInterval.Day,
            retainedFileCountLimit: 14
        );
});

builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseSerilogRequestLogging(options =>
{
    options.MessageTemplate =
        "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
});

app.MapGet("/orders/{orderId:int}", (
    int orderId,
    ILogger<Program> logger) =>
{
    logger.LogInformation("Get order detail. OrderId: {OrderId}", orderId);
    return Results.Ok(new { OrderId = orderId, Status = "Paid" });
});

app.Run();

这段配置做的事情其实很直接:

  1. ASP.NET Core 默认日志管道接入 Serilog
  2. 自动把日志上下文写进去,比如环境名、机器名、请求上下文
  3. 同时输出到控制台和文件,便于开发和本地排查
  4. 用请求日志中间件统一记录 HTTP 请求,不用每个接口都手写一遍

Serilog 里最有价值的三个能力

1. Message Template

它决定了你的日志到底是不是结构化日志。

csharp 复制代码
logger.LogInformation(
    "User {UserId} paid {Amount} for order {OrderId}",
    userId,
    amount,
    orderId
);

这里的 UserId、Amount、OrderId 都会变成独立字段。后续无论输出到文本、JSON 还是日志平台,这些字段都能继续保留下来。

2. Enricher

Enricher 的价值很直接,就是把那些每条日志都想带上、但又不想在业务代码里重复传递的上下文统一补进去。

比如统一补充服务名、部署环境、节点名:

csharp 复制代码
loggerConfiguration
    .Enrich.WithProperty("Application", "OrderService")
    .Enrich.WithEnvironmentName()
    .Enrich.WithMachineName();

如果要把 TraceId、TenantId、UserId 这类信息自动补到每条日志里,也通常是通过 Enricher 或 LogContext 完成。

3. Sink

Sink 决定日志最后落到哪里。

常见选择包括:

  • Console,适合本地开发和容器标准输出
  • File,适合简单部署或本地排查
  • Seq,适合结构化日志的快速查询和演示
  • Elasticsearch 或 Loki,适合接入中心化日志平台
  • OpenTelemetry,适合纳入统一可观测体系

Serilog 的优势不只是 Sink 多,而是这些 Sink 大多天然理解结构化字段,这一点在后续接日志平台时会省很多事。

记录复杂对象时怎么写

如果一个对象本身就带着明确的业务语义,可以直接让 Serilog 展开对象字段:

csharp 复制代码
logger.LogInformation("Submit order request: {@OrderRequest}", request);

这里的 @ 表示按对象结构展开,而不是只调用 ToString()。

适合的场景:

  • 请求入参排查
  • 领域事件快照
  • 审计日志里记录业务对象摘要

但这里有个前提:对象里不能直接带密码、令牌、银行卡号这类敏感字段,或者至少要先做脱敏。

工程实践建议

1. 先设计字段,再写日志

如果把 Serilog 接进来了,日志也开始结构化了,最后还是不好查。问题通常不在框架,而在字段设计。

适用场景:准备给核心业务链路补日志时

建议:

  • 先定一套高频字段名,比如 TraceId、UserId、OrderId、ElapsedMs、StatusCode
  • 同一业务概念只保留一个命名,不要混着写
  • 数值字段尽量保持真实数值类型,不要提前拼成字符串

注意事项:字段命名一旦被告警、仪表盘、查询脚本依赖,后面改动成本会很高。

2. 异常一定作为独立参数传入

错误日志里最容易踩的坑,就是把异常信息当普通字符串去拼。

错误写法:

csharp 复制代码
logger.LogError("Create order failed: {Exception}", ex.Message);

推荐写法:

csharp 复制代码
logger.LogError(ex, "Create order failed. OrderId: {OrderId}", orderId);

适用场景:所有异常日志

注意事项:异常对象独立传入后,堆栈、内部异常、异常类型才能被日志系统正确识别。

3. 不要默认记录完整请求体和响应体

如果你的团队为了方便排查,一上来就把请求体和响应体全量打进日志。短期看像是省事,后面大概率会变成新的问题源头。

适用场景:支付、用户、认证、订单等敏感业务接口

风险:

  • 日志体积迅速膨胀
  • 个人信息和敏感字段泄漏
  • 序列化开销增加,请求延迟上升

建议:默认只记录关键摘要字段。确实要查 body 时,走白名单接口、临时开关或者采样策略。

4. 开发环境和生产环境的日志策略要分开

适用场景:同一套应用在本地、测试、生产都要运行

建议:

  • 开发环境优先控制台可读性
  • 生产环境优先结构稳定、便于平台采集
  • Debug 级别不要在生产环境长期开启

注意事项:日志级别过低、字段过多、输出目标过重,最后都会变成真实的性能成本。

总结

结构化日志解决的,从来都不是日志格式好不好看,而是日志能不能被系统稳定消费。

当日志开始承担排障、审计、告警、链路关联这些职责时,纯文本日志很快就会撞上天花板。结构化日志把日志从句子变成事件,把检索从全文搜索变成字段查询,这一步很关键。

在 .NET 生态里,Serilog 被大量团队采用,不只是因为它好用,更重要的是它把 Message Template、Enricher、Sink 这几层都做得比较成熟,能把结构化日志真正落到工程里,而不是停留在概念上。

相关推荐
SRETalk7 小时前
不记命令也能排障:catpaw chat 实战手册
可观测性·故障排查·sre·catpaw
SRETalk1 天前
那些你不知道自己需要监控的 Linux 暗坑
可观测性·监控告警·开源监控·catpaw
BJ_Bonree4 天前
直播预告 | 三步构建可观测体系,守护制造业业务连续性
人工智能·可观测性
硅基喵6 天前
ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界
asp.net core·架构设计
硅基喵13 天前
ASP.NET Core 认证鉴权实战:JWT、Policy 与权限边界怎么落地
asp.net core·工程实践
硅基喵16 天前
从 IApplicationBuilder 到 RequestDelegate:ASP.NET Core 请求管线的性能与可观测性实战
asp.net core·工程实践
予枫的编程笔记1 个月前
【Kafka高级篇】Kafka监控不踩坑:JMX指标暴露+Prometheus+Grafana可视化全流程
kafka·grafana·prometheus·可观测性·jmx·kafka集群调优·中间件监控
绿荫阿广2 个月前
将SignalR移植到Esp32—让小智设备无缝连接.NET功能拓展MCP服务
.net·asp.net core·mcp
贾修行2 个月前
.NET 全栈开发学习路线:从入门到分布式
c#·.net·wpf·asp.net core·web api·winforms·services