摸着石头过河:知乎核心业务 Go 语言改造实践

302 阅读15分钟
原文链接: click.aliyun.com

背景

众所周知,知乎社区后端的主力编程语言是 Python。

随着知乎用户的迅速增长和业务复杂度的持续增加,核心业务的流量在过去一年内增长了好几倍,对应的服务端的压力也越来越大。随着业务发展,我们发现 Python 作为动态解释型语言,较低的运行效率和较高的后期维护成本带来的问题逐渐暴露出来:

  1. 运行效率较低。知乎目前机房机柜空间已经不足,按照目前的用户和流量增长速度,可预见将在短期内服务器资源告急(针对这一点,知乎正在由单机房架构升级为异地多活架构);

  2. Python 过于灵活的语言特性,导致多人协作和项目维护成本较高。

受益于近些年开源社区的发展和容器等关键技术的普及,知乎的基础平台技术选型一直较为开放。在开放的标准之上,各个语言都有成熟的开源的中间件可供选择。这使得业务做选型时可以根据问题场景选择更合适的工具,语言也是一样。

基于此,为了解决资源占用问题和动态语言的维护成本问题,我们决定尝试使用静态语言对资源占用极高的核心业务进行重构。

为什么选择 Golang

如上所述,知乎在后端技术选型上比较开放。在过去几年里,除了 Python 作为主力语言开发,知乎内部也不乏 Java、Golang、NodeJS 和 Rust 等语言开发的项目。

51d79450f28545d665ca117e07cdcc17513cb7ed
通过 ZAE(Zhihu App Engine) 新建一个应用时,提供了多门语言的支持

Golang 是目前知乎内部讨论交流最活跃的编程语言之一,考虑到以下几点,我们决定尝试用 Golang 重构内部高并发量的核心业务:

  • 天然的并发优势,特别适合 IO 密集应用

  • 知乎内部基础组件的 Golang 版生态比较完善

  • 静态类型,多人协作开发和维护更加安全可靠

  • 构建好后只需一个可执行文件即可,方便部署

  • 学习成本低,且开发效率较 Python 没有明显降低

相比另一门也很优秀的待选语言—— Java,Golang 在知乎内部生态环境、部署的方便程度和工程师的兴趣上都更胜一筹,最终我们决定,选择 Golang 作为开发语言。

改造成果

截至目前,知乎社区 member(RPC,高峰数十万 QPS)、评论(RPC + HTTP)、问答(RPC + HTTP)服务已经全部通过 Golang 重写。同时因为在 Golang 化过程中我们对 Golang 基础组件的进一步完善,目前一些新的业务在开发之初就直接选择了 Golang 来实现,Golang 已经成为知乎内部新项目技术选型的推荐语言之一。

相比改造前,目前得到改进的点有以下:

  1. 节约了超过 80% 的服务器资源。由于我们的部署系统采用蓝绿部署,所以之前占用服务器资源最高的几个业务会因为容器资源原因无法同时部署,需要排队依次部署。重构后,服务器资源得到优化,服务器资源问题得到了有效解决。

  2. 多人开发和项目维护成本大幅下降。想必大家维护大型 Python 项目都有经常需要里三层、外三层确认一个函数的参数类型和返回值。而 Golang 里,大家都面向接口定义,然后根据接口来实现,这使得编码过程更加安全,很多 Python 代码运行时才能发现的问题可以在编译时即可发现。

  3. 完善了内部 Golang 基础组件。前面提到,知乎内部基础组件的 Golang 版比较完善,这是我们选择 Golang 的前提之一。不过,在重构的过程中,我们发现仍有部分基础组件不够完善甚至缺少。所以,我们也完善和提供了不少基础组件,为之后其它项目的 Golang 化改造提供了便利。

eb3953d07dc6dbbc0c39595fe6ee1b366f850bdc
过去 10 个月问答服务的 CPU 核数占用变化趋势

实施过程

得益于知乎微服务化比较彻底,每个独立的微服务想要更换语言非常方便,我们可以方便地对单个业务进行改造,且几乎可以做到外部依赖方无感知。

知乎内部,每个独立的微服务有自己独立的各种资源,服务间是没有资源依赖的,全部通过 RPC 请求交互,每个对外提供服务(HTTP or RPC)的容器组,都通过独立的 HAProxy 地址代理对外提供服务。一个典型的微服务结构如下:

a04e6571aa6d7409a847c9eb672ebc66326c12bc
知乎内部一个典型的微服务组成,服务间没有资源依赖

