Serilog结构化日志在.NET应用中的核心原理与生产实践指南

发布时间:2026/8/8 8:05:33
Serilog结构化日志在.NET应用中的核心原理与生产实践指南 1. 为什么我们需要Serilog从日志的“混沌”到“秩序”如果你写过几年.NET程序尤其是Web应用肯定经历过日志的“混沌时期”。打开一个传统的日志文件里面充斥着各种Debug、Info、Error信息像一团乱麻。当线上出问题时你需要在成千上万行日志里用肉眼去搜索那个特定的用户ID、订单号或者请求TraceId过程堪比大海捞针。更别提当你想把日志结构化地输出到Elasticsearch或者Seq这样的工具里进行聚合分析和可视化时传统的基于字符串模板的日志库比如NLog、log4net就显得力不从心了。它们输出的日志对机器来说只是一段文本缺乏可供高效查询的“结构”。这就是Serilog诞生的背景也是它迅速成为.NET生态中结构化日志记录事实标准的原因。Serilog的核心思想很简单日志不仅仅是给人看的字符串更是给程序“消费”的结构化数据。它允许你将日志事件Log Event与一组丰富的属性Properties关联起来。这些属性可以是任何.NET对象Serilog会负责将它们序列化为结构化的格式如JSON。当你把这样的日志发送到像Seq、Elasticsearch或DataDog这样的日志管理平台时你就可以像查询数据库一样轻松地筛选出“所有用户ID为12345的订单创建失败日志”或者“过去一小时内所有响应时间超过500毫秒的API请求”。Serilog 2.10版本是一个重要的稳定版本它建立在成熟的API之上提供了强大的功能集和出色的性能。对于新手来说它可能看起来只是另一个日志库但一旦你体验过结构化日志查询的便捷就再也回不去了。本文不是官方文档的简单翻译而是结合我多年在微服务和分布式系统中使用Serilog的实战经验为你梳理从核心概念到高级用法的完整指南并分享那些官方文档里不会写的“踩坑”心得。2. 核心概念拆解理解Serilog的“世界观”要玩转Serilog必须先理解它的几个核心抽象。这就像学开车先要认识方向盘、油门和刹车一样。2.1 Logger与Log记录器的生命周期在Serilog中ILogger接口是你的主要操作对象。你通过它来写日志。但一个常见的误解是ILogger应该被创建很多次。实际上最佳实践是在应用程序启动时如Program.cs或Startup.cs中创建并配置一个全局的、共享的Logger实例然后通过依赖注入DI将其传递到各个需要记录日志的类中。// 传统方式不推荐在类内部创建 public class MyService { private readonly ILogger _logger new LoggerConfiguration().CreateLogger(); // 错误每个实例都创建新配置。 }为什么因为Logger的配置如输出到哪里、用什么格式、过滤哪些日志是全局性的。如果每个类都自己配置会导致配置不一致、资源如文件句柄、网络连接浪费。在ASP.NET Core中Serilog的集成包已经帮你做好了这一切你只需要在Program.cs中配置一次之后在控制器或服务中通过构造函数注入ILoggerT即可。2.2 结构化属性Properties日志的“灵魂”这是Serilog与传统日志库最本质的区别。看一个例子// 传统日志字符串拼接 _logger.LogInformation($User {userId} created order {orderId} with amount {amount}); // Serilog结构化日志 _logger.Information(User {UserId} created order {OrderId} with amount {Amount}, userId, orderId, amount);看起来只是把字符串插值换成了占位符区别巨大。在第一种方式中最终的日志文本是“User 123 created order 456 with amount 99.99”。日志系统只能把它当成一整段文本。如果你想找orderId为456的所有日志只能进行全文模糊搜索效率低且可能误匹配。在第二种方式中Serilog会记录消息模板Message Template:“User {UserId} created order {OrderId} with amount {Amount}”属性Properties:UserId123,OrderId456,Amount99.99当输出到控制台或文件时它可能仍然显示为类似的文本。但当输出到JSON格式或Seq时它会变成{ “t”: “2023-10-27T10:00:00.123456Z”, “m”: “User 123 created order 456 with amount 99.99”, “l”: “Information”, “UserId”: 123, “OrderId”: 456, “Amount”: 99.99 }现在你可以在Seq里直接输入OrderId 456进行精确查询速度极快。m是渲染后的消息t是时间戳l是日志级别。注意属性名是有讲究的。Serilog默认会对属性名进行一些处理例如将UserId这样的PascalCase名称在JSON输出中保持原样。为了保持一致性建议属性名都使用PascalCase。2.3 接收器Sinks日志的“目的地”Sink决定了日志写到哪里。这是Serilog扩展性极强的体现。通过NuGet安装不同的Sink包你可以轻松地将日志输出到几十种不同的目的地。Console Sink: 输出到控制台。开发环境必备。File Sink: 输出到滚动文件按日期或大小滚动。Seq Sink: 输出到Seq服务器。这是与Serilog“天作之合”的可视化日志平台强烈推荐用于开发和测试环境。Elasticsearch Sink: 输出到Elasticsearch结合Kibana进行生产环境日志分析。Application Insights / Azure Analytics Sink: 输出到Azure监控服务。还有很多数据库、邮件、Slack、HTTP端点等等。一个Logger可以配置多个Sink实现日志的多路输出。例如将Debug级以上日志输出到文件将Information级以上日志输出到Elasticsearch。2.4 日志级别Log Level控制日志“音量”Serilog遵循标准的日志级别Verbose,Debug,Information,Warning,Error,Fatal。你可以通过配置全局最低级别和针对特定命名空间或Sink的覆盖规则来精细控制日志的输出量避免生产环境日志泛滥。2.5 浓缩器Enrichers为日志“添砖加瓦”Enricher可以在日志事件被写入Sink之前自动为其添加额外的属性。这是实现上下文信息自动附加的利器。常用的Enricher包括ThreadIdEnricher: 添加线程ID。MachineNameEnricher: 添加机器名。EnvironmentUserNameEnricher: 添加环境用户名。最常用的是LogContext: 它允许你在一个特定的逻辑操作如一个HTTP请求范围内动态地附加属性。例如在ASP.NET Core中间件中你可以为当前请求附加RequestId和UserId那么这个请求处理链条中所有后续的日志都会自动带上这些属性无需在每个日志语句中手动传递。3. 从零开始在ASP.NET Core 6/8中配置Serilog理论说完了我们动手配置。以下以ASP.NET Core 6/8最小API或传统Startup风格均适用为例展示生产级的最佳配置。3.1 基础安装与配置首先通过NuGet安装核心包Install-Package Serilog.AspNetCore这个包集成了Serilog的核心、常用Sink和ASP.NET Core的适配器。接下来在Program.cs中进行配置。这是目前推荐的方式using Serilog; // 创建Bootstrap Logger用于捕获程序启动初期的日志 Log.Logger new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() .CreateBootstrapLogger(); try { var builder WebApplication.CreateBuilder(args); // 使用Serilog替代默认的ILogger builder.Host.UseSerilog((context, services, configuration) configuration .ReadFrom.Configuration(context.Configuration) // 从appsettings.json读取配置 .ReadFrom.Services(services) // 从DI容器读取服务用于某些需要服务的Enricher或Sink .Enrich.FromLogContext() // 启用LogContext .Enrich.WithMachineName() // 添加机器名 .Enrich.WithThreadId() // 添加线程ID .WriteTo.Console( outputTemplate: “[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}”) .WriteTo.File( path: “logs/app-.log”, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7, shared: true) .WriteTo.Seq(serverUrl: “http://localhost:5341”) // 假设本地运行Seq ); // ... 其他服务配置 (AddControllers, AddSwaggerGen等) var app builder.Build(); // ... 中间件管道配置 app.Run(); } catch (Exception ex) { // 捕获启动过程中的异常使用Bootstrap Logger记录 Log.Fatal(ex, “Application startup failed”); } finally { // 确保在应用关闭时缓冲的日志能被刷新 Log.CloseAndFlush(); }关键点解析Bootstrap Logger: 在builder.Build()之前ASP.NET Core默认的日志系统还没就绪。我们创建一个简单的Bootstrap Logger来捕获这期间可能发生的异常如配置读取错误确保它们能被记录而不是丢失。UseSerilog: 这个方法用Serilog的ILogger替换了ASP.NET Core内置的日志提供程序。这意味着通过DI注入的ILoggerT底层将是Serilog。ReadFrom.Configuration: 允许你将部分Serilog配置特别是Sink和级别放在appsettings.json中实现环境差异化配置开发/生产。Enrich.FromLogContext():务必启用。这是实现请求级上下文关联的关键。WriteTo.File中的shared: true: 允许多个进程写入同一个日志文件例如在IIS部署时。建议开启但要注意文件锁定问题对于高并发场景更推荐使用像Elasticsearch这样的集中式日志方案。3.2 在appsettings.json中进行配置为了更好的环境管理我们可以把Sink配置移到appsettings.json{ “Serilog”: { “Using”: [ “Serilog.Sinks.Console”, “Serilog.Sinks.File”, “Serilog.Sinks.Seq” ], “MinimumLevel”: { “Default”: “Information”, “Override”: { “Microsoft”: “Warning”, // 抑制Microsoft命名空间下的嘈杂日志 “System”: “Warning” } }, “WriteTo”: [ { “Name”: “Console”, “Args”: { “outputTemplate”: “[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj} {Properties:j}{NewLine}{Exception}” } }, { “Name”: “File”, “Args”: { “path”: “logs/app-.log”, “rollingInterval”: “Day”, “retainedFileCountLimit”: 7, “shared”: true } }, { “Name”: “Seq”, “Args”: { “serverUrl”: “http://localhost:5341” } } ], “Enrich”: [ “FromLogContext”, “WithMachineName”, “WithThreadId” ] } }然后在Program.cs中UseSerilog部分可以简化为builder.Host.UseSerilog((context, services, configuration) configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) );3.3 在代码中使用结构化日志在控制器或服务中通过构造函数注入ILoggerTpublic class WeatherForecastController : ControllerBase { private readonly ILoggerWeatherForecastController _logger; public WeatherForecastController(ILoggerWeatherForecastController logger) { _logger logger; } [HttpGet] public IEnumerableWeatherForecast Get() { var forecast ...; // 结构化日志记录 _logger.LogInformation(“Retrieved {ForecastCount} forecasts for {UserName}”, forecast.Count, User.Identity?.Name); return forecast; } [HttpGet(“{id}”)] public ActionResultWeatherForecast GetById(int id) { try { var item _service.GetForecast(id); if (item null) { _logger.LogWarning(“Forecast with id {ForecastId} was not found”, id); return NotFound(); } return item; } catch (Exception ex) { // 记录异常时将异常对象作为最后一个参数传入Serilog会自动捕获异常详情 _logger.LogError(ex, “An error occurred while fetching forecast with id {ForecastId}”, id); return StatusCode(500, “Internal server error”); } } }实操心得养成使用占位符{PropertyName}的习惯而不是字符串插值$“...”。这确保了属性的结构化捕获。一些代码分析工具如Serilog Analyzer可以帮助你检查并提醒。4. 高级用法与实战技巧超越基础配置掌握了基础我们来看看如何用Serilog解决更复杂的问题。4.1 利用LogContext实现请求级关联这是Serilog在微服务环境下排查问题的“杀手锏”。想象一下一个用户请求会经过网关、认证服务、多个业务服务每个服务都会产生日志。如何将这些分散的日志串联起来答案就是关联IDCorrelation Id。在ASP.NET Core中我们通常使用一个中间件来为每个请求创建并管理一个唯一的CorrelationId并将其放入LogContext。// CorrelationIdMiddleware.cs public class CorrelationIdMiddleware { private readonly RequestDelegate _next; private const string CorrelationIdHeaderKey “X-Correlation-ID”; public CorrelationIdMiddleware(RequestDelegate next) { _next next; } public async Task Invoke(HttpContext context) { var correlationId GetOrCreateCorrelationId(context); // 将CorrelationId添加到响应头方便前端或下游服务追踪 context.Response.Headers[CorrelationIdHeaderKey] correlationId; // 关键步骤将CorrelationId推入LogContext using (LogContext.PushProperty(“CorrelationId”, correlationId)) { await _next(context); } // using块结束时CorrelationId会自动从LogContext中弹出 } private string GetOrCreateCorrelationId(HttpContext context) { // 优先从请求头获取如果没有则新建一个 if (context.Request.Headers.TryGetValue(CorrelationIdHeaderKey, out var existingCorrelationId)) { return existingCorrelationId.FirstOrDefault(); } return Guid.NewGuid().ToString(); } } // 在Program.cs中注册中间件 app.UseMiddlewareCorrelationIdMiddleware(); // 或者放在UseRouting之后其他业务中间件之前配置了这个中间件后在这个请求生命周期内using块内记录的任何日志都会自动附加CorrelationId属性。无论这个请求触发了多少服务、多少条日志你都可以在Seq或Elasticsearch中用CorrelationId ‘some-guid’一次性查出所有相关日志完整复现请求链路。4.2 性能敏感场景下的日志优化日志记录并非零成本。在高性能API或循环内部记录大量低级别日志如Debug可能成为性能瓶颈。Serilog提供了两种优化手段1. 日志级别检查Level CheckingILogger的IsEnabled方法可以让你在构造复杂的日志消息前进行判断避免不必要的字符串格式化和对象序列化开销。if (_logger.IsEnabled(LogLevel.Debug)) { // 只有当日志级别为Debug或更低时才执行昂贵的操作 var expensiveData GatherExpensiveData(); _logger.LogDebug(“Processed data: {ExpensiveData}”, expensiveData); }2. 结构化数据序列化优化与$操作符在消息模板中Serilog使用两个特殊的操作符来控制属性的序列化方式操作符结构化解构{Order}。告诉Serilog”请递归地序列化这个Order对象的所有属性。” 这对于调试复杂对象非常有用但可能会产生巨大的日志体积。在生产环境中应谨慎使用避免记录包含大量数据或循环引用的对象。$操作符字符串化{$UserName}。告诉Serilog”调用这个对象的ToString()方法我只记录结果字符串。” 这是更安全、更轻量的方式适用于简单属性或已重写ToString()的对象。var order new { Id 1, Items new ListItem(...) }; _logger.LogInformation(“Order created: {Order}”, order); // 记录所有属性可能很大 _logger.LogInformation(“Order created with ID: {OrderId}”, order.Id); // 只记录ID推荐 _logger.LogInformation(“Order summary: {$Order}”, order); // 记录order.ToString()的结果踩坑记录我曾在一个高流量服务中不小心用{Request}记录了整个HTTP请求对象包含Headers、Body等。瞬间日志体积暴涨磁盘被塞满Seq服务器也差点宕机。教训是永远明确你需要记录什么而不是记录整个对象。可以创建只包含关键信息的匿名对象或DTO来记录。4.3 自定义Enricher添加业务上下文除了通用的Enricher你还可以创建自定义的Enricher来添加业务相关的属性。例如在一个多租户SaaS应用中你可能想为每条日志自动加上TenantId。public class TenantEnricher : ILogEventEnricher { private readonly IHttpContextAccessor _httpContextAccessor; public TenantEnricher(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor httpContextAccessor; } public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { var httpContext _httpContextAccessor.HttpContext; var tenantId httpContext?.User?.FindFirst(“TenantId”)?.Value; if (!string.IsNullOrEmpty(tenantId)) { var tenantProperty propertyFactory.CreateProperty(“TenantId”, tenantId); logEvent.AddPropertyIfAbsent(tenantProperty); } } } // 注册服务及Enricher builder.Services.AddHttpContextAccessor(); builder.Services.AddSingletonILogEventEnricher, TenantEnricher(); // 在UseSerilog配置中 .UseSerilog((context, services, configuration) configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.WithTenantEnricher() // 添加自定义Enricher )4.4 配置动态日志级别有时你需要在应用运行时动态调整某个特定命名空间或类的日志级别以便在不重启应用的情况下深入排查问题。Serilog可以通过LoggingLevelSwitch和Filter.ByIncludingOnly或MinimumLevel.Override的API调用来实现但更优雅的方式是结合Serilog.Settings.Configuration和外部配置源如Consul、Azure App Configuration在修改配置后触发配置重载。由于篇幅限制这里不展开但思路是将LoggingLevelSwitch实例与配置绑定并在配置变更时更新该开关的值。5. 生产环境部署与排坑指南将Serilog用于生产环境需要考虑更多运维层面的问题。5.1 日志输出目标选择与配置开发环境ConsoleSeq。Seq提供无与伦比的交互式查询体验能极大提升调试效率。测试/预发环境FileSeq/Elasticsearch。文件作为本地备份集中式日志平台用于团队协作查看。生产环境强烈推荐使用集中式日志平台如Elasticsearch Kibana、DataDog、Application Insights。避免登录服务器查看日志文件。文件Sink仅作为故障转移或缓冲使用。Elasticsearch Sink关键配置示例.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri(“http://elasticsearch:9200”)) { AutoRegisterTemplate true, AutoRegisterTemplateVersion AutoRegisterTemplateVersion.ESv7, IndexFormat “myapp-{0:yyyy.MM.dd}”, // 按天创建索引 BufferBaseFilename “./logs/elasticsearch-buffer”, // 本地缓冲文件目录防止网络故障丢日志 BufferFileSizeLimitBytes 1024 * 1024 * 10, // 每个缓冲文件10MB BufferLogShippingInterval TimeSpan.FromSeconds(5), // 5秒发送一次 FailureCallback e Console.WriteLine($“Unable to submit event {e.MessageTemplate}”), // 失败回调 EmitEventFailure EmitEventFailureHandling.WriteToSelfLog | EmitEventFailureHandling.RaiseCallback | EmitEventFailureHandling.ThrowException })重点BufferBaseFilename至关重要。它会在本地磁盘创建一个缓冲队列。当Elasticsearch不可达时日志会先写入本地文件待恢复后自动重发。这避免了因网络抖动或ES集群重启导致日志丢失。5.2 日志循环、清理与归档即使使用集中式日志本地文件Sink作为备份或缓冲时也需妥善管理。rollingInterval: RollingInterval.Day按天滚动文件。retainedFileCountLimit: 7最多保留7天的日志文件。fileSizeLimitBytes: 1024 * 1024 * 100每个日志文件最大100MB。rollOnFileSizeLimit: true达到大小限制后创建新文件。对于生产环境建议设置一个独立的日志清理作业如Linux的cron job或Windows计划任务定期清理超过一定天数的旧日志文件防止磁盘被占满。5.3 异常处理与自日志SelfLogSerilog自身也可能出错例如配置的Seq服务器地址错误网络断开。默认情况下这些错误是静默的。为了诊断Serilog本身的问题务必开启SelfLog。// 在程序启动初期例如Program.cs的Main方法开头 Serilog.Debugging.SelfLog.Enable(msg Console.Error.WriteLine(msg)); // 或者输出到文件 Serilog.Debugging.SelfLog.Enable(msg File.AppendAllText(“serilog-selflog.txt”, msg Environment.NewLine));当Sink写入失败或配置有问题时错误信息会输出到SelfLog这是排查“为什么我的日志没发出去”问题的第一把钥匙。5.4 与ASP.NET Core内置日志系统的兼容性使用UseSerilog()后Serilog会接管所有通过ILoggerT接口的日志。但ASP.NET Core框架内部和一些第三方库可能直接使用它们自己的日志实现。Serilog.AspNetCore包通过适配器确保了这些日志也能被捕获。不过你可能会看到大量来自Microsoft和System命名空间的底层日志。通过MinimumLevel.Override将它们提升到Warning级别是保持日志清洁的通用做法。5.5 在Docker容器中运行在Docker容器中日志应输出到标准输出Stdout由Docker Daemon收集然后通过日志驱动如json-file,journald, 或fluentd转发到集中式系统。配置非常简单只需确保控制台Sink是启用的并且格式是纯文本或JSON避免颜色代码。.WriteTo.Console( outputTemplate: “[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj}{NewLine}{Exception}”) // 或者使用更结构化的JSON格式方便后续处理 .WriteTo.Console(new RenderedCompactJsonFormatter())然后在Dockerfile的ENTRYPOINT或CMD中直接运行你的应用即可。使用docker logs命令就能查看日志。6. 常见问题排查QAQ1日志没有输出到Seq/ElasticsearchA1按以下步骤排查查SelfLog首先检查Serilog SelfLog是否有错误信息。查网络确认应用服务器能访问Seq/ES的地址和端口telnet或curl。查配置检查连接字符串、索引格式等配置是否正确。查API Key/认证如果Seq/ES有安全认证确认配置了正确的API Key或用户名密码。查缓冲如果是Elasticsearch Sink检查BufferBaseFilename指定的目录是否存在且可写查看缓冲文件是否有内容。可能日志正在缓冲中。Q2日志属性Properties在Seq里看不到A2确保使用的是结构化日志语法{PropertyName}而不是字符串插值。在Seq的查询界面输入Properties is not null看看是否有任何属性。有时属性名可能因为序列化设置而改变。检查日志级别是否过低被过滤掉了。Q3LogContext中的属性在某些异步代码中丢失了A3这是常见陷阱。LogContext是基于AsyncLocalT实现的它在大多数异步上下文中能正常工作但在某些特殊的异步切换场景如Task.Run 未正确配置的await中可能会丢失。确保在开启新任务时使用LogContext.PushProperty或Serilog.Context.LogContext.Clone来捕获和传递上下文。Q4日志性能影响大吗A4合理配置下影响很小。避免在热路径循环中记录Debug或Verbose级别日志。使用IsEnabled进行防护。对于Information及以上级别Serilog的性能经过优化开销主要在网络I/O如写入ES和序列化。使用本地缓冲如File Sink或ES Sink的缓冲可以平滑I/O压力。Q5如何对敏感信息如密码、身份证号进行脱敏A5有几种策略不记录最安全的方式是不要在日志中包含敏感信息。使用自定义Enricher或Destructor你可以注册一个自定义的IDestructuringPolicy在序列化特定类型对象时将其中的敏感字段替换为掩码如”CreditCardNumber”: “************1234”。在Sink层面过滤一些Sink如Seq支持在接收端配置数据清洗规则。但更推荐在源头应用内控制。我个人在几个大型微服务项目中全面采用SerilogElasticsearch的方案后最大的体会是投资在结构化日志上的时间会在问题排查时十倍地回报给你。它彻底改变了我们团队排查线上问题的方式从“猜测与 grep”变成了“查询与定位”。开始可能觉得配置稍显复杂但一旦跑通你就会发现这一切都是值得的。最后一个小建议为你的团队建立一个“日志规范”约定属性命名规则、哪些信息必须记录、哪些信息禁止记录这能保证所有微服务产生的日志有一致的“方言”让集中式日志分析发挥最大价值。