第一,Golang 并发优势执行效率很高。这也是很多人选择 Golang 的原因。
第二,相对于 JAVA 和别的语言来讲,Golang 在知乎内部的技术组件的生态比较完善。在去年上半年时间节点,Golang 是除了 Python 在平台支持最好的语言,另外是一个计算机型语言,在多人协作上很好的。
最后是学习成本。我们整个重构大部分同学都是现学的,包括我自己,我从去年上半年开始才使用Go的。我们大概从开始学习到开始上手就一周时间,最后 Golang 在知乎内部也是一门讨论非常热烈的语言,回到去年下半年时间节点,它的讨论热烈程度已经超过了Python2、Python3。
可能有人会问,我们选择了 Golang,把 Python 代码放到 Golang 上,Golang 比 Python 好吗?或者 Golang 是最好的语言吗?
这个问题非常引战。我们虽然选择了Golang,但是比较中立看这个问题,每门语言有每门语言适合的场景,我们在这个场景选择了Golang,但是我有大量的应用用了Python。在合适场景用合适的语言才是正确的方法,并不是说Golang能解决所有的问题。
重构成果
首先看一下我们整个重构的结果,刚才提到了我们机房快装不下了,Python 资源代码价很高,我们重构之后有没有满足之前的目标呢?在 CPU 方面,大概从去年2月份到11月份,从我们把新旧代码做完了替换之后,在时间节点上有超过80%的CPU的节约,这可能是最大的一个收益,或者说这是我们跟之前个预期达到的一个目标。而且这只是从 Python 翻译到 Golang 没有做任何语言层面的优化,只是纯代码翻译,已经带来了一个收益了。
除了节约80%以上的服务器资源之外,我们发现用 Golang 开发过程中,多人协作成本相比 Python 来讲低了很多。我们之前多人协作会遇到很多问题,发现静态语言来得彻底和爽快。Golang 之前在内部更多是中台使用,而没有是一个业务开发的语言。经过一轮踩坑,Golang 已经成为内部推荐的开发语言,尤其是对于资源占比比较大,而且流量更高的服务。现在我们 Web 服务评论、问答、文章和新的关注页都是使用 Golang 开发的。
重构过程
接下来分享我们重构的过程。取决于知乎从2013、2014做微服务尝试,去年的节点微服务到了非常彻底的状态,使得这次重构变得非常顺利:重构一个项目只需要针对这一个项目,而别的很多地方不需要做很多的改动。
这是一个知乎的微服务的大概的结构。我们会有一到多组RPC容器组,还有WEB服务,每个服务有一套独立的资源和CACHE,服务和服务之间没有任何资源的相互依赖。
我们第一步其实是新起了一个服务,这个服务跟以前的Python服务做了同样的实现,新服务和老服务的协议是完全兼容的,包括WEB服务和IBC服务接口协议、返回值各方各面完全兼容。但是新服务是没有资源,新服务使用老服务的资源,这一步完成之后,你不知道新服务实现是否正确,尤其是做热更新。到了这一步其实我们之前的Python是使用从一个流量PENEXT转到一个具体容器之后,在NEDOWE通过一个携程请求多浪的实现,Python这边会继续走后面的逻辑,会反馈结果,反馈结果会把Python和Golang的数据进行对比,这个时候拿到两份数据,应该100%一致,没有任何不同,如果有不同就说明有BUG。
这一步做打点和日志,有不一致有日志,有指标,根据指标和日志持续迭代Golang的实现。这个过程中,我们也发现一些以前Python的BUG,分两种,一种是一些比较简单的BUG直接在Python做了修复,但如果这个BUG比较大改起来比较困难,我们选择在Golang保持这个BUG,加一个todo,全部修复以后,又针对这些todo专门做了优化。
刚才的方式,读写口都是非常原始的状态和朴素的状态,通过单元测试保证,我们单元测试覆盖率以问答举例是85%左右,应该是比较高的状态了。开发者会手动验证,最后交付QA,QA做黑盒的验证,最后会把这个版本上到办公室,所有办公室都会访问新的Golang的实现,外网用户还会继续访问Python的实现。经过一到两周的验证,中间发现很多问题最后修复,最后达到正常的预期的状态。
到这一步其实我们做流量的转发,一个流量进到NGX,NGX打到业务的HA,HA打到进程里面。在PREP阶段有一个开关,经过这个开关,我的流量会走Golang,而不会继续走PYTHON,这个比例从0%、1%、2%、10%,逐步调到100%。有一些同学问为什么不把NGX,NGX指向新的Golang服务商,这是涉及稳定性问题,我们做这次重构的时候是非常保守的状态。如果换一个开关最多十秒钟就可以在所有容器生效了,但是如果我改NGX会跑测试,上测试环境,上办公室环境、上生产环境,上生产环境之前还上另外一个环境,折合下来二三十分钟过去了,如果不能100%保证服务用,通过放量更能保证我的稳定性。
一个放量达到100%之后,大概的状态就是一个流量或者所有的流量打进NGX达到到Python服务HA,Python自己不服务了,而是全部转发Golang服务,这一步可以改NGX,把真正流量直接打进Golang。当然RPC服务其实是通过改HA的地址,这个RPC以前Python服务完全没有流量了,还剩下一些资源在Golang,Python就下线了。到这一部为止,这几年知乎主力语言是Python,开始变成主力语言是Golang了。
一些实践经验
这个过程会遇到一些问题,下面会跟大家一起聊一下我们中间踩的一些坑。
1. 重构和优化不要同时做
首先一个踩过很大的坑就是,如果你不是非常有信心,千万不要同时做重构和优化,这两件事分开做。
我们在这个过程中遇到一个问题,我们在重构Member服务的时候,当时这个接口Cretmember创建一个用户,后端请求帐号,通过帐号创建一个用户,请求MEBER给用户申请资料,最后给用户生成一张登录的票据返回给客户端,客户端拿到票据就可以做事情。这个Cretmember实现不好,会返回用户ID,这个用户ID是COND服务生成的。当时我们重构过程中想这个实现不好,我们要改掉,怎么改呢?以前返回一个ID,现在变成注册成功和注册失败,改完了以后一同上线,我们没有改调用方。上线之后以前Python协议里面写的是一个INT,Golang恰好也是一个int,上线之后发现串号了,所有注册新用户都是M等于1,因为注册成功,Memer反馈是1,后端拿到1之后给新用户,所有新注册的用户变成了1。
整个故障从发现到回滚花不到五分钟时间,成本还是挺大的。带来一个经验教训就是,你不知道有一些很奇怪的实现,尤其在跑了很久的系统里面,一个很奇怪的是先有没有在哪一些隐藏角落里面被人依赖,最好如果你没有信心的话,最好把重构和优化当成两件事情来做,而不要同时做。以前发现Python一个大的BUG而在Golang保持这个Bug,等这个重构结束以后再做专门的BUG修复。
我们微服务做得比较早的状态,大概2013、2014年开始,到现在为止其实我们的Python项目有一个比较完善的模板,如果你维护一个Python项目,你去看其他的Python服务,都是八九不离十的状态。我们团队里面大部分同学都是新开始学Golang的时候,一开始对于整个项目的模板有一些比较大的争议,后来经过一些尝试,我们现在一个结构大概是像现在这样B目录放的是可执行文件,这个目录是不会提交到仓库。CMD是所有的程序入口,JGO是生成自动生成代码,PKG是逻辑实现。我们现在用的是DAD目前正在尝试用GENMOGO。一个BNT下面我们会分下接口和实现两个概念,以controller举例,一个controller会有多个接口,会有一个Worker实现和MPL的实现,在Worker的实现,我会向依赖controller的时候输入一个WORKER实现,针对业务逻辑方面注入MPL的实现。我之前通过Pacs的方式实现,会有问题,比如说我想验证一个接口是不是跑了一次,不是很完善,用WORKER方式比较好。整个项目我们是参考了gitrup。
2. 静态代码检查越早越好
另外一点,我们大概是在问答服务重构快完成的时候才想起来做代码检查,但是这个时候已经有点晚了,因为第一次代码检查的时候发现有一千多处问题,除了比较核心我们修复了之后还是不得不去掉了一些东西,知道最后做完了之后才花时间做调整。我们建议是项目一开始就要做代码检查,包括现在代码检查配到了ZAE新建项目里面,是一个比较严格的状态配置的。这样服务的时候,在创立之初代码模板带了代码检查的功能。我们一开始用的是GOMIINTER,他不是代码检查工具,他会给代码检查工具有统一的输入和输出。最近GOMIINTER已经不维护了。现在是Gometalli工具。
3. 降级
另外一个是关于降级,不知道大家维护的服务降级的边界点在哪里?我看很多项目包括跟很多同学做沟通,大部分日的观点是降级是做在RPC上,所有RPC的地方都会做降级。这点没有问题,因为RPC是系统交互,出现不稳定的情况是最多的,但是其实对我们来讲除了RPC降级之外,我们降级更细的密度是功能。如果这个功能挂了,不会影响整个接口,这个功能就可以挂,将应该降级掉。
如果回答作者没有了,只变成知乎的用户。这个回答题目评论了怎么办?这个回答评论过了怎么?只要回答被展示,所有评论都会被展示。我们这里进行了封装已经足够内部的使用了,加了指标点和监控的东西,降级一定要配合监控使用,监控和报警,当你的降级触发某一个点的时候,就应该有报警出来,这是对于RPC的降级对应监控,当他某一个检测比较高的时候,就会立法触发报警,后面的有一个同比和环比上周今天和昨天时刻。除此之外,也对功能点做了降级,这是对于功能点做降级的指标,功能失败的情况比RPC的情况少得多。
4. panic&recover 不推荐,但真香
另外一些问题比较有争议了,关于Panic和recover,我们大部分同学是从Python转到Golang,过程中碰到痛点问题,很多代码写if Error等于new一同处理。
举例说,我觉得代码会拿一个当前用户,如果获取当前用户失败返回一个Error Return如果准备一个回答失败返回一个ErrorReturn,最后把这个answer渲染出去。发现这里好几个Return忘写,虽然处理这个错误,但是会继续往下走,逻辑会走到后面。获取logen失败,后面也会失败,会渲染两个Error。
针对这一点,我们做了一些尝试,把外面这一层很多返回Error的地方,直接变成Panic,这样我们会在内层Cover住,如果发现是内部定义的错误,我们就让他正常返回,就是C++的错误。如果不是,就是一个500服务器错了。
我准备一个单线路用户,准备一个回答渲染这个回答,最后输出。我们所有的Panic会加一个MAST。这个有很大争议,当然社区和官方不推荐使用,不过从Python转过来的程序员来讲,这种使用帮我们减少了不少的问题,尤其是经常一些return导致多次渲染数据,这个问题得到了很大的解决。当然这个依然是不推荐使用,我们确实知道这么用不好,但是真香。
跟这个相关的另外一个问题,刚刚我们提到你可能写了数据错误忘写return,你可能多次JS,多次渲染数据的场景,目的因为逻辑写错了。比如我先输出一个Error,这是一个合法的JS,我没有return,后面又输出一个合法的JS,这两个合法的JS合在一起就不是一个合法的JS,如果客户端处理好有可能是没有数据,如果处理不好就垮掉了。针对这一点,我们WEBFROME层做了一个工作,你的程序只能跑进一处Return,当两次的时候客户端会收到OXX,服务端会错误打回去,你可以看看你的错误。这些工具帮助我们减少不少的bug。
对于很多从Python转到的Golang的开发者,在Goroutine的使用,一开始都会有一个困惑,如果你在Goroutine起一个新的Goroutine,这个Goroutine的Panic会怎么样?大家用Golang一段时间就会非常清晰知道他会Crash,因为Goroutine没有父子的概念,当你起完一个Goroutine就是独立的Goroutine,如果没有Recower,你在刚刚启他的Goroutine的Goroutine里面是没有办法Recower,这就意味着,如果做并发需要flak一定要做recower,否则就没有办法进程保证还活着。
针对这一点,我们参考了Java里面的Future,做了Golang的实现。之前起Goroutine的地方起了uture,整个Future实现比较简单,大概不到一百个代码,因为Golang至少目前为止还没有发现Future是属于Error,返回值是通过BBao的方式。
5. Golang call Python
最后一个问题是GolangcallPython。我们重构过程中一开始有比较基础的工具库叫RETXT。大家知道知乎有很多UDC的内容,尤其是关于文章和回答这一类比较复杂的文本,我们Python数据库有一个RETXT处理各种复杂文本,一开始做Golang的实现,并没有对这个库进行Golang的实现,因为工作量很大。我们选择通过Golang调C,通过PythonAPI调Python,一个比较强的调链。当我们上线之后发现,刚刚看的曲线图,省了80%的资源曲线图,上线之后没有多少性能所耗80%,很高。又跑到Prnait,发现全是通过Golang调C GO调PYTHON。真正火焰是我们逻辑,资源占用太高了。我们之前版本通过了Golang又重新实现了一版,现在只是的实现了输出的部分,输出只会影响渲染,不会影响数据的准确性。输入部分没有上线之后,可以看到后面一个比较大的下件,通过Golang调C Go,调C,调Python,这个完整的链性能损耗挺大。优化结束以后最后有50%的节约。
带来的经验就是,如果有办法,尽量重构得彻底一点,而不要保留一些可能比较小的点,带来很大性能的损耗。
以上我今天想要分享的内容,谢谢大家。
提问环节
提问:我想问做MAK测试的时候,有使用MAK的框架吗,还是自己做的?
杜旭:刚刚看到MAK实现都是通过对MAK对应生成,因为有对应接口定义,能够直接MOK的实现,你调这个MAK的时候,可以返回一个数据,可以有什么参数调的,最后做测试的时候,测试里面是可以点这部分代码是以某个参数调用的,返回值是什么,这部分代码是做生成的。
提问:您好,我想问一下你们验证的阶段,因为读的阶段一般咱们网接口都会有缓存,Golang也会有,在Python和Golang缓存问题怎么解决的呢?在验证过程中。
杜旭:刚刚看到所有的资源使用偏服务,这个地方有点不完全准确,是因为缓存是独立的,缓存没有混用。一开始Golang在重构第一步是只有写出口没有写接口,Golang写进之后我们加一个Golang缓存的失效在里面。
提问:如果只是只读两边缓存不一致,是让失效对比这个接口,但是有可能Python本来就是之前缓存的数据是吗?
杜旭:这个Golang和Python是各自独立的缓存。Golang写接口之前所有缓存失效是在PYTHON做。我们验证阶段是在放量验证之前会有一个验证过程是发射目标白盒的状态。因为我知道我知道我的参数是什么会调一次,拿两边的内容和结果做对比,看缓存是否一直,以及反馈的结果是不是一致。随机跑一些数据做验证。
提问:我还是想问一下你们刚刚说单侧的覆盖率是85%,我们现在工程也有要求,看你刚才发的代码结构图有一个controller有一个WORk,怎么看单元测试和接口测试。我个人看法是单元测试是针对函数,针对WORK,现在这张图有他们两个的区别怎么看待?这一块怎么做Mork?
杜旭:第一个问题,关于单元测试更多是一个白盒状态测试逻辑实现。单元测试是覆盖所有的条件,有一天一个新的同学进来,对你的某个实现做了改动,最好的状态就是改任何一行代码都会被测试反馈到,如果写错了立刻有测试反馈出来。但是很多人的项目实现很难达到这种状态。而接口是黑盒的状态,传某个参数进去拿结果回来。这两部分在我们项目都会有,可能您刚才和我刚才想说的不太一样,我们的Controller是一个逻辑的实现,这一部分是在WEB层,WEB层测试会起一个WebServer发一个请求拿到一个结果,对比我们拿到的结果和想要的结果,这个结果是一个黑盒的概念,不会涉及到有三把ES和两个LS会怎么样,写这个接口的时候不会关心实现,而只关心输入输出是什么。我测试的时候我更关注逻辑实现,我希望我测试代码能够覆盖到更多的逻辑的实现的部分。
提问:我想问一下,有一个Recover机制,如果普通问题recover没有问题,如果有一些写数据问题,他前面一些逻辑导致出现一些问题,你这样会不会导致一些到处数据都会写到数据库里面?
杜旭:首先这部分当我们提出来的时候有很多人说我们不会用Golang,确实我们用Golang时间不长。我们只在WEB这一层用到了,其他部分没有用。WEB层涉及很多输出的reland更复杂,这部分用PANRecover的时候,刚刚提到有Renext的函数,如果失败了我直接用Panic,后面逻辑不会走进去,这个反而解决刚刚提到的问题。我们在这部分用的时候可以看到其实刚刚我忘了一个字我渲染一个错误,我往后走,我可能再写进去。但是现在当某一步出错,直接Panic掉了,有点像JAVA、Python、trycach的东西。
提问:老师您好,我们项目是Python项目,我们有一些性能想用Golang做。我们看看你的分享有一个Golang调Python性能会很低,我想问一下性能很低的原因?还有另外一项Python和Golang之间相互调用有什么推荐的方式,谢谢。
杜旭:第一个问题是性能很低是一方面,我们这个图里面之所以占用CPU很长,一部分是因为C GO调Python的方式性能比较低。另外在TEXT库本身有很多重计算逻辑在里面,他也是对于H5的封装,这块性能比较有限。所以虽然占了80%,我们优化之后只调了一半的原因,而不是这个跟图上比较一致的状态,也有一部分原因是本身这个库的实现的问题,当然C Go也是其中一部分。
第二个问题我没有办法回答你的有什么比较高效的方式通过C GO调PYTHON问题。
提问:贵司和我司很像,用别的语言转了一部分GO这么一个过程,我刚看到您分享的用GO调Python的时候,之前您也提到了你们支持微服务,我们在改造的过程中,比如说各个语言之间的调用可能用一些GRPC,你们Python服务为什么不单独做一个服务,Golang也是服务,之间相互做调用,而是在一个服务里面做一层C的中转再调用呢?
杜旭:其实这个问题TEXT本身是工具库,一开始出现不是一个服务,所谓工具库就是刚刚提到了是用来做文本处理的,一些标签处理,比如说内部有问答、文章、评论,所有文本的地方都会经过在TEXT做一道处理,有点像SDK的方式传出去,如果说以RPC形式存在,可能优化服务,这里面涉及到一个问题是每一个请求在输出的时候都会获得TEXT,所以做成一个服务可能效率并不会高到哪里去。虽然用了很多Python特有的性能,不过在Golang大部分的库都是对应实现。比如说做代码决策的,我们找到很多Golang对应实现做了很多事情,Golang是很基础的库,把它坚决重构之后带来资源节约非常非常明显的。
提问:我看你们整个的项目分层非常明确,对于整个分层的一个依赖的管理你们怎么做的?
杜旭:依赖管理是用代码,现在尝试用vendor,很多东西放在vedio里面。这是所有程序员发现比较有趣的事情,可能第一次提交代码是20万行,如果代码按行算钱写Golang比写JAVA好。
提问:我想问你们依赖之间调用关系是怎么做的?你们不是有很多微服务,也分层了,服务和服务之间其实是有依赖的,你这些东西是怎么定义的?
杜旭:服务与服务之间更多是网状的结构,并不是树状的结构,A依赖B,反过来B也依赖A。比如说评论,专业回答的评论是不是调问答服务,问答要拿一个评论的时候要评论服务,更多是一个网状的关系在里面,而不是树形的关系,并不是说我依赖你,你就不能依赖我。
提问:老师您好,我们公司是小米,我是负责做小米社区的,所以我们经常遇到文本解析的问题,我想问一下,知乎现在文章的输出是H5还是结构化?
杜旭:现在是H5文本。不过这部分我们做一些尝试,计划变成JS格式。因为前端拿到这个H5格式数据之后本身做一次解析变成JS格式,之后才能渲染,中间这一层有点多余,我们做一些尝试,能不能直接输出JS的数据。
提问:这个感觉在数据库存的时候就先解决好然后再给到前端,因为前端要再做一次解析,肯定浪费时间?
杜旭:其实这个问题也困扰我们挺久,我们也在想解决方案,知乎在文本处理上有点不太一样,可能每个人内容,不同角色内容不一样,当你一份内容经过审核之后,你看到一个新版本的内容,其他的用户看到是一个版本的内容,直到审核通过才能是新内容。所以数据库里面也存多个版本,目前可以对外公布的版本以及更新过审核的版本。
Gopher Meetup 上海站即将开启。来自蚂蚁金服&携程、趣头条、讯联数据、TutorABC的大咖讲师将带来 Go 开发领域的一线实践经验分享,尽在12月21日,微软加速器!返回搜狐,查看更多