一、为什么我们需要追踪和关联日志?

想象一下,你正在管理一个在线商城。用户点击“下单”按钮后,这个简单的请求会像接力赛一样,跑过好几个服务:先到用户服务验证身份,再到商品服务检查库存,接着去订单服务创建记录,最后调用支付服务。如果某个环节出了问题,比如支付失败了,传统的排查方式就像在几个独立的房间里找线索。每个服务都有自己的日志文件,你需要在用户服务、订单服务、支付服务的日志里分别翻找属于这个用户这次请求的记录,再把它们像拼图一样拼起来。这个过程非常耗时,而且容易出错。

链路追踪和分布式日志关联,就是为了解决这个“找拼图”的难题。它给每一个进入系统的用户请求都发一个独一无二的“身份证”(TraceId),这个请求无论走到哪个服务,都会带着这个身份证。同时,在这个请求链条中产生的每一个子步骤,也会有自己的“子身份证”(SpanId),标明它和父步骤的关系。最后,所有带着相同“身份证”的日志,都会被收集到一个中心仓库。这样,当出现问题,你只需要知道这个“身份证号”,就能一下子把这个请求在所有服务中的完整旅程和所有日志都找出来,一目了然。

二、核心概念与工作原理

在动手之前,我们先花点时间理解几个关键“零件”,这会让后面的搭建更轻松。

首先是最重要的“身份证”——TraceId。它是一个全局唯一的字符串,代表一个完整的业务请求链路。只要请求没结束,这个TraceId就会一直传递下去。

然后是SpanId,它代表链路中的一个环节,比如调用一个数据库查询,或者请求另一个服务。一个Trace包含多个Span,它们之间有父子关系,形成一棵“调用树”。这能让我们清楚地看到服务A调用了服务B,服务B又调用了服务C这样的依赖关系。

为了传递这些信息,我们需要一种“信封”,这就是上下文(Context)传递。通常,我们会把TraceId和当前SpanId等信息放在HTTP请求头里,这样当下一个服务收到请求时,就能从请求头里取出这些信息,知道自己是谁、从哪来。

最后,我们需要一个“集散中心”来收集和展示这些带标记的日志,这就是可观测性后端。它负责存储海量日志,并能让我们通过TraceId快速检索。这就像是一个超级图书馆,不仅存了所有书(日志),还做了一个完美的索引(TraceId)。

三、手把手实现:从零搭建可观测系统

接下来,我们用一个简单的例子来演示如何实现。假设我们有两个服务:一个订单服务(OrderService)和一个库存服务(InventoryService)。订单服务在创建订单前,需要调用库存服务来扣减库存。

技术栈声明:本文所有示例均使用 .NET 6 + Serilog + OpenTelemetry + Jaeger + Elastic Stack 技术栈。

步骤1:基础设施准备(使用Docker快速启动)

我们首先使用Docker把需要的后台服务跑起来。你需要安装好Docker Desktop。

# 创建一个 docker-compose.yml 文件
version: '3.8'
services:
  # Jaeger: 用于收集、查看链路追踪数据
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # Jaeger UI 界面
      - "14268:14268"   # 接收 OpenTelemetry 数据的端口
    environment:
      - COLLECTOR_OTLP_ENABLED=true

  # Elasticsearch: 存储日志和追踪数据
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"

  # Kibana: 可视化查看 Elasticsearch 中的数据
  kibana:
    image: docker.elastic.co/kibana/kibana:8.10.2
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch

在终端中,进入存放这个文件的目录,运行 docker-compose up -d,等待所有服务启动完成。

步骤2:创建并配置订单服务(OrderService)

  1. 新建一个ASP.NET Core Web API项目。
  2. 通过NuGet安装必要的包:
    Install-Package Serilog.AspNetCore
    Install-Package Serilog.Sinks.Elasticsearch
    Install-Package OpenTelemetry
    Install-Package OpenTelemetry.Extensions.Hosting
    Install-Package OpenTelemetry.Instrumentation.AspNetCore
    Install-Package OpenTelemetry.Instrumentation.Http
    Install-Package OpenTelemetry.Exporter.Jaeger
    
  3. 修改 Program.cs 文件:
// OrderService - Program.cs
using OpenTelemetry;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;

var builder = WebApplication.CreateBuilder(args);

// 1. 配置 Serilog 日志,同时输出到控制台和 Elasticsearch
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .Enrich.FromLogContext() // 关键!允许从上下文中丰富日志属性
    .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {TraceId} - {Message:lj}{NewLine}{Exception}")
    .WriteTo.Elasticsearch(new Serilog.Sinks.Elasticsearch.ElasticsearchSinkOptions(new Uri("http://localhost:9200"))
    {
        AutoRegisterTemplate = true,
        IndexFormat = "order-service-logs-{0:yyyy.MM.dd}" // 按天创建索引
    })
    .CreateLogger();

builder.Host.UseSerilog(); // 使用 Serilog 替代默认日志

