Go 大小语义

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

proto.Size 函数返回 proto.Message 线格式编码的大小(以字节为单位),方法是遍历其所有字段(包括子消息)。

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

典型用法

识别空消息

检查 proto.Size 是否返回 0 是一种识别空消息的简便方法

if proto.Size(m) == 0 {
    // No fields set (or, in proto3, all fields matching the default);
    // 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 消息以触发网络上的崩溃。