快来一起快乐的压缩吧,简析protobuf的varint算法

710 阅读7分钟

快来一起快乐的压缩吧,简析protobuf的varint算法

[TOC]

前言

大家好,我是魔性的茶叶。公司的消息选型推送使用了protobuf做消息序列化,最近突然对这种序列化起了兴趣,简单了解了下,发现它的用途甚是广泛,包括推送,rpc调用,觉得protobuf的压缩算法很有意思,和大家分享下这把压缩数据的利剑

简单介绍

给不了解protobuf的朋友简单介绍下,官网介绍是这样的

image-20220816152225467

简而言之就是protobuf是一种序列化数据,和json或者xml一样

那么可能有朋友要问了,既然是和json或者xml一样,那我为什么还要使用这个protobuf呢,为什么不直接使用json或者xml呢?

那当然是因为上面介绍的一句but smaller, faster, and simpler

就是说,经过protobuf序列化的数据,更加短小精悍

quick start和两种序列化的对比

获取protobuf环境

介绍下如何在十分钟中内入门用protobuf序列化一段数据,首先在protobuf的realease页面找到自己的语言类型(这里我使用java)和环境,下载realease包将bin路径配置到path上

如果是idea可以下载一个插件

image-20220816153949988

简单编写一个proto文件

因为我们是im业务,所以我们简单用三个字段表示一个消息

syntax="proto3";
message MyMessage {
  int32 messageId = 1 ;
  int32 messageType = 2 ;
  string messageContent = 3;
}

消息id和消息类型使用int类型,消息内容使用string类型

如果是idea直接右键在当前文件夹生成proto文件对应的java文件:

image-20220816161410353

生成出的java文件是这样的

202208161627599.png

编写生成protobuf格式数据内容的代码:

public static void main(String[] args) {

        String msgContent = "您拨打的电话已宕机";
        MyMessageOuterClass.MyMessage.Builder messageBuilder = MyMessageOuterClass.MyMessage.newBuilder();
        messageBuilder.setMessageId(11000).setMessageType(2).setMessageContent(
                msgContent);
        MyMessageOuterClass.MyMessage myMessage = messageBuilder.build();
        String msgtring = new String(myMessage.toByteArray(), StandardCharsets.UTF_8);

}

我们对比下同一数据内容prtobuf和json序列化出来的字符串长度:

		String msgContent = "您拨打的电话已宕机";
        MyMessageOuterClass.MyMessage.Builder messageBuilder = MyMessageOuterClass.MyMessage.newBuilder();
        messageBuilder.setMessageId(11000).setMessageType(2).setMessageContent(
                msgContent);
        MyMessageOuterClass.MyMessage myMessage = messageBuilder.build();
        String msgtring = new String(myMessage.toByteArray(), StandardCharsets.UTF_8);
        System.out.println("protobuf序列化的字节数组长度"+msgtring.getBytes(StandardCharsets.UTF_8).length);

        MyMessageJsonDto messageJsonDto = new MyMessageJsonDto();
        messageJsonDto.setMessageId(11000);
        messageJsonDto.setMessageType(2);
        messageJsonDto.setMessageContent(msgContent);
        System.out.println("json序列化的字节数组长度"+JSON.toJSONString(messageJsonDto).getBytes(StandardCharsets.UTF_8).length);

结果如下:

这是protobuf序列化打印的字符串,直接乱码了

�U您拨打的电话已宕机

这是json序列化出来的字符串

{"messageContent":"您拨打的电话已宕机","messageId":11000,"messageType":2}

看上去很离谱,总感觉protobuf压缩后少了数据

对比两者大小

image-20220816172035958

可以看到这段简单信息json序列化出的数据内容居然比·protobuf数据内容的大小要大两倍,这真是不可思议的压缩。笔者在公司排查推送问题的时候,曾经发现华为推送限制了推送包的大小,如果包太大,是会导致推送失败的,这几个字节的差距就好像高考的几分之差,决定了能不能上大学(能不能把推送发出去),如果没有合理的压缩和序列化数据的方式,必然会导致复杂的数据的推送失败

protobuf原理浅析

那么protobuf是怎么做到既保存了消息表达的内容,又高效的压缩了数据了呢。**鲁迅说过,有的必有失,就好像有钱人,得到了钱,失去了烦恼。那么它牺牲了什么呢?**这里我先小小的卖个关子,最后再来解答这个问题

img

protobuf怎么保存信息原有的内容

我们先看一个正常的消息内容,需要包括哪些内容,再看看protobuf是怎么把这些内容还原的,首先拿上面的json数据来看

{"messageContent":"您拨打的电话已宕机","messageId":11000,"messageType":2}
  1. 首先是结构类型,{},这两个中括号,代表这是个json的数据
  2. 第二是字段名,比如说“messageContent”这个字段
  3. 第三是类型,比如说messageContent是字符串类型,但是messageId却是个整型
  4. 第四是字段和内容顺序,比如说"messageContent"在消息内容中排序第一,messageId的排序是第二
  5. 第五是内容,比如说messageContent的内容是"您拨打的电话已宕机"

