给线上服务排查一个慢接口最怕的就是打开日志文件看到一堆Info: xxx操作成功然后就没有然后了。你说它报错了吗没有。你说它正常吗延迟800ms你根本不知道是哪一层吃掉了时间。这种情况我在很多.NET项目里都见过——日志写了但等于白写。后来我把项目的日志组件从字符串拼接换成了Serilog把日志从“人肉阅读理解”升级成“结构化数据”事情的复杂度一下子降下来了。这篇东西不是官方文档的翻译是我自己把 Serilog 从认知到工程落地在真实项目里折腾了一圈之后的经验总结。1. 结构化日志到底解决什么问题1.1 以前那种字符串拼接到底差在哪很多老项目里日志是这么写的_logger.LogInfo($用户 {userId} 在 {DateTime.Now:yyyy-MM-dd HH:mm:ss} 下单成功订单号 {orderId}金额 {amount});表面上看信息都有人也能看懂。但一旦想查点东西就全露馅了。比如线上出问题你拿到一个订单号SO20250610001第一反应是去日志里 grep 这个单号。运气好的话能搜到运气不好就得先猜是哪个字段打漏了。即便搜到了周边日志还是几百条字符串混在一起你得靠肉眼找上下文。更麻烦的是这类字符串日志推给日志平台之后每个字段都“长”在文本里。你想统计一下不同金额区间的订单数没法直接聚合你想看某个用户最近一周的操作时序只能靠关键字拼。说白了字符串日志是写给开发者的不是写给系统的。结构化日志的思路完全不同先把事件本身拆成一组键值对再决定怎么展示、怎么存储。同样的日志如果用 Serilog 写_logger.LogInformation(用户 {UserId} 下单成功订单号 {OrderId}金额 {Amount}, userId, orderId, amount);这里{UserId}、{OrderId}、{Amount}不是简单的文本占位符而是消息模板里定义的属性名。最终生成的每一条日志事件都会带有这三个字段。推送到 Elasticsearch、Seq、ClickHouse 这类存储时字段是独立索引的想按用户聚合、按订单关联、按金额区间统计都是顺手的事。1.2 消息模板是核心Serilog 最核心的机制就是“消息模板”Message Template。它和 C# 6 的字符串插值长得很像都是{变量名}的写法但有一个本质区别字符串插值在调用前就把值拼成了字符串而消息模板保留的是“属性名 原始对象”等到真正输出时才决定怎么渲染。这个差异带来的好处非常实际。假设你这样写_logger.LogInformation(用户 {User} 登录成功, userObj);你传进去的是一个User对象不是预先格式化的字符串。Serilog 默认会把这个对象的公开属性逐个展开序列化日志里直接就能看到UserId、UserName、Email这些字段。不需要你手动拼$用户 {userObj.Id} ...。要是哪天想在User对象上追加一个TenantId你只需要改对象本身不需要去所有打印日志的地方补字段。另外它对格式化的支持也比普通拼接规范。比如时间_logger.LogInformation(订单 {OrderId} 创建于 {CreatedAt:yyyy-MM-dd HH:mm:ss}, orderId, DateTime.UtcNow);{CreatedAt:yyyy-MM-dd HH:mm:ss}这种格式化语法沿用 C# 标准格式串同时字段还是结构化的里外兼顾。我见过很多团队用 Serilog 半年一查日志平台发现每个消息前面还挂着一长串已经格式化好的时间字符串因为开发者在消息里又拼了一次DateTime.Now.ToString()。这种重复要尽量避免。1.3 日志不只是给人看的还有一个认知变化值得单独说说我刚用 Serilog 那阵子总觉得“结构化”就是把文本换成 JSON直到后来把日志接到告警平台才真正理解这套设计。以前我们要做告警靠的是正则匹配关键字“ERROR”。但正则这东西匹配到一行日志就算打了标签拿不到上下文。举个例子订单服务OOM前十分钟内存使用率已经超过90%了可日志里只有一堆正常的OrderCreated事件。你想设“订单创建量大到异常”的告警字符串日志根本没法按“每分钟订单数”聚合。结构化日志配合消息模板每个订单创建事件天然就是一个可计数的指标源。日志平台里写一句聚合查询“每分钟创建订单数量”“平均耗时”“失败率”就都出来了。也就是说结构化日志往上走一步就是可观测性——日志不只是排障工具它可以是业务指标的底层数据。这也是为什么现在很多团队在从 NLog、log4net 往 Serilog 迁移目标不单是为了好看而是为了后面接日志分析、告警、链路追踪。2. 为什么在.NET生态里选Serilog2.1 和NLog、log4net比优势在哪提到 .NET 日志组件绕不开 NLog 和 log4net。这三个都是老牌组件技术上都成熟没有谁“不能上线”的硬伤。但如果你实际对比一下“结构化能力”和“生态扩展”就能看出区别。log4net 是 Apache 出的老将稳定是真稳定但设计停在“用 pattern 拼字符串”的思路上。虽然新版也支持一些格式但和结构化消息模板不是一回事。NLog 的布局渲染器LayoutRenderer非常强大还自带 JSON 输出团队内部如果已经深度使用顺手写写也够用。Serilog 不一样的地方在于它把“结构化”做成了默认行为。你写出来的每一条消息模板天然就是一组属性集合不需要额外的配置。而且它的 Sink接收端生态在 .NET 社区里覆盖得特别全文件、控制台、Seq、Elasticsearch、ClickHouse、SQL Server、PostgreSQL、Kafka、HTTP基本上你能想到的存储都有对应的 NuGet 包。还用Serilog.Sinks.Async这类包装器解决性能问题用Destructure策略控制复杂对象的序列化行为。还有一个现实考量新入职的同事对 Serilog 的接受度普遍更高。因为它的 API 设计更贴近现代 C# 写法消息模板几乎不需要额外学习看完两段示例就能上手写。老项目里的 log4net 配置动不动上百行 pattern新人不改还好一改就崩。2.2 管道式架构Serilog 的配置模型可以理解为一条管道日志事件 - Enrichers富化 - Filters过滤 - Sinks输出日志在到达最终输出之前可以经过多级处理。这中间有几个非常重要的钩子Enrichers给每一条日志附加全局属性比如机器名、进程ID、线程ID、环境名称、请求ID。这些字段平时写日志时不用管但排查问题时特别管用。Filters按条件丢弃或保留日志。比如你只想保留某个业务模块的 Debug 日志其他模块一律 Information可以直接用过滤器表达式配置不用改代码。Destructure Policies控制对象如何被展开。前面说的脱敏就是在这个环节做的。这种“管道组件”的设计让日志逻辑跟业务代码解耦得非常干净。业务代码只负责记录ILoggerT至于日志落在哪、怎么富化、怎么脱敏全部在启动时配置好。换存储、调整级别改配置就能生效不需要动业务代码。2.3 什么项目适合上Serilog经验上分成三种情况你正在从零搭建一个 .NET Web API 或微服务直接上 Serilog 是零成本的默认就是结构化日志后面想接日志平台随时能接。老项目还在用字符串拼接日志但你已经受不了“查日志靠脑补”的日子可以用 Serilog 做渐进替换。不需要一天改完先加包新写的日志用消息模板旧的慢慢迁。你的团队对日志的要求不只是“打点”还要做告警、排障、业务分析那 Serilog 几乎是必选因为你已经绕不开结构化数据了。反过来如果项目就一个几万行代码的桌面工具日志只有添加、删除这几个操作那用简单日志组件也问题不大。Serilog 的优势要在“数据量上来、查询需求复杂”之后才真正体现出来。3. 工程接入与最小落地3.1 安装哪些包Serilog 是组件式设计的装“全家桶”不如按需安装。在一个新 ASP.NET Core 项目里我一般这样选包包名用途备注Serilog.AspNetCoreASP.NET Core 集成自动包含宿主扩展、控制台输出等依赖Serilog.Sinks.Console开发期控制台输出Serilog.AspNetCore 通常已带Serilog.Sinks.File写文件按天/大小滚动生产环境常用Serilog.Sinks.Async异步包装输出避免写文件/网络阻塞业务线程Serilog.Enrichers.Environment附加进程、机器名多实例排查时有用Serilog.Settings.Configuration从 appsettings.json 读取配置用配置文件时安装Serilog.Formatting.Compact待命 JSON 格式输出日志平台采集最佳格式Serilog.Sinks.Seq本地/远端 Seq 查询服务开发推荐体验极好命令直接一条条来dotnet add package Serilog.AspNetCore dotnet add package Serilog.Sinks.File dotnet add package Serilog.Sinks.Async dotnet add package Serilog.Formatting.Compact dotnet add package Serilog.Enrichers.Environment dotnet add package Serilog.Settings.Configuration装完包先别急着把代码塞满Log.Logger看一下 3.2 的最小接入方式理解整体结构后再动手。3.2 从控制台到 ASP.NET Core 的最小示例最简单的一个 Serilog 控制台程序三行就能跑起来using Serilog; Log.Logger new LoggerConfiguration() .WriteTo.Console() .CreateLogger(); Log.Information(Hello, {User}! Today is {Day:dddd}, Kay, DateTime.Now); Log.CloseAndFlush();CloseAndFlush()在程序退出前调用确保缓存里的日志都写出去了。控制台程序的 Main 方法里记得加。ASP.NET Core 里标准做法是用UseSerilog扩展方法替换默认的日志工厂。在Program.cs里using Serilog; var builder WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); builder.Host.UseSerilog((context, services, configuration) configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithThreadId() .WriteTo.Console(outputTemplate: [{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}) .WriteTo.Async(a a.File( logs/applog-.json, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 14, formatter: new CompactJsonFormatter())) ); var app builder.Build();注意两个细节第一builder.Logging.ClearProviders()要把默认的 Console/Debug Provider 清掉否则日志会重复出现两遍。第二ReadFrom.Configuration和ReadFrom.Services能让 Serilog 跟 ASP.NET Core 的配置文件、依赖注入容器打通后面讲配置化时会用到。时序上UseSerilog要放在builder.Build()之前。这样将来通过构造函数注入的ILoggerT使用的就是 Serilog 实现而不是 ASP.NET Core 自带的那个。3.3 配置外置到 appsettings.json代码里写死输出到哪个文件、什么级别维护起来不方便。Serilog 支持把配置放在appsettings.json改配置不用重新编译发布这点在生产环境很有用。一个典型的配置节长这样{ Serilog: { Using: [ Serilog.Sinks.Console, Serilog.Sinks.File, Serilog.Formatting.Compact ], MinimumLevel: { Default: Information, Override: { Microsoft: Warning, System: Warning, Microsoft.AspNetCore: Warning } }, WriteTo: [ { Name: Console, Args: { outputTemplate: [{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception} } }, { Name: File, Args: { path: logs/applog-.json, rollingInterval: Day, retainedFileCountLimit: 14, formatter: Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact } } ], Enrich: [ FromLogContext, WithMachineName, WithThreadId ], Properties: { Application: OrderService } } }这里说几点经验Using数组里要声明用到的程序集程序才能把配置节里的字符串类名如Serilog.Formatting.Compact.CompactJsonFormatter反射出正确的类型。少写一个常见的结果就是格式化器没生效文件长出一串默认文本。MinimumLevel.Override里的Microsoft.AspNetCore我一般单独压到Warning。不压的话开发环境十分吵中间件每层都会打 Information一个大请求恨不得刷三屏。Properties节点给所有日志附加一个固定属性在这里标个Application: OrderService将来多服务日志混在一个平台里区分服务特别方便。在代码里只需要这样读builder.Host.UseSerilog((context, services, config) config .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext());注意如果同时有代码配置和 JSON 配置ReadFrom.Configuration放在前面后面的代码配置可以覆盖它。这里的顺序就是最终生效顺序官方没明说实践踩过坑。4. 核心配置与多接收端编排4.1 控制台输出别小看这个默认Sink很多教程里控制台只是“入门示例”好像生产环境就一定要写文件、写数据库。其实控制台输出在容器化部署里反而是主流。Kubernetes 的 Pod 日志规范是应用把日志写到标准输出STDOUT/STDERR由 Docker 或 K8s 统一采集。这时候你根本不需要自己写文件只需要保证输出到控制台的是结构化格式。所以在容器环境里我给 Serilog 配的往往是.WriteTo.Console(new CompactJsonFormatter())这样每条日志一行 JSON日志采集器比如 Fluent Bit、Promtail直接解析就够了。如果你在容器里再写一个本地文件日志采集器反而要额外适配路径多一层复杂度。本地开发时控制台则用人类可读的模板更舒服[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{SourceContext}会输出ILoggerT的T名字比如OrderService.Handlers.CreateOrderHandler。看到这条日志你能立刻知道是哪个类打印的这个信息在本地调试时非常关键。4.2 文件输出滚动与保留策略传统部署在虚拟机上日志写文件仍然是最常见的方案。Serilog 的 File Sink 配置并不复杂关键参数就这几个path文件路径。支持logs/log-.txt这种带-日期占位符的写法。rollingInterval滚动周期。按天Day、按小时Hour、按分钟Minute。一般按天。retainedFileCountLimit保留的文件数量。超过后自动删除旧文件防止磁盘被日志撑爆。rollOnFileSizeLimit单个文件大小上限超过后切新文件。fileSizeLimitBytes配合上面的参数配置默认 1 GB可按需调小。shared多进程同时写同一个文件时开启。写入会多一层锁有性能损耗不建议默认开。一个常见的生产配置.WriteTo.Async(a a.File( path: logs/applog-.json, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 14, fileSizeLimitBytes: 100 * 1024 * 1024, rollOnFileSizeLimit: true, shared: false, formatter: new CompactJsonFormatter()))用Async包装文件写入业务线程不会因为磁盘慢而被卡住。WriteTo.Async内部有一个后台队列写日志的线程把事件丢进队列就返回了。有一个坑多实例部署时如果你有两个服务实例同时跑并且它们共享同一个文件路径比如写了同一个挂载盘那么必须把shared: true打开否则会出现文件被占用的报错。但这种共享盘方案本身不推荐因为写入锁的竞争会很严重日志量大时容易拖慢业务。更好的方案是每个实例按机器名或 PID 分开文件再把日志统一采到日志平台里筛。4.3 结构化数据写到哪里Seq、Elasticsearch还是数据库有了结构化日志接日志平台就是顺势而为。本地开发我最推荐Seq它是 Serilog 官方团队做的日志查询服务集成度极高。装好之后代码里加一个 Sink 就行.WriteTo.Seq(http://localhost:5341, apiKey: your-api-key)Seq 的查询界面支持按属性筛选、画图表、看时序比打开文本文件高效太多。关键是本地起一个 Docker 容器就能跑团队几个人共享一个实例非常舒服。生产环境常见的是写 Elasticsearch。通过Serilog.Sinks.ElasticSearch.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri(http://es01:9200)) { AutoRegisterTemplate true, IndexFormat app-log-{0:yyyy.MM.dd}, BatchAction ElasticOpType.Create, InlineFields true })Elasticsearch 的好处是字段自动建立索引Kibana 上可以自由聚合。但 ES 集群运维成本高日志量大时索引压力也大。如果你实际只是“存起来偶尔查一下”我更推荐先用 ClickHouse 或者对象存储S3 日志文件成本低很多。至于直接写 SQL Server / PostgreSQL我个人的建议是只写关键业务日志不要写全量调试日志。关系型数据库按行存储日志量大了写入压力不小而且查日志也很难高效。如果一定要写库建议走WriteTo.Async并用批量插入不要逐条 insert。实际项目里我见过好几起直接把 Serilog 配到 SQL Server结果日志上来后数据库 CPU 飙到 100% 的事故。5. 高级实践从“能写日志”到“写好日志”5.1 消息模板规范消息模板是 Serilog 的灵魂但也是最容易写歪的地方。我在代码评审里见过最多的三类问题在消息里继续拼字符串。比如_logger.LogInformation($订单 {_order.Id} 创建成功金额 {_order.Amount});这样写出来的日志虽然人模人样但丢失了结构化属性而且字符串插值在调用前就执行了变量再多也不会形成独立字段。正确做法是_logger.LogInformation(订单 {OrderId} 创建成功金额 {Amount}, _order.Id, _order.Amount);同一个业务事件不同地方模板不统一。这个很坑。比如订单支付成功支付服务打的是支付成功orderId{OrderId}对账服务打的是order {order_id} pay ok两边字段名也对不上。日志平台要跨服务串联就得靠消息模板去识别模板不统一基本等于断链。建议把核心业务的日志模板沉淀成常量类public static class LogTemplates { public const string OrderPaid 订单 {OrderId} 支付成功支付渠道 {PaymentChannel}金额 {Amount}; }整个团队引用同一套常量字段名默认统一。把对象直接塞进消息里。一个对象可能包含几十个字段直接序列化会让日志体积膨胀而且某些对象含循环引用序列化时直接抛异常。折中的做法是投影后再记录_logger.LogInformation(订单 {OrderId} 买家 {Buyer} 已发货, order.Id, new { order.BuyerId, order.BuyerName });5.2 属性富化与日志上下文属性富化Enrich解决的核心问题是让每条日志自动带上“它是谁、从哪来、处理什么请求”这类信息不需要业务代码逐个传。除了前面配置的WithMachineName、WithThreadIdASP.NET Core 场景里最常用的还有FromLogContext。它和LogContext配合能把当前请求上下文里的属性附加到每一条日志上。比如你有一个中间件在请求入口时把 TraceId、用户ID、租户ID 塞进上下文app.Use(async (context, next) { using (Serilog.Context.LogContext.PushProperty(TraceId, context.TraceIdentifier)) using (Serilog.Context.LogContext.PushProperty(UserId, context.User.FindFirst(sub)?.Value)) using (Serilog.Context.LogContext.PushProperty(TenantId, context.Request.Headers[X-Tenant-Id])) { await next(); } });这样这个请求处理过程中产生的所有日志都会自动带上TraceId、UserId、TenantId三个字段。排查问题时你只需要输入一个 TraceId就能把整个请求链路上的日志全部捞出来不用再去猜哪个日志属于哪个用户。这个能力在实际排障时的价值远胜于你手动传参数。另外一个容易被忽视的是UseSerilogRequestLogging中间件。它会把每一个HTTP请求的完整元数据记录下来包括请求方法、路径、状态码、耗时app.UseSerilogRequestLogging();它配合 Serilog 本身启用后会自动给“每一个请求”生成一条结构化日志。之前帮人排查过一个net::ERR_INCOMPLETE_CHUNKED_ENCODING的报错就是通过这个中间件发现是服务端返回响应超时后的断连问题。没有请求日志这类问题查起来特别费劲。但要注意这个中间件默认会把查询字符串也放进消息里如果 URL 带 token、密码之类敏感参数需要自己定制过滤逻辑。5.3 敏感信息脱敏日志里最怕出现用户密码、身份证号、手机号明文。Serilog 默认会把对象的属性全部序列化所以你要让“敏感字段”进不了日志或者在序列化层面直接处理掉。最实用的做法是自定义一个反序列化策略DestructuringPolicy。假设你有这样一 个UserInfopublic class UserInfo { public string Id { get; set; } public string Email { get; set; } public string Phone { get; set; } public string PasswordHash { get; set; } }默认塞进日志后PasswordHash也随之出现在控制台或日志平台里这是绝对不该发生的。实现一个策略把敏感属性替换成掩码public class UserInfoDestructuringPolicy : IDestructuringPolicy { public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, out LogEventPropertyValue result) { if (value is UserInfo user) { result new StructureValue(new[] { new LogEventProperty(Id, new ScalarValue(user.Id)), new LogEventProperty(Email, new ScalarValue(MaskEmail(user.Email))), new LogEventProperty(Phone, new ScalarValue(MaskPhone(user.Phone))), new LogEventProperty(PasswordHash, new ScalarValue([HIDDEN])) }); return true; } result null; return false; } }然后在配置中注册configuration.Destructure.With(new UserInfoDestructuringPolicy());这样后续所有包含UserInfo对象的日志事件统一套用脱敏策略。类似的策略可以扩展到CreditCard、IdCard等任何敏感模型。另外一个容易漏的地方是异常消息本身可能夹带敏感信息。比如数据库连接串里的密码有时会被抛进SqlException。这个要靠“日志之前先判断一下异常能否安全输出”的习惯来兜底不能完全依赖组件。5.4 用 LoggerMessage 提升高并发日志性能Serilog 虽然把结构化做得简洁但性能并不免费。每一条日志都要做模板解析、属性收集、格式化属性多的时候还有装箱和分配开销。如果你的接口 QPS 很高每条请求打四五条日志垃圾回收压力会明显上升。.NET 官方给了一个高性能写法LoggerMessage。它通过编译期生成代码把日志模板预先编译好运行时只需要填充值大幅度减少分配。public partial class OrderLog { [LoggerMessage( EventId 1001, Level LogLevel.Information, Message 订单 {OrderId} 创建成功用户 {UserId}金额 {Amount})] public static partial void OrderCreated(ILogger logger, long orderId, long userId, decimal amount); }使用时OrderLog.OrderCreated(_logger, order.Id, order.UserId, order.Amount);注意这里面有几个约定类必须是partial方法是partial参数名必须和消息模板里的{OrderId}对应上不区分大小写第一个参数必须是ILogger。这个方法生成的全是强类型代码没有反射、没有模板解析性能接近直接拼接字符串但结构化字段一个不少。有性能洁癖的话可以把关键链路上的日志全改成LoggerMessage。一般的业务日志则不需要成本可控别过度优化。6. 常见问题与排查技巧实录6.1 日志文件没生成怎么办新手最容易遇到的是配置完 File Sink程序起来了logs目录干干净净。排查顺序如下确认路径是否带日期占位符。logs/applog-.txt里必须有-才支持按天滚动否则 Serilog 会固定写一个文件。确认程序的工作目录。控制台/服务从哪个目录启动相对路径就基于哪个目录。建议直接用AppContext.BaseDirectory拼绝对路径避免“双击启动”和“服务方式启动”行为不一致。确认有没有权限。服务账户对目标目录没有写权限时Serilog 默认会静默失败不会中断程序。可以先试着手动创建一个同名文件验证权限。确认最低级别。如果配置里MinimumLevel是Warning而业务代码只打了Information日志当然不会出现。6.2 日志重复、控制台刷两遍这个问题最常见的原因是宿主自带的日志 Provider 没有清掉。ASP.NET Core 默认注册了 Console 等 ProviderUseSerilog加了 Serilog Provider 后同一个日志事件会被输出两次。处理办法是开头那句builder.Logging.ClearProviders();如果你是在ConfigureLogging里手动调AddSerilog()也要在它前面清一次。清完之后检查一遍控制台输出确保只剩一份。6.3 多实例日志串了、文件被占用部署多个服务实例时如果大家都把日志写到同一个共享磁盘路径很容易出现文件锁、日志交叉。更合理的做法是每实例写独立文件然后用采集器统一收集或者干脆走控制台输出。如果必须共享路径写文件把 File Sink 的shared: true打开。但要做好心理准备日志量大时写锁会比较凶。实际经验里文件写坏倒是没遇到过但性能损耗是实打实的。6.4 时间是 UTC 还是本地时间很多团队用DateTime.Now习惯了切到 Serilog 后发现时间戳是 UTC第一反应是“坏了”。其实这是 Serilog 的默认行为因为规范上日志时间就应该标准化等日志平台再转本地时区。如果你确实想让日志里直接显示本地时间配置.WriteTo.Console(new RenderedCompactJsonFormatter())不行这个只影响格式。正确处理方式是用WithUtcTime()或自定义Enricher。最省事的是在读取配置阶段统一处理configuration.Enrich.WithUtcTime();WithUtcTime来自Serilog.Enrichers.Process还是Serilog.Enrichers.Thread其实它是Serilog.Enrichers.Environment之外的另一个包Serilog.Enrichers.UtcTime需要单独安装。不装的话也可以在消息模板里要求渲染成本地时间但那就丢掉结构化了。最稳的方案还是日志存 UTC查询界面里按本地时区展示日志平台都支持。6.5 某些对象序列化报循环引用日志里直接写带导航属性的 Entity Framework 实体是重灾区。EF 实体通常有父子导航序列化时没做好循环检测直接抛异常或把整个对象图打出来。应对策略很简单不记实体记匿名对象取需要的字段即可。用自定义DestructuringPolicy控制展开策略。给配置加.Destructure.ByTransformingT(x new { ... })在序列化层面模型瘦身。官方推荐用法其实是第三种因为代码侵入最小。只要在启动时配置好所有写着带该类型对象的日志都会统一瘦身。6.6 日志量大性能降了怎么办先看几个执行细节排查是否符合预期是否用了WriteTo.Async包裹耗时 Sink文件、网络。是否在 MQ、HTTP 请求回调这种高频路径上打日志太多。是否有对象被序列化成超大 JSON比如一个实体带十几层导航。是否把 Debug 级别的日志录到生产环境。性能靠“压”不靠“调”。压测时直接用dotnet-counters看 GC 次数对比接 Serilog 前后指标通常就能定位到是日志写入引起的还是其他业务引起的。6.7 请求日志里出现敏感参数用UseSerilogRequestLogging()后如果 URL 里带 token、验证码这类东西默认都会被记录下来。排查订单问题时这个功能很好用但安全排查时这就是事故线索。处理办法是写一个自己的IDiagnosticContextCustomizer或者直接在中间件里对Request.Query先做一遍清洗。比如app.UseSerilogRequestLogging(o { o.EnrichDiagnosticContext (diag, http) { diag.Set(QueryString, http.Request.QueryString.HasValue ? Sanitizer.Clean(http.Request.QueryString.Value) : string.Empty); }; });把敏感参数替换成***再放进日志字段。这类问题的难点不在实现而在“你有安全意识想到要处理”。7. 落到工程里的一点点体会项目里用得越深越觉得日志不是写出来的是“设计”出来的。最开始一两个服务还好到几十个服务的时候字段名、消息模板、日志级别如果没统一规范日志平台里就是一片混乱。最重要的习惯是把日志模板当成接口一样约束。核心字段名提前定好新增字段先查一下有没有约定不要想着“先记着后面再规整”。数据一旦进了日志平台再想整理要付出翻倍的成本。举个例子。公司刚开始接日志平台时订单服务里字段名是OrderId支付服务却是order_id。两个服务日志拉到同一个索引里字段类型都对不上聚合直接失败。最后我们花了差不多两周的时间把两边所有跟订单相关的日志全部重新检查了一遍。如果刚开始就用统一模板常量这个钱根本不用花。另外一个小技巧日志级别一定要根据环境控制。本地开发开Debug没问题测试环境至少Information生产环境最常见的是把Microsoft.AspNetCore降到Warning自己的业务代码保持Information。这样排障时不会被中间件的一堆无关日志刷屏。真正出问题的时候利用LogContext里的 TraceId 把一次请求的所有日志串起来看效率远高于翻全文关键词。我自己在项目里迁移时还有一个体会Serilog 不是装个包就能说“上日志了”它需要你在架构层面想清楚“我要留什么数据、给谁用”。你在消息模板里每多写一个属性排查问题的时候就多一条线索。宁可一开始多设计几个关键属性TenantId、UserId、TraceId、耗时也比事后给日志补字段强得多。如果你正打算在 .NET 项目里接入结构化日志建议先别急着全量替换找一个请求量不大、但出问题很难查的服务把 Console 文件 Seq 这套跑通看看查询体验和原来 grep 文本的差距。用不了几天你大概率就回不去原来的写法了。