深圳幻海软件技术有限公司 欢迎您!

如何利用OpenTelemetry识别数据库依赖关系?

2023-02-28

​译者| 布加迪审校| 孙淑娟随着组织将单体应用程序分解成微服务,遇到的主要障碍之一就是识别数据库依赖关系。数据库共享可能是复杂的挑战。数据库不允许您定义什么是共享的、什么不是。在修改模式以更好地服务于一个微服务时,您可能无意中破坏另一微服务使用这同一数据库的方式。此外,常常很难

​译者 | 布加迪

审校 | 孙淑娟

随着组织将单体应用程序分解成微服务,遇到的主要障碍之一就是识别数据库依赖关系。

数据库共享可能是复杂的挑战。数据库不允许您定义什么是共享的、什么不是。在修改模式以更好地服务于一个微服务时,您可能无意中破坏另一微服务使用这同一数据库的方式。

此外,常常很难识别数据所有者、确定处理数据的业务逻辑。

本文探讨如何使用OpenTelemetry来识别共享同一数据库和数据库对象(比如表)的组件。

可观察性和OpenTelemetry:基础

在构建演示应用程序之前,不妨先讨论可观察性和OpenTelemetry。

什么让应用程序高度可观察?

如果可以通过研究在任何时间点的输出来推断系统的内部状态,该系统就被称为高度可观察。

比如说,与多个服务交互的可观察移动应用程序可以重建生成错误响应的事务,以便开发人员识别失败的根本原因。

图1. 可观察应用程序示例

可观察应用程序为每个事务收集三种类型的信息:

  1. 日志:记录构成事务的各个事件。
  2. 指标:记录构成事务的群体事件。
  3. 跟踪:记录操作延迟,以识别事务中的瓶颈。

OpenTelemetry简介

OpenTelemetry 是一个以集成方式生成日志、指标和跟踪的系统。OpenTelemetry定义了一个标准来捕获可观察性数据。OpenTelemetry数据模型有几个关键部分。

属性

OpenTelemetry中的每个数据结构都由属性组成,属性是键值对。OpenTelemetry标准定义了任何组件(比如SQL客户端或HTTP请求)可以指定的属性。

活动

事件就是时间戳和一组属性。您可以记录事件的详细信息,比如消息和异常细节。

上下文

上下文包括一组事件共有的属性。上下文有两种:静态上下文(或资源)定义了事件的位置。在应用程序可执行文件启动后,它们的值不变,比如包括服务的名称或版本或者库名称。

动态上下文(或span)定义了包含事件的活动操作。span属性的值在操作执行时发生变化。一些常见的span属性包括请求的开始时间、HTTP响应状态代码或HTTP请求路径。

在分布式事务中,上下文需要传递给所有关联的服务。在这种情况下,接收方使用上下文生成新的span。跨越服务边界的跟踪称为分布式跟踪,将上下文传输到其他服务的过程名为上下文传播。

日志

日志是仅伴随资源的事件。一个例子是程序启动时发出的事件。

跟踪

事件可以组织成与资源相关的操作图。跟踪是显示与事务相关的事件的图形。

指标

一个事件可能在任何应用程序中发生多次,或者它的值可能会变。指标是一种事件,其值可以是相关事件的计数或事件值的某种计算。指标的一个例子是系统内存事件,它的属性是使用和利用率。

想详细了解OpenTelemetry的概念,请参阅文档:https://opentelemetry.lightstep.com/。

使用OpenTelemetry识别数据库依赖关系

我们之前讨论过,OpenTelemetry规定了应用程序各组件应捕获的属性。许多流行语言提供了开箱即用的工具库,以收集用于数据库操作的遥测数据。

本文演示使用面向OpenTelemetry的​​.NET SQLClient工具​​,以及用于遥测数据存储和分析的Lightstep。

不妨讨论演示应用程序的架构,以了解遥测数据到达Lightstep的路径。我们仅讨论跟踪,因为跟踪足以识别数据库和单体组件之间的依赖关系。

然而,任何企业应用程序都会生成相关的日志和指标以及跟踪以实现完整的可见性。

图2. 从.NET应用程序导出OTEL跟踪

首先,我们使用OpenTelemetry SDK检测单体应用程序,以发出可观察性信号。虽然检测应用程序是.NET应用程序的手动过程,但使用Golang或Java等语言构建的应用程序可使用自动检测。