// 2. 配置 OpenTelemetry 链路追踪
builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource.AddService("OrderService")) // 设置服务名
    .WithTracing(tracing =>
    {
        tracing.AddAspNetCoreInstrumentation(options =>
        {
            // 为收到的HTTP请求自动创建Span
            options.EnrichWithHttpRequest = (activity, request) =>
            {
                activity.SetTag("http.client_ip", request.HttpContext.Connection.RemoteIpAddress?.ToString());
            };
        })
        .AddHttpClientInstrumentation() // 为发出的HTTP请求自动创建Span
        .AddSource("OrderService.ActivitySource") // 添加我们自定义的Activity源
        .SetSampler(new AlwaysOnSampler()) // 总是采样,生产环境可配置概率采样
        .AddJaegerExporter(jaegerOptions =>
        {
            jaegerOptions.AgentHost = "localhost";
            jaegerOptions.AgentPort = 6831; // Jaeger Agent UDP端口
            // 也可以使用 HTTP 方式,对应 docker-compose 中暴露的 14268 端口
            // jaegerOptions.Endpoint = new Uri("http://localhost:14268/api/traces");
        });
    });

builder.Services.AddHttpClient(); // 注册 HttpClientFactory,用于调用库存服务
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
  1. 创建订单控制器,并模拟调用库存服务:
// OrderService - Controllers/OrderController.cs
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
    private readonly IHttpClientFactory _httpClientFactory;
    private static readonly ActivitySource ActivitySource = new("OrderService.ActivitySource"); // 自定义Activity源

    public OrderController(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    [HttpPost]
    public async Task<IActionResult> CreateOrder([FromBody] OrderRequest request)
    {
        // 使用 Serilog 的 LogContext 来关联日志。这里面的属性会被自动添加到该作用域内的所有日志中。
        using (Serilog.Context.LogContext.PushProperty("TraceId", Activity.Current?.TraceId.ToString()))
        using (Serilog.Context.LogContext.PushProperty("SpanId", Activity.Current?.SpanId.ToString()))
        using (Serilog.Context.LogContext.PushProperty("UserId", request.UserId)) // 添加业务属性
        {
            // 记录一条信息日志,此时会自动包含 TraceId, SpanId, UserId
            Log.Information("开始创建订单,商品ID:{ProductId}, 数量:{Quantity}", request.ProductId, request.Quantity);

            // 1. 创建一个代表“创建订单”这个业务的Span
            using var activity = ActivitySource.StartActivity("CreateOrder", ActivityKind.Internal);
            activity?.SetTag("order.product_id", request.ProductId);
            activity?.SetTag("order.quantity", request.Quantity);

            // 模拟业务逻辑...
            await Task.Delay(100);

            // 2. 调用库存服务
            try
            {
                var inventoryClient = _httpClientFactory.CreateClient();
                // OpenTelemetry 的 HttpClientInstrumentation 会自动将当前Trace上下文注入到请求头中
                var response = await inventoryClient.PostAsJsonAsync("http://localhost:5001/api/inventory/reduce", new
                {
                    ProductId = request.ProductId,
                    ReduceQuantity = request.Quantity
                });
                response.EnsureSuccessStatusCode();
                Log.Information("库存扣减成功");
            }
            catch (Exception ex)
            {
                Log.Error(ex, "调用库存服务失败");
                activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
                return BadRequest("库存操作失败");
            }

            // 模拟创建订单记录
            await Task.Delay(50);
            Log.Information("订单创建成功,订单号:{OrderId}", Guid.NewGuid());
            activity?.SetTag("order.id", Guid.NewGuid().ToString());

            return Ok(new { Message = "订单创建成功" });
        }
    }
}

public class OrderRequest
{
    public int UserId { get; set; }
    public string ProductId { get; set; }
    public int Quantity { get; set; }
}

步骤3:创建并配置库存服务(InventoryService)

库存服务的配置与订单服务高度相似,主要区别在于服务名和业务逻辑。

  1. 新建另一个ASP.NET Core Web API项目(端口设为5001)。
  2. 安装相同的NuGet包。
  3. 修改 Program.cs,基本与订单服务一致,只需修改服务名:
    .ConfigureResource(resource => resource.AddService("InventoryService")) // 注意修改服务名
    
  4. 创建库存控制器:
