尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

.NET可观测性实战:Serilog结构化日志与OpenTelemetry链路追踪

.NET可观测性实战:Serilog结构化日志与OpenTelemetry链路追踪 凌晨两点四十分监控大屏突然弹出红色告警订单支付失败率在五分钟内从0.1%飙升到18%。你熟练地登上服务器从几百MB的日志文件里熟练地敲出grep ERROR屏幕刷出一大串文本夹杂着时间戳、线程号、半截堆栈。半小时过去了你只能确认“某个仓库服务好像抛了NullReferenceException”却说不清这次失败到底是哪个订单、哪个用户、经过了哪几个节点。这不是能力不够是工具没跟上——你的应用还在用“零散文本”记录“关联事件”。这篇内容想分享的就是我在.NET项目里用Serilog做结构化日志、再用OpenTelemetry打通日志、指标与链路追踪把排障从“大海捞针”变成“顺着网线摸过去”的完整思路和落地细节。无论你是后端开发、架构师还是刚接手.NET项目的运维这篇文章里踩过的坑和给出的配置应该都能直接拿过去改一改就能用。1. 文本日志为什么越查越绝望三个解决不了的老问题先说一个反直觉的结论日志不是写得不够多而是记录的方式从一开始就错了。大多数传统项目里的日志其实是“文本留痕”它的核心是“把消息拼成一行字符串”而这一行字符串恰恰丢失了最重要的东西——结构。结构一丢后面所有查询、关联、统计都只能靠人肉。1.1 你能看到“发生了什么”却看不到“为什么发生”传统写日志的经典姿势是字符串拼接_logger.LogWarning($用户 {userId} 尝试访问订单 {orderId} 但权限不足);这种写法在控制台里看起来没毛病但你把它扔进日志文件之后它变成的是一行无结构的文本。你要排查今天的用户权限问题只能用grep 用户去扫然后再从结果里人工辨认哪个是userId、哪个是orderId。更麻烦的是不同开发者写的日志风格完全不一样有人写“用户尝试访问订单”有人写“User access order failed”还有人只在异常里顺手记了个ex.ToString()。随着项目变大日志格式逐渐失控grep的时候你甚至得同时试五六种拼写风格。再加上时间格式不统一、时区混乱、多行堆栈被截断你在一个文本文件里得到的只是一个“疑似发生了什么”的残影而不是一个可复盘的完整现场。真正要回答的“为什么这个用户没有权限”“这个订单当时处于什么状态”“请求是从哪个入口进来的”文本日志里一个都答不上来。1.2 多线程、异步、微服务文本日志的“关联性”直接归零单机单线程的时代日志文件按时间排起来基本就是故事线。但现在一个普通的.NET Core应用多线程并发处理请求多个请求的日志交织在同一秒用了async/await线程池切换导致日志顺序并不是真正的执行顺序一旦拆成微服务“一个订单”的完整流程要横跨订单服务、支付服务、库存服务这时候你再靠文本日志去排障操作就变成了先在某台机器上找到订单服务日志再登录另一台机器翻支付服务日志最后人工把时间戳对齐、把TraceId或订单号从上下文里抠出来拼时间线。这种事情我做过程序员都懂偶尔一次还能忍多来几次就会想改行。而这一切的根源是文本日志里没有一个贯穿全局的“身份标识”。每个请求在代码里其实是有上下文的但打印日志的时候上下文没有随日志一起落到文件里到了排障端就是完全割裂的碎片。1.3 把思维从“打日志”切换到“记录事件”我在接手第一个需要认真做可观测性的.NET服务时最大的转变是想通了一个概念日志不是“打印出来的字符串”而是“记录下来的结构化事件”。一个事件包含三个要素发生了什么事、在什么场景下发生、带着哪些数据。比如“订单加载失败”这个事件应该被记录为{ Level: Error, Message: 订单加载失败, OrderId: 102488, UserId: 5021, Service: order-service, TraceId: e34ff2a11d4f4c2f }而不是2025-06-12 10:41:03 ERROR 订单102488加载失败 用户5021这个转变正是Serilog这类结构化日志库存在的意义。之前我们写日志是在拼文本模板之后我们写日志是在定义事件模型。只有先把日志变成数据后面OpenTelemetry的关联能力才有了落脚的根基。2. Serilog把日志变成可查询的数据结构化日志的落地姿势Serilog在.NET生态里已经是事实标准但它并不只是“换一个更漂亮的日志库”它的核心差异在MessageTemplate。理解了MessageTemplate的设计你就理解了结构化日志的一半。2.1 MessageTemplate是你真正应该写进代码的东西先看两组代码的对比。// 传统写法内容与格式耦合只能在字符串里做文章 _logger.LogWarning($用户 {userId} 尝试访问订单 {orderId} 但权限不足当前状态 {status}); // Serilog 写法内容与格式分离命名占位符保留语义 _logger.LogWarning(用户 {UserId} 尝试访问订单 {OrderId} 但权限不足当前状态 {Status}, userId, orderId, status);第二段代码里UserId、OrderId、Status这些占位符不只是给人看的Serilog在渲染日志时会把它们作为独立的命名字段输出。写到文本时是“用户 1024 尝试访问订单 8848…”写到JSON文件或日志平台时则是{ t: 2025-06-12T02:41:03.224Z, mt: 用户 {UserId} 尝试访问订单 {OrderId} 但权限不足当前状态 {Status}, UserId: 1024, OrderId: 8848, Status: Pending }注意UserId和OrderId在JSON里是数字类型而不是被塞进字符串里的文本。这意味着你在Loki或Elasticsearch里可以做OrderId 8000这种范围过滤也可以直接在Grafana的变量里把订单号抽成下拉框。传统文本日志要干这事得先靠正则从字符串里抠字段而这种“反解日志”的活儿又脆又慢字段稍有一点格式变化就全崩。所以在实际项目里我要求团队的唯一硬性规范就是永远不要在日志消息里拼接业务字段全部用命名占位符传参。这条规范带来的一致性和可查询性比任何日志平台选型都重要。2.2和$处理复杂对象的两把钥匙写结构化日志时经常会遇到“想把一个对象塞进日志”比如传参的是一个Order实体。这个时候有几种写法// 默认调用 ToString()输出通常是类型名没用 _logger.LogInformation(收到订单 {Order}, order); // 前缀用序列化器把整个对象展开成结构化字段 _logger.LogInformation(收到订单 {Order}, order); // $ 前缀强制字符串化输出 ToString() 的结果 _logger.LogInformation(收到订单 {$Order}, order);大多数时候你需要的是Order它会把对象的每个属性变成一个子字段在日志平台上可以展开看Order.OrderId、Order.Amount这样的树形结构。但我也要提醒一句很爽别滥用。一个订单对象展开可能就是十几个字段如果一个高频接口每秒请求上百次每个请求都记一份完整对象日志量会迅速把你炸哭。我的习惯是业务关键点用高频调试点用临时日志最后走审计要求再考虑全量记录。2.3 WriteTo.File里的文件名格式一个让很多人卡住的小问题很多人上网搜“Serilog WriteTo.File 文件名格式如何写”其实核心就是一个滚动日志的占位符规则。看这个标准写法Log.Logger new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() .WriteTo.File(logs/order-service-.log, rollingInterval: RollingInterval.Day) .CreateLogger();注意文件名order-service-.log里有个末尾的连字符这个连字符是滚动占位符它不会出现在日志文件名里而是配合rollingInterval生成实际的文件名rollingInterval实际文件名示例滚动频率RollingInterval.Dayorder-service-20250612.log每天切换一个新文件RollingInterval.Hourorder-service-2025061214.log每小时切换RollingInterval.Minuteorder-service-202506121441.log每分钟切换一般只用于调试RollingInterval.Infiniteorder-service.log始终只写同一个文件不推荐很多新手会写成logs/app-{Date}.log或者logs/app-yyyyMMdd.log结果文件要么一直不生成要么生成的是一坨带奇怪后缀的文件。原因很简单Serilog的File sink不是靠你自己拼时间格式的它只认固定的占位符语法和rollingInterval参数。连字符加上滚动策略才是官方支持的做法。你不需要在path里手动写{Date}这种模板。另外两条我实际用下来必须配合的参数.WriteTo.File(logs/order-service-.log, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30, fileSizeLimitBytes: 1_073_741_824, rollOnFileSizeLimit: true)retainedFileCountLimit控制保留多少份历史文件超过自动删除避免日志把磁盘撑爆fileSizeLimitBytes和rollOnFileSizeLimit是控制单文件多大多了开始滚动。生产环境建议保留至少30天文件大小限制在512MB到1GB之间比较合理。别小看这几个参数我曾经在测试环境见过一台服务器日志占到磁盘满原因就是retainedFileCountLimit默认值是31其实31份倒是还好但有人改成了null就是永不过期几个月下来好几十GB。Serilog还有一个容易被忽略但特别实用的方法Log.CloseAndFlush();在程序退出前调用确保缓冲区的日志全部刷到磁盘。在ASP.NET Core里通常注册成IHostApplicationLifetime的停止事件否则程序一崩最后几行日志可能就丢了。2.4 一套可以直接抄的Serilog初始化组合开发环境和生产环境的订阅渠道是不同的我推荐这样配置Log.Logger new LoggerConfiguration() .MinimumLevel.Information() .MinimumLevel.Override(Microsoft, LogEventLevel.Warning) .MinimumLevel.Override(Microsoft.EntityFrameworkCore, LogEventLevel.Error) .Enrich.FromLogContext() .Enrich.WithProperty(Application, OrderService) .Enrich.WithSpan() .WriteTo.Console(outputTemplate: [{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}) .WriteTo.File(logs/order-service-.log, rollingInterval: RollingInterval.Day) .CreateLogger();这里有个细节MinimumLevel.Override(Microsoft, LogEventLevel.Warning)是用来压掉ASP.NET Core框架自带的一大堆Info级别日志的。框架日志不进业务日志平台能省非常多的磁盘和采集带宽。EF Core的SQL日志默认是Info级别如果你没压它一次查询就刷好几行SQL出来量大到你会想把数据库干掉。这套初始化基本可以无脑复制到新项目里。生产环境如果需要发到Loki我后面会单独说怎么接先把链路追踪打通再谈日志平台。3. OpenTelemetry三信号联动用Trace把日志与指标串成一张网Serilog解决的是“日志本身的结构化”但可观测性还有另外两块拼图指标Metrics和链路追踪Traces。OpenTelemetry的价值不在于“又一个监控SDK”而在于它规范了这三个信号的产生、传播和关联方式。把三者用同一个TraceId串起来排障体验才会发生质变。3.1 Logs、Metrics、Traces分别回答什么问题我用一个生活类比帮团队理解三者的差异假设你在运营一个餐厅——Logs日志每一道菜出锅时厨师喊了一句“宫保鸡丁好了”。你能知道某个具体事件发生的时间点但看不清整体忙闲。Metrics指标每分钟出菜数量、平均等待时间、食材消耗速率。你能知道餐厅当前状态和趋势但不知道具体是哪一桌客人的具体经历。Traces追踪一位客人从进门、点菜、等菜、上菜到结账的完整流程每个环节花了多长时间、卡在哪一步。一个完整的可观测性体系是这三种信号都能各司其职而且能互相串起来指标显示“出菜速度下降了”你顺着Trace找到具体是“哪类订单的哪一步慢了”再点开对应Logs看慢在哪个SQL或哪个外部调用。OpenTelemetry就是那个既帮你统一产生信号、又帮你把三个信号钉在同一根轴上的骨架。3.2 TraceContext就是请求的“身份证”在OpenTelemetry的模型里一个请求被处理一次就会生成一个TraceId类似整个请求的唯一流水号每一步操作是一个Span有自己的SpanId还带着父SpanId所有Span通过父子关系串成一棵树。在.NET里这套东西的运行时载体是System.Diagnostics.Activity。当ASP.NET Core收到HTTP请求时如果你注册了OpenTelemetry的AspNetCore InstrumentationSDK会自动为这个请求创建一个Activity并生成TraceId和SpanId。关键点是Activity.Current在异步代码中会自动沿着调用链流转底层是AsyncLocal所以你await再多的异步方法只要在同一个逻辑请求里Activity.Current都还在。这就是“前因后果”能被串起来的底层机制。但这里有个工程师很容易忽略的点Activity归Activity日志归日志你不主动把它们关联起来TraceId永远不会出现在日志里。3.3 日志携带TraceIdEnrich.WithSpan做了什么要让日志带上TraceId最省事的方式是使用OpenTelemetry.Instrumentation.Serilog这个库提供的Enrich.WithSpan()扩展。名字看着像OpenTelemetry的官方包直接加NuGet引用PackageReference IncludeOpenTelemetry.Instrumentation.Serilog Version1.0.0 /然后在Serilog配置里加一行.Enrich.WithSpan()它的作用非常直白在每条日志写出的瞬间把当前Activity.Current的 TraceId、SpanId、TraceFlags 等字段注入到日志事件里。加了这一行之后你的结构化日志会多出两个核心字段{ t: 2025-06-12T02:41:03.224Z, mt: 正在加载订单 {OrderId}, OrderId: 102488, TraceId: e34ff2a11d4f4c2f, SpanId: a1b2c3d4e5f67890 }这一步做的事情看起来很小实际效果巨大。以前你查日志只能按业务字段订单号、用户ID去捡碎片现在你有了一个贯穿所有服务的统一字段TraceId。你只要能从一个入口拿到TraceId比如从Tempo里看到从Grafana日志里看到甚至用户报障时你从请求头里抠出来就能把所有服务的日志、整条调用链一次性全部拉出来。我在项目中真正体会到“重塑”的那一刻是我第一次在Tempo里点开一条慢请求的Trace然后点击“与TraceId关联的日志”按钮Loki直接把订单服务、支付服务、网关三个地方同一TraceId的日志全部并列展示。那种感觉就像以前查案靠翻纸箱子现在直接进了数据驾驶舱。4. 从应用埋点到Grafana全景盘一套可落地的技术栈组合有了Serilog和OpenTelemetry的代码基础下一步就是把数据送进一个能统一查看的平台。社区里现在很流行一套轻量组合OpenTelemetry Collector负责接收和分发Loki存日志Tempo存追踪VictoriaMetrics存指标Grafana做统一展示。这套组合不用Java、不用ES那样吃内存的巨头对中小团队非常友好。4.1 组件分工与技术栈脉络先把每个组件在这个体系里的角色说清楚组件角色接收数据方式类比OpenTelemetry Collector统一网关接收OTLP协议的数据按类型分发gRPC/HTTP (OTLP)快递中转站Loki日志存储与检索基于标签索引通常是Promtail/Alloy采集文件也可直接收OTLP日志文件仓库Tempo链路追踪存储按TraceId检索OTLP traces地图导航数据VictoriaMetrics指标存储兼容Prometheus查询OTLP metrics 或 Prometheus remote write仪表盘读数器Grafana统一可视化把三类数据源关联起来对接Loki/Tempo/VM驾驶舱这套链路大致长这样你的.NET应用把Logs/Metrics/Traces通过OTLP协议发给CollectorCollector根据信号类型把日志转给Loki、追踪转给Tempo、指标转给VictoriaMetrics最后到Grafana里做统一展示。Collector是可以不用的应用直接把数据发给各自后端也行但生产环境强烈建议加一层Collector因为你可以在这里做采样、过滤、批量、重试不用把应用和存储强耦合。4.2 Program.cs完整接入步骤我的建议是分四步走每一步都能独立验证效果不用一口气全上。第一步安装NuGet包PackageReference IncludeSerilog.AspNetCore Version8.0.1 / PackageReference IncludeOpenTelemetry.Exporter.OpenTelemetryProtocol Version1.8.1 / PackageReference IncludeOpenTelemetry.Extensions.Hosting Version1.8.1 / PackageReference IncludeOpenTelemetry.Instrumentation.AspNetCore Version1.8.0 / PackageReference IncludeOpenTelemetry.Instrumentation.Http Version1.8.0 / PackageReference IncludeOpenTelemetry.Instrumentation.Serilog Version1.0.0 /第二步在Program.cs里配置Serilog和OpenTelemetryvar builder WebApplication.CreateBuilder(args); // 用 Serilog 接管所有日志 builder.Host.UseSerilog((context, services, configuration) configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() .Enrich.WithProperty(Application, OrderService) .Enrich.WithSpan() .WriteTo.Console() .WriteTo.File(logs/order-service-.log, rollingInterval: RollingInterval.Day)); // 配置 OpenTelemetry builder.Services.AddOpenTelemetry() .WithTracing(tracing tracing .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddSource(OrderService)) .WithMetrics(metrics metrics .AddAspNetCoreInstrumentation() .AddMeter(OrderService.Meters)) .UseOtlpExporter(options options.Endpoint new Uri(http://otel-collector:4317));UseOtlpExporter默认把三个信号都通过OTLP导出。如果你还没有Collector可以先指向http://localhost:4317用本地跑一个Collector做验证。第三步在业务代码里写日志var logger app.Logger; app.MapGet(/orders/{id}, async (int id, IOrderService orderService) { logger.LogInformation(正在加载订单 {OrderId}, id); var order await orderService.GetByIdAsync(id); logger.LogInformation(订单加载完成 {OrderId}, 金额 {Amount}, id, order.Amount); return Results.Ok(order); });关键点在于你写的日志会自动带上TraceId因为你已经配置了Enrich.WithSpan()只要当前请求在Activity的上下文里日志就会带上Span字段。不需要你在每个业务方法里手动传TraceId。第四步用Docker Compose起一个最小的可观测性栈我测试时用过一个极简的docker-compose.yml包含Collector、Grafana、Loki、Tempo、VictoriaMetrics五个容器。Collector的配置文件大致是receivers: otlp: protocols: grpc: http: processors: batch: exporters: otlp/logs: endpoint: loki:3100 tls: insecure: true otlp/traces: endpoint: tempo:4317 tls: insecure: true prometheusremotewrite: endpoint: http://victoriametrics:8428/api/v1/write service: pipelines: logs: receivers: [otlp] processors: [batch] exporters: [otlp/logs] traces: receivers: [otlp] processors: [batch] exporters: [otlp/traces] metrics: receivers: [otlp] processors: [batch] exporters: [prometheusremotewrite]这个配置文件只用来说明数据流转逻辑实际生产配置还要考虑认证、TLS、队列等。Collector是中间层允许你灵活调整接收和导出的拓扑这也是为什么要单独加一层而不是让应用直连存储端。4.3 排障链路怎么走一个真实场景演示这套系统搭好之后排障的姿势就完全变了。假设今天线上有用户反馈“下单特别慢”我不会再登录服务器翻日志而是打开Grafana先看VictoriaMetrics里的指标面板。如果看到OrderService的平均响应时间从200ms涨到了800ms我就知道问题范围在订单服务内而不是客户端问题。点一下某个时间点的异常数据如果配置了Exemplar可以直接跳到对应的Trace列表或者进Tempo按“最近慢Trace”排序找到那条耗时800ms的调用。在Tempo里选中那条Trace能看到完整的Span瀑布网关→订单服务→支付服务→数据库。如果发现“订单服务 调用支付服务”这一段耗时600ms那问题就定位到了下游支付服务或者网络链路。点Trace里的“查看日志”Grafana会把关联TraceId的所有日志从Loki里拉出来。日志里的Exception、HTTP状态码、请求参数全都在现场不需要再追着运维要服务器文件。整个过程几分钟内就能完成而且每一步都是可点击、可回溯的不再有“我怀疑是这个原因我再去翻下日志确认”这种撞运气式排查。4.4 为什么不选传统ELK简单说说选型思考我不是说ELK不好。如果你的团队已经在用Elasticsearch那完全可以把Serilog日志通过现有管道接进去没必要强行换。但对于一个从零搭建可观测性的.NET项目用LokiTempoVictoriaMetrics这套组合有几个非常现实的好处资源占用低Loki不做全文索引而是基于标签做索引日志原文用对象存储或本地磁盘存VictoriaMetrics比Prometheus内存占用更友好。三件套加起来在测试环境2到4GB内存能跑得很舒服ES动不动就让你准备16GB以上的堆。和Grafana原生队列无缝贴合这三个组件都是Grafana Labs或兼容其数据源的Grafana里配置数据源、做关联跳转都是一等公民体验。技术栈轻整套都是Go写成的单二进制组件部署运维心智负担低。当然如果你的查询场景是大量全文检索、需要复杂聚合分析那还是ES这套更适合你。可观测性基础设施没有银弹按团队规模和查询需求选就行。5. 实测中的坑与技巧这些细节文档里不会主动告诉你写到这基本框架已经通了。但真正让你在生产环境“翻车”的往往是各种边角料的坑。我把实际项目中踩过的、帮别人排查过的几个高频问题集中列一下希望你能跳过这些坎。5.1 日志打重了Serilog和默认ILogger双重输出我先说一个每个人第一次集成Serilog都会遇到的诡异现象控制台里每条日志出现了两遍。原因很简单builder.Host.UseSerilog()只让Serilog接管了日志的“输出管道”但默认的日志系统里还留着Console、Debug等provider没有移除。解决办法是在注册Serilog之前先把默认的日志提供者清掉builder.Logging.ClearProviders(); builder.Host.UseSerilog(...);ClearProviders()是必须的不清理就会出现两条一模一样的输出。而且不只是控制台文件也可能出双份因为默认的Fileprovider也会被注册进很多模板里。清理之后再让Serilog一家独大输出就干净了。5.2 TraceId神秘消失从三处排查有段时间线上日志里时不时出现没有TraceId的记录我排查了半天发现原因五花八门。归纳起来主要是这三种没装OpenTelemetry.Instrumentation.Serilog或没调Enrich.WithSpan()。这个好解决加上就行。写日志的代码不在请求上下文中。比如你在一个IHostedService后台任务里调用ILogger没有对应的HTTP请求自然没有Activity.CurrentTraceId为空是正常的。对这种情况可以手动开启一个Activity或者为后台任务单独设置静态ActivitySource的根Span。异步上下文丢失。有些代码用Task.Run()把一段逻辑扔到线程池里的线程上执行AsyncLocal不会自动带过去Activity.Current就变成了null。遇到这种情况要么用自定义Enricher在进入Task.Run前把当前TraceId存到上下文字段里要么重构这段代码尽量避免跨线程裸跑。针对第三种情况一个临时的缓解做法是自定义Enricherclass ActivityEnricher : ILogEventEnricher { public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { var activity Activity.Current; if (activity ! null) { logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(TraceId, activity.TraceId.ToString())); logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(SpanId, activity.SpanId.ToString())); } } }这也是一种思路但最省事的还是直接Enrich.WithSpan()它已经处理了大部分场景。5.3 采样导致“链路断了”ParentBased采样策略怎么设置你可能碰到过Tempo里能看到Trace的起点但很多中间的Span或日志凭空消失。这通常不是代码问题而是采样策略过于激进。OpenTelemetry SDK的默认采样是ParentBased(AlwaysOn)即父Span采样了子Span跟着采样父Span没被采样子Span通常会一并放弃。如果你在图省事配置了AlwaysOff或者一个固定的比例采样器比如10%那大部分请求的Trace都不会被采集。日志这边因为有Enrich.WithSpan()即使Trace没被导出日志里可能还是有TraceId的但Tempo里查不到对应Trace看起来就像链路断了一半。我的做法是生产环境开启固定比例的尾部采样如保留10%的慢请求和错误请求的Trace普通请求抽5%但日志始终全量结构化。错误和慢请求链路必须保留100%这是排障的基本盘。在Collector里配置tail sampling processor按status.code是否为ERROR来强制保留错误Span效果很好。5.4 文件滚动和磁盘策略三个参数值得反复检查Serilog.File的坑集中在文件名和滚动策略上。Sync写日志倒是不会丢但会导致主线程受IO影响性能风暴时应用整体变慢。实践下来我推荐两个措施生产环境把日志写成JSON格式文件名按我的4.2节配置并加上异步.WriteTo.Async(a a .File(logs/order-service-.log, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30, fileSizeLimitBytes: 1_073_741_824, rollOnFileSizeLimit: true))WriteTo.Async需要通过NuGet加Serilog.Sinks.Async包。它让日志写入走一个后台缓冲队列业务线程只管把日志事件丢进队列就返回文件IO在另一个线程做能明显降低高并发下日志IO对请求延迟的影响。注意缓冲队列大小要设一个上限默认10000防止日志爆发时内存飙升。另外还有一个Windows上的坑如果日志文件被另一个进程比如另一个实例、日志采集器占用打开Serilog滚动的时候可能报IO锁冲突。检查日志采集Agent的配置不要对正在写的当前文件做tail -f式的强锁定最好让采集器只读带滚动后缀的历史文件当前活跃文件让给应用写。5.5 性能与成本日志全量记录还是按需记录可观测性的反面是昂贵的数据量。如果你把所有Info日志全部送到Loki一个月下来存储成本可能比服务器还贵。我的经验是分三级级别日志内容是否落盘/送LokiDebug详细参数、中间变量、第三方请求体默认关闭调试环境才开Information关键业务事件、进出接口、状态流转全量落盘生产默认送Loki但保留周期短如7天Warning/Error异常、降级、超时、外部服务失败全量落盘长期保留30天以上Error要告警在Loki里你可以给不同级别的日志打不同的标签比如levelwarning然后对不同标签设置不同的保留过期时间。Grafana的Loki数据源是支持按标签做保留策略的别把日志不分青红皂白全丢进去。实际项目中我建议在代码里对高频路径的日志用LogEventLevel.Debug严格控制把默认级别设为Information。你不能指望所有开发都自觉最好在Serilog的配置里做强制覆盖.MinimumLevel.Override(Microsoft.AspNetCore, LogEventLevel.Warning) .MinimumLevel.Override(System.Net.Http.HttpClient, LogEventLevel.Warning)办法越强制后面运维越省心。最后分享一个我自己现在最常用的起步路径如果你接手的是一个已经上线的老项目不用急着把日志和可观测性全套改造一遍。我的建议是第一步先给项目的Serilog加上Enrich.WithSpan()让所有日志都带上TraceId第二步把常用的几个关键服务接上OpenTelemetry的Tracing把Trace数据送到Tempo第三步在Grafana里把Loki和Tempo关联起来做到日志里点TraceId就能跳链路。这三步每步都能独立生效、独立验证排障效率每走一步都有明显提升。等团队跑顺了这套流程再逐渐补齐Metrics和告警规则。可观测性不是一蹴而就的项目而是你和你的服务之间越来越清晰的一层“连通神经”。
返回列表