干后端这些年,我对日志的理解经历了一次彻底“翻篇”,转折点就是在一套真实项目里认真用起了 Serilog。以前排查问题靠记事本打开几十MB文本再Ctrl+F,因为多行异常日志还经常把上下文冲散;后来切到结构化日志,按一个业务字段就能把整条调用链从入口一路拉到数据库层,那种舒畅感是传统文本日志给不了的。这篇内容不打算只讲理论,我想把 Serilog 的认知模型、在 .NET 工程里的接法,以及 WriteTo.File 这类日常落地细节一次性讲透。适合刚接触 Serilog 的 .NET 后端开发,也适合正在维护老项目、准备把日志体系升级成结构化方案的团队参考。
1. 结构化日志到底把“日志”变成了什么
1.1 字符串拼接那套,为什么会害了你
最早写日志,很多团队的习惯是 string.Format 或者 $"用户{userId}下单成功",然后整行字符串丢给 NLog、Log4Net 写到文本文件。早期这确实够用,毕竟系统小、请求量低,出问题翻日志也就几屏的事。可当流量上来、微服务拆分之后,这套做法开始暴露三个非常现实的问题。
第一个问题是没法按字段查。你只能依赖“关键词匹配”,但如果用户ID、订单号被拼接成了长字符串,你想查某个订单的全部日志,就得用订单号去全文搜索,还要忍受异常堆栈撑爆一行后出现的断行误匹配。第二个问题是上下文容易丢。一次请求经过网关、业务服务、数据库访问层,每层都往文件里追加文本,线程一交错,看起来就是一片乱码,想还原调用关系困难。第三个问题是日志缺少“类型”。你很难一眼分清某一行到底是错误、警告,还是普通业务信息,程序里虽然打了 Level,可在文本文件里它只是字符串前缀。
如果只是个人小项目,忍忍也就过去了。但任何一个需要多人维护、需要线上快速定位的系统,日志就不是“能记下来就行”这么简单。它应该是一等公民,是系统运行状态的结构化记录,而不仅仅是给人看的纯文本流水。
1.2 LogEvent才是Serilog眼里的日志
Serilog 的核心模型其实不是“日志字符串”,而是 LogEvent。你可以把一次日志记录想象成一个带有类型的数据包,里面包括时间戳、日志级别、消息模板、异常对象,以及一组键值对属性。
比如下面这行:
csharp复制Log.Information("用户 {UserId} 登录成功,来源IP {ClientIp}", userId, clientIp);
Serilog 不会把它简单地拼成 用户 10001 登录成功,来源IP 192.168.1.1 这样一个大字符串,而是记录成类似下面的结构:
Timestamp: 2025-04-12T10:24:31.235ZLevel: InformationMessageTemplate: 用户 {UserId} 登录成功,来源IPProperties.UserId= 10001Properties.ClientIp= 192.168.1.1
看到区别了吗?UserId、ClientIp 成了独立的结构化属性。日志到了 Seq、Elasticsearch、日志服务里,就能按属性过滤、聚合、做监控告警。就算只写本地 JSON 文件,也可以用 jq 之类的工具直接筛出某个 UserId 的全部日志。
这正是结构化日志的价值:它把“看日志”变成了“查数据”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先弄清Serilog的四个元件,再动手配置
2.1 四个角色:Logger、Sink、Enricher、Filter
Serilog 的架构非常清晰,你只需要理解四个角色:
Logger 是入口,你在代码里调用 Log.Information 或 ILogger<T>.LogInformation 时,就是在创建 LogEvent。LoggerConfiguration 负责组装这个 Logger,告诉它日志写到哪儿、怎么增强、是否需要过滤。
Sink 是输出端,负责把 LogEvent 写到某个目的地。控制台、文件、Seq、Elasticsearch、云日志服务都有对应的 Sink。一个 Logger 可以同时接多个 Sink,也就是一份日志既写文件又发到日志中心。
Enricher 是属性增强器。它可以在 LogEvent 进入 Sink 之前,给属性集合追加一些字段,比如机器名、进程ID、线程ID、请求路径。你不需要在每个业务类里手工塞这些信息,启动时统一挂上即可。
Filter 是过滤器。和日志级别的不同之处在于,它可以按任意属性决定哪些事件放行,比如只保留某个服务的错误日志,或者过滤掉健康检查接口的噪音。
理解这四个角色之后,再去看复杂配置就不会晕了。
2.2 用最小配置跑通第一条结构化日志
先来一个最简模型,感受一下:
bash复制dotnet add package Serilog
dotnet add package Serilog.Sinks.Console
csharp复制using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.CreateLogger();
Log.Information("Hello, {Name}!", "Serilog");
Log.CloseAndFlush();
这段代码做了三件事:创建一个 Logger、把 Information 级别以上事件输出到控制台、打完日志后清理并关闭。其中 {Name} 就是消息模板占位符,控制台格式化时看起来是普通的字符串,但内部已经保留了属性结构。
如果你希望控制台输出变成追查友好的 JSON 结构,可以加一个参数:
csharp复制.WriteTo.Console(new RenderedCompactJsonFormatter())
这样控制台每行输出就会是紧凑 JSON。很多容器环境就靠这种方式把日志通过标准输出交给采集器,再集中到日志平台。
2.3 选Sink不是越多越好
Sink 的选择听起来简单,实际项目里却常常翻车。我见过一个团队一口气接了 Console、File、数据库、
