
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 分钟里到底有多少用户下单失败失败原因分布是什么哪个接口耗时最长这些问题字符串日志几乎没法回答。再说难听点传统日志的“可读”是假象。它只是把一堆变量拼成了一段话拼好之后这些变量就永远变成了一段不可拆分的文本。想要按“用户ID8888”筛日志正则抠吧抠出来还是字符串。想要按“错误码”做聚合报表等着哭吧。这种日志说白了就是给人看的不是给机器用的更不是给监控系统用的。我见过太多团队日志系统建设了几年最后线上排障还是靠“大家把日志文件拉到本地用 Notepad 搜关键字”效率低得离谱。你问为什么不建日志平台答曰日志格式太乱采集进来也没法分析。你看问题根子就出在“日志格式”上。1.2 结构化日志的本质日志不再是“一行字”而是一批“键值对”结构化日志的核心思路特别简单就一句话每条日志不再是一个字符串而是一个结构化的事件对象由若干字段键值对组成。拿刚才那条下单失败日志举例在 Serilog 里你可能会写成_logger.LogError(用户下单失败,订单号 {OrderNo}, 金额 {Amount}, 用户ID {UserId}, 原因 {Reason}, orderNo, amount, userId, ex.Message);Serilog 不会把它拼成字符串存下来而是记录成类似这样的结构{ Timestamp: 2024-05-11T14:23:01.02508: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 上这样你用框架自带ILoggerT写的日志和用 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还能和依赖注入的ILoggerT配合使用。你可以在中间件里 Push 那个请求的上下文然后整个请求生命周期内的日志都带着这些字段请求一结束字段自动弹出。有个细节要注意LogContext.PushProperty返回一个IDisposable用using包住。在异步代码里千万别跨await太久不释放Serilog 内部使用异步执行上下文AsyncLocal来做值传递理论上是可以跨await的但你如果在请求结束后才释放可能导致字段泄漏到下一个请求里这个非常难排查。4.2 关联Id一条链路串起所有日志Web 项目排障时最常用的手段就是“关联 ID”。一个请求从网关进来带上一个TraceId后续所有日志都记录这个 ID这样你就能在日志平台里输入一个 ID把整个请求上下游的日志全捞出来。在 Serilog 里实现这个有好几种方式差别并不大。我推荐在中间件里读取请求头然后把 ID 推到 LogContextapp.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 的ILoggerT官方提供了SetMinimumLevel这样的控制方法运行时改配置就能生效。子 LoggerSubLogger算是一个更强的组合技巧。它的思想是给日志配置多个“分支”每个分支有自己独立的级别和 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__DefaultDebug那它就会覆盖 appsettings.json 里的设置。排查这个可以用配置绑定的方式打印当前生效值var serilogConfig context.Configuration.GetSection(Serilog).GetLoggerConfiguration();或者更简单把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所以很多团队会发现日志时间和业务时间对不上。解决办法有几种要么容器里统一设置TZAsia/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先把日志结构化这个习惯养起来后面再逐步接平台、加关联、做告警。这个投入是所有技术基础设施里回报率最高的一项。