给线上服务排查一个慢接口,最怕的就是打开日志文件看到一堆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生态里选Serilog
2.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:控制对象如何被展开。前面说的脱敏就是在这个环节做的。
这种“管道+组件”的设计让日志逻辑跟业务代码解耦得非常干净。业务代码只负责记录ILogger<T>,至于日志落在哪、怎么富化、怎么脱敏,全部在启动时配置好。换存储、调整级别,改配置就能生效,不需要动业务代码。
2.3 什么项目适合上Serilog
经验上分成三种情况:
- 你正在从零搭建一个 .NET Web API 或微服务,直接上 Serilog 是零成本的,默认就是结构化日志,后面想接日志平台随时能接。
- 老项目还在用字符串拼接日志,但你已经受不了“查日志靠脑补”的日子,可以用 Serilog 做渐进替换。不需要一天改完,先加包,新写的日志用消息模板,旧的慢慢迁。
- 你的团队对日志的要求不只是“打点”,还要做告警、排障、业务分析,那 Serilog 几乎是必选,因为你已经绕不开结构化数据了。
反过来,如果项目就一个几万行代码的桌面工具,日志只有添加、删除这几个操作,那用简单日志组件也问题不大。Serilog 的优势要在“数据量上来、查询需求复杂”之后才真正体现出来。
3. 工程接入与最小落地
3.1 安装哪些包
Serilog 是组件式设计的,装“全家桶”不如按需安装。在一个新 ASP.NET Core 项目里,我一般这样选包:
| 包名 | 用途 | 备注 |
|---|---|---|
Serilog.AspNetCore | ASP.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()之前。这样将来通过构造函数注入的ILogger<T>使用的就是 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}会输出ILogger<T>的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、WithThreadId,ASP.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)。假设你有这样一 个UserInfo:
public 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 等 Provider,UseSerilog加了 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.ByTransforming<T>(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 文本的差距。用不了几天,你大概率就回不去原来的写法了。