Elixir + gRPC : 通往生产之路

Tubi 免费向千万用户提供数以万计的电影和电视剧,我们乐享技术改变生活的理念。在过去的几年中,我们将 Elixir 和 gRPC 相结合,用来构建了生产环境中的诸多关键服务,为用户提供影片,图片等各类数据,是用户观影体验的核心部分。

gRPC 是一个起源于 Google 的高性能的 RPC 框架,可以广泛应用于各种语言,通过 Protobuf 定义接口并由 HTTP/2 传递编码后的数据,基于 gRPC,我们可以在团队之间维护稳定的接口规范。

Elixir 则是一种现代函数式语言,旨在构建高扩展易维护的应用程序,其建立于 Erlang 之上,基于 Actor 模型和 OTP 使我们能够仅用少数工程师就构建出低延迟高容错且易维护的系统。

作为 Elixir-gRPC 的主要作者,我感谢社区的所有贡献,并且很高兴看到我们将其用于打造生产环境中的服务,在这里简单分享一些有趣的经验教训。

性能

我们的一条 Protobuf 消息可能多达几十个字段,在一次请求中可能需要传递无数条这样的信息,这一过程中的编码速度是我们的 gRPC 请求变慢的原因之一。但是通过代码改进,我们最终将解码性能提高了 120%,编码性能提高了 30%。

我们不能在 Erlang/Elixir 这样的高级语言中灵活地操作二进制数据。所以我们的程序可能会因为不必要的内存分配而变慢,特别是在像 Protobuf 这样的项目中,包含了大量处理二进制数据的逻辑,但 Erlang 中的一些最佳实践可以帮我们来避免这种问题。

举个例子,如果我们想解析一个二进制文件并对每个字节的最后 7 位求和,在 Elixir 中有两种可能的选择:

# For binary 0000 0001 0100 0000 1000 1000
# The result is 0b1 + 0b100_0000 + 0b1000 = 73
defmodule BinaryParseFast do
  def parse(bin) do
    parse(bin, 0)
  end

  # A byte beginning with 0 means the end, but still need to add the last 7 bits
  def parse(<<0::1, x::7, _::bits>>, acc), do: acc + x
  # A byte beginning with 1 means there is more data needed to be processed.
  # and we need to add the last 7 bits
  def parse(<<1::1, x::7, rest::bits>>, acc) do
    parse(rest, acc + x)
  end
end
defmodule BinaryParseSlow do
  def parse(bin) do
    parse(bin, 0)
  end

  def parse(bin, acc) do
    case do_parse(bin) do
      {:nofin, x, rest} ->
        parse(rest, acc + x)
      {:fin, x} ->
        acc + x
    end
  end

  def do_parse(<<0::1, x::7, _::bits>>) do
    {:fin, x}
  end
  def do_parse(<<1::1, x::7, rest::bits>>) do
    {:nofin, x, rest}
  end
end

其中 BinaryParseFast 更快,因为 Erlang 在其中做了优化,避免了 Binary 的重复创建。正如这份测试所示,优化后的速度提升了足足一倍,其中,Erlang 提供了一个编译选项来提示我们潜在的问题。

export ERL_COMPILER_OPTIONS=bin_opt_info
$ mix run binary_parse_slow.exs
warning: BINARY CREATED: binary is used in a term that is returned from the function
  binary_parse_slow.exs:15
$ mix run binary_parse_fast.exs
warning: OPTIMIZED: match context reused
# This is good
  binary_parse_fast.exs:18

OPTIMIZED 的提示表示 Erlang 编译器将优化该代码,不然我们就应该自己尝试优化,这可以帮助我们改善 Protobuf-Elixir 的性能。

除此之外,Elixir-gRPC 现在增加了数据压缩的功能。虽然通常情况下 Protobuf 相比于 JSON 数据量更小,但如果当 Protobuf 消息中有很多字符串时,再次压缩往往有很好的效果。例如,我们可以通过 gzip 来压缩数据,从而提高性能并减少网络开销。

性能调优总能做得更好,现在仍有许多地方值得改进,比如 Protobuf 编码、HTTP/2 库优化等。即使我们提高了性能并做了测试,对于真实的使用情况,仍然有必要进一步测试,如果各位在这个过程中发现任何性能方面的问题,欢迎告诉我们。

稳定性

在没有被海量用户真正使用之前,很难真正判断一个系统的稳定性。但如果由于种种原因导致某些 Elixir 服务出现不可用的情况,将会极大影响我们的用户观影体验。

