架构艺术之应用分层设计

图片来自pixabay.com的ROverhate会员

1. 为什么需要应用分层架构设计

高内聚、低耦合、职责单一,是一个应用的基本设计要求。但是起初设计很好的应用边界,随着业务的扩张,待开发的业务功能越来越多,不断拆分出新应用
,经常出现的问题是,应用之间的相互依赖关系越来越模糊,相互调用,进而出现循环依赖和长链条依赖问题。

举一个简单的业务功能为例,用户下单支付,其需要调用支付中心进行支付、调用会员中心确认可用积分、调用营销中心发放优惠券、调用消息中心发送用户短信等步骤,这里涉及的应用有4个,

  • A 支付中心
  • B 会员中心
  • C 营销中心
  • D 消息中心

一个常见的技术实现是,在应用A中提供一个下单支付的接口,在A中先后调用执行应用B、C、D的接口,串联实现下单支付的功能。这不是理想的技术方案,最大的问题是,支付中心A承担了整个下单支付的流程,不仅需要负责处理各个接口的返回消息,还要处理在接口调用失败下的重试和异常告警,其功能职责不再单一。随着下单支付的业务功能越来越复杂,需要串联的业务流程步骤越多,今天加个促销积分,明天发不同优惠券,支付中心变得臃肿不堪,负责支付的开发工程师苦不堪言。

在调用关系上,到底是A调用B或C,还是从B调用到C,在没有沟通清楚的情况下,各种调用方法实现都有,很容易导致A->B->C->D的长链条调用,或A->B->A的循环调用,应用之间的调用变得复杂。管控的不好,业务架构的可扩展性无从谈起,开发团队之间经常扯皮,一个功能代码到底如何串联?在哪里实现?

这里其实涉及到的关键问题是,应用的逻辑架构和相互依赖关系,这正是应用分层架构设计所要解决的问题。

2. 一个通用的架构分层设计

下面将介绍一个通用的架构分层方案,

应用架构分层设计

如上图所示,这个架构分层设计的要点如下,

  1. 应用根据分层架构划分为四层,从上到下分别为,
    • API网关层:对外提供HTTP接口服务,实现统一的鉴权、流控和降级。
    • 业务聚合层:依赖业务中心服务,调用中心服务所提供的原子业务接口,串联起业务流程,实现基于业务场景的功能接口。其负责流程的异常处理和重试,跟进流程状态。
    • 业务中心服务层:实现单一、独立的原子业务功能,高内聚、低耦合。原子业务的含义是指业务不可拆分,有确定的输入和输出,在输入正常的情况下,必须确保业务的正常完成。
    • 业务数据服务层:提供业务数据的只读查询,不提供写操作。
  2. 应用调用关系必须从上往下调用,不允许同层调用,以免形成互相依赖和循环依赖。
  3. 应用必须按规范格式xxx-gateway/business/center/data命名,以便快速识别其工作的逻辑层次。
  4. 业务中心服务和数据服务应用将共享同一个DAO类库,其数据服务提供的是只读接口。
  5. 业务中心服务层不能相互调用,若有需要,允许通过消息中心进行异步通信调用。
  6. 业务数据服务层不是一定需要,若没有数据查询的需求,可以省略这一层的数据服务应用部署。

这个架构分层顺利执行的关键点主要在于两方面,

1. 应用调用关系严格按照从上往下调用,禁止同层调用,特别是业务中心服务层,各个中心服务不允许相互调用。
2. 应用命名必须严格遵从格式规范,规范的命名将方便团队之间沟通,特别是在架构评审时,大家可以快速识别其所在的逻辑层次,从而判断其调用关系是否合理。

3. 项目代码结构

根据上面的架构分层设计,一个相应的项目代码结构如下,

pphh-demo
  + demo-async        业务异步调用:包括消息或定时任务
  + demo-gateway      业务网关
  + demo-business     业务聚合层应用
  + demo-business-api 业务聚合层接口定义
  + demo-center       业务中心服务应用
  + demo-center-api   业务中心服务接口定义
  + demo-data         业务数据服务应用
  + demo-data-api     业务数据服务接口定义
  + demo-dao          数据库访问类库

4. 带来的好处

作为一个顶层设计,分层架构定义了整体的逻辑应用架构和上下依赖关系,确认各个层次的应用大边界。以此为基础,进行新应用的设计和拆分、定义功能边界,将会更加容易,整体架构的可扩展性也可以得到保证。

