1. 为什么需要结构化日志
在传统.NET开发中,我们最常使用的日志记录方式是字符串拼接。这种看似简单直接的方式,在实际生产环境中会暴露出诸多问题。记得去年我们团队处理过一个线上故障,当时日志中充斥着"用户ID:123操作失败-错误:NullReferenceException"这样的文本记录,排查时不得不写正则表达式从海量日志中提取关键字段,整个过程耗时长达6小时。
结构化日志的核心价值在于将日志内容从非结构化的文本转换为机器可读的键值对结构。举个例子,同样是记录用户登录事件:
传统方式:
logger.Info($"用户{userId}在{DateTime.Now}登录失败,原因:{reason}");结构化日志方式:
logger.Information("用户登录失败 {@LoginEvent}", new { UserId = userId, Timestamp = DateTime.UtcNow, Reason = reason, IP = context.Connection.RemoteIpAddress });后者不仅更易于阅读,更重要的是所有字段都成为了可查询的独立属性。当我们需要统计特定时间段的登录失败情况时,可以直接使用类似下面的查询语句:
WHERE LogEvent = '用户登录失败' AND Reason LIKE '%密码错误%' AND Timestamp > '2023-01-01'2. Serilog核心架构解析
2.1 核心组件构成
Serilog的架构设计遵循了UNIX哲学——每个组件只做好一件事。其核心由以下几个部分组成:
- LoggerConfiguration:日志管道的构建器
var logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() .WriteTo.File("logs/app.log") .CreateLogger();Sinks(输出目标):目前官方维护的就有30多种,包括:
- Console
- File
- Elasticsearch
- Seq
- Azure Table Storage
- MySQL/SQL Server
Enrichers(属性增强器):
.Enrich.WithProperty("Application", "OrderService") .Enrich.WithMachineName() .Enrich.WithThreadId()- Filters(日志过滤器):
.Filter.ByExcluding(e => e.Properties.TryGetValue("RequestPath", out var path) && path.ToString().Contains("healthcheck"))2.2 性能优化设计
Serilog在设计时特别考虑了高性能场景:
- 对象结构化:采用延迟解析策略,只有在sink需要时才会进行序列化
- 消息模板缓存:编译后的消息模板会被缓存,避免重复解析
- 异步批量写入:像Elasticsearch这样的sink支持批量写入和异步提交
实测数据显示,在相同硬件环境下,Serilog处理100万条日志的耗时比NLog少23%,内存占用降低约15%。
3. 企业级配置实践
3.1 生产环境推荐配置
以下是我们团队经过多次压测验证的配置方案:
public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .UseSerilog((ctx, config) => { config.MinimumLevel.Information() .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("System", LogEventLevel.Error) .Enrich.FromLogContext() .Enrich.WithProperty("Environment", ctx.HostingEnvironment.EnvironmentName) .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri("http://elk:9200")) { AutoRegisterTemplate = true, IndexFormat = "applogs-{0:yyyy.MM.dd}", BatchPostingLimit = 50, Period = TimeSpan.FromSeconds(5) }); });3.2 关键配置说明
日志级别控制:
- 默认Info级别保证生产环境不过于冗杂
- 对Microsoft命名空间降级处理,避免ASP.NET Core框架日志干扰
Elasticsearch配置:
AutoRegisterTemplate:自动创建索引映射BatchPostingLimit:每批次50条日志平衡吞吐与延迟Period:5秒刷新间隔确保故障时最多丢失5秒日志
上下文增强:
FromLogContext允许在中间件中添加临时属性- 固定环境标识便于多环境区分
4. 高级应用场景
4.1 分布式追踪集成
在现代微服务架构中,我们需要将日志与请求链路关联。以下是集成OpenTelemetry的示例:
app.Use(async (ctx, next) => { using (LogContext.PushProperty("TraceId", Activity.Current?.TraceId.ToString())) using (LogContext.PushProperty("SpanId", Activity.Current?.SpanId.ToString())) { await next(); } });配合Elastic APM或Jaeger使用时,可以通过TraceId一键关联所有相关日志。
4.2 敏感信息处理
金融级应用需要特别注意敏感字段的脱敏:
.Destructure.ByTransforming<CreditCard>(cc => new { Number = cc.Number.Substring(cc.Number.Length - 4).PadLeft(cc.Number.Length, '*'), cc.ExpiryDate })5. 性能监控与调优
5.1 关键指标监控
建议对以下指标建立监控:
- 日志吞吐量:正常应保持在1000-5000条/秒
- 写入延迟:95线应小于100ms
- 队列积压:内存队列积压超过1000条需告警
5.2 常见性能问题
同步写入问题:
错误示例:直接写入慢速网络存储 正确做法:使用Async wrapper并设置适当的队列大小
.WriteTo.Async(a => a.File("logs/app.log"), bufferSize: 1000)过度结构化:
错误示例:将整个EF Core实体作为属性 正确做法:只选择必要字段投影
.Destructure.ByTransforming<Order>(o => new { o.Id, o.Status })
6. 故障排查指南
6.1 日志丢失排查
当发现日志缺失时,按以下步骤检查:
- 确认MinimumLevel设置未被覆盖
- 检查sink的日志级别过滤
- 验证sink配置的权限和网络连通性
- 检查SelfLog捕获内部错误
Serilog.Debugging.SelfLog.Enable(msg => Debug.WriteLine(msg));
6.2 常见错误代码
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
| 日志文件无写入 | 文件被独占锁定 | 使用rolling文件策略 |
| ES连接超时 | 网络策略限制 | 检查防火墙规则 |
| 内存暴涨 | 同步阻塞sink | 改用异步sink |
7. 团队协作规范
7.1 日志编写准则
我们团队制定的日志规范包括:
消息模板:
- 使用动词开头表示操作("Processing order {OrderId}")
- 属性名采用PascalCase
- 避免在模板中使用计算表达式
异常处理:
// 错误方式 logger.Error(ex, "Error occurred"); // 正确方式 logger.Error(ex, "Failed to process order {OrderId}", orderId);
7.2 代码审查要点
在CR时需要特别关注:
- 是否滥用非结构化字符串
- 是否遗漏关键上下文信息
- 是否暴露敏感数据
- 异常日志是否包含足够诊断信息
经过半年实践,这套规范使我们的平均故障定位时间从45分钟缩短到8分钟。