所以,我们的 Golang 化改造分为了以下几步:

Step1. 用 Golang 重构逻辑

首先,我们会新起一个微服务,通过 Golang 来重构业务逻辑,但是:

  1. 新服务对外暴露的协议(HTTP 、RPC 接口定义和返回数据)与之前保持一致(保持协议一致很重要,之后迁移依赖方会更方便)

  2. 新的服务没有自己的资源,使用待重构服务的资源:

49c0cb6e3359af94dd5f77c9aff21b07173d8281
新服务(下)使用待重构服务(上)的资源,短期内资源混用

Step2. 验证新逻辑正确性

当代码重构完成后,在将流量切换到新逻辑之前,我们会先验证新服务的正确性。

针对读接口,由于其是幂等的,多次调用没有副作用,所以当新版接口实现完成后,我们会在老服务收到请求的同时,起一个协程请求新服务,并对比新老服务的数据是否一致:

    1. 当请求到达老服务后,会立即启一个协程请求新的服务,与此同时老服务的主逻辑会正常执行。

    2. 当请求返回后,会比较老服务与新实现的服务返回数据是否相同,如果不同,会打点记录 + 日志记录。

    3. 工程师根据打点指标和日志,发现新实现逻辑的错误,改正后继续验证(其实这一步,我们也发现了不少原本 Python 实现的错误)。

79bd0917932df4689e6c828ea712f3d5de92d6b5
服务请求两边数据,并对比结果,但返回老服务的结果

而对于写接口,大部分并不是幂等的,所以针对写接口不能像上面这样验证。对于写接口,我们主要会通过以下手段保证新旧逻辑等价:

    1. 单元测试保证

    2. 开发者验证

    3. QA 验证

Step3. 灰度放量

当一切验证通过之后,我们会开始按照百分比转发流量。

此时,请求依然会被代理到老的服务的容器组,但是老服务不再处理请求,而是转发请求到新服务中,并将新服务返回的数据直接返回。

之所以不直接从流量入口切换,是为了保证稳定性,在出现问题时可以迅速回滚。

7e54671ea2bb1b1df4d468342d2333e622b08eec
服务请求 Golang 实现

Step4. 切流量入口

当上一步的放量达到 100% 后,请求虽然依然会被代理到老的容器组,但返回的数据已经全部是新服务产生的。此时,我们可以把流量入口直接切换到新服务了。

93b62ca73fdef8eb659c2a457a435d0b216e4c9f
请求直接打到新的服务,旧服务没有流量了

Step5. 下线老服务

到这里重构已经基本接近尾声了。不过新服务的资源还在老服务中,以及老的没有流量的服务其实还没有下线。

到这里,直接把老服务的资源归属调整为新服务,并下线老服务即可。

f14880ec8bab51e3820287e25262c0900cb6ad89
Goodbye,Python

至此,重构完成。

Golang 项目实践

在重构的过程中,我们踩了不少坑,这里摘其中一些与大家分享一下。如果大家有类似重构需求,可简单参考。

换语言重构的前提是了解业务

不要无脑翻译原来的代码,也不要无脑修复原本看似有问题的实现。在重构的初期,我们发现一些看似可以做得更好的点,闷头一顿修改之后,却产生了一些奇怪的问题。后面的经验是,在重构前一定要了解业务,了解原本的实现。最好整个重构的过程有对应业务的工程师也参与其中。

项目结构

关于合适的项目结构,其实我们也走过不少弯路。

一开始,我们根据在 Python 中的实践经验,层与层之间直接通过函数提供交互接口。但是,迅速发现 Golang 很难像 Python 一样,方便地通过 monkey patch 完成测试。

经过逐渐演进和参考各种开源项目,目前,我们的代码结构大致是这样:

.
├── bin 	--> 构建生成的可执行文件
├── cmd 	--> 各种服务的 main 函数入口( RPC、Web 等)
│ ├── service 
│ │ └── main.go
│ ├── web
│ └── worker
├── gen-go 	--> 根据 RPC thrift 接口自动生成
├── pkg 	--> 真正的实现部分(下面详细介绍)
│ ├── controller
│ ├── dao
│ ├── rpc
│ ├── service
│ └── web
│ 	├── controller
│ 	├── handler
│ 	├── model
│ 	└── router
├── thrift_files 	--> thrift 接口定义
│ └── interface.thrift
├── vendor 	--> 依赖的第三方库( dep ensure 自动拉取)
├── Gopkg.lock 	--> 第三方依赖版本控制
├── Gopkg.toml
├── joker.yml 	--> 应用构建配置
├── Makefile 	--> 本项目下常用的构建命令
└── README.md

