Skip to content

结构化日志实践 ​

结构化日志的重点不是“把日志打出来”,而是让日志变成可筛选、可聚合、可定位问题的结构化事件。本文以 .NET 场景为主,整理从原理、落地到排障的完整实践路径。

1. 为什么要做结构化日志 ​

很多项目一开始都有日志,但真正出问题时,常见体验往往是:

  • 日志全是字符串拼接,只能全文搜索,无法按字段筛选。
  • 同一次请求散落着十几条日志,缺少统一的请求标识。
  • 报错时只看到一句 保存失败,没有用户、订单、耗时、上下文。
  • 业务审计日志和技术排障日志混在一起,既不利于开发,也不利于运营。

结构化日志的核心价值,就是把一条日志从“纯文本”变成“事件 + 字段”的组合。

先看最典型的对比:

csharp
// 反例:字符串拼接后,日志平台只能把它当成一整段文本。
_logger.LogInformation("用户 " + userId + " 下单失败,金额:" + amount);

// 正例:消息模板保持可读性,字段又能被日志平台单独索引。
_logger.LogInformation("用户 {UserId} 下单失败,金额:{Amount}", userId, amount);

第二种写法带来的变化不是“语法更规范”这么简单,而是后续可以直接做这些事:

  • 查询 UserId = 10086 的全部异常日志。
  • 过滤 ElapsedMs > 1000 的慢请求。
  • 统计某类错误在过去 24 小时的增长趋势。
  • 通过 RequestId 把一次请求从 Controller 到数据库调用串起来。

所以,结构化日志本质上是在为后续排障、监控、性能分析和运营追踪铺路。

2. 结构化日志的核心概念 ​

2.1 日志事件不是一段话,而是一条结构化记录 ​

一条结构化日志通常包含三层信息:

  1. 消息模板:给人看的简短描述。
  2. 属性字段:给机器筛选和聚合用。
  3. 上下文信息:请求链路、机器名、环境名、线程、异常堆栈等。

例如下面这条日志:

csharp
_logger.LogWarning("订单 {OrderId} 扣款超时,耗时 {ElapsedMs} ms", orderId, elapsedMs);

落到日志平台后,通常会变成类似下面的结构:

json
{
  "message": "订单 1024 扣款超时,耗时 1850 ms",
  "OrderId": 1024,
  "ElapsedMs": 1850,
  "Level": "Warning",
  "Timestamp": "2026-09-19T10:20:00Z"
}

2.2 消息模板和属性字段要分开理解 ​

消息模板是为了让人快速读懂,属性字段是为了让机器可分析。

如果开发过程中只关注“控制台打印看起来对不对”,往往就会退回到字符串拼接。真正长期可用的日志体系,要从一开始就把字段设计当成正式设计的一部分。

建议把日志字段当成一种“轻量协议”:

  • UserId
  • OrderId
  • RequestId
  • ElapsedMs
  • TraceId
  • BatchId

这些字段一旦命名不统一,后续聚合和查询体验会迅速变差。

2.3 Enrichment 是结构化日志体系的关键补强 ​

实际排障时,最有价值的常常不是业务消息本身,而是自动附加的上下文字段,例如:

  • Environment
  • Application
  • MachineName
  • RequestId
  • TraceId
  • UserId
  • TenantId

这类自动补充上下文的过程,通常叫做 Enrich 或 Enrichment。

2.4 技术日志和业务审计日志不要混为一谈 ​

很多项目会把所有日志都往文件里打,或者都往数据库表里塞,最后谁都用不好。

更实用的职责划分通常是:

  • 技术日志:给研发和运维排障用,关注异常、耗时、依赖调用、上下文。
  • 业务审计日志:给业务或管理员看,关注“谁做了什么操作”。

例如:

  • 用户修改密码失败,Redis 连接超时 是技术日志。
  • 管理员张三在 10:21 修改了用户李四的角色 是业务审计日志。

两者可能都重要,但不要强行用同一套存储和展示方式处理。

3. 推荐架构:Serilog + Seq ​

在 .NET 场景下,Serilog + Seq 是一套投入产出比很高的组合:

  • Serilog 负责结构化记录、字段绑定和输出管道。
  • Seq 负责日志聚合、检索、筛选和可视化查询。
mermaid
flowchart LR
    A[应用代码] --> B[ILogger / Serilog]
    B --> C[Console Sink]
    B --> D[File Sink]
    B --> E[Seq Sink]
    E --> F[Seq Server]

这套组合的优点主要在于:

  • 对 .NET 生态友好,接入成本低。
  • 本地开发就能获得较强的查询体验。
  • 比起完整 ELK 栈更轻量,适合中小团队。
  • 从开发环境到生产环境都能平滑过渡。

