近期在研发一套物联网设备管理系统,其主要用途是将公司旗下所负责智能园区中的硬件设备通过物联网云平台来进行综合管控。
整个系统在架构上设计分为 4 层。自底向上分别是设备硬件、设备接入网关、物联网平台、设备管理系统。除去设备硬件,其它 3 层都属于软件范畴。
这篇文章主要记录一下我在开发设备管理系统的后端过程中的一些总结。
系统架构分层的演进
在目前的业务系统架构中,分层是这样的。
API:接口路由层,作用是为请求匹配相应的控制器函数。
Controller:对每个请求作出响应控制器,在某些系统中被称为 Resource。
BLL:Business Logic Layout 的缩写,业务逻辑层,在某些系统中被称为 Service。
Model:数据持久层/ORM 层,通常负责持久化数据的读写操作,在某些系统中被称为 Mapper 或者 Repository。
这是中规中矩、传统且典型的三层架构。但是它存在一些弊端。
最常见的两种情况是层次边界混乱和服务循环依赖。
层次边界混乱
假设我有一个登陆服务,最初只提供给 Web 端调用,验证账号密码并返回 Token。
现在需要支持 H5 调用。如果通过 H5 调用,则会判断该手机号是否注册过,如果尚未注册,需要进行注册,并返回 Token,如果已注册,则直接返回 Token。
这里面就涉及到两个服务,Login 和 Register。
一种很不友好的方式是让客户端自行调用两次接口,显然这不太可取。因为这种做法是将业务逻辑前倾到了客户端。
如果在现有架构不变的基础上实现上述需求,有两种做法可以选。
第一种做法是将 Web 和 H5 的服务彻底分离开,走两套逻辑。
这种做法的优点是隔离性更好,对测试、维护和重构都更加友好,缺点是相同的逻辑要写多套。
第二种做法是服务不分离,在 Login 服务中写判断逻辑,由 Login 服务去调用 Register 服务。这是一种冗余的做法。
这种做法的优点是逻辑复用、对开发友好,缺点是隔离性很差,增加测试、维护和重构的难度。
服务循环依赖
假设我有一个用户服务和一个评论服务。其中用户服务依赖了评论服务,而评论服务耦又依赖了用户服务。这种情况就是典型的循环依赖,会引发宕机。
如果在现有架构不变的基础上实现上述需求,那么就一定要分清楚,哪个服务是上游服务、哪个服务是下游服务,上游只能依赖下游,而下游不能反向依赖上游。
但是一旦这么做了,客户端在调用下游服务时仍然想得到原本预期的数据结构,就需要发起至少两次调用,最终由客户端自己将数据在内存中做组合。整个流程相当麻烦。
而且最大的问题是,在架构的同一层级中不应该再继续分上游与下游。
解决之道
我们先来参考一下阿里巴巴 Java 开发手册 1.4.0 中给出的分层示例:
虽然语言不同,但是架构是可以彼此参考与借鉴的。阿里的 Java 架构是目前所有语言、所有行业、所有公司中做的最好的,所以非常有参考价值。
与我们的架构不同的是,阿里多了一个 Manager 的概念,它的作用是将原来的业务逻辑层一些通用的能力下沉到这一层,并且将所有外部接口和第三方服务都在 Manager 层处理,比如系统所依赖的 RPC 服务、HTTP 服务等。
除了阿里的架构外,我还参考了目前主流的微服务架构,它的宏观架构是这样的:
其中 MicroService 的意义是提供原子服务(Atomic Service)。
BFF 层的意义和阿里系统中 Service 的意义有些类似。
总而言之,应该有一层只提供职责单一的原子服务,还应该有另一层对原子服务进行编排的服务。有些系统设计中将这一层称为 API 聚合器(API Composer),我称为组合服务(Composite Service)。无论叫什么,它们的主要职责都是通过对原子服务的聚合、编排,对外提供一种友好、格式统一、数据完整的服务。
所以我的方案是将服务层拆分为两层,一层为原子服务,一层为组合服务。
原子服务仍然可以叫 bll,组合服务就叫做 bff,当然叫 service 也可以。
原则上控制器层不会直接调用原子服务,而是调用组合服务。在项目体量不大时,如果碰到单表的增删改查,直接调用原子服务也不会有太大问题。但仍然建议尽可能的不要直接调用原子服务,因为这是一种跨层的操作,当项目体量变大时,会让引用变得混乱。
BFF 的另一个关键优势
在很多缺乏 BFF 层的系统架构设计中,可能会**将这三个原子服务暴露给客户端,由客户端分别调用三次接口,并由客户端去维护数据间的关系,这是非常不可取的,因为这会让业务逻辑前置。**最严重的问题是,后端服务重构面临各种挑战,因为后端一旦重构意味着客户端端必须配合修改,从而需要发版。在这种情况下,Web 端还好解决,只需要清除缓存。但 App 端就必须要求用户升级版本。很显然,我们没有办法决定用户是否升级版本,而强制升级是一种非常不友好的做法。所以很多系统的接口会有版本的概念,比如 v1/v2/v3…主要还是为了兼容一些老旧版本的客户端。
一旦使用了 BFF 层设计,数据的聚合以及格式化就会从客户端下沉到 BFF 层,客户端耦合的服务将是 BFF 层而不是原子服务。
这样在重构原子服务时,只需要变更 BFF 层,由 BFF 层始终与客户端保持适配,小型改动则无需客户端发版。前后端耦合严重这个问题也将迎刃而解。
BFF 层落地的困境
单纯的设计技术架构只是开始,如何让其在现有项目的人员架构上落地将会是另一个问题。
理想状态下,应该是有专人负责 BFF 层的接口工作,而且这部分人应该是和大前端进行捆绑的,算是偏前的后端工程师。
如果项目的人员比较少,单独有人负责这个工作就有点得不偿失,理论上应该是前端工程师来写 BFF 层,但问题是前端工程师可能并不会使用后端的语言和框架。所以在 BFF 层概念出现的早期,基本上都是由 Node.js 来写的。
既然如此,那么由后端工程师来写怎么样呢?同时面临一个问题。BFF 层往往是跨模块的,往往一个后端工程师负责单个或几个模块,如果有一个 BFF 接口需要对接他负责范围以外的模块,就需要去了解对应的业务,成本也不会低。如果项目成员很少,那么采用协商数据格式的方式可能效率更高。
具体如何实施,需要看具体场景。
BFF 层性能优化:并行
在架构演进出来的 bff 层中,充斥着各种服务调用与数据格式化的工作。
假设我要提供一个用户主页接口。
该接口需要提供用户基础信息、用户粉丝数/关注数、用户近期发表的 10 篇文章。
这需要三个原子服务,分别是 UserInfo、Relationship、Article。
我们需要做的是由 BFF 层提供一个 UserHomePage 接口,这个接口的粒度很粗,可以直接满足客户端的数据需求。
主要的逻辑是客户端调用接口时附带用户 ID 参数,我们通过用户 ID 作为查询参数分别调用三个服务,得到三分数据,最终按照与客户端约定好的数据结构组合起来,返回给客户端。
我们可以简单的将这个步骤分为两个环节:请求数据(Request)与处理数据(Process)。
而这两步通常来说都无需串行。
如果使用串行模式,会让接口整体响应时间变得非常久。
多个服务的请求数据如果没有业务约束,统一使用协程去调用,然后通过 chan 与处理数据函数做数据交互。
数据处理流程同样可以采取并行模式,这将会大大提高性能。
高并发下的并行需要注意什么?
业务系统中有一个 receive 接口,主要就是提供一个 WebHook,用于接收从规则引擎中流出的设备数据。
由于设备的数量可能会非常多,同时每个设备的点位可能也非常多。所以这个接口的并发会非常高。
并发量到底有多大呢?有一个计算公式:设备数量*设备数据点/心跳时间。
假设有 500 台设备,每台设备有 40 个数据点,心跳时间是 2 秒。那么并发量就是 50040/2 = 10000 请求/秒。但后来我发现这个公式是有些问题的,因为它忽略了高峰期。因为设备几乎是同时上线的,所以心跳的并发可能是在同一时刻,也就是说上面的公式更像是在计算一个平均值。真实的并发量可能是 50040 = 20000 请求/秒。
这个接口主要做两件事:
- 存入 InfluxDB。
- 经过数据权限校验后推送给 WebSocket 客户端列表。
这两个任务彼此之间没有任何联系,为了提高整体的吞吐量,我在接收到数据后启动两个 GoRoutine,接着就把请求返回了,所以这个接口是一个异步接口。
伪代码:
func Receive(r *ghttp.Request) {
go saveToDB()
go sendToWS()
gplus.ResSuccess(r, nil)
}
在 sendToWS 中又会启动 N 个 GoRoutine 为 WebSocket 的每个客户端推送数据。
也就是说每有一个请求进来,都会创建 N 个 GoRoutine。计算公式为:请求数*(2 + WebSocket 客户端数量)。
假设有 10 个 WebSocket 客户端,继续按照上面的公式计算,20000 * (2 + 10) = 400000。
所以我很快就意识到这是有问题的,不能收到一个请求就创建一堆 GoRoutine。
这么做有两点坏处:
- 目前的 GoRoutine 创建至少消耗 2k 内存空间,按照以上的数值粗略估算,可以看到几乎每 2 秒内就会有 8G 的内存读写,非常消耗性能。而这些协程如果可以复用将大大提高性能。
- GoRoutine 的数量无限制,如果设备数量继续增加,或者 WebSocket 的客户端数量继续增加,都可能让 GoRoutine 的数量无限制增长下去,导致内存爆掉。
解决方案是引入协程池。协程池的实现原理非常简单,可以自己实现一个。或者直接使用开源的 ants。
使用协程池需要注意两个问题。
- 按照当前的业务量级合理设置协程池的数量。
- 协程池尽量提前预热。
性能瓶颈分析与优化
性能的分析三个步骤:
- 设置目标。
- 定位性能瓶颈点。
- 优化性能瓶颈点。
下面将对这三点进行展开分析。
设定目标
首先是设置性能目标,设置目标的通常是吞吐量和分位值的响应时间。如果你不清楚如何设置,我给出一个最简单的目标:在 QPS 符合预期的情况下,常规的面向客户端的接口平均响应时间不应该超过 200ms,最坏的情况是接口响应时间不应该超过 1s。你可以对你的接口进行测试,看看是否达到了这个目标。
性能测试、性能采集工具
定位性能瓶颈点除了经验以外,工具也是必不可少的。常见的工具有 benchmark、pprof 和 fortio 等。这些工具可以在不同的维度和粒度对程序进行精密的检查和监控,帮助我们全面的理解我们的程序。
benchmark
benchmark 是 Go 语言内置的一种性能测试框架,可以很容易分析出某个函数在各种调用情况下的性能表现。
pprof
pprof 是 Go 语言基础库提供的包,用于性能采集,支持 CLI 和 HTTP 两种方式。
如果是使用 goframe,可以使用 EnablePProf
开启。它可以监控 CPU、内存和 GoRoutine 等信息的实时情况。
通过 /debug/pprof 来访问它。
fortio
fortio 是一个接口负载测试框架,原来是著名的微服务管理项目 istio 的内置接口负载测试模块,后独立出来。它提供了一个简洁的 Web 界面,可以将测试结果保存到 JSON 文件中,非常简单易用。