不可否认,当你把一个单体程序拆成微服务后,单次请求的延迟也必定会增加。
然后再做 Service Mesh 改造后,每个服务外层还要再加一层 Proxy,那延迟又要增加了。
如果是一个复杂点的接口,内部产生 10 次远程调用是很常见的。Service Mesh 的核心 Sidecar 可以认为是一层反向代理。在这种情况下你数数一个请求经过了多少层反向代理?就算他们都是 C 写的,把极致性能放到首位,但单次反向代理的损耗在 1ms 左右还是很常见的。
也就是说单体程序做 Service Mesh 改造后,简单的 API 延迟增加 5ms 左右,复杂的 API 延迟增加 20ms 左右都是很正常的。
不仅如此,因为远程调用的网络因素,P99 也会比单体程序高很多,这也没办法,经过了多次网络调用后稳定性肯定是不如程序内部函数调用的。
面试 Java 的时候常常会问线程池的问题,其中一个关键的问题就是线程池的大小如何设置。
这种时候,标准答案就是要根据任务的类型来判断线程池大小。计算密集型的任务如果把线程池大小设置成远大于 CPU 核心数后,最终的吞吐量反而会下降。
大家都知道这是因为线程上下文切换的开销导致了这个结果。
而当任务是 IO 密集型的任务时,为了不浪费 IO 等待的时间,线程池大小往往都会大于 CPU 核心数。虽然此时也有线程上下文切换的开销,但是这个开销和浪费 IO 等待的时间比起来还是值得的。
具体上下文切换会有多少损耗呢?也有不少人做过测试了: 进程/线程上下文切换会用掉你多少CPU?
在微服务中,Java 就面临了这样的困境,大量的 IO 操作,线程池开小了不行,开大了损耗也大。
有一段时间,一直有人鼓吹 Node.js 性能多么多么牛逼。在一些场景下这是可能发生的,就像上面说到的微服务的场景。如果一个服务内部只是简单的调用了大量其他微服务,内部并没有什么复杂的计算逻辑,那在这种场景下 Node.js 的性能会比 Java 更高。
这个原因不难理解,Node.js 是单线程的,所有远程调用都是基于 IO 多路复用来实现的。就算并发量非常大,Node.js 的上下文切换损耗也非常小。
在代码编写这块,以前这种模式是有很大问题的,各种回调让人写得很崩溃。但后来有了 Promise, Async, Await 后就问题不大了。
另外一个问题就是单进程单线程无法充分利用多核,所以一般 Node.js Server 都是多进程跑的。而多进程对进程间通讯用凭空多了很多麻烦。
传统的 Java Web Server 都是一个请求一个线程,如果你要有同时处理 100 个请求的能力,那你必须要有至少 100 个线程。
这个数字远大于 CPU 核心数,除此以外,Java 默认线程栈大小是 1M,100 个线程就是 100M,这数字也是很大的。
理论上,Java 也完全可以实现类似 Node.js 这套并发模型,JVM 底层有 NIO 的支持,上层有 Netty 等框架的协助,要写出和 Node.js 一样的代码是非常轻松的。大部分 Java 高性能 RPC 框架都是这么做的。
除此以外,已经有很多更高层的框架,直接利用 NIO 或 Netty 实现了 Web Server。例如 Vert.x 和 Spring WebFlux。再配合 RxJava,那代码写起来也是非常爽的。
Java 最大的优势是生态非常好,然而这种模型因为 Service Mesh 而流行的时间不长,所以还是有很多东西不支持。这里不支持的最大原因就是新模型下的单个请求的不同操作不再是在同一个线程中运行的了。
所以以前很多工具,例如做 Tracing,以前只要把相关信息放 ThreadLocal
中就行了,但现在就不行了,需要手动传递。而现在各种框架百花齐放,也没有统一标准,所以这块是比较头疼的。
Golang 在并发模型这块就更进一步了,它自己实现了一套调度器模型,不仅可以充分利用多核特性,还可以尽量减少线程上下文切换,而且还对用户透明,用户写代码的时候就像平时写同步阻塞的代码一样。
因为有了协程,Golang 单进程中的线程也会少很多,一般都是个位数。此时线程栈的占用空间也是非常小的。
所以它既解决了 Node.js 的痛点,也解决了 Java 的痛点。
划重点,Golang GMP 模型是 Golang 面试必考的: 传送门
背景都介绍完后,我们再回到第一个问题,在 Service Mesh 架构下怎么优化。
我们通常说的优化其实有两个维度,一个是响应时间,一个是吞吐能力。
响应时间直接关乎到用户的体验;吞吐能力关乎到服务器的成本和用户体验,把吞吐能力提高不仅可以降低服务器成本也可以降低响应时间。
响应时间的优化只是相对啊,再快也不能和非微服务化架构的单体程序比。这块的优化需要改代码,常见的场景就是并发执行多个远程调用,如果没有依赖的话,不要等上一个完成了再调用下一个。
对于 Node.js 来说,它不会阻塞,所以天然就是并发执行的。
对于 Java 来说,直接写代码都是阻塞的,但通过多线程或者 RxJava 等封装好的框架,也可以很方便地实现。可是实现是实现了,它的背后却是多线程和上下文切换的成本。
对于 Golang 来说,也需要稍微改点代码,通过协程也可以像 Java 一样很方便地做到。然而它和 Java 有一个质的区别,它的背后不会有多线程和上下文切换(这里不是绝对的,只是大部分场景不会有,特殊场景下 Golang 调度器多线程之间有任务窃取机制,可能会跨线程)。
吞吐能力的提升就不需要额外的开发工作了,在 Golang 中这个优势天然存在,而且开发不会有额外的心智负担,还是像以前一样写代码。
Java 的各种特性是基于以前的架构设计的,所以有点水土不服。这并不是它的问题。
非常可喜的是,Java 社区正在往 Service Mesh 方向转型,来更好地适应新的软件架构。
但目前为止,这些缺点还是会产生很多问题,而 Golang 会更有优势:
Java 有虚拟机和运行时编译,这导致一个最简单的 Java 应用跑起来就会占用大量内存。再加上 Spring 全家桶的话,一个最简单 Spring Boot 应用至少要分配 256M 以上的内存。
而 Golang 没有虚拟机,直接编译成了二进制可运行程序,一个空应用启动后占用的内存只有 10M,就算是一个完整的应用,一般分配 128M 就绰绰有余了。
这里同样规模的应用 Java 占用的内存至少要大 5-10 倍。
Service Mesh 场景下,依靠 Kubernetes 自动扩容所容后,服务的自动扩容所容非常多。
如之前的文章所说,Java 在这块是有严重问题的。
本质原因还是虚拟机和运行时编译导致的,刚启动的一段时间虚拟机在执行编译操作,导致前期运行效率不高。
而 Golang 就没这个问题,启动后只要健康检查过了,运行效率就已经开足马力了。
这… 还是虚拟机的锅,从 Dockerhub 上可以看到,一个 openjdk:jre 的最小镜像也要 100M 左右,这还不算打包后的应用大小。
而 Golang 就不同了,直接编译成了二进制可运行程序,理论上不需要依赖任何东西。如果你的应用没有用到 CGO,不会调用任何 C 的库,你可以直接从 scratch
这个空镜像构建出一个镜像来。
里面只包含你自己的程序,没有任何别的依赖。
没错,这本来是 Java 的优势项目,但在 Service Mesh 的场景下,对各种框架的依赖会越来越少。反而对 Kubernetes 中的一些组件有一定的依赖。
而这些组件几乎都是 Golang 实现的,Golang 的 SDK 更容易找到,也更健壮。
随着 Service Mesh 的流行,还有 JVM AOT(提前编译)技术,响应式编程等技术的发展,这些问题的解决其实只是时间问题。但还是需要一段时间的,因为以前的东西越好用,面对新的环境它推进得也越慢。
本作品由Dozer 创作,采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。