如果团队还没有成熟的日志平台,Serilog + Seq 往往比“继续写文本文件 + 手动 grep”更容易带来立竿见影的收益。

4. 开发阶段的落地原则 ​

4.1 必须使用消息模板,不要用字符串拼接 ​

csharp
// 反例:字段被拼进字符串里,后续无法按字段筛选。
_logger.LogInformation($"处理订单 {orderId}");

// 正例:OrderId 会被单独记录为属性。
_logger.LogInformation("处理订单 {OrderId}", orderId);

这是最基础也最容易失守的一条规则。

4.2 字段命名统一,优先使用 PascalCase ​

建议统一使用大驼峰命名:

  • UserId
  • OrderId
  • ElapsedMs
  • RequestId

不要在不同地方混用这些风格:

  • userId
  • user_id
  • User_Id

日志字段一旦不统一,后续查询条件和仪表板配置都会越来越乱。

4.3 记录异常时,异常对象要作为第一个参数 ​

csharp
try
{
    // 业务代码
}
catch (Exception ex)
{
    // 正例:异常堆栈会被日志框架单独处理。
    _logger.LogError(ex, "保存订单 {OrderId} 失败", orderId);
}

不推荐这样写:

csharp
_logger.LogError("保存订单失败:" + ex.Message);

后者会丢掉异常堆栈和结构化信息,排障价值会大打折扣。

4.4 复杂对象要显式做结构化展开 ​

当需要记录 DTO、请求对象或上下文字段时,建议使用解构方式:

csharp
var request = new { UserId = 10086, OrderId = 1024, Items = 3 };

// @Request 表示把对象按结构化方式写入。
_logger.LogInformation("提交订单请求:{@Request}", request);

但这里也要克制:

  • 不要把超大对象整包打进日志。
  • 不要把敏感字段原样写入日志。
  • 不要把整段数据库实体、完整响应报文长期写入生产日志。

结构化不等于无脑全量记录。

4.5 通过上下文补充链路信息 ​

在很多排障场景里,单条日志价值有限,真正关键的是把同一次请求内的日志串起来。

LogContext 就很适合做这件事:

csharp
using (LogContext.PushProperty("BatchId", batchId))
using (LogContext.PushProperty("OperatorId", operatorId))
{
    // 这个作用域内的日志都会自动带上 BatchId 和 OperatorId。
    _logger.LogInformation("开始批量导入");
    _logger.LogInformation("校验完成,共 {ItemCount} 条", itemCount);
}

这类上下文字段对以下场景特别有帮助:

  • 批量任务
  • 定时任务
  • 多租户请求
  • 异步事件处理
  • 多步骤业务流程

5. .NET 项目中的基础接入方式 ​

5.1 包依赖与注册思路 ​

一个常见的 Web 项目接入思路如下:

csharp
// Program.cs
using Serilog;

var builder = WebApplication.CreateBuilder(args);

// 通过配置文件读取 Sink、级别、Enrich 配置。
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
{
    loggerConfiguration
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext();
});

这个写法的重点不在于“代码要多短”,而在于把日志行为尽量交给配置,而不是散落在各个类里硬编码。

5.2 配置层的典型写法 ​

下面是一个简化过的配置示例:

jsonc
{
  "Serilog": {
    "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Seq" ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "System": "Warning"
      }
    },
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "Seq",
        "Args": {
          "serverUrl": "http://localhost:5341"
        }
      }
    ],
    "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
    "Properties": {
      "Application": "MyApp",
      "Environment": "Development"
    }
  }
}

这里有几个值得注意的点:

  • MinimumLevel 是全局阀门,决定默认打到什么级别。
  • Override 用来压制框架噪音,例如 Microsoft、System。
  • WriteTo 决定日志发往哪些目标。
  • Enrich 用来补上下文。
  • Properties 用来加全局固定字段。

5.3 请求日志要做摘要,不要做洪泛 ​

HTTP 请求日志最容易失控。

一种更稳妥的做法是:

  • 每个请求保留一条摘要日志。
  • 在异常或慢请求场景补充更多上下文。
  • 避免每层都重复打印同一类“进入方法”日志。

例如:

csharp
// Program.cs
app.UseSerilogRequestLogging(options =>
{
    // 用一条摘要日志覆盖整次请求,而不是把噪音打满。
    options.MessageTemplate = "Handled {RequestPath}";

    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        // 这里补充的是后续可筛选的结构化字段。
        diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
        diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
        diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString());
    };
});

