说实话,日志这玩意儿,写的时候没人觉得重要,等线上出问题的时候,谁都抓瞎。干.NET开发这些年,我见过太多线上故障是从"看不了日志"开始的:日志打了一堆,控制台刷屏,可真要查一条具体请求的执行链路,翻半天文件找不到;服务一多,逐台机器去tail日志,眼睛都快看瞎了。日志系统这个听起来基础得不能再基础的东西,其实是很多项目的隐形短板。这篇博文,我想把自己在.NET生态里折腾日志系统的完整经验整理出来,从选型、结构化、集中采集、性能开销到各种让我熬夜排查的坑,一次性说清楚,希望给正在用.NET做服务端开发、又对日志没什么头绪的朋友一些真正能落地的参考。
1. 选型:.NET日志体系里那几套方案,边界到底在哪
1.1 Microsoft.Extensions.Logging 只是门面,不是日志框架
很多新手(包括早年的我)都以为ASP.NET Core项目里那个ILogger<xxx>就是全部了,往控制台里打几条Information就算"接好日志系统"了。这个认知要纠正一下:Microsoft.Extensions.Logging(下面叫MEL)本质是一套日志门面接口,它本身不负责把日志写到任何地方——控制台输出之所以开箱即用,只是因为默认注册了一个ConsoleProvider。
MEL只提供抽象层、级别过滤、格式化和Provider机制。真正干活的是Provider:默认的Console、Debug、EventLog、EventSource,以及第三方提供的Serilog Provider、NLog Provider。也就是说,你用ILogger写了代码,底层到底是写到文件、数据库还是日志采集平台,全看注册了哪个Provider。理解这一点很重要,它决定了你的业务代码可以保持对具体日志实现零依赖。
1.2 Serilog、NLog 这类完整框架,多出来的自由不是白给的
如果项目只是验证个想法、跑几个简单接口,默认的Console输出确实够用。但凡是上了规模、需要排查历史问题、需要从海量日志里捞线索的,就必须做三件事:结构化输出、按天/按大小滚动落盘、把日志送往日志平台。这套能力MEL默认没有,而Serilog和NLog恰好都提供。
Serilog的强项是消息模板和结构化日志,它把日志事件当成强类型数据处理,默认输出JSON也可以很友好;配合sink生态,写文件、接Elasticsearch、接Grafana Loki都非常顺。NLog的长处在于配置灵活、性能也不错,而且老项目里用NLog的历史包袱比较重。两个框架都很成熟,但如果条件允许,我个人更倾向Serilog——它在.NET社区的活跃度、文档质量和和MEL的集成顺滑度确实有优势。
下面这张表是我给团队讲选型时常用的对照:
| 方案 | 适合场景 | 结构化输出 | 文件滚动 | 日志平台对接 | 学习成本 |
|---|---|---|---|---|---|
| MEL + Console | 开发调试、临时脚本 | 无 | 无 | 无 | 极低 |
| MEL + 文件Provider | 单机小工具 | 弱 | 部分支持 | 无 | 低 |
| NLog | 成熟项目、老系统改造 | 支持 | 支持 | 插件丰富 | 中 |
| Serilog | 新项目、微服务、结构化日志优先 | 原生支持 | 支持 | sink生态极好 | 中 |
提示:选日志库不是选"最好"的,而是选"团队能长期维护"的。引入一套日志框架等于给项目增加长期依赖成本,没必要为了炫技重复造轮子,也别让NLog和Serilog混着用,到时候运维排查两边对不上,反而更痛苦。
1.3 我实际项目里的典型组合
我现在落地新项目,最常见的组合是:Serilog + MySQL/PostgreSQL业务数据,日志走文件或直接推Loki。文件用于本地快速排查,聚合平台用于线上全局检索。在ASP.NET Core里接Serilog的启动代码如下:
csharp复制builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
这里做了两件事:从appsettings.json读取Serilog配置,再注册LogContext。后者是Serilog的一个关键能力,后面讲链路追踪时会详细展开。如果项目里用了OpenTelemetry,还要配合WithTraceId()这类扩展,让日志里带上链路ID。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 结构化日志:从"人能看懂"到"机器能检索"
2.1 字符串拼接的日志,排查起来有多痛苦
很多老代码写日志是这么写的:
csharp复制logger.LogError($"用户 {userId} 下单失败,订单号 {orderId},错误:{ex.Message}");
这种写法在本地调试没毛病,真上了日志平台就是灾难。第一,整个日志变成了一段不可拆分的字符串,想按订单号查,只能做模糊搜索,日志一多速度直线下降;第二,如果日志平台按关键字索引,这种拼接式日志会占掉大量索引空间;第三,消息模板里变量和文本混在一起,后续想提取结构化字段做统计,基本不可能。
我接手过一个订单系统,排查某类支付失败时,运维从Elasticsearch里搜订单号,一次搜索要扫几十个索引,慢到怀疑人生。后来我把日志改成结构化输出,按订单号字段精确查询,秒出结果。这就是那一行代码带来的差别。
2.2 消息模板的正确用法:参数化,不要字符串插值
Serilog把一个日志事件拆成三部分:消息模板、参数列表、级别。样例:
csharp复制logger.LogError("用户 {UserId} 下单失败,订单号 {OrderId},错误:{Error}", userId, orderId, ex.Message);
注意模板里{UserId}、{OrderId}、{Error}是占位符,不是插值表达式。Serilog在记录事件时,会把参数值和模板分开存储。输出到控制台时拼接成可读文本,输出到JSON时则保留成字段。这样下游无论是Loki还是ES,都能按UserId、OrderId做精确过滤。
这里有个细节容易被忽略:占位符名要和参数语义一致。你要是写{0}、{1},模板是能跑,但查日志时字段名完全没意义,等于又回到字符串拼接的老路。所以日志模板的字段名要当API设计一样对待,定下来尽量别改,改了历史日志的字段就断了。
还有两个特殊语法值得记住:
csharp复制logger.LogInformation("操作对象:{@User}", user); // 结构化展开对象的所有公开属性
logger.LogInformation("原始对象:{$UserRaw}", user.ToString()); // 只存字符串,不做属性展开
@前缀记录对象的完整属性快照,适合记录请求体、参数对象;$前缀强制按字符串记录,适合对象本身巨大、只关心ToString()结果的场景。
2.3 Enricher和LogContext:让每条日志自动带上下文
结构化日志的另一个大杀器是Enricher。Enricher可以为所有日志统一附加上下文信息,例如机器名、进程号、线程号、版本号等。Serilog自带了一批,实际项目中我常用这几个:
csharp复制.Enrich.WithMachineName()
.Enrich.WithProcessId()
.Enrich.WithThreadId()
.Enrich.WithEnvironmentName()
这些信息在你排查"同一份代码为什么只有某一台机器出错"时特别有用。日志平台上按机器名过滤,几秒钟就能定位到哪台实例。
更灵活的用法是LogContext。它可以在一个作用域内给日志临时加字段:
csharp复制using (LogContext.PushProperty("OrderId", orderId))
using (LogContext.PushProperty("UserId", userId))
{
// 这个作用域内产生的所有日志都会带上 OrderId 和 UserId
_logger.LogWarning("库存扣减异常");
_logger.LogInformation("准备重试");
}
关键是,你不需要在每个方法里的每条日志里手动传这两个参数,只要在入口处Push一次,作用域内所有日志都会自动带上。这比在每个方法签名里传上下文变量要优雅得多,也不会漏字段。
3. 日志级别与过滤:配置治理是门细致的活
3.1 别忘了把框架默认日志压下去
Serilog默认情况下会记录所有来源,包括ASP.NET Core内部、EF Core、HttpClient等。你如果不做任何处理,开发环境启动一次服务,控制台能刷出几百行来自Microsoft.*命名空间的信息日志,真正属于业务代码的日志反而被淹没了。
所以在appsettings.json里,我几乎必配Override,把框架日志压到Warning级别:
json复制{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
}
}
}
这个配置的优先级规则需要注意:越具体的命名空间优先级越高,也就是Microsoft.AspNetCore.Hosting会覆盖Microsoft.AspNetCore的级别,但不会覆盖Microsoft的通用级别。实际排错时如果需要看EF Core执行的SQL,可以临时把Microsoft.EntityFrameworkCore改成Information,不用重启改全量配置,这是最常用的调试手段。
3.2 生产环境想动态调级别:用LoggingLevelSwitch
生产出问题时,临时想把某条链路的日志级别从Information调到Debug,总不可能改配置重启服务。所以我在比较大的服务里会暴露一个内部管理端点,配合LoggingLevelSwitch在运行中动态调整级别,不用重启进程。
csharp复制var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Information);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(levelSwitch)
.WriteTo.Console()
.CreateLogger();
然后在某个管理接口里改levelSwitch.MinimumLevel,日志级别即时生效。注意这会有安全风险,管理端点一定要加鉴权,别裸奔到公网。另一个稳妥做法是用一个定时任务去读配置中心里某个键,变则改,这样生产上改级别也走配置审批流程。
3.3 敏感信息:不该进日志的,一条都不能进
日志系统最怕的不是没日志,而是日志里全是密码、Token、身份证号。一旦日志外泄或进入第三方日志平台,数据安全就是个雷。所以我在代码审查里有一条硬规矩:严禁记录密码、密钥、用户敏感属性,即使这些对象是作为@参数展开的。
Serilog提供IDestructuringPolicy可以对指定类型做脱敏处理。比如一个User对象可能带Phone、IdCard字段,期望展开其他字段但把这两个打码,可以写一个脱敏策略:
csharp复制public class UserDestructuringPolicy : IDestructuringPolicy
{
public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, out LogEventPropertyValue result)
{
if (value is not User user)
{
result = null;
return false;
}
var props = new Dictionary<string, LogEventPropertyValue>
{
["Id"] = new ScalarValue(user.Id),
["Name"] = new ScalarValue(user.Name),
["Phone"] = new ScalarValue(MaskPhone(user.Phone))
};
result = new StructureValue(props.Select(p => new LogEventProperty(p.Key, p.Value)));
return true;
}
}
这样即使有同事不小心把整个User对象塞进了日志,落地的也只剩脱敏内容。这个问题上宁可过度谨慎,也不要抱侥幸心理,生产环境的日志泄密事故代价太高了。
4. 日志聚合:.NET服务接入Loki的完整链路
4.1 单体项目也需要日志平台
有不少朋友觉得,日志聚合是微服务的事,单体应用就几个文件,grep一下就够用了。这个观点在机器数量少、并发量低的时候确实成立,但容器化之后就崩了:Pod重启,日志文件没了;多副本部署,日志分散在多个Pod里,一条请求的多个日志落在了不同实例,排查问题要挨个进容器翻文件。这时候你需要的不是更努力地翻日志,而是一个集中式日志平台。
Grafana Loki在轻量级场景里优势很明显,它不像Elasticsearch那样吃内存,也不强制全局建立海量索引,而是用类似Grep的标签+内容过滤思路,部署和上手门槛都低,非常适合中小团队。
4.2 两种接法:应用直推 vs 文件侧刮取
.NET服务接Loki有两种主流方式。
第一种,应用直接通过HTTP推送到Loki。用Serilog.Sinks.Loki这个sink,在appsettings.json里配置:
json复制{
"Serilog": {
"WriteTo": [
{
"Name": "Loki",
"Args": {
"uri": "http://loki-gateway:3100",
"labels": [
{ "Key": "job", "Value": "order-api" },
{ "Key": "environment", "Value": "production" }
],
"batchPostingLimit": 100,
"period": "00:00:02",
"queueLimit": 10000
}
}
]
}
}
注意labels是Loki的索引标签,查询时第一层过滤就是按标签过滤的。job标签很有用,建议用服务名-环境这种命名方式,比如order-api-prod,避免不同环境标签互相干扰。
第二种,应用照常写日志文件,然后用Promtail这样的采集器去刮取文件推送给Loki。这是我在生产环境更推荐的做法。应用本身不需要感知Loki地址,采集器的任务就是监视文件变化、批量发送。这样日志采集链路和应用逻辑完全解耦,搬家、换采集方案都不影响业务代码。
两种方式对比如下:
| 方式 | 侵入性 | 运维成本 | 适用场景 |
|---|---|---|---|
| 应用直推Loki | 应用需配置Loki地址 | 低,无采集器 | 快速验证、小规模实验 |
| 文件 + Promtail | 应用只需写本地文件 | 需部署采集器 | 生产推荐,稳定可靠 |
4.3 标签设计:不是所有日志字段都适合做标签
用Loki最大的误区是把每个字段都塞进labels,结果几百万条日志,每个label的基数值高到爆炸。Loki的标签适合放低基数的维度,比如环境、服务名、日志级别。而请求ID、用户ID这些高基数的内容,应该作为结构化字段放进日志行里,查询时用LogQL的内容解析能力去筛。
比如日志行本身是JSON,Promtail或Loki的解析器可以把字段提出来。一段典型的LogQL查询:
logql复制{job="order-api-prod"} |= "error" | json | UserId="12345"
这条查询先按标签过滤服务,再按关键字过滤出错误日志,最后解析出JSON里的UserId字段精确匹配。查询速度比全库模糊搜快一个量级,而且完全不用为每个字段建索引。这个设计思路让我省了大量Loki资源。
4.4 接入后的实际排查体验
接完Loki之后,我排障的姿势就从"ssh上去grep"变成了"打开Grafana,按服务、级别、关键字段筛"。最舒服的是时间范围能精确到秒,多个服务同一时间段的日志可以在一个视图里按时间排序看。有一次排查线上慢请求,我在Grafana里把网关、订单服务、支付服务三份日志叠在一起,很快发现是支付回调里一个外部HTTP调用超时,而那台机器的本地时间和我们电脑差了将近一分钟,如果没有集中日志平台,这种问题得在几台机器之间来回切,效率完全不同。
注意:集中日志平台只能保证日志"都能查",并不保证所有时间戳是准的。各节点时间同步(比如容器里用NTP同步主机时钟)这一步别省,否则按时间排序会乱掉,跨服务排查直接白做。
5. 链路上下文:TraceId、Scope和中间件里的日志
5.1 一条请求穿过的服务越多,越需要同一个TraceId
单体应用排障相对简单,但从网关到订单服务再到支付服务,一条请求会触发多段日志。想把这串日志串成一条链路,就要让相关日志共享同一个链路标识——一般是TraceId。.NET原生有System.Diagnostics.Activity,配合OpenTelemetry可以在不侵入业务代码的前提下生成并传播TraceId。
在Serilog里把这些链路信息注入日志,核心是靠Enrich.FromLogContext()和在合适的位置Push属性。比如在中间件入口处,取出当前Activity.Current.TraceId,Push到LogContext:
csharp复制public class TraceIdLogContextMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
using (LogContext.PushProperty("TraceId", Activity.Current?.TraceId.ToString()))
{
await next(context);
}
}
}
这样中间件之后的整个请求处理过程产生的日志都会带上TraceId。在Loki或ES里按TraceId一搜,这条请求从头到尾经历了什么一目了然。
5.2 日志中间件的设计:一次请求两条关键日志
除了链路ID,我习惯在网关或者入口服务加请求日志中间件,记录请求的进出和耗时。最早的版本是记录每个请求的完整日志,包括请求体和响应体,结果日志量爆炸,而且大体积响应体把日志文件塞满了几次。
后来我收敛成按需设计:请求进来时记录Method、Path、Query、来源IP;请求结束时记录状态码、耗时、TraceId;只有接口出错时才额外记录请求体关键字段。这个设计既保证了排查问题的基本信息,又不会因为日志量过大拖垮系统。中间件核心逻辑大概长这样:
csharp复制public class RequestLoggingMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var sw = Stopwatch.StartNew();
_logger.LogInformation("请求开始 {Method} {Path}", context.Request.Method, context.Request.Path);
await next(context);
sw.Stop();
_logger.LogInformation("请求结束 {Method} {Path} {StatusCode} {ElapsedMs}",
context.Request.Method, context.Request.Path, context.Response.StatusCode, sw.ElapsedMilliseconds);
}
}
5.3 异步场景下TraceId容易丢,怎么兜底
有一个坑我踩了好几次:在ASP.NET Core默认的Activity机制下,TraceId靠AsyncLocal在异步上下文里流动,理论上异步过程中不丢。但如果你把日志任务丢到Task.Run里,或者用了自定义线程池、裸创建一个新线程,AsyncLocal的数据就不会自动传递,日志就没了TraceId。
解决思路分两条:一是尽量别在业务代码里随意制造线程边界,所有异步操作通过框架托管的异步流转;二是如果确实绕不开,手动在新任务入口恢复上下文,把当前TraceId作为参数传递,塞进新任务的LogContext。这是排查分布式问题时的兜底方案,看似简单,但在关键时候能救命。
6. 性能开销:别让日志成了隐形杀手
6.1 日志慢在哪:不是写入那一下,而是前置过程
很多人觉得只要用了异步sink,日志就完全不影响性能了。实际上日志开销不止IO,还包括三块:格式化、参数计算、写入。logger.LogDebug(expensiveObject.ToString())这种写法,即使日志级别没有开启Debug,expensiveObject.ToString()也已经执行了。这就是典型的日志影响性能的场景:级别判断短路发生在字符串拼接之前,但发生在参数表达式求值之后。
所以写日志时必须有意识地把昂贵计算延迟到日志真正要输出的时候。Serilog推荐的是传类型参数,让它自己按需格式化:
csharp复制// 错误:不管级别是否开启,昂贵的ToString都执行了
_logger.LogDebug($"对象状态:{await GetBigObjectStateAsync()}");
// 正确:传入委托或直接传对象,由日志框架在需要时处理
_logger.LogDebug("对象状态:{@State}", new { Name = "x", Data = lazyData });
还有个反直觉的坑:传字符串模板还是传插值结果,不只是风格问题。LogWarning($"userId={userId}")这种写法实际上在调用前就完成了插值,讲白了和上面一样。纪律要靠团队规范,建议在代码评审阶段就盯着。
6.2 批量异步写入:文件、Loki都要开队列
Serilog的批量sink会把日志事件先在内存队列里攒一批,再批量写入,降低IO次数。以Serilog.Sinks.File为例,配置里的关键参数是batchSizeLimit、period、queueLimit。比如我想让日志最多攒100条或者每2秒刷一次文件,可以这样:
json复制{
"Name": "File",
"Args": {
"path": "logs/app-.log",
"rollingInterval": "Day",
"buffered": true,
"batchSizeLimit": 100,
"period": "00:00:02",
"queueLimit": 10000
}
}
queueLimit要尤其注意:如果队列满了,新的日志事件会选择丢弃还是阻塞,直接关系到业务线程会不会卡住。默认情况下一旦队列溢出,后面的日志会被丢弃,这能保护业务线程不被日志IO拖死,但也会造成日志缺口。所以排查高并发问题时要意识到:慢日志系统丢日志是正常的,需要先解决日志写入瓶颈,而不是盯着"为什么少了两条日志"。
6.3 控制台输出在生产环境就是性能刺客
有过一次经历:一个高并发服务,测试环境没问题,一上生产CPU直接90%以上,排查半天发现是开发环境习惯带上了WriteTo.Console(),生产容器标准输出又接了日志采集,每条日志经历了格式化、写stdout、采集器捕获、再转发,整个链路叠出来开销极大。那次把控制台输出关掉、只保留文件写入后,CPU降了将近一半。
这个案例给我的教训是:日志sink的配置环境应该分开。开发用Console方便看,生产用文件或采集转发,别把开发配置复制到生产。可以用环境变量切换Serilog配置整体位置,或者用配置文件区分。
7. 踩坑实录:我调日志系统时遇到的典型问题
7.1 日志文件被锁,滚动失败导致磁盘写满
现象是文件日志到了午夜没有自动切割,logs目录里一个文件几百MB甚至上GB,磁盘报警。排查链路是这样的:先确认进程有没有被第三方库打开相同文件句柄,排除安全软件扫描;接着怀疑是rollingInterval配置被其他配置文件覆盖;最后发现是Windows服务里Serilog配置生效的顺序问题——ReadFrom.Configuration读的appsettings.json里没有显式配滚动间隔,默认行为就不滚动,而开发环境自己加了滚动配置,看起来正常。
这个坑的解法很朴素:所有环境用同一份基础配置模板,不同环境只是在模板基础上覆盖差异项,别让每个环境自己整一套。另外,日志文件不管配没配滚动,最好再加一个保留天数清理策略,比如retainedFileCountLimit: 30,别让磁盘被历史日志堆满。
7.2 多服务时间不同步,日志排序乱了
有次排查跨服务问题时,正序排列的日志里,支付服务的记录比订单服务的记录还早,每条日志单独看都没毛病,拼起来时间线全乱了。查下来发现是容器宿主机间时钟偏差,个别机器慢了将近一分钟。
这种问题的排查思路很直接:把所有节点的时间统一为UTC,并在日志里同时输出机器时间和时区;监控平台定期检查节点时间偏差。集中日志平台建索引时也建议用UTC,展示层再转换本地时间,避免因为时区导致排序混乱。
7.3 敏感对象被整段展开,日志里出现了不该出现的内容
具体经过是:一个同事把支付回调里的完整响应对当{@Response}记录,原本只想看返回码和消息,结果整个对象的所有属性被Serilog结构化展开,包含了对端返回的授权Token。日志进了Loki之后,等于把这个Token永久留在日志平台上了。
排查时我们先用LogQL把所有包含该对象类型的日志全找出来,先止血删数据,再补脱敏策略,最后在评审规范里明确禁止记录REST响应的原始对象。这个坑提醒了我:Serilog的@展开能力是好用,但它默认是"全部展开",用之前一定要想清楚对象里有没有不该卸载的属性。
7.4 多个WriteTo互相挤兑,批量队列抢资源
有一次在服务里同时配置了文件、Loki、Console三个WriteTo,结果高峰期频繁出现日志写入延迟。排查发现三个sink各自维护自己的队列和批量任务,同时抢占线程池资源,极端情况下造成互相等待。
后来优化方案是:生产环境只保留文件+异步采集器这一条路;如果非要多路输出,把次要输出降级,比如控制台只在调试模式使用,并把所有WriteTo包进Async包装器,由统一的队列去调度。.WriteTo.Async(a => a.File(...).Loki(...))这种写法可以显著减少多sink排队竞争。
最后再分享一点实际体会
日志系统的设计,最核心的不是框架本身,而是你要先想清楚"将来这些日志会在什么场景下、用什么字段被查出来"。想明白了,选型、结构化字段、标签设计、级别配置都会有方向,不会变成无脑堆配置。我现在接每个新项目,第一件事就是和团队把日志模板、字段命名、敏感字段红线、采集链路确认下来,后面运维省心太多。如果你正在为一个服务写第一行日志,可以先把方案定主体:MEL做抽象,Serilog做输出中间层,Loki做集中存储,再按生产需求去抑制和保留。剩下的细节,都是在真实故障里磨出来的。
