C++ grpc 消息压缩示例学习

0 阅读16分钟

前言

本文根据github.com/grpc/grpc/t… 进行C++ grpc 消息压缩示例学习运行。更多的是学习记录,水平不高,能力有限,错漏之处,还请见谅。欢迎友好讨论。

环境信息

  • 操作系统版本:ubuntu24.04
  • CMake版本:4.2.0
  • Git版本:2.43.0
  • GCC版本:gcc 13.3.0
  • OpenSSL版本: 3.0.13

在之前的教程mp.weixin.qq.com/s/50Tep3mq7… 中给出的是在Centos 7.6下的部署流程,现在我重装了操作系统为ubuntu 24.04,并且重新编译了grpc文件。相关二进制文件可以关注公众号 只做人间不老仙,后台发送 "grpc ubuntu 编译文件"获取我编译内容的压缩包 。

代码运行流程

编译

参考 mp.weixin.qq.com/s/50Tep3mq7… 克隆仓库 github.com/EarthlyImmo… 并配置grpc依赖。

配置好后,可以先修改一下 start_build.sh 中的gcc和g++的路径。

在blog_code/compression目录下执行:

./start_build.sh

完成编译。

运行

在blog_code/compression/build/server目录下运行服务器:

./server

另起一个终端,在blog_code/compression/build/client文件夹下运行客户端:

./client

代码大部分是copy的grpc官方的示例,这里对cmake文件和目录结构做了调整,对部分注释或者日志做了调整。

代码分析

客户端和服务器实现分析

代码使用hellworld示例,且是同步接口,与mp.weixin.qq.com/s/50Tep3mq7… 中相同。可以发现这个示例没有使用SSL认证。关于grpc 认证相关内容可以参考mp.weixin.qq.com/s/_54ixo8Dr…

这个示例的重点在于设置了压缩算法。对服务器来说,首先在创建服务器时,设置了服务器默认的压缩算法gzip。然后在SayHello协议中将此协议调用算法覆盖为deflate。

对客户端来说,在创建channel时,设置了默认压缩算法为gzip,在SayHello协议中设置了此协议调用压缩算法为deflate。

那么最终的效果是什么样呢?由于调用级别的压缩算法设置会覆盖通道级别和服务器级别设置,因此最终的效果是,客户端发送消息使用deflate算法,服务器返回消息,也使用deflate算法。其时序图如下:

我们可以抓包来验证一下。抓包方法可以参考:mp.weixin.qq.com/s/_54ixo8Dr… 中的 抓包分析SSL握手流程 一节 和 mp.weixin.qq.com/s/jPT8GBiNB…抓包操作流程 一节。

在前面介绍的抓包分析流程中,得到grpc_stream.pcap文件之后,是使用tshark 进行分析,不够直观。这次抓包之后,我直接使用wireshark客户端来进行分析,windows下的安装包下载链接为:www.wireshark.org/download.ht…

在开始分析之前,我们可以先对Wireshark进行设置使其可以解析Protobuf消息:

进入 编辑 → 首选项 → Protocols → ProtoBuf,勾选以下选项:

  • Dissect Protobuf fields as Wireshark fields
  • Show all fields of bytes type as strings
  • Add missing fields with default values
  • Load .proto files on startup

然后点击 Edit 按钮,添加.proto 文件所在目录路径,最后勾选 Load all files。所有设置完成之后,点击确认。

.proto 文件所在目录路径我是将用到的protobuf文件下载到本地桌面的文件夹中,然后设置的。

将抓包得到的grpc_stream.pcap文件下载到本地,然后加载:

加载后得到捕获的数据:

选中任意一个 50051 端口 的 TCP 数据包,右键选择 Decode As...,并将该端口的协议设置为 HTTP2并点击保存生效。

现在我们可以在过滤器中直接输入 grpc,来查找grpc相关通讯包:

很明显的两个包,一个客户端到服务器,一个服务器回复客户端。

首先来看客户端到服务器的包信息:

再来看看服务器到客户端的包信息:

可见,抓包结果与以上分析一致。客户端和服务器消息均是使用了deflate算法进行压缩。

使用压缩算法的原因和可能带来问题

grpc使用压缩算法对传输消息进行压缩,可以提高网络传输效率,可以减少网络带宽消耗,降低网络延迟,从而节省成本。

