specpaxos代码库的一些讨论
我曾经想过很多次,ddl之后一觉醒来的第一件事我会做什么。我从没想到自己是在写这个。
当然转过来想想是很合理的。毕竟一时半会我的脑子里什么别的都没有。我的心又受了C++和分布式联合带来的重创。我还能做什么呢。
趁热打铁,总结一些specpaxos衍生出的这套codebase的瑕疵。各种类型的问题都有。此时此刻我当然是抱着为重写做准备的心态来总结的。希望后续可以切换到一个更理性的状态。
protobuf的性能和其他问题。代码库中纯计算任务(本身也不多)大体上都有着合理的性能开销(并行模型下面再讨论),只有序列化/反序列化慢的离谱。在0/0性能测试中反序列化的时间稳定大于100%的消息处理时间,而且常常达到两到三倍的延迟比。这也导致了对于baseline把反序列化拿到工作线程中去可以使吞吐翻倍这种离谱的现象。另外,使用带有代码生成的方案对于编辑器的代码分析不算友好。通常有代码生成环节的工作流程都是生成一次代码然后一万年不改变(或者永远向后兼容的改变,也是protobuf设计的一个重要目标),然而这并不适用于这个项目的场合。
说到性能,其实无论是protobuf还是内核/libevent执行模型,都有一个共同的性质就是在架构设计初期没有预想到后续工作在延迟方面突飞猛进的进步。在吞吐量预期100K左右时,每个op有10us的 线性 时间配额,也就是说可以流水线化的步骤不需要算在这10us里,那么拿出2-3us去做一些基础设施服务是可以接受的——更不用说很多协议对replica之间通信的要求使得所有本地的延迟都隐藏在巨大的网络延迟之下了。然而当性能逐渐朝着400-500K发展时,每个op只有接近2us的样子,这时候把消息解包出来就花了1us多,后面的工作啥也不用干了。不管今后还要再做什么工作,对延迟/吞吐量的目标永远不会走回头路。可以说,达到这个性能区间以后量变逐渐引起质变了。
对于common和lib两个目录的代码组织问题。理念上来讲,lib应该只有涉及模型架构的定义,核心就是Transport
和TransportReceiver
两个界面,以及紧密相关的Config
等等。除此之外从各个协议实现中抽出的通用代码都应该放在common中,如打印日志和性能分析等等。当然只是移动一下文件而已问题不大。
少量应该采用值语义的对象用了引用语义,主要是TransportAddress
和LogEntry
。和设计上留下的限制有一定关系。所有的协议实现都用了引用语义的Timeout
,没有看出这有什么必要。
说到设计上的小缺陷,还有另外两个。一个是TransportReceiver
无法分辨数据包的来源。这主要是因为对multicast的支持大概是后期临时糊上去的(虽然从最初的specpaxos就要用到这个功能了)。从设计上TransportReceiver
的意图是对单一地址的listener,所以接口里面完全没有这个考虑,导致后面大票的协议只能在数据包当中用专门的字段来区分。这也不一定是一件坏事,毕竟很多时候本来也要在multicast包里包含特殊的信息。但是在应对丢包从其他replica恢复的时候,不能直接从包的来源来区分就有点尴尬了。在很多时候在state transfer reply外面专门包一层倒也没什么,但是等到数据包本来就被包了很多层的时候,结合Message
设计的不足就有点要命了,后面再说。
另一个小不足是TransportAddress
和ReplicaAddress
。前者是一个不透明的地址对象,对TransportReceiver
隐藏细节;但是有的时候又不能完全隐藏,所以通过ReplicaAddress
来提供序列化等等功能。两者并没有分立的必要。另外ReplicaAddress
与IP和端口强绑定也是没有必要的。考虑一个纯粹由DPDK程序构成的网络,采用MAC地址+lcore id来标识一个TransportReceiver
可能是更好的选择,还能提供一个完全省略IP+UDP层的可能性。
关于Log
的设计,这个比较深厚。Log
应该作为一个纯数据结构存在,不应该包含任何协议逻辑。我相信最初Log
是以这个理念设计的,但它选择了和viewstamp强绑定。也许从一开始viewstamp被看作了是一个replication协议必然依赖的概念和组件吧。但是实际上viewstamp的偏序关系很多协议都用不上,现在又加入了一些transactional协议也不一定要view了,还有一些协议只有view number和op number还不够用,导致viewsteamp里面被逐渐塞进去了越来越多的东西。扯得更远一点,有些基于区块链的协议,其Log
格式已经不是线性序列了。所以,基于对Log
的结构的任何假设,为它实现协议的逻辑,是不实用的。
更进一步的,Log
本身的目的应该是作为 一致性 的具现化表现。有些协议并不要求每一个replica都执行上层应用,但要尽量让所有人都以同样的顺序记录所有的op。从这个角度来说,一个Replica
中反而不一定要有AppReplica
但是一定要有Log
。更好的做法也许是把AppReplica
重新定义为Log
的子类。在Log
上只提供充足的数据操作,以及检查多个Log
是否具有一致性的断言功能,这对于单元测试很重要。
本来准备按照各个功能引入的前后顺序来写,还是先写说起来简单的吧。从整个架构定性以后加入的各个 patch 基本都是欠妥的。上面提到的臃肿的viewstamp_t
,以及侵入到各处的shard number
,都违反了 不为自己用不到的功能付出代价 的原则。类似的还有给Execute
方法加入的两个void *
参数。先不提看到void *
就会死的代码洁癖,因为它打破了老代码的override,在同一个地方坑了我两次,我就已经恨死了。
总体上来说,系统中的每个replica都应该有一个公开的replica id,这是必要的。但是跟shard有关的信息尽量不要下沉到各个模型定义里面,作为子类和子类之间约定俗成的细节更好一点。
后来一系列专门为了调整架构的小重构,大体上都是正确的,比如允许client选择使用的IP/interface。
对于消息对象的改变有点喜忧参半。把消息类型下沉到protobuf的oneof
,以及把消息序列化上浮至TransportReceiver
,从当时的考虑来说都是没问题的,但是后来情况有了很多变化……
关于消息类型,这个是按照接受消息的一端有可能接受哪些种类的消息来分类的。由于协议的限制,在oneof
之外有时候还需要进行自定义的wrapping,比如所有涉及sequencer的协议都要自己定义这一层的消息布局,因为sequencer看不懂protobuf。在nopaxos和eris的时候,只包了外面一层还可以接受,顶多就是所以其它的replica之间的通信都要被处理一下;然而到了BFT协议,外面包完了里面还得包,因为很多时候不能把收到的带签名的消息拆开了再重新装回去,签名可能就失效了,所以必须按照收到的二进制序列原样保存和发送,这样一来,上下两层之间硬要夹一个oneof
就尴尬起来了……
由于原样转发的要求,还导致了很多协议中由于用到了
BufferMessage
而莫名其妙依赖sequencer.h
的奇特景象,哈哈。
另一方面,在TransportReceiver
里做消息解析从定位上来说的确是合理的,然而问题是TransportReceiver
是一个天生顺序线性独占执行的对象,而处理消息本身又太慢了不得不并行优化(就算替换掉了protobuf,到了有签名的消息还是要遭重),目前拿Runner
勉强糊了一下,但显然把这个调度模块塞进Transport
还是更合理的做法。唉,只能说这是一个工程倒逼设计的惨烈现场。
说到签名,关于数字签名的身份管理系统,这个东西整个代码库里是没有的,也找不到一个合适的地方可以临时糊一下:身份应该是一个可以从TransportAddress
查询到的东西,这就要从根上开始改了。所以现在其实所有replica用了同一个身份"Steve"
,这样也就不用查谁的身份是什么了。最起码还是得做到libhotstuff的程度,每个replica能设置不同的公私钥才行。
关于Message
的设计已经提前吐槽过一次了。简单的说层数太多的时候写起来非常麻烦,格式类似于
proto::Message m;
auto &some_case = *m.mutable_some_case();
some_case.some_data = some_data; // 重复直到设置所有需要的字段
PBMessage pb_layer(m);
SignedAdapter signed_layer(pb_layer, identifier);
// 也许这里再来一个TOMBFTLayer……
// 最后才能拿给transport
当然,按理说只要每一层都提供移动构造函数,可以把这些层给嵌套到一行里面去,但是局限性很强。先不说目前的Message
模型是引用语义,要怎么提供移动构造函数;这个是发消息,收消息的时候就怎么都省不了了。更不要提在一些很限制的场合,必须要通过protobuf包签名层包protobuf来实现,还得手动调用Serialize
,就更加麻烦了。
此外Message
本身的设计也有点瑕疵,作为引用语义把序列化和反序列化合二为一,在只序列化的时候也不能用const
引用来构造(因为构造的时候也不知道是想序列化还是反序列化),从而导致一些不美观的mutable
之类的。总而言之,这个其实就是在设计Message
的时候完全是照着就一层PBMessage
来设计的所导致的,没想到后来事情一发不可收拾= =
引用语义的一个坏处就是传参很难。比如处理一个需要存下来的带签名的消息,只传验证过的protobuf对象是不行的,再单独传一个签名也是不行的,必须得把原始的整个二进制序列都传掉。如果出现了嵌套签名消息的消息那参数就更多了。这还会导致一个潜在的生命周期维护问题,尤其是在多线程环境下,跨线程复制以后,携带的引用基本上肯定是坏掉的了,写起来太容易出错了。当然,直接改成值语义也有它本身的问题,有些场合反而也不合适。
写了不少了,最后写一个比较重要的Transport
收包设计。在单线程工作模式下,收包和处理包抢夺同一个线程,在软件层面上其实没有消息缓冲区,没处理的包都缓存在网卡队列里面。一般来说这个队列肯定是够的,但是也存在着像TOMBFT这种三个速度快的replica联手把一个慢的replica干爆的情况,这种情况下的网卡队列丢包是非常不可控的,后果非常恶劣,一旦发生基本上软件层面没有任何挽回的办法。一个更好的做法可能是把所有包都收上来,在软件层面缓存,这样一面可以保证不存在网卡丢包,同时通过一些精巧的设计,也许可以优化处理包的顺序,比如把state transfer reply拉到最高优先级之类的。
整个codebase处在有一点历史包袱但比较整洁的状态。手写的Makefile不算尽善尽美,但也没损失什么。
在C++层面上,工程层面的提升选项主要有改写成全头文件形式,和使用比较现代的构建系统比如meson。基于个人口味一些工具组件相比自己写的私有代码更偏好依赖开源库,但这都还好。
如果可以从一开始就围绕DPDK,将其作为一等公民,将内核和模拟Transport
都当作是辅助测试工具,也许可以有助于设计出更适合超高性能的协议的架构。
在C++之外,主要可以考虑Rust和Go。Rust在其允许的范围内,想要把代码写对写好比C++容易太多了。但是在这个项目里面确实有一点点Rust不允许我们写的成分。另外Rust还是很新,大部分需要的基础设施确实都有好的选择,但几乎都只有唯一的选择,如果不好用就会被绑架。
Go的话对于写并行很方便,虽然这个项目的并行主要应该封装在Transport
里面,不会到处都涉及,但有GC也挺好,而且工程项目的特点就是到处都是例外情况。有GC也许对性能不是一件好事,但是这个项目其实不需要GC,除了数据包以外没有任何需要对象是创建完需要在程序终止之前回收的。Go是有值语义的语言,所以GC压力和Java根本不是一个水平。但基于个人喜好大概还是不会考虑。
总感觉还漏下了不少,大概先写这些吧。