以下几个因素给了我们有力的保证:

  • Erlang/OTP 和 cowboy(一款在生产环境久经考验的 HTTP server)为我们提供了坚实的基础。
  • Elixir-gRPC 中 的 Interoperability tests 涵盖了 gRPC 实现应具有的所有功能:如大型响应、流式请求、错误等。
  • 我们使用了大量数据集对服务运行进行了模拟测试。
  • 现在我们已经有多个关键服务在生产环境中使用了 Elixr-gRPC

Envoy 和 拦截器(interceptors)

Envoy 常被作为服务的 sidecar, 它管理服务之间的通信并提供一些常用的功能,例如动态服务发现、负载平衡、重试等,因此我们不需要在每个服务中再去实现所有这些功能。Envoy 已经成为 Tubi 基础设施中的常用组件,进而可以大大减少我们的工作。

Envoy 有很多指标,比如请求率、响应时间,这些指标非常有用,但是仍然缺少一些详细的指标,例如每个 gRPC 方法的请求率和响应时间;所以我们通过使用拦截器(中间件)收集这些详细指标。Elixir-gRPC 有内置的拦截器,比如 statsd 拦截器和 prometheus 拦截器,当然你也可以编写自己的拦截器。比如我们有很多平台 FireTV、Web、iOS、Android 等,它们的性能和 QPS 是不一样的,所以我们写了一个拦截器来为指标添加平台标签。

特例的处理

尽管 Cowboy 和 Gun( HTTP 客户端)非常好用,但对 HTTP/2 的支持仍然是一项新功能,我们发现了一些极端情况并已修复,比如在部署服务时可能会出错。有些问题可以通过简单的重试来解决,但这样一来便会影响性能。

首先是流量控制。HTTP/2 添加了流量控制以允许应用程序控制对方发送数据的速度。两端的窗口控制着是否要将 DATA 帧(如 HTTP body )发送给对方,并且在发送 DATA 帧后需要发送 WINDOW_UPDATE 帧来更新对端的窗口。如果 WINDOW_UPDATE 帧没有正确发送,HTTP/2 通信就会卡住,这是一个大问题。

在 Cowboy 中,窗口的状态大部分时间都是正确的,但如果其中一个 stream 提前终止,则会导致错误。这将使得同一连接的其他 stream 无法再发送消息,然后这些 stream 会超时,只能创建新的连接才能解决。

上图描述了这个过程,客户端维护了 conn window 和 stream window, 以了解其可以向服务器发送多少字节。简单来讲,假设初始窗口是 20000,流量控制算法就是在收到数据后发送 WINDOW_UPDATE:

  1. 客户端在 stream1 中发送一个 10000 字节的 DATA 帧,然后窗口减少 10000。
  2. 服务端发回 WINDOW_UPDATE 帧,客户端的窗口增加 10000,一切正常。
  3. 客户端在 stream 3 中发送一个 20000 字节的 DATA 帧,然后窗口减为 0。
  4. 服务器在发送 WINDOW_UPDATE 之前关闭 stream。
  5. HTTP/2 规范要求节点应发送 WINDOW_UPDATE,即使在 stream 异常关闭的情况下也应如此,否则连接级窗口会出错,而 Cowboy 未能很好地处理这个情况。
  6. 由于上一步的错误,现在一个新的 stream 不能再发送数据了,因为 conn 窗口是 0。客户端会挂在这里。

另一个问题是 Gun 没有很好地处理 GOAWAY帧。GOAWAY 帧可用于优雅地关闭连接。但是由于 Gun 的问题,部署服务时可能会遇到一些错误。

在下图中,部署服务时,HTTP/2 服务器会发送一个 GOAWAY 帧,其中包含一个 stream 标识符,客户端收到后,将继续处理 stream ID 小于 GOAWAY 中 ID 的 stream(stream 1、3),但不会在该连接上创建新的 stream(stream 5)。

但是 Gun 返回了一个错误而不是继续处理现有的 stream,如下图所示。因此,客户端就会因为未完成 stream(stream 3)而报错。

 

结论

随着 gRPC 和 Elixir 的成功结合,我们将在未来使用 Elixir-gRPC 构建越来越多的服务。构建像 Elixir-gRPC 这样的项目在初始阶段并不是很难,但让它们在生产环境中稳定运行才是真正有趣和值得深度学习的过程。

随着 Tubi 业务快速增长,我们将面临越来越多的挑战,但我们也乐于解决这些问题,如果你也对此感兴趣,欢迎加入 Tubi !

作者:Tony Han,Elixir-gRPC & Elixir-Protobuf 作者,曾为 Tubi 高级后端开发工程师

译者:Longzhi Liu, Tubi Senior Software Engineer - Distributed Systems


猜你喜欢

转载自blog.csdn.net/weixin_49193714/article/details/128659174