上个月排查一个线上订单状态不一致的问题,我在生产环境翻了两小时日志。雪上加霜的是,那套系统的日志还是一行行的字符串拼接,里面混着请求时间、用户手机号、异常堆栈,全部塞在一个文本框里。我明知道问题就藏在某个日志片段中,想按用户ID筛一下却发现根本筛不了——因为日志里压根没有"用户ID"这个字段,它只是某一行字符串里被拼进去的一段数字。那时候我真正意识到,日志这件事,如果还停留在"拼字符串给人看"的阶段,工程化是无从谈起的。也是从那次之后,我把项目里的日志方案整体迁移到了Serilog,这套 .NET 下的结构化日志库,极大改变了我们排障、监控和做数据挖掘的方式。
这篇文章不打算写成官方文档的翻译稿,我想从一个实际落地者的角度,把 Serilog 从认知到工程落地的完整链路梳理一遍。内容会用真实的使用场景来讲:为什么需要结构化日志、Serilog 的核心抽象怎么理解、NuGet 包如何选型、.WriteTo.File 文件名格式怎么写才不踩坑、和 ASP.NET Core 集成时有哪些注意事项,以及生产环境里最让人头疼的脱敏、性能和排错问题。无论你是第一次接触 Serilog,还是已经用过但总感觉哪里没玩明白,这篇文章都值得你花十分钟看完。
1. 文本日志的账本:为什么拼字符串这种方式必须换掉
1.1 一个让我彻底转向结构化日志的线上事故
先还原一下那个让我痛下决心改造日志的事故现场。业务侧反馈说用户在下单之后,订单状态经常卡在"待支付",但实际上钱已经扣了。要查这个问题,最直接的办法就是把这几个小时的交易日志全部捞出来,按用户手机号去筛。文本日志长这样:
code复制2025-01-12 14:03:22.123 INFO 用户 138****8888 发起支付,订单号 20250112140322001,金额 99.00
2025-01-12 14:03:22.134 INFO 用户 138****8888 支付回调处理开始,订单号 20250112140322001
2025-01-12 14:03:22.567 ERROR 支付回调处理失败: 状态=未支付
问题是什么呢?我想查这个用户的所有请求,命令是写出来了,日志也能 grep 到,但是因为所有信息都被揉在一个字符串里,细节字段完全没法被程序理解。你说我能不能用正则强行抠出手机号?这个小项目可以,但一旦字段多了、日志量大了、跨系统查了,纯文本的解析成本会指数级上升。更要命的是,这种写法对"埋点"完全不友好,日志分析平台拿过来也只能做全文检索,没法按字段聚合。
后来我们同期做的一个新服务用了 Serilog + Seq,同样的排查场景,我只需要在 Seq 的搜索框里输入 UserId = 138...,相关的所有日志、上下文、属性、异常堆栈全部结构化地列出来,秒级定位。那次对比,让我彻底认账了:日志不是写给人读的,更是写给机器读的。
1.2 从"给人看"到"给机器读":结构化日志的本质变化
所谓结构化日志,本质上就是每个日志事件不再是一个"字符串",而是一组属性包加上一条消息模板。比如上面那条日志,用结构化方式写出来是这样的:
csharp复制logger.Information("用户 {UserId} 发起支付,订单号 {OrderId},金额 {Amount}", userId, orderId, amount);
Serilog 在内部会把这条日志解析成一个 LogEvent对象,里面有时间戳、日志级别、消息模板,还有一个包含 UserId、OrderId、Amount 的属性字典。当它输出到控制台或文件时,会渲染成人类可读的文本;当它输出到 Json 格式的 Sink 时,属性会变成一个 JSON 对象,机器可以直接解析。
这意味着什么?意味着日志从"一段文字"变成了"一条记录"。记录是自带 Schema 的,字段可以被索引、被过滤、被聚合、被用于告警。这就是为什么很多日志平台(Elasticsearch、Seq、Loki)都能针对 JSON 日志做字段级查询,因为它们拿到的是结构化数据,而不只是日志字符串。
这里有个很多人没想透的点:结构化日志和日志平台(比如 ELK)并不是同一个东西。ELK 里的 Logstash 也得靠解析规则才能把文本变成字段,而 Serilog 在应用侧就已经把字段定义好了,输出端直接消费即可。所以从整个链路上看,在应用侧做结构化日志,是让日志后端真正智能起来的最短路径,没有之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Serilog 的核心抽象:Logger、Sink、Enricher 的分工与协作
2.1 消息模板:结构化日志的最小单元
Serilog 里最核心的 API 就是 LoggerConfiguration,所有能力都围绕它展开。而所有日志事件都是从消息模板(Message Template)开始的。消息模板长这样:
code复制"User {UserId} logged in from {IpAddress}"
这里 {UserId} 和 {IpAddress} 是命名占位符。写日志的时候你传入对应参数,Serilog 会完成两件事:把这些参数按名保存为结构属性,同时渲染出可读的文本消息。正是因为占位符有名字,下游才能按名取字段。这是"结构化"的根基。
很多刚上手的人会把消息模板当成字符串拼接来用,写出这种代码:
csharp复制// 错误示范:模板退化成字符串
logger.Information($"User {userId} logged in");
这样写看似没什么问题,实际上 Serilog 拿到的是一个已经拼好的字符串,userId 会被直接渲染进文本里,而不会成为结构属性。你在日志平台里就查不到 `
