Microservices陷阱: 切割单一系统
前面已经提到,构建microservices的第一步即划分服务边界,而对新系统直接进行边界划分具有相当的风险,且通常是不被鼓励的。我们建议选择一个较成熟的codebase再进行服务切割。
1.辨识服务边界
面对已有系统,首先需要做的就是辨识其中隐藏的服务边界。在《Working Effectively with Legacy Code》中,提到架构“缝隙”的概念。我们几乎可以把“缝隙”和“边界”等同起来,实际上DDD中的服务边界即一种好的“缝隙”。那么如何找到“缝隙”则是面对遗留代码所需的第一站。 许多编程语言提供一种自然的封装方式,比如namespace,java的package也拥有类似的概念,当然并非所有语言如此(如javascript)。 在进入代码之前,首先应该至少理解高级别的边界上下文抽象O,这将有利于我们进行逐渐向下的切割。当拥有O后(当然O可能并不完美,但这不是必须的),我们可以逐步把代码移动至相应的O’中。在这一过程中通常就能发现其中存在的问题,比如更细粒度级别的边界、错误的依赖、以及其它,借助代码分析工具可能会提高一部分工作效率。 当然,面对小型系统,你可以指望在数天、甚至一天内完成全部分析工作,但是多数情况下这可能是数周、或数月的付出。因此还应注意任务分解、确定优先级、循序渐进地切割新服务。
切割单一系统的好处
尽管前文已经无数次提到,但这里还是简单做一下总结:相互独立的自治单元更易实现变更、根据所属服务划分团队职责、针对特定服务的安全性增强、技术更新更加频繁…所有以上会是单一系统经过合理切割之后带来的好处,然而如果你不确定这些是否真的对你有“帮助”,还是谨慎一些比较好。
2.解决依赖缠绕
当切割服务边界时,如何处理新旧系统之间的依赖缠绕则成为随之而来的问题。前文提到过建模工具,它能更容易地让你发现设计是否基于有向无环图从而避免了明显的缠绕关系。当然,实践表明几乎所有的缠绕都和数据库模式相关。事实上到目前为止,切割服务就像是纸上谈兵,数据库才是大boss。 基于之前的实践,首先尝试依照服务把repository层进行垂直切割,也就是分表。SchemaSpy等类似的工具能够根据代码生成类UML的可视化分析结果,从而提高理解程度。
解除外键关系
如果确实存在外键依赖,唯一能做的就是自己维护一个与外键类似的字段,然后按需要手动解决一致性问题(这通常与业务有关,也就是说,有时需求根本不要求强一致性)。
静态共享数据
例如国家代码、区划等数据,一般来说有三种解决途径:在多个服务间实现冗余拷贝;通过静态配置文件消除代码和数据;或者直接建立独立服务,并在代码中内嵌静态数据。多数情况下,静态文件会是一种最简便的解决方法。
动态共享数据
此类数据比外键关系更为复杂,实际上出现的几率也更高。对于此类情况,通常是由于数据库中隐含了某种领域概念,而实际上这种领域概念理应由代码直接表示出来。当抽象出该概念模型后,就可以把共享数据转变成独立服务了。
动态共享表
为了降低数据冗余,有时会把分属于不同领域概念的数据放在一张表中,这就是动态共享表。为了进一步划分服务边界,应允许适当的冗余,也即垂直分表,但这里的分表并非是抽象出独立服务,而是分配到不同服务中。
3.重构数据库
正如前文所述,切割服务确实需要重构数据库,而这一领域又少有人涉及,如《Refactoring Databases: Evolutionary Database Design》。而在预上线环境,应尽可能先部署包含两套schema的但仍然保持单一的版本,然后逐渐分离服务代码,直至成为两个不同的codebase。之所以要循序渐进,是因为隔离schema带来的第一个问题就是影响了后台数据读取操作,由原先的若干查询语句,成为API请求-查询数据库-响应模式,这就直接破坏了系统原有的事务集成特性。因此,应当在处理好这一部分之前,尽量不要独立上线新服务.
事务边界
在单一schema系统中,事务的好处是能轻而易举实现操作的一致性。而对分布式系统而言,事务边界被一分为众,数据一致性将面临挑战。
稍后重试
一致性的首要目标是处理中间出错的情况,在许多场景中,“强一致”是非必要的,而通常我们仅要求“最终一致性”。在这种情况下,可以设置一个队列或日志文件,存储数据库操作及其状态,并对出错操作进行特殊标记,稍后重试该操作,直到数据成功写入。该方法的前提是假设重试操作必然有效。
全局操作中断
数据回滚是另一种解决方案,但需要另维护一份回滚代码,从而保证错误数据被及时清除。问题在于保证回滚代码能够正确执行,可能需要采用前一种不断重试的方法。然而随着回滚数据的增多,该方法的有效性就会降低,成本则会不断增加。
分布式事务
分布式事务通过设置一个中央协调进程,监控所有执行节点的状态。一旦接收到事务请求,中央进程会向所有执行节点发出投票通知,执行节点在接到通知后会检查自己的数据提交状态,成功则返回赞成票,否则反对。只有当中央进程接收到所有节点的赞成票时,才会再向所有节点发出执行通知,每个节点分别执行数据提交,这就是朴素的分布式事务“两段提交”算法。 然而分布式事务算法始终不是“绝对正确”的,比如执行节点在投“赞成”之后出错。此外,中央协调进程的存在会使得所有资源被加锁,进而存在资源竞争行为,降低系统可用性。 目前已经有一些针对分布式事务的实现,如Java的事务API。但是,分布式事务带来的副作用是必须要考虑的问题。
小结
保持一致性会带来更多的复杂因素,例如分布式事务会降低可用性和伸缩性。当一致性需求发生时,首先应思考它的必要性,或者采用局部事务、最终一致性加以替代。如果确实是强一致,应尽量避免切割。如果一定要切割,也可以选择设计一种混合抽象来代表事务本身,从而降低分布式事务的复杂度,进而保证可用性(例如在销售系统中,分离出一种“处理中”的订单服务,从而降低销售和库存服务之间的耦合)。
4.审计报表
审计报表系统是企业应用里常见的需求,而随着微服务架构的演进,该类型的系统将面临重构。在单一系统中,报表很可能只是意味着若干SQL语句的集合,顶多在架构层面增设一个读库专供报表使用,读库和主库之间采用定时同步策略。 对微服务而言,上述方法存在一定缺陷:首先,数据库schema将以服务与报表系统之间共享的形式存在,这就导致任何变更需要格外小心;其次,许多数据库提供优化能力以提高读写性能,然而当处于微服务环境时,由于数据结构的不确定性,很难在报表数据库中实现任何优化;此外,随着异构数据库架构越来越普遍,SQL、NOSQL的混合使用将使得数据融合成为新挑战。
采用服务调用获取数据
假设一个商业系统报表,常见需求是列出最近15分钟产生的订单。在微服务中,这可能需要跨服务的数据调用。然而,当数据体量过大时,这种方法就会变的效率低下,例如条件变为过去24个月的订单信息查询,如果采用备份数据的形式存放在报表系统中,可能会导致非一致的结果。 一种有效方案是定时从主库中提取数据到报表库(通常是一个关系型数据库),仍然有几个问题需要解决:首先,不同微服务暴露的接口可能并非报表友好的,如果硬要基于现有API,可能会导致一些其它问题,例如数据的额外处理、cache失效等等。因此在针对可能存在的大数据传输问题,一个好的办法是目标服务并不通过HTTP直接返回数据内容,而是以文件的形式转存至第三方位置,这样主服务就可以通过轮询文件生成状态从而实现HTTP的低负载通信。
数据泵
除了拉取数据这种方式,也可以尝试采用数据推送的办法。传统HTTP的缺点是链接耗费较高,一方面是底层协议原因,另一方面是报表系统请求的API次数较高(有时该API甚至只为报表提供服务)。一种高效方案是设立一个第三方的数据泵,其同时拥有主数据库和报表数据库的访问权限,定时把主库的更新数据同步至报表库中。该方法唯一要解决的问题就是schema的管理,而事实上,报表库的schema可以看作是published api,泵程序最好和目标服务共同由一支团队开发,这就尽可能保证了schema的同步。
事件数据泵
数据泵是一种有效的数据同步方案,但同步时机缺乏验证。对微服务而言,当某个服务产生状态迁移时,可通过发出一个特殊事件通知泵程序,使后者能够订阅、接收并处理相关事件,从而实现事件驱动的数据同步。此外,为了减小通信压力,报表服务可以只提取差异数据,而非全部,这就尽可能避免了广播报表更新事件与相关数据所带来的副作用。然而,这是一种有效的近实时同步报表解决方案。
备份数据泵
该方法被用于Netflix的报表系统中,基于现有的备份方案。Netflix采用Cassandra作为其主数据库,并在此基础上构建了许多备份服务应用(详见github)。为了备份Cassandra,常规做法是生成一份数据文件(SSTables)拷贝并存放至第三方,例如Amazon S3。报表服务是基于Hadoop实现的大数据处理框架Aegisthus,其作用与本文中数据泵的概念类似。
5.小结
面向实时
由于业务需求的不同,许多服务,比如dashboard、alerting、financial reports甚至user analytics都具有相互不同的时效性要求,这就导致各个服务可建立在不同的技术选项上。微服务为此提供了良好的前提条件。
变更代价
微服务是面向需求变更友好的,但走向微服务的过程可能是极不友好的…例如切割服务、重构数据库等工作,都存在一定的风险。我们唯一能做的就是尽量使风险最小、可控。采用白板分析设计是一个常用的办法。此外,class-responsibility-collaboration卡是个“好”工具。
理解根源
问题的关键在于理解为何我们需要微服务架构。在实践中,我们经常会遇到某个服务迅速变的臃肿,尽管我们可能知道这么做所带来的不良后果。改善的第一步是找准下手位置,这正是本文的目的。当你熟练于此,随后就会自然陷入到微服务庞杂且无序的内部细节中。但是相信我,第一步才是最难的。