我们使用SDK含有的OpenTelemetry Protocol(OTLP)Exporter。该导出工具让我们可以将数据直接发送到遥测数据摄取服务。Jaeger和Lightstep等OpenTelemetry平台聚合跟踪,帮助您获得洞察力。

与SDK集成后,应用程序的各个部分(比如ASP.NET Core请求处理程序和SQL客户端)会自动开始生成含有相关信息的跟踪。您的代码可以生成其他跟踪,以丰富可用信息。

以.NET为例,OpenTelemetry实现基于System.Diagnostics.*命名空间中的现有类型,如下所示:

  1. System.Diagnostics.ActivitySource代表负责生成Span的OpenTelemetry跟踪器。
  2. System.Diagnostics.Activity代表 Span。
  3. 您可以使用AddTag函数为span添加属性。此外,您可以使用AddBaggage功能添加行李。行李被运送到子活动,使用W3C标头的其他服务中有子活动。

检测应用程序后,您可以运行自动化测试,或允许用户使用您的应用程序来覆盖应用程序和数据库之间的所有交互路径。

演示

不妨创建一个简单的单体员工管理服务(EMS),以SP.NET Core minimal API为模型。我们的API将具有以下端点:

  1. POST /ems/billing:记录员工为项目所花的工时。
  2. GET /ems/billing/{employeeId}:获取员工为不同项目所花的工时。
  3. POST /ems/payroll/add:将员工添加到工资单上。
  4. GET /ems/payroll/{employeeId}:获取员工的工资单数据。

您会注意到单体应用程序服务于两个不同的领域:计费和工资单。这种依赖关系在复杂的单体应用程序中可能不是很明显,将它们分离开来可能需要大量的代码重构。

然而,如果研究依赖关系,您可以轻松地将它们分离开来。EMS应用程序的完整源代码可在该​​GitHub存储库​​中找到。

启动数据库

我们先在Docker中启动一个SQL server实例:

docker run \
-e "ACCEPT_EULA=Y" \
-e "SA_PASSWORD=Str0ngPa$$w0rd" \
-p 1433:1433 \
--name monolith-db \
--hostname sql1 \
-d mcr.microsoft.com/mssql/server:2019-latest
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

我们使用下列SQL脚本来创建我们的应用程序所使用的EMS数据库和表:

IF NOT EXISTS(SELECT * FROM sys.databases WHERE name = 'EMSDb')
BEGIN
  CREATE DATABASE EMSDb
END
GO

USE EMSDb

IF OBJECT_ID('[dbo].[Timekeeping]', 'U') IS NULL
BEGIN
  CREATE TABLE [Timekeeping] (
    [EmployeeId]      INT  NOT NULL,
    [ProjectId]       INT  NOT NULL,
    [WeekClosingDate] DATETIME NOT NULL,
    [HoursWorked]     INT  NOT NULL,
    CONSTRAINT [PK_Timekeeping] PRIMARY KEY CLUSTERED ([EmployeeId] ASC, [ProjectId] ASC,  [WeekClosingDate] ASC)
  )
END
GO

IF OBJECT_ID('[dbo].[Payroll]', 'U') IS NULL
BEGIN
  CREATE TABLE [Payroll] (
    [EmployeeId]   INT   NOT NULL,
    [PayRateInUSD] MONEY DEFAULT 0 NOT NULL,
    CONSTRAINT [PK_Payroll] PRIMARY KEY CLUSTERED ([EmployeeId] ASC)
  )
END
GO
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.

实现API服务

接下来,我们为API端点编写代码。我们把Program类中的样板代码换成以下代码:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped(_ =>
    new SqlConnection(builder.Configuration.GetConnectionString("EmployeeDbConnectionString")));
var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.MapPost("/ems/billing", async (Timekeeping timekeepingRecord, SqlConnection db) =>
    {
        await db.ExecuteAsync(
            "INSERT INTO Timekeeping Values(@EmployeeId, @ProjectId, @WeekClosingDate, @HoursWorked)",
            timekeepingRecord);
        return Results.Created($"/ems/billing/{timekeepingRecord.EmployeeId}", timekeepingRecord);
    })
    .WithName("RecordProjectWork")
    .Produces(StatusCodes.Status201Created);