没想到吧,一个小小的消息体隐藏了这么多信息,这都是前人的智慧啊

下载

那么我们再看看protobuf是怎么保留这些信息的

首先是结构类型,protobuf直接舍弃了,为什么可以舍弃的?因为假如对接的双方能够确保消息类型一定是protobuf,那么消息类型的表达就是多余的

第二是字段名,说实话“messageContent”这个确实很长(有时候字段名比内容长也不是什么稀奇事),在java8里面需要用14个字节来表示,protobuf于是直接把它丢弃了,通过proto的文件来描述字段名,也就说protobuf序列化出来的消息内容里面是没有字段名的

那么类型和字段顺序,protobuf是怎么表达的呢?protobuf有自己的类型,如下:

wire typeproto 类型含义
0int32, int64, uint32, uint64, sint32, sint64, bool, enumVarint
1fixed64, sfixed64, double64-bit
2string, bytes, embedded messages, packed repeated fieldsLength-delimited
3groups (废弃)Start group
4groups (废弃)End group
5fixed32, sfixed32, float32-bit

我简单介绍下,像int32或者int64(java中对应Long)使用varint算法进行压缩

double和float这种类型,使用固定的bit去表示,64位和32位,string直接就不压缩

我们先了解下思想,具体demo得等介绍了varint算法后再写

在protobuf中,字段和顺序是存一起的,具体公式如下

fied_number<<3|wire_type

换成中文就是说,字段顺序向左移三位,再与protobuf的wire_type的数字做或运算

那么问题来了,为啥要先左移三位然后再做或运算呢?这个三有什么特殊的意义吗

我们先随便拿个数字,就1吧,代表顺序第一位的字段,它的二进制是

0000001

左移三位变成

00001000

然后做或操作,为啥做或操作呢?我们知道什么数字和0或都是它自身,那么就很清楚了,左移三位和或操作是为了留住后三位的信息,这最后的三位就是存字段类型了,为什么是三呢?因为2的三次方是8,正好能覆盖wire_type的六个类型

假设现在这时proto类型是一个double,wire_type=1,拿去和00001000做或运算,8|1=9(00001001)

最终的二进制是00001001这样的,也就是说用一个字节就表达了两种含义:字段顺序和字段类型(这种思想在线程池也有用到,线程池用一个字段同时表达了线程数量和线程池状态),真厉害啊

下载

那么这时候问题来了,protobuf怎么还原被序列化的顺序和类型数据呢?这里我一开始还没想出来,后面经过一名不愿意透露姓名的郑网友指点了下才发现怎么还原,直接做逆向操作就行:

或操作为了保留0和1,那么拿到wire_type我只需要最后三位的信息,其他高位的信息直接丢弃,也就是说和7(0000111)做与操作就行,也就是:

9&7 = 1

得到wire_type=1,然后9直接右移三位,丢弃wire_type的信息,1>>3 = 1,得到字段顺序为1。

看完了protobuf怎么处理字段顺序和类型的,我们接下来看看它是怎么压缩消息内容的

varint算法压缩整型

接下来请出今天的主角varint算法,我们要知道,protobuf几乎没有对字符串(string)类型做压缩,可以说主要的压缩是在wire_type=0的数据类型上,这个类型使用了varint算法。那么我们来看看varint是怎么具体对int类型进行压缩的。

varint算法分为几个步骤

  1. 以7个bit作为单位分割数据为多组
  2. 按组颠倒顺序,顺序从低位到高位变成高位到低位
  3. 根据每组的数据后面是否还有数据决定每组的第一位是0还是1,0代表没有数据,1代表有数据。这个位名字叫MSB

以数字300为例,首先将其转为二进制:

0000000100101100

以7个bit作为单位分割数据为多组(注意,是从右向左分割),并丢弃没用的高位0:

000010 0101100

按组颠倒顺序

0101100 000010 

添加MSB

10101100 0000010  用十进制表达是 172 2

假如我要表达proto文件为int32 messageId=1这个消息内容为300,字段顺序为1,wire_type=0的整个消息,protobuf会怎么去压缩呢

字段顺序1<<3|0=8,然后300通过上面的计算得到是172 2

写成十进制是这样的

8 172 2

写成二进制是这样的

00000100 10101100 0000010

也就是说protobuf仅仅用了两个字节就表达了java里面需要四个字节需要表达的内容,是极为高效的优化

probobuf的优点和缺点

现在来解答下之前提出的问题,protobuf得到了高效和高性能,那么它牺牲了什么呢?

下载

我们看看java打印出来protobuf序列化出来的内容和json一比就知道了

protobuf序列化:

�U您拨打的电话已宕机

json序列化:

{"messageContent":"您拨打的电话已宕机","messageId":11000,"messageType":2}

很明显,protobuf牺牲了可读性,我这还是带了字符串的序列化结果,如果全是数字,那么protobuf压缩出来的玩意和摩斯电码差不多,如果是前后端对接接口还是乖乖用json吧,屠龙刀也该用在屠龙上。

但是如推送,im场景,内部rpc框架调用,用protobuf再合适不过了