写在前面
相信正在浏览这篇文章的同学,一定已经对PB(Protocol buffer)有所了解,所以这里不罗嗦何为PB了。
我自己从去年年底开始对PB的使用逐渐有一些了解,直到在搜索排序框架(iRank)的重构中尝试应用PB,希望能在“数据结构灵活增删改”和“高效的数据传输反序列化”之间求得平衡。
在这过程之中,对PB 动态消息和静态消息的C++使用方式进行了一些调研,对 动态消息 和 静态消息 的优缺点有了进一步了解。通过阅读源代码和实际应用,总结出一些经验,将 动态消息 与 静态消息 进行有机的结合,可以在“数据结构灵活增删改”和“高效的数据传输反序列化”之间求得平衡。
希望这些经验,对其他正纠结于选择 动态消息 与还是 静态消息 的同学,能有一些帮助和启发。
待解决问题的背景
俗话说,不存在无缘无故的爱,也不存在无缘无故的恨。这里尝试在iRank的重构中应用PB,是为了解决新旧版本索引兼容的问题,同时为线上提供高效的自反射机制,灵活读取需要的字段,保证线上服务性能。
事出有因
具体的说,我们负责的iRank模块,是ISearch引擎的一个排序算分模块,对每一次搜索请求,ISearch需要调用iRank模块对每一个(Query,Doc)二元关系对进行算分,根据算分结果对候选结果集进行排序。
在算分过程中iRank需要用到大量Query、Doc维度的特征信息,考虑到线上实时抽取所有特征信息的效率很低下(如抽取商品标题中的中心词),所以真实的情况是DUMP中心会调用Gaia模块(负责Doc维度特征抽取),将这些可线下预处理的特征抽取好并进行序列化,由ISearch存储到Profile索引中。线上对(Query, Doc)二元关系对算分时,直接从ISearch提供的Profile索引中取之前序列化产出的Doc特征信息,做相应反序列化操作后将其还原为可随机读取的结构化信息,进一步完成后续的排序算分操作。
也就是说这里存在一个数据格式,作为“线下预处理操作”和“线上随机读取”的信息载体,以往我们的数据格式的序列化、反序列化逻辑,是开发者按需要“自编码”实现的,如:
class AEDocument
{
public:
int32_t serialize(std::string &strBuffer);
int32_t deserialize(const char *pIndex, int nIndexSize);
public:
float m_fProdBizScore;
vector<float> m_cvrScore;
};
serialize和deserialize的实现在此忽略一万字,主要方式还是开发者自己决定每个成员变量序列、反序列化的次序。显然这种方式是原始的,新增字段信息需要编码的程序猿用KPI的“可用性得分”去保证索引的兼容性,开发和测试成本都很高。
没有绝对的优劣
不可否认,自编码这种方式是最透明的,序列化与反序列化的效率也是最高的,在业务逻辑相对简单的早期,增删字段的节奏并没有这么快,这种方式的优点大于缺点。
随着业务的快速发展,ISearch5上线后DUMP与ISearch运维耦合度越来越低,“线下预处理操作”和“线上随机读取”的强关联关系已经保证,增删字段后的索引兼容性面临的空前的压力,这种原始方式的缺点反而远大于优点。
在这样的情况下,我们在寻求一种DOC特征存储的数据格式,既能灵活增删字段,也能提供自反射机制灵活读取需要的字段,同时不能对线上服务性能产生影响。在这时候,ProtoBuf消息进入了我们的调研范围……
静态消息 与 动态消息
静态消息,是指消息的字段格式是在静态编译期间就决定好,系统调用期间生产/消费的pb消息的格式是确定不变的;动态消息,是指消息的字段格式是在运行时动态加载的,系统调用期间生产/消费的pb消息格式是不确定的。
这里概念不一定准确,叫法也不一定通用,如有班门弄斧之嫌,请见谅,文中暂且如上理解“静态消息”和“动态消息”两种使用方式。
静态消息的使用
如大家所知,一般分为3个步骤:
- step 1: 编码消息描述文件
message AEDocument
{
optional float m_fProdBizScore = 1;
repeated float m_cvrScore = 2;
};
- step2:用protoc工作将消息描述文件生成生产/消费消息的C++源代码
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/AEDocument.proto
生成的C++源代码如下(此处只摘取.pb.h的关键代码,对.pb.cc的解读后文有详细介绍):
// AEDocument.pb.h
// 此处忽略一万行...
class AEDocument;
class AEDocument : public ::google::protobuf::Message {
public:
// 此处忽略一万行...
// optional float m_fProdBizScore = 1;
inline bool has_m_fprodbizscore() const;
inline void clear_m_fprodbizscore();
static const int kMFProdBizScoreFieldNumber = 1;
inline float m_fprodbizscore() const;
inline void set_m_fprodbizscore(float value);
// repeated float m_cvrScore = 2;
inline int m_cvrscore_size() const;
inline void clear_m_cvrscore();
static const int kMCvrScoreFieldNumber = 2;
inline float m_cvrscore(int index) const;
inline void set_m_cvrscore(int index, float value);
inline void add_m_cvrscore(float value);
inline const ::google::protobuf::RepeatedField< float >&
m_cvrscore() const;
inline ::google::protobuf::RepeatedField< float >*
mutable_m_cvrscore();
// 此处忽略一万行...
private:
// 此处忽略一万行...
::google::protobuf::RepeatedField< float > m_cvrscore_;
float m_fprodbizscore_;
// 此处忽略一万行...
};
// 此处忽略一万行...
- step3:生产/消费
示例代码不多写了,因为淘宝技术博客《玩转Protocol Buffers》上有较详细的说明,基本就是包含AEDocument.pb.h文件,然后定义AEDocument的一个对象,调用step2中生成的相应的get、set函数去读写这个对象。至于序列化、反序列化的接口,藏在基类::google::protobuf::Message的虚函数里,直接调用基类的相关函数即可,网上有一个博客《ProtoBuf 常用序列化/反序列化API》介绍pb序列化和反序列化的接口,使用例子都很详细。
动态消息的使用
动态消息的使用方式,《玩转Protocol Buffers》描述都很详细,有两种方式:一种是运行时设置需要的字段名、字段类型等;另外一种是运行前定义一个proto文件设置需要的字段名、字段类型等,线上动态编译这个proto文件,并调用相关的消息描述子(Descriptor)、字段描述子(FieldDescriptor)、反射器(Reflection)去读、写相应的字段。两种方式非常类似,这里只挑了第二个方法与静态消息做对比。
int main(int argc, const char *argv[])
{
DiskSourceTree sourceTree;
//look up .proto file in current directory
sourceTree.MapPath("", "./");
Importer importer(&sourceTree, NULL);
//runtime compile AEDocument.proto
importer.Import("AEDocument.proto");
// Descriptor
const Descriptor *descriptor = importer.pool()->FindMessageTypeByName("AEDocument");
// build a dynamic message by "AEDocument" proto
DynamicMessageFactory factory;
const Message *template = factory.GetPrototype(descriptor);
// create a real instance of "AEDocument"
Message *doc = template->New();
const Reflection *reflection = doc->GetReflection();
const FieldDescriptor *field = NULL;
// write the "AEDocument" instance by reflection
field = descriptor->FindFieldByName("m_fProdBizScore");
reflection->SetFloat(doc, field, 0.9);
field = descriptor->FindFieldByName("m_cvrScore");
reflection->AddFloat(doc, field, 0.8);
// read the "AEDocument" instance by reflection
float score = 0.0;
field = descriptor->FindFieldByName("m_fProdBizScore");
score = reflection->GetFloat(*doc, field);
field = descriptor->FindFieldByName("m_cvrScore");
score = reflection->GetRepeatedFloat(*doc, field, 0);
// delete "AEDocument" instance
delete doc;
return 0;
}
学以致用
一般情况下,我们在应用过程中要么选择静态消息,要么选择动态消息。所谓学以致用,上面调研了静态消息的动态消息的使用方式,在前述描述的问题背景下,我们应该怎么选择是我们要解决的问题。
静态消息 pk 动态消息
首先我们要先对比一下两种方式的优缺点:
- 静态消息优点在于protoc生成的静态C++代码对消息对象的读写效率非常高(下面有详细数据对比),缺点是在运行前需要静态编译生成,代码逻辑一旦上线,新增字段需要重新编译发布上线,“传统”的字段读写方式不是自反射的方式,无法在运行时根据实际情况灵活选择读写的字段。
- 动态消息的优点在于可以在运行时动态编译proto格式描述文件,在运行时自反射式地根据实际情况灵活选择所要读写的字段,缺点是读写的效率“相对”较差,尤其是反序列化的效率相对静态消息要差很多。
网上对静态消息和静态消息的性能对比不少,《玩转Protocol Buffers》上面也有一些对比实验,这里根据我们日常的需求做一些特别的对比:
数据采用的是目前排序线上用的真实数据,共950,000+的Doc,即对应950,000+的pb消息对象,每个doc循环做50次序列化反序列,最终把序列化的总耗时和反序列化的总耗时计算出来,除以(50*总Doc数),得到平均每个Doc的序列化、反序列化耗时。
消息使用方式 | 平均每个Doc的序列化耗时(ms) | 平均每个Doc的反序列耗时(ms) | 序列化后平均每个Doc的消息长度(字节) |
---|---|---|---|
自编码 | 0.000316604 | 0.000286629 | 94 |
静态消息 | 0.000688785 | 0.000537664 | 135 |
动态消息 | 0.00428008 | 0.00428008 | 135 |
可以看到,静态消息的序列化和反序列化耗时,是自编码的2倍左右;而动态消息的序列化和反序列化耗时,是自编码的14倍左右;动态消息序列化和反序列化的耗时是静态消息的7倍。从这个对比来看,如果在一次服务请求过程中需要进行大量的消息反序列化的话,动态消息明显不是一个理想的选择。
静态消息 与 动态消息 的和亲
回到我们前面提到的问题场景,由于一次排序请求下,我们后台需要对海量的Doc特征执行反序列化操作,因此在线上算分时,我们不能采用动态消息,不然搜索请求的QPS会下降非常明显。这要求我们在线上算分时,Doc特征的信息载体只能选择静态消息了……
然而如果按常规思路,线下特征生成阶段也用静态消息,会带来无法灵活增删改特征的问题。因为即使pb消息本身很好地解决了新旧消息兼容问题,但我们线下想增删特征时,按静态消息的使用方式,代码需要重新静态编译,走漫长的发布流程(DUMP中Gaia模块发布为什么这么慢这里不细表),总之,线下不能按常规思路与线上一样同时使用静态消息。所幸DUMP对Gaia的性能要求不高,所以一个Doc使用动态消息去序列化,即使要花4+微秒(即使将来涨到10微秒),也是完全可以接受的。
So,我们最终的解决方案是线下Gaia用动态pb消息去存储、序列化Doc信息,线上iRank用静态pb消息去反序列、存储Doc信息:
- 线下,我们可以通过每天更新proto消息描述文件的方式,不用走繁琐的发布就能决定哪些特征需要建到引擎的索引里,提供了灵活性;
- 线上,我们使用静态消息反序列化、存储特征信息,解决了索引兼容问题,性能也没有受到比较大的影响(我们通过一些工程改造把每个doc反序列化时丢失的0.3微秒追回来)。
不过……现在还不是庆祝的时候,因为我们在线上选择了静态消息,意味着即使线下新增了特征,线上要使用这些特征依然重新编译iRank插件并走发布;同时,由于静态消息是通过protoc生成.pb.h、.pb.cc提供给调用方做静态编译使用的,如果我们在读取特征时只是使用*.pb.h里提供的set和get函数去读取字段值(如上面所示的m_fprodbizscore()、set_m_fprodbizscore(...)函数),意味着线上部分是不能运行时决定使用哪个字段,只能在编译时硬编码,可配置化是没戏了……
为了解决这个问题,我们的目光回到前面介绍静态消息时protoc生成的.pb.h和.pb.cc身上去,我们仔细阅读一下protoc生成的*.pb.cc源代码
// AEDocument.pb.cc
// 此处忽略一万行...
void protobuf_AssignDesc_AEDocument_2eproto() {
protobuf_AddDesc_AEDocument_2eproto();
const ::google::protobuf::FileDescriptor* file =
::google::protobuf::DescriptorPool::generated_pool()->FindFileByName(
"AEDocument.proto");
GOOGLE_CHECK(file != NULL);
AEDocument_descriptor_ = file->message_type(0);
static const int AEDocument_offsets_[2] = {
GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(AEDocument, m_fprodbizscore_),
GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(AEDocument, m_cvrscore_),
};
AEDocument_reflection_ =
new ::google::protobuf::internal::GeneratedMessageReflection(
AEDocument_descriptor_,
AEDocument::default_instance_,
AEDocument_offsets_,
GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(AEDocument, _has_bits_[0]),
GOOGLE_PROTOBUF_GENERATED_MESSAGE_FIELD_OFFSET(AEDocument, _unknown_fields_),
-1,
::google::protobuf::DescriptorPool::generated_pool(),
::google::protobuf::MessageFactory::generated_factory(),
sizeof(AEDocument));
}
// 此处忽略一万行...
void protobuf_AddDesc_AEDocument_2eproto() {
static bool already_here = false;
if (already_here) return;
already_here = true;
GOOGLE_PROTOBUF_VERIFY_VERSION;
::google::protobuf::DescriptorPool::InternalAddGeneratedFile(
"\n\020AEDocument.proto\"9\n\nAEDocument\022\027\n\017m_fP"
"rodBizScore\030\001 \001(\002\022\022\n\nm_cvrScore\030\002 \003(\002", 77);
::google::protobuf::MessageFactory::InternalRegisterGeneratedFile(
"AEDocument.proto", &protobuf_RegisterTypes);
AEDocument::default_instance_ = new AEDocument();
AEDocument::default_instance_->InitAsDefaultInstance();
::google::protobuf::internal::OnShutdown(&protobuf_ShutdownFile_AEDocument_2eproto);
// 此处忽略一万行...
}
// 此处忽略一万行...
我们注意一下protobuf_AssignDesc_AEDocument_2eproto、protobuf_AddDesc_AEDocument_2eproto这两个函数的实现,实际上前者执行时调用的后者:
- protobuf_AddDesc_AEDocument_2eproto里代码是不是很眼熟,跟*.proto文件中的字段类型描述是不是很像,其实这也是一个“动态编译”消息schema并注册到全局空间的过程,这个schema与AEDocunent是完全等价;
- protobuf_AssignDesc_AEDocument_2eproto的作用,简单来说,就是通过protobuf_AddDesc_AEDocument_2eproto在全局空间下注册一个名为“AEDocument.proto”的FileDescriptor,并把类AEDocument中各成员变量基于对象起始地址的偏移量存储起来。
通过这两个步骤,实际上在全局空间存储了一个与AEDocument完全等价的动态消息的schema,protobuf_AssignDesc_AEDocument_2eproto函数里::google::protobuf::DescriptorPool::generated_pool()->FindFileByName("AEDocument.proto")这个函数调用也暴露了,我们第三方调用方完全可以用同样的方式获得这个与AEDocument等价的FileDescriptor。
FileDescription、Descriptor、FiledDescriptor三者的关系,如果大家阅读过《Google Protocol Buffer 的使用和原理》这篇文章,应该清楚
- FileDescriptor对应一个.proto文件,里面包含了.proto文件里定义的所有message的schema信息(即Descriptor);
- Descriptor对应一个Mesaage的schema信息,里面包含了这个Message里的所有字段的schema信息(即FieldDescriptor);
- 有了FieldDescriptor,通过Reflection我们就能运行时决定要访问Message里的任意一个字段,真正实现自反射。
So,在静态消息上如何使用自反射机制的解决方法找到了!
#include "AEDocument.pb.h"
int main(int argc, const char *argv[])
{
const google::protobuf::FileDescriptor *pFileDescriptor = ::google::protobuf::DescriptorPool::generated_pool()->FindFileByName("AEDocument.proto");
// Descriptor
const Descriptor *descriptor = pFileDescriptor->pool()->FindMessageTypeByName("AEDocument");
// DynamicMessageFactory
DynamicMessageFactory factory;
const Message *template = factory.GetPrototype(descriptor);
// FieldDescriptor
const FieldDescriptor *field = NULL;
// static pb message object
AEDocument *doc = new AEDocument();
// Reflection
const Reflection *reflection = template->GetReflection(); // 或const Reflection *reflection = doc->GetReflection();,效果一样
// write the "AEDocument" instance by reflection
field = descriptor->FindFieldByName("m_fProdBizScore");
reflection->SetFloat(doc, field, 0.9);
field = descriptor->FindFieldByName("m_cvrScore");
reflection->AddFloat(doc, field, 0.8);
// read the "AEDocument" instance by reflection
float score = 0.0;
field = descriptor->FindFieldByName("m_fProdBizScore");
score = reflection->GetFloat(*doc, field);
field = descriptor->FindFieldByName("m_cvrScore");
score = reflection->GetRepeatedFloat(*doc, field, 0);
// delete "AEDocument" instance
delete doc;
return 0;
}
当然了,线下新增特征,线上要使用新特征还是要更新*.proto,并重新编译走一次发布。所幸目前的自动化回归测试机制能解决很多线上iRank模块发布的成本,这种schema的变更和其他配置更新一同上线,还是轻量级的操作,所增加的成本并不明显。
结论
- 为了性能,我们线上不能选择动态消息,schema不能通过配置的方式去更新,只能走静态编译的方式去更新上线,但pb本身在静态消息的实现过程中并没有抛弃动态消息的自反射机制,而是通过一种等价的方式在*.pb.cc里实现了,我们通过这种新的途径可以继续享用自反射的便利,同时索引兼容和反序列化的性能得到的保证;
- 线下我们在反序列化并不是瓶颈的地方,通过使用动态消息达到了灵活增删特征的效果。
理想是丰满的,现实是骨感的,在现实中我们总能找到一个平衡点~
参考:
- 《玩转Protocol Buffers》:http://www.searchtb.com/2012/09/protocol-buffers.html
- 《ProtoBuf 常用序列化/反序列化API》:(http://blog.csdn.net/sealyao/article/details/6940245
- 《Google Protocol Buffer 的使用和原理》:(http://www.ibm.com/developerworks/cn/linux/l-cn-gpb/?ca=drs-tp4608