app.MapGet("/ems/billing/{empId}/", async (int empId, SqlConnection db) =>
    {
        var result = await db.QueryAsync<Timekeeping>("SELECT * FROM Timekeeping WHERE EmployeeId=@empId", empId);
        return result.Any() ? Results.Ok(result) : Results.NotFound();
    })
    .WithName("GetBillingDetails")
    .Produces<IEnumerable<Timekeeping>>()
    .Produces(StatusCodes.Status404NotFound);

app.MapPost("/ems/payroll/add/", async (Payroll payrollRecord, SqlConnection db) =>
    {
        await db.ExecuteAsync(
            "INSERT INTO Payroll Values(@EmployeeId, @PayRateInUSD)", payrollRecord);
        return Results.Created($"/ems/payroll/{payrollRecord.EmployeeId}", payrollRecord);
    })
    .WithName("AddEmployeeToPayroll")
    .Produces(StatusCodes.Status201Created);

app.MapGet("/ems/payroll/{empId}", async (int empId, SqlConnection db) =>
    {
        var result = await db.QueryAsync<Payroll>("SELECT * FROM Payroll WHERE EmployeeId=@empId", empId);
        return result.Any() ? Results.Ok(result) : Results.NotFound();
    })
    .WithName("GetEmployeePayroll")
    .Produces<IEnumerable<Payroll>>()
    .Produces(StatusCodes.Status404NotFound);

app.Run();


public class Timekeeping
{
    public int EmployeeId { get; set; }
    public int ProjectId { get; set; }
    public DateTime WeekClosingDate { get; set; }
    public int HoursWorked { get; set; }
}

public class Payroll
{
    public int EmployeeId { get; set; }
    public decimal PayRateInUSD { get; set; }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.

此时,我们可以运行应用程序,测试各端点,并查看保存在数据库中的记录。虽然各端点和请求路径的数据库依赖关系在这个演示示例中很明显,但在大型应用程序中实际情况并非如此。

接下来,不妨使发现数据库依赖关系的过程实现自动化。

添加检测

我们使用OpenTelemetry SDK和面向.NET的SqlClient检测库来检测应用程序。我们先将以下NuGet包引用添加到API的项目文件中:

<PackageReference Include="OpenTelemetry" Version="1.2.0-rc2" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.2.0-rc2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.0.0-rc9" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.0.0-rc9" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.0.0-rc9" />
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.0.0-rc9" />
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

SDK为我们提供了几种扩展方法,我们可以使用这些方法将OpenTelemetry快速接入到请求处理管道。

以下代码在我们的API中检测OpenTelemetry。它还将检测SqlClient以发出详细的遥测数据。来自SqlClient的遥测数据是详细识别数据库依赖关系的关键。

// Configure tracing
builder.Services.AddOpenTelemetryTracing(builder => builder
    // Customize the traces gathered by the HTTP request handler
    .AddAspNetCoreInstrumentation(options =>
    {
        // Only capture the spans generated from the ems/* endpoints
        options.Filter = context => context.Request.Path.Value?.Contains("ems") ?? false;
        options.RecordException = true;
        // Add metadata for the request such as the HTTP method and response length
        options.Enrich = (activity, eventName, rawObject) =>
        {
            switch (eventName)
            {
                case "OnStartActivity":
                {
                    if (rawObject is not HttpRequest httpRequest)
                    {
                        return;
                    }

                    activity.SetTag("requestProtocol", httpRequest.Protocol);
                    activity.SetTag("requestMethod", httpRequest.Method);
                    break;
                }
                case "OnStopActivity":
                {
                    if (rawObject is HttpResponse httpResponse)
                    {
                        activity.SetTag("responseLength", httpResponse.ContentLength);
                    }

                    break;
                }
            }
        };
    })
    // Customize the telemetry generated by the SqlClient
    .AddSqlClientInstrumentation(options =>
    {
        options.EnableConnectionLevelAttributes = true;
        options.SetDbStatementForStoredProcedure = true;
        options.SetDbStatementForText = true;
        options.RecordException = true;
        options.Enrich = (activity, x, y) => activity.SetTag("db.type", "sql");
    })
    .AddSource("my-corp.ems.ems-api")
    // Create resources (key-value pairs) that describe your service such as service name and version
    .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("ems-api")
        .AddAttributes(new[] { new KeyValuePair<string, object>("service.version", "1.0.0.0") }))
    // Ensures that all activities are recorded and sent to exporter
    .SetSampler(new AlwaysOnSampler())
    // Exports spans to Lightstep
    .AddOtlpExporter(otlpOptions =>
    {
        otlpOptions.Endpoint = new 
Uri("https://ingest.lightstep.com:443/traces/otlp/v0.9");
        otlpOptions.Headers = $"lightstep-access-token={lsToken}";
        otlpOptions.Protocol = OtlpExportProtocol.HttpProtobuf;
    }));
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.

