选择正确的整数类型
Protobuf
的性能主要来源于其二进制格式以及对整数的表示方式。虽然某些类型(如字符串)是 “原样” 序列化的,并在其前面加上字段标签、类型和长度,但数字——尤其是整数——通常比在计算机内存中布局时所需的位数要少得多。
然而,您可能已经注意到我说的是 “通常序列化”。这是因为,如果为数据选择了错误的整数类型,varint
编码算法可能会将一个 int32
编码成 5 个字节或更多,而在内存中,它仅占用 4 个字节。
让我们来看一个选择错误整数类型的示例。假设我们想要编码值 268,435,456
。我们可以通过使用 Go 标准库中的 unsafe.Sizeof
函数和 Protobuf
提供的 proto.Marshal
函数,检查该值在内存中和使用 Protobuf
序列化时的大小。最后,我们还将使用著名的 Int32Value
类型来包装该值,以便能够使用 Protobuf
进行序列化。
在编写主函数之前,让我们尝试制作一个通用函数 serializedSize
,该函数将返回一个整数在内存中的大小和相同整数使用 Protobuf
序列化后的大小。
这里展示的代码位于随附的 GitHub 仓库中的 |
首先,我们添加依赖项:
$ go get –u google.golang.org/protobuf
$ go get –u golang.org/x/exp/constraints
第一个依赖项是为了能够访问著名的 Int32Value
类型,第二个依赖项是为了能够访问通用类型约束。
我们将使用通用类型来接受任何类型的整数数据,并允许我们指定一个包装消息,以便能够使用 Protobuf
序列化数据。我们将有如下函数:
func serializedSize[D constraints.Integer, W protoreflect.ProtoMessage](data D, wrapper W) (uintptr, int) {
// ...
}
然后,我们可以简单地使用 Protobuf
库中的 proto.Marshal
函数来序列化该包装器,并返回 unsafe.Sizeof
的结果以及序列化数据的长度:
func serializedSize[D constraints.Integer, W protoreflect.ProtoMessage](data D, wrapper W) (uintptr, int) {
out, err := proto.Marshal(wrapper)
if err != nil {
log.Fatal(err)
}
return unsafe.Sizeof(data), len(out) - 1
}
接下来,我们就可以在主函数中调用这个函数,使用值 268,435,456
和一个 Int32Value
实例来进行测试:
import (
"fmt"
"unsafe"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/wrapperspb"
"golang.org/x/exp/constraints"
)
// ...
func main() {
var data int32 = 268_435_456
i32 := &wrapperspb.Int32Value{
Value: data,
}
d, w := serializedSize(data, i32)
fmt.Printf("in memory: %d\npb: %d\n", d, w)
}
如果我们运行这个代码,应该会得到如下结果:
$ go run integers.go
in memory: 4
pb: 5
现在,如果你仔细查看代码,你可能会觉得 len(out) - 1
看起来有些不太对劲。事实上,在 Protobuf
中,Int32Value
被序列化为 6 个字节。你说得对,真实的序列化大小是 6 个字节,但前 2 个字节代表类型和字段标签。因此,为了公平比较序列化数据,我们去除了元数据,只比较数字本身。
你可能会认为我们当前的 TODO API 使用 uint64
作为 ID 也存在这个问题,没错,你完全正确。你可以很容易地通过将 int32
改为 uint64
、Int32Value
改为 UInt64Value
,并将我们的数据设置为 72,057,594,037,927,936
,来验证这个问题:
func main() {
var data uint64 = 72_057_594_037_927_936
ui64 := &wrapperspb.UInt64Value{
Value: data,
}
d, w := serializedSize(data, ui64)
fmt.Printf("in memory: %d\npb: %d\n", d, w)
}
运行上述代码,我们将得到以下结果:
$ go run integers.go
in memory: 8
pb: 9
这意味着,在大约 72 万亿个任务被注册之后,我们将遇到这个问题。显然,对于我们的使用场景来说,使用 uint64
作为 ID 是安全的,因为要遇到这个问题,我们需要每个地球上的人都创建 900 万个任务(72 quadrillion / 8 billion
)。但这个问题在其他使用场景中可能更加显著,因此我们需要意识到 API 的局限性。
使用整数的替代方法
一个常被提到的替代方案,甚至是谷歌推荐的做法,是使用字符串作为 ID。他们提到,2^64(int64
)已经不像以前那么 “大” 了。从公司的角度来看,这是可以理解的。他们必须处理大量数据,并且需要处理比我们大得多的数字。
然而,字符串相对于数字类型的优势不仅仅在于这个。最大的优势可能在于 API 的演变。如果将来你需要存储更大的数字,唯一的选择就是切换到字符串类型。但问题是,数字类型和字符串之间没有向前和向后的兼容性。因此,你将不得不在 schema 中添加一个新的字段,增加消息定义的复杂性,而且在与旧版本或新版本的应用程序通信时,开发人员需要检查 ID 是作为字符串还是数字来使用的。
字符串还提供了安全性,因为这些字符串不能进行算术操作。这在一定程度上限制了聪明的开发人员,不会因为对 ID 进行某些 “巧妙操作” 而导致数字溢出。ID 被有效地视为全局变量,任何人都不应该手动处理它们。
总结来说,对于某些使用场景,直接使用字符串作为 ID 可能是一个好主意。如果你预期会扩展系统或处理比整数限制更大的数字,字符串就是解决方案。然而,在很多情况下,你可能只需要 uint64
。只要你了解自己的需求并为未来做好规划,就能做出合适的选择。