请求日志做得太细,最后通常会出现两个问题:

  • 真实异常被淹没在大量普通请求日志里。
  • 存储成本快速上涨,但有效信息并没有增加多少。

6. 开发、测试、生产环境的差异化策略 ​

6.1 开发环境 ​

开发环境更强调调试友好:

  • 可以打开 Console Sink。
  • 可以让 Seq 指向本地容器。
  • 可以保留更多 Debug 级别日志。
  • 可以适度保留 SQL 或请求详情,方便快速定位问题。

6.2 测试与预发环境 ​

这类环境建议更接近生产:

  • 优先验证日志字段是否完整。
  • 提前验证查询条件、慢请求分析和异常聚合效果。
  • 检查是否有敏感字段泄漏。

6.3 生产环境 ​

生产环境要强调性能、可控和安全:

  • 默认不要依赖控制台输出。
  • 避免同步写磁盘导致性能瓶颈。
  • 避免写入大量低价值 Debug 日志。
  • 对密码、令牌、身份证号、手机号等敏感信息做脱敏。

一句话总结就是:

开发环境要看得清,生产环境要撑得住。

7. Seq 中最常用的查询方式 ​

结构化日志真正开始发挥价值,通常是在日志平台查询阶段。

几个常见查询示例:

sql
@Level = 'Error'
sql
RequestId = '00-abc123'
sql
ElapsedMs > 1000
sql
Application = 'MyApp' and @Level = 'Warning'
sql
UserId = 10086 and Message like '%下单%'

排障时可以优先按这几个维度切:

  • 级别:先看 Error,再看 Warning
  • 时间范围:先缩小时间窗
  • 请求标识:按 RequestId 或 TraceId
  • 业务主体:按 UserId、OrderId、TenantId
  • 性能指标:按 ElapsedMs

如果团队已经形成固定问题类型,还可以把高频查询保存成常用过滤器。

8. 常见坑与排障清单 ​

8.1 日志能看到,但查不到字段 ​

常见原因:

  • 使用了字符串拼接,而不是消息模板。
  • 对象没有按结构化方式展开。
  • 字段命名前后不一致。

优先检查打日志的代码写法,而不是先怀疑日志平台。

8.2 日志太多,真正的错误被噪音淹没 ​

常见原因:

  • 框架默认日志级别没压住。
  • 每个请求打印了过多过程日志。
  • SQL、HTTP、缓存调用明细全部长期保留。

处理建议:

  • 提高基础级别。
  • 针对框架命名空间做 Override。
  • 给高噪音日志设置更明确的启停策略。

8.3 日志没有链路,排障时只能猜 ​

常见原因:

  • 没有统一的 RequestId 或 TraceId
  • 异步任务没有带上下文
  • 跨层调用没有补公共字段

这类问题不能靠多打几条文本日志解决,还是要回到上下文设计本身。

8.4 生产环境日志泄漏敏感信息 ​

结构化日志会让字段更容易被检索,这既是优点,也是风险。

必须明确哪些字段禁止直接记录,例如:

  • 明文密码
  • Token / AccessKey / Secret
  • 完整身份证号
  • 完整手机号
  • 银行卡号
  • 完整请求报文中的敏感字段

需要时应做脱敏或摘要化处理。

9. 老项目也能借鉴什么 ​

结构化日志不是只有新项目才能受益。

哪怕老项目还在用 .NET Framework、log4net、NLog,下面这些思路依然非常值得借鉴:

9.1 先统一字段设计,再谈框架升级 ​

老项目不一定能立即切到 Serilog,但完全可以先统一日志字段:

  • 用户标识怎么叫
  • 请求标识怎么传
  • 订单号怎么记录
  • 耗时字段怎么命名

这一步做完,哪怕底层框架暂时不换,日志质量也会立刻提升。

9.2 先把请求链路串起来 ​

如果暂时没有 Seq 这类平台,也可以先在老项目里补这些能力:

  • 每个请求生成或透传 RequestId
  • 在关键业务流程补统一上下文字段
  • 统一异常日志的记录入口

这类改动的收益通常比“换一个日志框架”更直接。

9.3 技术日志与业务审计分流 ​

老项目很容易把所有日志塞进一个文件或一张表。

即使短期内不重构整套日志平台,也建议先把:

  • 技术异常
  • 性能日志
  • 业务审计

拆成不同职责,减少相互污染。

10. 参考资料 ​

11. 小结 ​

结构化日志真正有价值的地方,不是“日志更漂亮”,而是它让日志从一次性文本变成长期可分析的技术资产。

如果只记一句话,可以记成:

先把日志字段设计好,再谈日志平台;先让问题能被定位,再追求日志体系的高级感。

方舟 · Ark