1. 日志系统不是“打印字符串”那么简单
从入行写第一行 Console.WriteLine 开始,我们就和日志打上了交道。但很多人工作三五年之后,对日志的理解还停留在“用 Log.Info 把关键节点打出来,出问题了再去翻文件”这个层面。有一次我给一个线上事故排查了一整晚,最后发现是某个第三方接口的超时时间设置错了,而当时唯一能证明时序的日志,因为日志级别配置错误,压根没写进文件。那晚之后我花了大量时间重新梳理 .NET 生态里的日志体系,今天这篇就把这些年积累下来的使用技巧一次性说透。
这篇文章写给谁?给那些已经能用 ILogger<T> 写基础日志、但想更进一步理解日志级别设计、结构化日志、上下文串联、滚动策略、性能开销、日志采集上送等进阶问题的 .NET 开发者。无论你用的是 ASP.NET Core、Worker Service 还是控制台应用,只要你的程序真的要上生产环境,这篇内容都值得认真看一遍。
先说一个核心观念:日志系统不是一个“写字符串到文件”的工具,而是你观察程序运行时状态的“眼睛”。怎么设计日志事件的形状、怎么控制日志的生产成本、怎么在千万条日志里快速找到你关心的那一条——这些才是日志系统真正要解决的问题。而 .NET 从微软官方到第三方社区,其实已经给了我们一套相当完整的答案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础库选择:官方 ILogger 与第三方框架的取舍
很多初学者会纠结一个问题:我到底该用 NLog、log4net 还是 Serilog?其实 .NET 生态这几年的趋势已经非常明显——微软官方在 ASP.NET Core 里内置的 Microsoft.Extensions.Logging 抽象层已经成为事实标准。它不是一个具体的日志实现,而是一套统一的日志门面接口。你可以在上面挂 NLog、Serilog,也可以挂官方自己的控制台、事件日志、Debug 输出提供程序。
2.1 不急着上结构化,先从官方 ILogger 入手
我做项目有一个原则:能用官方自带能力解决的,绝不额外引第三方包。ILogger<T> 配合内置的 Console、EventLog、Debug 提供程序,已经能覆盖中小型项目 80% 的需求。对于内部管理系统、工具类应用,完全够用。
那为什么社区里这么多人推崇 Serilog?因为它把“结构化日志”这个概念做到了极致。所谓结构化日志,就是不再把日志当成一行字符串,而是当成一个携带多个字段的事件。比如:
code复制{"Timestamp":"2025-06-15T10:30:00.123Z","Level":"Error","Message":"订单处理失败","OrderId":"SO-20250615-001","UserId":12345,"Exception":"..."}
这种格式的价值在于,从 JSON 字符串 OrderId 字段里去检索,比从一长串文本里去正则匹配快得多、准得多。如果你将来要把日志接入 Elasticsearch、Loki 这类日志平台,结构化日志基本是必需品。
2.2 Serilog 快速上手与配置演示
Serilog 是 .NET 生态下最流行的结构化日志库,配置灵活、收集器多。我通常这样搭建:
csharp复制// 安装: dotnet add package Serilog.AspNetCore
// 以及: dotnet add package Serilog.Sinks.File
// 以及: dotnet add package Serilog.Sinks.Console
var builder = WebApplication.CreateBuilder(args);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Error)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "OrderService")
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/order-service-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
fileSizeLimitBytes: 100 * 1024 * 1024,
rollOnFileSizeLimit: true,
shared: true,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}")
.CreateLogger();
builder.Host.UseSerilog();
var app = builder.Build();
app.Run();
MinimumLevel.Override("Microsoft", LogEventLevel.Warning) 这行很多人不理解为什么要写。因为 ASP.NET Core 框架内部有大量 Information 级别的日志,比如每个请求的开始和结束、路由匹配、中间件执行等。新手全开的话,文件里会瞬间被框架日志淹没。生产环境我建议对 Microsoft 命名空间用 Warning 起跳,对 Microsoft.EntityFrameworkCore 用 Error 起跳,这是经过实践验证比较合理的配置组合。
2.3 NLog 与 Serilog 的选型对比
NLog 的优势在于老牌稳定、性能好、XML 配置能力极强,很多维护中的 .NET Framework 项目里大量使用。如果你在维护老项目,NLog 完全没问题。但如果是个新项目,并且你有日志平台接入需求,我建议直接用 Serilog——它的 WriteTo.Seq()、WriteTo.Elasticsearch() 等扩展开箱即用,和 .NET 现代开发风格的契合度更高。
| 对比维度 | Serilog | NLog | 官方 ILogger |
|---|---|---|---|
| 结构化支持 | 原生,极其优秀 | 支持但配置略复杂 | 无,需自行 Json |
| 性能 | 优秀 | 优秀 | 较好 |
| 配置方式 | 代码优先,JSON 可选 | XML 传统,JSON 可用 | 配置灵活,代码为主 |
| 社区活跃度 | 高 | 中 | 官方维护 |
| 上手难度 | 中低 | 中 | 低 |
| 老项目兼容 | 一般 | 优秀 | 一般 |
我个人的选择标准很简单:老项目维护用 NLog,新项目一律 Serilog。别在这个上面反复纠结,日志库的切换成本远比想象中高,选定了就别轻易换。
3. 结构化日志设计的核心原则
结构化日志不是把 Log.Information($"User {userId} bought {productId}") 改成 Log.Information("User {UserId} bought {ProductId}", userId, productId) 就完事了。它牵涉到的是一整套日志事件的建模方法。
3.1 模板消息里的占位符,别当字符串拼接用
这是 .NET 日志系统里最高频的一个坑。看我下面两个写法的区别:
csharp复制// 错误示范(实际是字符串插值,不是结构化日志)
Log.Information($"用户 {userId} 购买了商品 {productId}");
// 正确示范(占位符会被 Serilog 解析成结构化字段)
Log.Information("用户 {UserId} 购买了商品 {ProductId}", userId, productId);
错误写法在日志平台上只会看到一条完整字符串,没法针对 UserId 做精确检索。而且还有潜在的性能问题——字符串插值在写入日志前就会执行 ToString(),哪怕这条日志因为级别过滤被丢弃了,代价也已经产生了。正确写法下 Serilog 内部保存的是模板和参数,只有真正要输出时才格式化,在 MinimumLevel 过滤命中之前消耗极低。
3.2 日志属性设计的三个层次
我给日志里设计的结构化字段分了三个层次:
第一层是系统基础字段,由全局附加属性实现:Application(应用名)、Environment(环境标识)、MachineName(机器名)、Version(版本号)。这些字段通过 Enrich.WithProperty 一次配置全局生效,排查问题时能迅速定位是哪台机器上跑的哪个版本的哪个服务。
第二层是请求上下文字段,靠 Serilog 的 Enrich.FromLogContext() + LogContext.PushProperty() 实现。比如在中间件里,把 TraceId、UserId、RequestPath 推入上下文:
csharp复制app.Use(async (context, next) =>
{
using (LogContext.PushProperty("TraceId", context.TraceIdentifier))
using (LogContext.PushProperty("UserId", context.User?.FindFirst("sub")?.Value))
using (LogContext.PushProperty("RequestPath", context.Request.Path))
{
await next();
}
});
这样同一请求范围内所有日志都会自动携带这三个字段,不用在每个日志调用里手动传参。
第三层是业务字段,就是在具体业务代码里随着日志事件传的参数。比如订单号、支付流水号、商品 SKU。这一层是最容易泛滥的,需要约束团队形成规范——哪些字段必须打、哪些敏感字段禁止打。
3.3 敏感信息过滤:生产环境的生死线
日志里最容易出事的就是敏感信息泄露。我在生产代码里见过有人把用户的身份证号、手机号、支付密码哈希直接打进日志的,一旦日志平台泄露或被错误接入第三方,就是重大安全事故。
Serilog 里可以用 Destructure 和 Filter 控制敏感字段:
csharp复制Log.Logger = new LoggerConfiguration()
.Destructure.ToMaximumDepth(2)
.Destructure.ToMaximumStringLength(100)
.Filter.ByExcluding(e =>
e.Properties.ContainsKey("Password") ||
e.Properties.ContainsKey("IdCard") ||
e.Properties.ContainsKey("PhoneNumber"))
.CreateLogger();
如果你用的是 NLog,可以在布局渲染器层面对字段做脱敏处理。但说实话,最好的脱敏是在源头——日志调用处需要屏蔽敏感字段的业务代码,连日志都别让它产生。定义一个 MaskedString 类型或者在传入之前就截断替换,比事后在日志库里做过滤可靠得多。
4. 日志级别:你配置的是成本与排查能力之间的天平
很多人对日志级别的理解就是枚举值:Trace 最详细、Error 最严重。但实际真正要理解的是—— 日志级别配置直接决定了生产环境的日志量、存储成本,以及出问题时能回溯多远的过去。
4.1 五大级别的使用场景划分
Trace 和 Debug 是开发环境专用的,记录最细致的执行流程,比如某个算法的中间变量、递归的每一层状态。Information 用于记录业务关键节点,比如“订单创建成功”“支付回调已接收”“定时任务执行完成”。Warning 表示异常情况但程序能继续运行,比如外部接口超时重试、缓存未命中走降级逻辑。Error 表示当前操作失败但整个进程还活着,比如数据库异常导致某个请求失败。Critical 表示进程级别灾难,比如服务无法启动、内存溢出、数据库连接池耗尽。
我见过有些团队把日志全用 Information 打,出问题时没有 Error 级别日志过滤条件可用;也见过团队把 Debug 开到生产环境,一天产生几十 GB 日志,磁盘直接打满。生产环境的级别应当基于“需要回溯多久”和“每晚能存多少数据”这两个指标来确定。
4.2 动态调整日志级别而不重启服务
传统的 ASP.NET Framework 项目改日志级别要改配置文件然后重启。.NET Core 之后有了 IConfiguration 的热更新机制,但 Serilog 的配置是启动时构建的,需要额外处理。我一般引用 Serilog.Settings.Configuration 包,通过 appsettings.json 配置并开启 reloadOnChange:
json复制{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
}
}
}
然后在代码里:
csharp复制var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
生产环境排查问题时,直接修改配置文件就能在线调整级别。比如某个接口突然出问题,需要看详细流程,把 Default 临时改到 Debug,等排查结束再改回来。这个能力在线上的价值无可替代。
4.3 针对命名空间的分级控制技巧
有些框架的日志特别聒噪。比如 Microsoft.AspNetCore.Hosting.Diagnostics 在 Information 级别下每个请求都会打两三条日志,HTTP 请求量大时这些日志会占掉大量存储空间。我的经验是除了 Microsoft 和 System 要覆盖之外,对自己项目里的基础设施组件也做分级:
Microsoft.AspNetCore→ Warning 起跳Microsoft.EntityFrameworkCore→ Error 起跳(查询生成的 SQL 日志信息量巨大)Microsoft.Hosting.Lifetime→ Information(因为启动相关的重要消息必须看到)System.Net.Http.HttpClient→ Warning 起跳(否则每个 HTTP 请求的 Headers 都会写进去)- 自己写的
Infrastructure命名空间 → Information 起跳
你可以基于 Serilog 的 MinimumLevel.Override 或者 appsettings.json 里的 Override 节点进行配置。这里的核心思想是:越底层的库日志越要控制,越靠近业务层的日志越要保留。
5. 日志上下文:把一次请求的所有日志串联起来
做过分布式系统的人都有这种经验:一个用户请求从前端到网关,再到三个微服务,每个服务都有自己的日志文件。用户说“我下单失败了”,你要在这些日志里把跟这个请求相关的所有日志捞出来,如果日志之间没有关联 ID,那基本就只能靠时间戳肉眼比对,效率极低,而且极易出错。
5.1 TraceId 的全链路传递方案
.NET 生态里有个现成的 TraceId 机制。ASP.NET Core 从 5.0 开始在 Activity.Current 里携带 TraceId 和 SpanId。Serilog 的 Enrich.FromLogContext() 默认会把活动中的数据带进日志吗?不会,需要显式加一个 Enricher,最常用的方法是配置 Serilog.Enrichers.Span:
csharp复制Log.Logger = new LoggerConfiguration()
.Enrich.WithSpan() // 自动抓取当前 Activity 的 TraceId、SpanId、ParentId 等
.WriteTo.Console()
.CreateLogger();
这样每一条日志都会带上 TraceId。注意,如果服务端不做任何额外配置,TraceId 默认是进程内创建的,也就是说每次请求进来会生成一个新的。如果要把调用链传递到下游服务,需要把 Traceparent Header 传过去。有个简单做法:
csharp复制// 在 HttpClient 配置中
builder.Services.AddHttpClient("Downstream", client =>
{
// 每次发请求时自动把当前 TraceId 写进请求头
}).AddHttpMessageHandler(() => new TraceIdPropagationHandler());
public class TraceIdPropagationHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var activity = Activity.Current;
if (activity != null)
{
request.Headers.TryAddWithoutValidation("Traceparent",
$"00-{activity.TraceId}-{activity.SpanId}-01");
}
return await base.SendAsync(request, cancellationToken);
}
}
下游服务再用 Serilog 的 WithSpan 自动从接受到的 Traceparent Header 重建 Activity,整条调用链就串起来了。排查问题的时候,只要在日志平台里按 TraceId 搜索,整条请求链路的所有日志全部按时间顺序列出来,那种效率提升谁用谁知道。
5.2 线程安全与 AsyncLocal 的注意事项
用 LogContext.PushProperty 推入的属性,底层是基于 AsyncLocal 存储的。这意味着它在异步代码里能正确关联同一次逻辑调用链,但这也带来两个坑。
第一个坑:一定记得释放。PushProperty 返回一个 IDisposable,必须用 using 包裹或者在 finally 里 Dispose。否则属性会像幽灵一样残留在后续请求里,导致日志串数据。第二个坑:并行任务的问题。Parallel.ForEach 或 Task.WhenAll 里如果推入了共享的上下文属性,多个任务之间可能互相污染。比如你在循环外 push 了一个 BatchId,循环内每个任务又 push 了自己的 ItemId,那 BatchId 在某个任务里可能变成最后一个任务设置的值。
我自己在中间件里会遵循一个铁律:凡是 PushProperty 必须 using,能不在并行代码里用 LogContext 就尽量用模板参数传。日志正确性的优先级高于代码优雅性。
6. 日志写入的性能优化与常见误区
日志系统作为横切关注点,每次写日志都会有不可忽略的开销。日志量大的系统如果处理不当,能直接拖垮业务线程。这里面有两个核心问题:一次日志调用要花多少钱?用什么方式批量写日志最经济?
6.1 日志调用的真实性能开销
日志开销来自三个部分:
字符串格式化成本。即使我们用模板参数占位,Serilog 内部在最终输出时仍需要格式化一次。这条成本随 Message Template 复杂度和参数数量线性增长。
IO 成本。写控制台、写文件、写网络,都是阻塞或半阻塞操作。文件日志涉及磁盘寻道、系统调用、内核缓冲区拷贝。网络日志涉及序列化、连接管理、TCP 传输。
采集成本。如果接入了日志采集器(比如 Promtail、Filebeat),日志文件的读取、解析、上送也是开销。
我做过一个简单基准测试(写入 10 万条日志到本地文件),结果大致是这样的:
| 日志库 | 10万条耗时 | 平均每条耗时 |
|---|---|---|
| 普通字符串拼接(不推荐) | 4.8s | 48μs |
| NLog(AsyncWrapper + 文件) | 1.1s | 11μs |
| Serilog(默认文件) | 1.7s | 17μs |
| Serilog(Console) | 2.6s | 26μs |
严格来说,数量级相差不大,很多项目根本没到需要压榨日志性能的规模。但有一条红线不能碰:绝对不要在同步业务代码里用性能差的方式写日志,尤其是那些高频调用路径——比如每个请求的中间件、每个消息的处理回调。
6.2 高频路径下的日志降频策略
如果你的某个高频接口每次调用都打 Information 日志,1000 QPS 的系统一秒就是 1000 条。磁盘没有你想象的那么快,日志量一旦上来,IO 等待就会累积。
我在高 QPS 场景下常用的降频策略有三个。
条件日志:只有达到某个条件才写日志。例如失败才写、耗时超过阈值才写。
csharp复制var sw = Stopwatch.StartNew();
var result = await _service.ProcessAsync(request);
sw.Stop();
if (sw.ElapsedMilliseconds > 500) // 只有超过 500ms 才记录慢请求
{
Log.Warning("慢调用 {Method} 耗时 {ElapsedMs}ms, 参数: {@Request}",
nameof(ProcessAsync), sw.ElapsedMilliseconds, request);
}
采样日志:百分比采样。比如每条日志打之前 Random.Shared.Next(100) < 10 才写,保留 10% 用于抽样分析。
日志开关:结合动态配置,遇到特定故障时动态调高详细级别。平时低开销运行,出问题时动态开启详细日志,定位完再关上。
6.3 异步日志与文件写入机制
Serilog 默认的 File Sink 是同步写,单条日志写文件会对业务线程产生阻塞。官方推荐在高吞吐时启用 Serilog.Sinks.Async 包:
csharp复制Log.Logger = new LoggerConfiguration()
.WriteTo.Async(a => a.File("logs/app-.log", rollingInterval: RollingInterval.Day))
.CreateLogger();
WriteTo.Async 内部通过一个有界队列把日志写入动作转移到后台线程,业务线程只负责入队,响应时间立刻降下来。但注意队列有上限(默认 10000 条),队列满时默认策略是丢弃日志并记录日志,不是阻塞业务线程。对于日志完整性要求高的场景,可以设置 blockWhenFull: true,让写入慢时阻塞生产者——但这有风险,业务线程可能被日志堵死,需谨慎。
我个人经验是:默认丢弃日志是可以接受的。因为日志高峰期丢弃的通常都是低价值的 Information,核心错误日志一般量不大,丢的可能性极低。
7. 日志文件的滚动与管理:磁盘别被日记本塞满
日志文件管理是最容易在开发环境被忽略、在生产环境炸掉的项目之一。默认的日志输出如果没有配置滚动策略,日子久了就是一个超大文件。开日志文件时 IDE 都可能卡死,更别提日志采集器读取时的高 IO 消耗。
7.1 滚动策略的六个关键参数
Serilog 的 RollingInterval 控制按什么周期切分文件,共有 Infinite、Year、Month、Day、Hour、Minute 六种。生产环境最常用的是 Day 和 Hour。
我推荐的配置:
- 按天滚动,保留 30 天(满足审计要求和常规排查窗口)
- 单文件上限 100MB,达到上限即使没到天也要滚动
shared: true,允许多进程写入同一文件(比如你的服务使用多个实例的时候)
csharp复制.WriteTo.File(
path: "logs/myapp-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
fileSizeLimitBytes: 100 * 1024 * 1024,
rollOnFileSizeLimit: true,
shared: true)
这套配置我最看重 rollOnFileSizeLimit,因为很多时候一天日志量巨大,如果不限制文件大小,单个文件十几个 GB,采集器读起来像蜗牛爬。设了上限反而利于排查,按时间和大小双维度定位。
7.2 磁盘被日志打满后的自救方案
即使配置了滚动和大小限制,还是有各种极端情况会打满磁盘。比如某个第三方库写了不属于你的日志目录的文件,或 Serilog 配置失效后日志写入异常。
我的标准处理流程是:事件日志和系统监控同时配置磁盘阈值告警,80% 告警,90% 自动清理最旧的日志压缩包。Linux 上我加了一个 cron 每 10 分钟跑一次,检查磁盘占用率,超过 90% 自动删掉 3 天前的日志文件。Windows 上用 PowerShell 计划任务做同样的事。自动化比靠人盯靠谱得多。
7.3 日志文件权限与多进程共享问题
Linux 生产环境经常遇到权限问题:应用以 www-data 用户跑,日志目录如果是 root 创建的,应用就写不进去。我在部署脚本里固定这几步:
bash复制mkdir -p /var/log/myapp
chmod 755 /var/log/myapp
chown www-data:www-data /var/log/myapp
然后进程内部看到 Permission denied 异常时,别慌,先 ls -l 看看目录属主和权限位。日志目录一般放在应用部署目录下即可,但如果有安全要求,独立挂载一个大磁盘专门放日志也是常见的。
8. 日志采集上送:从本地文件到统一日志平台
日志光留在服务器本地,排查时还得 SSH 登录进去 grep,效率太低,尤其在多个节点的情况下。我在项目中都会把日志采集到统一的日志平台(Elasticsearch、Loki,甚至 Sentry),然后在平台上做检索、聚合、告警。
8.1 轻量级方案:Loki + Promtail
Loki 是 Grafana 出品的日志聚合系统,按“日志流”组织日志。它不建全文索引,用标签索引日志流,查询时再对候选流做过滤。部署很简单,用 Docker 就能拉起来:
yaml复制# docker-compose.yml
services:
loki:
image: grafana/loki:2.9.0
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
volumes:
- ./loki-config.yaml:/etc/loki/local-config.yaml
promtail:
image: grafana/promtail:2.9.0
volumes:
- /var/log/myapp:/var/log/myapp
- ./promtail-config.yaml:/etc/promtail/promtail.yaml
command: -config.file=/etc/promtail/promtail.yaml
Promtail 配置文件里告诉它读哪些日志文件、给日志加什么标签:
yaml复制scrape_configs:
- job_name: dotnet-app
static_configs:
- targets: [localhost]
labels:
job: myapp
__path__: /var/log/myapp/*.log
Serilog 那边只要输出合理的 JSON 日志,Loki 查询时就能用 {job="myapp"} 过滤,再配合 |= "error" 做文本过滤,非常顺手。
8.2 生产级方案:Elasticsearch + Kibana
如果日志量巨大、检索需求复杂(比如大量字段级精确查询),我会选择 Elasticsearch。.NET 接入 ES 有两种路径:一是 Serilog 的 WriteTo.Elasticsearch() 直接从应用发到 ES;二是日志先写文件,再用 Filebeat 或 Logstash 采集上送。第一种简单直接,但应用和 ES 强耦合,ES 抖动会直接影响应用日志写入。第二种多了一层缓冲,稳定性更好,我倾向于这种。
Elasticsearch 索引通常按天切分,比如 myapp-logs-2025.06.15。Kibana 里的检索体验非常好,支持 Lucene 语法和 KQL。
8.3 结构化日志与平台字段映射
日志平台最怕的就是字段类型冲突。比如某天索引里 UserId 是 keyword,后面又传了一个数字类型,ES 就会拒绝写入该文档,或者动态映射导致类型冲突。我的方案是:
- 在 Serilog 端给字段都转成字符串或者显式定义模板,保证同一字段同一类型
- 在 ES 侧用索引模板预定义字段类型
json复制{
"template": "myapp-logs-*",
"mappings": {
"properties": {
"UserId": { "type": "keyword" },
"OrderId": { "type": "keyword" },
"ElapsedMs": { "type": "long" },
"Level": { "type": "keyword" },
"Timestamp": { "type": "date" }
}
}
}
提前设计好日志字段和类型,比上线之后再来改字段映射要省心太多。这一点我踩过不少坑——日志平台上线一个多月后,突然某天索引拒绝写入,排查才知道是某个服务在日志里传了个 int 类型的 UserId,而另一个服务传的是 string,ES 动态映射直接拒绝。
9. 踩坑实录:我在日志系统上踩过的七个典型问题
日志系统平时看着简单,但实际运行中会碰到各种稀奇古怪的问题。我把自己经历过的和团队反馈来的问题整理成了速查表,希望你能绕开这些坑。
9.1 日志时间与业务时间的时区混乱
默认情况下 .NET 的 DateTime.Now 是本地时区,DateTime.UtcNow 是 UTC。如果程序部署在中国的 ECS 上,日志时间就成了 UTC+8,但日志平台在 UTC 环境解析,就会导致时间全部偏移 8 小时。排查问题时按“错误时间”搜日志,死活找不到,那叫一个难受。
我的解决方案是:日志模板统一用 UTC 输出,展示时由前端按浏览器本地时区转换。 在 Serilog 的 outputTemplate 里用 {Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz},zzz 会带上时区偏移量。同时在服务器上配置 TZ=Asia/Shanghai,让应用进程的本地时间就是 UTC+8。这样部署地域变了也能从日志里一眼看出时区偏置,不会出现模糊解读。
9.2 异步方法里的日志上下文丢失
C# 的 async/await 模型基于 AsyncLocal,理论上 LogContext.PushProperty 在异步流里能正确传递。但有个例外:如果代码里用了 Task.Run 开启了新的执行流,或者 ConfigureAwait(false) 在非 UI 场景切换了上下文,部分日志的上下文属性会串或者丢失。
我自己的避坑方案:在特别关键的业务链路上不依赖 LogContext 的隐式属性,改用显式传入的 TraceId/OrderId 模板参数。这是冗余,但能保证关键日志永远有正确的业务字段。
9.3 日志文件句柄泄漏导致磁盘空间无法释放
有次线上清理日志文件后,磁盘空间却没释放,du 显示的占用和 df 显示的占用对不上。排查后找到原因:有进程还持有已删除日志文件的句柄。Linux 下文件被删除但进程仍持有 fd 时,空间不会释放。这是因为 Serilog 的 File Sink 默认不会自动重开文件句柄,我们要按天滚动或按大小滚动,但如果外部用 logrotate 切割文件,而 Serilog 没感知到,句柄就滞留了。
解决办法是在 Serilog 使用 shared: true 并用 FileLogger 自己的滚动机制,避免外部工具切割。如果必须用外部 logrotate,配置 Serilog 的 rollOnFileSizeLimit 和 retainedFileCountLimit 让文件自管理,尽量避免外部参与切割。
9.4 敏感数据脱敏失效
团队里有新人往日志里打了完整的用户身份证号、银行卡号。脱敏规则我在配置里写了排除某些字段,但新人打日志时没有用标准字段名,直接把身份证拼在 Message 里。这种问题靠配置层脱敏根本防不住,信息已经在 Message 文字里了。
最终我在代码评审时加了硬性要求:所有日志调用里的参数,涉及个人敏感信息的必须先脱敏再传入。写了一个 Mask 工具类,提供 MaskIdCard()、MaskPhone() 等方法,统一使用,不直接放原始值。
9.5 堆栈跟踪不完整
catch 异常之后只打 ex.Message,不打完整的堆栈跟踪,是排查效率低下的元凶之一。很多人在 catch 块里写:
csharp复制catch (Exception ex)
{
_logger.LogError(ex, "操作失败:{Message}", ex.Message);
}
看起来没什么问题,但 ex.Message 只有一句话“对象引用未设置到对象的实例”,鬼知道是哪里出的问题。正确方式是把异常对象作为第一个参数传入,Serilog 会自动将 Exception.ToString() 完整写入:
csharp复制catch (Exception ex)
{
_logger.LogError(ex, "操作失败,业务编号 {BizId}", bizId);
}
另外有个坑:某些从远程服务或反序列化恢复的异常,堆栈被截断过。用 ExceptionDispatchInfo 重新抛出时堆栈信息应该完整保留,但如果你把异常打包成自定义异常再抛,记得 InnerException 不能丢,并且调用 ExceptionDispatchInfo.Capture(ex).Throw() 而不是 throw ex(后者会重置堆栈到当前帧,原异常堆栈丢失)。
9.6 日志库版本冲突
在大型解决方案里,不同项目引用的 Microsoft.Extensions.Logging 版本不一致,会出现 TypeLoadException 或方法找不到的诡异报错。我碰到过一次:某个类库引用了 Microsoft.Extensions.Logging 6.0,主项目是 8.0,运行时因为程序集绑定问题直接崩。
解决办法是保持解决方案内所有项目的 Microsoft.Extensions.Logging.* 和 Serilog.* 版本一致,或者直接用中央包管理(Central Package Management)统一版本。用 Directory.Packages.props 管理包版本,从根上杜绝冲突。
9.7 控制台日志编码问题
Windows 下跑控制台应用,中文日志乱码是常见问题。这不是日志库的锅,是控制台的代码页没设置。解决方案是在 Program.cs 最开头加一行:
csharp复制Console.OutputEncoding = System.Text.Encoding.UTF8;
文件日志一般不会乱码,因为 File Sink 默认 UTF-8。但如果你在 Windows PowerShell 里 Get-Content 查日志,要记住指定 -Encoding UTF8。
10. 日志增强:一个开箱即用的监控告警思路
日志系统的更高价值在于实时监控和告警。我们不仅要在出问题时能查到日志,还要在问题刚出现时就能收到通知。这里分享一个轻量级的做法。
10.1 基于 Loki 的告警规则
配上 Loki,Promtail 把日志推到 Loki 后,就可以用 Grafana 的 alerting 做规则匹配。比如最近 5 分钟内 Error 级别日志数量超过 20 条,就触发告警:
yaml复制groups:
- name: dotnet-app-alerts
rules:
- alert: HighErrorRate
expr: |
sum(count_over_time({job="myapp"} |= "level=error" [5m])) > 20
for: 2m
labels:
severity: warning
annotations:
summary: "应用错误日志数量超过阈值"
告警通知渠道可以根据团队情况配置钉钉、企业微信、邮件等,避免在业务人员发现故障之后才后知后觉。
10.2 基于 Serilog 发送到 Seq
如果你用 Seq 做日志平台,可以用 Serilog.Sinks.Seq 把结构化事件发送过去。在开发环境或中小团队的内部工具上,Seq 的 UI 体验比 ELK 轻量得多。配个 ApiKey 和 URL 就行:
csharp复制.WriteTo.Seq("http://seq.example.com:5341", apiKey: "your-api-key")
10.3 自己写一个简单的日志告警中间件
有的项目不想引入额外的日志平台,那可以在应用内部做告警判断。比如在 ASP.NET Core 的中间件里捕获未处理异常,如果异常来源是特定业务,直接发 HTTP 通知到企业微信/钉钉机器人。
csharp复制app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (exception != null)
{
_logger.LogError(exception, "未处理异常");
if (context.Request.Path.StartsWithSegments("/api/payment"))
{
await _notifier.SendAsync($"支付接口异常: {exception.Message}");
}
context.Response.StatusCode = 500;
}
});
});
这套方案胜在简单直接,不依赖外部基础组件,对于体量不大的项目足够用了。
11. 团队日志规范:一个人写得好不如全团队写得好
最后聊一个非技术但直接影响效果的问题。代码里日志写得不好,再牛的技术栈也白搭。我经历过几个团队,日志系统配好了,但每个人写日志的习惯千奇百怪,最后日志平台一大堆数据,能用的没多少。
我给自己所在团队定了一份《日志规范》,核心内容如下:
- 消息模板必须用英文占位符:
LogError("订单处理失败 {OrderId}", orderId),不要用中文变量名当占位符 - 所有错误日志必须传异常对象,禁止只传
ex.Message - 业务关键节点必须带业务主键(订单号、用户ID、批次号等)
- 禁止打印密码、令牌、身份证号、银行卡号、手机号、支付信息等敏感数据
- 日志级别使用原则:Error 只表示当前操作失败;Warning 表示可用降级但请求成功;Info 记录关键业务节点;Debug/Trace 只在定位问题时开启
- 每个日志调用点都应该回答三个问题:什么时候(时间戳自动)、发生了什么(消息)、影响什么(上下文字段)
规范文档本身不值钱,值钱的是评审时的坚持。我在每次代码审查时如果看到日志写得不符合规范,都会打回去改。坚持一两个版本之后,团队的日志质量就能稳定下来。
日志系统这一块,说复杂是真复杂,涉及面广;但说简单也简单,只要你把握住“为排查问题而记录”这个初心,所有设计决策都有了方向。从技术栈选型到字段设计,从滚动策略到采集上送,从性能优化到团队规范,把这些基础打扎实,你的 .NET 应用就算真正拥有了看得见摸得着的“眼睛”。
