过去排查线上问题时,我经常面对几百MB的文本日志文件,一条条往上翻,靠关键词碰运气。那时候最大的痛点不是日志的量,而是日志里根本没有可被程序理解的结构——时间、级别、消息混在一行字符串里,想按某个字段聚合统计几乎不可能。后来换成 Serilog 之后,一批日志从一种人读的文本,变成了有字段、有类型、可查询的事件流,排查问题的效率完全不一样了。这篇内容想把 Serilog 的用法和背后的设计逻辑系统地梳理一遍,给正在选型或准备改造日志方案的读者一份能直接上手的参考。
Serilog 是 .NET 生态里最流行的结构化日志库之一,核心思路是把“记录日志”这件事从拼字符串变成填充事件:每条日志是一个事件,带时间戳、日志级别、消息模板,以及任意多个附加属性。它设计了一套接收器(Sink)体系,Console、文件、数据库、Seq、Elasticsearch,甚至第三方监控平台,都有现成的适配器,配置好之后一行代码不用改就能切换输出目的地。对于从传统日志方案迁移过来的团队,Serilog 的学习成本很低,但它解决的问题比传统方案多一个维度——日志不再只是一堆文本,而是可以被查询、聚合和告警的数据源。
这篇文章适合正在用或者准备用 Serilog 的 .NET 开发者,也适合那些日志量上来之后开始头疼检索和排障的团队。
1. 为什么传统文本日志在生产环境越来越不够用
1.1 过去的日志排查:从文本里大海捞针
我刚工作那几年,团队用的还是 log4net 加文本文件的方式。日志格式大概是这样的:
code复制2024-06-18 14:23:45,678 ERROR [OrderService] 订单 20240618142345123 创建失败:库存不足
看起来信息都有,但一旦日志量变大,问题就暴露得很明显:
- 想按“订单号”查所有相关日志,只能靠正则匹配,而且匹配出来的结果还得自己拼上下文。
- 想统计某个时间段内有多少条 ERROR,得先把文件读了,再用脚本或者人工数。
- 想查某个用户 ID 对应的全部操作轨迹,日志里根本没有独立字段,全靠关键字碰运气。
- 想给日志接告警系统,得先做一套解析文本的逻辑,每种格式都要写一种解析器。
这不是工具的问题,而是文本日志的结构化程度太低导致的必然结果。真正的问题是:我们在记录日志的那一刻,其实已经拿到了所有上下文数据(订单号、用户 ID、耗时、堆栈),但所有信息都被编程习惯性地拼进了一个字符串,机器再想拿回来,就只能靠猜。
1.2 结构化日志到底改变了什么
Serilog 的结构化日志模型,可以简单理解成:把“日志内容”和“日志数据”分开了。
传统的日志写的是:
csharp复制_logger.Error($"订单 {orderId} 创建失败:{reason}");
Serilog 推荐写的是:
csharp复制_logger.Error("订单 {OrderId} 创建失败:{Reason}", orderId, reason);
从人的角度,看到的输出差不多,还是“订单 xxx 创建失败:xxx”。但从系统的角度,OrderId 和 Reason 在内部是作为独立属性保存的。如果接收端支持结构化存储(比如 Seq、Elasticsearch、ClickHouse 这类系统),那这两个字段就可以被索引、筛选、聚合。
这意味着什么?意味着你不再需要为了“查日志”这件事去解析文本了。你要查的不再是“哪个文件里有这个订单号”,而是“这个订单号在哪个时间段触发过哪些事件”。后者在结构化日志里是一条 SQL 或一句查询表达式的事。
这个模型的另一个好处是模板复用。传统方案下,同一类错误散落各处,写法可能不统一,有的带订单号,有的漏掉。Serilog 的模板是在代码里固定的,所有地方写同一个模板,出来的日志就必然是同一个结构。这保证了日志数据的“模式一致性”,在数据分析和监控场景下非常重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先跑起来:最小配置和两种接入方式
2.1 最低成本的接入路径
Serilog 的接入成本远比很多人想象的低。对一个普通控制台应用来说,装上三个 NuGet 包就能用:
bash复制dotnet add package Serilog
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
然后在程序入口配置:
csharp复制Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
Log.Information("应用启动,环境:{Environment}", "Production");
这已经是一个能用的最小配置了。输出到控制台方便本地调试,输出到文件方便留存。rollingInterval: RollingInterval.Day 是按天切分日志文件,避免单个文件无限增长。
如果你用的是 ASP.NET Core 项目,接入方式稍微不一样,但也不复杂。先装集成包:
bash复制dotnet add package Serilog.AspNetCore
然后在 Program.cs 里把 Serilog 挂到宿主上:
csharp复制builder.Host.UseSerilog((context, loggerConfig) =>
{
loggerConfig
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day);
});
这样做了之后,原来代码里的 ILogger<T> 注入方式完全不用改,_logger.LogInformation(...) 照常调用,底层已经自动切到 Serilog 了。这是 Serilog 做得非常漂亮的一点:对业务代码零侵入,改的是基础设施层,不是每一个调用点。
2.2 关键配置项逐行拆解
配置看起来简单,但有几个选项理解不到位,后面会吃亏。
MinimumLevel.Information() 控制日志输出的最低级别,级别从低到高是 Verbose、Debug、Information、Warning、Error、Fatal。实际生产环境我建议设到 Information,Debug 和 Verbose 留到本地调试用,否则日志量会暴涨。
Enrich.FromLogContext() 是 ASP.NET Core 集成里必须开的一个选项。它允许你在一次请求的任意位置往上下文里塞属性,这条请求后续产生的日志都会自动带上。比如在一个中间件里:
csharp复制app.Use(async (context, next) =>
{
LogContext.PushProperty("UserId", context.User?.FindFirst("sub")?.Value);
await next();
});
之后这次请求相关的所有日志都会自动附加 UserId 字段。这个能力对排查用户侧问题极其有用,后面第三节再细说。
ReadFrom.Configuration(context.Configuration) 是告诉 Serilog 从 appsettings.json 里读配置。这样日志级别、输出目标都可以在部署环境里调整,不用重新编译。
2.3 我建议的默认配置模板
给个小团队项目使用,我一般会推荐一个相对完整的默认配置,结合控制台和文件输出:
csharp复制Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithThreadId()
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
)
.WriteTo.File(
path: "logs/app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
fileSizeLimitBytes: 100 * 1024 * 1024,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
)
.CreateLogger();
解释几个关键点:
MinimumLevel.Override("Microsoft", LogEventLevel.Warning)是为了压住框架自身的 Info 级日志。ASP.NET Core 里微软自己的日志特别多,不 Override 的话一个普通请求能打出来几十条,非常吵。retainedFileCountLimit: 30表示最多保留 30 个日志文件,旧文件自动删除。避免磁盘被时间一长就堆满。fileSizeLimitBytes限制单个文件大小到 100MB,配合按天滚动,双保险。- 本地调试用的 Console 输出模板和文件用的模板可以不一样。本地不需要时间戳那么长,文件里则需要精确到毫秒和时区。
这套配置拿过去直接改个路径就能用,等业务规模变大再考虑接 Seq 或者 Elasticsearch。
3. 接收器体系:把日志送往该去的地方
3.1 接收器的工作模型
Serilog 有一个设计理念我非常认同:日志的记录端和输出端是彻底解耦的。
在代码里你只调 Log.Error(...),至于这条日志是写到控制台、写进文件、推到数据库、还是发给 Kafka,全由配置决定。每一类输出目标对应一个接收器(Sink),Serilog 生态里已经适配了几十种常见目标。
写入多个接收器是并联关系,配置几条 WriteTo 就是同时往几个地方写。最常见的组合是:本地文件保底,同时推一份到集中日志平台,方便团队查询和告警。
3.2 三种常见组合
实际项目里,我见过最多的是下面三类组合,可以按团队规模对号入座:
小型单体项目:Console + File
只需要弄一台服务器、日志量也不大的阶段,File 接收器就够了。配置简单,排障时直接上服务器看文件。
中型微服务项目:File + Seq
Seq 是一个基于 .NET 的日志查询服务,接收 Serilog 事件,可以按字段筛选、画图表、配告警。它体积小、部署简单(Docker 一条命令就能起来),适合微服务数量还没到夸张程度的中型团队。
csharp复制.WriteTo.Seq("http://seq.example.com:5341", apiKey: "your-api-key")
本地文件继续保留一份作为离线兜底,一旦 Seq 挂了,本地文件还能查。
大型分布式项目:Elasticsearch 或 ClickHouse 系
日志量上了每天几个 GB 甚至几十 GB,就需要上全文检索引擎或者列式存储。Elasticsearch 生态虽然运维成本高,但查询能力最强。ClickHouse 这几年也流行,存日志的成本比 ES 低很多。
很多人一上来就想上 ES,其实没有必要。日志方案跟着问题走:分布式系统里最典型的问题是一笔请求跨多个服务,需要在日志里把这次调用的轨迹串起来。这种场景下,哪怕是 Seq 也已经能解决 80% 的问题了。
3.3 文件接收器那些容易被忽视的细节
文件接收器虽然基础,但真正用好的人不多。几个重点:
rollingInterval 有七种可选:.Infinite、.Year、.Month、.Day、.Hour、.Minute、.Second。大多数服务按天滚动足够了,但日志量极其大的服务可以考虑按小时,降低单文件大小,减少查询时的 IO。
rollOnFileSizeLimit: true 配合 fileSizeLimitBytes 可以再套一层大小限制。注意这两者需要同时出现,否则大小限制不生效。
shared: true 参数只用于多进程写同一个文件时的共享模式。默认 shared 是 false,在多实例部署时如果两个进程写同一个文件路径,会造成文件锁冲突。注意:shared: true 是给历史遗留场景的兜底,多个进程往同一个文件写无论如何都不是好方案,生产环境应该做的还是让不同实例写不同文件,或者直接走集中日志平台。
路径问题也值得提一句。Serilog 的文件路径是相对当前工作目录解析的。有些项目用 systemd 部署,工作目录不一定是程序所在目录,日志就会写到意想不到的地方。建议尽量用配置项显式指定绝对路径,或者统一用环境变量拼出日志目录,避免这种问题。
4. 丰富器与过滤:让日志自己带上上下文
4.1 丰富器:自动附加公共属性
每条日志都需要的公共字段,比如机器名、进程 ID、线程 ID、请求路径、用户 ID、版本号,如果一个一个手动塞进去,代码会被污染得很厉害。
Serilog 的解决方案是丰富器(Enricher)。它作用在日志管道的某个层面,给每一条事件自动附加属性。
常用的几个:
csharp复制.Enrich.WithMachineName() // 附加机器名
.Enrich.WithProcessId() // 附加进程 ID
.Enrich.WithThreadId() // 附加线程 ID
.Enrich.FromLogContext() // 从 LogContext 读取自定义属性
WithMachineName 在多实例部署、日志聚合到同一平台时尤其有用。同一时间不同机器处理的请求,机器名会让你第一时间知道要查哪台机器。
FromLogContext 是动态属性的入口。它不是一次性设置,而是对一个执行上下文(比如一个 HTTP 请求)内所有日志生效。上一节讲 ASP.NET Core 接入时已经在中间件里展示了用法。这里再补充一点:LogContext.PushProperty 返回一个 IDisposable,用 using 包裹或者手动 Dispose 之后,属性自动结束作用域。也就是说你可以用这种方式给日志加“临时上下文”,比如:
csharp复制using (LogContext.PushProperty("OrderId", orderId))
{
// 这里面的日志都会带上 OrderId
}
这在处理同一个请求内多步骤的排障时非常高效——所有日志带上同一个订单号,前后按时间排开就是一次完整的事务轨迹。
如果你不希望代码里到处写 PushProperty,更推荐的做法是在框架层做一次全局注入。比如 ASP.NET Core 里写一个中间件,从请求 Header 里取 X-Request-Id,Push 进 LogContext,这样日志天然带链路 ID,后续排查按链路 ID 一拉,一个请求从头到尾的日志全部出来了。
4.2 过滤器与日志级别的配合
默认配置下,MinimumLevel 控制的是全局最低级别。但真实场景里,你很少希望一刀切。比如自己的业务代码想打 Information 就够,但某个第三方库的调试日志特别吵,Windows 系统日志相关的又特别依赖网络。
MinimumLevel.Override 就是干这个的。它针对某些命名空间前缀单独设置更高级别,过滤掉不想要的日志。比如:
csharp复制.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
EF Core 的 SQL 日志、HttpClient 的请求日志,默认情况下一开 Information 就全冒出来,在生产环境下信息量很大也是噪音。用 Override 提到 Warning,让它们只在出错的时候冒头。
如果你有更复杂的过滤条件,比如“某个关键接口的 Error 日志要单独落一份 ffi 文件”,可以用 Filter:
csharp复制loggerConfig
.Filter.ByIncludingOnly(evt =>
evt.Level >= LogEventLevel.Error ||
evt.MessageTemplate.Text.Contains("PaymentWithTimeout"))
.WriteTo.File("logs/critical-only-.log", rollingInterval: RollingInterval.Day);
通过过滤器,只把 Error 以上的日志和包含“PaymentWithTimeout”模板的消息写进专门文件。这样值班同事就只需要盯这一个文件,不用在所有日志里翻 ERROR。
4.3 结构化日志的查询方式
讲了这么多结构化的好处,实际用起来是什么样的体验?
以 Seq 为例。接入 Seq 后,你在它的查询框里输入:
sql复制OrderId = '20240618142345123'
就能得到这个订单相关的全部日志,按时间排序,还能按级别筛选。之前得在文本里大海捞针的事,现在变成了一条查询语句。
更进一步,你可以在 Seq 里写这种查询做统计分析:
sql复制Level = 'Error' and Application = 'order-service'
按服务、时间范围、错误类型聚合成图表,看错误趋势,设置告警阈值。这些能力建立在日志是结构化事件的基础上,文本时代想都不敢想。
需要强调的是:结构化日志不会立刻带来这种查询能力,你需要一个能理解和存储事件形态的接收端。Serilog 负责生产结构化事件,接收端负责消费和展示。如果你只是把事件序列化成纯文本存进文件,那相当于还是在用文本日志的方式使用结构化日志——能用,但价值大打折扣。
5. 生产环境里我踩过的坑和调整出的配置
5.1 性能影响和合理控制
很多人担心结构化日志的性能。这个担心不是空穴来风——毕竟要做属性提取、模板解析、多路输出。但实测下来,Serilog 在常规业务里的开销是可以忽略的。
关键要控制两点:
- 日志量是主要成本。单条日志的性能是微秒级,但如果一个请求写了几百条日志,每条都打一堆属性,叠加起来性能就会明显下降。合理配置日志级别,让 Debug 和 Verbose 默认不输出,是最有效的性能优化手段。
- 高并发场景下,设为异步输出是必要的。
WriteTo.File默认是同步写,极端情况下文件 IO 会阻塞业务线程。你可以给文件接收器加buffered: true,或者用专门的异步包装:
csharp复制.WriteTo.Async(a => a
.File("logs/app-.log", rollingInterval: RollingInterval.Day))
注意这个需要装 Serilog.Sinks.Async。它的作用是把日志写入操作排队,由后台线程批量处理,业务线程写完就返回。代价是如果进程崩溃,缓冲区里未写入的日志会丢。日志对你有用还是业务正确性优先,这里需要做个取舍。我的经验是:文件接收器值得开异步,数据库接收器更值得开,因为数据库写入慢、抖动大,最容易成为瓶颈。
5.2 日志保留策略:不是存得越久越好
日志是数据资产,也是存储成本。文件接收器提供了 retainedFileCountLimit、fileSizeLimitBytes 这些参数。我遇到过不只一个团队,上线后半年没人管日志,等磁盘满了才去处理,发现文件已经几十 GB,清理时程序还因为文件被占用删不掉。
建议从一开始就定清楚:按天滚动,保留 15-30 天,单文件上限 100MB,超出部分丢。对大多数系统,30 天足够覆盖排障和审计窗口。需要更久的数据,应该导到低成本的对象存储或者剪报系统,而不是躺在服务器磁盘上。
5.3 动态调整日志级别:不改代码改配置
生产环境日志级别设到什么程度是个两难:设低了,日志太多,IO 和存储扛不住;设高了,出问题的时候信息不够。
Serilog 支持运行时动态调级。把 MinimumLevel 放在配置中心(比如 Apollo、Nacos,甚至环境变量),修改配置后日志库不需要重启进程就能生效。
在 ASP.NET Core 里最简单的方式是把日志级别配置放到 appsettings.json:
json复制{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
}
配合一些配置热更新机制,就可以做到:线上出问题时把某个服务的日志级别临时切到 Debug,拿到细节再切回来,全程不用重启进程。这里提醒一下:即使支持热更新,把级别调低到 Debug 会让日志量上涨几个数量级,线上操作一定要有意识、有节奏,记录好切换时间点,完了及时调回。
5.4 老代码里的字符串插值日志要不要全改
这是团队改造时最常被问到的问题之一。已有的几百处 _logger.LogError($"xxx {userId} xxx") 到底要不要改成模板写法?
我的建议是:新代码统一用模板写法,老代码按模块分批改,不追求一次改完。
原因很简单:老代码的字符串插值日志在 Serilog 里能正常工作,你甚至不需要改动代码就能切到 Serilog。插值的字符串就是 MessageTemplate 的最终文本,日志能打出来,但属性没有被提取,后续无法按字段筛选。它退化成了一条“美化了的空结构日志”。应急垫底可以,但不能长期依赖。尤其是涉及订单、用户、支付这类核心链路的日志,值得花时间改成消息模板写法,因为排查线上问题的效率提升是立竿见影的。
还有一个改造中的小技巧:消息模板里给属性起名时,用 PascalCase 还是 snake_case 要统一。我见过一个项目里 OrderId 和 order_id 混着用,结果在日志平台里同一个业务字段被拆成两个属性,查询和统计都会漏数据。改起来不复杂,但统一这件事越早做越好。
最后说点个人使用体会
用了 Serilog 几年下来,我最大的感受是:日志这件事,值得在初期就认真设计,而不是等项目出问题了再回头补。结构化日志的价值不是立刻显现在第一周,而是三个月后当你第一次用“一条订单号的查询拉出完整失败链路”时,就会觉得当初的改造完全值得。代码层面的侵入非常小,但排障效率的提升是质变。
给刚开始用的朋友一个实用建议:先小范围试点,比如只对接一个服务,把 Console + File 跑通,日志能正常滚动落盘;再看你的团队需不需要集中查询,需要的话加一个 Seq;最后再考虑要不要上 Elasticsearch。日志系统的复杂度跟着团队规模走,不要一上来就规划一个庞大的日志平台,过度设计在这里同样适用。
Serilog 已经是我个人和所在团队的标准配置了。如果你还在用传统文本日志方案,我建议你认真考虑换掉它;如果你已经切换到 Serilog,希望上面这些配置和踩坑经验能帮你少走点弯路。
