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 {OrderId}
  • 属性: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 这几层都做得比较成熟,能把结构化日志真正落到工程里,而不是停留在概念上。

相关推荐
医疗信息化王工10 小时前
基于ASP.NET Core的住院日志统计系统设计与实现
后端·layui·asp.net core·npoi·dapper
观测云4 天前
AWS DevOps Agent 接入观测云最佳实践
aws·devops·可观测性·观测云
无风听海11 天前
.NET10之HttpContext.RequestServices 深入解析
.net·asp.net core
硅基喵11 天前
聊聊 ASP.NET Core 中间件和过滤器的区别
asp.net core
观测云17 天前
基于可观测的 IDP:产品开发从不可见走向透明化
软件开发·可观测性
硅基喵21 天前
Serilog:从结构化日志认知到 .NET 工程落地
asp.net core·可观测性
SRETalk21 天前
不记命令也能排障:catpaw chat 实战手册
可观测性·故障排查·sre·catpaw
SRETalk22 天前
那些你不知道自己需要监控的 Linux 暗坑
可观测性·监控告警·开源监控·catpaw
BJ_Bonree25 天前
直播预告 | 三步构建可观测体系,守护制造业业务连续性
人工智能·可观测性