历史背景
受限于技术栈积累,以前后端服务技术栈全部使用 PHP 实现,采用标准 的lnmp架构。
但是随着流量及用户的迅速增长和业务复杂度的不断增高,我们尝试将 PHP 单体服务逐个拆分成多个独立的PHP子服务,部分上解决了业务复杂度上升导致的服务不可维护问题,但是因为 PHP 的动态语言特性导致的一些的问题无法解决:
- 运行效率较低,随着用户和流量的不断攀升,PHP 服务所依赖的资源增长迅速,甚至在一段时间出现了每天都要扩容线上资源的情况。在流量较低时成本问题一般不会引起重视,但是在流量十倍百倍增加时所增加的成本情况就不容乐观了。
- 部署流程越来越繁琐,随着机器数量的增加,即使使用了自动化部署,部署时间也越来越久。
- 动态语言因为没有类型约束等原因,在项目复杂度上升团队扩大时导致的协作效率成本和维护成本也在不断升高。
为什么选择 Golang?
基于以上原因,所以我们开始调研并尝试将部分核心业务使用Golang 进行重构。选择 Golang 的理由如下:
- 强类型语言及 gofmt等工具带来团队协作效率提升的同时降低后期维护成本。
- 简单好记的关键字和语法,不会带来太高的学习成本。团队成员在学习一段时间后即可上手开发项目。
- golang 因为静态语言特性带来极高的执行效率提升同时因为其极快的编译速度也不会引入额外的编译时间成本。
- 跨平台交叉编译,编译后的二进制几乎无任何平台依赖,真正实现一处编译处处运行,极高的提升了代码部署效率。
- 强大的标准库,golang标准库基本覆盖了开发网络应用的大部分基础能力,标准库没有的功能也可以找到三方库支持,避免重复造轮子。
- 原生并发支持,一个 go 关键字即可享受到 golang 的协程并发能力。另外得益于管道等的加持,极大简化了并发编程时引入的各类数据共享同步问题。
另外还有 足够轻量的协程,支持GC,内嵌C 支持,提供足够丰富简编的工具链 等优点,这里就不一一罗列了。
重构服务迁移
在流量快速增长的项目内替换核心服务,用网上一句流行的话来说就是『开着飞机换发动机』,替换的同时需要确保业务稳定性不受影响。
因为我们的 App端一直保持着双周迭代的节奏,所以采用了跟随 APP 迭代节奏小步快跑得方式进行迁移。
1.确定迁移服务并将提供的服务内容分组,分别评估服务耦合度及重构成本。首次迁移挑选业务无耦合,逻辑较独立的分组接口。
2.历史服务保持不变,所有 Go服务使用独立的网关出口。重构已确定分组下的所有接口,重构后的服务复用历史服务的数据库缓存资源。
3.重构后的服务跟随 APP 版本迭代节奏进行灰度后上线,至此新版 APP 都逐步迁移到新服务。
4.随着版本迭代,历史服务流量会逐步降低到归零,期间逐渐关停旧版服务节点直至完全关停。
工程实践
在从 PHP 迁移到 Golang的过程中,首先面临的就是项目结构如何组织,PHP项目使用的 laravel框架基本已经把项目结构给规范好了,在 Golang项目里应该如何组织呢?
我们参考了golang-standards/project-layout项目和开源框架的项目结构设计,最终确定的方案如下:
项目分层:
传输层: 作为服务流量入口封装HTTP,GRPC等传输协议,提供单个服务多协议接入能力。
业务层: 实现业务逻辑,单个逻辑可同时对传输层各个协议提供实现,一次实现多协议可接入。
数据层: 如果传输层看做是流量入口的话,数据层可以理解为流量出口。所有对外部数据的访问请求都在该层实现,比如数据库访问,缓存,三方 API 调用等。
项目目录结构
project-code
├── cmd/
│ └── api.go
├── configs/
├── internal/
│ ├── conf/
│ ├── dao/
│ ├── model/
│ ├── server/
│ └── http/
│ └── grpc/
│ └── service/
├── scripts/
├── pkg/
├── go.mod
├── go.sum
├── Makefile
├── README.md
/cmd: 项目入口文件,该目录内建议仅实现一些与项目启动相关的功能逻辑,如服务初始化,注销等。
/configs: 如果没有采用动态配置中心时用于存在本地配置文件。
/internal: 当前项目的私有程序及库代码。在 go1.14版本以后,存放于该目录的所有包将不能被其他项目导入。
/internal/server: 对应项目分层的传输层。
/internal/service: 对应项目分层的业务逻辑层。
/internal/dao: 对应项目分层的数据访问层。
/pkg: 当前项目可用到,并且同时能提供给外部项目使用的公共库代码。
并发/超时控制
在开发后端项目中会不可避免需要用到各个粒度的并发及超时控制,控制某个请求全链路的超时时间或者控制某个请求内单个操作的超时时间。好在从 GO 1.7 版本开始标准库内引入了 context 用来实现超时及并发控制。
context通过使用 withTimeout,WithCancel等方法构建一颗控制树,子级节点将接受任意一个父级或者祖先节点控制:
主流的 web 框架,如 gin 等默认是没有请求超时控制的。为了实现请求超时控制我们从传输层基于框架上下文初始化一个带超时的 context,然后实现各个分层逻辑时,默认将 context 作为首个参数逐个传入被调用层,这样请求内的操作都将接受顶层的context超时控制。
//server
func SomeReq(c *gin.Context){
//定义一个超时的 context,控制当前请求超时时间
ctx, _ := context.WithTimeout(c, 3*time.Second)
// 调用时逐层传入
xx := service.Do(ctx,id)
}
//service
func Do(ctx context.Context,id int){
// 调用时逐层传入
xx := dao.GetSomeOne(ctx,id)
// 逻辑层有逻辑需要控制超时
select {
case <- ctx.Done():
fmt.Println("timeout")
return
default:
}
// 逻辑层需要大量 for 循环密集 CPU 运算时
for Con {
select {
case <- ctx.Done():
fmt.Println("timeout")
return
default:
fmt.Println("dosomething")
}
}
return
}
//dao
func GetSomeOne(ctx context.Context,id int){
// 使用三方组件时 使用带 context 的方法即可继承超时
xx := mysql.Query(ctx,id)
// 调用其他HTTP/Grpc服务时
yy := http.NewRequestWithContext(ctx,.....)
zz := grpc.Call(ctx,.....)
}
错误处理
golang 中的错误处理是一直是被诟病的地方, go 项目里面可能有一半的代码都是 “if err != nil {}“。与 php/java 的错误处理相比,go 的错误处理可以称之为简陋,标准库的errors实现也只能携带一个字符串,但是真的就回天无术了吗?
什么时候该记录 error?
我们的项目已经分成多层传输层,逻辑层,数据层,代码结构是层层递进的。这时 error 可以出现在任何层,这个时候就会很纠结:
只记录 error 出现的日志? 因为 error 是逐层返回的,另外存在各种三方组件调用,外层无法判断 error 是否已经记录过。另外如果只记录初始 error,无法附加堆栈等信息。
每出现一个 error ,就打印一个 error?这样会导致出现同一个 error 会打印多次,日志里充斥着大量重复的错误日志。
最终我们使用了github.com/pkg/errors包,并且约定在出现 error 时通过逐层 wrap error返回到入口处进行记录。入口包括一下情况:
http/grpc请求入口,通过中间件拦截 error 并记录。
异步任务入口,包括协程/定时任务/消费者等,在调用者捕获 error 并记录。
无 error 返回的方法/函数内部调用返回了 error,此时必须进行记录。
业务逻辑错误与内部服务错误
业务逻辑错误指业务中存在的分支情况,是可预测的,正常的。比如登录时,如果用户还没有绑定手机,这个时候可以返回 200,然后在返回的 JSON 的 code 字段中指定一个业务异常代码。在代码中可以根据 erroc_code 字段来判断这次登录的结果。
服务端错误是不正常的,比如数据库查询超时,上游服务宕机等。对于服务端错误,我们返回的 HTTP 状态码一定是 5xx。这一方面是遵守 HTTP 的语义。一方面是为了把服务端错误和其他的情况区分开来。在调用方我们可以针对 5xx 状态码的返回做统一处理。
这两种情况的共同点是都会返回一个自定义的 code,但 HTTP 状态码 200 VS 5xx 的区别已经说明了这两种情况有着本质的不同。
具体到 error 处理上,需要为所有的业务逻辑错误声明错误类型,在 http 响应时,判断 error 类型是否为业务逻辑错误返回 200 还是 5xx。
任何时候 error 不可丢弃
在刚转入 go 开始时,发现在不少项目中可能为了省事,在返回 error 时直接用 _ 扔掉 error的情况,最常见的是 JSON 反序列化时,项目运行后也没任何异常。
起初没有业务侧监控,用户使用情况怎么样也无人知晓。直到后来统一记录所有 error 时,随之而来的就是一堆未发现的bug然后挨个填坑。所以扔掉 error ,只是掩耳盗铃的做法。
错误处理总结
- error 只记录于入口处,内部通过 error wrap 逐层返回。
- error 区分业务错误及服务服务,业务错误码需要提前声明以标识。
- 任何时候 error 不可丢弃,需要逐层返回知道被记录。
协程处理
在项目中用到协程比较频繁的情况分为以下两种:
并发多个请求。
这种情况只需要直接使用 go 关键字启动多个协程后,然后通过 chan 或者 sync 包来接收执行结果。
但是对于刚开始使用 golang 的人就很容易出现问题,刚开始通常会认为协程之间存在父子关系,在子协程 panic 的时候会被父协程recover。这时完全错误的理解。事实上协程之间完全属于平级关系,每个独立协程不经 recover的panic 都会导致整个进程崩溃。
所以在使用协程的时候就需要注意在启动协程时就进行 recover,这个时候可以使用封装协程的方式,将 go 关键字封装为独立方法进行调用。
func GoRecover(fn func(param ...interface{}), param ...interface{}) {
go func(fn func(pram ...interface{})) {
defer func() {
if err := recover(); err != nil {
log.Print("goroutine panic: ", err)
}
}()
fn(param...)
}(fn)
}
异步执行任务。
有时候为了降低请求时间,可能会将一些请求内的非主线业务逻辑直接使用协程执行。一般大家都是直接用 go 关键字启动一个协程执行完事。在流量较低时这种方法看上去问题不大,但是在流量并发较高时,会导致服务出现大量执行协程,性能损耗严重。
更好的做法是确定需要异步执行的任务,在服务启动时初始化一个管道并启动多个消费者协程,消费者协程监听管道 的数据进行消费处理。某个请求内的逻辑需要进行异步处理时,写入管道即可。
但是这种方法同样有个不容忽视的弊端,在服务异常关闭时可能会丢失管道内数据,此时就需要权衡业务逻辑,增加补偿逻辑,或者允许部分小部分数据丢失。另外在服务正常关闭时,需要等待管道内数据消费完毕后退出。