从调用链的角度,应用的调用层次深度保持为一个常量,按照上图的分层架构设计,深度最多为5,并且不会出现循环调用和长链条调用的问题。

在团队规模很小时,分层架构设计带来的好处可能会比较小,但是一旦团队规模成长到50人以上、应用个数上升到30个以上时,分层架构设计将发挥越来越大的作用,一旦整个团队对分层架构都达成统一思想,大家对自己开发的功能边界和应用交互都有清晰的认知,随着应用数增多,团队的沟通成本几乎为零。换句话说,任何一个开发人员通过应用名都可以清晰地知道应用之间合理的调用关系。

以上述的用户下单支付为例,在各个中心服务提供相应的原子业务实现,

  • A 支付中心:完成支付功能
  • B 会员中心:确认可用积分
  • C 营销中心:发放优惠券
  • D 消息中心:发送用户短信

然后整个流程的串联放在聚合层(即business层)实现,不再放在支付中心或其它任何中心服务,在聚合层中确保流程的异常处理和重试。任何流程的串联变化,都只需修改聚合层的代码。流程的状态处理完全从中心服务的业务实现中隔离出来,让中心服务专注于业务的原子粒度实现。

当前分层架构也带来一些开销,在团队规模小的时候,需要拆分的应用个数也是可观的,带来部署的机器成本也是存在的。但总体而言,利大于弊。

5. 结束语

分层架构设计提供了一个自顶向下的设计思路,一旦理顺了架构分层,在团队内掌握好架构分层的要点,则应用的边界定义、架构的可扩展性、团队的沟通协作,都是水到渠成的事情,这也正是架构的艺术魅力所在。

一个架构评审文档模板

图片来自pixabay.com的Alexas_Fotos会员

架构评审作为项目研发的一个重要节点,一般发生在项目立项之后、正式的代码开始开发之前,由开发人员从架构的角度思考项目落地方案的合理性、规范性、可行性、可扩展性,使得能够在项目的前期解决架构问题,避免在后期、代码开发完成之后发生大量的返修改造工作,并让测试、运维、平台架构等相关的团队尽早介入项目的推进过程中。

为了让架构评审能够顺利组织运作,好的架构评审模板是关键点。

本文提供一个架构评审模板,本模板从实践中逐步总结而来,供技术团队参考。有几个需要关注的方面是,

  1. 和技术评审文档相比,架构评审文档是以架构师的视角来编写,体现了架构师对项目的整体理解和设计。架构评审文档关注架构设计,技术评审文档关注实现细节。
  2. 作为模板,其一般包含了架构评审所需要的方方面面,在实践过程中,并不是所有部分都需要一一列出,只需列出期望被架构评审的要点即可,无须求全。
  3. 在架构评审会议之前,建议对待评审的架构评审文档先做个快速审查,确认符合评审的要求。好的架构评审文档对架构设计有明确描述,能够帮助大家快速理解,并进入架构要点讨论。

1. 业务/功能简介

简要描述当前功能需求,其目标和意义,建议提供相应的业务主流程图。

2. 架构设计

描述当前项目的组件架构设计,包括物理架构和逻辑架构,用于评审架构是否合理,是否可行,是否符合规范。

2.1 物理架构

绘制应用服务的物理部署架构,以网络拓扑为主线,列出内外网访问的HTTP域名,内部RPC调用主链路。
主要用于查看部署架构是否高可用部署,是否支持水平扩展。
对于有网络流量治理的设计,比如限流和熔断,可以在物理部署架构图中进行说明,描述其网络调用情况和流量治理方案。

2.2 逻辑架构

绘制应用服务的逻辑架构,以组件的上下调用关系为主线,列出所有设计的应用组件和调用链,说明各个逻辑模块的职责功能,及其上下依赖情况。
主要用于查看组件的上下依赖关系是否合理,各个组件是否承载了合理的功能交互。
对核心流程,需要绘制调用时序图。

3. 数据模型和类设计

3.1 数据表ER数据模型设计

描述当前核心数据库表的关系大图,包括关联字段、关联关系(一对一、一对多、多对多)。

3.2 核心类设计

描述当前业务的抽象类、接口类设计,包括其相关继承和组合、实现关系,以及相关类设计模式的应用。

4. 技术难点设计

4.1 高并发场景

对于有大流量访问的项目,需要提供对流量的预估,描述支撑高并发场景的技术方案,包括缓存、异步消息、线程池配置等等,评估并发下的数据线程安全;
对于核心业务链路,还需要考虑相应的治理监控手段,比如限流熔断降级策略。

