众所周知,业务架构是逐渐演进的。随着业务和组织的发展,架构在持续变化,而这种变化往往会体现在业务域的划分上。动态调整是一个过程,一般是先拆开再治理。简单的拆分会引入依赖和耦合的问题,本文将着重讨论业务架构演进过程中出现的服务和模块边界问题以及解决这些问题的实践。
1. 业务架构演进
由于七鱼本身复杂度较大,因此在系统设计之初就是微服务架构,这个架构随着组织和业务的发展出现了比较重大的变化。
这些变化从根本上说,是系统拆分从“按照功能拆分”,逐渐演进到“按照业务领域”进行拆分的过程。
- 按照功能拆分
早期,由于大家都是一个团队,每个人负责的内容是按照功能来划定的。这种做法简单、直观而且且符合“单一职则”原则。
同时,由于采用面向数据和过程编程的方式(即需要什么数据自己负责组装,公共逻辑采用Jar包共享的方式进行提取和复用),因此服务和服务之间耦合度不高。
单一职责、高内聚低耦合,在业务发展初期支撑了七鱼以极高的速度进行版本和功能迭代。
- 按照业务拆分
随着业务的不断发展,七鱼逐步形成了几个大的独立售卖业务线,以及一些相对小但独立性也很强的支撑性业务。
到了这个阶段,可以明显的感觉到早期按照功能划分的服务不再能适应组织发展的需要了。最典型的情况就是各个业务组在开发功能的时候都会去改基础服务域的服务。
此时,“单一职责”原则虽然得到了保存,但是“高内聚、低耦合”原则被破坏掉了。这导致大量的代码耦合、不合理的服务依赖、发布依赖。这些问题会影响线上系统稳定性、维护性,拖慢研发效能。
为了解决上述问题,我们开始了七鱼服务治理项目。
2. 服务分级
在识别耦合和依赖的合理性之前,我们需要对服务进行分级。没有分级,就没有服务优先级、依赖倒置等技术优化的切入点,也不会有资源和进度的安排依据。
核心模块非核心模块的这种说法是由来已久的。按照这个思路可以对服务的重要程度度进行分级。
在七鱼中我们对服务的层级做了如下定义:(注意,这里不包括中间件和数据库等基础设施)
- P0: 系统级基础服务,如果宕机则导致大面积可感知的服务异常,通常数量很少(底层共用数据的管理和查询等)
- P1: 核心业务基础服务和核心功能,如果出现了宕机则某个核心业务出现主流程不可用
- P2: 非核心业务应用和核心业务非核心功能(数据报表、系统通知等)
- P3: 内部支撑业务(运营后台、运维后台等)
在定义了服务分级之后,我们有如下的一些基本原则:
- 下层服务不能直接调用上层服务
- 下层服务稳定性可用性不能受限于上层服务
- 同层级服务之间尽可能保持逻辑隔离
- 底层服务只提供基础能力,并保持模型稳定
3. 边界问题和解法
在服务分级以及模块划分的基础上,我们在日常开发中识别了如下问题:
代码耦合:由于我们经历了从功能拆分到业务拆分的过程。在中间阶段,某些服务承载了多个业务域的业务。虽然拆分之后这些服务被划到了某个业务组,但是代码的耦合依然存在,Owner需要按照其他组的需求修改代码。代码耦合会导致如下问题:
- Owner无法完全掌控自己的代码和计划,响应其他组的需求可能打乱自己的时间安排;
- Owner无法排期时,为了赶时间而让非Owner来进行开发,由于熟悉程度不够,从而引起问题;
- 上线存在依赖,这又会引入发布权限和发布顺序的问题。
不合理依赖:又分成了反向依赖、环状依赖、强弱依赖三个方面
- 反向依赖:底层服务依赖上层服务。调用倒置,造成下层服务稳定性受上层服务影响。
- 环状依赖:A依赖B,B依赖A,当然可能是有中间服务的 A->B->C->A 这样的依赖关系。会导致发布顺序失控。
- 不合理的强弱依赖:业务上弱依赖的但是服务调用上是强依赖的。即业务上,某个服务宕机应该不影响核心功能,但是实际结果是该服务宕机之后核心功能不可用。
在七鱼的基础服务边界治理过程中,采用了如下的一些技术手段来优化服务。为了优化某个场景,可能会联合采用多个手段来共同完成目标。
下面我们就从场景出发,对这些技术手段做简单的介绍。
4. 边界治理实践
拆分
拆分分为下面几种情况:
- 无共用代码的,一般是各业务方比较独立的功能,直接拆走即可;
有共用代码的,又分成两种情况:共用部分属于基础能力、共用部分属于业务逻辑的一部分:
- 共用的基础能力可以单出抽取成Jar包或者抽取成独立的服务
- 而如果业务逻辑耦合
- 如果底层模型无法拆开,这里就透露出了业务领域划分存在问题;
- 而如果为了展示的需要,则可以作为聚合服务存在,本身不影响业务领域划分;
早期,所有的页面接口承载在同一个服务中,导致该应用不得不被归类为P0级。其中大部分接口都是业务内部设置和数据查看,因此属于无共用代码的情况,可以直接拆走。
此外,所有页面都依赖于一系列基础数据,如企业信息、客服信息、权限信息等。这属于为了展示的需求而全局依赖某种基础能力的情况,因此我们将页面基础数据查询功能独立为一个单独服务。由于所有页面依然依赖这些数据,所以这个服务还是P0。
这样的拆分操作,将原来大杂烩的P0拆成了一个功能单一、逻辑简单、代码稳定的P0,以及一系列P1和P2服务。
按需加载 + 弱依赖降级
对于依赖多业务方的场景,通常这些依赖有强弱之分。
- 弱依赖:A场景依赖B服务,但是A跟B并非强相关。即如果B不可用,A的主流程还可以运行。
- 强依赖:A依赖B,且A场景跟B强相关。即如果B不可用,A的主流程走不通。
对于强依赖,必须要保证可用性,同时要做到按需加载以尽可能减少不必要的风险。弱依赖是允许出现不可用,但是为了防止弱依赖不可用之后出现不友好的提示,需要提供降级方案。
上个例子中页面依赖的所有基础数据,在拆分前是一起加载的。一项数据加载失败就可能导致所有数据返回失败。虽然业务上各个基础数据不一定都能用到,但是事实上就是强依赖了所有基础数据。
但是由于大量的数据是共用的,因此为每个页面单独写一个数据封装接口又是非常不划算的。所以我们在新数据加载服务中,引入GraphQL来解决这个问题。
GraphQL要求将数据拆分成基础的单元,通过组装query语句来向Server查询,query语句既包含了原子数据项,又包含了最终想要的数据格式。
相较于为每个页面写一个单独的数据接口,以满足按需加载的需要,这样做的好处很多:
- 原子数据的查询复用
- 按需加载
- 数据格式灵活可调
- 扩展容易
- 提供了丰富的数据运算和拼装能能力
- 跨前端技术栈
这里不对GraphQL做过多展开,感兴趣可以参考https://graphql.org/。
降级则通常有Hystrix或者Sentinel来完成。这里也不做过多的展开。
边界变更
业务领域往往有多种划分方式,但是有时候最符合业务领域的划分方式不一定是最现实可靠的划分方式。
从代码维护性和线上稳定角度考虑,有时候必须要对边界做重新划分。这里有几个参考原则:
- 减少P0应用数量
- 模型稳定、调用量大、有全局影响的业务逻辑可以放在一起
- 调整之后模型边界需要有明确的业务含义以便于理解和维护,不能硬凑。
七鱼的“企业信息管理”和“订单与服务包”开始分处于两个服务。但是日常工作中发现:企业管理调用量大且模型稳定;订单逻辑复杂变更多,但大部分调用量很小,只有“服务包查询”调用量很大且模型稳定。
我们将“服务包查询”的功能迁移到“企业信息管理”中,从概念上将“企业信息管理”模块变更成了“企业运行时管理”。通过边界变更,我们将两个P0级服务拆成一个P0一个P1,同时也保证了复杂多变的业务不影响稳定的底层服务。
领域模型优化
领域模型如果出现了对其他领域数据的耦合,那么代码也一定是强耦合的。但是只要能确定业务域划分没问题,则可以通过领域模型优化的方式来解除耦合。
通常的做法是用KV表存储其他领域的关联数据、用事件驱动异步更新这个KV表,这样当前的领域模型就可以不关注数据的业务含义。
不关心业务含义只是存储数据,则底层模型就可以做到通用化并保持稳定,将反向依赖和代码耦合彻底清除掉。
七鱼的User表中除了基础的用户信息,还保存了“最近和最后联系时间”等业务方的数据。显然,这种层面的耦合会导致User模型被污染。但是,业务功能上这些信息是展示User信息必须的。
考虑到现在User需要展示的是“最新联系时间”,那么后续就有可能要展示“最新工单时间”、“最新短信时间”等等。如果持续去适配需求改动代码,则就造成了代码耦合。
从模型层面上做调整,增加UserInfoExt表,用键值对的形式提供扩展信息的存储,业务系统通过主动更新K-V值的方式来更新数据。这样就保证了User模型层的稳定、调用关系的优化、代码层的完全解耦。
能力上推
接领域模型优化。
领域模型的优化成本很高,实际中不一定有资源来完成这种重构。特别是涉及到P0级应用的底层模型变更,风险往往很大。
在需要底层数据和上层数据关联展示的场景中。关联展示的逻辑可以不承载在底层模型上,而是将这部分组装的过程上推到上层业务系统中去,从而解除底层模型的数据耦合。
接上面的例子,由于User是全局最核心的服务之一,改造的风险很大,最后我们并没有采用领域模型优化的方案。而是在这里做能力上推,将P0级别的能力推到P1级别去。
User核心模型删除“最近最后联系时间”,获取信息的过程上推到User-Gateway服务中来完成。User-Gateway虽然是属于基础业务域,但仅负责提供页面需要的用户数据,宕机也不影响底层会话、工单等数据流,因此属于P1级别的服务。
事件驱动
当业务流程耦合了其他业务域的流程时。有两个可能性存在:
- 强依赖上层业务流程的结果;
- 不依赖上层业务的结果。
如果不依赖上层业务的结果,那么可以通过生命周期事件的方式,将流程和核心节点广播出去,让上层业务独立完成后续的流程。
为了保证生命周期事件能够被顺利消费并触发业务逻辑。需要在中间件层保证消息的触达和幂等性,同时假设在极端情况下出现了执行失败,则需要提供消息补偿执行的机制。
最初,七鱼的企业注册的流程简单的串行过程,中间任何一个设置没做完,企业都没法初始化完成从而导致企业注册失败。
这种失败其实是很不划算的,即便某个业务没有初始化好,也可以试用其他业务,不至于完全丢失一个潜在客户。
我们通过对企业注册生命周期进行建模,将“企业创建”事件广播出来,用事件驱动来完成整个注册过程。这样做的好处在于:
- 新增业务的初始化不会影响现有的官网代码,从而解除代码耦合;
- 部分业务的初始化失败不会影响整体的注册流程,解除对单一业务的强依赖;
- 官网作为P1级别服务,直接调用的只有P0的企业和客服管理服务,因此不存在反向依赖。
异步调用
接事件驱动。
当业务流程中底层流程依赖上层业务的结果时,解决这种依赖的方式有两种:
- 改造领域模型以解除强依赖。虽然比较彻底,但是往往成本很大。
- 直接调用并依赖结果;这样就造成了调用的反向依赖。
异步调用是为了解决直接调用产生的代码耦合和反向依赖的问题。通过事件驱动的方式获取上层业务的结果,而不是直接调用并获取结果。跟普通消息驱动不同,异步调用依赖返回的结果;跟直接调用不同,不依赖被调用方的接口。
在七鱼,删除客服需要校验:是否有未完成的电话 、会话、工单等。由于删除客服是属于基本的客服管理,所以在P0级服务中。而为了校验业务信息,则必须调用P1级服务。
如果采用领域模型重构的方式,让业务层将“能否删除”告知给“客服管理”并随着业务流程实时更新,则可以解除该反向依赖。但是这种方式需要侵入各个业务方的核心流程中,且需要修改当前业务逻辑,成本过高。
在七鱼,我们设计实现了一个异步调用的组件。假设删除客服关注A、B、C三个服务的结果,则ABC则向注册中心注册关注删除事件。每次删除过程都会拿到关注列表,然后广播删除消息,业务方收到消息之后将结果返回到注册中心。删除客服依赖注册中心的通知机制获取到结果并决定是否完成删除。
由于该过程是异步驱动的,所以会有一个Timeout等待过程。这里有两个模式,一个是强依赖模式,Timeout则操作失败;另外一种是弱依赖模式,Timeout依然可以操作成功。
这样做的好处在于:
- 可以解除掉代码层的耦合,假设新增需要校验的业务方D,在D服务上注册关注删除事件即可。
- 这个改造对现有业务逻辑和核心流程没有影响,变更范围很有限。
- 不是直接调用,因此不会出现反向依赖。
防腐层
接事件驱动和异步调用。
最理想的情况下,业务方能够响应核心系统的驱动事件,从而完成完整的业务流程。
但是实际上,第三方由于不受本团队的控制,开发排期不可控、开发动力不强。为了能持续推进本团队的优化更新,需要依赖业务方的代码全部封装在一起并从P0级服务中剥离出去,防止对核心模型和核心流程造成污染。
我们在做注册解耦的时候,需要业务方响应企业注册的生命周期事件。在做客服删除的解耦时,则需要业务方集成异步调用组件。
这就导致我们的开发依赖了其他业务团队。为此我们单独增加一个防腐层应用,将响应生命周期事件和集成异步掉调用组件的逻辑迁移过去,最后完成了改造。
这样做,我们的开发才能够按时顺利完成,同时跟业务系统的耦合被限制在一个单独的应用中限制了腐化的范围,后期业务方迁移也变得非常容易。
5. 总结
拆分、按需加载、弱依赖降级、边界变更、事件驱动这些手段是最开始做治理时的切入点。随着治理的深入,很多问题不是简单的拆分和变更能解决的。只有通过领域模型改造,才能找到一种方法将服务完整解开来。
但是,模型改造的成本往往是很高的,现实操作中我们不得不采用防腐层、能力上推、异步调用等手段来确保改造能实际进行下去,而不是陷入到无穷无尽的排期、测试中。
在梳理好了边界关系之后,上层服务还是可能影响底层服务稳定性的。主要的场景在不受控的调用产生的系统压力。这属于熔断限流降级的范畴,这里就不展开做详细讨论了。
更多技术干货,欢迎关注【网易智企技术+】微信公众号
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。