使用 Apache Arrow 进行内存分析——探索 Apache Arrow Flight RPC

361 阅读30分钟

分布式系统一直让我感兴趣。分布式系统就像一个非常好的拼图;一旦你弄清楚所有的部分如何结合在一起以实现你的目标,便会感到极大的满足。如果你不熟悉这个术语,分布式系统简单来说就是将系统的各个组件分散在网络上的多台机器上的情况。其想法是将工作拆分开来,并在各个组件之间协调努力,以更有效地完成任务。一个很好的例子就是 Apache Spark。

分布式系统的目标通常是提供一个强大、可扩展且可靠的组件集合,通过在系统中分配工作来高效地执行操作。这通常意味着大量的数据在各个组件之间流动,以便进行处理、操作或其他操作。当涉及到 Apache Arrow 格式的数据时,Arrow 项目提供了一个远程过程调用 (RPC) 框架,用于创建高性能的服务,该服务接受和返回 Arrow 数据,称为 Arrow Flight。

在本章结束时,你将能够:

  • 理解 Arrow Flight 构建在什么技术之上,以及为什么选择这些技术
  • 理解 Flight 协议的基本结构以及使用时数据的流动方式
  • 创建一个简单的 Arrow Flight 服务器和客户端,以发送/请求数据
  • 理解什么是 Flight SQL,以及为什么开放数据库连接 (ODBC) 和 Java 数据库连接 (JDBC) 不足够

技术要求

在我们进行解释和定义后,我们将一起创建一个 Arrow Flight 服务器和客户端。你可能还想尝试复制一些性能测试的结果。为此,你需要以下内容:

  • 一台联网的计算机

  • 你选择的语言的常用工具:

    • Go 1.21+
    • 安装了 pyarrow 模块的 Python 3.x
  • 你喜欢的 IDE

  • 在你的机器上安装 Docker(可选);如果是在 Windows 上,则安装 Docker Desktop

gRPC的基础与复杂性

Arrow Flight 建立在两个基础上:

  1. Arrow IPC 格式(我们在第三章中已讨论)
  2. gRPC(grpc.io),一个开源的 RPC 框架,基于 Protocol Buffers (Protobuf) 构建,旨在提供高性能服务

既然我们已经讨论了 Arrow IPC 格式,接下来快速看看 gRPC 是什么以及如何使用它。为此,我们需要谈谈应用程序编程接口 (API)。

构建现代数据 API

在本书中我们已经多次使用“API”这个词,你们中的大多数人可能已经在概念上对 API 有一定的了解,但我们还是快速定义一下我们所讨论的内容。

虽然 API 这个术语自1960年代以来就存在,但其涵盖的范围已经大大扩大。最初,它仅用于描述面向最终用户的接口,当时称为应用程序,因此得名应用程序编程接口。随着时间的推移,它开始指代各种类型软件的接口,而不仅仅是最终用户应用程序。从本质上讲,API 是一个抽象契约,隐藏了底层实现,仅暴露与系统、库或其他内容交互所需的部分。

随着计算机网络的普及,工程师希望在本地机器上调用函数的同时,也能在其他地方的计算机上进行调用。RPC 这一术语通常被认为是在1980年代早期创造的,尽管这一概念在那时已经存在了相当长一段时间。RPC 简单来说就是调用一个在进程外执行的过程(函数)。RPC 框架允许调用而无需工程师编写所有远程交互的细节。图 7.1 显示了一个简单的 RPC 服务器和客户端的模型,其中请求<–>响应风格充当函数调用。

image.png

耶,历史!不过,回到2000年,超文本传输协议(HTTP)规范的主要作者之一,罗伊·菲尔丁(Roy Fielding),提出了“基于网络”的API概念,作为与传统“基于库”的API不同的一种接口。他创造了“表现层状态转移”(Representational State Transfer,REST)这个术语来描述这种接口。REST 迅速成为行业内构建基于网络的 API 的主流风格,至今仍是你在写作时最常遇到的服务 API 类型。符合 REST 定义约束的 API 通常被称为 RESTful API。与软件相关的几乎所有事物一样,关于 RESTful 风格的 API 是否优于 gRPC API 的问题持续存在争论。图 7.2 显示了一个 RESTful API 的模型,使用 HTTP 请求动词来传达语义。

image.png

答案显然是,具体情况而定。但重要的是要理解为什么 Arrow Flight 选择 gRPC 作为其框架。让我们看看 gRPC 与传统 RESTful API 的不同之处。

效率和流式处理的重要性

在 RESTful API 和 gRPC 的机械原理与使用之间,有几个显著的差异影响了 API 的开发与使用。这些差异解释了为什么为 Arrow Flight 选择了 gRPC:

消息格式

大多数 RESTful 服务在选择消息格式时,通常选择 JSON 或 XML。这些格式灵活、人类可读且平台无关,几乎每种编程语言都有用于读取和写入这两种格式的库。然而,这些格式非常冗长,如果试图在系统之间传输大量数据,消息的大小以及序列化和反序列化的成本将成为负担。

记住:我们并不是第一次提到 Protobuf!我们在第三章“格式与内存处理”中也简单讨论过它。

相较之下,gRPC 使用 Protobuf 作为消息格式。与 JSON 或 XML 一样,Protobuf 也是平台和语言无关的。作为二进制格式,Protobuf 牺牲了人类可读性,但大大压缩了消息的大小,从而生成更小的消息。Protobuf 是一种轻量级消息格式,由接口描述语言(IDL)和生成序列化与反序列化消息代码的编译器组成。与 JSON 或 XML 不同,Protobuf 的消息定义是严格类型的,因此可以专注于序列化和反序列化。这使得数据传输速度更快,因为需要通过网络发送的字节大幅减少。此外,作为二进制格式,通过 Protobuf 传递 Arrow 格式的数据更为简单。对于 JSON 或 XML,必须使用 base64 编码来确保安全,或者需要将 Arrow 数据作为完全独立的消息发送。

代码生成

假设你是一名开发者,手头有一个 API 规范,需要用它来请求所需的数据。框架的选择如何影响这个任务?

如果你请求的服务是 RESTful 服务,通常会使用外部工具(如 Swagger)生成所选语言的模板客户端代码。在拥有多种编程语言的工具、客户端和服务的多语言环境中,你不得不依赖这些外部工具,或者构建自己的工具来减少创建服务和客户端所需的模板代码。

使用 gRPC 创建服务器或客户端时,你使用的是同一个 protoc 编译器,该编译器用于生成 Protobuf 消息的代码。即使没有现成的 Arrow Flight 服务器或客户端供你的特定语言或环境使用,你也可以轻松使用协议规范生成绑定,并快速启动可用的代码。

网络使用和支持

这就复杂了。通常,大多数 REST API 是基于 HTTP/1.0 或 HTTP/1.1 协议构建的,而 gRPC 则通过使用 HTTP/2 协议提升了效率。尽管 HTTP 规范的 2.0 版本在 2015 年标准化并发布,超过 95% 的浏览器当前支持它,但在服务基础设施中,它的普及程度并没有达到预期。HTTP/2 有两个重要特性使得 gRPC 具有优势——多路复用和双向流连接。