但是使用压缩算法会带来额外的性能消耗,压缩和解压都要消耗cpu。

grpc压缩算法设置使用

grpc默认支持的压缩算法可以在 grpc/include/grpc/impl/compression_types.h 中找到,我使用的版本(gRPC 1.76.0) 目前支持deflate和gzip两种,并标注了 todo: snappy

不管对客户端还是服务器来讲,默认都是不进行压缩。

对客户端来讲,压缩算法设置分为通道级别和调用级别,其中优先级:调用级别 > 通道级别。如在本示例客户端中,通道级别压缩算法设置为gzip,而调用级别压缩算法设置为deflate,最终压缩算法使用的是deflate。

对服务器来讲,压缩算法设置分为服务器级别和调用级别,其中优先级:调用级别 > 通道级别。如在本示例服务器中,服务器级别的压缩算法设置为gzip,而调用级别压缩算法设置为deflate,最终压缩算法使用的是deflate。

grpc客户端和服务器的压缩算法设置完全相互独立,可以使用不同的压缩算法。

以上均可以使用抓包来验证。比如,我们将客户端所有设置压缩算法的代码屏蔽掉,然后编译运行抓包分析,可以看到客户端没有使用压缩算法,但是服务器使用了deflate算法。这可以说明客户端默认情况下不使用压缩算法;客户端和服务器压缩算法设置相互独立,可以使用不同的压缩算法。

如果我们只把客户端调用级别的压缩算法设置去掉,只保留通道级别,编译运行抓包分析,可以看到,包头部显示客户端使用压缩算法gzip,这证明了对客户端来说,压缩算法优先级:调用级别 > 通道级别,调用级别设置去掉之后,通道级别设置开始起作用了。但是有个问题:消息包中的压缩标志并没有设置,说明实际上这个消息并没有被压缩。

这是为什么呢?注意到grpc 的 Issue 22494( github.com/grpc/grpc/i… )也提到了这个问题。其中提到,如果将word重复1600次(8KB), 则事情会变得不一样。我们也可以进行测试,为了不影响原用例,这个测试代码放到modify_client和modify_server中。编译运行流程与client和server基本一致,只是文件夹和生成程序名不同。

在modify_client中,客户端程序增加了一个辅助函数,用于生成长字符串:

编译运行抓包分析, 可以发现这次消息也真的压缩了,因为都是重复数据,所以压缩率相当可观。

出现这种现象是因为gRPC C++ Core 内部有一个智能的压缩决策机制:即使在通道级别设置了压缩算法为 gzip,如果启动了压缩并没有收益,grpc C++ 也并不会启动压缩。这个判断标准是什么?有AI说是grpc会尝试压缩消息,如果压缩后的数据并不比压缩前小,则grpc并不会真的使用压缩。不过更加细节的内容可能要看源码才能确定了,时间精力有限,暂时不去确认这一点。

如果是调用级别的压缩,会有这个智能决议吗?可以把原示例客户端中的通道压缩算法也设置为gzip,编译运行抓包分析,可以看到也并没有压缩。可见调用级别也是有的。

同样的,可以对服务器进行类似分析,验证相关压缩优先级以及压缩实际效果,内容较为重复,这里不再给出。

grpc压缩级别设置使用

除了直接设置压缩算法,grpc还可以设置压缩级别。压缩级别可以以一种抽象的方式请求消息压缩,而不需要直接指定具体的压缩算法。

grpc支持的压缩级别定义在grpc/include/grpc/impl/compression_types.h中,目前有4个级别。

  • GRPC_COMPRESS_LEVEL_NONE:也是默认值
  • GRPC_COMPRESS_LEVEL_LOW:低级别压缩,更偏向压缩速度,在压缩率方面要差一些
  • GRPC_COMPRESS_LEVEL_MED:中级别压缩,追求压缩速度和压缩率的均衡
  • GRPC_COMPRESS_LEVEL_HIGH:高级别压缩,更偏向高压缩率,压缩速度要差一些

在设置了压缩级别之后,grpc底层会结合自己和对端支持的压缩算法,自动的匹配合适的算法。

