原创 风弈、空蒙、玄力 淘系技术 2月26日
最近也收到很多后端同学的提问,为什么Go的web框架速度还不如Java?为什么许多原本的 Java 项目都试图用 go 进行重写开源?Java会不会因为容器的兴起而没落?Java这个20多年的后端常青树难道真的要走下坡路了?橙子邀请了淘系技术部的同学对以上问题进行解答,也欢迎大家一起交流。
Q:为什么Go的web框架速度还不如Java?
风弈:华山论剑,让我们索性把各框架的性能分析跑一下再说话。
各种框架的应用场景不同导致其优化侧重点不同,下面我们展开详细分析。
▐ http server 概述
首先描述一下一个简单的 web server 的请求处理过程:
Net 层读取数据包后经过 HTTP Decoder 解析协议,再由 Route 找到对应的 Handler 回调,处理业务逻辑后设置相应 Response 的状态码等,然后由 HTTP Encoder 编码相应的 Response,最后由 Net 写出数据。
而 Net 之下的一层由内核控制,虽然也有很多优化策略,但这里主要比较 web 框架本身,那么暂时不考虑 Net 之下的优化。
看了下 techempower 提供的压测框架源码,各类框架基本上都是基于 epoll 的处理,那么各类框架的性能差距主要体现在上述这些模块的性能了。
▐ 关于各类压测的简述
我们再看 techempower 的各项性能排名,有JSON serialization, Single query, Multiple queries, Cached queries, Fortunes, Data updates 和 Plaintext 这几大类的排名。
其中 JSON serialization 是对固定的 Json 结构编码并返回 (message: hello word), Single query 是单次 DB 查询,Multiple queries 是多次 DB 查询,Cached queries 是从内存数据库中获取多个对象值并以json返回,Fortunes 是页面渲染后返回,Data updates 是对 DB 的写入,Plaintext 是最简单的返回固定字符串。
这里的 json 编码,DB 操作,页面渲染和固定字符串返回就是相应的业务逻辑,当业务逻辑越重(耗时越大)时,则相应的业务逻辑逐渐就成为了瓶颈,例如 DB 操作其实主要是在测试相应 DB 库和 DB 本身处理逻辑的性能,而框架本身的基础功能消耗随着业务逻辑的繁重将越来越忽略不计(Round 19 中物理机下 Plaintext 下的 QPS 在七百万级,而 Data updates 在万级别,相差百倍以上),所以这边主要分析 Json serialization 和 Plaintext两种相对能比较体现出框架本身 http 性能的排名。
在 Round 19 Json serialization 中 Java 性能最高的框架是 firenio-http-lite (QPS: 1,587,639),而 Go 最高的是 fasthttp-easyjson-prefork(QPS: 1,336,333),按照这里面的数据是Java性能高。
从 fasthttp-easyjson-prefork 的 pprof 看除了 read 和 write 外, json (相当于 Business logic) 占了 4.5%,fasthttp 自身(HTTP Decoder, HTTP Encoder, Router)占了 15%,仅看 Json serialization 似乎会有一种 Java 比 Go 性能高的感觉。
那我们继续把业务逻辑简化,看一下 Plaintext 的排名,Plaintext 模式其实是在使用 HTTP pipeline 模式下压测的,在 Round 19 中 Java 和 Go 已经几乎一样的 QPS 了,在 Round 19 之后的一次测试中 gnet 已经排在所有语言的第二,但是前几个框架QPS其实差别很微小。
这时候其实主要瓶颈都在 net 层,而 go 官方的 net 库包含了处理 goroutine 相关的逻辑,像 gonet 之类的直接操作 epoll 的会少一些这方面的消耗,Java 的 nio 也是直接操作的 epoll 。
拿了 gnet 的测试源码跑了下压测,看到 pprof 如下,其实这里 gnet 还有更进一步的性能优化空间:time.Time.AppendFormat 占用 30% CPU。
可以使用如下提前 Format ,允许减少获取当前时间精度的情况下大幅减少这部分的消耗。
var timetick atomic.Value func NowTimeFormat() []byte { return timetick.Load().([]byte) } func tickloop() { timetick.Store(nowFormat()) for range time.Tick(time.Second) { timetick.Store(nowFormat()) } } func nowFormat() []byte { return []byte(time.Now().Format("Mon, 02 Jan 2006 15:04:05 GMT")) } func init() { timetick.Store(nowFormat()) go tickloop() }
这样优化后接下来的瓶颈在于 runtime 的内存分配,是由于这个压测代码中还存在下面的部分没有复用内存:
其实 gnet 本身的消耗已经做到非常小了,而 c++ 的 ulib 也是类似这样使用的非常简单的 HTTP 编解码操作来压测。
▐ 分析
对于这里面测试的框架,影响因素主要如下:
1、直接基于epoll的简单http: 没有完整的 http decoder 和 route (如gnet, ulib 直接简单的字节拼接,固定的路由 handler回调)
2、zero copy 和内存复用: 内部处理字节的 0 拷贝(go 官方 http 库为了减少开发者的出错概率,没有使用 zero copy,否则开发者可能在无意中引用了已经放回 buff 池内的的数据造成没有意识到的并发问题等等),而内存复用,大部分框架或多或少都已经做了。
3、prefork:注意到 go 框架中有使用了 prefork 进程的方式(比如 fasthttp-prefork),这是 fork 出多个子进程,共享同一个 listen fd,且每个进程使用单核但并发(1 个 P)处理的逻辑可以避免 go runtime 内部的锁竞争和 goroutine 调度的消耗(但是 go runtime 中为了并发和 goroutine 调度而存在的相关“无用”代码的消耗还是会有一些)
4、语言本身的性能差异
对于第一点,其实简化了各种编解码和路由之后,虽然提高了性能,但是往往会降低框架的易用性,对于一般的业务而言,不会出现如此高的QPS,同时选择框架的时候往往还需要考虑易用性和可扩展性等,同时还需要考虑到公司内部原有中间件或者 SDK 所使用的框架集成复杂度。
对于第二点,如果是作为一个网络代理而言,没有业务方的开发,往往可以使用真正的完全 zero copy,但是作为业务开发框架提供出去的话是需要考虑一定的业务出错概率,往往牺牲一部分性能是划算的。
第三点 prefork , java netty 等是直接对于线程操作,可以更加定制化的优化性能,而 go 的 goroutine 需要的是一个通用协程,目的是降低编写并发程序的难度,在这个层次上难免性能比不上一个优化的非常出色的 Java 基于线程操作的框架;但是直接操作线程的话需要合理控制好线程数,这是个比较头疼的调优问题(特别是对于新手来说),而 goroutine 则可以不关心池子的大小,使得代码更加优雅和简洁,这对于工程质量保障其实是一个提升。另外这里存在 prefork 是由于 go 没法直接操作线程,而 fasthttp 提供了 prefork 的能力,使用多进程方式来对标 Java 的多线程来进一步提高性能。
第四点,语言本身来说 Java 还是更加的成熟,包括 JVM 的 Jit 能力也使得在热代码中和 Go 编译型语言的差异不大,何况 Go 本身的编译器还不是特别成熟,比如逃逸分析等方面的问题, Go 本身的内存模型和 GC 的成熟度也比不上 Java。还有很重要的一点,Go 的框架成熟度和 Java 也不在一个级别,但相信这些都会随着时间逐步成熟。
总之,对于这个框架压测数据意义在于了解性能天花板,判断继续优化的空间和ROI (投入产出比)。具体选择框架还是要根据使用场景,性能,易用性,可扩展性,稳定性以及公司内部的生态等作出选择,语言和性能分别只是其中一个因素。
各种框架的应用场景不同导致其优化侧重点不同,如 spring web 为了易用性,可扩展性和稳定性而牺牲了性能,但它同样拥有庞大的社区和用户。再比如 Service Mesh Sidecar 场景下 Go 的天然并发编程上的优势,以及小内存占用,快速启动,编译型语言等特点使得比 Java 更加适合。
(附:其实我使用上述代码和 dockerfile 构建,并且使用同样的压测脚本,在阿里云4核独享机器测试下 go fasthttp-easyjson-prefork 框架 Json serialization 的性能要高于 Java wizzardo-http 和 firenio-http-lite 30% 以上且延迟更低的,这可能和内核有关)。
Q:为什么许多原本的 Java 项目都试图用 go 进行重写开源?
空蒙:Java还是go核心是生态问题。
生态发展会经历起步、发展、繁荣、停滞、消亡几个阶段,Java目前至少还在繁荣阶段,go还是发展阶段,不同阶段在开发人员的数量与质量、开源能力丰富性、工程配套上是有巨大差异的,go是在狂补这三块。另外不同公司还有个公司内部小生态的所处阶段问题,也会影响技术的选型判断。
现阶段go的火热,很大因素是云原生裹挟着大家往前,k8s operator go语言实现的自带光环,各种中间件能力在下沉与k8s融合,带动着一波基础中间件能力的go实现潮头,但基础的中间件能力相对是有限集合,如RPC、config、messagequeue等,这些中间件能力,以及云原生k8s对上层业务而言应该做的是开发语言的中立性,让业务基于公司的小生态和整个语言技术的大生态去抉择,如果硬逼着业务也用go语言开发那就是耍流氓了。
总结来说,基础中间件能力需要与k8s的融合需要会有go语言的动力,但整个开源生态其他能力并不见得是必须;业务开发依据公司生态和技术大生态选择最合适的开发语言,不要盲目的追从而导致在人、开源能力、工程配套上的尴尬。go语言能否在业务研发上发力,还有待其生态的进一步发展。
Q:Java会不会因为容器的兴起而没落?
玄力:近年来以容器为核心的云原生技术,让服务端部署的伸缩性、可协作性,得到巨大的提升。使得原本开发语言本身选取的重要性,有一定程度的减弱。但不妨碍Java语言本身继续保持活力。
毕竟,作为研发而言,研发输出效率也是蛮关键的一个考量点,得益于Java完善而有庞大的开发者生态,提供了比大多数语言都要丰富的类库/框架,也得益于Java强大的IDE工具,开发起来往往事半功倍。
而且,Java自身也有一些变种语言(如Scala),也是在朝更灵活更好用的方向发展;
另一方面,在大数据领域,Java仍在大放异彩,我们所熟知的 ES、Kafka、Spark、Hadoop。
我们评估和预测一个技术的生命力的时候,往往不会孤立地只看技术本身,同时也会结合它背后的整个生态。一个具有顽强生命力的技术的背后往往都有一个成熟的生态体系支撑,上面也提到Java在多个领域都有完善而庞大的生态,因此,我们认为Java的生命力仍然是顽强的。
但由于众所周知的原因,客观来讲,Java本身在使用上,也会有一定的限制性。并且,在容器场景中,Java进程的内存配置,是需要小心谨慎的。
总的来说,Java的地位仍难撼动,而且在云原生场景中,也仍绽放着生命力。