分别是:

  • bin:构建生成的可执行文件,一般线上启动就是 `bin/xxxx-service`

  • cmd:各种服务(RPC、Web、离线任务等)的 main 函数入口,一般从这里开始执行

  • gen-go:thrift 编译自动生成的代码,一般会配置 Makefile,直接 `make thrift` 即可生成(这种方式有一个弊端:很难升级 thrift 版本)

  • pkg:真正的业务实现(下面详细介绍)

  • thrift_files:定义 RPC 接口协议

  • vendor:依赖的第三方库

其中,pkg 下放置着项目的真正逻辑实现,其结构为:

pkg/
├── controller 	
│ ├── ctl.go 	--> 接口
│ ├── impl 	--> 接口的业务实现
│ │	└── ctl.go
│ └── mock 	--> 接口的 mock 实现
│ 	└── mock_ctl.go
├── dao 	
│ ├── impl
│ └── mock
├── rpc 	
│ ├── impl
│ └── mock
├── service 	--> 本项目 RPC 服务接口入口
│ ├── impl
│ └── mock
└── web 	--> Web 层(提供 HTTP 服务)
 ├── controller 	--> Web 层 controller 逻辑
 │ ├── impl
 │ └── mock
 ├── handler 	--> 各种 HTTP 接口实现
 ├── model 	-->
 ├── formatter 	--> 把 model 转换成输出给外部的格式
 └── router 	--> 路由

如上结构,值得关注的是我们在每一层之间一般都有 impl、mock 两个包。

255fecac8a3f1c0889a13732aba73ddfd2500732

这样做是因为 Golang 中不能像 Python 那样方便地动态 mock 掉一个实现,不能方便地测试。我们很看重测试,Golang 实现的测试覆盖率也保持在 85% 以上。所以我们将层与层之间先抽象出接口(如上 ctl.go),上层对下层的调用通过接口约定。在执行的时候,通过依赖注入绑定 impl 中对接口的实现来运行真正的业务逻辑,而测试的时候,绑定 mock 中对接口的实现来达到 mock 下层实现的目的。

同时,为了方便业务开发,我们也实现了一个 Golang 项目的脚手架,通过脚手架可以更方便地直接生成一个包含 HTTP & RPC 入口的 Golang 服务。这个脚手架已经集成到 ZAE(Zhihu App Engine),在创建出 Golang 项目后,默认的模板代码就生成好了。对于使用 Golang 开发的新项目,创建好就有了一个开箱即用的框架结构。

静态代码检查,越早越好

我们在开发的后期才意识到引入静态代码检查,其实最好的做法是在项目开始时就及时使用,并以较严格的标准保证主分支的代码质量。

在开发后期才引入的问题是,已经有太多代码不符合标准。所以我们不得不短期内忽略了很多检查项。

很多非常基础甚至愚蠢的错误,人总是无法 100% 避免的,这正是 linter 存在的价值。

实际实践中,我们使用 gometalinter。gometalinter 本身不做代码检查,而是集成了各种 linter,提供统一的配置和输出。我们集成了 vet、golint 和 errcheck 三种检查。

降级

降级的粒度究竟是什么?这个问题一些工程师的观点是 RPC 调用,而我们的答案是「功能」。

在重构过程中,我们按照「如果这个功能不可用,对用户的影响该是什么」的角度,将所有可降级的功能点都做了降级,并对所有降级加上对应的指标点和报警。最终的效果是,如果问答所有的外部 RPC 依赖全部挂了(包括 member 和鉴权这样的基础服务),问答本身仍然可以正常浏览问题和回答。

我们的降级是在 circuit 的基础上,封装指标收集和日志输出等功能。Twitch 也在生产环境中使用了这个库,且我们超过半年的使用中,还没有遇到什么问题。

anti-pattern: panic - recover

大部分人开始使用 Golang 开发后,一个非常不习惯的点就是它的错误处理。一个简单的 HTTP 接口实现可能是这样:

