Go Protobuf 新增了一套 Opaque API,通过生成不透明结构体和实现惰性解码,来减少消息体内存占用并提高性能。

本文翻译自 Go 官方博客,原文链接:https://go.dev/blog/protobuf-opaque

Michael Stapelberg

16 December 2024

[Protocol Buffers (Protobuf) 是 Google 的语言中立数据交换格式。请参阅 protobuf.dev.]

早在 2020 年 3 月,我们就发布了该google.golang.org/protobuf 模块,这是对 Go Protobuf API 的一次重大改进。该包引入了对反射的一流支持,一个dynamicpb 实现和更易于测试的 protocmp 包。

该版本引入了带有新 API 的新 protobuf 模块。今天,我们将为生成的代码(即 protoc 创建的 .pb.go 文件中的 Go 代码)发布一个额外的 API。本博文将解释我们创建新 API 的动机,并向你展示如何在你的项目中使用它。

明确一点:我们不会删除任何内容。我们将继续支持生成代码的现有 API,就像我们仍然支持旧的 protobuf 模块(通过封装 google.golang.org/protobuf 实现)一样。Go 致力于向后兼容,这也适用于 Go Protobuf!

背景:(现有的) Open Struct API

我们现在将现有的 API 称为 Open Struct API,因为生成的结构体类型可以直接访问(首字母大写)。在下一节中,我们将了解它与新的 Opaque API 有何不同。

要使用 protocol buffers ,首先要创建一个如下.proto 文件:

edition = "2023";  // 继承 proto2 和 proto3

package log;

message LogEntry {
  string backend_server = 1;
  uint32 request_size = 2;
  string ip_address = 3;
}

