前言
这篇文章是想表达我对系统软件的一些理解,风格跟之前的不太一样,整体偏“务虚”。我自己其实是不太擅长“务虚”的,甚至是有点排斥。就跟相比起看论文,我更喜欢看code,当然我也看论文,只不过相对来说少些。 毕业以来一直在数据库存储引擎领域工作,过去5年主要精力集中在阿里自研LSM-Tree存储引擎X-Engine研发上,并且在过去两年多时间我们完成了X-Engine的云原生架构升级和商业化,在公有云上承接一定规模的客户并稳定运行,在业界应该也是首个基于LSM-Tree架构实现云原生能力的TP存储引擎。
完整经历一个TP存储引擎的架构规划、设计研发、落地上线,稳定性运维的全周期,并且得益于从我进入数据库领域一路以来经历的高水平团队、technology leader以及整个团队成员的出色工程能力和技术视野,加上我自己在此过程中的一些思考,阶段性的形成了一些自己的心得体会。 另外,跟业界一些优秀的架构师和工程师交流,发现对于系统工程的理解有很多的共鸣,也收到很多非常有价值的输入,当然也存在一些不同的观点。这也是促使我写这篇文章的主要原因,希望能将我自己的一些理解表达清楚,这些观点并不fashion,更谈不上创新,更多的是一些自己的思考和经验之谈。
对系统软件的看法
观点1:软件的本质是对硬件资源的消耗。不同软件的区别在于,消耗硬件资源去解决什么问题以及如何分配硬件资源的消耗。软件架构设计中经常提到"抽象"和“trade-off”,抽象本质上的就是"解决什么问题","trade-off"其实就是"如何分配硬件资源"。 举个例子TP存储引擎和AP存储引擎,从实现上可以列举出一大堆不同的地方,行存 VS 列存、二级索引 VS ZoneMap索引、强事务 VS 弱事务等等。这些不同之处其实都是结果,导致这些的根本原因是:
1)两者解决的问题不同,TP场景主要是online实时业务,这些业务的特征是整体数据规模相对较小(真正需要online处理的数据,历史数据可能很多)、请求短平快、数据locality明显、高并发低时延等,而AP场景整体的数据规模大、计算密度高、高吞吐等。(解决什么问题)
2)TP引擎的完整事务支持使得业务的并发控制简化很多,其实就是把业务系统本来需要做的事情,TP引擎自己做了,当然也就意味着TP引擎需要为此消耗一部分硬件资源。而AP引擎为了加快数据入库的速度,事务的支持比较弱,这部分工作还是由业务系统来完成(比如ETL),也就不需要为此消耗硬件资源。(如何分配硬件资源)
观点2: 系统软件的重大变革,背后基本都是硬件发展所推动的。这跟观点1)是相呼应的,系统软件领域的理论在进入21世纪之前,学术界已经做了广泛深入的研究。从最开始计算机的出现,到大型机和小型机,再到家庭PC和廉价通用服务器,以及现在的云计算IAAS服务,基本上系统软件发展也是跟随这个脉络在发展。系统软件的再次火热,本质上也是因为IAAS这个“新硬件”所推动的。整个IAAS的on-demand获取,打破了系统软件之前在物理资源受限的背景下很多设计,这也就是为什么云原生系统软件会迎来新的机会。
观点3: 几乎不存在某一种系统架构全面领先另外一种架构。这跟观点1)2)是相呼应的,不同的架构选择背后都是不同的trade-off,所谓有得必有舍。经常听到一些说法,你看这篇论文、这篇文章,他们这种架构就没有某问题,我们这种架构就有这个问题。我听到这些观点的第一反应是质疑,这里边主要有三个原因:
1)很多论文和文章的实验结果是没法复现的,也就说很有可能他的结论就有问题;
2)很多时候只会强调“得”的部分,而“舍”的部分是没有讲的。
3)我们系统所存在的问题到底影响有多大,是不是可以解决的,这些需要量化的数据才能确定。轻易地被各种论文和文章的结论影响,很有可能会做出一个不伦不类的系统。就像习武之人各个门派的武功都学学,最终很容易走火入魔。
观点4:条条大路通罗马,最终系统对外呈现的区别,更多的是工程实现的原因,而非架构的原因。不同的系统架构需要解决的大部分问题本质上其实是一样的,并且组成一个系统的零部件都差不多,只是根据需要选择哪些零部件来构建系统。只有躬身入局,真正地去面对问题、分析问题、解决问题,才能认清楚其中的本质,否则很容易变成纸上谈兵。
举个例子:经常有人问我LSM-Tree架构中持续写入数据时,compaction问题对性能影响很大。这个问题我是这么看的,首先LSM-Tree架构上写入吞入优势的其中一个原因是,相比于innodb这种磁盘B+ Tree在写入的时候直接sort on write(page内有序,全局有序),LSM-Tree架构选择将一部分sort转移到sort on compaction、sort on read,本质上是将写入时排序的资源消耗,转移到了compaction或read。
刷脏其实是包含两个动作:生成脏页,将脏页刷盘。innodb相当于是在写入的时候生成脏页,在刷脏的时候就是单纯的io操作。而compaction其实是同时做了生成“脏页”和“脏页”刷盘。innodb如果持续写入的话,也会有刷脏来不及时导致影响写入性能的问题。因为innodb刷脏和compaction之所以成为问题,本质上都是因为内存和磁盘写入速度的差异,导致生产者消费者模型失衡。所以innodb的刷脏和LSM-Tree的compaction本质上是相同的问题,只是通过不同的方法来将这个过程对系统的影响降到最低。
系统软件构建的七个面向
接下来的内容,主要是在进行详细设计的时候我认为比较重要的原则。这些原则的道理其实很容易理解,并且“软件工程”这门学科已经研究的很充分,但是实际操作的时候其实是蛮困难的,可能是历史包袱的原因,也有可能是外界环境的原因,需要根据实际情况做出不同的trade-off。值得注意的是,我们做出的trade-off一定是要经过仔细考虑的,而不是草率的,否则很容易出现“有舍没有得”。
另外遵守这些原则设计实现出来的系统和不完全遵守这些原则设计实现出来的系统,结果其实是“好和更好的区别”,但是“好多少”这个量在系统做出来之前,其实很难衡量。这七个原则不是独立存在的,而是相辅相成的。
面向场景: 首先我们需要明确要解决什么问题,这是整个系统构建的出发点。one size fit all的系统在过去是不存在的,在未来也不一定存在。系统的完善,必然是要靠不断的迭代来完成的,那么如何迭代本质上就是我们在那些阶段解决哪些问题。一个系统可以有远大的目标去解决很多问题,但是所有问题的路标需要有相对清晰的规划,以达到既可以快速满足需求,同时保留向未来演进和扩展的基础。
实际研发过程中,可能发生的两类错误是:
1)想采用敏捷开发的方式来进行工程管理,以满足整个迭代的需求。敏捷开发本质上先定义最小功能集,也就是首先想清楚解决什么问题,然后快速的迭代扩充功能,有点像小步快走。在实操上,很容易把敏捷开发搞成了"快、糙、猛",有点大干30天赶英超美的味道。
2)问题定义不清楚,系统的“不变式”设置就容易草率。每个系统都有一些“不变式”,随后很多设计都是基于这些不变式进行展开的,比如在LSM-Tree系统中一个常见的“不变式”是更新版本的数据在更低的层次,同一行的数据的多个版本如果同时在memtable、level0、level1中存在,那么必然memtable中对应的版本是最新的,level0中的版本也比level1中的更新。如果在迭代的过程中发现之前设置的“不变式”不合理的,那么进行改动的代价是非常之大的。
面向解耦:无论是自上而下的去设计系统,还是自下而上的去设计系统,很重要的一个思考逻辑就是将各个模块间的耦合度降到最低。解耦做地比较好的系统,往往意味着:
1)每个模块的功能是考虑的比较清楚,方案的完整度是比较高的;
2)有利于专注的将某个模块实现的更加高效,避免其他模块的影响;
3)有利于之后的迭代,影响面可控;
4)出了问题好排查,单个模块的问题是比较好排查,真正那些难搞的问题往往是问题在各个模块间传导后才暴露出来,比如A模块出问题,经过模块B、C、D,最后在模块E暴露出来。
有些质疑的观点会说,面向解耦的思路去设计,有可能会牺牲系统的整体性能。其实这个跟不要一开始就为性能做过度的设计是一样的道理,真到了某些解耦的设计影响了性能,那么该耦合的就去耦合。
把两个模块耦合在一起的难度往往是低于把耦合在一起的两个模块拆开。 面向防御:这个就是防御性编程的逻辑,要假设调用的函数都是有可能出错的, ,比如内存分配可能出错,io可能出错,基础库的调用可能出错等等,基于此来考虑如果出错,系统的行为是什么。有一个非常简单的原则就是"fail stop", 如果没有完整的防御,那么即使fail了也很难立即stop,最终造成一些很奇怪的表象。 通常的质疑是:
1)你看这个函数的逻辑肯定不会失败的。也许从当前来看这个函数确实不会失败,但是很难保证随着迭代增加逻辑,之后没有失败的可能性。
2)加了这么多防御,防御代码比实际逻辑的代码还多,会影响性能。首先,现在cpu的分支预测能力,基本上可以做到绝大部分情况下防御代码不会影响性能。另外跟对于面向耦合的质疑一样,真到某些防御代码成为了性能瓶颈,该优化就优化。优化一个防御,总比去解决一个因为没有防御而导致的问题代价更低吧。
面向测试:在测试阶段修复问题的代价是远低于在生产环境修复问题的代价,因此让系统变得可测试是非常重要的。系统可测试的标准就是,能方便的进行单元测试、集成测试,并覆盖绝大部分的代码路径。可测试的系统,随着不断的迭代,会累积越来越多的测试case,不断的夯实稳定性基础。面向测试跟面向解耦、面向防御是相辅相成的。只有模块间耦合度足够的低,才有可能做更多的测试,否则做一个模块的测试需要mock很多乱七八糟的东西。面向防御会使得测试的行为可以更好的预期,不然输入了一个异常的参数,具体怎么失败是不确定的,那测试case就很难写了。
面向运维:bug是一定会有的,对于复杂的系统,不管前期做多少准备都很难避免生产环境中遇到未知的问题。面向运维的主要目的是,遇到问题的时候,能用代价最低的手段去及时止损。遇到线上问题,动态调参数就能解决比需要重启才能解决的代价更低,重启能解决比需要发版才能解决的代价更低。面向运维不仅仅是加几个参数,加几个开关那么简单,而是需要把“面向运维”作为设计方案的重要组成部分来考虑,保证出了问题有运维手段,有运维手段敢用,用了以后有效果。
面向问题本质:当去解决一个问题的时候,一定要多思考这个问题的本质原因是什么,简单的问题复杂化和复杂的问题简单化,都是因为没有抓住本质。如果能思考清楚其背后的本质原因,从源头避免掉是更加彻底的解决方式,否则很容易陷入不断打补丁的状态,我一直有个观点:“没有抓住问题本质去解决问题,结果往往是在制造问题”。另外一个经验是,如果一个模块连续出了好几次问题,那么就要想想是不是在最开始的设计上就有需要改进的地方。
面向可视化:可视化的目标主要是以更加直观的形式,来展现系统运行状况,这对于系统调优和诊断是非常重要的。当系统异常时,可视化的方式可以帮助快速定位到系统哪里出了问题。另外一方面是,可以提供接口给监控系统做历史状态的追踪。比如oracle的诊断监控就是一个非常优秀的案例,而SnowFlake对于内部状态的打点监控也是近乎疯狂。
总结
说了这么多,最终系统还是靠一行行的code实现出来的,保持匠心、严谨、较真的态度去打造系统是非常朴素正确,但又很难做到的事情,共勉!