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中配置一次,之后在控制器或服务中通过构造函数注入ILogger<T>即可。
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):
UserId=123,OrderId=456,Amount=99.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注入的ILogger<T>底层将是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 在代码中使用结构化日志
在控制器或服务中,通过构造函数注入ILogger<T>:
public class WeatherForecastController : ControllerBase { private readonly ILogger<WeatherForecastController> _logger; public WeatherForecastController(ILogger<WeatherForecastController> logger) { _logger = logger; } [HttpGet] public IEnumerable<WeatherForecast> Get() { var forecast = ...; // 结构化日志记录 _logger.LogInformation(“Retrieved {ForecastCount} forecasts for {UserName}”, forecast.Count, User.Identity?.Name); return forecast; } [HttpGet(“{id}”)] public ActionResult<WeatherForecast> 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在微服务环境下排查问题的“杀手锏”。想象一下,一个用户请求会经过网关、认证服务、多个业务服务,每个服务都会产生日志。如何将这些分散的日志串联起来?答案就是关联ID(Correlation 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.UseMiddleware<CorrelationIdMiddleware>(); // 或者放在UseRouting之后,其他业务中间件之前配置了这个中间件后,在这个请求生命周期内(using块内)记录的任何日志,都会自动附加CorrelationId属性。无论这个请求触发了多少服务、多少条日志,你都可以在Seq或Elasticsearch中用CorrelationId = ‘some-guid’一次性查出所有相关日志,完整复现请求链路。
4.2 性能敏感场景下的日志优化
日志记录并非零成本。在高性能API或循环内部记录大量低级别日志(如Debug)可能成为性能瓶颈。Serilog提供了两种优化手段:
1. 日志级别检查(Level Checking)ILogger的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 List<Item>(...) }; _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.AddSingleton<ILogEventEnricher, TenantEnricher>(); // 在UseSerilog配置中 .UseSerilog((context, services, configuration) => configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.With<TenantEnricher>() // 添加自定义Enricher )4.4 配置动态日志级别
有时,你需要在应用运行时动态调整某个特定命名空间或类的日志级别,以便在不重启应用的情况下深入排查问题。Serilog可以通过LoggingLevelSwitch和Filter.ByIncludingOnly或MinimumLevel.Override的API调用来实现,但更优雅的方式是结合Serilog.Settings.Configuration和外部配置源(如Consul、Azure App Configuration),在修改配置后触发配置重载。由于篇幅限制,这里不展开,但思路是:将LoggingLevelSwitch实例与配置绑定,并在配置变更时更新该开关的值。
5. 生产环境部署与排坑指南
将Serilog用于生产环境,需要考虑更多运维层面的问题。
5.1 日志输出目标选择与配置
- 开发环境:
Console+Seq。Seq提供无与伦比的交互式查询体验,能极大提升调试效率。 - 测试/预发环境:
File+Seq/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 异常处理与自日志(SelfLog)
Serilog自身也可能出错(例如,配置的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会接管所有通过ILogger<T>接口的日志。但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. 常见问题排查(Q&A)
Q1:日志没有输出到Seq/Elasticsearch?A1:按以下步骤排查:
- 查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看看是否有任何属性。有时属性名可能因为序列化设置而改变。 - 检查日志级别是否过低被过滤掉了。
Q3:LogContext中的属性在某些异步代码中丢失了?A3:这是常见陷阱。LogContext是基于AsyncLocal<T>实现的,它在大多数异步上下文中能正常工作,但在某些特殊的异步切换场景(如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)支持在接收端配置数据清洗规则。但更推荐在源头(应用内)控制。
我个人在几个大型微服务项目中全面采用Serilog+Elasticsearch的方案后,最大的体会是:投资在结构化日志上的时间,会在问题排查时十倍地回报给你。它彻底改变了我们团队排查线上问题的方式,从“猜测与 grep”变成了“查询与定位”。开始可能觉得配置稍显复杂,但一旦跑通,你就会发现这一切都是值得的。最后一个小建议:为你的团队建立一个“日志规范”,约定属性命名规则、哪些信息必须记录、哪些信息禁止记录,这能保证所有微服务产生的日志有一致的“方言”,让集中式日志分析发挥最大价值。