根据grpc文档 github.com/grpc/grpc/b… 的描述,当前仅服务器支持压缩级别设置。因为服务器始终知道客户端支持哪些算法,但是客户端事先不知道服务器支持哪些算法,这个问题需要通过协商功能或自动重试机制来解决,而这些功能目前还没有实现。

根据grpc文档github.com/grpc/grpc/b… ,使用“压缩级别”的好处在于,它抽象并屏蔽了选择对端所支持的具体压缩算法这一复杂细节,同时也减轻了开发者在选择具体算法时的决策负担。并且文档中建议 服务器端应尽量避免直接指定具体的压缩算法。除非有极其充分的理由(例如进行性能基准测试或功能测试),否则建议优先通过设置“压缩级别”来间接指定压缩策略。

下面我们在modify_server中尝试使用指定压缩等级的方式。同样的,指定压缩等级可以分为服务器级别指定和调用级别指定,其中调用级别指定优先级大于服务器级别指定。

服务器级别指定调用接口ServerBuilder::SetDefaultCompressionLevel

而调用级别指定调用接口ServerContext::set_compression_level

后面分别抓包分析压缩级别设置为LOW, MED 和HIGH时的服务器实际使用压缩算法和压缩效果。测试过程中只修改调用级别指定压缩级别。测试时,客户端传入字符串设置world重复次数为1600。

当设置压缩级别为LOW时,抓包分析可以发现服务器回包压缩算法为gzip,消息被压缩了,压缩后大小为69Bytes。

当设置压缩级别为MED时,抓包分析可以发现服务器回包压缩算法为defalte,消息被压缩了,压缩后大小为57bytes。

当设置压缩级别为HIGH时,抓包分析可以发现服务器回包压缩算法为deflate,消息被压缩了,压缩后大小为57bytes。

可以看到deflate应该压缩率更好,是高级别,而gzip压缩率低,是低级别,对这个测试场景来说,deflate压缩后确实更小了。这部分源码比较简单,在

grpc/src/core/lib/compression/compression_internal.ccCompressionAlgorithmSet::CompressionAlgorithmForLevel中,可以看一下:

确实是deflate对应高级别,gzip对应低级别。从实现来上,二者底层实现应该是类似的,可能因为gzip多了一些额外信息,所以被认为压缩率更低。

话说回来,这里压缩级别判断映射还是过于草率了。感觉不具备实用价值,还是手动指定压缩算法好一点。

常用的压缩算法

除了grpc 原生支持的deflate和gzip之外,其他常用的压缩算法包括:lz4、zstd和snappy等。下面对其进行定性的简单分析介绍。

deflate和gzip底层实现基本一致,gzip多了一些封装信息,兼容性更好。

gzip压缩率较高,但是相应的压缩和解压速度较慢,适合在带宽敏感场景或者是大文本传输场景下使用。但是要注意cpu的消耗。

lz4的压缩率较低,但是胜在压缩速度快,cpu开销低,适合对性能要求较高的场景下。lz4也可以设置高压缩率,相应的性能就会下降。

snappy由谷歌开发,压缩速度也比较快,压缩率较低。

zstd由facebook开发,追求性能与压缩率的平衡,当对压缩率有要求,且不希望性能下降太多时,可以选择。

则可以做如下总结:

  • 如果追求极致压缩率以节省带宽,则选择gzip和zstd
  • 如果追求压缩速度和低cpu消耗,则选择snappy或lz4
  • 如果需要压缩率和cpu消耗的平衡,则zstd是综合表现更好的算法

以上只是一些定性的分析和介绍,在一些更加细分的场景下(比如小包频繁发送等),有些算法可能会更加适合,这里不再进行更细致的分析。更加具体的业务上的选择,可能要根据业务的实际测试数据来进行。更加定量的对不同算法的比较,可以参考:quixdb.github.io/squash-benc… 。这个图里面对相同算法的不同压缩等级参数下的性能和压缩率也有体现。

grpc自定义压缩算法使用

常用的压缩算法 一节中除了grpc C++ 原生支持的压缩算法,还介绍了lz4、zstd和snappy,这些算法目前grpc C++原生并不支持,那么如何使用呢?这里可能有三种思路。

第一种是修改grpc代码,添加自定义算法实现,但是对代码的稳定性和可维护性都会有影响,后续升级也比较困难,因为后续grpc官方可能也会支持相关算法。这种方法不是很可取。

