Go 大小语义

解释如何(不)使用 proto.Size

proto.Size 函数通过遍历 proto.Message 的所有字段(包括子消息)来返回其有线格式编码的字节大小。

特别是,它返回 Go Protobuf 将如何编码消息的大小。

关于 Protobuf Editions 的说明

使用 Protobuf Editions,.proto 文件可以启用改变序列化行为的特性。这可能会影响 proto.Size 返回的值。例如,设置 features.field_presence = IMPLICIT 将导致设置为默认值的标量字段不被序列化,因此不计入消息的大小。

典型用法

识别空消息

检查 proto.Size 是否返回 0 是检查空消息的常用方法

if proto.Size(m) == 0 {
    // No fields set; skip processing this message,
    // or return an error, or similar.
}

限制程序输出大小

假设您正在编写一个批处理管道,该管道为另一个系统(在此示例中称为“下游系统”)生成工作任务。下游系统配置为处理中小型任务,但负载测试表明,当遇到超过 500 MB 的工作任务时,系统会发生级联故障。

最好的解决方案是为下游系统添加保护(参见 https://cloud.google.com/blog/products/gcp/using-load-shedding-to-survive-a-success-disaster-cre-life-lessons),但当实现负载削减不可行时,您可以决定在管道中添加一个快速修复

func (*beamFn) ProcessElement(key string, value []byte, emit func(proto.Message)) {
  task := produceWorkTask(value)
  if proto.Size(task) > 500 * 1024 * 1024 {
    // Skip every work task over 500 MB to not overwhelm
    // the brittle downstream system.
    return
  }
  emit(task)
}

不正确用法:与 Unmarshal 无关

因为 proto.Size 返回 Go Protobuf 将如何编码消息的字节数,所以在解组(解码)传入的 Protobuf 消息流时使用 proto.Size 是不安全的

func bytesToSubscriptionList(data []byte) ([]*vpb.EventSubscription, error) {
    subList := []*vpb.EventSubscription{}
    for len(data) > 0 {
        subscription := &vpb.EventSubscription{}
        if err := proto.Unmarshal(data, subscription); err != nil {
            return nil, err
        }
        subList = append(subList, subscription)
        data = data[:len(data)-proto.Size(subscription)]
    }
    return subList, nil
}

data 包含 非最小有线格式 的消息时,proto.Size 可能会返回与实际解组大小不同的值,从而导致解析错误(最好情况)或最坏情况下的错误解析数据。

因此,此示例只有在所有输入消息都由(相同版本的)Go Protobuf 生成时才能可靠工作。这令人惊讶且可能并非预期。

提示: 请改用 protodelim 来读/写带大小分隔的 Protobuf 消息流。

高级用法:预设缓冲区大小

proto.Size 的高级用法是在编码前确定缓冲区所需的大小

opts := proto.MarshalOptions{
    // Possibly avoid an extra proto.Size in Marshal itself (see docs):
    UseCachedSize: true,
}
// DO NOT SUBMIT without implementing this Optimization opportunity:
// instead of allocating, grab a sufficiently-sized buffer from a pool.
// Knowing the size of the buffer means we can discard
// outliers from the pool to prevent uncontrolled
// memory growth in long-running RPC services.
buf := make([]byte, 0, opts.Size(m))
var err error
buf, err = opts.MarshalAppend(buf, m) // does not allocate
// Note that len(buf) might be less than cap(buf)! Read below:

请注意,当启用延迟解码时,proto.Size 返回的字节数可能比 proto.Marshal(以及 proto.MarshalAppend 等变体)写入的字节数更多!因此,当您将编码字节放置在线路上(或磁盘上)时,请务必使用 len(buf) 并丢弃任何先前的 proto.Size 结果。

具体来说,当满足以下条件时,(子)消息在 proto.Sizeproto.Marshal 之间可能会“缩小”:

  1. 启用了延迟解码
  2. 并且消息以 非最小有线格式 到达
  3. 并且在调用 proto.Size 之前未访问消息,这意味着它尚未解码
  4. 并且在 proto.Size 之后(但在 proto.Marshal 之前)访问消息,导致其被延迟解码

解码会导致任何后续的 proto.Marshal 调用编码消息(而不是仅仅复制其有线格式),这会导致隐式规范化为 Go 编码消息的方式,目前是最小有线格式(但不要依赖这一点!)。

如您所见,这种情况相当特殊,但尽管如此,proto.Size 结果视为上限是最佳实践,切勿假设结果与实际编码消息的大小匹配。

背景:非最小有线格式

在编码 Protobuf 消息时,存在一种*最小有线格式大小*和许多更大的*非最小有线格式*,它们解码为相同的消息。

非最小有线格式(有时也称为“非规范化有线格式”)指的是非重复字段多次出现、非最优 varint 编码、在线上出现非打包的打包重复字段以及其他情况。

我们会在不同场景中遇到非最小有线格式

  • 故意为之。 Protobuf 支持通过拼接其有线格式来拼接消息。
  • 意外为之。 一个(可能是第三方的)Protobuf 编码器编码不理想(例如,在编码 varint 时使用比必要更多的空间)。
  • 恶意为之。 攻击者可以专门制作 Protobuf 消息,以通过网络触发崩溃。