快来一起快乐的压缩吧,简析protobuf的varint算法
[TOC]
前言
大家好,我是魔性的茶叶。公司的消息选型推送使用了protobuf做消息序列化,最近突然对这种序列化起了兴趣,简单了解了下,发现它的用途甚是广泛,包括推送,rpc调用,觉得protobuf的压缩算法很有意思,和大家分享下这把压缩数据的利剑
简单介绍
给不了解protobuf的朋友简单介绍下,官网介绍是这样的
简而言之就是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可以下载一个插件
简单编写一个proto文件
因为我们是im业务,所以我们简单用三个字段表示一个消息
syntax="proto3";
message MyMessage {
int32 messageId = 1 ;
int32 messageType = 2 ;
string messageContent = 3;
}
消息id和消息类型使用int类型,消息内容使用string类型
如果是idea直接右键在当前文件夹生成proto文件对应的java文件:
生成出的java文件是这样的
编写生成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压缩后少了数据
对比两者大小
可以看到这段简单信息json序列化出的数据内容居然比·protobuf数据内容的大小要大两倍,这真是不可思议的压缩。笔者在公司排查推送问题的时候,曾经发现华为推送限制了推送包的大小,如果包太大,是会导致推送失败的,这几个字节的差距就好像高考的几分之差,决定了能不能上大学(能不能把推送发出去),如果没有合理的压缩和序列化数据的方式,必然会导致复杂的数据的推送失败。
protobuf原理浅析
那么protobuf是怎么做到既保存了消息表达的内容,又高效的压缩了数据了呢。**鲁迅说过,有的必有失,就好像有钱人,得到了钱,失去了烦恼。那么它牺牲了什么呢?**这里我先小小的卖个关子,最后再来解答这个问题
protobuf怎么保存信息原有的内容
我们先看一个正常的消息内容,需要包括哪些内容,再看看protobuf是怎么把这些内容还原的,首先拿上面的json数据来看
{"messageContent":"您拨打的电话已宕机","messageId":11000,"messageType":2}
- 首先是结构类型,{},这两个中括号,代表这是个json的数据
- 第二是字段名,比如说“messageContent”这个字段
- 第三是类型,比如说messageContent是字符串类型,但是messageId却是个整型
- 第四是字段和内容顺序,比如说"messageContent"在消息内容中排序第一,messageId的排序是第二
- 第五是内容,比如说messageContent的内容是"您拨打的电话已宕机"
没想到吧,一个小小的消息体隐藏了这么多信息,这都是前人的智慧啊
那么我们再看看protobuf是怎么保留这些信息的
首先是结构类型,protobuf直接舍弃了,为什么可以舍弃的?因为假如对接的双方能够确保消息类型一定是protobuf,那么消息类型的表达就是多余的
第二是字段名,说实话“messageContent”这个确实很长(有时候字段名比内容长也不是什么稀奇事),在java8里面需要用14个字节来表示,protobuf于是直接把它丢弃了,通过proto的文件来描述字段名,也就说protobuf序列化出来的消息内容里面是没有字段名的
那么类型和字段顺序,protobuf是怎么表达的呢?protobuf有自己的类型,如下:
wire type | proto 类型 | 含义 |
---|---|---|
0 | int32, int64, uint32, uint64, sint32, sint64, bool, enum | Varint |
1 | fixed64, sfixed64, double | 64-bit |
2 | string, bytes, embedded messages, packed repeated fields | Length-delimited |
3 | groups (废弃) | Start group |
4 | groups (废弃) | End group |
5 | fixed32, sfixed32, float | 32-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算法分为几个步骤
- 以7个bit作为单位分割数据为多组
- 按组颠倒顺序,顺序从低位到高位变成高位到低位
- 根据每组的数据后面是否还有数据决定每组的第一位是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再合适不过了