// InventoryService - Controllers/InventoryController.cs
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class InventoryController : ControllerBase
{
    private static readonly Dictionary<string, int> MockInventory = new()
    {
        {"P001", 100},
        {"P002", 50}
    };

    [HttpPost("reduce")]
    public IActionResult ReduceStock([FromBody] ReduceRequest request)
    {
        // 同样使用 LogContext 关联日志
        using (Serilog.Context.LogContext.PushProperty("TraceId", System.Diagnostics.Activity.Current?.TraceId.ToString()))
        using (Serilog.Context.LogContext.PushProperty("SpanId", System.Diagnostics.Activity.Current?.SpanId.ToString()))
        {
            Log.Information("收到扣减库存请求,商品:{ProductId}, 数量:{ReduceQuantity}", request.ProductId, request.ReduceQuantity);

            if (!MockInventory.ContainsKey(request.ProductId))
            {
                Log.Warning("商品不存在:{ProductId}", request.ProductId);
                return NotFound("商品不存在");
            }

            if (MockInventory[request.ProductId] < request.RediseQuantity)
            {
                Log.Warning("库存不足。商品:{ProductId}, 当前:{Current}, 请求:{Requested}",
                    request.ProductId, MockInventory[request.ProductId], request.ReduceQuantity);
                return BadRequest("库存不足");
            }

            // 模拟扣减
            MockInventory[request.ProductId] -= request.ReduceQuantity;
            Log.Information("库存扣减完成。商品:{ProductId}, 剩余:{Remaining}",
                request.ProductId, MockInventory[request.ProductId]);

            return Ok(new { Message = "库存扣减成功" });
        }
    }
}

public class ReduceRequest
{
    public string ProductId { get; set; }
    public int ReduceQuantity { get; set; }
}

四、运行与效果验证

现在,启动你的订单服务(默认在5000端口)和库存服务(在5001端口)。

  1. 发起一次请求:使用Postman或curl向 http://localhost:5000/api/order 发送一个POST请求,Body为 {"UserId": 123, "ProductId": "P001", "Quantity": 2}
  2. 查看链路追踪(Jaeger):打开浏览器,访问 http://localhost:16686。在Service下拉框中选择“OrderService”,点击“Find Traces”。你会看到刚才的请求链路,点击进去可以看到完整的调用树,清晰地显示了OrderService调用了InventoryService,以及每个Span的耗时和标签。
  3. 查看关联日志(Kibana):打开 http://localhost:5601。首次进入需要配置索引模式,输入 order-service-logs-*inventory-service-logs-* 创建索引模式。然后进入“Discover”页面,选择对应的索引模式。在搜索框输入 TraceId:"你的TraceId"(可以从Jaeger界面或控制台日志中复制),点击搜索。你会看到来自两个服务的、拥有相同TraceId的所有日志条目按照时间顺序排列在一起,完整重现了这次请求的每一步。

五、深入分析与最佳实践

应用场景:

  • 故障快速定位:线上支付失败,通过支付单号关联的TraceId,瞬间定位是网络问题、风控拦截还是银行接口异常。
  • 性能瓶颈分析:发现某个接口变慢,通过链路追踪查看调用树,立即发现是某个数据库查询或下游服务响应变慢导致。
  • 系统依赖梳理:可视化地看到所有微服务之间的调用关系,对于理解架构和做容量规划非常有帮助。
  • 业务日志审计:追踪一个用户从登录、浏览、下单到支付的完整行为轨迹。

技术优缺点:

  • 优点
    • 排查效率革命:将跨服务的问题定位从“小时级”降至“分钟级”。
    • 可视化程度高:调用链、耗时、依赖关系一目了然。
    • 对代码侵入性较低:通过AOP(面向切面编程)和中间件,大部分工作是自动的。
    • 标准统一:OpenTelemetry已成为云原生可观测性的事实标准,生态丰富。
  • 缺点/挑战
    • 性能开销:采样、日志序列化与传输会带来额外的CPU、内存和网络开销,需合理配置采样率。
    • 存储成本:全量日志和追踪数据量巨大,需要精心设计保留策略和索引生命周期管理(ILM)。
    • 复杂度:需要维护一整套后端系统(如Elasticsearch, Jaeger)。
    • 上下文传递:对于非HTTP协议(如gRPC、消息队列)需要额外处理上下文传播。

注意事项:

  1. 采样策略:在生产环境,务必不要使用AlwaysOnSampler。应根据流量采用头部采样(如1%)或尾部采样(只采样错误和慢请求),以平衡开销与效用。
  2. 日志级别与内容:避免在链路中记录敏感信息(如密码、身份证号)。使用结构化日志,方便后续检索和分析。
  3. 依赖服务稳定性:确保可观测性后端(如Elasticsearch、Jaeger Collector)本身高可用,避免因其故障导致应用雪崩或日志丢失。
  4. 客户端兼容性:确保所有下游服务(特别是不同语言编写的)都能正确理解和传播Trace上下文头(通常是traceparent)。

文章总结: 为.NET Core应用引入链路追踪和分布式日志关联,就像给一个复杂的分布式系统装上了“全景行车记录仪”和“智能检索系统”。它通过赋予每个请求全局唯一的TraceId,并利用OpenTelemetry标准自动传播上下文、收集数据,再借助Serilog等工具将TraceId注入日志,最终在Jaeger、Kibana等平台上实现日志与链路的完美关联与可视化。虽然引入它会带来一定的复杂度和运维成本,但对于提升现代微服务系统的可观测性、运维效率和团队排障能力来说,这项投资是绝对必要且回报丰厚的。从今天介绍的简单示例开始,你可以逐步将其扩展到更复杂的生产环境中,让你的系统从此“脉络清晰,有迹可循”。