func (h *AnswerHandler) Get(w http.ResponseWriter, r *http.Request) { ctx := r.Context() loginId, err := auth.GetLoginID(ctx) if err != nil { zapi.RenderError(err)---> return } answer, err := h.PrepareAnswer(ctx, r, loginId) if err != nil { zapi.RenderError(err)---> return } formattedAnswer, err := h.ctl.FormatAnswer(ctx, loginId, answer) if err != nil { zapi.RenderError(err)---> return } zapi.RenderJSON(w, formattedAnswer)}

如上,每行代码后有紧跟着一个错误判断。繁琐只是其次,主要问题在于,如果错误处理后面的 return 语句忘写,那么逻辑并不会被阻断,代码会继续向下执行。在实际开发过程中,我们也确实犯过类似的错误。

为此,我们通过一层 middleware,在框架外层将 panic 捕获,如果 recover 住的是框架定义的错误则转换为对应的 HTTP Error 渲染出去,反之继续向上层抛出去。改造后的代码成了这样:

func (h *AnswerHandler) Get(w http.ResponseWriter, r *http.Request) { ctx := r.Context() loginId := auth.MustGetLoginID(ctx) answer := h.MustPrepareAnswer(ctx, r, loginId) formattedAnswer := h.ctl.MustFormatAnswer(ctx, loginId, answer) zapi.RenderJSON(w, formattedAnswer)}

如上,业务逻辑中以前 RenderError 并直接紧接着返回的地方,现在再遇到 error 的时候,会直接 panic。这个 panic 会在 HTTP 框架层被捕获,如果是项目内定义的 HTTPError,则转换成对应的接口 4xx JSON 格式返回给前端,否则继续向上抛出,最终变成一个 5xx 返回前端。

这里提到这个实现并不是推荐大家这样做,Golang 官方明确不推荐这样使用。不过,这确实有效地解决了一些问题,这里提出来供大家多一种参考。

Goroutine 的启动

在构建 model 的时候,很多逻辑其实相互之间没有依赖是可以并发执行的。这时候,启动多个 goroutine 并发获取数据可以极大降低响应时间。

不过,刚使用 Golang 的人很容易踩到的一个 goroutine 坑点是,一个 goroutine 如果 panic 了,在它的父 goroutine 是无法 recover 的——严格来讲,并没有父子 goroutine 的概念,一旦启动,就是一个独立的 goroutine 了。

所以这里一定要非常注意,如果你新启动的 goroutine 可能 panic,一定需要本 goroutine 内 recover。当然,更好的方式是做一层封装,而不是在业务代码裸启动 goroutine。

因此我们参考了 Java 里面的 Future 功能,做了简单的封装。在需要启动 goroutine 的地方,通过封装的 Future 来启动,Future 来处理 panic 等各种状况。

http.Response Body 没有 close 导致 goroutine 泄露

一段时间内,我们发现服务 goroutine 数量随着时间不断上涨,并会随着重启容器立刻掉下来。因此我们猜测代码存在 goroutine 泄露。

5668382ed19d1638cdddeeb991d9fe698dd3459b
Goroutine 数量随运行时间逐渐增长,并在重启后掉下来

通过 goroutine stack 和在依赖库打印日志,最终定位到的问题是某个内部的基础库使用了 http.Client,但是没有 `resp.Body.Close()`,导致发生 goroutine 泄露。

这里的一个经验教训是生产环境不要直接用 http.Get,自己生成一个 http client 的实例并设置 timeout 会更好。

修复这个问题后就正常了:

861c3474e07de815d5ec32f6841cfe654959bc6b
resp.Body.Close()

虽然简单几句话介绍了这个问题,但实际定位问题的步骤耗费了我们不少时间,后面可以新起一篇文章专门介绍下 goroutine 泄露的排查过程。

最后

核心业务的 Golang 化重构是由社区业务架构团队与社区内容技术团队的同学一起,经过 2018 年 Q2/Q3 的努力达成的目标。以下是两个团队的部分成员:

@姚钢强 @Adam Wen @万其平 @陈铮 @yetingsky @王志召 @柴小喵 @xlzd


社区业务架构团队负责解决知乎社区后端的业务复杂度和并发规模快速提升带来的问题和挑战。随着知乎业务规模和用户的快速增长,以及业务复杂度的持续增加,我们团队面临的技术挑战也越来越大。目前我们正在实施知乎社区的多机房异地多活架构,同时也在努力保障和提升知乎后端的质量和稳定性。


原文发布时间为: 2018-11-30
本文作者:xlzd
本文来自云栖社区合作伙伴“ 码洞”,了解相关信息可以关注“码洞”。