4.2 数据一致性场景

对于有对数据一致性要求的项目,比如交易、支付等场景,需要考虑相应实现的技术方案,包括数据库事务、消息事务等等。

4.3 数据大表设计

每天的数据记录多少,是否考虑分库分表,归档策略

  • 每天的数据增量多少?
  • 是否需要读写分离?
  • 是否需要分库分表?
  • 是否可以归档?若归档,保留多久的记录?

4.4 慢接口说明

对于单接口运行时间大于10秒的,需要单独列出进行评审。注:这里的10秒为业务入口网关的接口超时时间。

5. 运维设计

5.1 新应用和新域列表

若有新增应用或新域名,列出并说明相关信息,包括AppId、应用框架、功能等。

5.2 运维成本

列举所需的机器硬件配置,硬件成本,软件成本等。

5.3 监控接入

日志监控:是否接入日志和监控平台
告警设置:是否接入告警平台
健康检查接口:是否提供健康检查接口

5.4 可维护性

是否有新引入组件和技术栈?
是否符合发布标准?
是否可以快速水平扩展?
是否接入健康自检?

5.5 安全性

网络:当前的网络调用是否安全?是否需要配置白名单访问?是否遵循产品设计与开发安全红线?
软件:使用的第三方软件框架是否安全?(列出第三方软件、框架、组件,开源协议)

6. 其它

上述文档列出架构评审的基本要点,若有更多方面,可以在本评审模板的基础上进行增减内容。

Dubbo RPC调用链追踪接入Jaeger

图片来自pixabay.com的NickRivers会员

1. 介绍

微服务架构中,分布式追踪(distributed tracing)是一个关键的基础功能,通过分布式追踪技术,我们可以深入分析一次请求调用所执行的路径、性能消耗,帮助定位性能瓶颈点,透明化服务之间上下游网络调用关系,帮助优化服务层次依赖问题。

Dubbo RPC是业界常用的一个开源RPC框架,而Jaeger也是业界流行的一个开源分布式追踪组件,本文将介绍如何把Dubbo RPC调用链接入Jaeger追踪,文末将结合分布式追踪技术,对常见的问题案例进行分析和给出优化方案。

2. 追踪数据模型和dubbo埋点实现原理

Jaeger的追踪实现遵循了OpenTracing语义规范,其数据模型是一个trace包含多个span构成的有向无环图,如下是一个典型的trace样例,

        [Span A]  ←←←(the root span)
            |
     +------+------+
     |             |
 [Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
     |             |
 [Span D]      +---+-------+
               |           |
           [Span E]    [Span F] >>> [Span G] >>> [Span H]
                                       ↑
                                       ↑
                                       ↑
                         (Span G `FollowsFrom` Span F)

更多OpenTracing术语的定义和详细介绍请见这里,本文将不赘述。

若要将Dubbo RPC接入Jaeger追踪,从consumer到provider一次调用为最基本的追踪单元,整个dubbo的调用追踪可以视为该基本单元的规模扩展,因此该基本单元的追踪埋点实现是Dubbo RPC接入Jaeger追踪的核心。

下图为对这个基本追踪单元的埋点实现描述,

3. 代码实现

下面为简化版的RpcConsumerFilter实现代码,

@Activate(group = {Constants.CONSUMER})
public class RpcConsumerFilter implements Filter {

    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        // 获取context并创建span
        RpcContext rpcContext = RpcContext.getContext();
        Span span = DubboTraceUtil.extractTraceFromLocalCtx(rpcContext);

        Result result = null;    
        try {
            // 将span context加载到dubbo rpc remote context中
            DubboTraceUtil.attachTraceToRemoteCtx(span, rpcContext);

            // 执行dubbo rpc调用
            result = invoker.invoke(invocation);
        } catch (RpcException rpcException) {
            span.setTag("error", "1");
            throw rpcException;
        } finally {
            span.finish();
        }

        return result;
    }
}

下面为简化版的RpcProviderFilter实现代码,


