作者|Arnold Galovics
译者|核子可乐
编辑|燕珊
本文希望从零开始带大家通过项目推进了解关于微服务的种种真相和经验教训。
以微服务的方式构建新项目并不困难,新架构带来的新承诺也着实令人充满期待。然而,现实与想象往往相去甚远。本文是该作者 Arnold Galovics 关于微服务系列文章中的第二篇。感兴趣的朋友可以点击此处阅读第一篇《新项目别一上来就用微服务》,在第一篇文章中,Arnold 介绍了微服务架构对于基础设施的要求、更快的部署特性、给组织文化提出的挑战以及天然的故障隔离优势。
Arnold 提示道:“本系列文章中提到的所有观点都是个人心得,毕竟不同环境、组织和项目都会给开发工作带来变数。没准我踩过的坑反而是你开发流程中最顺畅的部分,我的考虑方式也未必符合各位的实际情况。总之,内容仅供参考,请大家轻拍。”
本系列目的在于提醒大家,只有对新项目建立起深入的评估与理解,才能真正找到最适合的架构选项。当然,也期待大家在留言中分享自己的真知灼见。以下是正文:
易于理解
很多朋友都觉得微服务架构的理解难度更低……但事实真是这样吗?
当然了,我们只需要具体管控各个肩负明确职责的微服务项目,所以每种元素在干嘛、需要干嘛、系统整体状况如何不就更清晰了吗?毕竟我们面对的是一个个服务,而非彼此交织的架构整体。
但我要给大家泼点冷水:这完全就是骗人的。没错,确实有一些微服务架构做出了优秀的边界定义,任何半路介入的参与者都能快速理解某项微服务的实际作用。但也有很多项目做得不好,导致某项服务要么做得太多、要么做得太少;甚至部分单一功能也被过度拆分成了多项服务,因此系统的混乱度大幅提升,任何单一服务的故障都可能将整体应用拖入崩溃的深渊。
大家可能会说,“对,这种情况是有,但那是你的问题、不是微服务的问题”,或者“你笨啊,笨还能怪架构?”有道理,但问题是项目绝对不可能百分之百受控,真的不可能。团队、组织、项目,各个层面都有出错的可能,所以边界定义不清的几率会远远高于边界定义良好的几率。
但我也承认,这种情况在单体式架构中也可能带来麻烦。我也见过那些搞不清在干什么的单体式应用,所有功能就像飘香拌面一样混杂成大坨,代码库硕大无朋、缺少必要的测试、说明文档含糊不清、不同功能的编程风格格格不入等等。
不过单体式架构修改起来还是更轻松一点,它相较于微服务的优越性也正在于此——模块化。我们同样像为微服务定义边界那样进行代码构建,只是不再将这些“服务”或者说模块视为其他应用的元素,而是同一整体中的各个组成部分。
所以对我来说,这种杂乱的毛病主要还是出在微服务架构身上。只是我也承认,如果要想搞砸,那在单体式架构中也一样可以搞砸。
可扩展性
提到微服务,网上总在强调“你可以横向扩展各项服务——也就是为同一服务启动多个实例,从而轻松应用负载增长。”
说得倒是轻巧,但具体实现起来有那么简单吗?我们用以下架构为例(沿用系列第一篇文章的用例):
假定你的应用中有大量活跃用户,所以系统需要处理众多用户会话。那很明显,我们就得启动多个会话服务。
如果会话服务可以通过 HTTP 协议访问,那用多实例实现就会比较困难。如果是通过 API 网关或者登录服务来接入会话服务,那就得调用会话的 HTTP API。但使用 HTTP API 时,我们得调用表达特定会话服务实例位置的特定 URL(主机 + 端口)。所以如果同时启动多个会话服务,消费方就得调用多个主机 + 端口组合。
这个问题倒不至于无解,常见的办法就是使用服务注册表或者负载均衡器。如果是使用服务注册表,那么当实例启动时,它们会将自身注册到某种类型的存储当中,再通过存储检索实例的相应位置。这时候当消费方服务打算跟特定服务对话时,它就会先到服务注册表去检索实例位置,之后再使用该位置实现与特定实例的对话。
另一种解决方案则是负载均衡器。这时候我们无需直接与服务对话,而是使用中间层、即另一项服务(负载均衡器)来维持各实例的可访问性并代理通信负载。例如,大家可以将其理解成类似于 DNS 负载均衡的功能,或者是像 AWS ALB 那种负载均衡器服务。
所以很明显,当我们使用 HTTP 进行服务间通信时,就必须通过位置解析才能正确处理服务实例。这可是有成本的哦,毕竟天底下没有免费的午餐。
如果会话服务可以通过消息进行访问,那在本文的示例中我们就要用到 Kafka,而这又会让问题跃上新的层次。具体为何,容我细细道来。
理由很简单,因为在使用 Kafka 时,我们可以做到有多少主题分区、就启动多少服务实例。假定我们有一个 session-service-topic 主题,这个主题明显需要由会话服务进行读取。Kafka 主题的扩展取决于主题分区的数量,每个分区对应一个消费方。如果我们希望将会话服务扩展至 4 个实例,那就需要建立 4 个分区来存储各实例所需读取的主题。
这有什么问题吗?我们可以随时增加分区数量呀。没错,但请大家注意,消息的顺序只能存留在主题分区之内,这可是有很大影响的。
我们假设会话服务需要处理两种消息类型:
在收到 UserLoggedInEvent 之后,会话服务将创建一个内部“会话”,这可能涉及相关的数据库表之类。而在收到 UserActivityEvent 之后,会话服务则须更新现有用户会话的过期时间,这可能涉及相关的数据库条目。
问题在于,假定我们有 2 个会话服务实例,而每个主题又对应 2 个用于消息发送的分区。主题生产方选择使用循环分区策略,意味着一条消息进入第一分区、接下来第二条消息进入第二分区。这时候,就会有一项服务接收到 UserLoggedInEvent,而另一项服务则接收到 UserActivityEvent。
在这种情况下,接收 UserActivityEvent 的服务在处理速度上可能快于接收 UserLoggedInEvent 的服务;如果双方共享同一数据库,就有可能引发问题。因为初始会话记录还没有被相应的服务实例正确写入至数据库。
听起来问题不大,但实际应用起来可是相当复杂。这种情况当然也有解决方案,但它本来可以不必存在,只是因为我们选择了微服务架构、就必须多承担调试压力,有点不划算。
我也见过很多比较复杂的系统,由于很难明确该如何进行数据分区、如何解决排序问题,所以几乎无法使用 Kafka 实现横向扩展。另外,也有一些系统设计者为了避免这类问题,而选择使用单一 Kafka 主题实现所有服务间通信。这种方法虽然回避了分区的困扰,但也彻底牺牲掉了扩展的可能性。而且在大部分场景下,HTTP 其实就完全够用了,着实没必要搞那么麻烦。
我想强调的一点是:千万别以为微服务的横向扩展能力是默认的、“免费的”。如果不开动脑筋,这种扩展能力根本就实现不了。不知道大家有没有尝试过在微服务环境下调试排序问题,却发现问题只发生在常规开发 / 测试环境中,却没法在本地计算机上重现。这真的很让人头大,不提了。
技术自由
终于进入了我最喜爱的环节。这是种幸运、也是种不幸,我本人对技术自由这事有着深切的体会。有时候问题的根源并不是无奈的意外,而是……开发者们实在太有创意了。
所以大家会天然更喜欢微服务架构。毕竟在单体式架构中,我们总要被编程语言和技术栈的条条框框所束缚,但在微服务里却可以使用不同的编程语言和技术栈编写不同服务。比方说,我们可以在某一服务中使用 Java,在另一服务中使用 Node.js,在第三项服务中使用 Go 等等,完全没有问题。
但这种优势,有时候反而成为最大的弊端。我当然不反对创新,但我理解的创新是用前所未有的方法解决问题、而不是用前所未有的方法创造问题。
大家可能觉得异构微服务架构没什么问题,但前提是你得有明确的职能划分,保证由专人专门管理特定微服务项目。只有这样,我们才真正能说“没什么问题”。
但如果我们还没有做好使用微服务架构的万全准备,甚至才刚刚踏出探索的第一步,那人员与服务间的对应关系恐怕没有那么清晰。换句话说,大家在开发新功能时,往往不免要触及到由其他人编写的服务。
假定我们是一支负责开发登录服务的团队,服务本体由 Java 编写。另一支团队则开发会话服务,至少在项目启动之初是如此。每个人都对产品万分期待、充满动力,并努力用新鲜元素满满的创新方案解决现实问题。于是乎,会话服务是用 Node.js 编写的,因为最近刚刚面世了一套全新 JS 框架,大家都赞不绝口、说它能把生产力提高好几倍之类的。
接下来,MVP 已经初步成型,产品开始在生产环境下运行。向个月后,会话服务(Node.js 服务)的构建者开始过渡到其他项目,甚至离职去了其他公司。接下来这段时间就成了空窗期,大家不再为会话服务开发任何相关功能。
突然之间,产品负责人跳出来说“大家好,我们需要上线新功能,用来扩展平台上的会话服务。”但这时候原本的会话服务创建者已经离职,另一支接手团队却没有任何会话服务或者 Node.js 开发经验。
于是大家傻眼了,根本不知道自己能不能接下这样一份重担。
下面,咱们再来聊聊 DevOps。我倒不是想刻意针对 DevOps,但如今各类组织都在积极组建独立于工程团队的专职 DevOps 部门,这真有必要吗?DevOps 并不是什么万金油,DevOps 工程师也不可能了解每种语言和技术细节。所以就算顶着这个光芒四射的头衔,他们也很可能搞不定服务运营工作。
另外,很多产品负责人或者公司老板也不够负责,他们压根不重视由不同编程语言带来的种种隐患。比如说继续沿用 Java 开发某项服务只需要 2 周时间,但为了跟其他服务保持统一,我们最好花 6 周时间用另一种语言来编写,他们会同意吗?估计够呛,毕竟短期来看时间就是生命。但如果真这么随性,一旦人员离职、产生代码交接需求,后面的麻烦完全可以预见。
不止于此,异构系统还会带来其他跨越性的新难题。比如如何实现标准化……
我们得在不同的语言和技术栈上一遍遍重复这些标准化调整,这工作可不轻松,而且要求各个团队付出大量精力。我记得我们就曾在一个已经开发 5 个月的项目中使用到 80 多项微服务,当时大家打算标准化 API 错误处理,保证生成一致的 HTTP 代码。5 个小队最初的预估周期就长达 107 天,而且这还不属于异构系统——所有代码都是用 Java + Spring Boot 编写的。可以想象一下,如果代码涉及三、四种不同语言和技术栈,工作量会膨胀到什么地步。
我当然不反对使用多种语言 / 技术栈,但这事最好要有明确的理由,比如切实需要某些语言 / 技术栈中的功能特性。我也会在低延迟负载中使用 Go 或者 Node.js,并倾向于使用 Java 开发逻辑更复杂、但对性能要求不高的任务——但一定要有理有据。
这里再分享一点在异构架构方面的经验。我接触过的一套架构涉及五种语言,分别是 Java、Scala、Node.js、Erlang 等。团队当然就得随时维持这五种代码,可以想象会有多困难。更要命的是,里面还涉及不同的 Java 版本、不同的开发框架等。事实证明,很多语言的引入根本没有必要,开发者这么干只是因为他们好奇、想要实验一下。
我当然相信产品的成功源自人的成功,而人的成功源自对创新的探索。但我也觉得创新这事不能泛滥,我们在制定决策时必须小心谨慎、保证充分理解“创新”背后的含义。从个人角度来看,“我就是想试试”不能叫理由,这种随心所欲的风格只会给项目留下无数暗伤、拖累后续发展。
团队扩张
团队的扩张可以说是使用微服务架构的最佳论据。设想一下,一支 10 人小队在一个单体式代码库上工作,效果很好、复杂性始终不高。
但如果扩大团队规模,让 100 个人同时处理一个单体式代码库,结果会如何?代码库相同、工作方式相同,一切都不做变动。大家会把代码推送至同一个 git repo,使用相同的类、相同的测试、处理 10 项不同功能。
这很快就会引发冲突,大家发现单体式架构太过“拥挤”,容不下大规模作战。微服务的要义就是把单一团队的工作从整体代码库中抽离出来,形成新的独立代码库。这样人们才能并行工作,保证不对其他开发者的行动产生干扰。
但这个适合微服务架构的规模临界点在哪里?我不太清楚,具体要视组织情况而定。如果管理者既不负责、也没水平,那 10 个人就足够把项目搅成一锅粥了。但在这样的团队里,难道微服务就能发挥作用?我压根不信。
总 结
从负责任的角度出发,我不会轻易断言大家该用单体式架构、还是微服务架构。
我的个人看法是,这个艰难的选择无法回避、必须在项目起步阶段就预先设定完成。我觉得大概四分之三的新项目都可以无脑选择单体式架构,再配合适当的模块化设计保证后续有必要时能比较轻松地转化成微服务架构。那什么叫“有必要”呢?就是转化的工作量低于继续维护原有单体式架构的工作量时。
剩下的四分之一可能天然更适合微服务架构,但还是要先整理出明确的理由。总之,如果不假思索地盲选,我个人肯定是先单体、后微服务。
架构判断绝非易事,我们需要对产品做出未来一到两年的发展预期、估算未来会有多少人 / 什么样的人参与到项目中来,会有哪些基础设施限制,我们的预算、产品功能路线等等。综合各项因素,最后得出的才是安全可靠的架构决断。
如果你的产品只是一款普通的终端消费级 Web 应用程序,例如网上商店,日活用户 5000 左右,前五年月均订单量 100 份上下,那就完全没必要选择微服务。另外,如果你的初始团队是一位老手带多位新手,那微服务同样不太适用。最后,如果项目预算有限,同样记得远离微服务——它带来的很可能是一套没人愿意维护的混乱系统。
微服务这个概念属于听起来简单,做起来却极为困难。相信我,没人天生就能编写出完美的微服务项目,我们都需要不断摸索和学习、围绕新概念打磨自己的业务水平。虽然开发顶尖单体式应用程序的难度也不低,但它的结构特性更符合我们的思维本能。而单体式架构中最难学习的正是模块化、可测试性、关注点分离等要素,也就是那些跟微服务架构最相似的部分。本篇文章到此为止,总之,两种架构各有各的挑战。
你给解释解释,什么叫微服务?
原文链接:
https://arnoldgalovics.com/truth-about-microservices/