在 HTTP 1.0 中,只能通过对服务器进行同时的单独请求来并行化请求。这导致两个问题——资源使用和头部阻塞问题。资源使用问题很明显;因为并行化需要多个同时请求,所以需要多个连接来执行。而头部阻塞问题发生在浏览器(或任何 HTTP 客户端)能够处理的最大并行请求数被用完,后续请求需要等待前面的请求完成后才能运行。在服务器端,当达到最大同时连接数时,后续请求也必须等待当前请求完成后才能被处理。HTTP/2 使用多路复用技术在同一连接上处理多个请求,从而解决了这个问题。通过多路复用,单个客户端可以仅使用一个连接发起多个并行请求。

HTTP/2 还引入了新的流机制,利用其提供的双向通信。因此,gRPC 提供了四种请求类型:

  • 单向请求:类似于传统的 HTTP 1.1,客户端发送一个请求,服务器返回一个响应。
  • 服务器端流:客户端发送一个请求,服务器返回一系列响应。发送最后一个响应后,服务器会发送状态消息,并可能会附带后续元数据,完成请求。
  • 客户端流:客户端向服务器发送一系列消息,服务器通常在接收到所有请求消息后,发送一个响应回客户端,同时附带状态消息和可能的后续元数据。
  • 双向流:服务器和客户端互相发送消息,顺序不特殊。客户端发起这个连接,并且也是结束连接的一方。

gRPC 解释完后,我们可以继续解释 Arrow Flight 协议。由于 gRPC 使用 Protobuf,因此这并不复杂;我们可以直接通过 .proto 文件中的定义进行讲解!

Arrow Flight 的构建模块

由于 Arrow Flight 的方法和消息传输格式都由 Protobuf 定义,支持 gRPC 和 Arrow 但不支持 Flight 的客户端仍然可以轻松与 Arrow Flight 服务器进行交互和通信。Apache Arrow 项目还为某些语言提供了具体的 Flight 实现。这些实现包含优化以避免开销,例如减少在使用 Protobuf 时过多的内存复制。因为 Protobuf 对象仅用于传递元数据,所以 Flight 仍然保持 Arrow IPC 协议的优点,实现零反序列化以确保快速数据传递。

以下是 Flight 服务器实现的基本请求类型:

  • Handshake:一个简单的请求,允许自定义身份验证逻辑,并可选地实现定义的会话令牌。
  • ListFlights:获取服务器上可用数据流的列表。
  • GetSchema:检索特定数据流的模式。
  • GetFlightInfo:检索获取特定数据集的“计划”,可能描述多个数据流的消费。该请求允许自定义序列化命令和元数据,例如服务器的特定应用参数。
  • PollFlightInfo:类似于 GetFlightInfo,启动查询并获取信息以轮询执行状态。该接口的目的是处理可能长时间运行的查询。GetFlightInfo 在请求完成前不会返回,而 PollFlightInfo 应该尽快返回,然后轮询状态。
  • DoGet:从服务器检索数据流。
  • DoPut:将数据流从客户端发送到服务器。
  • ListActions:检索可用的实现定义的动作类型列表。
  • DoAction:执行指定的实现定义动作并返回任何结果,例如一般的 RPC 函数调用。
  • DoExchange:在服务器和客户端之间打开一个双向流,用于发送和接收 Arrow 数据及元数据。这对于卸载计算特别有用。

使用基本请求,简单的 Flight 请求模式可能如下图 7.3 所示,可选择在前面调用 Handshake 方法:

image.png

Arrow Flight 的水平可扩展性

处理分布式数据库类系统在现代数据工作流中变得越来越常见。大多数这些分布式系统采用类似的架构模式:

  1. 请求首先通过协调节点进行路由,然后分发到工作节点。
  2. 协调节点收集来自工作节点的所有结果片段,并将其发送回客户端。

如果您试图访问一个非常大的数据集,数据最终会在不同节点之间多次传输,直到到达客户端。此外,您将受到协调节点的吞吐量限制,影响获取所有数据的速度。这就是为什么 GetFlightInfo 方法返回一组端点对象。每个端点对象包含一个位置和一个 Ticket 对象,描述发送 DoGet 请求以使用 Ticket 检索数据集的一部分的服务器。要获取整个数据集,您只需消费所有端点。

这种模式允许您可能拆分和委托服务中节点的职责。图 7.4 展示了这种服务的潜在架构:

image.png

除了拥有一个分布式服务器来委派数据服务的责任外,我们还可以拥有一个分布式客户端!例如,Spark 可以使用 Arrow Flight 从服务器请求数据,然后并行地从多个服务器节点消费结果到多个 Spark 工作节点。这在图 7.5 中显示:

image.png

使用 GetFlightInfo 返回一个端点列表以消费数据的另一种模式是处理地理分布的服务器。现代数据网络中,通常同一数据可能在多个位置可用,以便从世界不同地区或缓存层实现更低延迟的访问。在这种情况下,协调器可以返回多个位置的端点,以允许消费者选择最合适的位置。在图 7.6 中,服务器为客户端提供了美国地区和德国地区的位置,客户端选择其首选位置:

image.png

我们已经涵盖了数据传输部分。那么,任意的业务逻辑呢?大多数服务不仅仅是通过某些序列化请求进行简单的数据检索,通常还需要暴露除了数据获取之外的其他信息或功能。Arrow Flight 为此提供了 Action 消息,并为数据消息添加应用程序定义的元数据。

将业务逻辑添加到 Flight

也许客户端想设置某种会话特定的参数,或者他们希望请求某个特定数据集以便在内存中作为未来请求的缓存。无论哪种情况,都需要在序列化请求本身之上添加逻辑。使用 Arrow Flight,最简单的方式是通过 DoAction 方法来实现。该方法接受一个任意命名的操作和可选的二进制数据,您可以在实现服务器时以任何方式解释这些数据。这个数据可以是字符串、以某种方式序列化的数据,甚至是有趣图像的原始二进制数据——随您所愿。

注意
Flight 服务器并不要求实现任何操作,也不要求操作返回任何结果。这些都是应用程序定义的,可以根据需要进行自定义。只需确保您的 Flight 服务器通过 ListActions 方法公开它支持的操作;这只是良好的设计。

当从 Flight 服务器返回数据时,使用 FlightData 结构,其中有一个属性用于不透明的、应用程序定义的元数据。这意味着每个单独的数据消息,通常是一个完整的记录批次,可能包含您的应用程序可以定义格式的任意信息。这些数据由 Flight 对象公开,但在 Flight 实现中被忽略,因此可以根据您的需要在构建客户端或服务器时使用。当我们在后面的“使用 Flight”部分中进行示例和练习时,您将看到这一点的实际应用。

