Google Protocol Buffer使用经验分享(一) C++动态消息与静态消息的博弈

写在前面

  相信正在浏览这篇文章的同学,一定已经对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里实现了,我们通过这种新的途径可以继续享用自反射的便利,同时索引兼容和反序列化的性能得到的保证;
  • 线下我们在反序列化并不是瓶颈的地方,通过使用动态消息达到了灵活增删特征的效果。

  理想是丰满的,现实是骨感的,在现实中我们总能找到一个平衡点~

参考:

  1. 《玩转Protocol Buffers》:http://www.searchtb.com/2012/09/protocol-buffers.html
  2. 《ProtoBuf 常用序列化/反序列化API》:(http://blog.csdn.net/sealyao/article/details/6940245
  3. 《Google Protocol Buffer 的使用和原理》:(http://www.ibm.com/developerworks/cn/linux/l-cn-gpb/?ca=drs-tp4608
上一篇:Rtti实现对象的XML持久化


下一篇:WinRAR命令行参数[转]