第二种比较直接,就是在应用层信息中自行进行压缩。即在消息中保存的不是原始数据,而是压缩后的数据。这里实现了一个简单的lz4压缩协议。lz4的github链接:github.com/lz4/lz4 。在proto中新增协议SayHelloLz4,这里需要注意的是,请求和回包中压缩后的lz4数据不能再使用string类型,因为string类型会进行UTF-8 校验,不能保存任意二进制文件,因此修改为bytes类型。另外还加入了原始数据大小,解压缩时使用。

使用的lz4代码在仓库blog_code/compression/third_party/lz4目录下,最简单的使用,只用到了lz4.h和lz4.c两个文件。lz4还有很多其他内容,具体可以看lz4官方资料。

测试用例使用的是modify_client和modify_server,测试时客户端传入字符串设置world重复数量为1600。编译运行抓包分析,对客户端到服务器的包,可以发现grpc并没有压缩,但是实际消息从9600多字节压缩到了58字节。

对服务器到客户端的包,也是类似,这里不再给出。

第二种方案虽然可以实现,但是在每个协议中都要自行调用压缩和解压函数,为了统一流程,可以引入拦截器,统一进行压缩和解压处理,这就是第三种方案。由于拦截器相关知识我还没有看过,这里也不再具体展开。

单消息禁用压缩

在文档grpc.io/docs/guides… 中提到,

  • 如果用户请求禁用压缩,下一条消息将以未压缩的形式发送。这对防范 BEAST(en.wikipedia.org/wiki/Transp…) 和 CRIMEhttps://en.wikipedia.org/wiki/CRIME) 攻击至关重要。此机制适用于一元调用和流式调用这两种场景。

其中提到的两种攻击简单原理为:

  • CRIME 攻击:攻击者利用 TLS 压缩的特性,通过观察压缩后的数据长度变化来推断加密通道中的敏感信息(如 Cookie)
  • BEAST 攻击:类似的侧信道攻击,利用 TLS 的某些弱点

对一元调用来说,设置下一条消息以未压缩的形式发送较为简单,直接在调用级别设置压缩算法为none即可,但是对流式调用这样做不行,因为调用级别是针对整个调用的,而一次流式调用可能要发多个消息。要实现消息级别的禁用,需要使用流式Write 接口的WriteOptions 参数。

在modify_client和modify_server中实现了双向流式RPC接口 rpc SayHelloBidiStream (stream HelloRequest) returns (stream HelloReply) {},其中使用流式调用中消息级别的禁用,以客户端代码为例,通过指定WriteOptions::set_no_compression来实现消息级别的禁用。

执行双向流式rpc,编译运行,可以得到客户端输出:

服务器输出:

根据代码可知,客户端和服务器的都是第2和第4个消息禁用了压缩,抓包分析: 客户端第1个消息,压缩,

客户端第2个消息,未压缩,

客户端第3个消息,压缩,

客户端第4个消息,未压缩。

服务器第1个消息,压缩,

服务器第2个消息,未压缩,

服务器第3个消息,压缩,

服务器第4个消息,未压缩。

符合预期。 对客户端流式rpc和服务器流式rpc,也是类似。这里不再单独给出示例。

异常情况说明

根据文档grpc.io/docs/guides…

  • 如果客户端消息使用了服务器不支持的压缩算法进行压缩,该消息将在服务器端导致 UNIMPLEMENTED 错误状态。服务器将在响应中包含一个 grpc-accept-encoding 标头,用于指明服务器所支持的压缩算法
  • 如果客户端消息使用了服务器 grpc-accept-encoding 标头中列出的某种算法进行压缩,且服务器返回了 UNIMPLEMENTED 错误状态,则该错误的成因与压缩无关
  • 对等方可以选择不披露其支持的所有压缩算法。然而,如果它接收到一条使用了未披露但受支持的压缩算法进行压缩的消息,它将在响应的 grpc-accept-encoding 标头中包含该压缩算法
  • 对服务器被请求使用客户端不支持的算法(根据从客户端收到的最后一个 grpc-accept-encoding 标头指示)进行压缩的每条消息,它将发送未压缩的消息

参考资料

欢迎关注公众号:只做人间不老仙