面对所有这些复杂性,您可能会想,为什么要使用 Flight,而不是自己构建基于 Arrow 的客户端和服务器。最终,答案是互操作性。通过使用 Flight,您可以利用现有的 Arrow 库和工具,而不必自己构建。不仅如此,您还不必为您的 API 构建自定义客户端!这使得您的服务能够与所有现有的 Arrow Flight 库和工具轻松互操作。使用 Arrow Flight,您可以构建一系列微服务,所有服务使用 Flight RPC 进行通信,并轻松与其他现有的 Flight 服务集成——本质上,享受使用标准化 API 和基础设施的所有好处。

通过使用 gRPC 作为底层技术,Flight 还继承了许多通过已在 gRPC 中实现的各种有用功能带来的好处,如加密、中间件和跟踪。我们将在接下来的部分讨论这些功能。

其他附加功能

开箱即用,gRPC 支持 TLS/OpenSSL 进行加密。因此,Flight 通过使用 gRPC 的加密实现,自动获得所有支持。由于 Handshake 函数被泛化为双向流功能,因此使用 Flight 实现自定义认证方案变得容易,大多数库提供可扩展的认证处理程序。对于简单情况,Flight 的 Protobuf 定义中已经包含了一个 BasicAuth 对象,可以实现简单的用户和密码认证,而不需要太多自定义开发。

您还可以使用现有的“拦截器”概念为 gRPC 中的传入和传出消息周围的自定义包装和逻辑添加“中间件”。一个常见用法是实现分布式追踪框架,例如 OpenTracing,已经如此普遍,以至于许多支持 gRPC 的语言已经有开源的拦截器来支持 OpenTracing 协议。同样,由于这是 gRPC 内置的功能,Flight 服务器和客户端也可以自动受益于此。

关于状态处理的思考

认证的正确处理非常棘手。尽管 Flight 的 Handshake 协议提供了一种实现自定义认证握手的简单方式,但利用基于头部的认证方法可能更具可扩展性。由于 gRPC 基于 HTTP/2,因此允许使用 HTTP 头部,如承载令牌和 cookie。这意味着所有当前无状态的基于头部的认证方法仍然可以与 Arrow Flight 一起使用。为什么选择无状态?因为它更具可扩展性!由于 Flight 服务器本身不需要在调用之间保留状态信息,因此任何请求都可以无障碍地访问服务的任何实例。对于状态可能重要或有用的情况,可以利用头部和中间件。例如,可以构建中间件,将本地状态转换为可以注入请求的头部。即使您使用 Handshake 方法进行认证,也要尽量确保生成的认证令牌可以以无状态的方式进行验证。

在开始一些示例和练习之前,让我们快速浏览一下 Flight 的 Protobuf 定义。了解构成 Flight 的对象和组件后,将更容易与其进行交互。

理解 Flight Protobuf 定义

