文 /吕亚霖、蒋帅
作业帮服务端 GO 语言化背景
作业帮初期因业务快速发展,服务端采用 PHP 语言作为主要开发语言,很好支撑了业务快速的迭代发展。但随着业务发展,以 ODP 为代表的 PHP 服务端技术栈遇到了一些问题,主要是:
微服务架构支持欠缺:ODP 通过 PHPLIB 耦合服务,类单体架构,服务间边界模糊,框架全局部署且缺乏现代包管理工具。
性能/成本瓶颈:PHP 缺乏线程/协程支持,资源使用率高,业务成本大,在高并发、高性能的部分场景与 GO 有一定差距。
云原生适配不足:云原生带来的技术红利,比如容器化、服务治理、devops、服务观测,PHP 的适配度低,比如 FPM fastcgi 在原生 mesh 的支持上及 CD 上过多耦合。
所以作业帮选择了 GO 作为主推的服务端开发语言来替代 PHP。作业帮 GO 语言框架 zgin 是基于 gin 衍生而来,是面向 web 服务的开发框架,提供了开箱即用的常用组件和功能,侧重通用性和稳定性,兼顾性能和时延,构建了符合公司业务场景的生态体系。
框架在技术体系中的定位
设计思想
应用框架是规范了 WEB 请求处理周期内的主体流程,并且把服务的流程进行抽象和分离。我们希望框架有一个性能优良、稳定可靠、功能简单明确的核心,通过常用组件管理来解决业务通用性扩展的问题。同时应用框架是连接业务研发和基础架构(架构、安全、运维、DBA)的纽带。所以他承担了向下落地研发规范、提升研发效率、保证研发质量;向上承接云原生架构(服务观测、服务治理、容器化、devops)、保证架构的落地和持续迭代。
框架能力/职责如下:
server 模块:基于 gin 的二次开发及相关优化。
支持云原生的服务治理体系:友好支持基础架构的服务发现注册、服务观测、服务间同步异步通信。
封装可靠易用的通用组件:开箱即用、稳定可靠、性能优化、修复开源问题。
提升开发效率:完善的框架周边效率工具,提升研发效率。包含开发、测试、部署到线上维护等方面,比如代码生成工具、构建本地开发环境、本地和 kubernetes 测试环境互通等。
标准化、规范化:提升代码质量 &可维护性
开发规范、目录规范、go 语言规范
规范落地:部署、运行、观测
安全:业务无感知的 RASP 安全防护
架构示意图
httpserver 模块
性能优化
GIN 是一个通用性应用框架,我们在 GIN 的基础上做了一些性能优化,用来适配底层基础架构技术方案,同时对业务场景下带来性能提升。
1. netpool or mesh uds。我们知道 httpServer 中一直以性能著称的 fastHttp,与 gin 相比有一个很大的优势点就是复用了连接池,但是我们并未在 gin 的二次开发中实现连接池,这里考虑能力下移,借助底层架构的能力,多语言栈保持同等能力。我们所有服务模块都已经接入 MESH 化治理,上游请求实际上是跟 mesh-proxy 建立连接,而 mesh-proxy 和服务模块之间是通过 UDS 通信且保持长链接,同时优化 UDS 的零拷贝。
2. GC 优化。尽可能使用 sync.Pool 复用对象,优化 string 与[]byte 的转换等等,尽量减少 GC 压力,并针对运行资源优化 GC 参数。
3. GMP 优化。作业帮现有服务模块基本以容器 K8S POD 模式,运行在大规格(256 核)裸金属服务器上。我们做了以下优化:1.自动适配容器场景下 POD 的 limit,作为 maxProcs。2.在对大规模裸金属服务器的 numa 拓扑特性做 GMP 调度优化。
任务功能扩展
除了核心的 httpServer 外,我们还对处理逻辑进行了扩展,请求的入口除了上游发起的 http 请求外,还有服务自动发起的任务,包括一次性任务、周期任务、定时任务。应用框架中,我们模拟请求链路实现了任务的封装,实现了 requestId 串联整个请求、支持加载中间件等功能。
暴露通用接口
httpServer 默认添加了一些通用的工具型接口,旨在减少业务对接观测工具平台,主要接口如下:
1. readiness 服务就绪检查接口。一般来说,服务什么时候准备好接收流量意味着:应用运行状况是良好的、任何潜在的初始化步骤均已完成了,发送到应用的任何有效请求都不会导致错误。通常,业务如果没有特殊需求不需要做特殊处理,在框架中我们给出了默认的实现。如果业务有特殊需求,可以重写 ready 接口来实现。
2. pprof 信息采集接口。定位瓶颈要借助各种性能分析工具,对外提供统一的接口和输出结构体,对接公司工具平台。同时完善 pprof 能力,对当前 go 的 CPU profiler 不能分析 off-cpu time 的问题,做二次开发。
3. go runtime 信息采集接口。针对 go 运行时的相关指标(GC/memory/goroutine),对外提供统一的接口和输出格式,对接公司工具平台。
通用组件
针对研发过程中常见的组件进行封装,修复开源问题、保证稳定性、持续优化性能,按照作用组件大致可以分为三类:
通用 client 组件
通用 client 组件,主要包括消息队列、对象存储、数据存储、httpClient 常用库等。主要工作:
1.对接服务治理体系:所有网络 client 组件都接入 MESH 治理体系。出流量观测示例:
2.对开源软件做规范适配、性能优化:规范比如日志规范、容器化运行规范;性能优化比如:json 编解码优化、字符串拼接及转化的优化、对频繁分配的小对象使用 sync.Pool、避免反射、减少加锁粒度、优化 MAP key 类型使用不当造成的 gc 问题等。
3.对应用技术栈平台做适配:适配脚手架工具和平台,方便后期升级推送、统一升级,此外还提供了丰富的 demo 实例。
通用基础组件
该类组件主要是选型的问题,通过对比、压测选取最优的开源包或者自行开发,定制合理的规范、保证可靠性、优化性能。该类型组件比如:json 库、协程池、日志组件等。
以日志组件为例:
gin 中日志输出相对较少,所以使用的是原生的日志库,没有考虑日志格式及性能的问题。然而日志对整个研发周期来说非常重要,日志消费方有研发人员、监控报警、大数据分析等。并且日志输出是非常耗性能的(根据我们观察,很多业务喜欢开 debug 级别的日志,日志量非常大,一个接口中日志输出占到 30%的 CPU 时间都是很常见的),所以我们在框架中封装了 zlog 提供给业务使用,同时优化了 gin 框架中日志打印不规范的问题。
关于日志格式
目标:最大限度发挥日志的价值,需要让日志既满足人类可读性的要求,又满足机器结构化的需求。常见的日志格式:
格式一:kv 拼接格式,即半结构化格式。原服务端 C++项目多采用这种格式。
优点:
可读性高:方便业务方进行日志排查
明文匹配:对于需要按照正则匹配特定字符串的系统友好
缺点:
value 中可能包含特殊字符串,无法进行结构化分析。上述示例中,key value 使用=分隔,多个 key=value 使用空格分隔,当 value 存在分隔符(即:=或者空格)时,程序就无法自动解析。
为了解决无法自动解析的问题,对 value 进行 encode,避免 value 中出现特殊字符的问题,优化后格式如下:
优化后的日志格式虽然解决了结构化解析的问题,但是又引入了新的问题:可读性差、对需要正则匹配的消费方无法直接消费。
格式二:json 格式
Json 日志也可称作结构化日志,好处显而易见:简化了日志解析,使得日志的后续处理、分析或查询变得方便高效。
可读性:适中,可读性没有方式一中 kv 拼接格式好,但是经过日志检索平台的解析后,可读性与<方式一>基本无差别了。
明文匹配:由于 value 是明文的,能够兼容正则匹配。
同时考虑到 json 格式的受欢迎程度,易接受以及已经有大量服务使用了 json 做日志序列化方式,我们最终选择了 json 格式作为日志组件的标准格式。
日志性能优化点
1. 合理设置日志级别
2. json 序列化:在 go 中 json 编解码是非常耗费性能的,我们在框架里 json 编码实现是手动拼接字符串了。这种方式虽然使用不友好,但是对于基础组件中进行特定的优化,收益非常大。同样大小的 json 进行编码,使用字符串拼接方式能提升 4-5 倍的性能。
3. 减少反射:牺牲体验以换取性能,在对业务提供的日志输出方法中,日志中的扩展字段需要明确指定类型。
4. 使用对象池:一条日志的输出往往会涉及到多次小对象的分配,这种问题的常规做法就是使用 sync.Pool 增加临时对象的重用率,减少 GC 负担。
5. 缓存/异步:开启 buffer 缓存,缓存满或到了设定的最大间隔才输出。需要注意的是,最大间隔的设置与监控的实时性要求是不可调和的,框架需要权衡间隔时间的设置,框架默认(interval=5s,bufferSize=256k)。我们模拟一个线上真实的服务输出日志的场景,一次请求输出 15 条日志,压测结果显示,开启 buffer 后 CPU 能下降 40%,T99 可降低 90%
公司特定 utils 工具库
针对公司私有库中的一些基础函数(比如加解密函数、位运算函数、特定序列化函数)进行封装,避免每个业务实现一遍。另外针对 go 中常用的方法及耗费性能却不容易发现的方法进行封装,避免业务过度关注基础方法性能优化的问题。
效率工具
代码生成工具:简单易用,可以快速生成服务代码的脚手架,同时根据基础模板加可选的组件来生成业务应用的功能。一方面,可以规范业务对框架的使用,在目录结构、配置加载方式、组件初始化方式上给出了模版范例,避免个性化的写法,以减少后期维护的成本。另一方面,组件中给出了很多可选的示例代码,给用户提供了简单易上手的示例的同时还给用户演示了遵守框架标准规范的写法。
问题和性能分析:当业务方需要时可以在性能平台按照条件和需求开启 pprof,提供在线分析报告。支持业务开启 go runtime 通用指标采集并有统一监控大盘展示。
测试环境互通工具:提供简易命令行工具,可以代理测试环境指定服务的流量到本地端口,也可以转发本地的出流量到测试环境上。
框架推广和应用
框架推广痛点
升级推动困难
首先从 0 到 1 将大家收敛使用新框架的周期很长,需要业务方升级、部署,观察框架在他们业务上的表现。而升级框架对业务方来说优先级不高、成本大,需要熟悉框架、培训宣讲、也需要 QA 全面回归测试。
其次对与框架一些小版本出现的 BUG,需要强制快速通知升级,避免问题蔓延。而框架对业务方来说本身是一个 SDK,需要及时通知业务方主动升级。
最后业务方缺乏主动升级动力。很多业务方对性能优化、稳定性提升不敏感。比如管理后台类服务,往往框架版本落后 release 版本较多。
如何推动:
首先做好工具及生态建设,比如代码生成脚手架,组件更新脚手架等工具平台建设。同时对使用文档和使用样例、问题排查和注意事项,不断维护补充。
其次我们建立反向包管理工具平台。通过 CI 分析,知道所有使用方使用的是哪个包版本。对某个版本的使用方都明确建立索引关系。如下图:
当某个版本有明确问题需要升级或者下线时:
1. 系统会主动发布通知,通知到所有使用方。
2. 同步给服务模块评价体系,评价体系会将服务模块评分调低同时提出优化改进项和方法,在每日巡检里发送邮件给使用方。
3. 在 CICD pipeline 里,会拦截包含该版本的编译上线,提示使用方升级。
最后框架开发上尽量不做破坏性升级,尽量维持平滑升级。针对一些较早版本,我们会组织年度升级计划(21 年我们把 0.X 版本的业务全量推动升级到 1.X 版本以上)。通过平台工具,协助业务升级,让业务做好回归验证。
问题排查定位
使用框架过程中,基础架构面临最多的问题是协助业务方定位疑难问题。大部分场景里,业务方只要认为业务逻辑没有问题,遇到性能问题、偶现性 BUG、疑难问题都会寻求基础架构协助排查。而在规模较大的研发团队内,靠人工协助是很难做到快速响应,高效处理。
如何解决:
一是提供平台工具让业务方可以基于监控、日志、分布式追踪做一个初步分析。应用框架会适配基础架构服务观测体系,观测体系对全量流量做采集分析,不仅有 RPC 流量包括 mysql、redis、对象存储等有网络交互的组件纳管到服务观测体系中,同时打通了监控、日志、分布式追踪的数据互通,可以从分布式追踪跳转到日志检索找到对应所有日志,也可以从监控数据分析上下游流量的 QPS、时延、成功率等黄金指标。
二是提供平台工具让业务方自行分析 pprof 和 go runtime 各种指标。业务方如果做好以上分析后还是不能解决问题,转发给框架方来进一步定位问题。
资源和性能收益
和原生 GIN 性能对比
压测场景:模拟业务场景的数据,压测接口中我们模拟了从 db 查询 10 条记录压测案例:
原生 gin:直接使用原生 gin + gorm 实现一个接口
zgin:基于框架 cg 一个新项目,实现同样功能的一个接口
pod 规格: 2c/4G
压测结果:
CPU 对比:
T99 对比:
因为框架为适配底层架构做了很多优化,所以从整体上看 CPU 可以节省 30%左右。
真实业务服务下对比
业务场景:真实业务从原生 GIN 迁移到 ZGIN
迁移前后对比(10:30 发版上线):
应用框架应用情况
历经 2 年发展,GO 从 0 演化成服务端使用数量最多的开发语言,已有 GO 项目全部基于 ZGIN 构建。
增长数量见下图:
服务模块数量:600+
服务 POD 数量:1 万+
总结展望
作业帮 zgin 框架落地过程是结合业务实际需求、贴合业务场景,对开源框架进行二次优化,构建了初步的 go 生态,保证了云原生架构的快速落地和持续迭代。对开发、部署、运行、运维等各个阶段都提供了有力支持。同时也看到在框架落地推广的过程不是一蹴而就的,是一个长期治理升级的过程。
作者介绍:
吕亚霖,作业帮基础架构 - 架构研发团队负责人。负责技术中台和基础架构工作。在作业帮期间主导了云原生架构演进、推动实施容器化改造、服务治理、GO 微服务框架、DevOps 的落地实践。
蒋帅,2019 年加入作业帮,资深架构师,负责应用技术栈方向,包括应用基础镜像、应用框架(php 和 go)、业务微服务领域设计等。在作业帮期间,主要推动了 ODP 框架容器化改造、ODP 转 GO 及 GO 框架生态的建设。
评论 2 条评论