然后,运行协议编译器(protoc来生成如下代码(在.pb.go文件中):

译注:

将上面的内容保存至 logpb 目录下的 logpb.proto 文件, 使用 protoc 编译 。

版本信息:

protoc-gen-go v1.36.4

protoc v5.29.2

protoc --proto_path=logpb --go_out=logpb --go_opt=paths=source_relative logpb/logpb.proto

编译后可以得到类似下面的 Go 代码。

package logpb

type LogEntry struct {
  BackendServer *string
  RequestSize   *uint32
  IPAddress     *string
  // …internal fields elided…
}

func (l *LogEntry) GetBackendServer() string {  }
func (l *LogEntry) GetRequestSize() uint32   {  }
func (l *LogEntry) GetIPAddress() string     {  }

现在,你可以在 Go 代码中导入生成的 logpb 包,并调用诸如 proto.Marshal 之类的函数,以将 logpb.LogEntry 消息编码为 Protobuf 的字节流格式。

你可以在生成的代码 API 文档中找到更多详细信息。

(现有) Open Struct API:字段存在性

这样生成的代码有一个重要方面是如何对字段存在性(无论是否设置字段)进行建模。例如,上面的示例使用指针对存在性进行建模,因此你可以将 BackendServer 字段设置为:

  1. proto.String("zrh01.prod"):该字段已设置并包含“zrh01.prod”
  2. proto.String(""):该字段已设置(非nil指针)但包含空值
  3. nil指针:该字段未设置

如果你习惯于生成没有指针的代码,那么你可能正在使用以 syntax = "proto3" 开头的 .proto 文件。多年来,字段存在性行为发生了变化:

新的 Opaque API

我们创建了新的 Opaque API,以将生成的代码 API与底层内存表示分离。(现有的)Open Struct API 没有这样的分离:它允许程序直接访问 protobuf 消息内存。例如,可以使用该flag包将命令行标志值解析为 protobuf 消息字段:

var req logpb.LogEntry
flag.StringVar(&req.BackendServer, "backend", os.Getenv("HOST"), "…")
flag.Parse() // 通过 -backend 填充 BackendServer 字段

如此紧密耦合的问题在于,我们永远无法改变 protobuf 消息在内存中的布局方式。解除此限制可以实现许多实现改进,我们将在下面看到。

新的 Opaque API 有什么变化?以下是上述示例生成的代码的变化情况:

译注:

可以通过命令行设置default_api_level,也可以在文件指定。详见 https://protobuf.dev/reference/go/go-generated-opaque/

protoc --proto_path=logpb --go_out=logpb --go_opt=paths=source_relative --go_opt=default_api_level=API_OPAQUE logpb/logpb.proto

重新编译后将会得到类似下面的 Go 代码。

package logpb

type LogEntry struct {
  xxx_hidden_BackendServer *string // no longer exported
  xxx_hidden_RequestSize   uint32  // no longer exported
  xxx_hidden_IPAddress     *string // no longer exported
  // …internal fields elided…
}

func (l *LogEntry) GetBackendServer() string {  }
func (l *LogEntry) HasBackendServer() bool   {  }
func (l *LogEntry) SetBackendServer(string)  {  }
func (l *LogEntry) ClearBackendServer()      {  }
// …

使用 Opaque API,结构字段将被隐藏,无法再直接访问。相反,新的访问器方法允许获取、设置或清除字段。

不透明结构体使用更少的内存

我们对内存布局所做的一项更改是为了更有效地为基本字段建模字段存在:

  • (现有的) Open Struct API 使用指针,它在字段的空间成本中添加了一个 64 位字。
  • Opaque API 使用 bit 字段,每个字段需要一位(忽略填充开销)。

使用更少的变量和指针还可以降低分配器和垃圾收集器的负载。

性能改进在很大程度上取决于协议消息本身的字段:更改只会影响整数、布尔值、枚举和浮点数等基本字段,不会影响字符串、重复字段或子消息(因为这些类型的收益较低 )。

我们的基准测试结果表明,具有较少基本字段的消息表现出的性能与以前一样好,而具有较多基本字段的消息解码所需的分配明显较少:

             │ Open Struct API │             Opaque API             │
             │    allocs/op    │  allocs/op   vs base               │
Prod#1          360.3k ± 0%       360.3k ± 0%  +0.00% (p=0.002 n=6)
Search#1       1413.7k ± 0%       762.3k ± 0%  -46.08% (p=0.002 n=6)
Search#2        314.8k ± 0%       132.4k ± 0%  -57.95% (p=0.002 n=6)

减少分配还可以使解码 protobuf 消息更加高效:

             │ Open Struct API │             Opaque API            │
             │   user-sec/op   │ user-sec/op  vs base              │
Prod#1         55.55m ± 6%        55.28m ± 4%  ~ (p=0.180 n=6)
Search#1       324.3m ± 22%       292.0m ± 6%  -9.97% (p=0.015 n=6)
Search#2       67.53m ± 10%       45.04m ± 8%  -33.29% (p=0.002 n=6)

(所有测试均在 AMD Castle Peak Zen 2 上完成。ARM 和 Intel CPU 上的结果相似。)

注意:具有隐式存在的 proto3 同样不使用指针,因此如果您来自 proto3,则不会看到性能改进。如果您出于性能原因使用隐式存在,放弃了区分空字段和未设置字段的便利,那么 Opaque API 现在可以使用显式存在而不会影响性能。

动机:惰性解码

惰性解码是一种性能优化,其中子消息的内容在首次访问时解码,而不是在访问期间解码 proto.Unmarshal。惰性解码可以避免不必要地解码从未访问过的字段,从而提高性能。

(现有的)Open Struct API 无法安全地支持延迟解码。虽然 Open Struct API 提供了 getter,但将(未解码的)结构字段暴露在外极易出错。为了确保解码逻辑在字段首次被访问之前立即运行,我们必须将字段设为私有,并通过 getter 和 setter 函数调解对它的所有访问。

这种方法使得使用 Opaque API 实现惰性解码成为可能。当然,并非每个工作负载都能从这种优化中受益,但对于那些确实受益的工作负载,结果可能是惊人的:我们已经看到日志分析管道根据顶级消息条件(例如,backend_server其中一台机器是否运行新的 Linux 内核版本)丢弃消息,并且可以跳过解码深层嵌套的消息子树。

作为示例,以下是我们所包含的微基准测试的结果,展示了延迟解码如何节省超过 50% 的工作和超过 87% 的分配!

                  │   nolazy    │                lazy                │
                  │   sec/op    │   sec/op     vs base               │
Unmarshal/lazy-24   6.742µ ± 0%   2.816µ ± 0%  -58.23% (p=0.002 n=6)

                  │    nolazy    │                lazy                 │
                  │     B/op     │     B/op      vs base               │
Unmarshal/lazy-24   3.666Ki ± 0%   1.814Ki ± 0%  -50.51% (p=0.002 n=6)

                  │   nolazy    │               lazy                │
                  │  allocs/op  │ allocs/op   vs base               │
Unmarshal/lazy-24   64.000 ± 0%   8.000 ± 0%  -87.50% (p=0.002 n=6)

动机:减少指针比较错误

使用指针对字段存在进行建模会导致与指针相关的错误。

考虑在消息中声明的枚举 LogEntry

message LogEntry {
  enum DeviceType {
    DESKTOP = 0;
    MOBILE = 1;
    VR = 2;
  };
  DeviceType device_type = 1;
}

一个容易犯的错误是像下面这样比较枚举字段 device_type

if cv.DeviceType == logpb.LogEntry_DESKTOP.Enum() { // 不对!

你发现了错误吗?条件比较的是内存地址而不是值。由于Enum()访问器在每次调用时都会分配一个新变量,因此条件永远不会为真。检查应该如下所示:

if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {

新的 Opaque API 避免了这个错误:因为字段是隐藏的,所以所有访问都必须通过 getter 进行。

动机:减少意外共享错误

让我们考虑一个稍微复杂一点的与指针相关的错误。假设您正在尝试稳定在高负载下失败的 RPC 服务。请求中间件的以下部分看起来正确,但只要有一个客户发送大量请求,整个服务就会瘫痪:

logEntry.IPAddress = req.IPAddress
logEntry.BackendServer = proto.String(hostname)
// redactIP()函数将 IPAddress 编辑为 127.0.0.1,
// 出乎意料的是不仅修改了 logEntry 而且也修改了 req!
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
    // BUG: 所有请求都在这里结束,无论其来源如何
    return fmt.Errorf("server overloaded")
}

你发现错误了吗?第一行不小心复制了指针(从而在 logEntry 和 req 消息之间共享变量),而不是其值。 在这里应该读取其值。

logEntry.IPAddress = proto.String(req.GetIPAddress())

新的 Opaque API 可以避免这个问题,因为 setter 采用值(string)而不是指针:

logEntry.SetIPAddress(req.GetIPAddress())

动机:解决潜在问题:反射

为了编写不仅能处理特定消息类型(例如 logpb.LogEntry),而且能处理任何消息类型的代码,我们需要使用某种形式的反射。之前的示例中使用了一个函数来编辑 IP 地址。为了使其能够处理任何类型的消息,可以将其定义为func redactIP(proto.Message) proto.Message { … }

很多年前,实现类似redactIP函数的唯一选择是使用Go 的reflect,这导致了非常紧密的耦合:你只有生成器的输出,并不得不反向推导出输入的 protobuf 消息定义是什么样的。从 2020 年 3 月开始发布的 google.golang.org/protobuf 模块引入了 Protobuf 反射,这通常是更好的选择:Go 的 reflect 包遍历数据结构的表示,而这应该是实现细节。相比之下,Protobuf 反射遍历协议消息的逻辑树,而不考虑其表示。

不幸的是,仅仅提供protobuf 反射是不够的,并且仍然会暴露一些尖锐的问题:在某些情况下,用户可能会不小心使用了 Go 反射而不是 protobuf 反射。

例如,使用encoding/json包(使用 Go 反射)对 protobuf 消息进行编码在技术上是可行的,但输出结果不是规范的 Protobuf JSON 编码。应该使用 protojson 包来代替。

新的 Opaque API 可以避免这个问题,因为消息的结构体字段被隐藏了:如果意外地使用了 Go 反射,将会得到一个空消息。这足以引导开发者使用 protobuf 反射。

动机:实现理想的内存布局

更高效的内存表示部分的基准测试结果已经表明,protobuf 的性能很大程度上取决于具体用途:消息是如何定义的?设置了哪些字段?

为了让每个人都能尽可能快地使用 Go Protobuf ,我们不能实施只对一个程序有帮助而损害其他程序性能的优化。

Go 编译器曾经也处于类似的境地,直到Go 1.20 引入了基于配置文件的优化(PGO)。通过记录生产行为(通过配置文件)并将该配置文件反馈给编译器,我们允许编译器针对特定程序或工作负载做出更好的权衡。

我们认为使用配置文件来优化特定工作负载是进一步优化 Go Protobuf 的一种有前景的方法。Opaque API 使这些成为可能:程序代码使用访问器,当内存表示发生变化时不需要更新,因此我们可以将很少设置的字段移动到可导出的结构体中。

迁移

你可以按照自己的计划进行迁移,甚至可以不迁移 — 现有的 Open Struct API 不会被删除。但是,如果你不使用新的 Opaque API,你将无法从其改进的性能或针对它的未来优化中受益。

我们建议你在新开发时选择 Opaque API。Protobuf Edition 2024( 如果你还不熟悉,请参阅Protobuf 版本概述)将使 Opaque API 成为默认 API。

Hybrid API

除了 Open Struct API 和 Opaque API 之外,还有 Hybrid API,它通过保持结构体字段的可导出使现有代码正常运行,同时还通过添加新的访问器方法支持迁移到 Opaque API。

使用 Hybrid API,protobuf 编译器将在两个 API 级别上生成代码:.pb.go在 Hybrid API 上,而_protoopaque.pb.go版本在 Opaque API 上,可以通过使用 build 标签进行构建来选择protoopaque

使用 Opaque API 重写代码

请参阅迁移指南 以获取详细说明。大体步骤如下:

  1. 启用 Hybrid API。
  2. 使用open2opaque迁移工具更新现有代码。
  3. 切换到 Opaque API。

对已发布生成代码的建议:使用混合 API

小范围使用的 protobuf 可以完全保存在同一存储库中,但是通常,.proto 文件是在不同团队拥有的不同项目之间共享的。一个常见的例子是涉及到不同的公司时:比如要调用 Google API(使用 protobuf),请使用项目中的Google Cloud Client Libraries for Go。不能直接将 Cloud Client Libraries 切换到 Opaque API ,因为这将是一个重大的 API 更改,但切换到 Hybrid API 是安全的。 对于发布生成代码(.pb.go文件)的此类软件包,我们的建议是切换到 Hybrid API!请同时发布.pb.go_protoopaque.pb.go文件。该protoopaque版本允许你的消费者按照自己的时间表进行迁移。

启用惰性解码

一旦迁移到 Opaque API,就可以使用惰性解码(但尚未启用)!🎉

要启用惰性解码:在你的.proto文件中,为你的消息类型字段添加[lazy = true]注释 。

要关闭惰性解码(在.proto文件中有[lazy = true]注释的情况下),可以采用protolazy 包文档 描述的关闭方法,这会影响单个 Unmarshal 操作或整个程序。

下一步

过去几年,我们通过自动化方式使用 open2opaque 工具,将 Google 的绝大部分.proto文件和 Go 代码转换为 Opaque API。随着越来越多的生产工作负载转移到 Opaque API,我们不断改进 Opaque API 实现。

因此,我们希望你在尝试 Opaque API 时不会遇到问题。如果你确实遇到了任何问题,请在 Go Protobuf issues 上告诉我们。

可以在 protobuf.dev→Go参考上找到有关 Go Protobuf 的参考文档。


扫码关注微信公众号