Building MicroServices Thoughts
最近在看微服务设计,理清楚到底该怎么设计我们的服务与API。
微服务
什么是微服务
1、很小专注于做好一件事
怎么定义小,可以从如何意识到’过大’入手。in other words,当我们不在感觉代码库过大时,可能服务就足够小了。
2、自制性
可以独立部署,服务间通过网络通信,避免紧耦合。注意:这些服务彼此独立,即修改服务A内部而不会影响服务B。所以,暴露出的API实现应该避免与消费方耦合。
思考:2个service共用一个common lib里的BO,当serviceA删除BO一个属性时,意味着serviceB也需要进行修改。那么这种是否违背了自制性。也即是说,修改一个服务并对其进行部署,不应该影响其他的任何服务。
为了达到服务间解耦,需要正确的建模服务 和 API。
好处
- 技术异构性
- 弹性可扩展
- 简化部署
如何构建服务
好服务特点
1、松耦合
修改服务A时不需要修改服务B。in other words,能够独立修改及部署单个服务,而不是需要修改系统的其他部分。
一个松耦合的服务应该 尽量少知道 与之协作服务的信息。这意味着,应该制约两个服务之间不同 调用形式的数量,除了潜在的性能问题外,过度的通信可能会导致紧耦合。
2、高内聚
把相关的行为聚集在一起,为了应对修改这个行为时,只需要修改一个服务并部署,而不是同时发布多个服务。
所以,找到问题域的边界就可以确保相关的行为能放在一起,并且边界间以松耦合进行通信。这里有点抽象,关键在于限定上下文。
限定上下文
这是DDD的概念bounded context。它规定了每个bounded context里都有明确的接口,该接口决定了它会暴露哪些模型给其他的上下文。
1、共享的隐藏模型
当分出bounded context后,两个context需要一个模型 来进行数据交换。如下的,仓库context 与 财务context 需要库存项这个共享模型。
但是我们并不会把仓库context里的库存项entity直接共享出去,而是采用mapper成BO再共享出去,因为我们不会盲目把entity所有东西都暴露出去。
有时候同一个名字,在不同的context里表示的业务概念不一样。如退货在客户和仓库的角度,走的业务流程就不一样,它们的entity自然也不一样。所以,这些model在每个context内部都会存在。
应该共享特定的模型(BO),而不应该共享内部表示(entity),就可以避免潜在的紧耦合风险。
业务功能
当在思考bounded context时,不应该从共享数据出发。而应该从每个context需要提供的功能出发。如,仓库需要提供当前的库存清单,财务需要库存清单计算账单。为了实现这些功能,需要交换仓库的存储信息。业务需要什么样的功能,才提供什么样的接口。而不是基于模型,上来就是CRUD,造成贫血对象。
逐步划分上下文
开始从粗粒度的bounded context入手,同时这些context可能又包含一些嵌套context。如上面的图片里仓库的划分。倾向于将嵌套context写的服务,包裹在一起在对外提供,使得架构成块从而更好地测试。
集成(服务调用)
集成是微服务技术中重要的一个,即使服务间怎么调用沟通。处理好,可以保证每个服务的自治性。
集成技术
服务间的通信方式有许多中,SOAP/RPC/REST/PB… 重点在于我们希望从这些技术中得到什么。
1、避免破坏性修改
对服务A修改会导致它的consumer也要随之发生改变。比如,修改serviceA的返回BO某个字段类型,它的consumer不应该受到影响。所以,我们希望选用技术来尽量避免这种情况。
2、保证API的技术无关性
服务间的通信,不应该受到某种技术的限制。即是说,golang的服务可以通信java的服务。
3、使服务易于消费方使用
与第二点相似,consumer可以使用任何技术来与我们的服务通信。避免通信受到依赖而产生耦合。
4、隐藏内部实现细节
与第一点相似,也是为了避免耦合。隐藏内部细节,避免修改产生的破坏。
共享数据库
这也是最常见的服务集成方式,服务间的通信变成直接访问数据库。它是最快与简单的通信方式。
而这种产生的问题也很明显,服务间的依赖变成了数据库。如果某个服务需要修改表结构,那么其他服务就无法工作了。要保证修改后功能的正确性,需要大量的回归测试来保证。
其次,consumer隐性地与数据库产生了依赖。如果需要从MySQL迁移到MongoDB,正常consumer只需要关心通信的service返回的数据就可以了。共享数据库可定无法实现。
其次,操作数据库也造成了 数据操作职责不清晰,到底是由那个服务来管理数据库。如果consumer直接操作,那它是不是要负责操作DB的业务逻辑。这就造成了职责不清,一旦修改操作逻辑,产生很大问题。
所以,对应上面的设计原则,高内聚和低耦合。共享数据库都无法实现,应该避免。
同步与异步 + 编排与协同
这个说的是两种服务调用方式如下
通常采用 同步调用,我们需要知道调用的结果。但是 对于一个需要长时间的调用流程,基于事件的异步调用具有更低的耦合性。因为,我们只需要增加一个event consumer,就可以在流程中增加一个新功能。
编排调用类似同步,协同调用类似异步。采用哪种架构风格或者混用,根据上面说的两者优势选择。最终都是为了实现服务的低耦合。
远程过程调用(RPC)
服务间的RPC调用种类繁多,有通过IDL来生成客户端和服务端的代码(SOAP/Thrift/pb),也有和某种语言强耦合的技术如Java RMI。
使用RPC需要注意以下几点:
1、技术的耦合。对于与特定平台强绑定的技术,如Java RMI。则需要将客户端和服务端都要绑定在JVM平台上。这不是我们所希望的。
2、考虑网路因素。明确网路调用本身是不可靠的。
3、不要对IDL过分抽象。
REST
REST是RPC的一种替代方案。它是一种设计风格而不是标准。
其中重要概念是资源。资源通过URI来指定。对资源的CRUD对应HTTP的GET/POST/PUT/DELETE。
REST引入了用来避免客户端和服务端之间产生耦合的另一个原则:HATEOAS(Hypermedia As The Engine Of Application State,超媒体作为程序状态的)。它的概念是:有一块内容,该内容包含了指向其他内容的链接,而这些内容的格式可以不同(如文本/图像/声音等)。
HATEOAS的优势在于,客户端和服务端之间实现了松耦合。客户端自行获取相关API,不需要调整客户端代码来匹配服务端的改动。缺点在于,客户端和服务端间通信次数比较多,客户端需要不断发现链接,直到找到符合要求的操作链接。同时它的操作成本也是有的。
实现基于事件的异步协作方式
主要考虑两个部分:微服务发布事件机制 和 消费者接收事件机制。
通常使用message queue可以解决上面的问题。并且这种系统具有很好的伸缩性和弹性,但同时也带来了开发流程的复杂度,需要额外的机器与专业知识来维护这些基础设施正常运行。
服务即状态机
我们的服务应该根据bounded context进行划分,同时服务应该包含这个context中行为相关的所有逻辑。 所以,如果出现了在这个服务外进行model的操作,也就表明了,我们失去了内聚性。
因此,把关键领域的生命周期显示建模处理非常有用。保证了高内聚以后,修改和扩展逻辑都会很方便。换句话说,服务也就成了状态机,对某个model的状态进行流转。
微服务中的DRY和代码复用的危险
通常DRY告诉我们代码复用会带来好处,如快速修改问题,提高代码维护性。
但是这种在microservice架构中可能是危险的。我们要想避免service和consumer之间的过度耦合,而共享代码就可能会导致这种耦合。
比如,多个service之间共享 公共领域对象。所以,当某个service需要对common domain model修改时,就会导致其他service重新部署。
本书作者建议:在service内部可以DRY,但是,在跨service情况下,可以不用DRY,因为它带来的紧耦合 会比重复代码 带来更糟糕的问题。
1、关于调用service的client问题(如feign)
类似feign这种封装的service client,存在的意义:简化对服务的使用,避免不同consumer调用时候重复的代码。同时feign这种还会处理服务发现/负载均衡/熔断/重试的基础功能。**但是,要注意 我们共用的client保证只有网络调用,而不包含具体的业务代码(如bff-common)。**这样才能,保证service之间的松耦合。
版本管理
当服务接口发生改变时,如何管理这些改变呢。
首先理解Postel法则(鲁棒性原则):系统中每个模块都应该宽进严出,即对自己发送的东西要严格,对接受的东西要宽容。这个原则最初上下文在网络设备之间的交互,因为在这个场景中,所以奇怪的事情都可以发生。
所以,在请求/响应的场景下,该原则可以帮助我们在服务发生改变时,减少消费方的修改。应对编码时,service减少对request内容的限制,严格控制response的内容。
小结
上面说了多种不同的service集成方法,重要的是保证service间的低耦合:
- 避免数据库集成
- 理解REST/RPC之间的取舍,但总使用REST作为 req/rsp 模式的起点
- 优先选择协同 而不是编排
- 避免破坏性修改接口,理解Postel法则
- 使用BFF给UI
分解单块系统
把现有代码如何循序渐进地分解成单块系统,满足上面小服务的松耦合与高内聚。
关键是接缝
我们应该只把经常一起变化的部分放到一起,从而实现内聚性。然而在单块应用中,所有代码都放在一起的,修改一行代码后,不能保证对单块系统的其他部分是否造成影响。同时还要对整个系统重新部署。
《修改代码的艺术》中定义了接缝概念:从接缝处可以抽取出相对独立的一部分代码,对这部分代码进行修改不会影响到系统的其他部分。相当于,这部分代码具有很强的内聚性,只影响到当前的service。
实现好的接缝,就要把相关的功能组织在一起,如Java中的不同package。通过这些接缝来划分我们的context(这里就会GET到为什么拆分微服务需要DDD)。如仓库、财务、推荐等服务 都会从一个大的单体系统里拆分出来。
接缝的依赖
当已经识别出一些接缝与其划分的context后,我们需要考虑,这部分代码与系统剩余代码的依赖。让接缝代码尽量少的被其他组件所依赖,让接缝间的依赖形成有向无环图。如上面所说的,仓库 -> 财务。
发现问题关键
当在组织2个接缝代码时,会遇到共同用到的部分,也就是数据库。如仓库和财务都会用到库存表。
这时就暴露出了一个问题,不同context下表间可能会出现耦合,如何去处理这些
作者列出以下几点:
例1、打破外键关系
场景:
如图财务的报表需要展示,卖出产品的信息。(如 我们xxx号卖出了苹果10份,收入10元)。但是,财务的总账表里,只记录的产品的ID,同时可能还存在在与行条目表的ID外键约束。
如何让两个context形成独立的service?首先,要除去财务对产品表的访问权限,财务通过调用产品API的方式来获取信息,而不是直接访问数据库。
这时,你会发现之前在一个库里,一个join的语句就可以完成,现在需要两条或多条SQL。这样会不会存在性能问题。作者的回答很精辟:你的系统需要多快?系统现在是多快?如果能够对当前性能做一个测试,并且还知道你的期望是什么,那就可以放心地做这些修改。有时候让系统的一部分变慢 会带来更大的好处,尤其是这个慢事实上还在可以接受的范围内。
join有时候还可能会出现笛卡尔集(表的字符集不一致/join两张表的column都含有空字符串,这里又关联到join时on和where区别)。
分开服务后,外键约束就需要转到代码中来实现一致性检查。这种检查不仅仅需要在创建总账时检查,还需要周期性的清理数据。**注意:这里阐述了一个问题,如何处理数据一致性。如何处理,应该由具体的业务区定义,即首先应该知道系统的期望行为是什么,然后在根据期望行为做决定。**举例如,一个产品删除后,它所关联的订单包含一个不存在的产品ID,该如何处理?是否允许这种事情发生;如果允许,订单该显示成什么样;如果不允许,该怎么约束避免产生这种情况。
例2、共享静态数据
把一些静态数据放在数据库里,如国家数据,多个服务都要从表中读取。而放到表里,是否暗示着国家数据改变的频率 比 部署代码的频率 还要高。显然这不符合事实情况。作者列出了如下解决方法:
把这些共享的静态数据放入属性文件或枚举中。如果静态数据的数据量和复杂度较高,也可以放到单独服务中。但一般场景,使用的是配置文件或代码解决。
例3、共享数据
共享的可变数据对分离服务来说是个大麻烦。如:财务 需要查看客户的订单信息,同时需要操作客户订单状态(退货/退款);仓库 需要客户下单后更新客户的订单信息。
所以,就产生了财务和仓库都会向同一张客户订单表写数据/读数据的场景。这是我们就要思考,是否漏了一种领域概念,才让数据库中产生了隐式的依赖。
思考后,很容易会想到。财务和仓库依赖的是客户,从而逐步划分出新的bounded context。后面,要做的就是创建客户service提供给财务service和仓库service。
例4、共享表
这里想说的是,多个service共享一张表,这种就可以分离共享表,根据不同的context对应不同的表。
重构数据库
我们在分离系统中,避免不了重构数据库,分离表。即将以前的单块系统对应的单表结构,分离成不同表结构,进而分离成不同的service。如下图:
问题:表分离后,相对原先某个业务,现在对数据库的访问次数可能会变多。因为以前一个select就可以查出的数据,现在要通过两张表去查,然后在内存中进行连接。除此,分离成不同库和表后,会破坏事务完整性。后面会对此讨论。
事务边界
以前在一个单库中,我们通过事务来保证 对多表的一致性操作。一旦发生任何错误,对多表的操作都会回滚,从而保证数据库不会处于一个不一致的状态。
然而,在微服务中,数据分布在不同数据库中。这种没有数据库事务来保证 数据一致性了。如下图就展示了两个事务边界。
解决方式:
1、最终一致性
对我们来说,知道插入仓库表失败就足够了,因为后面可以在对仓库提取表进行一次插入操作。方式:可以把这个操作放在队列或者日志文件中,之后再尝试对其触发操作。
我们把这种方式 称作最终一致性。接受在未来某个时间达到一致。
2、补偿事务
仓库表插入失败,会有自己的事务回滚。而订单表插入成功且已经提交了事务。解决方式:对订单在发起一个补偿事务 请求来抵消之前的操作。可能是一个delete操作,然后向用户返回操作失败。
问题1:补偿事务如果失败了,怎么处理?要么重试,要么定时任务定期清除不一致状态。
问题2:如果需要同步操作是5个,该怎么处理?要清楚这5个事务的是否成功,如果某些成功某些失败,是否继续重试?还是记录状态 后面定时处理?如何重试仍然有些成功有些失败,是否继续?可见处理非常麻烦。
3、分布式事务
手动编排补偿事务非常麻烦。一种替代方案是使用 分布式事务。
分布式事务 会横跨多个事务,然后使用一个事务管理器来统一编配 其他服务系统里运行的事务。就像普通的事务一样。一个分布式事务会保证整个系统处于一致性的状态。唯一不同的是,这里的事务是运行在不同的系统不同进程中,通常它们之间使用网络进行通信。
常用算法2PC/3PC,但它们都无法彻底解决数据一致性的问题。
4、应该怎么办呢
所有这些方案都会增加复杂度。分布式事务很容易出错,而且不利于扩展。通过重试或补偿达成最终一致性的方式,会使定位问题更加困难。
所以,如果现在有一个业务需要跨库实现单个事务。那么要问问自己是否真的需要这么做,是否可以简单地把它们放到不同的本地事务中,然后依赖最终一致性的概念,这种系统的构建和扩展都会比较容易。
如果业务必须需要保持一致性,我们要避免仅仅从纯技术(分布式数据库事务)的角度考虑,而是显示的创建一个概念来表示这个事务。如创建一个叫做"处理中的订单的"的概念,围绕这个概念可以把所有与订单相关的端到端的操作(以及相应的异常)管理起来。理解:处理中的订单 这是一种概念不是具体的表。它存在的目的:把异常数据 即订单表插入成功 提起表插入失败 统称为处理中的订单,我们针对这种类型的数据 再统一进行一些补偿操作。
小结
我们需要寻找服务边界把系统分解开来,这是一个增量的过程(如上面财务和仓库形成客户边界),发现服务间的接缝从而分离他们,逐步演进系统。
部署
部署一个单块系统的流程非常简单。然而在相互依赖的微服务中,部署却是完全不同的情况,如果部署的方法不合适,那么其带复杂度。
CI
Continuous Integration:持续集成目的快速将提交代码集成,快速得到代码质量的反馈
这里介绍几种微服务的构建方式:
1、将所有微服务放在同一个仓库中,并且只有一个CI构建
优势:构建简单,一个提交就可以搞定
劣势:
如果我们只修改了一个service的代码,所以其他的服务都要进行验证和构建,事实上它们无需如此。这样花费了CI不必要的时间。同时,我们不知道这次提交应该重新部署哪个服务,才能让修改生效。再者,如果多团队协作,一方service导致构建失败,其他团队的service也服务部署。
2、每个service对应一个pipeline,它是方式1的变种
这种方式即每个service对应一个仓库,且对应一个pipeline。它所解决问题就是方式1的劣势。
CD
Continuous Delivery
在CI中,我们知道把一个构建分为多阶段的优势:可以更快速的得到反馈。如将更耗时的API测试放到后面。
CD持续交付基于上述概念并有所发展,CD将从提交以及部署到生产环境这个过程,所经历的流程进行建模。
如:build->test->UAT->性能测试->prod
可以理解CD是CI的一个扩展
Artifact
构建物。这里想要表明的问题是:不同平台的构建物无法互相运行。如linux下的artifact无法运行在win环境中。
解决方式:通过定制化镜像,让我们的program在不同的平台运行。即将image作为artifact。如docker
测试
如何高效测试分布式系统的功能,这是本章想要表明的问题。
首先展示一下测试金字塔模型,其中描述了不同的自动化测试类型,帮助我们思考不同的测试类型应该覆盖的范围与投入的精力。
1、单元测试。这个比较好理解,TDD写的测试就属于这一类。我们不会启动服务。UT是帮助开发人员,快速捕获大部分缺陷。对代码重构非常重要
2、服务测试。针对单个服务的测试,保持服务间的隔离性。对于外部依赖使用打桩服务处理。
3、端到端测试。E2E会覆盖整个系统,通常需要打开一个浏览器来操作GUI,模拟用户交互行为。
总结:越靠近顶端,测试覆盖面越大,需要更长时间测试,反馈周期变长。当顶层测试失败后,通常会写UT去重现问题,以便快速捕获问题,缩短反馈周期。
服务测试
服务测试需要把依赖的外部服务打桩,如在测试的时候,保证打桩的服务正常运行,通常模拟正常的服务的req和rsp。
mock vs stub:
stub是指为测试服务的请求创建一些预设相应的服务。如当请求用户1的余额时候,返回1000。测试不需要关心这个stub服务被访问了多少次。
mock需要对请求本身进行验证。
智能打桩服务器mountebank
测试场景问题:
我们不需要为每个新增的功能增加一个E2E测试,这会加剧测试臃肿。解决:E2E把重心放到核心场景上,把其他的场景放到相互隔离的服务测试中覆盖。
契约测试
E2E测试,试图解决的问题;部署新服务后,确保不会破坏对应的消费者,注意E2E使用的是真正的消费者如浏览器。
有一种不需要真正consumer也能达到这种目的的方式:Consumer-Driven-Contract
基于PACT的demo。主要流程:consumer定义contract生成json,Provider读取json运行测试。
我们可以将CDC放在pyramid的服务测试中。
部署后再测试
大多数测试会在部署到prod前验证完成,同时我们也需要在prod环境中先验证一下,在大规模部署。
为了达到这个目的:
1、蓝/绿部署
我们会部署两份服务,但只有一个服务会接受真正的请求。如下图展示了v456怎么上线的过程。
2、金丝雀发布
金丝雀发布是指通过将部分生产流量引流到新部署的系统
,来验证系统。它与蓝/绿部署区别:新旧版本会共存时间更长,而且经常会调整流量。
例子:新上了一个推荐服务验证推荐效果,如果没有达到预期,可以快速切回旧版本。如果达到预期,可以引流更多流量。
Netflix就采用这种方式。
canary releasing是种强大的技术,帮助大家用实际的请求来验证软件的新版本,同时可能推出一个糟糕的新版本,提供工具来帮组控制风险。
监控
微服务中我们需要监控的是多服务与多机器。
除了基础的机器CPU/MEM等指标,更重要的是服务的日志。将多机器上的日志集中到一起方便使用。例子:logstash可以解析多种日志文件格式,并将它们发送给下游系统进一步分析。kibana将ES里的日志通过图表展示出来。流程如下:
指标查看:
graphite就是一种监控指标的工具。当Linux安装collectd并执行graphite时,会生成大量操作系统指标。
关联标识:
在服务调用链中,如果想要把日志串起来,需要一个关联标识,然后写入日志中。如用户的uuid。
zipkin就是可以提供非常详细的服务间调用的追踪信息的工具。
规模化微服务
微服务对应的分布式系统,所以也会有CAP原则。尤其在我们规模化服务时,需要注意这些问题。
1、使用断路器 避免雪崩效应
2、幂等
多次操作所产生的影响均与一次执行的影响相同。如果API是幂等的,我们就可以对其重复调用而不必担心影响。当我们不确定操作是否被执行,想要重新处理消息时候,幂等非常有效。
3、扩展
为了保证HA,提升处理性能等。我们将服务进行扩展。
包括拆分负载机器(异地多活)、负载均衡、实现基于master-slave的系统。
扩展数据库
扩展微服务相对简单,我们也需要在必要时扩展数据库。不同类型的数据库提供不同形式的扩展
扩展读取:将master的数据实时复制到slave上。而业务只从slave上读取数据。这种slave称为只读副本,但是缓存也可以解决读的压力,而且工作量会更少。
扩展写操作:扩展读操作比较容易。扩展写操作呢
使用分片
:即数据库存在多个节点,当有一块数据写入时,会根据数据的关键字生成哈希,根据这个结果决定放在哪个数据库节点上。(如kafka发送消息时的key,决定哪个partition)。
分片写产生的问题在于读:
如需要的数据需要跨多个节点查询,则需要异步查询然后在内存中拼接返回。同时写分片并不会提高弹性,若某个节点挂了,该节点对应的数据依然不可获取。
总结,扩展写操作非常棘手,当人们无法轻松扩展现有的写容量时候,才会采用。(如有些查询少写入大的gps数据,采用写分表)。但是,通常扩展机器可以快速解决。
CQRS
command-query-responsibility-segregation:命令查询职责分离
该模式,是一个存储和查询信息的替代模型。传统的RDBMS,数据的修改和查询使用的是同一个系统。
使用CQRS后,系统中负责 修改状态命令 的部分进行数据修改。另一部分则负责处理查询。
关键在于:修改和查询的模型是完全独立的,这样读和写的逻辑就隔离开了。后面,就可以采用不同的库进行读写分离。而CQRS作用在于:它从代码设计角度 将读和写分离开来,进而方便了程序的高性能和高扩展。当然具体实现也会有些复杂。都会设计DDD。具体CQRS参考
缓存
它是提升性能的常用手段。
1、cache可以存在客户端/服务端/代理服务端。使用哪种缓存,取决于你正在试图优化什么。
- client cache可以大大减少网路调用次数,但同时让数据失效时候,也比较棘手。
- proxy server cache这是增加缓存到已有系统的最简单方式。通过缓存HTTP通信。如CDN
- server cache最常用。如Redis,还可以跟踪与优化cache命中率。
2、HTTP缓存
使用HTTP的cache-control指令,告诉client是否缓存,缓存多久等。
还可通过Expires设置缓存过期的日期。
Etag,还可以标识资源是否改变。
3、为写使用缓存
通常都是读取缓存,为写使用缓存,即后写式缓存,目的提高性能。在批处理写操作时候,有效提高性能。还可将写操作放入队列,下游consumer依次消费写数据。
4、为弹性使用缓存
在服务不可用时,使用缓存实现故障时候的可用。
5、隐藏源服务
场景:当缓存失效时,并不是立马请求源服务,获取最新数据。这种做法会造成大量请求到源服务,从设计上源服务本身只处理小流量请求,如果并发过高很容易将服务拖死。
正确的做法如下图:缓存是源服务去异步填充。
6、保持简单
避免在太多处地方使用缓存,因为缓存越多,越难保证数据新鲜度,也容器出现数据一致性问题。
总结
上面介绍的是作者关于微服务设计的一些原则,具体的实现招式可能有多种。
作者觉得在无法划分服务之前,首先构建单块系统,当稳定后在进行拆分。