让我们来看看 Arrow Flight 协议中的各种消息类型。为了提高可读性,我将使用伪代码,省略一些 Protobuf 的包装,以便更好地解释对象类型。如果需要,您可以访问 Arrow GitHub 仓库(github.com/apache/arro… .proto 文件:

对于执行握手,我们有 HandshakeRequest、HandshakeResponse 和 BasicAuth 对象。协议版本可以由应用程序定义,以便支持身份验证方案的渐进演变和向后兼容:

message HandshakeRequest {
    // 某个定义的协议版本
    uint64 protocol_version
    // 用于身份验证/握手的任意数据
    bytes payload
}
message HandshakeResponse {
    // 某个定义的协议版本
    uint64 protocol_version
    // 用于身份验证/握手的任意数据
    bytes payload
}
message BasicAuth {
    string username
    string password
}

请注意,请求和响应对象使用了一个任意的 payload 属性,它只是一个字节数组。由于 Handshake 方法是双向流方法,因此可以根据需要实现任何任意的身份验证方案。

对于 ListActions 和 DoAction 方法,我们有名为 Empty、Action、Result 和 ActionType 的对象。准确命名的 Empty 对象是 ListActions 方法中的占位参数,因为它当前不接受任何参数,因此没有属性:

message Empty {}
// 描述可用的 Action
message ActionType {
    string type
    string description
}
// 请求执行一个动作
message Action {
    string type
    // 用于使用此动作的任意字节
    bytes body
}
// 执行动作后的结果
message Result {
    bytes body
}

再次注意,调用动作使用的数据和从动作返回的数据都是任意的字节数组。使用的数据将由应用程序定义,允许实现所需的任何功能。

为了使用 ListFlights 和 GetFlightInfo 识别特定数据集,我们有两个其他对象类型——Criteria 和 FlightDescriptor。Criteria 对象允许通过应用程序定义的方式过滤数据集列表,而 FlightDescriptor 用于通过应用程序定义的字节块(CMD)或将路径拆分为字符串数组(PATH)来识别特定数据集:

// 用于 ListFlights 过滤返回的航班列表
message Criteria {
    // 服务指定的任意字节
    bytes expression
}
// 识别特定数据集的一种方式
message FlightDescriptor {
    enum DescriptorType {
        // Protobuf 模式,未使用
        UNKNOWN
        // 标识数据集的命名路径
        PATH
        // 任意命令字节
        CMD
    }
    DescriptorType type
    // 包含 CMD 或 PATH
    string[] path
}

描述数据集的部分还包括通过 GetSchema 方法检索这些数据集的模式。它返回一个 SchemaResult 对象,其中包含 IPC 模式消息的字节(请回顾第三章的格式和内存处理)。这里字节的预期格式应为可选的 4 字节 IPC_CONTINUATION_TOKEN 前缀、一个 4 字节整数描述消息的长度,然后是原始的 FlatBuffers 模式消息:

message SchemaResult {
  // 数据集的 IPC 形式的模式
  bytes schema
}

在调用 GetFlightInfo 方法时,返回的消息通过提供一个 FlightInfo 对象来解释如何检索数据。或者,PollFlightInfo 返回一个类似的 PollInfo 对象。在这两种情况下,要获取整个数据集,必须消费所有端点:

// 提供足够的信息以检索数据集
message FlightInfo {
    // 格式与 SchemaResult 中相同
    bytes schema
    // 与此信息相关的描述符
    FlightDescriptor flight_descriptor
    // 数据集的端点列表
    FlightEndpoint[] endpoint
    // 如果未知,值将为 -1
    int64 total_records
    int64 total_bytes
    // 表示端点是否与数据的顺序相同
    bool ordered
    // 应用定义的元数据
    bytes app_metadata
}
message PollInfo {
    // 当前可用的结果
    FlightInfo info
    // 下一次重试的描述符
    // 如果未设置,则查询已完成
    FlightDescriptor flight_descriptor
    // 查询进度。如果已知,则必须在 [0.0, 1.0] 之间
    optional double progress
    // 请求的过期时间
    Timestamp expiration_time
}

接下来是 FlightEndpoint 消息定义,包含 Location 和 Ticket,用于发送 DoGet 请求。位置列表用于表示冗余和/或负载均衡服务,例如地理位置端点。如果位置列表为空,则表示 Ticket 只能在生成它的服务处兑换:

message FlightEndpoint {
    // 用于检索流的令牌
    Ticket ticket
    Location[] location
    // 此流的过期时间
    Timestamp expiration_time
    // 应用定义的元数据
    bytes app_metadata
}
// 包含服务器的完整 URI
// 用于发送 DoGet 或 DoPut 请求
message Location {
    string uri
}
// 用于服务识别特定数据流的任意字节
message Ticket {
    bytes ticket
}

最后,我们有用于 DoGet、DoPut 和 DoExchange 方法的数据对象。使用的对象是 FlightData 和 PutResult:

// 从一系列批次中的流获取的单个 Arrow 数据批次
message FlightData {
    // 描述符,仅对 DoPut 相关
    FlightDescriptor flight_descriptor
    // 数据的 flatbuffer 消息头
    bytes data_header
    // 应用定义的任意元数据
    bytes app_metadata
    // Arrow 数据的原始体字节
    bytes data_body
}
message PutResult {
    // 任意应用定义的元数据
    bytes app_metadata
}

前面部分提到的消息对象都是定义 Flight RPC 接口的基本对象。虽然看起来很多,但这些基本对象使 Flight 对多种用途非常灵活。

使用 Flight,选择你的语言!

我们在本书中使用的三种语言:Python、C++ 和 Go,都提供了 Arrow Flight 服务器和客户端的实现。接下来我们将演示的例子是锻炼你在不同语言中的技能的好方法,同时可以看到这些语言及其实现接口之间的相似之处和差异。Arrow 项目利用了这种语言之间的互操作性,通过 Flight 运行不同语言库之间的自动集成测试,确保它们相互兼容。

那么,我们就直接开始吧,来点乐子!

构建一个 Python Flight 服务器

PyArrow 库的 flight 模块提供了 Flight 服务器的基本实现;只需重写你想在服务器上实现的 Flight RPC 方法的函数即可。让我们尝试一下,写一个可以运行的 Python 脚本:

首先,获取所需的导入:

import pyarrow as pa
from pyarrow import fs
import pyarrow.parquet as pq
import pyarrow.flight as flight

让我们实现 ListFlights 函数,并返回我们之前用于 Dataset API 练习的公共 ursa-labs-taxi-data S3 存储桶中的文件:

首先,我们定义一个类,并初始化我们的 S3FileSystem 对象:

class Server(flight.FlightServerBase):
    def __init__(self, *args, **kwargs):
        # 暂时转发任何参数
        super().__init__(*args, **kwargs)
        self._s3 = fs.S3FileSystem(region='us-east-2', anonymous=True)

接下来,我们开始 list_flights 方法,通过检查任何过滤文件的标准,然后从 S3 获取文件的详细信息:

    def list_flights(self, context, criteria):
        path = 'ursa-labs-taxi-data'
        if len(criteria) > 0:
            # 使用标准作为起始路径
            path += '/' + criteria.decode('utf8')
        flist = self._s3.get_file_info(fs.FileSelector(path, recursive=True))

然后,我们遍历文件列表,读取每个 Parquet 文件的元数据,并使用 Python 的 yield 关键字将 FlightInfo 对象迭代地流式传输给调用者:

        for finfo in flist:
            if finfo.type == fs.FileType.Directory:
                continue
            with self._s3.open_input_file(finfo.path) as f:
                data = pq.ParquetFile(f)
                yield flight.FlightInfo(
                    data.schema_arrow,  # arrow schema
                    flight.FlightDescriptor.for_path(finfo.path),
                    [],  # 不提供端点
                    data.metadata.num_rows,
                    -1
                )

你觉得怎么样?还不错吧?我强调了设置服务器的关键代码行——首先,从基本服务器实现继承并创建 __init__ 函数,调用基本实现的 __init__ 函数。基本服务器实现允许一些参数,比如服务的地址,默认为 localhost 和随机端口,以及身份验证处理程序、中间件和安全证书。这些都可以在官方文档中查看;现在我们只需转发参数即可。

库的基础架构处理 Protobuf 对象的序列化和反序列化,因此 list_flights 方法的 criteria 参数是 Python 字节对象的实例。因此,我们可以决定我们的协议将标准作为字符串路径,并将其解码为 UTF-8 字符串。在生产环境中,我们可能希望进行更多的错误检查。

我们使用之前相同的代码获取文件列表,并为每个文件返回一个 FlightInfo 对象。

到此,我们有了一个完全功能的 Flight 服务器;我们所需的就是创建一个 Server 类的实例并调用 serve 函数。我们可以轻松添加一些简单代码来构建客户端并测试我们的服务器:

if __name__ == '__main__':
    with Server() as server:
        client = flight.connect(('localhost', server.port))
        for f in client.list_flights(b'2009'):
            print(f.descriptor.path, f.total_records)

然后,我们可以运行这个文件,看看效果:

$ python server.py

让我们让服务器变得更有用一些;为这些文件实现一个 DoGet 方法以检索记录批次的流。从我们之前写的内容开始,我们只需添加一个新函数:

    def do_get(self, context, ticket):
        in_file = self._s3.open_input_file(ticket.ticket.decode('utf8'))
        pf = pq.ParquetFile(in_file, pre_buffer=True)
        def gen():
            try:
                for batch in pf.iter_batches():
                    yield batch
            finally:
                in_file.close()
                # 确保始终关闭文件
        return flight.GeneratorStream(pf.schema_arrow, gen())

你能搞明白这里发生了什么吗?并不难,对吧?记住,由于票据是任意字节数组,我们需要手动将其解码回字符串,因为我们决定只是使用路径作为票据。然后,我们打开 S3 中的文件,创建一个生成器来迭代记录批次,并在读取时将它们传递给流。我们本可以选择一次性读取整个文件,并使用 flight.RecordBatchStream 返回该表,但这将要求我们一次性将整个文件放入内存中。

另外,由于我们每次都完全从 S3 读取,我们限制了潜在的性能。对于这个非常简单的实现,S3 成为瓶颈,因此虽然这是一种有用的玩具,但它并不真正代表 Flight 的性能。在生产场景中,你会有各种缓存层,以便在每次读取时不必回到 S3。

为了验证其有效性,我们只需添加几行其他代码:

首先,修改之前的 list_flights 函数,将票据与 FlightInfo 对象一起返回:

...
        yield flight.FlightInfo(
            data.schema_arrow,  # arrow schema
            flight.FlightDescriptor.for_path(finfo.path),
            [flight.FlightEndpoint(finfo.path, [])],
            data.metadata.num_rows,
            -1
        )
...

然后,我们修改主函数,从列表中选择一个航班,并用 do_get 方法请求它:

        ...
        flights = list(client.list_flights(b'2009'))
        data = client.do_get(flights[0].endpoints[0].ticket)
        print(data.read_all())

在这种情况下,我们使用流读取器的 read_all 方法读取整个数据表。相反,我们可以通过在循环中调用 read_chunk 方法来迭代流,当你到达流的末尾时,这将引发 StopIteration 异常。或者,我们可以调用 to_reader 方法,将流转换为常规的 RecordBatchReader,以便将其传递给其他函数或工作流。这非常灵活。

如你所见,管理 Flight 客户端也并不困难。我们只需提供要连接的主机名和端口,然后就可以调用我们想要的方法。在继续下一部分之前,尝试玩玩其他实现或通过各种方法传递信息!尝试一下!现在,让我们继续下一种语言!

构建一个 Go Flight 服务器

我假设 Python 可能是本书读者中最常见和最熟悉的语言。在这一点明确后,我们可以开始创建一个 Arrow Flight 服务器,使用 Go 来实现。这对我而言非常重要,因为我对官方的 Apache Arrow Go 库贡献了不少代码。我们将创建与之前 Python 示例相同的功能。

设计骨架

为了处理与 S3 的交互,我在 GitHub 仓库的 utils 目录下提供了一个小包装对象。这段代码并不复杂,它只是简化了 AWS SDK 的使用,包装了 S3 客户端,以提供 Reader、Seeker 和 ReaderAt 接口。接下来,让我们整合所有的导入:

package main

import (
    ...
    "github.com/apache/arrow/go/v17/arrow"
    "github.com/apache/arrow/go/v17/arrow/flight"
    "github.com/apache/arrow/go/v17/arrow/memory"
    "github.com/apache/arrow/go/v17/arrow/ipc"
    "github.com/apache/arrow/go/v17/parquet/file"
    "github.com/apache/arrow/go/v17/parquet/pqarrow"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "github.com/PacktPublishing/In-Memory-Analytics-with-Apache-Arrow-Second-Edition/utils"
    ...
)

随着我们继续构建服务器,将会使用并添加更多的导入。如果您使用的是 IDE,例如 Visual Studio Code,它可以自动为您添加这些导入。不过,我会在需要添加之前未提及的导入时提醒您。现在,让我们创建服务器的骨架。我们需要一个结构体,它嵌入 flight.BaseFlightServer 对象并实现我们想提供的函数:

type server struct {
    flight.BaseFlightServer
    s3Client *s3.Client
    bucket   string
}

func NewServer() *server {
    return &server{
        s3Client: s3.New(s3.Options{Region: "us-east-2"}),
        bucket:   "ursa-labs-taxi-data",
    }
}

func (s *server) ListFlights(c *flight.Criteria, fs flight.FlightService_ListFlightsServer) error {
    ...
    return nil
}

func (s *server) DoGet(tkt *flight.Ticket, fs flight.FlightService_DoGetServer) error {
    ...
    return nil
}

我特别强调了嵌入的基础服务器、创建 S3 客户端的调用,以及 ListFlightsDoGet 函数的签名。目前,这些函数不执行任何操作,仅返回 nil。接下来,我们为新的服务器对象创建一个主函数以启动服务器并运行它:

func main() {
    srv := flight.NewServerWithMiddleware(nil)
    srv.Init("0.0.0.0:0")
    srv.RegisterFlightService(NewServer())
    go srv.Serve()
    defer srv.Shutdown()
    ...
}

在这里,我们指定了服务器运行的地址。在这个例子中,我们让服务器在随机端口上本地运行,通过传递 0 作为端口号。现在我们有一个可以运行的服务器,可以实现其操作的方法。

实现 ListFlights 方法

由于我们要复制 Python 服务器中使用的相同逻辑,因此我们首先实现 ListFlights 函数。我们将从 S3 获取 Parquet 文件的列表,然后为每个文件返回一个 FlightInfo 对象。如果用户传递了表达式参数,我们将其用作前缀,假设它是一个字符串,就像 Python 版本那样:

首先,从 S3 获取文件列表,使用我们拥有的 bucket 和前缀:

func (s *server) ListFlights(c *flight.Criteria, fs flight.FlightService_ListFlightsServer) error {
    var prefix string
    if len(c.Expression) > 0 {
        prefix = string(c.Expression)
    }
    list, err := s.s3Client.ListObjectsV2(
        fs.Context(),
        &s3.ListObjectsV2Input{
            Bucket: &s.bucket,
            Prefix: &prefix,
        })
    if err != nil {
        return err
    }
    for _, f := range list.Contents {
        if !strings.HasSuffix(*f.Key, ".parquet") {
            continue
        }
        // 处理文件!
    }
    return nil
}

这里的第一部分是调用 S3 库以获取我们想要的 bucket 中对象的列表,使用用户传递的前缀(如果没有,则为空字符串)。第二部分是我们将放置代码以读取文件的元数据并获取与 Parquet 文件等效的 Arrow 模式。

现在,让我们创建一个辅助函数,从 S3 文件名获取 FlightInfo。我们可以将这段代码直接放在内联,但设计得更好且更具可读性的方法是创建一个小的辅助函数。记住,我们需要根据 Flight RPC 协议将 FlightInfo 对象返回给调用者:

func (s *server) getFlightInfo(ctx context.Context, key string, filesize int64) (*flight.FlightInfo, error) {
    s3file, err := utils.NewS3File(ctx, s.s3Client, s.bucket, key, filesize)
    if err != nil {
        return nil, err
    }
    pr, err := file.NewParquetReader(s3file)
    if err != nil {
        return nil, err
    }
    defer pr.Close()
    sc, err := pqarrow.FromParquet(pr.MetaData().Schema, nil, nil)
    if err != nil {
        return nil, err
    }
    return &flight.FlightInfo{
        Schema: flight.SerializeSchema(sc, memory.DefaultAllocator),
        FlightDescriptor: &flight.FlightDescriptor{
            Type: flight.DescriptorPATH,
            Path: []string{key},
        },
        Endpoint: []*flight.FlightEndpoint{{
            Ticket: &flight.Ticket{Ticket: []byte(key)},
        }},
        TotalRecords: pr.NumRows(),
        TotalBytes:   -1,
    }, nil
}

让我们解析这段代码: 首先,我们使用 S3 文件工具创建一个读取器,以通过服务器的 S3 客户端读取 S3 中的 Parquet 文件。 接下来,我们将该读取器传递给 Parquet 库以打开文件,并使用 defer 确保在完成后关闭它。 最后,我们将 Parquet 模式转换为等效的 Arrow 模式,以便构建 FlightInfo 对象。

现在,我们可以将对辅助函数的调用插入到 ListFlights 函数中,以将对象作为流发送回去:

...
for _, f := range list.Contents {
    if !strings.HasSuffix(*f.Key, ".parquet") {
        continue
    }
    info, err := s.getFlightInfo(fs.Context(), *f.Key, f.Size)
    if err != nil {
        return err
    }
    if err := fs.Send(info); err != nil {
        return err
    }
}
return nil

这段代码是新增的部分,用于调用我们的辅助函数。现在,我们可以创建一个客户端并进行测试: 在主函数中,我们可以构造 FlightClient 并调用我们刚实现的 ListFlights

func main() {
    ...
    client, err := flight.NewClientWithMiddleware(
        srv.Addr().String(), nil, nil,
        grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        panic(err) // 处理错误
    }
    defer client.Close()
    infoStream, err := client.ListFlights(
        context.TODO(),
        &flight.Criteria{Expression: []byte("2009")})
    if err != nil {
        panic(err) // 处理错误
    }
    for {
        info, err := infoStream.Recv()
        if err != nil {
            if err == io.EOF {
                break // 到达流的末尾
            }
            panic(err) // 出现错误!
        }
        fmt.Println(info.GetFlightDescriptor().GetPath())
    }
}

让我们解析这段代码: 我们可以通过查询我们创建的服务器对象来获取连接所需的字符串地址,因为我们没有给它指定具体的端口号,而是让它自己选择。这不是 gRPC 特有的,而是因为使用端口 0 在大多数操作系统中具有特殊意义,表示应搜索并动态使用一个可用的端口号。 我们不使用身份验证处理程序或任何中间件,因此只需传递一个 gRPC 选项以指示不使用 SSL,即可连接。构造客户端时可以传递任何所需的 gRPC 选项。 当我们调用 ListFlights 时,它返回一个流,我们可以使用该流检索每个 FlightInfo 对象。 最后的代码段显示,我们在循环中反复调用 Recv 方法,直到收到 io.EOF,表示我们到达了流的末尾。 这并不复杂,对吧?非常简单!实现了 ListFlights 方法并更新了主函数以调用和处理响应后,我们可以继续获取实际数据。

实现 DoGet 方法

现在,让我们实现 DoGet 函数,该函数将返回 Parquet 文件的记录批次数据,并让客户端读取这些数据。

您已经知道如何打开文件并获取 Parquet 文件的读取器,因此现在我们只需要以 Arrow 格式从中获取数据。我们创建一个 pqarrow.FileReader,它将提供直接将 Parquet 数据读取为 Arrow 记录的方法,然后从中获取 RecordReader

func (s *Server) DoGet(tkt *flight.Ticket, fs flight.FlightService_DoGetServer) error {
    path := string(tkt.Ticket)
    // 像在 ListFlights 中那样打开文件
    // pr 将是文件读取器的名称
    arrowRdr, err := pqarrow.NewFileReader(pr,
        pqarrow.ArrowReadProperties{
            Parallel: true, BatchSize: 100000,
        }, memory.DefaultAllocator)
    if err != nil {
        return err
    }
    rr, err := arrowRdr.GetRecordReader(fs.Context(),
        nil,nil)
    if err != nil {
        return err
    }
    defer rr.Release()

在创建文件读取器时,我们传递一组属性以控制数据的读取方式。我们告诉它并行读取列,并指定一个每个记录批次的行批次大小,读取每个记录批次 100,000 行。创建 RecordReader 允许您根据需要仅获取列或行组的子集,但由于我们想要所有内容,因此可以将默认值设置为 nil

现在,我们有了从 Parquet 文件读取记录批次的方法,我们需要一些东西来将这些记录批次写入客户端。我们可以创建 RecordWriter,然后通过添加导入来将所有记录从读取器复制到写入器:

...
    // 添加这些导入
    "fmt"
    "github.com/apache/arrow/go/v17/arrow/arrio"
...

DoGet 函数内,我们只需添加以下内容:

    wr := flight.NewRecordWriter(fs,
                 ipc.WithSchema(rr.Schema()))
    defer wr.Close()
    n, err := arrio.Copy(wr, rr)
    fmt.Println("wrote", n, "record batches")
    return err
}

现在,我们只需要让客户端调用 DoGet 函数并收集记录批次。将以下内容添加到主函数中:

stream, err := client.DoGet(context.TODO(),
    &flight.Ticket{Ticket:[]byte(
        "2009/01/data.parquet")})
if err != nil {
    panic(err) // 处理错误
}
rr, err := flight.NewRecordReader(stream)
if err != nil {
    panic(err)
}
defer rr.Release()
records := make([]arrow.Record, 0)
for rr.Next() {
    rec := rr.Record()
    rec.Retain()
    defer rec.Release()
    records = append(records, rec)
}
fmt.Println("received", len(records),
    "record batches")
tbl := array.NewTableFromRecords(records[0].Schema(),
    records)
defer tbl.Release()
fmt.Println("total rows:", tbl.NumRows())

让我们解析这段代码: 首先,我们在客户端调用 DoGet 函数。如果服务器端的批次大小过大,可能会试图发送过大的消息。如果发生这种情况,您可以在函数调用中添加 gRPC 选项,将最大大小设置为 10 兆字节:grpc.MaxRecvMsgSizeCallOption{MaxRecvMsgSize: 1024 * 1024 * 10}

接下来,我们从流中构造 RecordReader,以便读取传入的记录批次。不要忘记添加 defer 以释放它!

然后,我们可以创建一个切片来保存到达的记录。当您从读取器获取到 Record 的引用时,如果希望它在调用 Next 之后继续存在,必须调用 Retain。否则,读取器在每次新记录到达时会释放内存。

最后,为了方便起见,我们根据记录构造一个 Table,这样可以将整个响应视为一个大表。如果您愿意,可以在记录到达时处理结果,而不是将整个表保持在内存中。

到此为止!我在 GitHub 仓库中有完成的 C++ 版本代码示例,但在查看解决方案之前,您应该先尝试自己编写!这里有一些您可以尝试的练习:

  1. 使用其中一种语言搭建 Flight 服务器,然后用另一种语言的客户端向其发起请求。例如,使用我们在 Go 中编写的服务器,并使用 Python Flight 客户端请求数据,或反之亦然。
  2. 不从 S3 中获取数据,而是从本地 Parquet 文件返回数据,看看速度有多快。也许可以使用 CSV、JSON 或 ORC 文件而不是 Parquet,或者提供某种方式来请求数据的子集。
  3. 实现某种机制以缓存 S3 中的部分 Parquet 文件,以便后续对 DoGet 的调用无需每次都从 S3 获取数据。

到目前为止,我们只看到了使用 Flight 从原始文件获取数据的示例,但还有很多其他功能可以使用。我们将更详细地探讨使用 Arrow Flight 通过 SQL 请求数据的内容,提供一种可以替代 ODBC 和 JDBC 的方案。

什么是 Flight SQL?

在这一章中,我们已经讨论了很多关于 Arrow Flight 的内容,并学习了如何创建一个简单的 Flight 客户端和服务器。但 Arrow Flight 是一个用于构建 RPC 协议的框架。那么,可以用它构建什么样的协议呢?比如说,一个与数据库交互的通用协议。大多数与数据库交互的系统会利用 ODBC 或 JDBC 协议,这些协议已经存在很长时间了。让我们快速回顾一下它们的基本概念。ODBC 和 JDBC 分别在 1992 年和 1997 年创建,目的是帮助数据库公开一个通用的 API。通过创建一个所有数据库供应商都可以实现驱动程序的通用抽象层,应用开发人员可以简单地编写代码,使用这个通用接口与数据库交互,而无需为他们想使用的所有不同数据库软件创建自定义对象。这些技术迅速成为企业界的事实标准,随后也在开源界流行开来。

这些技术仍然是事实标准的问题在于,它们是在一个非常不同的计算时代构建的。那时数据远比现在小,系统更为单一而非分布式,CPU 也没有几十个核心可供利用。这让我们回想起第一章中关于列导向数据与行导向数据的讨论。ODBC 强制要求数据采用行导向结构,而 Flight SQL 则允许我们利用关于 Arrow 数据的列导向特性所带来的性能和内存使用优势。结果是,ODBC 和 JDBC 在日益扩大的大数据世界以及当今数据科学家或数据分析师的需求面前,无法很好地扩展。

如果您不相信这一点,我们可以进行实证。我们将 Flight 和 ODBC 进行直接比较,看看哪个胜出。

设置性能测试

为了进行公平的比较,我们需要一个同时提供 ODBC 接口和 Arrow Flight 接口的工具。在这个练习中,我们将使用一个名为 Dremio Sonar 的开源湖屋查询引擎。Dremio Sonar 也提供 Flight SQL 接口,但在本次练习中,我们只会使用 Arrow Flight API。我之前在不同的上下文中提到过几次,Dremio 是 Arrow 的创始者之一,并在捐赠给 Apache 基金会之前创建了 Arrow Flight 和 Arrow Flight SQL。在设置性能测试之前,我们需要通过 Docker 启动一个开发环境。

每个人都能获得一个容器化的开发环境!

我们可以使用 Docker 启动一个一致且有用的开发环境,而无需自己在本地安装 Dremio Sonar。无需手动处理依赖项,只需一个易于共享的镜像名称,即可复制示例。哦,Docker,我们有多爱你?让我们来数一数:

  • Docker Hub 社区提供了易于测试和使用的预打包开发环境容器。
  • 我们不必纠结于如何正确安装 Spark 及其所有依赖项;只需使用现成的镜像。
  • 我们可以确保任何想要跟随示例的读者都能轻松设置我展示的环境,只需参考特定的 Docker 镜像。
  • 你可以在 Docker 内部运行 Docker,所以你可以在使用 Docker 时再次使用 Docker……算了。

如果你还没安装 Docker,请确保在开发机器上安装它。对于 Windows,我发现 Docker Desktop 是设置的最简单方法,而且是免费的(有一些限制)。大多数 Linux 包管理器也提供 Docker 安装。

一旦安装了 Docker,我们可以按照以下说明安装 Dremio Sonar ODBC 驱动程序:

  1. 访问 Dremio ODBC 驱动下载页面,下载适合你操作系统的包。

  2. 对于 Windows,请参阅这里的说明:Dremio 驱动下载

  3. 对于 Mac,请参阅这里的说明:Dremio 驱动下载

  4. 对于 Linux,首先确保安装你发行版的 unixODBC 包。然后,如果你使用 CentOS 或 Red Hat(或任何使用 yum 包管理器的发行版),可以按照这些说明操作:Dremio 驱动下载

  5. 对于其他发行版,可以安装一个名为 rpm2cpio 的工具,它会解压你从 Dremio 下载的压缩 rpm 包。下载包后,运行以下命令:

    $ rpm2cpio dremio-odbc-<version>.x86_64.rpm | cpio -idum
    

这将把驱动程序解压到本地目录,创建一个名为 opt 的子目录。解压文件夹的 opt/dremio-odbc/conf/ 目录中包含 ODBC 配置文件。

安装完 ODBC 驱动后,让我们启动 Docker 镜像。使用 Docker 启动 Dremio Sonar 的命令如下:

$ docker run -p 9047:9047 -p 31010:31010 -p 32010:32010 -e DREMIO_JAVA_EXTRA_OPTS="-Ddebug.addDefaultUser=true" dremio/dremio-oss:latest

让我们解释一下选项:

  • -p 选项定义了 Docker 应该转发到运行镜像的端口——在这种情况下,用于 UI、ODBC 连接和 Flight 连接。
  • -e 选项设置一个环境变量,作为启动 Dremio Sonar 时使用的默认用户,而不必自己添加。这个默认用户的用户名为 dremio,密码为 dremio123。

在 Docker 镜像启动后,等待几分钟,看输出在屏幕上滚动。当你看到输出中的这一行时,就说明它已经启动并运行:

Dremio Daemon Started as master

此时,你可以打开浏览器,访问以下地址:https://localhost:9047。如果一切顺利,你应该会看到登录界面,如图 7.7 所示:

image.png

使用默认的用户名和密码,分别为 dremio 和 dremio123 登录。对于我们的数据练习,我们将使用 Dremio Sonar 提供的一个示例数据源。在屏幕左侧,找到“添加示例数据源”按钮,如图 7.8 中所示:

image.png

这将会在“数据湖”标题下添加一个名为“Samples”的文件夹,当你点击它时,会有一个名为“samples.dremio.com”的文件夹。你需要进入标记为“NYC-taxi-trips”的文件夹。为了方便,你可以直接使用这个链接:https://localhost:9047/source/Samples/folder/samples.dremio.com/NYC-taxi-trips

现在你应该看到一组 Parquet 文件。我们将通过点击右上角的“转换文件夹”按钮,将这个文件夹转换为 Dremio Sonar 视为一个单一大型表,如图 7.9 所示:

image.png

点击按钮后,会弹出一个窗口;请在右下角点击“保存”按钮。最终,你将看到一个 SQL 编辑器窗口和数据(见图 7.10):

image.png

此时,我们已经准备好进行测试了!让我们开始吧!

运行性能测试

为了简化操作,我们将使用 Python,这样可以方便地进行小规模、易于重复的示例,并进行计时。在开始之前,您需要安装 pyodbc Python 模块。和之前的示例一样,您可以使用 pip 来完成这一步:

$ pip install pyodbc

注意
在 Linux 机器上,pyodbc 会编译所需的 C 扩展。请确保您的计算机上安装了 unixODBC 开发包以及 C++ 编译器。

现在,我们可以像之前一样启动 IPython,这样可以使用魔法命令 %timeit。首先,让我们打开我们的 ODBC 连接:

In [1]: import pyodbc
In [2]: cnxn = pyodbc.connect('DSN={};Host={};Port={};ConnectionType={};AuthenticationType={};UID=dremio;PWD=dremio123'.format ('Dremio Connector', 'localhost', '31010', 'Direct', 'Plain'), autocommit=True)

请注意我们使用的数据源名称(DSN)。Dremio Sonar 的 Windows ODBC 驱动程序安装时的名称为 Dremio Connector,而在 Linux 上,则安装为 Dremio ODBC 64-bit。请确保检查您安装驱动程序时的配置说明,以确保您使用的是正确的名称。其余参数很简单:

  • 它在本地运行,所以主机是 localhost
  • ODBC 的标准端口是 31010
  • 我们使用的是直接连接,用户名和密码分别为 dremio 和 dremio123

只要没有错误,您现在就可以使用 ODBC 连接了。让我们使用 %timeit 发出请求,获取 1 百万行数据,看看需要多长时间:

In [3]: %timeit data = cnxn.execute('SELECT * FROM Samples."samples.dremio.com"."NYC-taxi-trips" LIMIT 1000000').fetchall()

在我的笔记本上,我们看到使用 ODBC 转移 100 万行数据大约需要 3.85 秒。这是一个相当不错的时间,但有点慢。让我们尝试用 Flight 做同样的事情。由于 Dremio Sonar 使用身份验证,我们还需要为我们的 Flight 客户端获取身份验证,这在之前没有做过。不过这很简单,让我们创建客户端并进行身份验证:

In [4]: import pyarrow.flight as flight
In [5]: client = flight.FlightClient('grpc+tcp://localhost:32010')
In [6]: token = client.authenticate_basic_token('dremio', 'dremio123')
In [7]: options = flight.FlightCallOptions(headers=[token])

Dremio Sonar 默认使用基本身份验证,这意味着在我们进行身份验证后,会收到一个授权令牌。我们将其作为标头添加到后续的所有调用中,表明我们已通过身份验证,就这样!现在,我们可以请求我们的 100 万行数据并进行计时:

In [8]: %%timeit
   ...: info = client.get_flight_info(flight.FlightDescriptor
   ...: .for_command('SELECT * FROM Samples."samples.dremio.com".
   ...: "NYC-taxi-trips" LIMIT 1000000'), options)
   ...: reader = client.do_get(info.endpoints[0].ticket, options)
   ...: b = reader.read_all()

596 毫秒 ± 39.4 毫秒每循环(平均 ± 标准偏差为 7 次运行,1 次循环)
哇,差异显著,对吧?3.85 秒对比 596 毫秒?如果我们将行数增加到 500 万行呢?记得多次运行,以便消除 S3 的慢速影响,并确保 Dremio Sonar 是从其缓存中提供服务。当我使用 500 万条记录运行相同的测试时,Flight 用时约 1.84 秒,而 ODBC 则为 17.1 秒。当运行使用 10 亿条记录的测试时,Arrow Flight 用时 3.3 分钟,而 ODBC 用时 150 分钟,使得 Arrow Flight 在该规模上比 ODBC 快 45 倍(见 www.dremio.com/blog/is-tim…

您可以尝试不同数量的记录,但要注意,这些测试会将整个结果集保留在内存中,而不是抓取行并丢弃它们。如果请求的行数过多,Python 可能会耗尽内存!看看您是否可以修改这些测试,以便只获取行并丢弃它们,这样就可以尝试任意数量的行。我们使用的示例数据集稍多于 3 亿行,但如果您将提取的行数提高到该数字,您可能会发现与 S3 的通信会成为瓶颈,而不是 ODBC 与 Flight 之间的比较。

好吧,既然我们已经建立了 Arrow Flight 相对于 ODBC 的性能优势,Flight SQL 到底是什么呢?嗯,我很高兴你问了……

Flight SQL,新的“宠儿”

ODBC 标准中存在大量不同的操作,不仅仅限于执行查询和获取数据行。它包括关于数据库元数据的请求,例如:

  • 可用的数据库表
  • 数据库和表的模式及目录
  • 数据库使用的 SQL 风格
  • 在执行过程中被数据库视为“关键字”或保留的词汇
  • 数据库在执行期间可用的 SQL 函数
  • 数据库支持的 SQL 特性

由于 Arrow Flight 是一种通用框架,这些内容并没有在其中标准化。在 Flight SQL 之前,SQL 查询的执行也没有标准化;任何使用 Flight 的服务都有不同的方式来传达查询。通过可用的元数据和功能添加这些内容并不困难,但标准化用于发现这些元数据的协议是有益的。如果不支持这些类型的元数据和信息提取,Flight 就很难在所有用例中真正取代 ODBC 和 JDBC。

为了促进这一想法,Dremio 提出了 Flight 的新扩展(后来被 Arrow 社区接受)——Flight SQL。Flight SQL 提供了一系列预定义的对象、消息和类型,以支持通过 ODBC 可用的相同类型的元数据和命令信息。需要注意的是,Flight SQL 是一个使用 Flight 协议管理 SQL 查询和元数据的框架,而 ODBC 是一个有许多实现的 API,这些实现都使用不同的协议。

另一个提议是创建一个通用适配器,允许 ODBC 驱动程序或消费者使用 Flight SQL 作为其基础实现,同时仍提供完整的 ODBC 接口。这将允许现有的应用程序和工作流程从 ODBC 迁移到 Flight SQL,而无需进行大量的工程工作。它将使传统软件获得 Arrow Flight 的性能和分布式优势,而无需重写!不过,这并不会为这类软件提供所有 Flight 的好处。

尽管可以获得一些好处,但某些 ODBC API 不支持的特性(例如流式传输)将无法使用。还有一个小缺点是,它确实会产生将 Arrow 数据序列化为 ODBC 原语的成本,但总体而言,这仍然比 ODBC 更具性能优势。

我在这里不打算详细讨论 Flight SQL 的实现细节;重点是解释其存在的原因及背后的思考。目前,我们开始看到 Arrow Flight SQL 被 Dremio Sonar 之外的各种产品采纳,例如 InfluxDB,甚至作为 Grafana 等产品与 Flight SQL 兼容服务器交互的客户端协议。

总结

如果您需要将数据从一个地方传输到另一个地方,您应该考虑 Arrow Flight 作为服务器的一个可能解决方案。即使不利用其提供的并行性,它仍然显示出比大多数当前传输表格数据的方式有显著改进。由于它利用了 Arrow 的 IPC 格式,因此避免了序列化和反序列化的成本。虽然它目前建立在 gRPC 和 HTTP/2 之上,但有许多提案可以使其更轻松地支持可能受到关注的其他网络配置。

在本章中,如果您跟随进行,您已经创建了一些简单的 Flight 服务器和客户端。让它们作为模板来使用。基于这些模板进行构建。为它们添加更多功能。探索 Flight 提供和支持的特性与能力。如果您在数据科学领域的工作多于开发领域,请尝试使用 Flight 作为客户端从 Dremio Sonar 等引擎中获取数据。关键在于实验!Arrow Flight 是一个工具箱;它是一个非常精致的工具箱,拥有许多附加功能,但仍然是一个工具箱。您需要弄清楚如何最好地利用它满足您的需求,而这前提是对它的功能进行实验。

如果您对 Flight SQL 在数据库通信中的作用尚未完全信服,那么请查看下一章!虽然我们已经讨论了一些关于 ODBC 和 JDBC 的内容,但下一章将介绍 Arrow 数据库连接 (ADBC)!