虽然检测在当前状态下对我们来说足够了,还是不妨添加相关跟踪,进一步丰富数据。

首先,我们定义跟踪器,应用程序的span将来自该跟踪器。

var activitySource = new ActivitySource("my-corp.ems.ems-api");
  • 1.

接下来,我们创建一个span,并添加相关细节、属性和事件:

app.MapPost("/ems/billing", async (Timekeeping timekeepingRecord, SqlConnection db) =>
    {
        using var activity = activitySource.StartActivity("Record project work", ActivityKind.Server);
        activity?.AddEvent(new ActivityEvent("Project billed"));
        activity?.SetTag(nameof(Timekeeping.EmployeeId), timekeepingRecord.EmployeeId);
        activity?.SetTag(nameof(Timekeeping.ProjectId), timekeepingRecord.ProjectId);
        activity?.SetTag(nameof(Timekeeping.WeekClosingDate), timekeepingRecord.WeekClosingDate);

        await db.ExecuteAsync(
            "INSERT INTO Timekeeping Values(@EmployeeId, @ProjectId, @WeekClosingDate, @HoursWorked)",
            timekeepingRecord);
        return Results.Created($"/ems/billing/{timekeepingRecord.EmployeeId}", timekeepingRecord);
    })
    .WithName("RecordProjectWork")
    .Produces(StatusCodes.Status201Created);
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

我们遵循同样的程序来检测剩余的端点。

连接到Lightstep

最后,我们需要一个API密钥将跟踪信息发送到Lightstep。我们先创建一个帐户。在账户的Project设置页面中,我们找到令牌(Token),它将充当API密钥。

图3. Lightstep中的API密钥

我们拷贝令牌,并将它粘贴到appsettings文件中。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "EmployeeDbConnectionString": "Server=localhost;Database=EMSDb;User Id=sa;Password=Str0ngPa$$w0rd;"
  },
  "LsToken": "<Lightstep token>"
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.

发送请求

我们的应用程序已准备就绪。我们启动应用程序,向每个端点发送一些请求。以下是我发送到/ems/billing端点的请求。该请求应在数据库的Timekeeping表中创建一条记录。

图4. 将请求发送到计费端点

这是我向/emp/payroll/add端点发出的另一个请求,用于将记录添加到Payroll表:

图5. 将请求发送到工资单端点

进入到Lightstep可观察性门户网站后,我们可以点击Operations选项卡,查看Lightstep从应用程序接收到的所有span。

图6. Lightstep中查看来自应用程序的span

我们点击/ems/payroll/add操作后,可以查看端到端跟踪。通过查看span,我们可以确定任何请求的操作顺序。点击span可显示其事件和属性,从中我们可以更深入地了解操作。

跟踪中可见的最后一个span是EMSDb,它是由我们检测的SQL客户端生成的。点击span可查看其属性和事件,如下所示:

图7. 工资单端点生成的span详细信息

我们可以从属性得到一些关键信息:

  1. 数据库名称
  2. 数据库操作中使用的SQL语句
  3. SQL语句的类型(文本或存储过程)
  4. 发出请求的服务的主机名

我们从/ems/billing操作的子span中找到了一组类似的详细信息。

图8. 计费端点生成的span的详细信息

如果梳理来自跟踪的信息,我们可以推断出以下内容:

  1. 入站操作(接收外部请求的操作)
  2. 完成请求的一系列活动,包括外部服务调用和数据库操作。
  3. 每个操作涉及的数据库操作。

总之,这些信息足以让我们规划服务和数据库的分离,并为微服务之间的通信建立联系。

结论

本文讨论了开发人员将单体应用程序转换成微服务时遇到的常见挑战之一。在所有问题中,拆分数据库是一项复杂的工作,因为访问数据库的任何服务都可以处理数据库。

通过使用OpenTelemetry,我们可以识别各组件之间以及组件与数据库之间的依赖关系。 了解依赖关系后,我们可以为自己的组件制定重构计划,规划它们作为独立的微服务应如何与时俱进。

原文标题:​​How to Use OpenTelemetry to Identify Database Dependencies​​​,作者:Rahul Rai​