Appearance
结构化日志实践
结构化日志的重点不是“把日志打出来”,而是让日志变成可筛选、可聚合、可定位问题的结构化事件。本文以 .NET 场景为主,整理从原理、落地到排障的完整实践路径。
1. 为什么要做结构化日志
很多项目一开始都有日志,但真正出问题时,常见体验往往是:
- 日志全是字符串拼接,只能全文搜索,无法按字段筛选。
- 同一次请求散落着十几条日志,缺少统一的请求标识。
- 报错时只看到一句
保存失败,没有用户、订单、耗时、上下文。 - 业务审计日志和技术排障日志混在一起,既不利于开发,也不利于运营。
结构化日志的核心价值,就是把一条日志从“纯文本”变成“事件 + 字段”的组合。
先看最典型的对比:
csharp
// 反例:字符串拼接后,日志平台只能把它当成一整段文本。
_logger.LogInformation("用户 " + userId + " 下单失败,金额:" + amount);
// 正例:消息模板保持可读性,字段又能被日志平台单独索引。
_logger.LogInformation("用户 {UserId} 下单失败,金额:{Amount}", userId, amount);第二种写法带来的变化不是“语法更规范”这么简单,而是后续可以直接做这些事:
- 查询
UserId = 10086的全部异常日志。 - 过滤
ElapsedMs > 1000的慢请求。 - 统计某类错误在过去 24 小时的增长趋势。
- 通过
RequestId把一次请求从 Controller 到数据库调用串起来。
所以,结构化日志本质上是在为后续排障、监控、性能分析和运营追踪铺路。
2. 结构化日志的核心概念
2.1 日志事件不是一段话,而是一条结构化记录
一条结构化日志通常包含三层信息:
- 消息模板:给人看的简短描述。
- 属性字段:给机器筛选和聚合用。
- 上下文信息:请求链路、机器名、环境名、线程、异常堆栈等。
例如下面这条日志:
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 消息模板和属性字段要分开理解
消息模板是为了让人快速读懂,属性字段是为了让机器可分析。
如果开发过程中只关注“控制台打印看起来对不对”,往往就会退回到字符串拼接。真正长期可用的日志体系,要从一开始就把字段设计当成正式设计的一部分。
建议把日志字段当成一种“轻量协议”:
UserIdOrderIdRequestIdElapsedMsTraceIdBatchId
这些字段一旦命名不统一,后续聚合和查询体验会迅速变差。
2.3 Enrichment 是结构化日志体系的关键补强
实际排障时,最有价值的常常不是业务消息本身,而是自动附加的上下文字段,例如:
EnvironmentApplicationMachineNameRequestIdTraceIdUserIdTenantId
这类自动补充上下文的过程,通常叫做 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
建议统一使用大驼峰命名:
UserIdOrderIdElapsedMsRequestId
不要在不同地方混用这些风格:
userIduser_idUser_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 > 1000sql
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. 小结
结构化日志真正有价值的地方,不是“日志更漂亮”,而是它让日志从一次性文本变成长期可分析的技术资产。
如果只记一句话,可以记成:
先把日志字段设计好,再谈日志平台;先让问题能被定位,再追求日志体系的高级感。
