
1. 为什么我最终把项目里的日志框架换成了Serilog先交代一下背景。我接手维护过好几个.NET系的老项目日志这块儿的通病几乎一模一样要么是一堆Console.WriteLine写在业务代码里要么就是用了某个自带日志库但完全没人配置过最后日志文件打开来全是毫无规律的纯文本别说搜问题连按时间线把一次请求的事件串起来都费劲。真正让我下决心全面替换的是我有一次排查线上偶发超时问题花了整整两天最后靠的是在关键节点临时加了十几个日志点重新发布才定位到。那时候我就在想如果日志从一开始就是结构化、带上下文的这种问题应该几个小时就能解决。这个“结构化日志”正是Serilog最核心的价值点。Serilog是.NET生态里非常成熟的一个日志库它在2013年前后出现作者是Nicholas Blumhardt如今已经成为社区里最主流的日志方案之一。它跟传统日志库最大的区别在于传统日志是记录“一段文字”Serilog记录的是“一个事件”事件里可以附带任意数量的键值对属性。也就是说你除了能看到那条日志消息本身还能看到这条日志发生时携带的用户ID、订单号、请求路径、耗时、机器名、线程ID等等所有你想记录的上下文数据。那它到底能解决什么实际痛点第一个痛点是检索效率。文本日志只能靠grep或编辑器全文搜索搜出来的内容是“包含关键字的行”但如果你想知道“这个用户ID范围内最近三天所有超过500毫秒的请求”传统文本方案基本就抓瞎了。而结构化日志把属性变成可查询的字段无论是输出到Elasticsearch、Seq还是SQL Server都能像查数据库一样去过滤和聚合。第二个痛点是上下文丢失。传统日志里你打一条“扣款成功”它不会自动带上订单号。你需要在打日志之前手动做字符串拼接少拼一个字段排障时就少一条线索。Serilog的模板语法强迫你用结构化的方式记录消息那些重要的业务数据天然就跟着日志走。第三个痛点是输出目标太死板。早期很多日志库只支持文件或者写完文件之后再也变不了。Serilog用Sink机制把输出目标做成插件式控制台、文件、数据库、各类第三方日志平台想接哪就接哪而且可以在不改业务代码的前提下通过配置随时切换。这篇文章我会从核心设计讲起然后给出能直接抄作业的最小示例再讲一讲跟ASP.NET Core集成的正确姿势最后把我实际踩过的坑汇总成一张排查表。适合刚接触Serilog、或者已经在项目里用了但对它“知其然不知其所以然”的.NET开发人员也适合想给团队搭建一套统一日志方案的技术负责人。2. Serilog的核心设计一条日志从产生到输出的完整旅程2.1 LoggerConfiguration是那个“组装车间”Serilog的使用体验跟很多库不一样它不是你new一个实例就开始干活而是先构建一个“管道”。整个管道的组装入口就是LoggerConfiguration这个类。你可以把LoggerConfiguration理解成一条流水线的总控台在这条流水线上你决定日志最少到什么级别才放行放行之后要经过哪些“润色”步骤最后送到哪个出口。典型的构建代码长这样Log.Logger new LoggerConfiguration() .MinimumLevel.Debug() .Enrich.WithMachineName() .Enrich.WithThreadId() .WriteTo.Console() .WriteTo.File(logs/myapp-.log, rollingInterval: RollingInterval.Day) .CreateLogger();这里有个特别重要的理念Serilog的整个配置是在构建阶段一次性完成的一旦CreateLogger()执行完毕管道就固定了。你以后代码里写的每一条Log.Information、_logger.LogError本质上都是往这个已经铺好的管道里塞事件走完整个管道就结束了。这种设计的好处是灵活性和性能都兼顾了你要加一个输出目标只需要在构建时叠加一个WriteTo而运行时不需要再做任何匹配判断减少了性能损耗。在版本选择上目前多数新项目直接用Serilog 3.x和4.x。有历史代码的老项目Serilog 2.x的API基本兼容升到新版一般不会出大问题。我自己的习惯是新建项目直接装最新稳定版老项目如果日志逻辑不复杂也顺手升一下省得以后维护两套写法。2.2 Sink日志去向的抽象为什么它这么能打日志写出来发到哪里Serilog把它抽象成了Sink。这个名字很形象水槽嘛水往低处流日志从管道里流出来最终流进一个容器。文件、控制台、数据库、消息队列、第三方日志平台每一种都是一个Sink实现。官方和社区贡献的Sink非常多我列几个最常遇到的Sink包名输出去向典型使用场景Serilog.Sinks.Console控制台本地开发调试Serilog.Sinks.File本地文件单机应用、简单项目Serilog.Sinks.Async异步包装Sink高并发场景下避免IO阻塞Serilog.Sinks.SeqSeq日志服务器集中式结构化日志检索Serilog.Sinks.ElasticSearchElasticsearch大规模日志分析、Kibana可视化Serilog.Sinks.MSSqlServerSQL Server公司已有数据库平台便于复用权限体系需要注意一点Sink不是互斥的。一个管道里可以挂多个Sink而且每个Sink可以有自己的级别限制和输出模板。这就带来一个非常实用的玩法——同一份日志控制台里只Debug文件里留Information以上出问题的模块单独的红线日志直接发到邮件或IM告警。全在构建配置里做完业务代码里一行都不需要改。Sink粒度这个设计其实是Serilog最聪明的决策之一。核心库只负责日志事件的建模、级别过滤、消息模板渲染至于事件最终落到哪里完全交给Sink去扩展。所以社区才涌现出几百个SinkAzure、AWS、阿里云、腾讯云各种都有对应实现。以后就算公司换了日志平台大概率也不用改业务代码只需要改配置、换Sink包、重编译发布而已。2.3 级别Level从源头减少日志噪音很多初学Serilog的人会忽略级别配置结果一上线日志文件狂涨几天就把磁盘干满了。级别这个事看似简单但里面有个很容易被误解的点。Serilog沿用了常见的日志级别体系从低到高分别是Verbose最细的跟踪信息、Debug调试信息、Information常规信息、Warning警告、Error错误、Fatal致命错误。MinimumLevel控制的是“最低放行级别”低于这个级别的日志事件会被直接丢弃连消息模板都不会渲染。我推荐的通用配置是开发环境Debug或Verbose测试环境Information生产环境Warning或Information。但生产环境如果真的只给Warning往往又会丢失一些关键的业务链路信息。更好的做法是给关键的模块单独调级别。Serilog支持通过配置文件覆盖级别也支持代码里按命名空间定制.MinimumLevel.Information() .MinimumLevel.Override(Microsoft.EntityFrameworkCore, LogEventLevel.Warning) .MinimumLevel.Override(System, LogEventLevel.Warning)这两行Override的意思是命名空间以Microsoft.EntityFrameworkCore和System开头的日志事件最低级别提升到Warning。这样做是为了把框架自身的Info日志过滤掉只保留自己的业务Info日志。如果不加这两行ASP.NET Core在启动时会打出海量的框架内部信息整个日志文件会非常嘈杂真正的业务数据反而淹没在里面。很多新手第一次接Serilog都会遇到“日志好多好乱”的问题八成就是没做这层Override。3. 快速上手五分钟搭出一个可用的Serilog日志系统3.1 一个最小示例的完整拆解理论讲再多不如直接跑一段代码。创建一个控制台应用然后通过NuGet安装两个包这一步之后你就能打出第一条结构化日志了SerilogSerilog.Sinks.Console先看代码using Serilog; Log.Logger new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() .CreateLogger(); Log.Information(应用启动); Log.Debug(初始化用户信息UserId: {UserId}, 10086); Log.Warning(请求耗时偏高ElapsedMs: {ElapsedMs}ms, 850); Log.Error(扣款失败OrderId: {OrderId}, Error: {ErrorMessage}, A2024060001, 余额不足);跑起来之后控制台输出大概长这样[14:32:08 INF] 应用启动 [14:32:08 DBG] 初始化用户信息UserId: 10086 [14:32:08 WRN] 请求耗时偏高ElapsedMs: 850ms [14:32:08 ERR] 扣款失败OrderId: A2024060001, Error: 余额不足注意看这几条日志的写法消息模板里用了{UserId}、{OrderId}这种占位符后面的参数按顺序对应。千万不要自己去拼字符串比如写Log.Error(扣款失败OrderId orderId)这样Serilog就无法把OrderId识别成一个独立字段你的结构化就白做了。为了把这个问题说透我举一个对比明显的例子。用Log.Error($请求失败{url}状态码{statusCode})这种方式日志输出后就是一段平铺的文字。但用Log.Error(请求失败 {RequestUrl}状态码 {StatusCode}, url, statusCode)这种方式Serilog会正确地把RequestUrl和StatusCode作为两个属性存下来。后续如果接上Seq你直接在查询框里写StatusCode 500就能把所有500错误的日志找出来字符串拼接的日志是做不了这种查询的。3.2 从控制台到文件配置文件的正确打开方式控制台输出适合开发时看项目上线肯定要落到文件或者直接送到日志平台。文件Sink是最常用的它的安装包是Serilog.Sinks.File。Log.Logger new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() .WriteTo.File( path: logs/myapp-.log, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30, outputTemplate: {Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception} ) .CreateLogger();path参数里的myapp-.log看起来很怪其实那个短横线是给rolling日期占位的。配了rollingInterval: RollingInterval.Day之后当天会写myapp-20240607.log第二天自动切到myapp-20240608.log不用自己管文件切割。除了按天滚动也可以按小时Hour、按月Month。retainedFileCountLimit控制的是最多保留多少个历史文件到了数量上限会自动删最老的。这个参数对磁盘空间非常关键。我见过一个生产事故日志没配保留数量半年下来磁盘被日志文件撑爆了整个服务挂掉。这种低级错误只要在配置里加这么一行就能避免。outputTemplate则是用来控制输出到文件里的文本长什么样的。默认模板已经够用但我个人习惯把时间戳格式改成带毫秒和时区的格式排查问题时毫秒级时间戳能帮你判断两次操作是否在同一个瞬间发生。文件Sink还有两个进阶选项值得知道。一个是shared: true允许多个进程同时写同一个日志文件如果你的服务是单机多进程部署可以省掉不少聚合麻烦。另一个是配合Serilog.Sinks.Async这个包做异步写文件用法是在WriteTo.Async(a a.File(...))这种写法因为直接用文件Sink时每条日志写入都会同步做IO高并发下吞吐量有明显瓶颈。我压测过一个报表服务日志量达到每秒几千条时同步文件写入直接让接口平均耗时翻倍。换成异步Sink之后日志写入对性能的影响几乎可以忽略不计。代价是进程崩溃时异步缓冲区里最后几百条日志可能没来得及落盘就丢了。业务日志如果必须保证不丢建议直接用稳定的日志服务而不是本地文件。这里再补充一个实际开发中非常有用的习惯把第一行日志放在Program的最前面。很多项目习惯先做一些初始化再配日志结果在初始化阶段一旦抛异常日志系统还没就绪唯一的报错信息只能看控制台或者被系统吞掉。正确的做法是像上面那样进Main方法之后第一件事就构建Log.Logger然后把Log.Information(应用启动)打在初始化之前。4. 进阶实战把Serilog接入ASP.NET Core并让日志带上“现场信息”4.1 替换默认日志框架的完整姿势控制台项目里Log.Logger是全局静态的想打日志直接Log.Information就行。但ASP.NET Core项目里的最佳实践不是直接用这个静态入口而是把Serilog无缝接到Microsoft.Extensions.Logging体系里这样你注入到控制器、服务里的ILoggerT依然能用但背后的实际输出已经切到了Serilog。这么做的好处是显著的一方面第三方库比如EF Core、ASP.NET Core框架打印的日志也会走Serilog你能得到一个统一的日志视图另一方面你自己的业务代码不需要关心到底用的是什么日志实现依赖注入的方式完全不变万一以后想切方案业务层一行都不用改。新建一个ASP.NET Core Web API项目然后安装NuGet包Serilog.AspNetCore这个包会把其他核心包都引进来包括Serilog、Serilog.Extensions.Hosting等装这一个就够然后在Program.cs里写using Serilog; var builder WebApplication.CreateBuilder(args); builder.Host.UseSerilog((context, services, configuration) configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() .WriteTo.Console() .WriteTo.File(logs/weba-.log, rollingInterval: RollingInterval.Day)); builder.Services.AddControllers(); var app builder.Build(); app.MapControllers(); app.Run();这里几个链式方法分别有什么作用ReadFrom.Configuration表示读取appsettings.json里名为Serilog节点的配置也就是说日志级别、Sink、参数这些都可以放到配置文件里动态修改改配置之后重启应用即可生效不需要重新编译。ReadFrom.Services则允许你在日志配置里使用依赖注入容器里注册的组件比如自定义的Enricher。Enrich.FromLogContext()是接入ASP.NET Core场景最关键的一行下面会详细说。配置文件里需要在appsettings.json的根部增加Serilog节点但要注意一点不要重复配置。如果你在代码里已经写了WriteTo.File又在json里写了WriteTo: [ { Name: File, ... } ]两个地方会各自生效结果就是同一条日志被写入同一个文件两次。这是新手最容易踩的坑之一后面常见问题里还会再讲。4.2 Enricher与LogContext让日志自动带上“现场信息”结构化日志价值最大的场景是在高并发系统里排查某一个具体用户或某一次具体请求出的问题。如果日志只有消息本身没有关联字段你从几千条日志里捞一条特定请求的日志非常困难。Serilog的Enricher机制就是来干这件事的。先看最简单的内置Enricher.Enrich.WithMachineName() .Enrich.WithThreadId() .Enrich.WithEnvironmentName()这三个分别是给日志加上机器名、线程ID和环境名。加机器名在多机部署时特别有用你能直接判断日志来自哪台服务器。加线程ID适合排查并发问题。但真正让我觉得“值回票价”的是LogContext配合FromLogContext。它允许你在一个作用域内给后续所有日志动态添加属性。举个电商下单的例子。假设一个订单处理流程涉及创建订单、扣库存、发消息三个步骤你希望整个流程的日志都带上订单ID。传统做法是每个方法都传一个订单ID参数打日志时手动带上更糟的做法是写一个静态类存当前请求的订单ID线程安全都是个问题。用LogContext就清爽得多public async TaskIActionResult CreateOrder(CreateOrderRequest request) { using (LogContext.PushProperty(OrderId, request.OrderId)) using (LogContext.PushProperty(UserId, request.UserId)) { _logger.LogInformation(开始创建订单); await _orderService.CreateAsync(request); _logger.LogInformation(订单创建逻辑完成); await _inventoryService.DecreaseAsync(request.ProductId, request.Quantity); _logger.LogInformation(扣减库存完成); return Ok(); } }这三条LogInformation本身没有写OrderId和UserId参数但因为它们都在LogContext.PushProperty的using作用域之内Serilog会自动给这三个日志事件都附加上OrderId和UserId属性。排查问题时你在Seq里搜OrderId xxx能立刻把整个请求链路相关的日志全部捞出来一条不漏。这个体验比我接Serilog之前好了不止一个量级。如果某个字段是每个请求都要带的比如用户身份、会话ID、租户ID可以写一个自定义的ILogEventEnricher在中间件里把属性推进LogContext。基本思路是写一个类实现ILogEventEnricher接口重写Enrich方法在中间件里组合使用。实际项目中我建议至少要带上这几个通用字段机器名、环境名、请求路径、请求方法、用户标识、TraceId。特别是用户标识有了它“某个用户反馈出错了你去看看他的日志”这种需求就变成了几秒钟的查询操作。5. 真实踩坑记录Serilog使用中的高频问题速查5.1 日志重复输出多半是配置被加载了两次这是我被问过最多的问题。现象是代码里的每条日志在文件里出现了两次一模一样的时间戳和内容。原因基本逃不过两种。第一种是最上面提到的代码里写了WriteTo.File同时配置文件里的Serilog节点也配了File Sink等于一条日志走了两遍文件Sink。第二种是用builder.Host.UseSerilog()正确替换了日志框架但同时还在某些地方手动调Log.Logger new LoggerConfiguration()...CreateLogger()重新初始化了全局Logger并且那个Logger也配了同一个文件。两条管道都指向同一个文件自然就是双份。解决办法也很简单定一个原则配置只在入口处做一次。我的习惯是全部用代码配置因为代码里容易做条件判断比如if (builder.Environment.IsDevelopment())可以灵活给不同环境挂不同的Sink。如果你更习惯用配置文件那代码里就不要写任何WriteTo把所有Sink都放appsettings.json。5.2 消息模板写成字符串拼接等于放弃了结构化这个问题开发时肉眼看不出来因为控制台输出的效果看起来差不多。但只要一接入Seq或者Elasticsearch差异立刻就出来了你搜不到字段因为没有字段。我见过不止一个项目代码里全是Log.Error($调用外部接口失败url{url}status{status})的写法日志平台也接好了结果接了个寂寞。关于这一点团队内最好做一次规范约定所有日志消息模板必须是常量字符串动态数据一律用占位符传参。这不只是Serilog的约定也是结构化日志的通用规则。如果你用的模板不是常量编译器也无法帮你检查占位符数量和参数个数是否匹配。Serilog在渲染模板时如果参数数量和占位符对不上会有两种表现参数比占位符少多出来的占位符显示为空参数比占位符多多出来的参数被忽略并且在部分Sink里会记录警告。这一步要卡得严一点工欲善其事必先利其器。5.3 日志里的敏感信息脱敏不能等到上线再考虑日志最容易被忽略的风险是敏感信息泄露。用户手机号、身份证号、密码重置Token、支付回调的签名这些东西一旦进了日志再进到ELK或者第三方的日志平台就等于数据出境了。Serilog没有内置通用的脱敏Sink但实现思路不复杂。最常用的做法是写一个自定义Enricher重写Enrich方法遍历日志事件的所有属性对值做脱敏替换public class MaskingEnricher : ILogEventEnricher { private const string MaskPattern ***; public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { var properties logEvent.Properties; if (properties.TryGetValue(Phone, out var phoneValue)) { var masked propertyFactory.CreateProperty(Phone, MaskPattern); logEvent.AddOrUpdateProperty(masked); } if (properties.TryGetValue(IdCard, out var idCardValue)) { var masked propertyFactory.CreateProperty(IdCard, MaskPattern); logEvent.AddOrUpdateProperty(masked); } } }更复杂的场景推荐用现成的Serilog.Enrichers.Sensitive这个包它支持按正则表达式做脱敏配置几个正则就能覆盖大部分手机号、邮箱、银行卡号的脱敏需求。说到底脱敏这个动作一定要在写入Sink之前完成所以用Enricher是正确的介入时机等日志到了文件或者搜索平台再做脱敏基本已经晚了。5.4 性能问题Serilog会不会影响接口吞吐很多人担心加了日志之后服务变慢。这个担心在Serilog上其实是“看你怎么用”。Serilog核心层面的开销非常小日志事件的构造是轻量级的但如果你的每个Sink都是同步写文件或者在异步Sink里又写了很重的格式化那性能瓶颈确实会出现。我测过一组数据供参考单线程循环写10万条简单日志到本地文件同步文件Sink大概要6到8秒用异步Sink包装之后能降到2秒以内。控制台Sink的开销其实比文件Sink更大因为控制台本身是慢速设备而且Windows和Linux的终端渲染效率差别很大。性能优化的基本套路从重到轻排列第一生产环境不要开Debug级别这条能砍掉90%的日志量第二用异步Sink包装文件或网络Sink第三减少outputTemplate里的格式化复杂度比如不要默认输出全类名第四日志量大到每秒几万条以上时优先考虑直接发到高吞吐的日志服务而不是先写文件再转发因为文件中间层本身会成为瓶颈。5.5 Serilog与旧框架混用时的兼容问题老项目改造时可能有很多历史代码用的是Log4Net或者NLog。你可以直接全部替换成Serilog但改动量会比较大。一个折中方案是让Serilog和旧库共存一段时间。Serilog对Microsoft.Extensions.Logging的兼容性很好如果你的项目是.NET Core 3.1及以上版本UseSerilog之后走ILoggerT的所有日志包括EF Core、ASP.NET Core框架、第三方库的都会进入Serilog管道你只需要把业务代码里的Log4Net调用一个一个替换掉就行不需要一次性全改。如果是.NET Framework老项目也可以装Serilog.Extensions.Logging然后手动给ILoggerFactory添加Provider只是步骤稍微绕一点。另外一个容易忽略的点是Log.Logger是全局静态的在单元测试里如果反复调用Log.Logger ...CreateLogger()可能会导致测试用例之间日志配置互相污染。建议用Serilog.Sinks.InMemory这类测试专用的Sink或者每次测试结束后调用Log.CloseAndFlush()清理。6. 关于日志配置的几个最终建议自己在多个项目里把Serilog用顺之后我慢慢形成了一套比较固定的配置模板在这里分享出来可以直接复制到新项目里做基础配置再按需调整。开发环境的配置Log.Logger new LoggerConfiguration() .MinimumLevel.Debug() .MinimumLevel.Override(Microsoft, LogEventLevel.Warning) .MinimumLevel.Override(Microsoft.Hosting.Lifetime, LogEventLevel.Information) .Enrich.FromLogContext() .WriteTo.Console() .CreateLogger();生产环境的配置我会在这套基础上加文件Sink、按天滚动保留30天并且加上机器名和环境名两个Enricher。再高级一点如果公司有Seq或者Elasticsearch就再挂一个相应的Sink把Warning以上级别的日志送过去Log.Logger new LoggerConfiguration() .MinimumLevel.Information() .MinimumLevel.Override(Microsoft, LogEventLevel.Warning) .MinimumLevel.Override(System, LogEventLevel.Warning) .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithEnvironmentName() .WriteTo.Console() .WriteTo.File(logs/app-.log, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30) .WriteTo.Seq(http://seq.yourcompany.com, restrictedToMinimumLevel: LogEventLevel.Warning) .CreateLogger();有一个原则我想强调一下日志配置应该和项目一起进版本控制。不管你是用appsettings.json还是代码配置都不要在服务器上手工改日志级别和Sink否则不同机器的日志行为不一致排查问题时你会怀疑人生。正确的流程是配置文件进Git通过环境变量或发布管道来区分开发、测试、生产环境。最后再讲一个我最近在实践中收获很大的改动。以前我们所有服务都往同一个文件目录写日志跨服务排查一次分布式请求要同时打开三四个日志文件手动对齐时间戳。后来我们把所有服务的日志都集中送到Seq用TraceId这一个字段把所有服务的日志串起来看效果非常震撼。如果你的团队还没上集中式日志平台我建议下一个迭代就把它排上优先级。Serilog本身已经把结构化日志的基础打好了剩下的事情只是把日志送到一个能查询的地方。接入那一层之后你才会真正体会到“结构化日志”四个字的分量。