1. 从一行字符串日志说起:结构化日志为什么是刚需
1.1 传统日志与结构化日志的本质差异
在 .NET 项目里我见过太多代码是这么写日志的:
csharp复制_logger.LogInformation($"用户 {userId} 在 {DateTime.Now} 支付了订单 {orderId}");
你跑到服务器上看到的日志行大概是:
code复制2024-05-18 15:22:31 用户 10086 在 2024/05/18 15:22:31 支付了订单 ORD202405180018
从人肉阅读的角度看,这行日志没什么问题。但是当你需要回答这几个问题的时候,噩梦就开始了:
- 今天订单支付失败的数量分布如何?按错误码或用户维度怎么聚合?
- 昨晚 10 点这个接口的耗时中位数是多少?P99 涨了多少?
- 能不能按订单号在日志平台里直接精确检索,不用一把鼻涕一把泪地敲 LIKE 语句?
字符串拼接产生的日志本质上是"死格式"。它没有字段结构、没有类型信息、没有统一的序列化规则。你在日志平台里能做的只有全文搜索,而全文搜索在数据量上来之后,慢、不准、没法聚合,事故排查的时长会从分钟级一路拉到小时级。
结构化日志解决的就是这个问题:不再记录"一条渲染好的字符串",而是记录"一个事件 + 一组命名属性"。还拿上面那个支付案例来说,用 Serilog 的消息模板写法是:
csharp复制_logger.LogInformation("用户 {UserId} 支付了订单 {OrderId}", userId, orderId);
落成一个 JSON 日志事件大概是:
json复制{
"@t": "2024-05-18T15:22:31.1234567+08:00",
"@mt": "用户 {UserId} 支付了订单 {OrderId}",
"UserId": 10086,
"OrderId": "ORD202405180018"
}
这里的 @t 是带时区的完整时间戳,@mt 是消息模板原文,UserId 和 OrderId 是独立字段。到了日志检索平台里,你可以直接按 UserId:10086 做精确过滤,也可以按 OrderId 做关联查询,还可以对用户维度做聚合统计。
这个转变看似只是把 $"..." 换成了模板占位符,实际上背后是一次日志思维的升级:日志不该是一堆给人翻阅的字符串,而应该是机器可消费的、带着语义的事件流。 .NET 团队在引入 ILogger 时也是这么设计的,所以你会看到官方文档里反复强调用消息模板而不是字符串插值。
1.2 Serilog 在 .NET 日志生态里的位置
.NET 生态目前的情况是:Microsoft.Extensions.Logging.Abstractions 提供了 ILogger 这个抽象接口,但它本身不干活,真正的日志输出由各种 Provider 实现。Serilog 就是这些实现里最主流的之一,它的定位是一个"结构化日志实现库",核心思路是:你用统一的 API 记录事件,通过不同的 Sink 把事件写到控制台、文件、数据库、日志平台等目标。
最开始我们用 Serilog,说实话是冲着"可以让日志变成 JSON"去的。但用久了你会发现它的好处远不止这个。它有一套完整的管线设计:Logger 负责接日志,Enricher 负责往日志里附加上下文,Filter 负责决定哪些日志能过,Sink 负责把日志写到哪儿,Formatter 负责决定日志长什么样。这套管线的组合能力非常强,而且是经过多年生产环境检验的。
相对于自己封装一个日志帮助类或者简单用 File.AppendAllText 硬写,Serilog 至少解决了几个关键问题:
- 统一的级别过滤能力,不用自己在业务代码里 if 来 if 去。
- 无需业务方关心输出格式,切 JSON 还是切文本由配置决定。
- 文件滚动、大小限制、并发写入这些基础设施级问题不用重复造轮子。
- 对 ASP.NET Core 的请求管道、依赖注入、配置系统有现成的集成包。
如果你团队刚开始搞 .NET 服务,又想把日志作为可检索、可分析的数据资产来建设,Serilog 基本是避不开的一环。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Serilog 的核心概念:别把结构化日志用成高级 StringBuilder
2.1 Logger、Sink、Enricher、Formatter 之间的关系
很多人上手 Serilog 时最容易犯的错误,是把所有配置都堆在 Program.cs 里,不管什么日志都往控制台和文件里各写一份,然后发现字段不对、级别不对、上下文丢失,却不知道去哪里排查。所以我建议先在心里建一个四层模型:
- Logger:业务代码唯一打交道的入口,你在 Service 里注入的
ILogger<T>本质上就是通过 Serilog 的 Provider 接到这条管线上的。 - Sink:日志写到哪儿。控制台、文件、Elasticsearch、Seq、HTTP,都是一个 Sink。
- Enricher:给每个日志事件附加额外字段的处理器,比如机器名、进程 ID、请求路径、用户 ID、TraceId。
- Filter / MinimumLevel:决定哪些事件可以流出,哪些被丢弃。
一个最基本的配置大概长这样:
csharp复制Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console(new RenderedCompactJsonFormatter())
.WriteTo.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7)
.CreateLogger();
这里面的顺序是有讲究的:先设置最小级别,再决定附加什么上下文,最后才决定输出到哪。很多人一上来就关心 Sink 的格式参数,反而忽略了 Enricher 和 Filter,结果日志里一堆想要的字段没有,不想要的框架噪音倒是一大堆。
从长期维护的角度看,我强烈建议把 Logger 的创建收敛在程序入口处,不要在每个类里各自 new LoggerConfiguration()。让业务代码只管调 ILogger,到底写到哪儿、怎么格式化,全部交给入口处的配置去管,这样将来接日志平台时只需要改一个地方。
2.2 消息模板的规则:占位符、@ 操作符、属性渲染
消息模板是 Serilog 最容易出彩也最容易踩坑的地方。它表面上看跟 C# 的字符串插值很像,但有几个关键差异值得花时间搞明白。
首先是占位符属性名。{UserId} 和 {userid} 在事件里会被视为同一个属性,默认输出时属性名保持原样,但在 JSON 格式化时通常会保留你在模板里写的大小写。为了让日志字段风格统一,最好在团队内部约定一套命名规范,比如全部用 PascalCase 或 CamelCase。
其次是 @ 操作符。直接写 {User} 时,Serilog 会对对象调用 ToString()。如果你的 User 对象没有重写 ToString(),得到的往往是一个类型名,比如 MyApp.Models.User,这在日志检索里毫无价值。想要把这个对象展开成结构化字段,需要在占位符前加 @:
csharp复制_logger.LogInformation("用户资料变更 {变化User}", ...); // 实际模板里别写中文哦
正确的是:
csharp复制_logger.LogInformation("更新用户资料 {@User}", user);
加了 @ 之后,Serilog 默认会按照对象的公共属性做序列化,生成嵌套结构或者打平的字段,具体取决于你用的 JSON Formatter。这个特性在处理"请求体""响应体""配置快照"这类复合对象时特别有用。
反过来还有一个操作符是 $,作用于字符串类型的属性,目的是让 Serilog 在渲染消息模板时照常格式化,但在结构化输出时保留字符串类型而不是再次序列化。说实话 $ 用得不如 @ 多,但知道它的存在可以避免在序列化时踩到"字符串被转义成 JSON"的怪问题。
最后是消息模板本身。@mt 里保留的是模板原文,比如 "用户 {UserId} 支付了订单 {OrderId}",而并不是拼接好的句子。在日志检索平台里,你可以拿 @mt 做聚合,统计某类事件出现了多少次,而不是对着一堆格式化后的文本做字符串匹配。这是结构化日志比传统文本日志在高阶分析上强很多的原因。
2.3 消息模板与字符串插值的性能差异
顺手提一个很多人问过的点:_logger.LogInformation($"...{userId}...") 和 _logger.LogInformation("...{UserId}...", userId) 在性能上有什么区别?
简单说,字符串插值会先把整个字符串拼好,哪怕日志级别是 Warning 甚至更高、这条日志根本不会被输出,拼接动作也已经执行了。而 Serilog 的消息模板走的是延迟绑定:先判断级别是否允许,不允许就直接返回;允许的话,再对模板里的占位符做属性绑定和格式化。
所以在高并发路径上,如果日志级别设得比较高,用消息模板能省掉大量无谓的字符串分配。这倒不是说性能差异一定大到肉眼可见,但作为工程习惯,ILogger 的语义本来就是"可能被丢弃的事件",不应该为它提前付出完整格式化的成本。
3. .NET 工程落地:从包选型到具体配置
3.1 包清单:别一上来就装一堆扩展
在 .NET 8 / .NET 6 项目里落地 Serilog,核心包不需要太多。我平时的基准清单是:
Serilog.AspNetCore:ASP.NET Core 项目的主集成包,包含Serilog、Serilog.Extensions.Hosting、Serilog.Settings.Configuration、Serilog.Sinks.Console等常用依赖。Serilog.Sinks.File:写文件用。Serilog.Formatting.Compact:用于输出 Compact JSON 格式,也就是带@t、@mt这种精简字段的事件格式。Serilog.Enrichers.Environment:附加机器名、进程名等环境信息。Serilog.Enrichers.Process和Serilog.Enrichers.Thread:按需安装,用来记录进程 ID 和线程 ID。Serilog.Sinks.Async:如果你需要把日志写入放到后台队列,减少业务线程的阻塞。
如果你是控制台应用、Worker Service 而不是 ASP.NET Core,那么可以不装 Serilog.AspNetCore,直接装 Serilog.Extensions.Hosting 加需要的 Sink 包。不要一上来就追求把所有 Sink 都装上,先用控制台和文件跑通一条链路,再决定要不要接 Elasticsearch 或 Seq。
很多项目的实际困境不是"没有日志",而是"日志太多太乱,不知道该看哪儿"。所以包选型阶段就要克制。
3.2 Program.cs 中的启动配置套路
以 ASP.NET Core 为例,比较稳的启动配置包括三个阶段:引导期、构建期、运行期。
引导期使用 CreateBootstrapLogger,这样在应用配置还没加载完全时就能记录启动异常:
csharp复制using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, configuration) =>
configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.WriteTo.Console(new RenderedCompactJsonFormatter())
.WriteTo.File("logs/app-.log",
rollingInterval: RollingInterval.Day,
fileSizeLimitBytes: 104857600,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 15));
var app = builder.Build();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "应用启动异常");
}
finally
{
Log.CloseAndFlush();
}
这里把 CreateBootstrapLogger 和 UseSerilog 配合使用,避免出现"启动早期日志丢失"的问题。我在生产环境排查过很多次启动失败,如果是配置阶段就炸了,而你的日志配置本身还没跑起来,那就什么线索都没有。有了 Bootstrap Logger 之后,起码能拿到一段控制台输出,帮你在第一现场定位。
使用 Host 的 UseSerilog 之后,你在控制器、Service、BackgroundService 里注入的 ILogger<T> 都会自动走 Serilog 的管线。这套机制的底层是 SerilogLoggerFactory,它把 ILogger 转成 Serilog 的 Log.Logger 再写入。
3.3 appsettings.json 配置方式与 ReadFrom.Configuration
把配置放进 appsettings.json 是常见做法,好处是可以不改代码就切换输出方式和级别。它的基本结构长这样:
json复制{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
},
"Enrich": [ "FromLogContext", "WithMachineName" ],
"WriteTo": [
{ "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact" } },
{ "Name": "File", "Args": { "path": "Logs/log-.log", "rollingInterval": "Day" } }
]
}
}
这里最烦人的是 Formatter 的配置。如果你在代码里写 .WriteTo.Console(new RenderedCompactJsonFormatter()),非常直观;但放到 JSON 里就得写全类型名和程序集名,像这样:
code复制Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact
少了逗号、拼错程序集名、或者忘了引这个命名空间,启动时就会报 Could not create 之类的错误。我的建议是:第一次搭的时候全部用代码写,跑通了以后再逐步迁到 JSON。不要一边踩 Formatter 的类型名坑一边学配置系统,容易分心。
另外一个高发问题是 MinimumLevel 的 Override 段。ASP.NET Core 框架日志量很大,如果默认级别是 Information,每次请求过来你会看到一堆 Request starting、Request finished、Executed action 之类的框架日志。这些日志不是没用,但在正常级别的生产环境通常是噪音。用 Override 把 Microsoft.AspNetCore 调到 Warning,能有效降低日志量,保留业务命名空间的完整级别即可。
关于嵌套配置还有一个细节:Serilog:MinimumLevel:Default 是小写敏感的,你写成 information 它也能接受,但为什么我仍然建议代码配置优先呢?因为 JSON 配置没有编译期检查和模板补全,一旦某个 Sink 的 Arg 名写错了,问题通常会推迟到运行时才爆发,比如日志一条都没有,或者启动直接崩。代码配置再怎么说也是强类型,IDE 会提示方法签名,对新手友好得多。
3.4 WriteTo.File 的文件名格式与滚动规则
"Serilog WriteTo.File 文件名格式"是搜得很勤的一个问题。坦白说我自己也被这个绕晕过,这里把规则说透。
WriteTo.File 的 path 参数里如果带有 - 加扩展名,Serilog 会根据 rollingInterval 自动在中间插入日期,比如:
csharp复制.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
最终生成的文件是:
code复制app-20240518.log
app-20240519.log
如果你希望按小时切分,就用 RollingInterval.Hour。Serilog 支持 Minute、Hour、Day、Month、Year 等粒度,但没有默认的"按小时但文件名带分钟"这种自定义格式。想自定义日期格式的话,你需要关注 Sink 的 path 里的时间戳格式选项,或者用更冷门的 LogFile 生命周期实现。
很多人在网上问"我 path 写 app-{Date}.log 为什么不行",因为那是 log4net / NLog 的习惯写法,Serilog 不把 {Date} 当占位符处理。你要么用 rollingInterval 自动生成,要么明确写成:
csharp复制.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
注意:文件名里的日期格式不是通过 outputTemplate 控制的。outputTemplate 控制的是每条日志内容长什么样,比如你要不要带时间前缀、级别缩写、异常堆栈等。想改文件名里的日期格式,Serilog 没有提供直接的 filenameFormat 参数,最接近的替代方案是继承 RollingInterval 或者手动指定路径模板。
还有两个必踩的文件参数坑:
fileSizeLimitBytes默认值是 1GB,对大多数业务可能偏大。建议显式设置成 100MB 或更小,并且配合rollOnFileSizeLimit: true。retainedFileCountLimit默认值是 31。如果你按天滚动,默认会保留 31 天;但如果你只想要 7 天,不显式设置的话,磁盘上会积压一个月的日志。
曾经有个服务因为忘设保留数量,日志文件从 31 天一路涨到几十 GB,把数据盘写满了。这个教训告诉我,文件类 Sink 的容量参数一定要在建日志的第一天就定好,不然后期清理成本和事故风险都不小。
最后补一句:多个进程写同一个文件路径是可以的,但不推荐。要让 Serilog 支持跨进程写同一文件,需要配置 shared: true。如果你跑的是多实例部署,我倾向在文件路径里带机器名或实例 ID,避免多个实例同时往同一文件里挤,排查时也能区分是哪个节点打的日志。
4. 工程化的关键细节:上下文关联、过滤级别、敏感信息边界
4.1 用 LogContext 与 FromLogContext 关联请求维度数据
在 Web 服务里,排查问题最刚需的几个维度通常是:请求 TraceId、当前用户、客户端 IP、具体接口路径。如果这些信息不是每条日志自动携带,等你翻日志时再去找链路,会费很多劲。
Serilog 的解法是 LogContext 配合 Enrich.FromLogContext()。逻辑很直白:你在某个作用域内 Push 进去的属性,会自动附加到该作用域内产生的所有日志事件上。
csharp复制using Serilog.Context;
public async Task<IActionResult> PayOrder(int userId, string orderId)
{
using (LogContext.PushProperty("UserId", userId))
using (LogContext.PushProperty("OrderId", orderId))
{
_logger.LogInformation("开始处理支付");
// 业务逻辑...
_logger.LogInformation("支付成功,跳到回调");
return Ok();
}
}
FromLogContext 这个 Enricher 负责在执行日志写入时读取当前上下文里暂存的字典,把它合并到日志事件里。配置项有两种方式:
代码:
csharp复制.Enrich.FromLogContext()
或 JSON:
json复制"Enrich": [ "FromLogContext" ]
不过有一个很大的坑:LogContext 对异步流程的感知并非永远可靠。在常规的 async/await 里,因为 ExecutionContext 会流动,绝大多数场景没问题。但是如果你把日志记录放到 Task.Run 里,或者消息队列消费线程和 Web 请求线程不是同一个,上下文就可能丢。因为负责记录日志的工作线程上根本没有执行过 Push。
我在排查一个消息消费服务时就遇到过这类事件:处理器从队列拿到消息后,代码本身没有在消费线程里重新 Push TraceId,日志里就永远看不到消费链路 ID,排查链路成了查字典靠猜。解决方式是显式把关键字段传给后台任务,或者在消费线程的入口处重新 Push。
还有一个反模式是:把 LogContext.PushProperty 放在一个不主动 Dispose 的静态作用域里,导致后续线程的日志被上一个请求的脏上下文污染。日志里出现 UserId 错乱、请求 ID 对不上,这类问题通常就是作用域没管理好。所以在用 LogContext 时,请务必保证 Dispose 时机跟业务作用域一致,能放在 using 块里就别手动保存返回值。
4.2 级别控制:MinimumLevel、Override 与 Filter 的搭配
级别控制看起来简单,实际上有很多组合。默认的级别链是:
code复制Verbose < Debug < Information < Warning < Error < Fatal
当你在配置里写了 MinimumLevel.Information(),等价于让 Verbose 和 Debug 直接没有输出机会。这个"上游过滤"非常有效,因为它能避免你为不输出的日志付出格式化和 Sink 写入成本。
在 ASP.NET Core 里你还会遇到另一个级别的来源:builder.Logging。很多人的认知盲区在于,ILogger 是经过两层过滤的:
- 第一层是
Microsoft.Extensions.Logging的LoggerFilterRule列表。 - 第二层才是 Serilog 自己的 MinimumLevel 和 Filter。
结果就是,你明明在 Serilog 里把 MinimumLevel 调成了 Debug,但日志还是不来。查了半天发现是 appsettings.json 里的 Logging:LogLevel:Default 是 Information,微软的过滤规则把 Debug 在更上游就拦截了。
我的建议是:如果你用 Serilog 的配置来管级别,就把微软的默认日志过滤器全部清掉或者显式覆盖,否则两套系统同时在管,排查成本翻倍。代码里可以这样做:
csharp复制builder.Logging.ClearProviders();
builder.Host.UseSerilog(...);
如果你同时想看框架内部日志,也可以只清掉和 Serilog 冲突的部分:
csharp复制builder.Logging.AddFilter("Microsoft", LogLevel.Warning);
builder.Logging.AddFilter("System", LogLevel.Warning); // 按需
Silencing noisy framework logs is often achieved by adjusting the "Microsoft" / "System" categories,而业务相关的 namespace 保持 Information 级别即可。
如果还需要更精细的过滤条件,Serilog 提供 Filter.ByIncludingOnly 和 Filter.ByExcluding,可以写基于表达式或委托的规则。举个例子:
csharp复制.Filter.ByIncludingOnly(evt =>
evt.Level >= LogEventLevel.Warning ||
evt.MessageTemplate.Text.Contains("Payment", StringComparison.OrdinalIgnoreCase))
意思是:只保留 Warning 以上事件,或者消息模板里包含 Payment 字样的所有级别事件。这个规则适合"平时只记重要信息 + 关键业务链路全量打点"的需求。
在 JSON 配置里也可以用表达式写法,但需要引入 Serilog.Expressions 包,而且表达式写错通常只在运行时报。个人体验是,复杂的过滤规则尽量放到代码里,配合测试去验证,只有极其简单的规则才进配置文件,不然维护的人会在半年后对着一段神秘的表达式发愁。
写这套过滤逻辑时,一条重要的准则是:过滤器只摘所需,不要过度设计。比如你为了排查某个历史问题加了一个 ByExcluding 规则,问题解决了却不删,一个月后你会发现日志里莫名少了一类关键消息,而过滤条件自己都不记得了。
4.3 敏感信息治理:结构化日志暴露的数据面比以前更大
日志从非结构化走向结构化之后,突然多了一个头疼问题:以前大家写文本日志时,可能无意中把手机号、邮箱、身份证号拼进去,想在日志平台里精确检索,还得先用正则去匹配,检索效率极低。现在好了,事件里每个字段都被 JSON 序列化得清清楚楚,敏感字段反而更容易被聚合和暴露。
所以工程落地阶段必须把敏感信息当成一等公民来治理。我的几条落地看法:
- 日志模板里不推荐传入整个实体对象。尤其是在更新场景,把整个 User 模型、Order 模型带进日志,极容易把不该留的字段也顺带输出。正确做法是抽一个白名单 DTO,只保留需要排查的字段,比如 Id、状态、金额、时间。
- 必要时写一个自定义 Enricher 做脱敏。可以对手机号、邮箱、证件号做统一掩码。Serilog 允许你在 Enricher 里遍历日志属性,对属性名或者属性值做规则处理。比如一个
MaskPhoneNumberEnricher,在输出前把符合手机号形态的字符串替换成138****5678。 - 审计类日志如果必须要记录原始值,建议独立建库 / 索引,在写入端做权限隔离,不给普通日志查询接口开放全部字段。
很多做过数据安全的人会强调,日志里出现敏感字段不光是一个技术问题,还是一个合规问题。即便现在没有硬性要求,等你做等保测评、客户审计的时候再去回溯清理日志,成本和风险都会成倍增长。所以我的建议是:从第一天起,日志字段就要有规范,至少要明确"哪些字段不能进日志"这个负面清单,配合 Code Review 去执行。
4.4 异步 Sink 和缓冲:要不要用 Serilog.Sinks.Async
日志写入是不是会阻塞业务线程,这是很多高并发项目关心的问题。默认的 File Sink 在执行写入时是调用线程直接写的,虽然单条日志写入文件通常在微秒到几毫秒级,但在并发量极大的路径上,频率一高依然会影响请求尾延迟。
解决办法是引入 Serilog.Sinks.Async,把日志写入操作放到后台的专用线程上,业务线程只把日志事件放进一个内存队列就返回。配置方式:
csharp复制.WriteTo.Async(a => a.File("logs/app-.log", rollingInterval: RollingInterval.Day))
用异步 Sink 之后,文件日志写入不再阻塞当前处理线程。代价是进程异常退出或有日志事件仍在队列中时可能会丢一部分日志。如果你对日志可靠性要求极高,比如要做对账、要追踪资金链路,那建议直接去掉 Async,用可靠的文件 Sink 或者走网络发送到日志平台,靠接收方的持久化来保证。
什么时候值得用异步?
- 每秒日志量较大,比如超过几千条,File Sink 写文件的时间开始影响接口吞吐。
- 你对日志丢失容忍度较高,更多是希望业务线程不卡顿。
不需要用的情况也很明显:业务量不大,日志文件写不写都无所谓,这时候引入 Async 就白白增加复杂度。
5. .NET 落地中的常见问题与排查技巧
5.1 从热词里翻出来的高频问题:文件名、级别、格式化
搜过 Serilog 相关热词的朋友应该能感受到,日常踩坑大多不是高深原理,而是很琐碎的细节。我把它们整理成一个速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| File Sink 没有生成文件 | 最小级别太高或 Filter 把所有事件拦掉了 | 先检查 MinimumLevel;临时去掉 Filter,调用一次 Log.Information 确认事件能进来 |
| 文件名没有按天滚动 | path 里只写了 app.log 没加 -,或没有设置 rollingInterval |
使用 app-.log + RollingInterval.Day,生产环境显式设置保留数量 |
| 所有日志都打成了一行很长的字符串 | 没有配置任何 JSON Formatter,消息模板被当作文本模板渲染 | 想输出结构化 JSON 就必须为 Sink 指定 CompactJsonFormatter,例如 CompactJsonFormatter 或 RenderedCompactJsonFormatter |
| 日志里的对象输出成类型名 | 占位符没有加 @ 操作符 |
使用 {@User} 让 Serilog 按对象结构序列化,或先转成 DTO |
LogContext.PushProperty 在异步方法后半段丢失 |
上下文作用域结束或跨线程执行 | 确认 using 生命周期覆盖整个逻辑;跨任务场景显式传递字段 |
| 关闭应用时最后几条日志缺失 | 没有在 finally 里执行 CloseAndFlush |
在 Program.Main 的 finally 中调用 Log.CloseAndFlush(),给缓冲一个强制排空的机会 |
| 调低 Serilog 的 MinimumLevel 后 Debug 日志还是不出来 | 微软 Logging 的全局级别过滤没有放开 |
检查 builder.Logging 的过滤器,清掉 provider 或在两套配置中都放行 Debug |
| 文件里出现内容错乱的串行日志 | 多进程写同一个文件,且没有启用 shared 或没有区分文件名 | 使用 shared 或按机器名拆分路径;建议路径带 Environment.MachineName |
5.2 我踩过的格式化器和配置加载的那些坑
格式化器这边我吃过的最大一个亏,是把 RenderedCompactJsonFormatter 和 CompactJsonFormatter 搞混。这两个名字看着很像,行为差异却很明显:
CompactJsonFormatter:输出的事件里@mt保留模板原文。消息模板里占位符的值是独立属性,但 message 本身不会渲染成人能直接读的句子。RenderedCompactJsonFormatter:输出的事件里除了@mt,还会多一个@m字段,这个字段是已经渲染好的消息文本,比如"用户 10086 支付了订单 ORD202405180018",方便人眼在日志平台里快速浏览。
如果你既希望日志中的人读文案完整,又希望机器能解析字段,用 RenderedCompactJsonFormatter 会更省心。当年我一开始选了 CompactJsonFormatter,在 Seq 里看到日志事件没有渲染文本,整个人愣了十分钟,最后才发现是格式化器选错了。
配置加载的另一个坑是 ReadFrom.Configuration 必须在 UseSerilog 的回调里调用,而且要确保传入的是同一个 IConfiguration。如果你配置了 Serilog 节点,但忘写 ReadFrom.Configuration,那整个 appsettings.json 里的 Serilog 设置全部不会生效。代码会静默地使用你在 LoggerConfiguration 里硬编码的内容,看起来"级别没用 JSON 里的值"、"Sink 没写在 JSON 里"等等,排查方向容易跑偏。
5.3 结构化日志平台接入:本地文件、JSON 输出、Elastic 搜索
聊到这里,很多人会问:文件日志和 JSON 日志到底哪个适合生产?我的答案是:本地文件是为了给人快速看,JSON 是为了让日志平台消费。
把 Serilog 接入 Elasticsearch / 其他日志中台,通常会走两种方式:
- 方式一:应用直接输出 JSON 到标准输出/文件,由 Filebeat、Fluentd 这类采集器收集后统一写入 Elasticsearch 或云日志服务。这种方式适合大部分场景,因为你的应用不直接依赖日志平台的可用性,采集失败也不影响业务。
- 方式二:用 Serilog 的
ElasticsearchSink/HttpSink直接把日志推到日志平台。好处是链路短,坏处是日志平台的故障或网络抖动可能反向影响业务线程,所以一般还要配合异步 Sink 做缓冲。
如果你用的是云厂商的日志服务,最常见的接入方式是方式一。应用日志采用 JSON 格式输出到控制台,比如在容器里,Console Sink + RenderedCompactJsonFormatter 是最常见的组合。这样容器标准输出本身就是 JSON 流,采集器逐条读取后解析字段,宿主机上只驻留采集进程,不需要额外的日志卷来落文件。
这里有一个细节需要留意:如果直接输出 JSON 到控制台,且你的日志里带了 Exception,Sink 会通过 @x 字段把异常堆栈序列化成一个多行字符串。在日志平台里解析这个字段时,要确认你的采集器能正确处理多行 JSON 值,否则可能出现一条日志被拆成多行的现象。很多云日志服务默认按整行 JSON 解析,遇到多行堆栈就会炸,需要在采集端配置单条日志的拆分方式。
6. 写在最后的工程习惯
如果一个项目让我去检查日志系统,我会先问几个问题:日志默认级别是几?框架日志有没有被调成 Warning?有没有用 LogContext 把 TraceId、UserId 打到关键日志上?文件日志有没有设置滚动和保留数量?输出有没有用 JSON?答案是"没做"的地方,我就会保持怀疑态度。
这几年做 .NET 服务,我最深的一个体会是:日志系统的设计会直接影响你排查线上问题的能力。Serilog 提供了一套非常顺手的工具箱,但真正让日志发挥价值的,是团队把日志当成数据来管理的意识和一套被严格执行的规范。很多团队引入 Serilog 后,代码里确实不再拼字符串了,但消息模板写得很随意、上下文不加、敏感字段不脱敏、Sink 没配好,最后得到的依然是一堆看不了的结构化噪音。
如果你正准备在项目里引入 Serilog,建议按这个顺序来:先把消息模板这关过了,所有日志都改成模板写法;然后在入口处配好控制台和文件 Sink,保证能落盘;接着把 FromLogContext 和 HttpContext 里的关键信息推上去;最后再考虑接日志平台、调过滤规则。别一开始就想把 Seq 或者 Elasticsearch 全上齐,先让日志能稳定输出,再做消费端的事。
最后分享一个我在新项目里必加的"日志自检"函数,方便所有人快速验证 Serilog 链路是否正常:
csharp复制public void Diagnose()
{
Log.Information("Serilog 链路自检通过:{Timestamp} {DiagnoseKey}", DateTimeOffset.UtcNow, Guid.NewGuid());
}
写完跑到控制台或者文件里看一眼,如果能看到带 DiagnoseKey 的 JSON 字段或者模板输出,就说明管线至少是通的。这种小函数在对接日志平台时最有价值,它能帮你快速区分"应用没写日志"和"日志平台没采集到"两类问题。等你把日志系统真正跑通了,再回头看之前用字符串拼日志的日子,会明显感受到结构化带来的检索和排障效率差距。