@Activate(group = {Constants.PROVIDER}) public class RpcProviderFilter implements Filter { @Override public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException { // 获取context并创建span RpcContext rpcContext = RpcContext.getContext(); Span span = DubboTraceUtil.extractTraceFromRemoteCtx(rpcContext); Result result = null; try { // 将span context加载到dubbo rpc local context中 DubboTraceUtil.attachTraceToLocalCtx(span, rpcContext); // 执行dubbo rpc调用 result = invoker.invoke(invocation); } catch (RpcException rpcException) { span.setTag("error", "1"); throw rpcException; } finally { span.finish(); } return result; }

完整的代码演示样例请见这里

4. 埋点上报的span数据格式

数据字段 字段类型 是否必要字段 说明
traceId 字符串 Y 当前span所属的一次调用跟踪ID
spanID 字符串 Y 当前span的ID
parentSpanID 字符串 Y 父spanID,串联上下游span
startTime 长整型 Y 当前span的开始时间
duration 长整型 Y 当前span的时长
tags.span.kind 字符串 Y 当前调用方类型:server/client
tags.sampler.type 字符串 抽样器类型
tags.sampler.param 字符串 抽样比例,取值范围:0-1
tags.peer.hostname 字符串 对调方的主机名/IP
tags.peer.port 短整型 对调方的端口
operationName 字符串 Y dubbo接口名
tags.arguments 字符串 Y dubbo接口调用的参数
tags.error 字符串 Y* 当前dubbo调用出现异常或错误,值为“1”,注:该字段只有在有错误异常情况下为必要字段
tags.error.code 字符串 dubbo的异常码
tags.error.message 字符串 dubbo的异常消息

如下分别为来自dubbo consumer/provider的span上报数据样例,

// 消费方span
{
    "traceID": "5eb2e61e850be731",
    "spanID": "5eb2e61e850be731",
    "parentSpanID": "0",
    "startTime": 1592835612981000,
    "duration": 9781,
    "operationName": "com.pphh.demo.common.service.SimpleService:save",
    "tags.span.kind": "client",
    "tags.sampler.type": "probabilistic",
    "tags.peer.service": "com.pphh.demo.common.service.SimpleService",
    "tags.sampler.param": "1.0",
    "tags.arguments": "[{\"userName\":\"michael\"}]",
    "tags.peer.hostname": "192.168.1.105",
    "tags.peer.port": "29001"
}

// 提供方span
{
    "traceID": "5eb2e61e850be731",
    "spanID": "c7aaec2ab0a20823",
    "parentSpanID": "5eb2e61e850be731",
    "startTime": 1592835612986000,
    "duration": 1880,
    "operationName": "com.pphh.demo.common.service.SimpleService:save",
    "tags.span.kind": "server",
    "tags.peer.service": "com.pphh.demo.common.service.SimpleService",
    "tags.arguments": "[{\"userName\":\"michael\"}]",
    "tags.peer.hostname": "192.168.1.105",
    "tags.peer.port": "49707"
}

5. Dubbo RPC分布式追踪大图

将所有dubbo rpc调用的上报span数据按应用聚合,可以看到整个Dubbo RPC分布式追踪大图,

6. 通过分布式追踪发现的常见问题案例

6.1 应用服务依赖:多次重复rpc调用/上下依赖倒置

通过分布式追踪大图可以清晰地看到各个应用的调用关系,应避免不必要的重复rpc调用,禁止底层应用调用上层应用。

6.2 大流量调用

分布式追踪大图中,调用线条的粗细描述了各个调用关系的流量大小,

对于大流量调用,需要评估其流量的合理性,减少不必要的RPC调用开销,可以考虑从如下几个方面进行优化,
1. 循环多次调用转为单次批量调用。
2. 若是读操作,接受数据的时延,可以考虑使用local cache,在指定的N秒内,直接读取local cache。
3. 应用服务拆分,隔离因大流量调用而产生的CPU/内存/网络等资源竞争。

6.3 性能问题 - 重复调用转为单次批量调用

该问题典型场景为在一个循环中重复执行RPC调用,其调用性能取决于循环的次数,比如获取100个用户信息,循环100次获取用户信息,这个问题最好的办法是将循环调用转为单次批量调用。

下图为一个重复调用的追踪图,

6.4 异常调用定位 - 非法参数

对于异常调用,可以查看异常信息,并结合调用的参数,定位问题,比如用户名非法的异常,可以查看调用参数,用户名是否包含非法字符。

6.5 网络抖动

这种问题常见的现象是,整个调用链中,上游span耗时非常长,下游span耗时非常短,见下图,

可以看到,上下游都被执行,但是上下游衔接耗时很长,其问题的原因主要出现在上下游衔接。上图中的问题后来定位到网络抖动导致。

7. 参考资料

  1. Jaeger分布式追踪
  2. Jaeger开源代码
  3. OpenTracing语义规范