1. 先说清楚:为什么日志必须要“结构化”
1.1 传统日志的尴尬:能看,但没法用
很多 .NET 项目跑了好几年,日志文件堆积如山,可真到线上出问题的时候,你打开那个几百 MB 的 txt 文件,看到的全是这种玩意:
2024-05-11 14:23:01.025 [Error] 用户下单失败:订单号 100234 金额 199.00 用户ID 8888 异常:数据库连接超时 2024-05-11 14:23:01.031 [Info] 用户 8888 尝试重新下单咋一看,信息好像都有。订单号、用户ID、异常原因,一个不少。但仔细想一下:你靠肉眼从几百 MB 文件里搜“数据库连接超时”,搜出来了又怎么统计?这 10 分钟里到底有多少用户下单失败?失败原因分布是什么?哪个接口耗时最长?这些问题,字符串日志几乎没法回答。
再说难听点,传统日志的“可读”是假象。它只是把一堆变量拼成了一段话,拼好之后,这些变量就永远变成了一段不可拆分的文本。想要按“用户ID=8888”筛日志?正则抠吧,抠出来还是字符串。想要按“错误码”做聚合报表?等着哭吧。这种日志,说白了就是给人看的,不是给机器用的,更不是给监控系统用的。
我见过太多团队,日志系统建设了几年,最后线上排障还是靠“大家把日志文件拉到本地,用 Notepad++ 搜关键字”,效率低得离谱。你问为什么不建日志平台?答曰:日志格式太乱,采集进来也没法分析。你看,问题根子就出在“日志格式”上。
1.2 结构化日志的本质:日志不再是“一行字”,而是一批“键值对”
结构化日志的核心思路特别简单,就一句话:每条日志不再是一个字符串,而是一个结构化的事件对象,由若干字段(键值对)组成。
拿刚才那条下单失败日志举例,在 Serilog 里你可能会写成:
_logger.LogError("用户下单失败,订单号 {OrderNo}, 金额 {Amount}, 用户ID {UserId}, 原因 {Reason}", orderNo, amount, userId, ex.Message);Serilog 不会把它拼成字符串存下来,而是记录成类似这样的结构:
{ "Timestamp": "2024-05-11T14:23:01.025+08:00", "Level": "Error", "MessageTemplate": "用户下单失败,订单号 {OrderNo}, 金额 {Amount}, 用户ID {UserId}, 原因 {Reason}", "OrderNo": "100234", "Amount": 199.00, "UserId": 8888, "Reason": "数据库连接超时", "Exception": "..." }注意,这里的“MessageTemplate”只是模板,真正的价值在后面的字段。所有变量都被独立保留下来了。这意味着什么?意味着日志终于可以被程序批量消费了:按 UserId 检索、按 Amount 做数值聚合、按 Reason 做分布统计,全都变成可能。如果你把日志写到 Elasticsearch、ClickHouse 这类存储里,那就是天然的表格,直接 SQL 查询。
结构化日志还有个隐藏好处:字段名从你写代码那一刻就固定了,后面无论多少人改代码,日志的 schema 不会乱动。这为日志平台建设、告警规则编写、甚至是团队协作提供了稳定的基础。字符串日志做不到这一点,每个人写出来的拼法都不一样,模板千奇百怪。
1.3 Serilog 在 .NET 生态里的角色
.NET 生态里的日志库不少,早期有 NLog、Log4Net,官方后来也搞了个Microsoft.Extensions.Logging.Abstractions(简称 MEL)。那为什么我还要单独讲 Serilog?
原因有三个:
第一,Serilog 是“结构化日志”这个理念的坚定践行者。它的核心抽象就是 MessageTemplate,从根上就是把日志当作“事件 + 字段”来处理,而不是“文本 + 换行符”。NLog 和 Log4Net 虽然也支持结构化,但设计上多少还残留着字符串日志的惯性。
第二,Serilog 的Sink(输出管道)生态非常丰富。控制台、文件、Seq、Elasticsearch、ClickHouse、PostgreSQL、MongoDB、HTTP、SignalR…… 你能想到的输出目标,几乎都有现成的包。这意味着你的日志可以非常方便地接入各种监控体系。
第三,Serilog 和 Microsoft.Extensions.Logging 是兼容的,不是替代关系。你可以在 ASP.NET Core 项目里把 Serilog 作为底层 Provider 挂到 MEL 上,这样你用框架自带ILogger<T>写的日志,和用 Serilog 的Log写的日志,走的是同一条输出管道。这种“旧代码不动,新能力落地”的演进方式,非常适合已经有一定规模的存量项目。
所以,这篇文章我不想只讲 API 怎么用,我想从“结构化日志到底是什么”开始讲,一直讲到怎么把一个真实项目的日志体系逐步 Serilog 化。这中间有认知层面的东西,也有工程层面的落地细节,还有我踩过的一堆坑。
2. 搞懂 Serilog 的三个核心概念,后面的路就顺了
2.1 Logger:不是 new 出来的,是配置出来的
很多刚接触 Serilog 的人会困惑:为什么Log.Logger是个静态属性,而不是像new Logger()这样用?这其实是 Serilog 的一个重要设计——Logger 的生命周期是“配置”出来的,不是“创建”出来的。
你在入口处做一次配置,后面所有地方都能通过静态入口写日志:
Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() .WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day) .CreateLogger();然后就Log.Information("程序启动了,版本 {Version}", version);,完事。
这种设计的直接好处是:配置和调用彻底解耦。怎么输出、输出到哪、什么级别才输出,这些都是配置项。业务代码里你只需要表达“发生了什么,关键数据是什么”,不需要关心日志去向。以后想加个数据库输出,或者调高某个模块的日志级别,改配置就行,业务代码一行不动。
在工程里我建议用HostBuilder或WebApplicationBuilder来配置,而不是在一个裸类里直接调LoggerConfiguration。原因很实在:框架已经帮你管理了配置系统、环境变量、依赖注入,你不蹭这趟车,反而要自己去读配置文件、处理环境差异,纯属重复造轮子。
这里有个概念要先理清:Serilog 的LoggerConfiguration是一套构建器,它管的是“这个 Logger 具体怎么干活”。一旦CreateLogger()之后,这个 Logger 的行为就固定了。你在运行中改不了它的 Sink、改不了它的级别。如果要动态调整,得靠MinimumLevel的 override 或者过滤器机制,后面我会细说。
2.2 Sink:日志往哪写,决定了你能拿它干什么
Serilog 把“日志输出到哪”抽象成了Sink。这个词直译叫“水槽”,很形象——日志就是水流,Sink 就是接水的池子,不同池子干不同的活。
常见的 Sink 大致分三类:
| Sink 类型 | 典型包 | 适合场景 |
|---|---|---|
| 本地开发 | Serilog.Sinks.Console、Serilog.Sinks.Debug | 本地调试、快速看输出 |
| 文件/持久化 | Serilog.Sinks.File | 单机服务、简单部署、无集中日志平台 |
| 集中存储/分析 | Serilog.Sinks.Seq、Serilog.Sinks.Elasticsearch、Serilog.Sinks.ClickHouse、Serilog.Sinks.PostgreSQL | 多实例服务、Prod 环境排障、监控告警 |
开发阶段用 Console 就够,看个痛快。到了测试环境,通常就会加文件输出,方便把日志从容器里拉出来。到了生产环境,如果实例多,文件日志基本没法看——你要去几十台机器上翻文件?还不如直接接到集中的日志平台。
我自己的习惯是:本地开发只开 Console,测试环境开 File + Console,生产环境开 File(留底)+ 集中平台(用于分析)。有的人会说,生产只接平台就行,不要 File。我不同意,因为日志平台本身也可能挂,留一份文件兜底,排障时多一条路。
另外提醒一句,Serilog.Sinks.File 里的“滚动日志”(rolling file)这个功能特别实用。rollingInterval: RollingInterval.Day意思是每天一个文件,不会无限膨胀。你可以再配一个retainedFileCountLimit来控制最多保留多少天,避免磁盘被日志塞爆。
2.3 Enricher 与 MessageTemplate:先有模板,再有内容
这两个概念是 Serilog 区别于传统日志库的核心,我放在一起讲。
MessageTemplate就是你写日志时传的那个字符串模板,里面用{FieldName}占位。注意,这个占位不是普通的字符串插值,它是被解析出来的字段名。所以 Serilog 要求你写模板时花点心思:
- 字段名用大驼峰或小驼峰,别用中文和空格;
- 一个字段名全局统一,别一会儿
UserId一会儿user_id,后面分析时你会感激自己的强迫症; - 模板本身保持稳定,别把动态内容拼进模板字符串里。
"用户 {UserId} 下单失败"和"用户 " + userId + " 下单失败"在 Serilog 里是两种完全不同的东西:前者是模板 + 字段,后者就是一段拼好的字符串,字段信息全丢了。
Enricher则是“往日志事件里附加额外字段”的机制。比如你想在每条日志里都带上当前应用名、机器名、进程号、用户名,不需要到处手写,配一个 Enricher 就行:
Log.Logger = new LoggerConfiguration() .Enrich.WithMachineName() .Enrich.WithProcessId() .Enrich.WithProperty("Application", "OrderApi") // ... .CreateLogger();WithProperty最灵活,可以附加任意静态字段。比如你在容器环境里,把实例 ID、版本号附到每条日志上,排查“为什么 3 个实例里只有 1 个有问题”时,这就是救命的数据。
理解 MessageTemplate 和 Enricher 的价值,是“结构化日志认知”最关键的一步。在你开始写第一行 Serilog 代码之前,我建议你先梳理一下:我的业务里有哪些核心维度是每次排障都要用的?比如订单号、用户ID、商户ID、接口名。把这些设计成固定字段,在写日志时保证它们都在场。这样,日志平台的检索效率会高出好几个量级。
3. 工程落地:从零到一个能上生产的 Serilog 配置
3.1 最小接入:三个包、五行代码、先跑起来
我建议先在一个干净的 .NET 项目里把链路跑通,再往正式项目里搬。最小配置只需要一个 NuGet 包:
dotnet add package Serilog dotnet add package Serilog.Sinks.Console dotnet add package Serilog.Sinks.File如果是 ASP.NET Core 项目,我还要加两个:
dotnet add package Serilog.AspNetCore dotnet add package Serilog.Extensions.Hosting先写一个最小示例,确认日志能正常输出:
using Serilog; Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() .WriteTo.File("logs/demo-.log", rollingInterval: RollingInterval.Day) .CreateLogger(); Log.Information("Demo 服务启动,环境 {Environment},版本 {Version}", "Development", "1.0.0"); try { throw new InvalidOperationException("模拟一个业务异常"); } catch (Exception ex) { Log.Error(ex, "业务处理失败,订单号 {OrderNo}", "A10086"); } Log.CloseAndFlush();跑起来后,控制台能看到彩色的日志,logs目录下会出现一个demo-20240511.log文件。大功告成。
注意那个Log.CloseAndFlush()。很多人会漏了它。它负责在进程退出前把缓存里的日志刷到 Sink 里。如果你在程序崩溃、强制杀进程的时候发现“最后几条日志丢了”,多半就是没等它 flush。在 Web 程序里,这个动作一般挂在ApplicationStopped或IHost停止时。
3.2 用 appsettings.json 管理配置,和环境解耦
控制台直接写LoggerConfiguration没有错,但工程上我更推荐把配置放进appsettings.json,然后通过ReadFrom.Configuration读取。这样不同环境(开发、测试、生产)只需要改配置文件,不用动代码。
先装扩展包:
dotnet add package Serilog.Settings.Configuration在appsettings.json里加一段:
{ "Serilog": { "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information", "System": "Warning" } }, "Enrich": [ "WithMachineName", "WithThreadId" ], "Properties": { "Application": "OrderApi" }, "WriteTo": [ { "Name": "Console", "Args": { "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" } }, { "Name": "File", "Args": { "path": "logs/app-.log", "rollingInterval": "Day", "retainedFileCountLimit": 15, "shared": true, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}" } } ] } }然后在Program.cs里配置:
var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog((context, services, configuration) => { configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services); }); var app = builder.Build(); app.Run();这里有一个很容易踩的坑:ReadFrom.Configuration读的是当前进程的IConfiguration,所以必须在builder初始化之后读取。我见过有人把UseSerilog写在配置系统加载之前,结果程序跑起来日志全无,还以为是包没装好。
再解释一下MinimumLevel.Override的作用。ASP.NET Core 框架内部有大量信息日志,比如每个请求的路由匹配、鉴权结果,全开的话日志量会非常恐怖。Override就是把某个命名空间下的日志级别单独压住。上面配置里,Microsoft级别被压到 Warning,这能让你的日志文件里少掉 80% 的噪音。等你排查框架本身的问题时,再临时把Microsoft调回Information或者Debug。
3.3 文件日志的正确姿势:滚动、分级、格式化
文件 Sink 是最常用的,也是最容易被写坏的。我维护过好几个项目的日志系统,几乎每个项目都有人在“文件日志”上摔过跤。所以这块我要多写几句。
第一,滚动策略要想清楚。我见过有人开到RollingInterval.Day还不够,还要.Infinite,也就是不限制文件数。结果跑三个月,磁盘被日志堵得死死。日志是拿来排查的,不是拿来当古董收藏的。一般服务,保留 7~30 天足够了,长于那个时间的历史日志,真用到时价值也极低。我习惯 15 天左右。
第二,多进程/多实例写同一个文件,必须开shared: true。在 Kestrel 里,一个进程可能对应多个线程同时写日志,如果不共享,会出现文件被占用的异常:IOException: The process cannot access the file because it is being used by another process.开shared之后,Serilog 会用一个跨进程的文件锁来协调写入,虽然性能略降,但稳定得多。
第三,用outputTemplate控制格式,但别丢了结构化。文件里你想让人眼看得舒服,控制台和文件可以用不同的模板。但我要强调:文件里可以没有 JSON 格式,但字段信息不能丢。像{Message:lj}中那个:lj是“字面值 + JSON”的格式化选项,它会把消息里嵌入的字段值以 JSON 风格呈现,方便你阅读时定位。
如果你想直接输出 JSON 格式的文件,给后续的日志采集器用,那也很简单,Serilog.Formatting.Compact包里的CompactJsonFormatter就够:
.WriteTo.File(new CompactJsonFormatter(), "logs/app-.json", rollingInterval: RollingInterval.Day)这种 JSON 文件一行为一条事件,机器解析非常友好。如果你的日志会送到 Filebeat、Logstash 这类采集器,强烈建议用这个格式,解析成本极低。
第四,日志文件编码和时区问题。Windows 下日志文件默认可能输出 GBK 编码,如果你要采集到 Linux 上的日志平台,建议显式指定 UTF-8。Serilog 的FileSink 一般会输出 UTF-8,但我还是见过去日志平台后中文乱码的情况,多半是采集器判断编码失败。稳妥起见,你可以在文件输出里加.WithFileExtension("log"),但真正要留意的是,不要再用File.WriteAllText这种私有方式混写日志文件,绕过了 Serilog 的锁和编码逻辑,乱套是迟早的事。
3.4 接 ASP.NET Core:请求日志、中间件和异常处理
Serilog 接 ASP.NET Core 之后,有一个功能一定要开:请求日志中间件(RequestLogging)。它能在每个请求结束时输出一条汇总日志,包括 HTTP 方法、路径、状态码、执行时间、客户端 IP。就这一条日志,够你排查 90% 的性能和可用性问题。
开启方式很简单,在管线里加上:
app.UseSerilogRequestLogging();注意,它要放在路由匹配、异常处理中间件之前。这样它才能捕获到后续所有中间件和 MVC 的异常信息。如果不放对位置,你会发现自己收到的请求日志总是残缺的,状态码永远 200。
开启后,每个请求会输出类似这样的日志:
[15:23:01 INF] HTTP GET /api/order/100234 responded 200 in 125.45 ms默认模板已经够用。但如果你想自定义,可以传一个RequestLoggingOptions,把EnrichDiagnosticContext委托里加上自定义字段,比如当前登录用户、请求体大小。这个后面讲“上下文”时再展开。
另外,ASP.NET Core 的异常处理,建议配合 Serilog 一起。控制器和中间件里的try-catch不要只记录一个异常对象就完事,尽量把上下文信息一起带上:
catch (Exception ex) { _logger.LogError(ex, "订单查询失败,OrderNo {OrderNo}", orderNo); throw; // 或转换成 500 响应 }如果你用的是IApplicationBuilder的UseExceptionHandler,它内部只能看到ExceptionHandlerFeature,拿不到业务参数,所以我更倾向在 Service 层或者 Controller 层 catch,把业务字段补全后抛给全局异常过滤器去转 HTTP 状态码。这个模式各家团队有各家习惯,但共同点是:日志里必须能还原出“哪个业务对象”“哪个操作”出了问题,而不是只有个堆栈。
4. 进阶玩法:让日志从“记录错误”变成“追踪现场”
4.1 LogContext 与 Scoped:给日志加上“操作上下文”
只靠单个方法里的日志字段,很多场景是不够的。比如一个请求进来,经过中间件 → 控制器 → Service → Repository,每一层都可能写日志。如果每层只记自己的局部字段,你很难把这几条日志串起来:它们到底是同一个请求还是一个请求的多次调用?
解决这个问题的关键叫LogContext(日志上下文)。它允许你在一个作用域范围内,给所有日志事件附加公共字段。
看一个最简单也是最有用的例子:
using (LogContext.PushProperty("OrderNo", orderNo)) using (LogContext.PushProperty("UserId", userId)) { _logger.LogInformation("开始处理订单"); // 这里面的所有日志都会带上 OrderNo 和 UserId _logger.LogInformation("扣减库存完成"); _logger.LogInformation("发送通知完成"); }里面的三条日志,每一条都会自动携带OrderNo和UserId。你不用在每条日志里手动传参数。这不仅仅是方便,更重要的是:你想往这个操作的所有日志里加入新字段时,只需要在一个地方加,而不是去改所有日志调用。
在 ASP.NET Core 里,LogContext还能和依赖注入的ILogger<T>配合使用。你可以在中间件里 Push 那个请求的上下文,然后整个请求生命周期内的日志都带着这些字段,请求一结束,字段自动弹出。
有个细节要注意:LogContext.PushProperty返回一个IDisposable,用using包住。在异步代码里,千万别跨await太久不释放,Serilog 内部使用异步执行上下文(AsyncLocal)来做值传递,理论上是可以跨await的,但你如果在请求结束后才释放,可能导致字段泄漏到下一个请求里,这个非常难排查。
4.2 关联Id:一条链路串起所有日志
Web 项目排障时最常用的手段就是“关联 ID”。一个请求从网关进来,带上一个TraceId,后续所有日志都记录这个 ID,这样你就能在日志平台里输入一个 ID,把整个请求上下游的日志全捞出来。
在 Serilog 里实现这个,有好几种方式,差别并不大。我推荐在中间件里读取请求头,然后把 ID 推到 LogContext:
app.Use(async (context, next) => { var traceId = context.Request.Headers.TryGetValue("X-Trace-Id", out var trace) ? trace.ToString() : Guid.NewGuid().ToString("N"); context.TraceIdentifier = traceId; using (LogContext.PushProperty("TraceId", traceId)) { await next(); } });如果你是微服务架构,这个X-Trace-Id请求头要由最外层的网关生成,然后各个服务之间调用时把这个头传递下去。现在很多公司会用 OpenTelemetry 的TraceId来做,Serilog 也支持从诊断活动(DiagnosticSource)里取TraceId,通过Serilog.Enrichers.Span或Serilog.Enrichers.ClientInfo这类包自动附加。
对我来说,最省心的是接 OpenTelemetry 后,直接让 Serilog 把TraceId和SpanId作为字段写到日志里。排查时你只需要把日志平台里的TraceId复制一下,就能看到整条链路所有服务、所有模块的日志,这才是真正高效的排障体验。
4.3 过滤、子Logger与Funnel:该省的省,该留的留
Serilog 还有一套过滤机制。大部分场景下,MinimumLevel加Override已经够用了,但有时会有更细粒度的需求。
比如:你只关心某个特定订单号的所有日志,想单独出来一份文件。这时可以用.Filter.ByIncludingOnly(...):
Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .Filter.ByIncludingOnly(e => e.Properties.ContainsKey("OrderNo") && e.Properties["OrderNo"].ToString().Contains("A10086")) .WriteTo.File("logs/special-order-.log", rollingInterval: RollingInterval.Day) .CreateLogger();再比如:Microsoft.EntityFrameworkCore的 SQL 日志,你平常不想看,但今天调一个查询性能问题,真的需要看。你可以在代码里临时改Override,也可以动态调整 Logging Level。如果你用的是 ASP.NET Core 的ILogger<T>,官方提供了SetMinimumLevel这样的控制方法,运行时改配置就能生效。
子 Logger(SubLogger)算是一个更强的组合技巧。它的思想是:给日志配置多个“分支”,每个分支有自己独立的级别和 Sink。比如:
Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Logger(lc => lc .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Error) .WriteTo.File("logs/error-.log", rollingInterval: RollingInterval.Day)) .WriteTo.Logger(lc => lc .Filter.ByIncludingOnly(e => e.Properties.ContainsKey("OrderNo")) .WriteTo.File("logs/order-.log", rollingInterval: RollingInterval.Day)) .WriteTo.Console() .CreateLogger();这里我建了两个子 Logger:一个专门收集 Error 级别的日志,另一个收集所有带OrderNo字段的日志。这两个文件用途不同:Error 文件用于告警排查,Order 文件用于业务追踪。这种设计非常灵活,也很直观。
但要提醒一点:子 Logger 用得太多,会让配置变得难维护。我见过一个项目里整了七八个子 Logger,每个都套一堆 Filter,最后没人敢动配置,生怕影响日志输出。我建议控制在两到三个以内,能用Override解决的,就别搞子 Logger。
4.4 性能:别让日志拖垮接口
有人担心“结构化日志那么丰富,性能肯定不行吧”。这个担心一半有道理,一半是误解。相比以前的字符串拼接,Serilog 的格式化和 Sink 写入确实有一定开销,但不至于成为瓶颈。真正把性能拖垮的,往往是使用姿势不对。
第一,热路径上别写高成本日志。我见过有人在每秒调用几千次的循环里,中间夹着一条.WriteTo.Debug()。调试输出在小流量的本地也许没感觉,生产上一跑,控制台输出光同步就占了 CPU。开发环境的 console sink 和文件 sink,通常有内部缓冲,但 Sink 的写入动作依然会增加线程调度和锁竞争。建议:高频打点日志用MinimumLevel.Debug级别的条件判断,或者干脆不要在生产开。
第二,用LoggerMessage.Define避免模板解析开销。在 .NET 里,ILogger.LogInformation每次调用都要解析模板字符串找出占位符。如果你的日志量大,这个开销会被放大。LoggerMessage.Define可以提前把模板编译成一个静态委托,运行时直接填充参数,减少分配和解析。Serilog 本身也做了缓存(它内部会缓存模板解析结果)但这部分缓存是按模板字符串缓存的,模板不同就缓存不同。所以实际影响没有想象中大。真正要做的是:模板字符串不要动态拼接,比如_logger.LogInformation("Order " + orderNo + " processed")这种写法会让模板缓存彻底失效,还会破坏结构化字段。
第三,异步 Sink 是救命稻草。如果你用 Serilog.Sinks.Async,可以把日志写入放到后台线程,业务线程不用等文件写完。注意Serilog.Sinks.Async会有一个内部的缓冲队列,日志量突然暴增时可能丢日志,但通常可接受。生产环境建议开启,但配合buffered: false之类的选项要仔细看文档,避免把异步变成“吞日志”。
第四,字符串插值问题。新手最容易犯的错是用 C# 字符串插值代替模板:
_logger.LogInformation($"Order {orderNo} processed");这个写法在运行时生成的是一个完整字符串,Serilog 拿不到orderNo字段,等于退回了传统日志。正确写法是:
_logger.LogInformation("Order {OrderNo} processed", orderNo);这两个写法在性能上的差异,本质是:前者的模板里没有字段,Serilog 只能把整个字符串当作一个字段存起来;后者有字段,序列化和检索都更高效。这也是我前面强调“先有模板,再有内容”的实际意义。
5. 常见问题与排查技巧实录
5.1 日志怎么突然不写了
这是最常见的坑,具体表现是:本地跑得好好的,部署到服务器上就什么都看不到。
首先排除是不是日志级别问题。Serilog 有“自诊断”日志,你可以在程序入口临时打开:
Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() .CreateLogger();如果级别是Information,而你又只写Log.Debug(...),那当然什么都看不到。这个看起来简单,但我经历过太多次“日志没了”最后发现是级别问题。
其次,确认MinimumLevel.Override有没有误伤。你在appsettings.json里把Microsoft调成Warning,结果自己写的日志的 namespace 恰好是Microsoft.Extension下的某个类,也会被压掉。排查方式:把Override里可疑的条目注释掉,看日志是否恢复。
再次,检查文件路径是不是有权限问题。Linux 下服务启动用户可能对/var/log/yourapp没写权限,或者目录不存在(Serilog 一般会帮你创建,但权限不对时会失败)。这时候启动时Log.CloseAndFlush()没执行,错误会被吞掉。想看到这类错误,可以把配置打出来:
Log.Logger.Debug("Current configuration: {Config}", config);Serilog 还提供了一个治理工具类型的东西:Serilog.Debugging.SelfLog。把它打开就能看到 Serilog 内部的错误:
Serilog.Debugging.SelfLog.Enable(msg => Console.WriteLine(msg));遇到“日志没了”问题时,我第一个干的就是开 SelfLog,这比瞎猜快十倍。
5.2 配置改了半天不生效
很多人第一次把 Serilog 接进 ASP.NET Core 时,会遇到:appsettings.json里改了日志级别,程序重启后还是旧配置。
常见原因有两个。
第一,ReadFrom.Configuration的时机不对。我前面强调过,它必须在UseSerilog里读取已经加载好的IConfiguration,而不是在builder构造之前。如果你用的是WebApplication.CreateBuilder,它默认会加载appsettings.json和appsettings.{Environment}.json,所以顺序没问题。但如果你手动创建ConfigurationBuilder,就要确保它在UseSerilog之前构建完成。
第二,环境变量覆盖了配置文件。ASP.NET Core 的配置系统是“最后写入者胜”。如果你在系统环境变量里配了Serilog__MinimumLevel__Default=Debug,那它就会覆盖 appsettings.json 里的设置。排查这个,可以用配置绑定的方式打印当前生效值:
var serilogConfig = context.Configuration.GetSection("Serilog").Get<LoggerConfiguration>();或者更简单:把Log.Logger的当前级别打到启动日志里:
Log.Information("当前最低级别: {Level}", Log.Logger.MinimumLevel());(注意这个方法不是 Serilog 原生 API,我是自己写了个扩展去读 Sink 配置。真正简洁的办法是看最终配置文件和加载后的configuration。)
还有一点我几乎每次都要提醒:改 JSON 配置后,要确认部署时上传的是新的 JSON,没有因为发布流程把旧文件覆盖回去。这种低级错误引发的排查时间,比任何一个技术问题都长。
5.3 文件日志被占用、乱码、无限增长
文件被占用这个坑,Windows 上最容易遇到。原因通常是:你在 Windows 服务里用了文件 Sink,而服务停止时没有正确 flush 和释放文件句柄。你手动删日志文件时会提示文件正在被另一个进程使用。
解决策略:
- 确认
Log.CloseAndFlush()在进程停止时被调用。在 Windows 服务里,这个要挂在OnStop或ApplicationStopped钩子上; - 如果是测试环境,你开着一个调试会话,文件 Sink 句柄会锁住日志文件,这是开发环境常见现象,不用紧张,停掉调试进程就释放了;
- 生产环境中,合理使用滚动策略不会让文件过度增大,但如果你发现某个文件特别大,同时文件Sink 写很慢,可以考虑开
buffered: true并加大输出间隔。不过这会增加日志丢失的风险,要在可靠性和性能间取舍。
乱码问题主要出现在文件日志和采集器之间。我在 3.3 提过,用CompactJsonFormatter的输出几乎没乱码问题(JSON 编码统一 UTF-8),但用自定义outputTemplate时,如果模板里带了中文逗号、中文括号,某些采集器可能会误判编码。最稳妥的方法:文件输出统一用 UTF-8,并在模板里避免非 ASCII 分隔符。比如[级别] 时间 消息这种模板看着整齐,但里面的中文括号在采集端会带来麻烦,我吃过亏。
无限增长,直接查retainedFileCountLimit配没配。我见过有人把rollingInterval设成Minute,配了retainedFileCountLimit: 15,结果一分钟一个文件,15 分钟后旧文件被清理,日志反而断档。这种方案适合高日志量排障场景,不适合长期运行。生产环境,我建议至少Hour级别,一般Day级别就够。
5.4 和容器、微服务相关的几个坑
现在 .NET 服务基本都是容器化部署,容器环境里 Serilog 有几个问题特别容易踩。
第一,时间戳时区。Serilog 默认输出本地时间,容器时区通常默认 UTC,所以很多团队会发现日志时间和业务时间对不上。解决办法有几种:要么容器里统一设置TZ=Asia/Shanghai,要么在 Serilog 配置里用Timestamp格式化时带上zzz偏移量,要么干脆输出 UTC 时间,在日志平台端统一转换。我习惯输出 UTC + 偏移量,这样日志平台能正确换算。
第二,文件日志在容器里的持久化。容器实例销毁后文件就没了,如果没接到集中日志平台,那日志基本等于白记。所以容器场景我强烈建议:文件 Sink 只做兜底,主日志输出走集中平台。File 输出路径要挂到 Volume 或者 stdout,不然排查时根本拿不到文件。
第三,多实例日志乱序。负载均衡后面有多个实例,每个实例的本地时钟可能有微小偏差,日志按时间排序会出现“跨实例乱序”。这个在日志平台里其实无所谓,因为你会按 TraceId 去筛。但如果你在测试环境用文件日志对比“两个实例谁处理了某个请求”,就要注意时间精度,至少按毫秒对齐。
第四,容器退出时日志丢失。尤其在 Kubernetes 里,Pod 被调度、被抢占时,进程可能被强制终止,Log.CloseAndFlush()不一定有机会执行。所以在容器场景,多一层 Sink(比如 HTTP 或平台 SDK)能有效减少日志丢失。等你有数据进到集中平台后,就会明白我为什么强调“不要把日志命都拴在文件上”。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 所有日志都不输出 | 级别过高 / 配置文件未加载 | 开 SelfLog,查看配置 |
| 只缺某几个类的日志 | Override级别压制 | 找 namespace 对应的 Override |
| 文件被占用,无法删除 | Windows 下句柄未释放 | 停进程,检查CloseAndFlush |
| 日志时间不对 | 容器 UTC 时区 | 修正时区设置或按偏移显示 |
| 中文乱码 | 编码不一致 / 模板含特殊字符 | 统一 UTF-8,避免非 ASCII 分隔符 |
| 日志量太大,磁盘暴涨 | 滚动策略缺失 / level 过低 | 配置rollingInterval和retainedFileCountLimit |
| 接口响应慢,日志量高 | 热路径大量同步写日志 | 开启异步 Sink,检查高频日志 |
| 模板里字段一直是空的 | 用了字符串插值而不是模板 | 替换成{Field}占位符 |
6. 最后分享一点我个人的实操体会
Serilog 这个东西,刚上手会觉得它就是“写日志的库”,API 就那几个,配置也简单。但真正把它用出价值,靠的不只是 API,而是你对日志体系整体的认知。
我特别想强调一个习惯:在开始写代码之前,先设计日志字段,再设计日志调用点。很多团队是边写代码边插日志,字段名随意,模板临时想,最后日志一堆但没法分析。我现在的做法是,新模块开发时,先规划一个“日志字段字典”,把核心实体 ID、操作类型、结果、耗时、版本这些字段统一命名好,规定哪些日志用Information、哪些用Debug、哪些必须Error。这样等日志真正进了平台,你才会发现这些前期投入特别值。
再分享一个小技巧:用Serilog之前,先想清楚你的日志最终要去哪。如果只是本地开发看下,Console 就够了;如果准备升级到集中日志平台,尽早把CompactJsonFormatter配上,尽早用一个全局的TraceIdEnricher,后面迁移成本几乎为零。很多人等日志积累了几个月再想迁移,结果发现格式、字段、关联都乱成一锅粥,迁移成本高得吓人。
我实际维护的项目里,Serilog 真正发挥威力的时刻往往不是它“写”日志的时候,而是你用它“查”日志的时候。有一次生产环境用户投诉某个接口偶发超时,我在日志平台里输入一个 TraceId,沿着请求日志往下追,发现耗时卡在一次 Redis 调用上。这场排查前后不过十分钟。搁以前字符串日志时代,光是把几十个实例的日志文件拉下来、拼时间线,就得半天。这就是结构化日志的回报。
所以我的建议很直接:不要犹豫,早点在你的 .NET 项目里把 Serilog 接上。哪怕一开始只做 Console + File,先把日志结构化这个习惯养起来,后面再逐步接平台、加关联、做告警。这个投入,是所有技术基础设施里回报率最高的一项。